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

hooks/register.tsx 133 lines1import { 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}
133types/index.d.ts 8 lines1export 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