COMPANION, a mod. /board opens a pane that draws this bundle's board — in-flight tasks, the need-you rail, per-project counts, the last snapshot time — from…

A mod: a hooks module Claude Code runs inside every session that loads it. This one adds /board, which opens a pane drawing this bundle's board beside the transcript (or above the prompt in a narrow terminal), and nothing else.
/plugin marketplace add cbmono/loopd # already added? skip
/plugin install loopd-mod-pane@loopd
Uninstall it (/plugin → Installed) and the three core renderers — build-board.sh, print-board.sh, watch-board.sh — are all there is, with no other edits anywhere. Absence is the safe behaviour, never an error.
The fourth renderer over the one board contract, plugin/scripts/write-snapshot.sh → .loopd/SNAPSHOT.json (docs/conventions.md §11 in cbmono/loopd). It reads the snapshot, never the bundle, so the snapshot writer's field allowlist holds here without being re-implemented: nothing this pane shows is anything the file does not already carry.
| Section | From the snapshot's fields |
|---|---|
| header | group, counts.projects, counts.tasks, counts.awaiting |
| need you | the AWAITING verbs — approve · answer · merge · unblock · close — counted off each task's awaiting verb, plus one close per project with awaiting_close (print-board.sh's rule) |
| in flight | each task with in_flight: <project slug>/<task id>, assignee (a role, never a person), status, the PR's pr_mergeable when prs is non-empty, title |
| projects | per project: slug, status, phase_progress.done/total, the task count, in-flight count, awaiting count, title |
| footer | generated_at — when the writer last wrote the file — and tick queued while a Tick is waiting for the session to go idle |
Not drawn, because the snapshot does not carry it: a round number, a session id, a usage line, a continue verb, any question or blocker text, any document body, any author identity (owner is in the file for the HTML board's partitioning and is deliberately not drawn here), any URL. The two things that would make those appear are a field added to the writer — read its header first — or a second reader of the bundle, which this must never be.
Untrusted text, untrusted types. Every string passes one clean() for the terminal medium — every Unicode category-C code point is dropped (so ESC cannot repaint what you already read and a newline cannot forge a row), whitespace controls become one space — and every number passes one toint(), so a "tasks": "many" draws as 0 and never throws. A number is never truncated: a clipped count is a wrong count.
The pane redraws from the file every 5 seconds, by stat first and read only when the mtime moved; the poll starts when the pane opens and stops when it closes.
Exactly two paths, both under the session's working directory as $.session.cwd() gives it, and nothing else — never a parent directory, never a path a config file names:
| Path | Why |
|---|---|
instance.config.json | exists? — the cwd is a loopd bundle. Absent ⇒ the pane says not a loopd bundle and draws nothing else. |
.loopd/SNAPSHOT.json | stat every poll, read when the mtime moved. Absent ⇒ the pane says board OFF with the touch that turns it on (absence is the off switch — conventions 3 and 11) and draws nothing else. Unparseable ⇒ one line, and the next write-snapshot.sh run overwrites it. |
The .loopd/SNAPSHOT.json spelling is AB_SNAPSHOT in plugin/scripts/bundle-paths.sh; tests/mods.test.sh asserts the module and that file agree, and that every path the module names is listed in this section.
| Button | Does |
|---|---|
Tick (t) | $.command.run({ command: 'loopd:dispatch' }) — runs /loopd:dispatch once in this session, as if you had typed it. The engine queues a plugin's command run until the session is idle, so this is idle-only by contract. (Not $.prompt.submit: the host refuses a prompt text that begins with /, measured on 2.1.293.) A refused run is drawn in the footer as tick refused: <reason>, never swallowed. It is the human pressing a key at their own prompt, not automation: nothing here runs on a timer, and no message goes to another session. One press, one queued tick; a second press while one is queued is ignored and the footer says tick queued. |
Refresh (r) | re-read the snapshot now, mtime or not. |
Where no surface places the pane (a terminal under the width floor, a claude -p run), /board prints one dim ● loopd-mod-pane: line with the header counts instead.
A mod runs with your permissions in every session on the machine, so the lines it does not cross are the whole design, and tests/mods.test.sh in cbmono/loopd asserts each on the source and on what claude plugin validate reads out of it:
tool.check; every event it sees is handed on.$.process, $.http and $.env are never called.$.fs.write is never called, and $.fs.ancestors (which walks UP from the cwd) is never called either. The two reads above are the whole file-system footprint, and they are the one widening of the mod rule tests/mods.test.sh grants, to a mod whose README names its paths here.$.session.send is not used; the cross-session half is unmeasured (docs/spikes/mods-in-background-sessions.md).Claude Code 2.1.293 (claude plugin validate . --strict passes, claude plugin test 9/9, 2026-10-09), written against the mods docs and the 2.1.289 claude-code.d.ts. Mods need v2.1.287 or later. After a CLI update run, from this directory:
claude plugin validate . --strict
claude plugin test
Settled by that run: $.prompt.submit refuses a text beginning with / ("would run a command as the user; run one with $.command.run"), so Tick is a $.command.run. Still assumed: that ui.close fires with e.id for a pane the person closes (else the poll outlives the pane until reload); that $.fs.stat rejects rather than resolving for a missing file (the code handles both).
/plugin uninstall loopd-mod-pane@loopd, or remove the directory from a checkout. The contract this plugin is an instance of — how a companion registers, where core looks, and the rule that a companion ADDS behaviour and never removes a core gate — is in ../plugin/README.md → "Companion plugins".
hooks/register.ts 312 lines1// loopd-mod-pane — the bundle's board, drawn in a pane of the session that opens it.
2//
3// THE FOURTH RENDERER over ONE contract. `plugin/scripts/write-snapshot.sh` derives
4// `.loopd/SNAPSHOT.json` from the bundle; `build-board.sh`, `print-board.sh` and
5// `watch-board.sh` render it. This module is a renderer too: it reads the SNAPSHOT and
6// nothing else, so the writer's field allowlist (docs/conventions.md §11) holds here
7// without being re-implemented — no question or blocker text, no document body, no author
8// identity and no out-of-bundle path can reach this pane because none is in the file.
9//
10// WHAT IT READS, exactly (README.md → "What it reads", asserted by tests/mods.test.sh in
11// cbmono/loopd): `<cwd>/instance.config.json` (exists? — is this a loopd bundle) and
12// `<cwd>/.loopd/SNAPSHOT.json` (stat, then read when the mtime moved), where `<cwd>` is
13// `$.session.cwd()`. It never walks up, never reads outside the cwd, never writes.
14//
15// ABSENCE IS THE OFF SWITCH (conventions 3 and 11): no snapshot file ⇒ one line saying so
16// and how to enable, nothing else. The snapshot is untrusted TEXT and untrusted TYPES: every
17// string is cleaned for the terminal medium (category C dropped, so ESC cannot repaint and a
18// newline cannot forge a row), every number goes through toint() so a drifted field costs
19// one 0 and never a throw, and a number is never truncated — a clipped count is a wrong
20// count (conventions 11d, 11e).
21//
22// TWO ACTIONS, both the human's, both idle-only by the engine's own contract: Tick runs
23// `/loopd:dispatch` in THIS session (`$.command.run` is "queued and run once the session is
24// idle"; `$.prompt.submit` is not used — the host refuses a prompt text beginning with `/`,
25// measured 2.1.293), Refresh re-reads now. No timer ever runs a command or submits a
26// prompt, and no message goes to another session.
27//
28// NEVER: a model call, a hook on the permission event, a process, the network, the
29// environment, a file write, a walk up from the cwd — tests/mods.test.sh asserts each
30// absence on this source, so none of those is spelled here even as a comment.
31
32const PANE = 'loopd-board'
33const COMMAND = 'board'
34const TITLE = 'loopd board'
35const CONFIG = 'instance.config.json'
36const SNAPSHOT = '.loopd/SNAPSHOT.json'
37const POLL_MS = 5000
38const DISPATCH = 'loopd:dispatch' // the command, without its slash, as $.command.run names it
39// write-snapshot.sh's verb set, minus nothing: the pane names the verb, never the reason.
40const VERBS = ['approve', 'answer', 'merge', 'unblock', 'close'] as const
41
42type Kind = 'unread' | 'not-bundle' | 'off' | 'malformed' | 'ok'
43
44type Task = {
45 id: string
46 title: string
47 status: string
48 assignee: string
49 in_flight: boolean
50 pr_mergeable: string
51 awaiting: string
52 open_questions: number
53 prs: number
54}
55type Project = {
56 slug: string
57 title: string
58 status: string
59 awaiting_close: boolean
60 phase_done: number
61 phase_total: number
62 tasks: Task[]
63}
64type Board = {
65 group: string
66 generated_at: string
67 projects: number
68 tasks: number
69 awaiting: number
70 rows: Project[]
71}
72
73// Module state. A reload starts it over, and the next poll or /board fills it again.
74let kind: Kind = 'unread'
75let board: Board | null = null
76let detail = '' // the one line a non-ok kind shows
77let mtimeMs = -1 // the snapshot's mtime at the last read; -1 = never read
78let reads = 0 // how many times the file was actually read (the poll skips an unchanged one)
79let timer: { cancel: () => void } | null = null
80let tickQueued = false
81let tickRefused = '' // why the last Tick's submit was refused, drawn in the footer; '' = it was not
82
83// ---------------------------------------------------------------- untrusted input
84// The TERMINAL sink's one sanitising point, the same rule print-board.sh applies: drop
85// every code point in Unicode general category C (ESC, the bidi overrides, surrogates…),
86// turn whitespace controls into one space, collapse runs. One rule, not a blocklist.
87// The property escape is tried once; an engine without it gets the C0/C1/format fallback.
88// Both are built from STRINGS, double-escaped, so this source stays ASCII: a raw U+2028 in
89// a regex literal is a line terminator to the parser and ends the literal early.
90let CAT_C: RegExp
91try { CAT_C = new RegExp('\\p{C}', 'gu') } catch {
92 CAT_C = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\u2066-\\u206f\\ufeff\\ufff9-\\ufffb]', 'g')
93}
94function clean(v: unknown): string {
95 const s = typeof v === 'string' ? v : (v === null || v === undefined ? '' : String(v))
96 return s.replace(/[\t\n\r\f\v]/g, ' ').replace(CAT_C, '').replace(/ {2,}/g, ' ').trim()
97}
98
99// Every number off the snapshot goes through here — a `"tasks": "many"` is 0, not a throw.
100// Never truncated downstream: the caller prints String(n) whole.
101function toint(v: unknown): number {
102 if (typeof v === 'number' && isFinite(v)) return Math.max(0, Math.floor(v))
103 if (typeof v === 'string' && /^[0-9]+$/.test(v.trim())) return parseInt(v.trim(), 10)
104 return 0
105}
106function str(v: unknown): string { return typeof v === 'string' ? clean(v) : '' }
107function obj(v: unknown): Record<string, unknown> | null {
108 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
109}
110function arr(v: unknown): unknown[] { return Array.isArray(v) ? v : [] }
111
112// Presentation over the parsed JSON: shape it once, cleanly, so the render hook is a pure
113// function of `board`. Nothing here is derived from the bundle — only from the snapshot.
114function shape(json: unknown): Board | null {
115 const top = obj(json)
116 if (!top) return null
117 const counts = obj(top.counts) || {}
118 const rows: Project[] = []
119 for (const p of arr(top.projects)) {
120 const po = obj(p)
121 if (!po) continue
122 const pp = obj(po.phase_progress) || {}
123 const tasks: Task[] = []
124 for (const t of arr(po.tasks)) {
125 const to = obj(t)
126 if (!to) continue
127 tasks.push({
128 id: str(to.id), title: str(to.title), status: str(to.status), assignee: str(to.assignee),
129 in_flight: to.in_flight === true, pr_mergeable: str(to.pr_mergeable), awaiting: str(to.awaiting),
130 open_questions: toint(to.open_questions), prs: arr(to.prs).length,
131 })
132 }
133 rows.push({
134 slug: str(po.slug), title: str(po.title), status: str(po.status),
135 awaiting_close: po.awaiting_close === true,
136 phase_done: toint(pp.done), phase_total: toint(pp.total), tasks,
137 })
138 }
139 return {
140 group: str(top.group), generated_at: str(top.generated_at),
141 projects: toint(counts.projects), tasks: toint(counts.tasks), awaiting: toint(counts.awaiting),
142 rows,
143 }
144}
145
146// ---------------------------------------------------------------- the read
147// `force` re-reads regardless of mtime (Refresh, the first open); the poll passes false and
148// reads only when stat says the file moved. Every `$` call is caught: a missing or renamed
149// field, a vanished file or a refused call costs one line in the pane, never a throw.
150async function refresh($: any, force: boolean): Promise<void> {
151 let cwd: unknown = null
152 try { cwd = await $.session.cwd() } catch { cwd = null }
153 if (typeof cwd !== 'string' || !cwd) { kind = 'malformed'; board = null; detail = 'cannot read the working directory'; return }
154 const root = cwd.replace(/\/+$/, '')
155 let isBundle = false
156 try { isBundle = (await $.fs.exists(root + '/' + CONFIG)) === true } catch { isBundle = false }
157 if (!isBundle) { kind = 'not-bundle'; board = null; detail = 'not a loopd bundle — no ' + CONFIG + ' in ' + clean(root); mtimeMs = -1; return }
158 const path = root + '/' + SNAPSHOT
159 let st: any = null
160 try { st = await $.fs.stat(path) } catch { st = null }
161 if (!st || st.kind !== 'file') {
162 kind = 'off'; board = null; mtimeMs = -1
163 detail = 'board OFF — no ' + SNAPSHOT + ' here. `touch ' + SNAPSHOT + '` in the bundle root turns it on (absence is the off switch).'
164 return
165 }
166 const m = typeof st.mtimeMs === 'number' ? st.mtimeMs : -2
167 if (!force && kind !== 'unread' && m === mtimeMs) return
168 let text: unknown = null
169 try { text = await $.fs.read(path) } catch { text = null }
170 reads += 1
171 mtimeMs = m
172 if (typeof text !== 'string') { kind = 'malformed'; board = null; detail = 'could not read ' + SNAPSHOT; return }
173 let json: unknown
174 try { json = JSON.parse(text) } catch { kind = 'malformed'; board = null; detail = SNAPSHOT + ' is not valid JSON — the next write-snapshot.sh run overwrites it'; return }
175 const b = shape(json)
176 if (!b) { kind = 'malformed'; board = null; detail = SNAPSHOT + ' is not a JSON object — the next write-snapshot.sh run overwrites it'; return }
177 kind = 'ok'; board = b; detail = ''
178}
179
180function summary(): string {
181 if (kind !== 'ok' || !board) return detail
182 return 'loopd board: ' + board.projects + ' project(s) · ' + board.tasks + ' task(s) · ' + board.awaiting + ' awaiting you'
183}
184
185// ---------------------------------------------------------------- the drawing
186// Function-call form (this is a .ts file, so no JSX); Box, Text and Button are the three
187// elements every surface draws. Every string passed to Text has been through clean().
188function draw($: any, e: any): any {
189 const { Box, Text, Button } = $.ui.resolve(e)
190 const line = (s: string, props: Record<string, unknown> = {}) => Text({ ...props, children: [s] })
191 const rows: any[] = []
192
193 if (kind !== 'ok' || !board) {
194 rows.push(line(kind === 'unread' ? 'loopd board: reading…' : detail, { dimColor: kind === 'unread' }))
195 } else {
196 const b = board
197 rows.push(line(TITLE + (b.group ? ' · ' + b.group : '') + ' · ' + b.projects + ' project(s) · ' + b.tasks + ' task(s) · ' + b.awaiting + ' awaiting you', { bold: true }))
198
199 // The need-you rail: the AWAITING verbs, counted off each task's `awaiting` plus one
200 // `close` per project whose `awaiting_close` is set — print-board.sh's rule.
201 const need: Record<string, number> = {}
202 for (const v of VERBS) need[v] = 0
203 const inFlight: { slug: string; t: Task }[] = []
204 for (const p of b.rows) {
205 if (p.awaiting_close) need.close += 1
206 for (const t of p.tasks) {
207 if ((VERBS as readonly string[]).includes(t.awaiting)) need[t.awaiting] += 1
208 if (t.in_flight) inFlight.push({ slug: p.slug, t })
209 }
210 }
211 rows.push(line('need you: ' + VERBS.map(v => v + ' ' + need[v]).join(' · ')))
212
213 rows.push(line(' '))
214 rows.push(line('in flight (' + inFlight.length + ')', { bold: true }))
215 if (inFlight.length === 0) rows.push(line('nothing in flight', { dimColor: true }))
216 for (const { slug, t } of inFlight) {
217 rows.push(line(' ' + slug + '/' + t.id + ' · ' + (t.assignee || 'unassigned') + ' · ' + t.status
218 + (t.prs ? ' · PR ' + (t.pr_mergeable || 'UNKNOWN') : '') + ' · ' + t.title))
219 }
220
221 rows.push(line(' '))
222 rows.push(line('projects (' + b.rows.length + ')', { bold: true }))
223 for (const p of b.rows) {
224 let aw = p.awaiting_close ? 1 : 0
225 let fly = 0
226 for (const t of p.tasks) { if ((VERBS as readonly string[]).includes(t.awaiting)) aw += 1; if (t.in_flight) fly += 1 }
227 rows.push(line(' ' + p.slug + ' · ' + p.status + ' · phases ' + p.phase_done + '/' + p.phase_total
228 + ' · tasks ' + p.tasks.length + ' · in flight ' + fly + ' · awaiting ' + aw + ' · ' + p.title))
229 }
230
231 rows.push(line(' '))
232 rows.push(line('snapshot written ' + (b.generated_at || 'unknown') + (tickQueued ? ' · tick queued' : ''), { dimColor: true }))
233 }
234 // A refused submit is said, not swallowed: a Tick that silently did nothing is the
235 // silent wrong answer convention 12 is about.
236 if (tickRefused) rows.push(line('tick refused: ' + tickRefused, { dimColor: true }))
237
238 rows.push(line(' '))
239 rows.push(Box({
240 flexDirection: 'row', columnGap: 2, children: [
241 Button({
242 key: 'tick', label: 'Tick', hotkey: 't',
243 // The human pressed a key in their own session: one /loopd:dispatch run, queued
244 // until the session is idle (the engine's own contract for a plugin's command.run).
245 // Not awaited — a handler that waits for the run would block the press. Never on
246 // a timer. A COMMAND run, not a prompt: the host refuses a `/` text as a prompt.
247 onPress: () => {
248 if (tickQueued) return
249 tickQueued = true
250 tickRefused = ''
251 const done = (err?: unknown) => {
252 tickQueued = false
253 if (err !== undefined) tickRefused = clean(err && (err as any).message ? (err as any).message : String(err)) || 'refused'
254 try { $.ui.invalidate('ui.render') } catch { /* no surface to redraw */ }
255 }
256 let p: any = null
257 try { p = $.command.run({ command: DISPATCH }) } catch (err) { done(err); return }
258 if (p && typeof p.then === 'function') p.then(() => done(), (err: unknown) => done(err ?? 'refused')); else done()
259 try { $.ui.invalidate('ui.render') } catch { /* no surface to redraw */ }
260 },
261 }),
262 Button({
263 key: 'refresh', label: 'Refresh', hotkey: 'r',
264 onPress: async () => { await refresh($, true); $.ui.invalidate('ui.render') },
265 }),
266 ],
267 }))
268
269 return Box({ flexDirection: 'column', children: rows })
270}
271
272// ---------------------------------------------------------------- hooks
273export function register(on: any) {
274 on('session.start', async ($: any, e: any, next: any) => {
275 // `immediate` so /board opens while a turn is streaming. A taken name throws and
276 // would skip the rest of this hook, so it is caught and said once, where someone can see it.
277 try {
278 await $.command.register({ name: COMMAND, description: 'Draw this bundle\'s loopd board in a pane', immediate: true })
279 } catch (err: any) {
280 if (e && e.isInteractive === true) $.ui.log('/' + COMMAND + ' not registered: ' + clean(err && err.message ? err.message : String(err)))
281 }
282 return next(e)
283 })
284
285 on('command.run', { command: COMMAND }, async ($: any) => {
286 await refresh($, true)
287 let placed = false
288 try { const r = await $.ui.open({ id: PANE, title: TITLE }); placed = !!(r && r.isPlaced === true) } catch { placed = false }
289 if (!timer) {
290 // The poll re-reads only when stat's mtime moved (refresh(…, false)); it never
291 // submits anything. It starts with the pane and ends with it (ui.close below).
292 try { timer = $.clock.every(POLL_MS, async () => { const before = reads; await refresh($, false); if (reads !== before) $.ui.invalidate('ui.render') }) } catch { timer = null }
293 }
294 // Where no surface draws a pane (a narrow terminal, a `-p` run), one transcript line.
295 if (!placed) $.ui.log(summary())
296 return {}
297 }).catch(async (_$: any, e: any, next: any) => next(e)) // fail OPEN: a broken pane never eats the command
298
299 on('ui.close', { id: PANE }, async (_$: any, e: any, next: any) => {
300 if (timer) { try { timer.cancel() } catch { /* already gone */ } timer = null }
301 return next(e)
302 }).catch(async (_$: any, e: any, next: any) => next(e)) // fail OPEN: the pane still closes
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($: any, e: any) => {
305 if (kind === 'unread') {
306 // Drawn before /board ran (a reload while the pane stayed up): read, then redraw.
307 refresh($, true).then(() => $.ui.invalidate('ui.render'), () => $.ui.invalidate('ui.render'))
308 }
309 return draw($, e)
310 })
311}
312