SLOPSHOPPER

loopd — pane (a mod that draws the board inside the session)

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…

newpanecommandtimer
v3.0.0MITupdated 2026-10-08cbmono/loopd/plugin-mod-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loopd-mod-pane
│ ┃ loopd board ✕ › fix the failing auth test and add an audit log call │ ┃ not a loopd bundle — no instance.config.json │ ┃ in /work/app ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Tick ] [ Refresh ] ⏺ 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 │ │ › /board │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · loopd board
not a loopd bundle — no instance.config.json in /work/app [ Tick ] [ Refresh ]
README

loopd-mod-pane — the board-in-a-pane companion (a mod)

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.

What it draws

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.

SectionFrom the snapshot's fields
headergroup, counts.projects, counts.tasks, counts.awaiting
need youthe 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 flighteach 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
projectsper project: slug, status, phase_progress.done/total, the task count, in-flight count, awaiting count, title
footergenerated_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.

What it reads

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:

PathWhy
instance.config.jsonexists? — the cwd is a loopd bundle. Absent ⇒ the pane says not a loopd bundle and draws nothing else.
.loopd/SNAPSHOT.jsonstat 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.

The two buttons

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

What it never does

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:

  • No model call — nothing here spends the plan.
  • No permission decision — it never hooks tool.check; every event it sees is handed on.
  • No process, no network, no environment — $.process, $.http and $.env are never called.
  • No write — $.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.
  • No cross-session message — $.session.send is not used; the cross-session half is unmeasured (docs/spikes/mods-in-background-sessions.md).
  • No timer-driven run — the poll reads a file; only a press runs the command, and nothing here ever submits a prompt.

Tested with

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

Delete it

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

Source 1 files
hooks/register.ts 312 lines
1// 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