SLOPSHOPPER

hq-band

z-stu's fleet band: one line above the prompt from hq band --json; inert in every other session

newpanebandcommandprocesstimer
v0.2.0MITupdated 2026-10-26zgeoff/hq/mods/hq-band
A shopper browsing a rack in a slop shop
README

hq-band

A Claude Code mod that draws one line above z-stu's prompt, refreshed every minute:

Needs you 0 · Nix up · lanes 10/17 (impd 16/16 GiB, 16/16 vCPU) · CI green · reviews 3

The line comes from hq band --json, which reads only what hq already stores. hq decides each segment's text and tone, and the mod only draws them: red when something needs Geoff, Nix is down or CI failed, amber near impd's line or when hq can't tell. When hq can't be read, the band says hq unreachable.

/hq

/hq opens a pane with the detail behind the band, and /hq close closes it. The pane shows what needs Geoff with its links, Nix today, a heat map of the lanes with the runs that hold each slot, reviews in flight, what merged today, and the newest news. It comes from hq pane --json, read with the band each minute. The terminal draws the heat map as a Raster, two columns to a slot: green while the lane has room, amber when it is full.

Only z-stu

Two layers keep the band out of every other session.

  1. Load it for z-stu only. fleet.json gives z-stu "pluginDirs": ["~/projects/hq/mods/hq-band"], and hq fleet ensure passes the list to atc's spawn, which starts Claude Code with --plugin-dir ~/projects/hq/mods/hq-band. This needs atc 4.15.0 or later. A z-stu spawned before that change runs without the band until it is respawned; hq fleet names the mismatch.
  2. Inert anywhere else. On session.start the mod runs hq whois $ATC_SESSION_ID --json once, with a 10 s limit. Unless hq names the session z-stu, the mod starts no timer, registers no /hq, runs nothing more and draws nothing: its hooks pass straight through. A failed or slow whois has the same result.

CAUTION: Never set CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. That loads the mod in every session on the PC. Layer 2 keeps it inert there, but each session then runs one hq whois at start.

A --plugin-dir folder is watched. The band reloads when the folder changes, for example when git merge --ff-only origin/main updates ~/projects/hq before bun run install-bin.

Settings

  • HQ_BAND_BIN: the hq to run. The default is ~/.local/bin/hq.

Checks

Run these from this folder; hq's CI runs neither, because CI has no claude.

claude plugin validate .
claude plugin test .

hq's own tests cover hq band and hq whois (src/band/load-band.test.ts, src/atc/find-fleet-name.test.ts).

Source 4 files
hooks/register.tsx 197 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On, ResolveInput } from 'claude-code';
3import type { BandReading, PaneDetail, PaneHeatRow, PaneReading } from '../types';
4import { isHeatList } from './is-heat-list';
5import { isSegmentList } from './is-segment-list';
6
7// The band is z-stu's alone. In any other session, or when hq can't say whose session this is,
8// both hooks pass straight through: no timer, no process after the first, nothing drawn.
9const LIVE = atom({ plugin: 'hq-band', key: 'live' } as const, false),
10  OWNER = 'z-stu',
11  PANE = atom({ plugin: 'hq-band', key: 'pane' } as const, null),
12  PANE_ID = 'hq',
13  READING = atom({ plugin: 'hq-band', key: 'reading' } as const, null),
14  REFRESH_MS = 60_000,
15  TIMEOUT_MS = 10_000,
16  TONE_COLORS = { ok: undefined, warn: 'warning', alert: 'error' } as const;
17
18export function register(on: On): void {
19  on('session.start', async ($, e, next) => {
20    const result = await next(e);
21    try {
22      const name = await loadSessionName($);
23      if (name === OWNER) {
24        await update($, LIVE, () => true);
25        await refreshAll($);
26        $.clock.every(REFRESH_MS, () => refreshAll($));
27        await $.command.register({ name: 'hq', description: "The fleet in detail: what needs you, Nix, lanes, reviews, today's merges, news", argumentHint: '[close]' });
28      }
29    } catch {
30      // A whois that fails or times out leaves the band inert for the session.
31    }
32    return result;
33  });
34
35  on('command.run', { command: 'hq' }, async ($, e, next) => {
36    const isLive = await read($, LIVE);
37    if (!isLive) {
38      return next(e);
39    }
40    if (e.args.trim() === 'close') {
41      await $.ui.close({ id: PANE_ID });
42      return { text: 'Closed the hq pane.' };
43    }
44    await refreshPane($);
45    await $.ui.open({ id: PANE_ID, title: 'hq' });
46    return { text: 'Opened the hq pane.' };
47  });
48
49  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
50    const elements = $.ui.resolve(e),
51      isLive = await read($, LIVE),
52      shown = isLive ? await read($, PANE) : null;
53    if (shown === null) {
54      return next(e);
55    }
56    return 'unreachable' in shown ? <elements.Text color="warning">hq unreachable: {shown.unreachable}</elements.Text> : renderPaneDetail($, e, shown);
57  });
58
59  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
60    const elements = $.ui.resolve(e),
61      isLive = await read($, LIVE),
62      shown = isLive && !e.props.hasSurvey ? await read($, READING) : null;
63    if (shown === null) {
64      return next(e);
65    }
66    if ('unreachable' in shown) {
67      return (
68        <elements.Box key="band">
69          <elements.Text color="warning" wrap="truncate-end">
70            hq unreachable
71          </elements.Text>
72        </elements.Box>
73      );
74    }
75    return (
76      <elements.Box key="band">
77        <elements.Text wrap="truncate-end">
78          {shown.segments.map((segment, index) => (
79            <elements.Text key={segment.key}>
80              {index > 0 ? <elements.Text dimColor>{' · '}</elements.Text> : null}
81              <elements.Text color={TONE_COLORS[segment.tone]}>{segment.text}</elements.Text>
82            </elements.Text>
83          ))}
84        </elements.Text>
85      </elements.Box>
86    );
87  });
88}
89
90async function refreshAll($: EngineInterface): Promise<void> {
91  await refreshBand($);
92  await refreshPane($);
93}
94
95// One read of hq pane --json into the pane's state; any failure shows "hq unreachable".
96async function refreshPane($: EngineInterface): Promise<void> {
97  try {
98    const answer = await runHq($, ['pane', '--json']),
99      pane = answer?.['pane'] as { markdown?: unknown; heat?: unknown } | undefined,
100      shown: PaneReading = typeof pane?.markdown === 'string' && isHeatList(pane.heat) ? { markdown: pane.markdown, heat: pane.heat } : { unreachable: 'hq pane printed no pane' };
101    await update($, PANE, () => shown);
102  } catch (error) {
103    await update($, PANE, () => ({ unreachable: String(error) }));
104  }
105}
106
107// The heat map, a row per lane with its name, then hq's Markdown.
108function renderPaneDetail($: EngineInterface, e: ResolveInput, shown: PaneDetail) {
109  const elements = $.ui.resolve(e),
110    width = Math.max(...shown.heat.map((row) => row.lane.length), 4);
111  return (
112    <elements.Box flexDirection="column" key="pane">
113      {shown.heat.map((row) => (
114        <elements.Box key={`lane-${row.lane}`} flexDirection="row">
115          <elements.Text dimColor>{row.lane.padEnd(width + 1)}</elements.Text>
116          {e.surface === 'terminal' ? renderRasterHeat($, { ...e, surface: 'terminal' }, row) : renderTextHeat($, e, row)}
117          {row.used > row.size ? (
118            <elements.Box key={`over-${row.lane}`}>
119              <elements.Text color="warning">{` +${row.used - row.size}`}</elements.Text>
120            </elements.Box>
121          ) : null}
122        </elements.Box>
123      ))}
124      <elements.Markdown key="detail" text={shown.markdown} />
125    </elements.Box>
126  );
127}
128
129// The terminal draws a lane's slots as one Raster, two columns to a slot.
130function renderRasterHeat($: EngineInterface, e: ResolveInput & { surface: 'terminal' }, row: PaneHeatRow) {
131  const terminal = $.ui.resolve(e);
132  if (row.size === 0) {
133    return null;
134  }
135  return <terminal.Raster key={`heat-${row.lane}`} columns={row.size * 2} rows={1} cells={buildHeatCells(row)} />;
136}
137
138// A surface with no Raster draws the slots as block characters.
139function renderTextHeat($: EngineInterface, e: ResolveInput, row: PaneHeatRow) {
140  const elements = $.ui.resolve(e);
141  return (
142    <elements.Box key={`heat-${row.lane}`}>
143      <elements.Text>{'██'.repeat(Math.min(row.used, row.size)) + '░░'.repeat(Math.max(row.size - row.used, 0))}</elements.Text>
144    </elements.Box>
145  );
146}
147
148// Two cells per slot: a held slot is full blocks, amber when the lane is full and green otherwise;
149// a free slot is a light shade in the terminal's own colour.
150function buildHeatCells(row: PaneHeatRow): string {
151  const DEFAULT = 0x01_00_00_00,
152    FREE = 0x25_91,
153    FULL_COLOR = 0xd2_99_22,
154    HELD = 0x25_88,
155    OPEN_COLOR = 0x3f_b9_50,
156    held = row.used >= row.size ? FULL_COLOR : OPEN_COLOR,
157    words = Uint32Array.from({ length: row.size * 6 }, (_unused, index) => {
158      const cell = Math.floor(index / 6),
159        isHeld = cell < row.used,
160        part = index % 3;
161      return [isHeld ? HELD : FREE, isHeld ? held : DEFAULT, DEFAULT][part] ?? DEFAULT;
162    });
163  return btoa(String.fromCodePoint(...new Uint8Array(words.buffer)));
164}
165
166// The fleet name hq fleet last saw under this atc session's id; none outside atc.
167async function loadSessionName($: EngineInterface): Promise<string | null> {
168  const id = await $.env.get('ATC_SESSION_ID'),
169    whoisAnswer = id === undefined || id === '' ? null : await runHq($, ['whois', id, '--json']);
170  return typeof whoisAnswer?.['name'] === 'string' ? whoisAnswer['name'] : null;
171}
172
173// One read of hq band --json into the band's state; any failure shows "hq unreachable".
174async function refreshBand($: EngineInterface): Promise<void> {
175  try {
176    const answer = await runHq($, ['band', '--json']),
177      segments = (answer?.['band'] as { segments?: unknown } | undefined)?.segments,
178      shown: BandReading = isSegmentList(segments) ? { segments } : { unreachable: 'hq band printed no segments' };
179    await update($, READING, () => shown);
180  } catch (error) {
181    await update($, READING, () => ({ unreachable: String(error) }));
182  }
183}
184
185// hq's JSON answer, run from HQ_BAND_BIN or ~/.local/bin/hq with a 10 s limit; none on a failed exit.
186async function runHq($: EngineInterface, args: string[]): Promise<Record<string, unknown> | null> {
187  const home = await $.env.get('HOME'),
188    override = await $.env.get('HQ_BAND_BIN'),
189    ran = await $.process.run([override ?? `${home ?? ''}/.local/bin/hq`, ...args], { timeoutMs: TIMEOUT_MS });
190  return ran.exitCode === 0 ? parseHqAnswer(ran.stdout) : null;
191}
192
193function parseHqAnswer(stdout: string): Record<string, unknown> | null {
194  const parsed: unknown = JSON.parse(stdout);
195  return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
196}
197
hooks/is-heat-list.ts 8 lines
1import type { PaneHeatRow } from '../types';
2
3// Whether `hq pane --json`'s heat map is one the pane can draw. hq's CLI test runs its own output
4// through this check, so a change on either side fails there.
5export function isHeatList(value: unknown): value is PaneHeatRow[] {
6  return Array.isArray(value) && value.every((row: Partial<PaneHeatRow> | null) => typeof row?.lane === 'string' && Number.isInteger(row.size) && Number.isInteger(row.used));
7}
8
hooks/is-segment-list.ts 8 lines
1import type { BandSegment } from '../types';
2
3// Whether `hq band --json`'s segments are ones the band can draw. hq's CLI test runs its own
4// output through this check, so a change on either side fails there.
5export function isSegmentList(value: unknown): value is BandSegment[] {
6  return Array.isArray(value) && value.every((segment: Partial<BandSegment> | null) => typeof segment?.text === 'string' && typeof segment.key === 'string' && ['ok', 'warn', 'alert'].includes(segment.tone ?? ''));
7}
8
types/index.d.ts 33 lines
1// One segment of `hq band --json`: hq decides its text and tone, the band only draws it.
2export interface BandSegment {
3  key: string;
4  text: string;
5  tone: 'ok' | 'warn' | 'alert';
6}
7
8// The band's last read: hq's segments, or hq could not be read.
9export type BandReading = { segments: BandSegment[] } | { unreachable: string };
10
11// One lane of `hq pane --json`'s heat map: a cell per slot, the first `used` held. `used` can
12// exceed `size`: the pane draws the slots, then the count over.
13export interface PaneHeatRow {
14  lane: string;
15  size: number;
16  used: number;
17}
18
19// What `hq pane --json` gives: the sections as Markdown, and the heat map's lanes.
20export interface PaneDetail {
21  markdown: string;
22  heat: PaneHeatRow[];
23}
24
25// The /hq pane's last read: hq's detail, or hq could not be read.
26export type PaneReading = PaneDetail | { unreachable: string };
27
28declare module 'claude-code' {
29  interface PluginState {
30    'hq-band': { live: boolean; reading: BandReading | null; pane: PaneReading | null };
31  }
32}
33