SLOPSHOPPER

session-fleet

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-fleet
│ ┃ Session fleet ✕ › fix the failing auth test and add an audit log call │ ┃ 1 live · 0 waiting on you │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ app fix the failing auth test (this) ⎿ Read 6 lines │ ┃ /work/app ⏺ Update(src/auth.ts) │ ┃ no branch ⎿ Added 2 lines, removed 1 line │ ┃ $ cat .env · 0s ago ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ session-fleet: Session fleet pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session fleet
1 live · 0 waiting on you ○ app fix the failing auth test (this) /work/app no branch $ cat .env · 0s ago
README

Claude Code mods for running a lot of sessions at once

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

The session-fleet pane listing two live sessions, one waiting on a permission prompt

The prompt

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.

The five mods

ModWhat it gives youCommands
session-fleetA 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-trackerA 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-hygieneConfigurable 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-activityA ledger of everything that left the machine, a hold on database writes, and what each session is waiting on/ledger, /waiting
context-gaugeContext 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.

Install

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.

From the marketplace

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.

From a clone

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.

What each one does

session-fleet

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 ⚠.
  • Switch by clicking. In the desktop app, click another session's title in the pane to jump to it, the same as clicking it in the sidebar. Terminal sessions show their title as plain text.
  • Project pills. Each session gets a pill naming its repo. To name and color your own projects, set 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" }
    }
  }
  • Needs-you row. When another session is blocked on you, a row shows above your prompt with the reason and how long it has waited, plus a toast. "Blocked" means a permission prompt, a question dialog, or a turn that ended with a question mark. That last one is a guess.
  • Worktree guard. The first session to edit a file or run a mutating git command (commit, checkout, rebase, push…) in a worktree claims it. A second live session that tries gets a Proceed or Stop question naming the other session, its branch and last action. Claims let go after 30 minutes idle.

The needs-you row above the prompt The worktree guard question

ship-tracker

  • Picks up PRs from gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.
  • Every 45 seconds it reads the PR state and checks from GitHub. After the merge, it finds the GitHub deployment for the merge commit in the Production environment. Live means that deployment succeeded.
  • Merged for 15 minutes with no production deployment? The row says no prod deploy!. That's the silent Vercel git-trigger failure that got me before.
  • A receipt line after every answer: duration, tool calls, files edited, shell commands, outward actions (push, PR, MCP writes) and context fill.

Two PRs in the ship band

session-hygiene

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.

OptionDefaultWhat it does
holdEnvSourcingonAsks Run it or Stop before a command sources a .env file into the shell
mutationCheckTestsonWhen Claude writes a new test file, reminds it to prove the test fails without the code it covers
reviewBeforePushoffFlags a git push with edits made since the last local /code-review
uncheckedReposnonePath 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
middlewareRouteReposnoneNext.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it
bannedStringsnoneCase-insensitive text that should never be written into a file
supabaseoffAsks 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.

A tripwire, and Claude responding to it The handoff card in a new session

session-activity

  • /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.
  • Database write hold. Any 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.
  • Waiting-on. The spinner shows pending background work (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.

The ledger pane

context-gauge

  • The status line shows ctx 62% · 124k/200k · compacted 1×.
  • Toasts at 70% and 85%, and from 80% a row above the prompt with a Compact now button.
  • Before any compaction it snapshots what's in flight (task, latest request, files edited, PR links, last actions), asks the summarizer to keep it, logs it, and hands it back to Claude with your next prompt.
  • /context-log lists every compaction this session with its snapshot.

The context log after a compaction

Privacy

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

Known issues

  • Tested in the terminal. The desktop app's Code tab should draw the same panes and rows, but I haven't seen it yet.
  • In one long session, /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.
  • The mods API is still early access, so an update to Claude Code can break things here.
  • The needs-you "ended with a question" check is a heuristic and will be wrong sometimes.

Who made this

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.

Source 1 files
hooks/register.tsx 524 lines
1import 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