Read-only fleet status band above the prompt (seat, context %, Room unread, pending job results, needs-you, held seats) plus /fires, /pending and /held…

A read-only status line above the Claude Code prompt, plus three slash commands that answer with no model turn.
main · ctx 42% · room 3 unread · results 0 pending · needs-you 2 · held: alpha
The line is dim, capped at 100 columns, and drops fields from the right when the terminal is narrower. It refreshes on a 60 s timer and at the end of each turn, never more often. If the snapshot is older than three refresh periods, the line ends with (stale).
/plugin install fleet-band --marketplace wrg32786/aigent-os
Answer y to add the marketplace, then pick a scope. A stock install shows only the seat and context %. Every other field stays off until its setting is filled in, in /config or under pluginConfigs.fleet-band in settings.
| Setting | Turns on | Example |
|---|---|---|
installRoot | results pending, /fires | C:/example/aigent |
roomRoot | room unread; held (with watchedSeats) | C:/example/agent-room/room |
eodPath | needs-you | C:/example/cockpit/eod/latest.json |
watchedSeats | held (with roomRoot) | alpha, beta |
showSummaries | each result's summary in /fires | false (default) |
An empty setting means the field is skipped and its command says "not configured". A configured source that cannot be read shows as ?.
| Field | Source |
|---|---|
| seat | env AIGENT_SEAT or SEAT, else the install folder's name with a trailing -vault dropped (an install at beta-vault is seat beta). Lowercased, and it must match [a-z0-9_-]+, else seat. |
| ctx % | $.session.usage().context.percent, the engine's own figure, as the status line shows it |
| room unread | count of *.json files directly in <roomRoot>/inbox/<seat>/ |
| results pending | node <installRoot>/daemons/job-results.mjs pending, run with JOB_RESULTS_ROOT=<installRoot>. The script's existence is checked once per session; when it is missing, nothing is ever spawned. |
| needs-you | length of needs_you[] in <eodPath> |
| held | the last 50 lines of <roomRoot>/state/<seat>.supervisor.log for each watched seat. A seat is held when its newest [refresh-cycle] HOLD line has no later [refresh-cycle] … released line. |
Next fire is not shown. No cheap local source has the next scheduled fire.
Large logs read as ?. $.fs.read refuses files over 4 MiB and has no byte-range read. A supervisor log over that size is reported as ?, with the reason log-over-read-cap. A rotated log or a small per-seat hold-state file fixes this.
/fires: each pending job result's job and time. Its summary is shown only with showSummaries./pending: /fires plus the EOD needs-you titles./held: each watched seat as HELD, clear or ?, with a reason class (hold-without-release, released, no-hold-in-tail, log-over-read-cap, log-unreadable). Raw log lines and error text are never printed.Each command refreshes the band and returns { text } with no model turn. The output still enters the session transcript, so the model reads it on its next turn.
$.permission calls and no tool-approval hooks.$.state (fleet-band.snapshot)./drain: draining the Room needs the room_drain MCP tool and the seat's own judgment, so it is out of scope for a read-only mod.claude plugin validate <this folder>
claude plugin test <this folder>
claude --plugin-dir <this folder>hooks/register.tsx 304 lines1// fleet-band: a read-only status band above the prompt plus /fires, /pending
2// and /held, which answer with no model turn. Fences: no writes outside
3// $.state, no network, no permission calls, nothing that submits a prompt,
4// and one host process only (`node <installRoot>/daemons/job-results.mjs
5// pending`), never started unless that script exists. Every source comes from
6// userConfig: an empty one is skipped, never guessed. Every read is
7// try/catch: a configured source that cannot be read draws as "?".
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
10
11import type { FleetBandHeld, FleetBandPending, FleetBandSnapshot } from '../types'
12
13const snapshot = atom({ plugin: 'fleet-band', key: 'snapshot' } as const, null)
14
15const REFRESH_MS = 60_000
16const STALE_MS = 3 * REFRESH_MS
17// $.fs.read rejects files over 4 MiB; stat first so the reason is ours to say.
18const FS_READ_CAP = 4 * 1024 * 1024
19const TAIL_LINES = 50
20const SEAT_NAME = /^[a-z0-9_-]+$/
21
22type $ = EngineInterface
23
24type Config = {
25 installRoot: string
26 roomRoot: string
27 eodPath: string
28 watchedSeats: string[]
29 showSummaries: boolean
30}
31
32const path = (value: unknown) => (typeof value === 'string' ? value.trim().replace(/[\\/]+$/, '') : '')
33
34function readConfig(options: PluginOptions): Config {
35 const seats = options.watchedSeats
36 const list = Array.isArray(seats) ? seats : typeof seats === 'string' ? seats.split(',') : []
37 return {
38 installRoot: path(options.installRoot),
39 roomRoot: path(options.roomRoot),
40 eodPath: path(options.eodPath),
41 watchedSeats: list.map(one => String(one).trim().toLowerCase()).filter(one => SEAT_NAME.test(one)),
42 showSummaries: options.showSummaries === true,
43 }
44}
45
46// Module state, reset by a hot reload along with the environment.
47let config: Config = readConfig({})
48// The job-results script is checked once and remembered: no spawn ever
49// happens unless installRoot is set and the script is there.
50let jobScript: 'unchecked' | 'present' | 'absent' = 'unchecked'
51let timer: Timer | undefined
52let lastTickAt = 0
53
54const scriptPath = () => `${config.installRoot}/daemons/job-results.mjs`
55
56async function hasJobScript($: $): Promise<boolean> {
57 if (jobScript === 'unchecked') {
58 if (!config.installRoot) {
59 jobScript = 'absent'
60 } else {
61 try {
62 jobScript = (await $.fs.stat(scriptPath())).kind === 'file' ? 'present' : 'absent'
63 } catch {
64 jobScript = 'absent'
65 }
66 }
67 }
68 return jobScript === 'present'
69}
70
71// Seat name: AIGENT_SEAT or SEAT when set, else the install folder's basename
72// with a trailing "-vault" dropped (a seat whose install lives at <seat>-vault
73// is still <seat>), lowercased and validated.
74async function seatName($: $): Promise<string> {
75 try {
76 const fromEnv = ((await $.env.get('AIGENT_SEAT')) || (await $.env.get('SEAT')))?.trim().toLowerCase()
77 if (fromEnv) return SEAT_NAME.test(fromEnv) ? fromEnv : 'seat'
78 } catch {}
79 try {
80 const base = (await $.session.root()).split(/[\\/]/).filter(Boolean).pop()?.toLowerCase().replace(/-vault$/, '')
81 if (base && SEAT_NAME.test(base)) return base
82 } catch {}
83 return 'seat'
84}
85
86async function ctxPercent($: $): Promise<number | null> {
87 try {
88 const { context } = await $.session.usage()
89 return context.percent ?? null
90 } catch {
91 return null
92 }
93}
94
95async function roomUnread($: $, seat: string): Promise<number | null> {
96 try {
97 const entries = await $.fs.list(`${config.roomRoot}/inbox/${seat}`)
98 return entries.filter(one => one.kind === 'file' && one.name.endsWith('.json')).length
99 } catch {
100 return null
101 }
102}
103
104async function pendingResults($: $): Promise<FleetBandPending[] | null> {
105 try {
106 const ran = await $.process.run(['node', scriptPath(), 'pending'], {
107 cwd: config.installRoot,
108 env: { JOB_RESULTS_ROOT: config.installRoot },
109 timeoutMs: 20_000,
110 })
111 if (ran.exitCode !== 0) return null
112 const rows: unknown = JSON.parse(ran.stdout)
113 if (!Array.isArray(rows)) return null
114 return rows.map(row => ({
115 id: String(row?.id ?? ''),
116 job: String(row?.job ?? ''),
117 summary: String(row?.summary ?? ''),
118 producedAt: String(row?.produced_at ?? ''),
119 }))
120 } catch {
121 return null
122 }
123}
124
125async function needsYou($: $): Promise<string[] | null> {
126 try {
127 const eod = JSON.parse(await $.fs.read(config.eodPath))
128 if (!Array.isArray(eod?.needs_you)) return null
129 return eod.needs_you.map((row: { title?: unknown }) => String(row?.title ?? '(untitled)'))
130 } catch {
131 return null
132 }
133}
134
135async function heldSeat($: $, seat: string): Promise<FleetBandHeld> {
136 const log = `${config.roomRoot}/state/${seat}.supervisor.log`
137 try {
138 if ((await $.fs.stat(log)).size > FS_READ_CAP) return { seat, state: 'unknown', reason: 'log-over-read-cap' }
139 const lines = (await $.fs.read(log)).split(/\r?\n/).filter(Boolean).slice(-TAIL_LINES)
140 let hold = -1
141 let released = -1
142 lines.forEach((line, i) => {
143 if (/\[refresh-cycle\] HOLD\b/.test(line)) hold = i
144 if (/\[refresh-cycle\] .*\breleased\b/.test(line)) released = i
145 })
146 if (hold < 0) return { seat, state: 'clear', reason: 'no-hold-in-tail' }
147 return released > hold
148 ? { seat, state: 'clear', reason: 'released' }
149 : { seat, state: 'held', reason: 'hold-without-release' }
150 } catch {
151 return { seat, state: 'unknown', reason: 'log-unreadable' }
152 }
153}
154
155async function collect($: $): Promise<FleetBandSnapshot> {
156 const seat = await seatName($)
157 const s: FleetBandSnapshot = { at: await $.clock.now(), seat, ctxPercent: await ctxPercent($) }
158 if (config.roomRoot) s.roomUnread = await roomUnread($, seat)
159 if (await hasJobScript($)) s.pending = await pendingResults($)
160 if (config.eodPath) s.needsYou = await needsYou($)
161 if (config.roomRoot && config.watchedSeats.length > 0) {
162 s.held = await Promise.all(config.watchedSeats.map(one => heldSeat($, one)))
163 }
164 return s
165}
166
167async function refresh($: $): Promise<FleetBandSnapshot | null> {
168 try {
169 const next = await collect($)
170 await update($, snapshot, () => next)
171 return next
172 } catch {
173 return null
174 }
175}
176
177// Starts the 60 s timer, or restarts it when it threw or stopped ticking
178// (a refused period ends an interval without saying so).
179async function ensureTicking($: $): Promise<void> {
180 try {
181 const now = await $.clock.now()
182 if (timer && now - lastTickAt <= STALE_MS) return
183 timer?.cancel()
184 lastTickAt = now
185 timer = $.clock.every(REFRESH_MS, () => {
186 void $.clock.now().then(now => {
187 lastTickAt = now
188 })
189 void refresh($)
190 })
191 } catch {
192 timer = undefined
193 }
194}
195
196const shown = (n: number | null | undefined) => (n === null || n === undefined ? '?' : String(n))
197
198// Fields left to right; only configured ones appear. The band drops from the
199// right until it fits.
200export function bandFields(s: FleetBandSnapshot): string[] {
201 const fields = [s.seat, `ctx ${s.ctxPercent === null ? '?' : `${s.ctxPercent}%`}`]
202 if ('roomUnread' in s) fields.push(`room ${shown(s.roomUnread)} unread`)
203 if ('pending' in s) fields.push(`results ${shown(s.pending?.length)} pending`)
204 if ('needsYou' in s) fields.push(`needs-you ${shown(s.needsYou?.length)}`)
205 if (s.held) {
206 const names = s.held.filter(one => one.state === 'held').map(one => one.seat)
207 const isAnyUnknown = s.held.some(one => one.state === 'unknown')
208 const unknown = isAnyUnknown ? '?' : ''
209 fields.push(`held: ${names.length > 0 ? [...names, unknown].filter(Boolean).join(',') : unknown || 'none'}`)
210 }
211 return fields
212}
213
214export function fitBand(fields: string[], columns: number, suffix = ''): string {
215 const kept = [...fields]
216 while (kept.length > 1 && kept.join(' · ').length + suffix.length > columns) kept.pop()
217 return kept.join(' · ') + suffix
218}
219
220function firesText(s: FleetBandSnapshot | null): string {
221 if (jobScript !== 'present') return 'Pending job results: not configured (installRoot unset or its job-results script is missing)'
222 if (!s?.pending) return 'Pending job results: ? (the job-results script did not answer)'
223 if (s.pending.length === 0) return 'Pending job results: none'
224 return [
225 `Pending job results: ${s.pending.length}`,
226 ...s.pending.map(one => `- ${one.job} ${one.producedAt}${config.showSummaries ? ` ${one.summary}` : ''}`),
227 ].join('\n')
228}
229
230function needsText(s: FleetBandSnapshot | null): string {
231 if (!config.eodPath) return 'Needs you: not configured (eodPath unset)'
232 if (!s?.needsYou) return 'Needs you: ? (the EOD file could not be read)'
233 if (s.needsYou.length === 0) return 'Needs you: none'
234 return [`Needs you: ${s.needsYou.length}`, ...s.needsYou.map(title => `- ${title}`)].join('\n')
235}
236
237function heldText(s: FleetBandSnapshot | null): string {
238 if (!config.roomRoot || config.watchedSeats.length === 0) {
239 return 'Held seats: not configured (roomRoot or watchedSeats unset)'
240 }
241 if (!s?.held) return 'Held seats: ? (refresh failed)'
242 const word = { held: 'HELD', clear: 'clear', unknown: '?' } as const
243 return ['Held seats:', ...s.held.map(one => `- ${one.seat}: ${word[one.state]} (${one.reason})`)].join('\n')
244}
245
246const COMMANDS = [
247 { name: 'fires', description: 'fleet-band: list pending job results (no model turn)' },
248 { name: 'pending', description: 'fleet-band: pending job results plus the EOD needs-you titles (no model turn)' },
249 { name: 'held', description: 'fleet-band: held state of each watched seat (no model turn)' },
250]
251
252async function registerCommands($: $): Promise<void> {
253 for (const command of COMMANDS) {
254 try {
255 await $.command.register(command)
256 } catch {}
257 }
258}
259
260export const register: Register = (on, options) => {
261 config = readConfig(options)
262
263 on('session.start', async ($, e, next) => {
264 await registerCommands($)
265 await ensureTicking($)
266 void refresh($)
267 return next(e)
268 })
269
270 on('turn.complete', async ($, e, next) => {
271 await ensureTicking($)
272 void refresh($)
273 return next(e)
274 })
275
276 on('command.run', { command: 'fires' }, async $ => {
277 await ensureTicking($)
278 return { text: firesText(await refresh($)) }
279 }).catch(() => ({ text: 'fleet-band: /fires could not read its sources' }))
280
281 on('command.run', { command: 'pending' }, async $ => {
282 await ensureTicking($)
283 const s = await refresh($)
284 return { text: `${firesText(s)}\n\n${needsText(s)}` }
285 }).catch(() => ({ text: 'fleet-band: /pending could not read its sources' }))
286
287 on('command.run', { command: 'held' }, async $ => {
288 await ensureTicking($)
289 return { text: heldText(await refresh($)) }
290 }).catch(() => ({ text: 'fleet-band: /held could not read its sources' }))
291
292 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
293 const s = await read($, snapshot)
294 if (e.props.hasSurvey || s === null) return next(e)
295 const { Text } = $.ui.resolve(e)
296 const isStale = (await $.clock.now()) - s.at > STALE_MS
297 return (
298 <Text dimColor wrap="truncate">
299 {fitBand(bandFields(s), Math.min(100, e.props.bodyColumns), isStale ? ' (stale)' : '')}
300 </Text>
301 )
302 })
303}
304types/index.d.ts 41 lines1// fleet-band's $.state contract: one snapshot, written by the refresh
2// (60 s timer, turn end, or a command run) and read by the band.
3// A field left out is not configured (skipped); null is configured but
4// unreadable (drawn as "?").
5
6export type FleetBandHeldReason =
7 | 'hold-without-release'
8 | 'released'
9 | 'no-hold-in-tail'
10 | 'log-over-read-cap'
11 | 'log-unreadable'
12
13export type FleetBandHeld = {
14 seat: string
15 state: 'held' | 'clear' | 'unknown'
16 reason: FleetBandHeldReason
17}
18
19export type FleetBandPending = {
20 id: string
21 job: string
22 summary: string
23 producedAt: string
24}
25
26export type FleetBandSnapshot = {
27 at: number
28 seat: string
29 ctxPercent: number | null
30 roomUnread?: number | null
31 pending?: FleetBandPending[] | null
32 needsYou?: string[] | null
33 held?: FleetBandHeld[]
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'fleet-band': { snapshot: FleetBandSnapshot | null }
39 }
40}
41