Every running Claude Code session on this Windows machine at a glance: who needs you, branch, uncommitted work, context size and whether its cache has gone cold

A Claude Code mod that shows every Claude session running on this Windows machine: which ones need you, what each is working on, the state of its code, and how much context it holds.
It is a Windows port of Dubbus/claude-fleet, whose original needs ps, kill, head and tail and so doesn't run on Windows. This version only reads: it shows sessions and never closes them.
10 sessions · 2 busy · 1 need you · 6 safe to close · 841k tokens to re-cache
● Payments webhook retry bug waiting api fix/webhooks ✎1 2m 141k
▸ ● Auth middleware refactor busy api feat/auth ✎4 ↑2 now 182k
● Onboarding copy rewrite shell web feat/onboarding ✎6 ↑1 now 58k
● 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
Payments webhook retry bug is waiting on: permission prompt
That's the demo fleet (CLAUDE_FLEET_DEMO=1), cut short.
⧉ 6 sessions · 2 busy · 1 need you. It's empty while only one session runs./fleet opens a live pane, refreshed every 5 seconds. For each session it shows:api-5dcold once the prompt cache has expired: resuming it then pays to cache that whole context againIn the pane, ↑/↓ selects a session, c copies a PowerShell command that resumes it, and r refreshes.
/fleet list, /fleet stale (idle over a week), /fleet retitle and /fleet help print the same information as text, also in claude -p.Needs Claude Code 2.1.302 or later on Windows, with function hooks on (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
At the prompt of a terminal session:
/plugin install fleet --marketplace daldaman/claude-fleet-windows
From a shell:
claude plugin install fleet --marketplace daldaman/claude-fleet-windows
In the desktop app, click + next to the prompt box, then Plugins. Choose Add marketplace with daldaman/claude-fleet-windows, then Add plugin.
Set these as environment variables, for example in the env block of ~/.claude/settings.json.
| Variable | Effect |
|---|---|
CLAUDE_FLEET_TITLES=0 | No Haiku titles; sessions show Claude Code's own names. |
CLAUDE_FLEET_NOTIFY=0 | No toasts. |
CLAUDE_FLEET_DEMO=1 | Made-up sessions, for trying the pane or taking screenshots. |
CLAUDE_FLEET_REGISTRY | Read the session list from another folder. |
/fleet command runs. A title is made again only when its conversation has doubled in size. Titles are kept between sessions.~/.claude/sessions, along with the start time of the session's process. The PowerShell helper asks Windows for those processes. A process counts only if its start time matches the file's, so a crashed session whose pid Windows has handed to another program never shows up.tail, and the mod's own file reads stop at 4 MiB, while a long session's transcript runs past 14 MB.shell while a session runs a command. That counts as busy, not as needing you.pwsh first, then Windows PowerShell 5.1, which every Windows has. The script runs with -Command, so execution policy doesn't apply. It's passed as one line, because a multi-line -Command runs nothing when Claude Code starts PowerShell.Left out of the original for now: closing sessions, saving conversations, handoffs, the handoff offer above the prompt, and the pause on /exit.
~/.claude/sessions is internal to Claude Code, not an API, so a release can move or reshape it. When it does, fleet says what's wrong instead of showing an empty list. This version was tested on Claude Code 2.1.302.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT, with the original's copyright kept. See LICENSE.
hooks/register.tsx 421 lines1// Everything here touches `$`, which the engine only follows within this one file.
2// Pure logic lives in ./lib: rules, formatting, parsing, the PowerShell helper, prompts, demo data and the view.
3// A Windows port of Dubbus/claude-fleet (MIT), read-only for now: it shows sessions and never closes them.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Git, Registered, Row, Snapshot, TitleEntry } from '../types'
8
9import {
10 ALIVE_TTL_MS, MISSING_RETRY_MS, PROJECTS_DIR, REGISTRY_DIR, STATUS_EVERY_TICKS, TICK_MS, TITLES_KEY, TITLES_PER_SCAN,
11} from './lib/constants.ts'
12import { demoRows } from './lib/demo.ts'
13import { ago, asTable, HELP, label, needText, resumeCommand, staleReport, statusLineText } from './lib/format.ts'
14import { helperArgv, isSessionProcess, parseHelperReply, POWERSHELLS } from './lib/helper.ts'
15import type { HelperFile, HelperReply, HelperRequest } from './lib/helper.ts'
16import {
17 lastAssistantText, lastContextTokens, parseGitStatus, parseRegistryEntry, parseWorktrees, samePath, transcriptPath,
18 userPrompts,
19} from './lib/parse.ts'
20import { LABEL_SYSTEM, labelPrompt, parseLabel } from './lib/prompts.ts'
21import { isBusy, isDemo, needsYou, sortRows } from './lib/rules.ts'
22import { paneView } from './lib/views.tsx'
23
24// Kept here as a literal: hook matchers are read from this file, so an imported id would be opaque.
25const PANE = 'fleet'
26
27const snapshot = atom({ plugin: 'fleet', key: 'snapshot' } as const, null)
28const isOpen = atom({ plugin: 'fleet', key: 'isOpen' } as const, false)
29const selected = atom({ plugin: 'fleet', key: 'selected' } as const, null)
30
31// Module state: lost on a reload, which only means asking or caching again.
32// path -> { size, tokens }: a transcript is re-read only when it has grown.
33const tokenCache = new Map<string, { size: number; tokens: number | null }>()
34const transcriptCache = new Map<string, { path: string | null; at: number }>()
35// In flight across scans, so a slow title call isn't started twice.
36const titling = new Set<string>()
37// Which registry entries were still running at the last check, by `pid:procStart`.
38let alive: { at: number; keys: Set<string>; checked: Set<string> } = { at: 0, keys: new Set(), checked: new Set() }
39// The PowerShell that started last time, so a missing pwsh is tried only once.
40let powershell: string | null = null
41
42const entryKey = (s: Registered) => `${s.pid}:${s.procStart ?? ''}`
43
44// --- Where things are ---
45
46// Windows sets USERPROFILE; HOME is there only when something like Git Bash set it.
47async function configDir($: EngineInterface) {
48 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
49 if (custom) return custom.replace(/[\\/]+$/, '')
50 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
51 return home ? `${home.replace(/[\\/]+$/, '')}/.claude` : null
52}
53
54// --- The PowerShell helper ---
55
56async function runHelper($: EngineInterface, request: HelperRequest): Promise<HelperReply> {
57 const stdin = JSON.stringify(request)
58 let lastError = 'no PowerShell found'
59 for (const exe of powershell ? [powershell] : POWERSHELLS) {
60 let r
61 try {
62 r = await $.process.run(helperArgv(exe), { stdin, timeoutMs: 20_000 })
63 } catch (err) {
64 // Not installed (or timed out): try the next one.
65 lastError = `${exe}: ${(err as Error).message}`
66 continue
67 }
68 powershell = exe
69 const why = r.stderr.trim().split('\n')[0]
70 if (r.exitCode !== 0) throw new Error(`${exe} exited ${r.exitCode}: ${why || 'no message'}`)
71 if (!r.stdout.trim()) throw new Error(`${exe} printed nothing${why ? `: ${why}` : ''}`)
72 return parseHelperReply(r.stdout)
73 }
74 powershell = null
75 throw new Error(lastError)
76}
77
78// --- Reading sessions ---
79
80// The registry is internal to Claude Code, so a release can move or reshape it.
81// Every way it can fail says why, rather than showing an empty fleet.
82async function readRegistry($: EngineInterface): Promise<{ sessions: Registered[]; problem: string | null }> {
83 // CLAUDE_FLEET_REGISTRY points fleet at another registry folder (a moved one, or a test fixture).
84 const override = await $.env.get('CLAUDE_FLEET_REGISTRY')
85 const config = await configDir($)
86 if (!override && !config) return { sessions: [], problem: "Neither USERPROFILE nor HOME is set, so fleet can't find ~/.claude/sessions." }
87 const dir = override || `${config}/${REGISTRY_DIR}`
88 const shown = override || `~/.claude/${REGISTRY_DIR}`
89 if (!(await $.fs.exists(dir))) {
90 return { sessions: [], problem: `No ${shown} folder: this Claude Code version may keep its session list elsewhere.` }
91 }
92
93 const found: Registered[] = []
94 let files = 0
95 for (const entry of await $.fs.list(dir)) {
96 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
97 files += 1
98 try {
99 const parsed = parseRegistryEntry(String(await $.fs.read(`${dir}/${entry.name}`)), entry.mtimeMs)
100 if (parsed) found.push(parsed)
101 } catch {
102 // A file mid-write or from another version: skip it this tick.
103 }
104 }
105 if (files === 0) return { sessions: [], problem: `${shown} is empty, though this session is running: the registry may have moved.` }
106 if (found.length === 0) {
107 return { sessions: [], problem: `Couldn't read any of the ${files} files in ${shown}: their format may have changed.` }
108 }
109 return { sessions: found, problem: null }
110}
111
112// Registry files outlive crashed processes; keep only the sessions whose process is still the one that wrote them.
113function keepAlive(sessions: Registered[], reply: HelperReply) {
114 const byPid = new Map(reply.procs.map(p => [p.pid, p]))
115 return new Set(sessions.filter(s => {
116 const proc = byPid.get(s.pid)
117 return proc !== undefined && isSessionProcess(s.procStart, proc)
118 }).map(entryKey))
119}
120
121async function gitStatus($: EngineInterface, cwd: string): Promise<Git | null> {
122 const r = await $.process.run(['git', '-C', cwd, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 }).catch(() => null)
123 return r && r.exitCode === 0 ? parseGitStatus(r.stdout) : null
124}
125
126// Worktrees of this session's repo that no session is sitting in.
127async function idleWorktrees($: EngineInterface, taken: Set<string>): Promise<string[]> {
128 const r = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: await $.session.cwd(), timeoutMs: 5000 }).catch(() => null)
129 return r && r.exitCode === 0 ? parseWorktrees(r.stdout).filter(path => !taken.has(samePath(path))) : []
130}
131
132// Transcripts are filed under the folder a session started in, which can differ from where it is
133// now, so fall back to looking for the session's file in every project folder.
134async function findTranscript($: EngineInterface, config: string, row: Pick<Row, 'cwd' | 'sessionId'>) {
135 if (!row.sessionId) return null
136 const direct = transcriptPath(config, row)
137 if (await $.fs.exists(direct)) return direct
138
139 const now = await $.clock.now()
140 const hit = transcriptCache.get(row.sessionId)
141 if (hit && (hit.path ? await $.fs.exists(hit.path) : now - hit.at < MISSING_RETRY_MS)) return hit.path
142
143 let found: string | null = null
144 for (const entry of await $.fs.list(`${config}/${PROJECTS_DIR}`)) {
145 if (entry.kind !== 'dir') continue
146 const path = `${config}/${PROJECTS_DIR}/${entry.name}/${row.sessionId}.jsonl`
147 if (await $.fs.exists(path)) {
148 found = path
149 break
150 }
151 }
152 transcriptCache.set(row.sessionId, { path: found, at: now })
153 return found
154}
155
156async function scan($: EngineInterface, isFull: boolean): Promise<Snapshot> {
157 if (await $.env.get('CLAUDE_FLEET_DEMO')) {
158 const now = await $.clock.now()
159 const demo: Snapshot = { rows: demoRows(now), scannedAt: now, warning: null }
160 await update($, snapshot, () => demo)
161 $.ui.status(statusLineText(demo))
162 return demo
163 }
164 const [registry, selfId, now, config, titles, previous] = await Promise.all([
165 readRegistry($), $.session.id(), $.clock.now(), configDir($), readTitles($), read($, snapshot),
166 ])
167
168 let warning = registry.problem
169 const sessions = registry.sessions
170
171 // Transcripts that grew since their context size was last read.
172 const paths = new Map<string, string | null>()
173 const files: HelperFile[] = []
174 if (isFull && config) {
175 for (const s of sessions) {
176 const path = await findTranscript($, config, s)
177 paths.set(s.sessionId, path)
178 if (!path) continue
179 const { size } = await $.fs.stat(path).catch(() => ({ size: -1 }))
180 if (size >= 0 && tokenCache.get(path)?.size !== size) files.push({ path, mode: 'tokens' })
181 }
182 }
183
184 // One helper run answers both; outside the pane, liveness is re-checked only once a minute or for new entries.
185 const isAliveStale = isFull || now - alive.at > ALIVE_TTL_MS || sessions.some(s => !alive.checked.has(entryKey(s)))
186 if (sessions.length && (isAliveStale || files.length)) {
187 try {
188 const reply = await runHelper($, { pids: isAliveStale ? sessions.map(s => s.pid) : [], files })
189 if (isAliveStale) alive = { at: now, keys: keepAlive(sessions, reply), checked: new Set(sessions.map(entryKey)) }
190 for (const f of reply.files) if (!f.error) tokenCache.set(f.path, { size: f.size, tokens: lastContextTokens(f.lines.join('\n')) })
191 } catch (err) {
192 warning = `Couldn't check which sessions are running (${(err as Error).message}).`
193 }
194 }
195 const running = warning ? [] : sessions.filter(s => alive.keys.has(entryKey(s)))
196
197 const rows: Row[] = running.map(s => ({
198 key: `pid:${s.pid}`,
199 name: s.name,
200 isNamed: s.isNamed,
201 title: titles[s.sessionId]?.title ?? null,
202 value: titles[s.sessionId]?.value ?? null,
203 status: s.status,
204 waitingFor: s.waitingFor,
205 cwd: s.cwd,
206 pid: s.pid,
207 sessionId: s.sessionId,
208 updatedAt: s.updatedAt,
209 isSelf: s.sessionId === selfId,
210 git: null,
211 tokens: null,
212 }))
213
214 if (isFull) {
215 const taken = new Set(rows.map(r => samePath(r.cwd)))
216 for (const path of await idleWorktrees($, taken)) {
217 rows.push({
218 key: `wt:${path}`, name: '(no session)', isNamed: true, title: null, value: null, status: 'worktree', waitingFor: null,
219 cwd: path, pid: null, sessionId: '', updatedAt: 0, isSelf: false, git: null, tokens: null,
220 })
221 }
222 const byCwd = new Map<string, Promise<Git | null>>()
223 for (const row of rows) if (!byCwd.has(samePath(row.cwd))) byCwd.set(samePath(row.cwd), gitStatus($, row.cwd))
224 for (const row of rows) {
225 row.git = await byCwd.get(samePath(row.cwd))!
226 const path = paths.get(row.sessionId)
227 row.tokens = path ? tokenCache.get(path)?.tokens ?? null : null
228 }
229 } else {
230 // Keep the last readings so the pane doesn't flicker between full scans.
231 const prev = new Map((previous?.rows ?? []).map(r => [r.key, r]))
232 for (const row of rows) {
233 row.git = prev.get(row.key)?.git ?? null
234 row.tokens = prev.get(row.key)?.tokens ?? null
235 }
236 }
237
238 if (!warning && rows.length && !rows.some(r => r.isSelf)) {
239 warning = "This session isn't in ~/.claude/sessions, so the list may be incomplete."
240 }
241 const next: Snapshot = { rows: sortRows(rows), scannedAt: now, warning }
242 await update($, snapshot, () => next)
243 $.ui.status(statusLineText(next))
244 if (previous && !previous.warning && !next.warning) await notifyChanges($, previous, next)
245 if (isFull && config) void refreshTitles($, config, rows, titles).catch(() => undefined)
246 return next
247}
248
249// A toast when another session starts waiting on you, or finishes what it was doing.
250// CLAUDE_FLEET_NOTIFY=0 turns these off.
251async function notifyChanges($: EngineInterface, before: Snapshot, after: Snapshot) {
252 if ((await $.env.get('CLAUDE_FLEET_NOTIFY')) === '0') return
253 const was = new Map(before.rows.map(r => [r.key, r.status]))
254 for (const row of after.rows) {
255 const prev = was.get(row.key)
256 if (row.isSelf || row.pid === null || prev === undefined || prev === row.status) continue
257 if (needsYou(row.status) && !needsYou(prev)) $.ui.toast(`⚠ ${label(row)} needs you (${needText(row)})`)
258 else if (isBusy(prev) && row.status === 'idle') $.ui.toast(`✓ ${label(row)} finished`)
259 }
260}
261
262// --- Titles and resume value ---
263
264async function readTitles($: EngineInterface) {
265 return ((await $.store.get(TITLES_KEY)) ?? {}) as Record<string, TitleEntry>
266}
267
268async function makeTitle($: EngineInterface, path: string, idleText: string) {
269 const reply = await runHelper($, { pids: [], files: [{ path, mode: 'ends' }] })
270 const ends = reply.files[0]
271 if (!ends || ends.error) return null
272 const first = userPrompts(ends.head).slice(0, 3)
273 const latest = userPrompts(ends.tail).slice(-2).filter(p => !first.includes(p))
274 if (first.length === 0) return null
275
276 const r = await $.model.complete({
277 model: 'haiku',
278 system: LABEL_SYSTEM,
279 prompt: labelPrompt(first, latest, lastAssistantText(ends.tail).slice(-800), idleText),
280 maxTokens: 80,
281 effort: 'low',
282 })
283 return r.isAnswered ? parseLabel(r.text) : null
284}
285
286// Titles a few untitled sessions per scan, and retitles one whose transcript has doubled.
287// CLAUDE_FLEET_TITLES=0 turns titling off (no Haiku calls); sessions then show Claude Code's own names.
288async function refreshTitles($: EngineInterface, config: string, rows: Row[], titles: Record<string, TitleEntry>, limit = TITLES_PER_SCAN) {
289 if ((await $.env.get('CLAUDE_FLEET_TITLES')) === '0') return 0
290 const now = await $.clock.now()
291 const due: { row: Row; path: string; size: number }[] = []
292 for (const row of rows) {
293 if (!row.sessionId || row.isNamed || titling.has(row.sessionId)) continue
294 const path = await findTranscript($, config, row)
295 if (!path) continue
296 const { size } = await $.fs.stat(path)
297 const known = titles[row.sessionId]
298 if (!known || !known.value || size > known.size * 2) due.push({ row, path, size })
299 if (due.length >= limit) break
300 }
301
302 for (const { row } of due) titling.add(row.sessionId)
303 const made = await Promise.all(
304 due.map(({ row, path, size }) =>
305 makeTitle($, path, ago(now - row.updatedAt).replace('now', 'under a minute'))
306 .then(made => (made ? { sessionId: row.sessionId, ...made, size } : null))
307 .catch(() => null),
308 ),
309 )
310 for (const { row } of due) titling.delete(row.sessionId)
311
312 const fresh = made.filter(m => m !== null)
313 if (fresh.length === 0) return 0
314 const all = await readTitles($)
315 for (const m of fresh) all[m.sessionId] = { title: m.title, size: m.size, value: m.value }
316 await $.store.set(TITLES_KEY, all)
317 const byId = new Map(fresh.map(m => [m.sessionId, m]))
318 await update($, snapshot, snap =>
319 snap && {
320 ...snap,
321 rows: snap.rows.map(r => {
322 const m = byId.get(r.sessionId)
323 return m ? { ...r, title: m.title, value: m.value ?? null } : r
324 }),
325 },
326 )
327 return fresh.length
328}
329
330// --- Pane actions on the selected row ---
331
332async function selectedRow($: EngineInterface) {
333 const [snap, key] = await Promise.all([read($, snapshot), read($, selected)])
334 return snap?.rows.find(r => r.key === key) ?? null
335}
336
337async function copyResume($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
338 const row = await selectedRow($)
339 if (!row) return $.ui.toast('Select a session first (↑/↓)')
340 if (isDemo(row)) return $.ui.toast('Demo mode: nothing to resume for made-up sessions')
341 const command = resumeCommand(row)
342 const copied = await $.ui.copy({ text: command, surface })
343 $.ui.toast(copied.isCopied ? `Copied: ${command}` : command)
344}
345
346async function openPane($: EngineInterface) {
347 await update($, isOpen, () => true)
348 await $.ui.open({ id: PANE, title: 'Fleet', focus: true })
349 await scan($, true)
350}
351
352// --- Hooks ---
353
354export const register: Register = on => {
355 on('session.start', async ($, e, next) => {
356 await $.command.register({
357 name: 'fleet',
358 description: 'Every running Claude session: who needs you, branch, context size and whether it has gone cold',
359 argumentHint: '[list|stale|retitle|help]',
360 })
361
362 let tick = 0
363 $.clock.every(TICK_MS, () => {
364 tick += 1
365 void (async () => {
366 const open = await read($, isOpen)
367 if (open || tick % STATUS_EVERY_TICKS === 0) await scan($, open)
368 })().catch(() => undefined)
369 })
370 void scan($, false).catch(() => undefined)
371
372 return next(e)
373 })
374
375 on('command.run', { command: 'fleet' }, async ($, e) => {
376 const [sub = ''] = e.args.trim().split(/\s+/)
377
378 if (sub === 'help') return { text: HELP }
379 if (sub === 'list') return { text: asTable(await scan($, true)) }
380 if (sub === 'stale') return { text: staleReport(await scan($, true)) }
381
382 if (sub === 'retitle') {
383 await $.store.delete(TITLES_KEY)
384 const config = await configDir($)
385 const snap = await scan($, true)
386 const made = config ? await refreshTitles($, config, snap.rows, {}, Infinity) : 0
387 return { text: `Titled ${made} sessions.\n\n${asTable(await scan($, true))}` }
388 }
389
390 if (sub) return { text: `Unknown: /fleet ${sub}\n\n${HELP}` }
391 await openPane($)
392 return { text: 'Fleet pane opened. /fleet help lists the rest.' }
393 })
394
395 on('ui.close', async ($, e, next) => {
396 if (e.id === PANE) await update($, isOpen, () => false)
397 return next(e)
398 })
399
400 on('ui.focus', async ($, e, next) => {
401 if (e.requestId === PANE && e.element?.startsWith('row:')) {
402 const key = e.element.slice('row:'.length)
403 await update($, selected, () => key)
404 }
405 return next(e)
406 })
407
408 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
409 const [snap, picked] = await Promise.all([read($, snapshot), read($, selected)])
410 return paneView(
411 $.ui.resolve(e),
412 { snap, picked, columns: e.props.bodyColumns, rows: e.viewport?.rows ?? 24 },
413 {
414 select: key => { void update($, selected, () => key) },
415 copy: press => { void copyResume($, press.surface) },
416 refresh: () => { void scan($, true) },
417 },
418 )
419 })
420}
421hooks/lib/constants.ts 30 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
4// Outside the pane, which processes are alive is checked at most this often (one PowerShell start each).
5export const ALIVE_TTL_MS = 60_000
6export const STALE_MS = 7 * 24 * 60 * 60 * 1000
7// Prompt-cache lifetime: 5 minutes on the API by default, 1 hour on some plans.
8// Past it, resuming a session pays to cache its whole context again.
9export const CACHE_TTL_MS = 60 * 60 * 1000
10// The helper looks for the last reply's usage in this much of a transcript's end, then 4x more, up to the max.
11export const TOKENS_WINDOW_BYTES = 512 * 1024
12export const TOKENS_MAX_BYTES = 8 * 1024 * 1024
13// Session titles: the opening prompts come from the head, the latest from the tail.
14export const HEAD_BYTES = 200_000
15export const TAIL_BYTES = 200_000
16export const TITLES_KEY = 'titles'
17export const TITLES_PER_SCAN = 2
18// A transcript that couldn't be found is looked for again after this long.
19export const MISSING_RETRY_MS = 60_000
20// The registry's procStart and the running process's start time agree to the 100 ns tick; allow 2 s.
21export const START_TOLERANCE = 20_000_000
22
23// Claude Code writes one <pid>.json per running session here, under its config folder. It is an
24// internal file, not an API: read only the fields parseRegistryEntry names, never the rest.
25export const REGISTRY_DIR = 'sessions'
26export const PROJECTS_DIR = 'projects'
27
28// Demo sessions' ids start with this; no action touches them.
29export const DEMO_PREFIX = 'demo-'
30hooks/lib/demo.ts 37 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 nothing reads or touches them.
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, waitingFor: null,
16 cwd: `C:\\Users\\dev\\code\\${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), { waitingFor: 'permission prompt' }),
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', 'shell', '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:C:/Users/dev/code/api-hotfix', name: '(no session)', isNamed: true, title: null, value: null, status: 'worktree',
32 waitingFor: null, cwd: 'C:/Users/dev/code/api-hotfix', pid: null, sessionId: '', updatedAt: 0, isSelf: false,
33 git: git('hotfix/rate-limit'), tokens: null,
34 },
35 ]
36}
37hooks/lib/format.ts 146 lines1import type { Git, ResumeValue, Row, Snapshot } from '../../types'
2
3import { isBusy, isClosable, isCold, isStale, needsYou, windowCounts } from './rules.ts'
4
5// Windows paths split on either separator.
6export const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
7
8export const ago = (ms: number) => {
9 const s = Math.max(0, Math.round(ms / 1000))
10 if (s < 60) return 'now'
11 if (s < 3600) return `${Math.round(s / 60)}m`
12 if (s < 86400) return `${Math.round(s / 3600)}h`
13 return `${Math.round(s / 86400)}d`
14}
15
16export const tokensText = (n: number | null) =>
17 n === null ? '—' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
18
19export const fit = (text: string, width: number) =>
20 text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text.padEnd(width)
21
22// A PowerShell single-quoted string: nothing inside expands, and a quote is written twice.
23export const psQuote = (text: string) => `'${text.replace(/'/g, "''")}'`
24
25export const resumeCommand = (row: Row) =>
26 `Set-Location -LiteralPath ${psQuote(row.cwd)}; claude${row.sessionId ? ` --resume ${row.sessionId}` : ''}`
27
28export const label = (row: Row) => (row.isNamed ? row.name : row.title ?? row.name)
29
30export const VALUE_GLYPH: Record<ResumeValue, string> = { active: '●', reference: '◐', light: '○' }
31export const VALUE_COLOR: Record<ResumeValue, string | undefined> = { active: 'green', reference: 'yellow', light: undefined }
32
33export const glyph = (row: Row) => (row.value ? VALUE_GLYPH[row.value] : ' ')
34
35// Fits the label to `width`, keeping a ×N marker visible by shortening the label instead.
36export const fitLabel = (row: Row, counts: Map<string, number>, width: number) => {
37 const n = counts.get(row.sessionId) ?? 1
38 if (n <= 1) return fit(label(row), width)
39 const mark = ` ×${n}`
40 return (fit(label(row), width - mark.length).trimEnd() + mark).padEnd(width)
41}
42
43export const gitText = (git: Git | null) => {
44 if (!git) return '—'
45 const marks = [git.dirty ? `✎${git.dirty}` : '', git.ahead ? `↑${git.ahead}` : '', git.behind ? `↓${git.behind}` : '']
46 return [git.branch, ...marks.filter(Boolean)].join(' ')
47}
48
49export const ctxText = (row: Row, now: number) =>
50 row.pid === null ? '' : `${tokensText(row.tokens)}${row.tokens !== null && isCold(row, now) ? ' cold' : ''}`
51
52// What a waiting session waits on, for the toast and the list.
53export const needText = (row: Row) => (row.waitingFor ? `${row.status}: ${row.waitingFor}` : row.status)
54
55// The status line: counts only, and nothing when this is the only session.
56export const statusLineText = (snap: Snapshot) => {
57 if (snap.warning) return "⧉ fleet can't read sessions (/fleet)"
58 const live = snap.rows.filter(r => r.pid !== null)
59 if (live.length <= 1) return undefined
60 const busy = live.filter(r => isBusy(r.status)).length
61 const waiting = live.filter(r => needsYou(r.status)).length
62 const parts = [`⧉ ${live.length} sessions`]
63 if (busy) parts.push(`${busy} busy`)
64 if (waiting) parts.push(`${waiting} need you`)
65 return parts.join(' · ')
66}
67
68// Headline counts, then the details; the pane draws them as two lines.
69export const summaryParts = (snap: Snapshot): [string, string] => {
70 const live = snap.rows.filter(r => r.pid !== null)
71 const stale = live.filter(r => isStale(r, snap.scannedAt)).length
72 const busy = live.filter(r => isBusy(r.status)).length
73 const cold = live.filter(r => !r.isSelf && isCold(r, snap.scannedAt)).reduce((sum, r) => sum + (r.tokens ?? 0), 0)
74 const twice = [...windowCounts(live).values()].filter(n => n > 1).length
75 const closable = live.filter(r => isClosable(r, snap.scannedAt)).length
76 const waiting = live.filter(r => needsYou(r.status)).length
77 const head = [
78 `${live.length} sessions`,
79 `${busy} busy`,
80 waiting ? `${waiting} need you` : '',
81 closable ? `${closable} safe to close` : '',
82 ]
83 const detail = [
84 cold ? `${tokensText(cold)} tokens to re-cache` : '',
85 stale ? `${stale} idle > 7d` : '',
86 twice ? `${twice} open in ${twice === 1 ? 'two windows' : 'several windows'}` : '',
87 ]
88 return [head.filter(Boolean).join(' · '), detail.filter(Boolean).join(' · ')]
89}
90
91export const summary = (snap: Snapshot) => summaryParts(snap).filter(Boolean).join(' · ')
92
93export const tableLine = (r: Row, now: number, hasGit = true, counts = new Map<string, number>()) =>
94 [
95 r.isSelf ? '▸' : ' ',
96 glyph(r),
97 fitLabel(r, counts, 34),
98 fit(r.status, 9),
99 fit(basename(r.cwd), 22),
100 hasGit ? fit(gitText(r.git), 22) : '',
101 (r.pid === null ? '' : ago(now - r.updatedAt)).padStart(4),
102 ctxText(r, now).padStart(11),
103 ].join(' ')
104
105// A row prefixed with its pid, for finding its window in Task Manager.
106export const pidLine = (r: Row, snap: Snapshot) =>
107 ` ${String(r.pid).padStart(6)} ${tableLine(r, snap.scannedAt, true, windowCounts(snap.rows))}`
108
109export const asTable = (snap: Snapshot) => {
110 const counts = windowCounts(snap.rows)
111 const hasGit = snap.rows.some(x => x.git)
112 const waiting = snap.rows.filter(r => r.pid !== null && r.waitingFor && needsYou(r.status))
113 return (
114 (snap.warning ? `⚠ ${snap.warning}\n\n` : '') +
115 `${summary(snap)}\n\n${snap.rows.map(r => tableLine(r, snap.scannedAt, hasGit, counts)).join('\n')}\n\n` +
116 (waiting.length ? `${waiting.map(r => `${label(r)} is waiting on: ${r.waitingFor}`).join('\n')}\n\n` : '') +
117 `ctx = context the session re-caches on its next message; "cold" = cache expired, so that costs the full amount.` +
118 ([...counts.values()].some(n => n > 1)
119 ? `\n×2 = the same conversation open in two windows; closing the older one loses nothing.`
120 : '') +
121 `\n● in progress: worth resuming ◐ finished: hand off, then close ○ light: just close`
122 )
123}
124
125export const staleReport = (snap: Snapshot) => {
126 const stale = snap.rows.filter(r => r.pid !== null && !r.isSelf && isStale(r, snap.scannedAt))
127 if (stale.length === 0) return 'No sessions idle for more than 7 days.'
128 return (
129 `${stale.length} sessions idle for more than 7 days:\n\n` +
130 `${stale.map(r => pidLine(r, snap)).join('\n')}\n\n` +
131 `Close them from their own windows; claude --resume brings any of them back.`
132 )
133}
134
135export const HELP = `/fleet open the live pane
136/fleet list print the table
137/fleet stale sessions idle for more than 7 days
138/fleet retitle forget the generated session titles and make new ones
139/fleet help this list
140
141● in progress: worth resuming · ◐ finished: hand off, then close · ○ light: just close
142In the pane: ↑/↓ select · c copy a PowerShell resume command · r refresh
143
144CLAUDE_FLEET_TITLES=0 turns off the Haiku titles · CLAUDE_FLEET_NOTIFY=0 turns off the toasts
145CLAUDE_FLEET_DEMO=1 shows made-up sessions`
146hooks/lib/helper.ts 117 lines1// Windows has no ps, head or tail, and $.fs only reads whole files of at most 4 MiB. So one
2// PowerShell process per scan answers both questions: which pids are running (and since when),
3// and what the ends of the transcripts that changed say.
4import { HEAD_BYTES, START_TOLERANCE, TAIL_BYTES, TOKENS_MAX_BYTES, TOKENS_WINDOW_BYTES } from './constants.ts'
5
6export type HelperFile = { path: string; mode: 'tokens' | 'ends' }
7export type HelperRequest = { pids: number[]; files: HelperFile[] }
8export type HelperProcess = { pid: number; name: string; start: string }
9// `lines` (tokens): the last main-thread reply that carries usage. `head`/`tail` (ends): raw text,
10// cut mid-line at the edges. `error`: why the file couldn't be read.
11export type HelperRead = { path: string; size: number; lines: string[]; head: string; tail: string; error: string | null }
12export type HelperReply = { procs: HelperProcess[]; files: HelperRead[] }
13
14// Written one statement per line for reading; HELPER_SCRIPT below is what runs.
15// No double quotes ([char]34 stands in) and no backticks, so it passes through Windows command-line
16// quoting unchanged, and every `}` that a catch, finally or else follows shares its line.
17const HELPER_SOURCE = `
18$ErrorActionPreference = 'Stop'
19[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
20$q = [string][char]34
21$nl = [char]10
22$req = [Console]::In.ReadToEnd() | ConvertFrom-Json
23$procs = @()
24$ids = @($req.pids | Where-Object { $_ })
25if ($ids.Count -gt 0) {
26 foreach ($p in @(Get-Process -Id $ids -ErrorAction SilentlyContinue)) {
27 $start = ''
28 try { $start = [string]$p.StartTime.ToFileTimeUtc() } catch {}
29 $procs += [pscustomobject]@{ pid = $p.Id; name = $p.ProcessName; start = $start }
30 }
31}
32function Read-Bytes($fs, [long]$from, [long]$count) {
33 if ($count -le 0) { return '' }
34 $buf = New-Object byte[] $count
35 $fs.Position = $from
36 $got = 0
37 while ($got -lt $count) {
38 $n = $fs.Read($buf, $got, $count - $got)
39 if ($n -le 0) { break }
40 $got += $n
41 }
42 [System.Text.Encoding]::UTF8.GetString($buf, 0, $got)
43}
44$usage = $q + 'usage' + $q
45$assistant = $q + 'assistant' + $q
46$side = $q + 'isSidechain' + $q + ':true'
47$files = @()
48foreach ($f in @($req.files)) {
49 $out = [ordered]@{ path = $f.path; size = 0; lines = @(); head = ''; tail = ''; error = $null }
50 try {
51 $fs = [System.IO.File]::Open($f.path, 'Open', 'Read', 'ReadWrite, Delete')
52 try {
53 $len = $fs.Length
54 $out.size = $len
55 if ($f.mode -eq 'ends') {
56 $out.head = Read-Bytes $fs 0 ([Math]::Min($len, ${HEAD_BYTES}))
57 $from = [Math]::Max(0, $len - ${TAIL_BYTES})
58 $out.tail = Read-Bytes $fs $from ($len - $from)
59 } else {
60 $found = @()
61 for ($w = ${TOKENS_WINDOW_BYTES}; $found.Count -eq 0; $w *= 4) {
62 $from = [Math]::Max(0, $len - $w)
63 $lines = (Read-Bytes $fs $from ($len - $from)).Split($nl)
64 $first = 0
65 if ($from -gt 0) { $first = 1 }
66 for ($i = $lines.Length - 1; $i -ge $first; $i--) {
67 $l = $lines[$i]
68 if ($l.Contains($usage) -and $l.Contains($assistant) -and -not $l.Contains($side)) { $found = @($l); break }
69 }
70 if ($from -eq 0 -or $w -ge ${TOKENS_MAX_BYTES}) { break }
71 }
72 $out.lines = @($found)
73 }
74 } finally { $fs.Dispose() }
75 } catch { $out.error = $_.Exception.Message }
76 $files += [pscustomobject]$out
77}
78[pscustomobject]@{ procs = @($procs); files = @($files) } | ConvertTo-Json -Depth 4 -Compress
79`
80
81// A multi-line -Command runs nothing when Claude Code starts PowerShell (it exits 0, silent),
82// so the script goes as one line. Execution policy doesn't apply to -Command.
83export const HELPER_SCRIPT = HELPER_SOURCE.split('\n').map(line => line.trim()).filter(Boolean).join('; ')
84
85// pwsh first; Windows PowerShell 5.1 ships with every Windows and is the fallback.
86export const POWERSHELLS = ['pwsh', 'powershell'] as const
87
88export const helperArgv = (exe: string) => [exe, '-NoProfile', '-NonInteractive', '-Command', HELPER_SCRIPT]
89
90// ConvertTo-Json writes a one-element array as itself on some versions, and null for an empty one.
91const list = (x: unknown): unknown[] => (Array.isArray(x) ? x : x == null ? [] : [x])
92
93export const parseHelperReply = (stdout: string): HelperReply => {
94 const raw = JSON.parse(stdout.trim().replace(/^/, '')) ?? {}
95 return {
96 procs: list(raw.procs).map((p: any) => ({ pid: Number(p?.pid), name: String(p?.name ?? ''), start: String(p?.start ?? '') })),
97 files: list(raw.files).map((f: any) => ({
98 path: String(f?.path ?? ''),
99 size: Number(f?.size ?? 0),
100 lines: list(f?.lines).map(String),
101 head: String(f?.head ?? ''),
102 tail: String(f?.tail ?? ''),
103 error: f?.error == null ? null : String(f.error),
104 })),
105 }
106}
107
108// A pid can be reused once its session has ended. The registry records when the session's process
109// started (a Windows FILETIME), so a running process with that pid and that start time is the session.
110// Older registry files without it fall back to the process name.
111export const isSessionProcess = (procStart: string | null, proc: HelperProcess) => {
112 const a = Number(procStart)
113 const b = Number(proc.start)
114 if (procStart && proc.start && Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < START_TOLERANCE
115 return /^(claude|node|bun)$/i.test(proc.name)
116}
117hooks/lib/parse.ts 130 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'
5
6// --- Registry ---
7
8// Reads only these fields of a registry file; the others hold tokens and sockets and are never touched.
9export const parseRegistryEntry = (text: string, mtimeMs: number): Registered | null => {
10 const raw = JSON.parse(text.replace(/^\uFEFF/, ''))
11 if (typeof raw.pid !== 'number' || typeof raw.cwd !== 'string') return null
12 return {
13 pid: raw.pid,
14 procStart: typeof raw.procStart === 'string' && /^\d+$/.test(raw.procStart) ? raw.procStart : null,
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 waitingFor: typeof raw.waitingFor === 'string' && raw.waitingFor ? raw.waitingFor : null,
20 cwd: raw.cwd,
21 sessionId: String(raw.sessionId ?? ''),
22 updatedAt: Number(raw.updatedAt ?? mtimeMs),
23 }
24}
25
26// --- Paths ---
27
28// One spelling per folder, for comparing: registry cwds use backslashes, git answers with forward ones,
29// and Windows paths ignore case.
30export const samePath = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
31
32// --- Git ---
33
34// `git status --porcelain=v2 --branch` → branch, uncommitted files, ahead/behind.
35export const parseGitStatus = (stdout: string): Git => {
36 const git: Git = { branch: '?', dirty: 0, ahead: 0, behind: 0, hasUpstream: false }
37 for (const line of stdout.split(/\r?\n/)) {
38 if (line.startsWith('# branch.head ')) git.branch = line.slice('# branch.head '.length)
39 else if (line.startsWith('# branch.ab ')) {
40 const [, a, b] = line.match(/\+(\d+) -(\d+)/) ?? []
41 git.ahead = Number(a ?? 0)
42 git.behind = Number(b ?? 0)
43 git.hasUpstream = true
44 } else if (line && !line.startsWith('#')) git.dirty += 1
45 }
46 return git
47}
48
49// `git worktree list --porcelain` → worktree paths.
50export const parseWorktrees = (stdout: string) =>
51 stdout
52 .split(/\r?\n/)
53 .filter(line => line.startsWith('worktree '))
54 .map(line => line.slice('worktree '.length))
55
56// --- Transcripts ---
57
58// Claude Code's project folder name: every character outside [A-Za-z0-9] becomes '-',
59// so C:\Users\me\code is C--Users-me-code.
60export const transcriptPath = (configDir: string, row: Pick<Row, 'cwd' | 'sessionId'>) =>
61 `${configDir}/${PROJECTS_DIR}/${row.cwd.replace(/[^A-Za-z0-9]/g, '-')}/${row.sessionId}.jsonl`
62
63// Context size of the last main-thread reply; what resuming re-sends.
64export const lastContextTokens = (tail: string): number | null => {
65 for (const line of tail.split('\n').reverse()) {
66 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
67 try {
68 const entry = JSON.parse(line)
69 if (entry.type !== 'assistant' || entry.isSidechain) continue
70 const u = entry.message?.usage
71 if (!u) continue
72 return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
73 } catch {
74 // The first line of a tail is usually cut mid-entry.
75 }
76 }
77 return null
78}
79
80export const textOf = (content: unknown): string =>
81 typeof content === 'string'
82 ? content
83 : Array.isArray(content)
84 ? content.filter(b => b?.type === 'text').map(b => String(b.text)).join('\n')
85 : ''
86
87// A user turn as the person typed it: reminders dropped, slash commands shown as typed.
88export const cleanUserText = (text: string) =>
89 text
90 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
91 .replace(/<command-message>[\s\S]*?<\/command-message>/g, '')
92 .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '')
93 .replace(/<command-name>(.*?)<\/command-name>/g, '$1')
94 .replace(/<command-args>(.*?)<\/command-args>/g, ' $1')
95 .replace(/<\/?local-command-stdout>/g, '')
96 .trim()
97
98export const userPrompts = (jsonl: string) => {
99 const prompts: string[] = []
100 for (const line of jsonl.split('\n')) {
101 if (!line.includes('"user"')) continue
102 try {
103 const entry = JSON.parse(line)
104 if (entry.type !== 'user' || entry.isSidechain || entry.isMeta) continue
105 const text = cleanUserText(textOf(entry.message?.content))
106 if (text) prompts.push(text.slice(0, 600))
107 } catch {
108 // A head or tail cut mid-entry.
109 }
110 }
111 return prompts
112}
113
114export const lastAssistantText = (jsonl: string) => {
115 const lines = jsonl.split('\n')
116 for (let i = lines.length - 1; i >= 0; i--) {
117 const line = lines[i]!
118 if (!line.includes('"assistant"')) continue
119 try {
120 const entry = JSON.parse(line)
121 if (entry.type !== 'assistant' || entry.isSidechain) continue
122 const text = textOf(entry.message?.content).trim()
123 if (text) return text
124 } catch {
125 // Cut mid-entry.
126 }
127 }
128 return ''
129}
130hooks/lib/prompts.ts 37 lines1// What fleet asks Claude: a title and resume value per session, from Haiku.
2import type { ResumeValue } from '../../types'
3
4export const LABEL_SYSTEM =
5 'You label coding sessions for a session list. Reply with JSON only: {"title": string, "value": string}.\n' +
6 'title: 3-6 words naming the task, leading with the concrete project and goal ("Canvas lab 2 grading drafts"); ' +
7 'no quotes, no trailing punctuation, never the words "session", "conversation" or "chat".\n' +
8 'value: whether resuming is worth re-sending the whole conversation. Pick exactly one:\n' +
9 '- "light": quick questions or one-off lookups; nothing a fresh session couldn\'t redo in a minute.\n' +
10 '- "reference": substantial work whose main task was delivered or answered. This is the DEFAULT for real work. ' +
11 'A last reply that offers more help, lists optional next steps, or asks "want me to...?" is still "reference".\n' +
12 '- "active": ONLY when work is clearly mid-flight: the last reply stops partway through an implementation, ' +
13 'a bug or failing test is still being chased, or the person must answer a question before anything can continue. ' +
14 'Sessions idle for weeks are rarely active.'
15
16export const labelPrompt = (first: string[], latest: string[], lastReply: string, idleText: string) =>
17 (
18 `Opening requests:\n${first.join('\n---\n')}\n\nLatest requests:\n${latest.join('\n---\n') || '(same)'}` +
19 `\n\nLast reply (end):\n${lastReply || '(none)'}\n\nIdle for: ${idleText}`
20 ).slice(0, 5000)
21
22const cleanTitle = (text: string) =>
23 text.split('\n')[0]!.replace(/^(title:\s*)/i, '').replace(/["'`*#]/g, '').replace(/[.\s]+$/, '').trim().slice(0, 48)
24
25// The label reply as JSON, or a bare title when the model skipped the JSON.
26export const parseLabel = (text: string): { title: string; value?: ResumeValue } | null => {
27 try {
28 const raw = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
29 const title = cleanTitle(String(raw.title ?? ''))
30 const value = ['active', 'reference', 'light'].includes(raw.value) ? (raw.value as ResumeValue) : undefined
31 return title ? { title, value } : null
32 } catch {
33 const title = cleanTitle(text)
34 return title && !title.includes('{') ? { title } : null
35 }
36}
37hooks/lib/rules.ts 34 lines1import type { Row } 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// Claude Code reports `shell` while a session runs a command: working, not waiting on the person.
10export const isBusy = (status: string) => status === 'busy' || status === 'shell'
11
12// Anything but working, idle, or a worktree row is a session waiting on the person.
13export const needsYou = (status: string) => status !== 'idle' && !isBusy(status) && status !== 'worktree'
14
15// Safe to close: idle, and either cold with nothing unfinished, or idle for a week and not known to be in progress.
16export const isClosable = (row: Row, now: number) =>
17 row.pid !== null && !row.isSelf && row.status === 'idle' &&
18 ((isCold(row, now) && (row.value === 'light' || row.value === 'reference')) || (isStale(row, now) && row.value !== 'active'))
19
20export const isDemo = (row: Row) => row.sessionId.startsWith(DEMO_PREFIX)
21
22// The same conversation resumed in two windows 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 : isBusy(r.status) ? 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 91 lines1// What the pane draws. It gets 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 { Snapshot } from '../../types'
6
7import { ago, basename, ctxText, fit, fitLabel, gitText, glyph, label, summaryParts, VALUE_COLOR } from './format.ts'
8import { isBusy, isCold, isStale, needsYou, windowCounts } from './rules.ts'
9
10export type PaneActions = {
11 select: (key: string) => void
12 copy: (press: UiPressArgument) => void
13 refresh: () => void
14}
15
16export type PaneView = {
17 snap: Snapshot | null
18 picked: string | null
19 // The pane body's width and the viewport's height, in cells.
20 columns: number
21 rows: number
22}
23
24export const paneView = (el: ElementTable, view: PaneView, act: PaneActions) => {
25 const { Box, Button, Text } = el
26 const { snap, picked } = view
27 if (!snap) return <Text dimColor>Scanning…</Text>
28
29 const now = snap.scannedAt
30 const counts = windowCounts(snap.rows)
31 const hasGit = snap.rows.some(r => r.git)
32 const [headline, details] = summaryParts(snap)
33 const waiting = snap.rows.filter(r => r.pid !== null && r.waitingFor && needsYou(r.status))
34 const ctxW = 10
35 const projW = Math.max(8, Math.min(20, Math.floor(view.columns * 0.16)))
36 const gitW = hasGit ? Math.max(6, Math.min(22, Math.floor(view.columns * 0.2))) : 0
37 const nameW = Math.max(10, view.columns - 4 - 9 - projW - gitW - 5 - ctxW - 5)
38 const room = Math.max(1, view.rows - 7 - waiting.length)
39
40 return (
41 <Box flexDirection="column">
42 {snap.warning ? <Text color="red" wrap="wrap">⚠ {snap.warning}</Text> : null}
43 <Text bold wrap="truncate-end">{headline}</Text>
44 {details ? <Text dimColor wrap="truncate-end">{details}</Text> : null}
45 <Text> </Text>
46 {snap.rows.slice(0, room).map((r, i) => {
47 const isPicked = r.key === picked
48 const stale = r.pid !== null && isStale(r, now)
49 const color = isBusy(r.status) ? 'yellow' : needsYou(r.status) && r.pid !== null ? 'red' : undefined
50 const cold = r.tokens !== null && isCold(r, now)
51 return (
52 <Box key={r.key}>
53 <Button
54 key={`row:${r.key}`}
55 plain
56 label={isPicked ? '▸' : r.isSelf ? '•' : ' '}
57 autoFocus={i === 0 && picked === null ? true : undefined}
58 onPress={() => act.select(r.key)}
59 />
60 <Text color={r.value ? VALUE_COLOR[r.value] : undefined} dimColor={r.value === 'light'}>{` ${glyph(r)} `}</Text>
61 <Text bold={r.isSelf || isPicked} inverse={isPicked} color={r.isSelf ? 'cyan' : undefined} dimColor={stale && !isPicked} wrap="truncate-end">
62 {fitLabel(r, counts, nameW)}
63 </Text>
64 <Text> </Text>
65 <Text color={color} dimColor={!color}>{fit(r.status, 9)}</Text>
66 <Text dimColor={stale}>{fit(basename(r.cwd), projW)} </Text>
67 {hasGit ? (
68 <Text color={r.git?.dirty ? 'magenta' : undefined} dimColor={!r.git || stale}>{fit(gitText(r.git), gitW)}</Text>
69 ) : null}
70 <Text dimColor>{(r.pid === null ? '' : ago(now - r.updatedAt)).padStart(4)} </Text>
71 <Text color={cold && (r.tokens ?? 0) >= 500_000 ? 'red' : cold ? 'blue' : undefined} dimColor={!cold}>
72 {ctxText(r, now).padStart(ctxW)}
73 </Text>
74 </Box>
75 )
76 })}
77 {snap.rows.length > room ? <Text dimColor> +{snap.rows.length - room} more (/fleet list)</Text> : null}
78 {waiting.map(r => (
79 <Text key={`waiting:${r.key}`} color="red" wrap="truncate-end"> ⚠ {label(r)} is waiting on: {r.waitingFor}</Text>
80 ))}
81 <Text> </Text>
82 <Box>
83 <Button key="copy" label="Copy resume" hotkey="c" onPress={act.copy} />
84 <Text> </Text>
85 <Button key="refresh" label="Refresh" hotkey="r" onPress={act.refresh} />
86 </Box>
87 <Text dimColor wrap="truncate-end">● in progress ◐ finished: hand off ○ light · ctx = tokens a resume re-caches · cold = cache expired</Text>
88 </Box>
89 )
90}
91types/index.d.ts 62 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: write a handoff and close it), or quick questions (light: just close it).
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 // What a waiting session waits on, as Claude Code words it ("permission prompt").
17 waitingFor: string | null
18 cwd: string
19 pid: number | null
20 sessionId: string
21 updatedAt: number
22 isSelf: boolean
23 git: Git | null
24 // Context size of the session's last reply: what resuming it re-caches once the cache is cold.
25 tokens: number | null
26}
27
28export type Snapshot = {
29 rows: Row[]
30 scannedAt: number
31 // Why the list may be wrong or empty (the registry moved or changed format), else null.
32 warning: string | null
33}
34
35// One session as Claude Code's registry describes it (only the fields fleet reads).
36export type Registered = {
37 pid: number
38 // When that process started, as a Windows FILETIME string; tells a live session from a reused pid.
39 procStart: string | null
40 name: string
41 isNamed: boolean
42 status: string
43 waitingFor: string | null
44 cwd: string
45 sessionId: string
46 updatedAt: number
47}
48
49// A generated title and resume value, cached per session with the transcript size it was made at.
50export type TitleEntry = { title: string; size: number; value?: ResumeValue }
51
52declare module 'claude-code' {
53 interface PluginState {
54 fleet: {
55 snapshot: Snapshot | null
56 isOpen: boolean
57 // The row the pane's actions apply to, by Row.key.
58 selected: string | null
59 }
60 }
61}
62