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

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 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.
Two layers keep the band out of every other session.
"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.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_DIRSin theenvblock 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 onehq whoisat 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.
HQ_BAND_BIN: the hq to run. The default is ~/.local/bin/hq.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).
hooks/register.tsx 197 lines1import { 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}
197hooks/is-heat-list.ts 8 lines1import 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}
8hooks/is-segment-list.ts 8 lines1import 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}
8types/index.d.ts 33 lines1// 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