SLOPSHOPPER

fleet

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

panecommandtoaststatusmodelprocess
v0.1.1MITupdated 2026-10-20daldaman/claude-fleet-windows
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) │ ┃ [ Copy resume ] [ Refresh ] ⎿ 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 the rest. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 [ Copy resume ] [ Refresh ] ● in progress ◐ finished: hand off ○ light · ctx = tokens…
README

fleet for Windows

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.

What you get

  • Status line, always on: ⧉ 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:
  • a short title for what the session is about, written by Haiku, instead of names like api-5d
  • its status: busy in yellow, waiting on you in red, and what it waits on (a permission prompt, say)
  • the project folder and git branch, uncommitted files (✎) and commits ahead or behind (↑↓)
  • how long since it was last active
  • its context size, marked cold once the prompt cache has expired: resuming it then pays to cache that whole context again
  • whether it's worth resuming: ● in progress, ◐ finished (write a handoff, then close it), ○ light (just close it)

In the pane, ↑/↓ selects a session, c copies a PowerShell command that resumes it, and r refreshes.

  • A toast when another session needs you, for example when it's waiting on a permission prompt, and another when a busy session finishes.
  • /fleet list, /fleet stale (idle over a week), /fleet retitle and /fleet help print the same information as text, also in claude -p.

Install

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.

Settings

Set these as environment variables, for example in the env block of ~/.claude/settings.json.

VariableEffect
CLAUDE_FLEET_TITLES=0No Haiku titles; sessions show Claude Code's own names.
CLAUDE_FLEET_NOTIFY=0No toasts.
CLAUDE_FLEET_DEMO=1Made-up sessions, for trying the pane or taking screenshots.
CLAUDE_FLEET_REGISTRYRead the session list from another folder.

What it costs

  • Titles: one small Haiku call per session that has no title yet, two per refresh at most, and only while the pane is open or a /fleet command runs. A title is made again only when its conversation has doubled in size. Titles are kept between sessions.
  • PowerShell: each refresh starts one PowerShell process, which checks which sessions are still running and reads the end of each transcript that grew. With the pane closed, that happens at most once a minute.

How the port works

  • Which sessions run: Claude Code writes one file per session to ~/.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.
  • Context size: the helper reads a transcript's end from disk and finds the last reply's token usage there, starting with the last 512 KB and reading further back up to 8 MB. Windows has no tail, and the mod's own file reads stop at 4 MiB, while a long session's transcript runs past 14 MB.
  • Statuses: Claude Code reports shell while a session runs a command. That counts as busy, not as needing you.
  • PowerShell: 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.

Limits

  • ~/.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.
  • Sessions in the Claude desktop app show up too, but the resume command it copies is for a terminal.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT, with the original's copyright kept. See LICENSE.

Source 10 files
hooks/register.tsx 421 lines
1// 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}
421
hooks/lib/constants.ts 30 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
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-'
30
hooks/lib/demo.ts 37 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 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}
37
hooks/lib/format.ts 146 lines
1import 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`
146
hooks/lib/helper.ts 117 lines
1// 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}
117
hooks/lib/parse.ts 130 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'
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}
130
hooks/lib/prompts.ts 37 lines
1// 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}
37
hooks/lib/rules.ts 34 lines
1import 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}
34
hooks/lib/views.tsx 91 lines
1// 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}
91
types/index.d.ts 62 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: 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