SLOPSHOPPER

fleet

One pane for this session's subagents, background tasks, bgrun jobs and live lanes

newpanecommandprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-04markusstrasser/agent-infra/mods/fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ 0 live · 0 finished · refreshed 08:53:20Z │ ┃ Nothing running. ⏺ Read(src/auth.ts) │ ┃ [ refresh ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ ⎿ fleet: Fleet pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
0 live · 0 finished · refreshed 08:53:20Z Nothing running. [ refresh ]
Source 2 files
hooks/register.tsx 133 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Row } from '../types'
5
6// Replaces pgrep + log peeks: subagents ($.agent.list), the background tasks
7// the last Stop event reported, bgrun jobs (<TMPDIR>/bgrun: .pid/.done
8// markers) and lanes that are not DONE (`lane ls`). Polls only while open.
9
10const rows = atom({ plugin: 'fleet', key: 'rows' } as const, [])
11const tasks = atom({ plugin: 'fleet', key: 'tasks' } as const, [])
12const refreshedAt = atom({ plugin: 'fleet', key: 'refreshedAt' } as const, 0)
13const error = atom({ plugin: 'fleet', key: 'error' } as const, '')
14
15const PANE = 'fleet'
16const POLL_MS = 15_000
17const LIVE_AGENT = new Set(['pending', 'running', 'waiting'])
18
19type $ = EngineInterface
20
21const age = (ms: number) => (ms < 120_000 ? `${Math.round(ms / 1000)}s` : ms < 7_200_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`)
22
23export function parseLanes(out: string): Row[] {
24  return out
25    .split('\n')
26    .slice(1)
27    .map(l => l.trim().split(/\s+/))
28    .filter(f => f.length >= 2 && f[0] !== '' && !(f[1] ?? '').startsWith('DONE'))
29    .map(f => ({ kind: 'lane' as const, name: f[0]!, state: f[1]!, detail: `log ${f[2] ?? '?'}`, isLive: true }))
30}
31
32async function bgrunRows($: $, now: number): Promise<Row[]> {
33  const dir = (await $.env.get('BGRUN_DIR')) ?? `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')}/bgrun`
34  if (!(await $.fs.exists(dir))) return []
35  const entries = await $.fs.list(dir)
36  const names = entries.filter(f => f.name.endsWith('.pid')).map(f => f.name.slice(0, -4))
37  const out: Row[] = []
38  for (const name of names) {
39    const done = entries.find(f => f.name === `${name}.done`)
40    const log = entries.find(f => f.name === `${name}.log`)
41    const rc = done ? (await $.fs.read(`${dir}/${name}.done`)).trim() : ''
42    if (done && now - done.mtimeMs > 6 * 3_600_000) continue
43    out.push({
44      kind: 'bgrun',
45      name,
46      state: done ? `rc ${rc}` : 'running',
47      detail: log ? `log quiet ${age(now - log.mtimeMs)}` : 'no log',
48      isLive: !done,
49    })
50  }
51  return out
52}
53
54async function refresh($: $): Promise<void> {
55  const now = await $.clock.now()
56  const problems: string[] = []
57  const agents: Row[] = (await $.agent.list()).map(a => ({
58    kind: 'agent',
59    name: a.name ?? a.type,
60    state: a.status,
61    detail: a.description.slice(0, 80),
62    isLive: LIVE_AGENT.has(a.status),
63  }))
64  const bg = await bgrunRows($, now).catch(err => (problems.push(`bgrun: ${String(err).slice(0, 80)}`), [] as Row[]))
65  const home = (await $.env.get('HOME')) ?? '/Users/alien'
66  const lane = await $.process.run([`${home}/Projects/skills/bin/lane`, 'ls'], { timeoutMs: 20_000 })
67  if (lane.exitCode !== 0) problems.push(`lane ls exit ${lane.exitCode}`)
68  await update($, rows, () => [...agents, ...bg, ...(lane.exitCode === 0 ? parseLanes(lane.stdout) : [])])
69  await update($, refreshedAt, () => now)
70  await update($, error, () => problems.join(' · '))
71}
72
73let timer: { cancel: () => void } | null = null
74
75export const register: Register = on => {
76  on('session.start', async ($, e, next) => {
77    await $.command.register({ name: 'fleet', description: 'Pane: subagents, background tasks, bgrun jobs, live lanes' })
78    return next(e)
79  })
80
81  on('command.run', { command: 'fleet' }, async $ => {
82    await refresh($)
83    await $.ui.open({ id: PANE, title: 'Fleet' })
84    timer?.cancel()
85    timer = $.clock.every(POLL_MS, () => void refresh($))
86    return { text: 'Fleet pane opened.' }
87  })
88
89  on('ui.close', { id: PANE }, async ($, e, next) => {
90    timer?.cancel()
91    timer = null
92    return next(e)
93  })
94
95  on('classic.Stop', async ($, e, next) => {
96    const list = (e.background_tasks ?? []).map(t => ({
97      kind: 'task' as const,
98      name: t.name ?? t.agent_type ?? t.type,
99      state: t.status,
100      detail: (t.description || t.command || '').slice(0, 80),
101      isLive: true,
102    }))
103    await update($, tasks, () => list)
104    return next(e)
105  })
106
107  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108    const { Box, Button, Text } = $.ui.resolve(e)
109    const all = [...(await read($, rows)), ...(await read($, tasks))]
110    const err = await read($, error)
111    const at = await read($, refreshedAt)
112    const live = all.filter(r => r.isLive)
113    const quiet = all.filter(r => !r.isLive)
114    const line = (r: Row) => (
115      <Text key={`${r.kind}-${r.name}`} dimColor={!r.isLive}>
116        {r.kind.padEnd(6)} {r.name.slice(0, 28).padEnd(28)} {r.state.padEnd(10)} {r.detail}
117      </Text>
118    )
119    return (
120      <Box flexDirection="column">
121        {err !== '' && <Text color="warning">[DEGRADED] {err}</Text>}
122        <Text dimColor>
123          {live.length} live · {quiet.length} finished · refreshed {at ? new Date(at).toISOString().slice(11, 19) : 'never'}Z
124        </Text>
125        {all.length === 0 && <Text dimColor>Nothing running.</Text>}
126        {live.map(line)}
127        {quiet.slice(-12).map(line)}
128        <Button key="refresh" label="refresh" onPress={() => void refresh($)} />
129      </Box>
130    )
131  })
132}
133
types/index.d.ts 8 lines
1export type Row = { kind: 'agent' | 'task' | 'bgrun' | 'lane'; name: string; state: string; detail: string; isLive: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    fleet: { rows: Row[]; tasks: Row[]; refreshedAt: number; error: string }
6  }
7}
8