Fleet board of live sessions, a needs-you beacon, and a worktree claim guard, shared across sessions

Five Claude Code mods built from how I actually use Claude Code: four to six sessions at a time, mostly in one repo, a lot of PRs going to production, and a pile of rules I keep forgetting to check.
Claude built all five. The full story, with screenshots, videos and the bugs a real run caught, is on my site: I asked Claude to read 30 of my Claude Code sessions and build the mods I needed

This is what I gave Claude Code. It read my last 30 sessions, came back with ten ideas, and built all ten as these five mods.
look at the last 30 sessions and see how we use claude to work - then look at https://code.claude.com/docs/en/plugins/mods/overview and suggest 10 possible mods for our workflow that would help me be better informed as to what each session is doing.
Run it on your own sessions. Your ten ideas will probably come out different from mine, and I'd like to see them.
| Mod | What it gives you | Commands |
|---|---|---|
| session-fleet | A board of every live session, a "needs you" row when another session is blocked on you, and a guard when two sessions write to the same worktree | /fleet |
| ship-tracker | A row per PR: CI → merged → production deployment → live, a warning when a merge never deploys, and a receipt line after every answer | /ship-track <pr> |
| session-hygiene | Configurable tripwires (typecheck before push, review before push, mutation-check new tests and more), plus a handoff card of loose ends from earlier sessions | /handoff, /handoff clear |
| session-activity | A ledger of everything that left the machine, a hold on database writes, and what each session is waiting on | /ledger, /waiting |
| context-gauge | Context fill in the status line, warnings before compaction, and a snapshot of in-flight work that survives compaction | /context-log |
They work on their own. Install any one, or all five.
You need a Claude Code version with mods (see the mods docs), git, and for ship-tracker and the handoff card, the GitHub CLI signed in.
A mod is code that runs with your permissions inside Claude Code. Read it before you install it. Each one is a single hooks/register.tsx file, and claude plugin validate <folder> lists exactly which events it hooks and what it calls.
In a Claude Code session:
/plugin marketplace add bennewton999/claude-code-mods
/plugin install session-fleet@bennewton-mods
/plugin install ship-tracker@bennewton-mods
/plugin install session-hygiene@bennewton-mods
/plugin install session-activity@bennewton-mods
/plugin install context-gauge@bennewton-mods
Then /reload-plugins, or start a new session.
git clone https://github.com/bennewton999/claude-code-mods ~/claude-code-mods
Load them in every session by adding the folders to env in ~/.claude/settings.json (colon-separated):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/session-fleet:/Users/you/claude-code-mods/ship-tracker:/Users/you/claude-code-mods/session-hygiene:/Users/you/claude-code-mods/session-activity:/Users/you/claude-code-mods/context-gauge",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
Or try one for a single session:
claude --plugin-dir ~/claude-code-mods/session-fleet
CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop app sessions hot-reload a mod when its files change (an interactive terminal session already does). A session reads it at startup, so sessions that were already open pick up the mods, and the watch, after one restart.
Every session writes one record to a key-value store that all sessions on the machine share ($.store), with a heartbeat every 30 seconds. Three minutes without a heartbeat and the session counts as gone.
/fleet opens a pane with every live session: a status dot, its first prompt (there's no session title API), the worktrees it's touching, branch, PR, last action and how long ago. Waiting sessions sort to the top, and a worktree two live sessions share gets a ⚠.projectPills to Label:regex:color entries separated by ;, the regex tested against the repo folder name: "pluginConfigs": {
"session-fleet": {
"options": { "projectPills": "Work:acme|billing:#2563eb; Blog:blog:#059669" }
}
}

gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.no prod deploy!. That's the silent Vercel git-trigger failure that got me before.
Tripwires for rules you keep forgetting. When one trips you get a transcript line and a toast, and Claude gets a reminder with the tool result so it can act on it.
Every rule is a plugin option. You set them on the install screen, or later in /config. Out of the box only the two generic rules are on; the rest stay off until you point them at your repos.
| Option | Default | What it does |
|---|---|---|
holdEnvSourcing | on | Asks Run it or Stop before a command sources a .env file into the shell |
mutationCheckTests | on | When Claude writes a new test file, reminds it to prove the test fails without the code it covers |
reviewBeforePush | off | Flags a git push with edits made since the last local /code-review |
uncheckedRepos | none | Path fragments (/my-app/) of repos whose CI doesn't typecheck or build. There it flags a push after TypeScript edits with no typecheck, and a merge after package.json or next.config changes with no local build |
middlewareRouteRepos | none | Next.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it |
bannedStrings | none | Case-insensitive text that should never be written into a file |
supabase | off | Asks before a command uses a service-role key; after apply_migration, reminds Claude to run NOTIFY pgrst, 'reload schema' and to revoke default grants on new tables |
If you load the mod from a clone instead of the marketplace, set the same options in ~/.claude/settings.json:
{
"pluginConfigs": {
"session-hygiene": {
"options": {
"uncheckedRepos": ["/my-app/"],
"bannedStrings": ["lorem ipsum"],
"supabase": true
}
}
}
}
The handoff card: after each turn a session saves its open loops (open PRs, uncommitted or unpushed worktrees, a question it left you). A new session lists loops from sessions that ended or went quiet, re-checked live first. /handoff shows them again, /handoff clear dismisses them.

/ledger lists everything that left the machine: MCP write tools, git push, gh pr create and merge, gh api writes, Vercel deploys, curl writes, publishes, SQL writes. A button switches to all sessions in the last 24 hours. Reads stay out of it.execute_sql that writes (INSERT, UPDATE, DELETE, DDL, GRANT…) stops for a Run it or Stop question. SELECTs and NOTIFY pass. Words inside comments and quoted strings don't count.Thinking · 2 bg · agent 3m…), and a row above the prompt lists background commands, agents, workflows and monitors still running after Claude stops. /waiting lists them, /waiting clear resets.
ctx 62% · 124k/200k · compacted 1×./context-log lists every compaction this session with its snapshot.
Everything these mods record stays on your machine in Claude Code's per-plugin store. The only network calls are gh requests to GitHub for PR and deployment state (ship-tracker, and the handoff card's re-check).
/ledger reported the pane as opened but it never drew. It works in fresh sessions; the built-in diff pane seemed to be in front of it.I'm Ben Newton. I write about building with AI at benenewton.com, and I'm building BlackOps Center, the platform that runs my site, my posts and the notes these Claude sessions write into. If you want to see it, start at blackopscenter.com/start.
Claude Code wrote the mods with its built-in plugin-authoring skill. Issues and PRs are welcome.
MIT licensed.
hooks/register.tsx 524 lines1import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
2
3// session-fleet: one registry of live sessions in $.store (shared by every
4// session on the machine), read three ways:
5// 1. /fleet opens a pane listing every live session; click a desktop
6// session's title to switch to it, pills tag its project
7// 2. the band above the prompt flags other sessions that are waiting on you
8// 3. edits and mutating git in a worktree another live session claimed first
9// are held with a question naming that session
10
11const PANE = 'fleet'
12const PREFIX = 's:'
13const ALIVE_MS = 3 * 60_000 // no heartbeat for this long = session is gone
14const CLAIM_MS = 30 * 60_000 // a worktree untouched for this long is released
15const PRUNE_MS = 24 * 60 * 60_000
16const BEAT_MS = 30_000
17const REDRAW_MS = 5_000
18
19type Phase = 'working' | 'idle' | 'waiting'
20
21type Claim = { since: number; lastTouch: number }
22
23type Rec = {
24 id: string
25 task: string
26 cwd: string
27 branch: string | null
28 pr: string | null
29 phase: Phase
30 waitingWhy: string | null
31 waitingSince: number | null
32 last: string
33 lastAt: number
34 beat: number
35 claims: Record<string, Claim>
36 hostId?: string | null // desktop app session id (local_…), null in a terminal
37}
38
39const GIT_MUTATION =
40 /\bgit\b(?:\s+-C\s+\S+)?\s+(commit|checkout|switch|reset|rebase|merge|pull|push|stash|add|rm|mv|restore|cherry-pick|revert|am|apply|clean|worktree\s+remove)\b/
41const HUMAN = new Set(['composer', 'bridge', 'sdk'])
42
43const me: Rec = {
44 id: '',
45 task: '',
46 cwd: '',
47 branch: null,
48 pr: null,
49 phase: 'idle',
50 waitingWhy: null,
51 waitingSince: null,
52 last: 'started',
53 lastAt: 0,
54 beat: 0,
55 claims: {},
56 hostId: null,
57}
58
59let lastWrite = 0
60let lastRepoRefresh = 0
61let paneOpen = false
62let timers: Timer[] = []
63const toplevelCache = new Map<string, string | null>()
64const allowed = new Set<string>() // worktrees the user said to proceed in
65const toasted = new Map<string, number>() // other session id -> waitingSince toasted
66
67const short = (s: string, n: number) => {
68 const one = s.replace(/\s+/g, ' ').trim()
69 return one.length > n ? one.slice(0, n - 1) + '…' : one
70}
71
72const home = (p: string) => p.replace(/^\/Users\/[^/]+/, '~')
73
74const shortTree = (p: string) => {
75 const wt = p.match(/\.claude\/worktrees\/([^/]+)$/)
76 if (wt) return `${(p.split('/.claude/')[0] ?? '').split('/').pop()}:${wt[1]}`
77 return home(p)
78}
79
80// Which project a session works in, from its newest worktree or its cwd
81const repoName = (p: string) => (p.split('/.claude/worktrees/')[0] ?? p).split('/').filter(Boolean).pop() ?? ''
82
83type Pill = { label: string; fg: string; bg: string }
84type PillRule = { label: string; test: RegExp; bg: string }
85
86const OTHER_BG = '#4b5563'
87let pillRules: PillRule[] = []
88
89// The projectPills option: `Label:regex:color` entries separated by `;`
90function parseRules(spec: string): PillRule[] {
91 const rules: PillRule[] = []
92 for (const entry of spec.split(';')) {
93 const [label, pattern, color] = entry.split(':').map(x => x.trim())
94 if (!label || !pattern) continue
95 try {
96 rules.push({ label, test: new RegExp(pattern, 'i'), bg: color || OTHER_BG })
97 } catch {
98 // a bad regex skips its entry
99 }
100 }
101 return rules
102}
103
104function pill(r: Rec, now: number): Pill | null {
105 const trees = Object.entries(r.claims)
106 .filter(([, c]) => now - c.lastTouch < CLAIM_MS)
107 .sort((a, b) => b[1].lastTouch - a[1].lastTouch)
108 const dir = trees[0]?.[0] ?? r.cwd
109 const name = repoName(dir)
110 if (!name || /^\/Users\/[^/]+\/?$/.test(dir)) return null // home dir: no project
111 const rule = pillRules.find(p => p.test.test(name))
112 return { label: rule?.label ?? name, fg: 'white', bg: rule?.bg ?? OTHER_BG }
113}
114
115const ago = (now: number, t: number) => {
116 const s = Math.max(0, Math.round((now - t) / 1000))
117 if (s < 60) return `${s}s`
118 if (s < 3600) return `${Math.round(s / 60)}m`
119 return `${Math.round(s / 3600)}h`
120}
121
122async function save($: Engine, force = false) {
123 const now = await $.clock.now()
124 if (!force && now - lastWrite < 2000) return
125 lastWrite = now
126 me.beat = now
127 if (!me.id) return
128 await $.store.set(PREFIX + me.id, me)
129}
130
131async function all($: Engine): Promise<Rec[]> {
132 const keys = (await $.store.keys()).filter((k: string) => k.startsWith(PREFIX))
133 const recs: Rec[] = []
134 const now = await $.clock.now()
135 for (const k of keys) {
136 const r = (await $.store.get(k)) as Rec | undefined
137 if (!r) continue
138 if (now - r.beat > PRUNE_MS) {
139 await $.store.delete(k)
140 continue
141 }
142 recs.push(r)
143 }
144 return recs
145}
146
147const isAlive = (r: Rec, now: number) => now - r.beat < ALIVE_MS
148
149async function toplevel($: Engine, dir: string): Promise<string | null> {
150 let d = dir
151 for (let i = 0; i < 6 && d && d !== '/'; i++) {
152 if (toplevelCache.has(d)) return toplevelCache.get(d) ?? null
153 try {
154 if (await $.fs.exists(d)) {
155 const r = await $.process.run(['git', '-C', d, 'rev-parse', '--show-toplevel'], {
156 timeoutMs: 5000,
157 })
158 const top = r.exitCode === 0 ? r.stdout.trim() : null
159 toplevelCache.set(d, top)
160 return top
161 }
162 } catch {
163 return null
164 }
165 d = d.slice(0, d.lastIndexOf('/')) || '/'
166 }
167 return null
168}
169
170const dirname = (p: string) => p.slice(0, p.lastIndexOf('/')) || '/'
171
172// Which worktree a tool call writes to, or null when it is not a guarded write.
173async function target($: Engine, e: { tool: string } & Record<string, unknown>): Promise<string | null> {
174 const fp = (e.file_path ?? e.notebook_path) as string | undefined
175 if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(e.tool) && typeof fp === 'string') {
176 return toplevel($, dirname(fp))
177 }
178 if (e.tool === 'Bash' && typeof e.command === 'string' && GIT_MUTATION.test(e.command)) {
179 const cmd = e.command
180 const dashC = cmd.match(/git\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
181 const cd = cmd.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|\S+)/)
182 const raw = (dashC?.[1] ?? cd?.[1])?.replace(/^["']|["']$/g, '')
183 const base = me.cwd || (await $.session.cwd())
184 const dir = raw ? (raw.startsWith('/') ? raw : `${base}/${raw}`).replace('~', '/Users') : base
185 return toplevel($, dir)
186 }
187 return null
188}
189
190function describe(e: { tool: string } & Record<string, unknown>): string {
191 if (e.tool === 'Bash' && typeof e.command === 'string') return `$ ${short(e.command, 48)}`
192 const fp = (e.file_path ?? e.notebook_path) as string | undefined
193 if (typeof fp === 'string') return `${e.tool} ${fp.split('/').pop()}`
194 if (e.tool.startsWith('mcp__')) return e.tool.split('__').pop() ?? e.tool
195 return e.tool
196}
197
198async function refreshRepo($: Engine) {
199 const now = await $.clock.now()
200 if (now - lastRepoRefresh < 120_000) return
201 lastRepoRefresh = now
202 // The worktree touched most recently, else the session's own directory
203 const trees = Object.entries(me.claims).sort((a, b) => b[1].lastTouch - a[1].lastTouch)
204 const dir = trees[0]?.[0] ?? (await toplevel($, me.cwd))
205 if (!dir) {
206 me.branch = null
207 me.pr = null
208 return
209 }
210 try {
211 const b = await $.process.run(['git', '-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], {
212 timeoutMs: 5000,
213 })
214 me.branch = b.exitCode === 0 ? b.stdout.trim() : null
215 if (me.branch && !['main', 'master', 'HEAD'].includes(me.branch)) {
216 const pr = await $.process.run(
217 ['gh', 'pr', 'view', '--json', 'number,state', '-q', '"#" + (.number|tostring) + " " + .state'],
218 { cwd: dir, timeoutMs: 10_000 },
219 )
220 me.pr = pr.exitCode === 0 ? pr.stdout.trim().toLowerCase().replace(/ open$/, '') : null
221 } else {
222 me.pr = null
223 }
224 } catch {
225 // git or gh unavailable: keep what we had
226 }
227 await save($, true)
228}
229
230async function setPhase($: Engine, phase: Phase, why: string | null = null) {
231 const now = await $.clock.now()
232 me.phase = phase
233 me.waitingWhy = phase === 'waiting' ? why : null
234 me.waitingSince = phase === 'waiting' ? (me.waitingSince ?? now) : null
235 await save($, true)
236 $.ui.invalidate('ui.render')
237}
238
239// Toast when another session starts waiting; redraw the pane and band.
240async function tick($: Engine) {
241 const now = await $.clock.now()
242 for (const r of await all($)) {
243 if (r.id === me.id || !isAlive(r, now) || r.phase !== 'waiting' || !r.waitingSince) continue
244 if (toasted.get(r.id) === r.waitingSince) continue
245 toasted.set(r.id, r.waitingSince)
246 $.ui.toast(`Needs you: ${r.task || r.id.slice(0, 8)} (${r.waitingWhy ?? 'waiting'})`, {
247 timeoutMs: 8000,
248 })
249 }
250 $.ui.invalidate('ui.render')
251}
252
253// Show another desktop session: the app's own deep link, as a sidebar click
254async function switchTo($: Engine, r: Rec) {
255 if (!r.hostId || !/^[\w-]+$/.test(r.hostId)) return
256 try {
257 const res = await $.process.run(['open', `claude://claude.ai/epitaxy/${r.hostId}`], { timeoutMs: 5000 })
258 if (res.exitCode !== 0) $.ui.toast(`Could not switch to ${r.task || r.id.slice(0, 8)}`)
259 } catch {
260 $.ui.toast(`Could not switch to ${r.task || r.id.slice(0, 8)}`)
261 }
262}
263
264export const register: Register = (on, options) => {
265 pillRules = parseRules(typeof options.projectPills === 'string' ? options.projectPills : '')
266
267 on('session.start', async ($, e, next) => {
268 me.id = await $.session.id()
269 me.cwd = await $.session.cwd()
270 const prior = (await $.store.get(PREFIX + me.id)) as Rec | undefined
271 if (prior) Object.assign(me, prior, { phase: 'idle', waitingWhy: null, waitingSince: null })
272 if (!me.task) {
273 try {
274 const first = (await $.session.messages()).find(
275 m => m.role === 'user' && m.text && !m.text.startsWith('<'),
276 )
277 if (first) me.task = short(first.text, 70)
278 } catch {
279 // no transcript yet
280 }
281 }
282 me.lastAt = me.lastAt || (await $.clock.now())
283 try {
284 const env = await $.process.run(['printenv', 'CLAUDE_CODE_HOST_SESSION_ID'], { timeoutMs: 3000 })
285 me.hostId = env.exitCode === 0 && env.stdout.trim() ? env.stdout.trim() : null
286 } catch {
287 me.hostId = null
288 }
289 await save($, true)
290 void refreshRepo($)
291
292 await $.command.register({
293 name: 'fleet',
294 description: 'Show every live Claude Code session in a pane',
295 })
296
297 for (const t of timers) t.cancel()
298 timers = [
299 $.clock.every(BEAT_MS, () => void save($, true)),
300 $.clock.every(REDRAW_MS, () => void tick($)),
301 ]
302 return next(e)
303 })
304
305 on('session.end', async ($, e, next) => {
306 for (const t of timers) t.cancel()
307 timers = []
308 if (me.id) await $.store.delete(PREFIX + me.id)
309 return next(e)
310 })
311
312 on('command.run', { command: 'fleet' }, async $ => {
313 await $.ui.open({ id: PANE, title: 'Session fleet' })
314 paneOpen = true
315 return { text: 'Session fleet pane opened.' }
316 })
317
318 on('ui.close', async ($, e, next) => {
319 if (e.id === PANE) paneOpen = false
320 return next(e)
321 })
322
323 on('prompt.submit', async ($, e, next) => {
324 const human = !e.origin || HUMAN.has(e.origin.kind)
325 if (human && !me.task && !e.text.startsWith('<')) me.task = short(e.text, 70)
326 await setPhase($, 'working')
327 return next(e)
328 })
329
330 on('classic.PermissionRequest', async ($, e, next) => {
331 await setPhase($, 'waiting', `permission: ${short(e.tool_name, 30)}`)
332 return next(e)
333 })
334
335 on('tool.call', async ($, e, next) => {
336 if (e.agentId !== undefined) return next(e)
337 const call = e as unknown as { tool: string } & Record<string, unknown>
338 const now = await $.clock.now()
339 me.last = describe(call)
340 me.lastAt = now
341
342 // Any shell command run inside a worktree counts as working there (no guard)
343 if (call.tool === 'Bash' && !(await target($, call))) {
344 const here = await toplevel($, me.cwd || (await $.session.cwd()))
345 if (here) me.claims[here] = { since: me.claims[here]?.since ?? now, lastTouch: now }
346 }
347
348 // Worktree claim guard
349 const tree = await target($, call)
350 if (tree) {
351 const mine = me.claims[tree]
352 const rivals = (await all($)).filter(
353 r =>
354 r.id !== me.id &&
355 isAlive(r, now) &&
356 r.claims[tree] &&
357 now - r.claims[tree].lastTouch < CLAIM_MS &&
358 (!mine || r.claims[tree].since < mine.since),
359 )
360 if (rivals.length > 0 && !allowed.has(tree)) {
361 const r = rivals[0]!
362 const rc = r.claims[tree]!
363 const who = `"${r.task || r.id.slice(0, 8)}"${r.branch ? ` on ${r.branch}` : ''}`
364 await setPhase($, 'waiting', `worktree clash: ${shortTree(tree)}`)
365 let answer = 'Stop'
366 try {
367 answer = await $.ui.ask(
368 `Session ${who} claimed ${shortTree(tree)} ${ago(now, rc.since)} ago (last: ${r.last}, ${ago(now, r.lastAt)} ago). Write there anyway?`,
369 {
370 header: 'Worktree',
371 options: ['Proceed for this session', 'Stop'],
372 },
373 )
374 } catch {
375 // dismissed, or nobody to ask: allow headless runs, stop otherwise
376 answer = (await $.session.surfaces()).length === 0 ? 'Proceed for this session' : 'Stop'
377 }
378 await setPhase($, 'working')
379 if (answer !== 'Proceed for this session') {
380 return {
381 deny: `session-fleet: another live session (${who}, last action "${r.last}") is working in ${tree}. Ask the user before writing there, or use a separate worktree.`,
382 }
383 }
384 allowed.add(tree)
385 }
386 me.claims[tree] = { since: mine?.since ?? now, lastTouch: now }
387 }
388 // Release claims nobody has touched for a while
389 for (const [k, c] of Object.entries(me.claims)) if (now - c.lastTouch > CLAIM_MS) delete me.claims[k]
390
391 const asking = e.tool === 'AskUserQuestion'
392 if (asking) await setPhase($, 'waiting', 'question')
393 else await save($)
394
395 const ran = await next(e)
396 if (asking || me.phase === 'waiting') await setPhase($, 'working')
397 return ran
398 })
399
400 on('turn.complete', async ($, e, next) => {
401 if (e.agentId !== undefined) return next(e)
402 const tail = e.answer.trim().slice(-400)
403 const question = /\?\s*(\*\*)?\s*$/.test(tail) || /\?\s*\n[^\n]{0,80}$/.test(tail)
404 if (question) {
405 const q = tail.split(/(?<=[.!\n])\s+/).filter(s => s.includes('?')).pop() ?? 'question'
406 await setPhase($, 'waiting', `asked: ${short(q, 60)}`)
407 } else {
408 await setPhase($, 'idle')
409 }
410 void refreshRepo($)
411 return next(e)
412 })
413
414 // 2. Needs-you beacon above the prompt
415 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416 const below = await next(e)
417 if (e.props.hasSurvey) return below
418 const now = await $.clock.now()
419 const waiting = (await all($)).filter(
420 r => r.id !== me.id && isAlive(r, now) && r.phase === 'waiting',
421 )
422 if (waiting.length === 0) return below
423 const { Box, Text, Button } = $.ui.resolve(e)
424 const width = Math.max(30, (e.props.bodyColumns ?? 80) - 20)
425 return (
426 <Box flexDirection="column">
427 {waiting.slice(0, 3).map(r => (
428 <Box key={r.id}>
429 <Text color="yellow" bold>
430 ⚑ Needs you{' '}
431 </Text>
432 <Text>
433 {short(
434 `${r.task || r.id.slice(0, 8)} · ${r.waitingWhy ?? 'waiting'} · ${ago(now, r.waitingSince ?? r.lastAt)}`,
435 width,
436 )}
437 </Text>
438 </Box>
439 ))}
440 <Box>
441 {waiting.length > 3 && <Text dimColor>+{waiting.length - 3} more </Text>}
442 {!paneOpen && (
443 <Button
444 key="open-fleet"
445 label="Open fleet"
446 plain
447 dimColor
448 onPress={async () => {
449 await $.ui.open({ id: PANE, title: 'Session fleet' })
450 paneOpen = true
451 }}
452 />
453 )}
454 </Box>
455 {below}
456 </Box>
457 )
458 })
459
460 // 1. Fleet board pane
461 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
462 const { Box, Text, Button } = $.ui.resolve(e)
463 const now = await $.clock.now()
464 const order: Record<Phase, number> = { waiting: 0, working: 1, idle: 2 }
465 const live = (await all($))
466 .filter(r => isAlive(r, now))
467 .sort((a, b) => order[a.phase] - order[b.phase] || b.lastAt - a.lastAt)
468 const cols = Math.max(30, e.props.bodyColumns ?? 60)
469 const dot: Record<Phase, string> = { waiting: '⚑', working: '●', idle: '○' }
470 const color: Record<Phase, string> = { waiting: 'yellow', working: 'green', idle: 'gray' }
471
472 // Worktrees held by more than one live session
473 const holders = new Map<string, number>()
474 for (const r of live)
475 for (const [t, c] of Object.entries(r.claims))
476 if (now - c.lastTouch < CLAIM_MS) holders.set(t, (holders.get(t) ?? 0) + 1)
477
478 return (
479 <Box flexDirection="column" gap={1}>
480 <Text dimColor>
481 {live.length} live · {live.filter(r => r.phase === 'waiting').length} waiting on you
482 </Text>
483 {live.map(r => {
484 const trees = Object.entries(r.claims)
485 .filter(([, c]) => now - c.lastTouch < CLAIM_MS)
486 .map(([t]) => (holders.get(t)! > 1 ? `⚠ ${shortTree(t)}` : shortTree(t)))
487 const where = trees.length ? trees.join(', ') : home(r.cwd)
488 return (
489 <Box key={r.id} flexDirection="column">
490 <Box gap={1}>
491 <Text color={color[r.phase]}>{dot[r.phase]}</Text>
492 {(() => {
493 const p = pill(r, now)
494 return p ? <Text color={p.fg} backgroundColor={p.bg} bold>{` ${p.label} `}</Text> : null
495 })()}
496 {r.hostId && r.id !== me.id ? (
497 <Button
498 key={`go-${r.id}`}
499 label={short(r.task || '(no prompt yet)', cols - 24)}
500 plain
501 onPress={() => void switchTo($, r)}
502 />
503 ) : (
504 <Text bold>{short(r.task || '(no prompt yet)', cols - 24)}</Text>
505 )}
506 {r.id === me.id && <Text dimColor>(this)</Text>}
507 </Box>
508 {r.phase === 'waiting' && (
509 <Text color="yellow"> {short(`${r.waitingWhy ?? 'waiting'} · ${ago(now, r.waitingSince ?? r.lastAt)}`, cols - 2)}</Text>
510 )}
511 <Text dimColor> {short(where, cols - 2)}</Text>
512 <Text dimColor>
513 {' '}
514 {short([r.branch, r.pr].filter(Boolean).join(' · ') || 'no branch', cols - 2)}
515 </Text>
516 <Text dimColor> {short(`${r.last} · ${ago(now, r.lastAt)} ago`, cols - 2)}</Text>
517 </Box>
518 )
519 })}
520 </Box>
521 )
522 })
523}
524