SLOPSHOPPER

fleet-band

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

newbandcommandprocesstimer
★ 19v0.1.0MITupdated 2026-10-07wrg32786/aigent-os/plugins/fleet-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /fires ⎿ fleet-band: Pending job results: not configured (installRoot unset or its job-results script is missing) app · ctx 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app · ctx 49%
README

fleet-band

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

Install

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

Settings

SettingTurns onExample
installRootresults pending, /firesC:/example/aigent
roomRootroom unread; held (with watchedSeats)C:/example/agent-room/room
eodPathneeds-youC:/example/cockpit/eod/latest.json
watchedSeatsheld (with roomRoot)alpha, beta
showSummarieseach result's summary in /firesfalse (default)

An empty setting means the field is skipped and its command says "not configured". A configured source that cannot be read shows as ?.

What it reads

FieldSource
seatenv 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 unreadcount of *.json files directly in <roomRoot>/inbox/<seat>/
results pendingnode <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-youlength of needs_you[] in <eodPath>
heldthe 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.

Commands

  • /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.

Fences

  • No $.permission calls and no tool-approval hooks.
  • No writes outside $.state (fleet-band.snapshot).
  • No network access.
  • No process other than the job-results call above.
  • Nothing that submits a prompt.

Follow-ups

  • /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.
  • A bounded hold-state source, so held can resolve on large logs.

Develop

claude plugin validate <this folder>
claude plugin test <this folder>
claude --plugin-dir <this folder>
Source 2 files
hooks/register.tsx 304 lines
1// 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}
304
types/index.d.ts 41 lines
1// 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