SLOPSHOPPER

overlord-board

Your work board above the prompt and a /board pane: now, your priorities, alarms, tick and spend, read from the local board.json that agents/bin/board writes…

newpanebandcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overlord-board
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ AGENTS_REPO is not set, so the board is │ ┃ off: set it in your user settings (env) to ⏺ Read(src/auth.ts) │ ┃ your repo's folder. Nothing is read or run ⎿ Read 6 lines │ ┃ from the working directory. ⏺ 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 │ ⎿ overlord-board: Board pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Board
AGENTS_REPO is not set, so the board is off: set it in your user settings (env) to your repo's folder. Nothing is read or run from the working directory.
README

overlord-board (optional)

A Claude Code mod that draws your work board above the prompt and adds a /board pane. It is optional: the framework works the same without it, and the same information is on the page agents/bin/board renders.

What it shows, from one local file ($AGENTS_REPO/agents/state/board/board.json, written by agents/bin/board):

  • the band above the prompt: BOARD · now · <next.md> · ctx NNK/150K · <session age>, then on deck · week, your top three priorities (coloured by slot), alarm rows, and a foot line tick · ran · asks · open · ceiling with a board button. 150K is the context size at which the context-watch hook first asks you to wrap up.
  • alarms: ✖ board stale (the board file is older than OVERLORD_BOARD_STALE_MIN minutes, default 60 — the board is only as fresh as the last time something rendered it), ▲ for rows the board could not parse, work in progress over the limits in agents/state/ceiling.json (wip_initiatives, ask_backpressure), arc DEAD/STALE pills, and a next.md older than 36 hours.
  • /board opens a pane: every priority and its next step, the threads waiting on you (tag = owner in threads.md), every ask, what ran unattended in the last 24 hours, the morning brief's head, and spend.
  • /board refresh runs agents/bin/board once (the only thing the mod ever runs), then opens the pane.

It spends no model tokens, writes nothing, and uses no network: all parsing of your state files stays in agents/bin/board (tested in agents/bin/test_board.py); the mod only reads the JSON it wrote. If the file is missing or is not JSON, the band is simply empty and the pane says where it looked.

Where it draws: in the terminal, the Desktop app and the VS Code extension. Not on a phone or in a Remote Control client — the hooks run, but what a mod draws appears only on the machine's own screen. The rendered page is the view for everywhere else.

Settings (environment variables, all optional)

VariableDefaultMeaning
AGENTS_REPO(required)your repo's folder. The board file and agents/bin/board are taken from here and never from the working directory (a folder you merely opened cannot plant a board or a script). Unset: the mod reads and runs nothing, and the pane says so. The kit's settings drop-in sets it
OVERLORD_BOARD_FILE$AGENTS_REPO/agents/state/board/board.jsonthe board file; honoured only as an absolute path
OVERLORD_BOARD_STALE_MIN60minutes before the band says the board is stale
OVERLORD_BOARD_PYTHONpython3the interpreter /board refresh uses (python or py on some Windows installs)

To keep the board fresh without thinking about it, render it from the tick, a timer, or a settings hook — the mod will pick up the new file within a minute.

Install

A mod is code that runs with your permissions, so it ships off. Read it first (hooks/register.tsx is one file, about 200 lines), then list what it does with claude plugin validate kit/mods/overlord-board — the calls: line shows $.fs.read (the board file), $.process.run (only for /board refresh) and the interface calls; nothing else.

Load it for one session: claude --plugin-dir <path to kit/mods/overlord-board>. To load it every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of your ~/.claude/settings.json (absolute paths joined with :, ; on Windows), keeping any keys already there, and start a new session. Off-switch: remove the entry.

Tests

claude plugin validate kit/mods/overlord-board
claude plugin test kit/mods/overlord-board

hooks/register.test.tsx has ten tests (distilling, the band, the pane, a missing or malformed file, stale and work-in-progress alarms, the path override, /board refresh). They need a Claude Code recent enough to have the plugin validate and plugin test subcommands (checked with 2.1.306); older versions simply skip the mod.

Source 2 files
hooks/register.tsx 271 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Board, Row } from '../types'
5
6// An optional board above the prompt, plus a /board pane. The data path is one local file: board.json, which
7// agents/bin/board writes (run it by hand, from a timer, or with `/board refresh`). The engine reads the file for the
8// mod ($.fs.read - the mod has no file access of its own); all parsing of the state files lives in that script and its
9// tests, and this mod only distils the JSON into a few hundred bytes of plain data, draws rows, and opens a pane.
10// Drawn in the terminal / Desktop / VS Code surfaces only, never in a Remote Control client. It reads the board; the
11// only thing it ever runs is `agents/bin/board` itself, and only when you type `/board refresh`.
12// Both the board file and agents/bin/board are resolved from AGENTS_REPO (the owner's repo, set in their user settings),
13// never from the session's working directory: a folder you merely opened cannot plant a board.json or a script here.
14// Without AGENTS_REPO the mod reads and runs nothing and says so in the pane.
15const BOARD_REL = 'agents/state/board/board.json'
16const BOARD_BIN_REL = 'agents/bin/board'
17const POLL_MS = 60_000
18const ROTATE_K = 150 // context-watch.conf WARN_AT: the attended session's "wrap up" point, in thousands of tokens
19const DEFAULT_STALE_MIN = 60 // the board is rendered on demand, so "stale" means "nobody re-ran it for this long"
20const DEFAULT_WIP = 5
21const DEFAULT_ASKS = 4
22const PANE = 'overlord-board'
23const SLOT_COLOR: Record<string, 'success' | 'suggestion' | 'warning' | 'planMode' | 'claude'> = {
24  tap: 'success', reply: 'suggestion', 'at-pc': 'warning', evening: 'planMode', decide: 'claude',
25}
26
27const board = atom({ plugin: 'overlord-board', key: 'board' } as const, null as Board | null)
28const readAt = atom({ plugin: 'overlord-board', key: 'readAt' } as const, 0)
29const settings = atom({ plugin: 'overlord-board', key: 'settings' } as const, { file: '', repo: '', staleMin: DEFAULT_STALE_MIN })
30
31// Every string from board.json is untrusted text: control characters (escape sequences that could drive the
32// terminal, C1 codes, NUL) are removed from all of it before it is kept or drawn.
33const CTRL = /[\x00-\x1f\x7f-\x9f]/g
34const text = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v)).replace(CTRL, ' ')
35const clean = (s: string): string => s.replace(CTRL, ' ').replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
36const slotColor = (slot: string) => (Object.prototype.hasOwnProperty.call(SLOT_COLOR, slot) ? SLOT_COLOR[slot] : undefined)
37const joinRepo = (repo: string, rel: string): string => `${repo.replace(/[\\/]+$/, '')}/${rel}`
38const list = (v: unknown): any[] => (Array.isArray(v) ? v : [])
39
40// board.json -> Board: the few fields the band and pane draw, nothing else kept in state
41export const distil = (d: any): Board => {
42  const cards = list(d?.cards)
43  const s = d?.strategist ?? {}
44  const n = s.next ?? {}
45  const brief = cards.find(c => c.col === 'autonomous' && c.kind === 'brief')
46  const t = d?.tick
47  const c = d?.ceiling
48  return {
49    renderedEpoch: Number(d?.rendered_epoch) || 0,
50    lastWake: clean(text(d?.last_wake)),
51    now: clean(text(n.now)),
52    deck: clean(text(n.deck)),
53    week: clean(text(n.week)),
54    nextAgeHours: Number(n.age_hours) || 0,
55    priorities: list(s.priorities).map((r: any): Row => ({
56      n: text(r['#']), slot: text(r.slot), what: clean(text(r.what)), next: clean(text(r['next (who)'])),
57    })),
58    asksOpen: Number(d?.asks_open) || 0,
59    open: cards.filter(x => x.col === 'open' || x.col === 'running').length,
60    waiting: cards.filter(x => x.col === 'waiting').map(x => clean(`${text(x.title)} ${text(x.status)}`)),
61    watching: cards.filter(x => x.col === 'watching').length,
62    acts: cards.filter(x => x.col === 'autonomous' && x.kind === 'act').map(x => clean(text(x.status))),
63    brief: brief ? clean(text(brief.excerpt)).slice(0, 600) : '',
64    tick: t ? `${text(t.finished).slice(5, 16).replace('T', ' ')} ${text(t.mode)} ${text(t.subtype)}`.trim() : 'none',
65    ceiling: c ? `$${(Number(c.usd_used) || 0).toFixed(2)}/$${text(c.usd_ceiling)}${c.spent ? ' SPENT' : ''}` : '',
66    arcAlarms: list(s.arc).filter((a: any) => a.alarms && a.alarms !== '—').map((a: any) => `${text(a.spec)} ${text(a.alarms)}`),
67    threads: list(s.threads).filter((x: any) => text(x.tag) === 'owner').map((x: any) => ({
68      thread: clean(text(x.thread)), on: clean(text(x['waiting on'])), since: text(x.since),
69    })),
70    errors: list(d?.errors).length,
71    wipInitiatives: Number(d?.limits?.wip_initiatives) || DEFAULT_WIP,
72    askBackpressure: Number(d?.limits?.ask_backpressure) || DEFAULT_ASKS,
73  }
74}
75
76const age = (ms: number): string => {
77  const m = Math.max(0, Math.round(ms / 60_000))
78  if (m < 60) return `${m}m`
79  const h = Math.floor(m / 60)
80  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
81  return `${Math.floor(h / 24)}d`
82}
83
84// alarm rows: [color, text]; a cross is blocking, a triangle a warning
85const alarms = (b: Board, now: number, staleMin: number): Array<['error' | 'warning', string]> => {
86  const out: Array<['error' | 'warning', string]> = []
87  const renderedAge = now - b.renderedEpoch * 1000
88  if (b.renderedEpoch && renderedAge > staleMin * 60_000) out.push(['error', `✖ board stale ${age(renderedAge)} - re-run agents/bin/board (or /board refresh)`])
89  if (b.errors) out.push(['warning', `▲ board could not parse ${b.errors} row(s) of the state files`])
90  if (b.open > b.wipInitiatives || b.asksOpen > b.askBackpressure) out.push(['warning', `▲ WIP ${b.open}/${b.wipInitiatives} initiatives · ${b.asksOpen}/${b.askBackpressure} asks`])
91  if (b.arcAlarms.length) out.push(['warning', `▲ arc · ${b.arcAlarms.join(' · ')}`])
92  if (b.nextAgeHours > 36) out.push(['warning', `▲ next.md is ${Math.round(b.nextAgeHours)} h old`])
93  return out
94}
95
96let tick: { cancel: () => void } | undefined
97let poll: (() => Promise<void>) | undefined
98
99export const register: Register = on => {
100  on('session.start', async ($, e, next) => {
101    const repo = ((await $.env.get('AGENTS_REPO')) || '').trim()
102    // an override is honoured only as an absolute path (the owner's env); a relative one would resolve against the cwd
103    const override = ((await $.env.get('OVERLORD_BOARD_FILE')) || '').trim()
104    const file = !repo ? '' : /^([/\\]|[A-Za-z]:[/\\])/.test(override) ? override : joinRepo(repo, BOARD_REL)
105    const staleMin = Number(await $.env.get('OVERLORD_BOARD_STALE_MIN')) || DEFAULT_STALE_MIN
106    await update($, settings, () => ({ file, repo, staleMin }))
107    poll = async () => {
108      let got: Board | null = null
109      try {
110        if (!file) throw new Error('AGENTS_REPO is not set')
111        const body = await $.fs.read(file)
112        got = distil(JSON.parse(body))
113      } catch { got = null } // missing file, unreadable, or not JSON: the band stays empty and the pane says why
114      const now = await $.clock.now()
115      await update($, board, prev => (JSON.stringify(prev) === JSON.stringify(got) ? prev : got))
116      await update($, readAt, () => now)
117    }
118    await $.command.register({ name: 'board', description: 'Open the board pane; "/board refresh" re-renders it first', argumentHint: '[refresh]' }).catch(() => null)
119    tick?.cancel()
120    await poll()
121    tick = $.clock.every(POLL_MS, () => { void poll?.() })
122    return next(e)
123  })
124
125  on('command.run', { command: 'board' }, async ($, e) => {
126    let note = ''
127    const { repo } = await read($, settings)
128    if (text(e.args).trim() === 'refresh' && !repo) {
129      note = 'AGENTS_REPO is not set: refusing to run agents/bin/board from the working directory. '
130    } else if (text(e.args).trim() === 'refresh') {
131      const py = (await $.env.get('OVERLORD_BOARD_PYTHON')) || 'python3'
132      try {
133        const r = await $.process.run([py, joinRepo(repo, BOARD_BIN_REL)])
134        note = r.exitCode === 0 || r.exitCode === 2 ? 'Board re-rendered. ' : `Board render failed (exit ${r.exitCode}). `
135      } catch { note = 'Could not start agents/bin/board. ' }
136    }
137    await poll?.()
138    const opened = await $.ui.open({ id: PANE, title: 'Board' })
139    return { text: note + (opened.isPlaced ? 'Board pane opened.' : `Board pane is open but not drawn: ${opened.reason}`) }
140  })
141
142  // a turn may have changed state that a re-render picked up; re-read once the turn ends if the last read is old
143  on('turn.complete', async ($, e, next) => {
144    const now = await $.clock.now()
145    if (now - (await read($, readAt)) > 20_000) void poll?.()
146    return next(e)
147  })
148
149  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150    const b = await read($, board)
151    if (!b || e.props.hasSurvey) return next(e)
152    const { staleMin } = await read($, settings)
153    const now = await $.clock.now()
154    const u = await $.session.usage().catch(() => null)
155    const k = u?.context.tokens != null ? Math.round(u.context.tokens / 1000) : null
156    const ctx = k == null ? 'ctx ?' : `ctx ${k}K/${ROTATE_K}K`
157    const since = u ? age(now - u.startedAt) : ''
158    const { Box, Text, Button } = $.ui.resolve(e)
159    const three = b.priorities.slice(0, 3)
160    const warn = alarms(b, now, staleMin)
161    const open = () => { void $.ui.open({ id: PANE, title: 'Board' }) }
162    // header, on deck, top three, alarms; the foot is always the last row; rows past maxRows are cut from the middle
163    const main = [
164      <Text wrap="truncate">
165        <Text color="claude" bold>BOARD</Text>
166        <Text dimColor> · now · </Text><Text>{b.now || '—'}</Text>
167        <Text dimColor> · </Text><Text color={k != null && k >= ROTATE_K ? 'warning' : undefined} dimColor={k == null || k < ROTATE_K}>{ctx}</Text>
168        {since ? <Text dimColor> · {since}</Text> : null}
169      </Text>,
170      <Text wrap="truncate">
171        <Text dimColor>on deck · </Text><Text>{b.deck || '—'}</Text>
172        <Text dimColor> · week · </Text><Text>{b.week || '—'}</Text>
173      </Text>,
174      ...three.map(r => (
175        <Text wrap="truncate">
176          <Text dimColor>{r.n} </Text><Text color={slotColor(r.slot)}>{r.slot}</Text><Text dimColor> · </Text><Text>{r.what}</Text>
177        </Text>
178      )),
179      ...warn.map(([c, t]) => <Text wrap="truncate" color={c} bold={c === 'error'}>{t}</Text>),
180    ]
181    const room = Math.max(1, e.props.maxRows - 2)
182    return (
183      <Box flexDirection="column">
184        {main.slice(0, room).map((row, i) => <Box key={`r${i}`}>{row}</Box>)}
185        <Box flexDirection="row">
186          <Text wrap="truncate" dimColor>
187            tick · {b.tick} · ran {b.acts.length} · asks {b.asksOpen} · open {b.open}{b.ceiling ? ` · ${b.ceiling}` : ''}{' '}
188          </Text>
189          <Button key="board" label="board" plain onPress={open} />
190        </Box>
191      </Box>
192    )
193  })
194
195  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196    const { Box, Text } = $.ui.resolve(e)
197    const b = await read($, board)
198    const { file, repo, staleMin } = await read($, settings)
199    const now = await $.clock.now()
200    if (!b) {
201      return (
202        <Box paddingX={1}>
203          {repo
204            ? <Text dimColor>No board at {file}. Run agents/bin/board (or /board refresh), then reopen this pane.</Text>
205            : <Text color="warning">AGENTS_REPO is not set, so the board is off: set it in your user settings (env) to your repo's folder. Nothing is read or run from the working directory.</Text>}
206        </Box>
207      )
208    }
209    const H = (t: string) => (
210      <Box marginTop={1}>
211        <Text bold dimColor>{t}</Text>
212      </Box>
213    )
214    const warn = alarms(b, now, staleMin)
215    return (
216      <Box flexDirection="column" paddingX={1}>
217        <Text wrap="wrap">
218          <Text color="claude" bold>BOARD</Text>
219          <Text dimColor> · rendered {age(now - b.renderedEpoch * 1000)} ago · {file}</Text>
220        </Text>
221        <Text wrap="wrap"><Text dimColor>now · </Text>{b.now || '—'}</Text>
222        <Text wrap="wrap"><Text dimColor>on deck · </Text>{b.deck || '—'}</Text>
223        <Text wrap="wrap"><Text dimColor>week · </Text>{b.week || '—'}</Text>
224        <Text wrap="wrap" dimColor>last wake · {b.lastWake || '—'}</Text>
225        {H('Priorities · by rank')}
226        {b.priorities.length === 0 && <Text dimColor>none ranked</Text>}
227        {b.priorities.map((r, i) => (
228          <Box key={`p${i}`} flexDirection="column">
229            <Text wrap="wrap">
230              <Text dimColor>{r.n} </Text><Text color={slotColor(r.slot)}>{r.slot}</Text><Text dimColor> · </Text>{r.what}
231            </Text>
232            <Text wrap="wrap" dimColor>    {r.next}</Text>
233          </Box>
234        ))}
235        {H(`Waiting on you (${b.threads.length})`)}
236        {b.threads.length === 0 && <Text dimColor>nothing</Text>}
237        {b.threads.map((t, i) => (
238          <Box key={`t${i}`}>
239            <Text wrap="wrap">{t.thread}<Text dimColor> · {t.on} · since {t.since}</Text></Text>
240          </Box>
241        ))}
242        {H(`Asks · ${b.asksOpen} open of ${b.waiting.length}`)}
243        {b.waiting.length === 0 && <Text dimColor>nothing</Text>}
244        {b.waiting.map((w, i) => (
245          <Box key={`a${i}`}>
246            <Text wrap="wrap">{w}</Text>
247          </Box>
248        ))}
249        {H(`Alarms (${warn.length})`)}
250        {warn.length === 0 && <Text color="success">none</Text>}
251        {warn.map(([c, t], i) => (
252          <Box key={`w${i}`}>
253            <Text wrap="wrap" color={c} bold={c === 'error'}>{t}</Text>
254          </Box>
255        ))}
256        {H(`Autonomous · last 24 h (${b.acts.length})`)}
257        {b.acts.length === 0 && <Text dimColor>nothing ran</Text>}
258        {b.acts.map((a, i) => (
259          <Box key={`x${i}`}>
260            <Text wrap="wrap">· {a}</Text>
261          </Box>
262        ))}
263        {H('Morning brief')}
264        <Text wrap="wrap" dimColor>{b.brief || 'no brief on the board'}</Text>
265        {H('Spend')}
266        <Text wrap="wrap"><Text dimColor>tick · </Text>{b.tick}<Text dimColor> · ceiling · </Text>{b.ceiling || '—'}</Text>
267      </Box>
268    )
269  })
270}
271
types/index.d.ts 33 lines
1// The distilled board the mod keeps in session state: plain data cut from board.json (agents/bin/board --json),
2// never the whole document. The board script has already withheld secret-shaped text before it wrote the file.
3export type Row = { n: string; slot: string; what: string; next: string }
4export type Thread = { thread: string; on: string; since: string }
5export type Board = {
6  renderedEpoch: number // unix seconds the board was last rendered
7  lastWake: string
8  now: string
9  deck: string
10  week: string
11  nextAgeHours: number
12  priorities: Row[] // priorities.md, ranked; the first three are shown in the band
13  asksOpen: number // open ask rows (no go recorded yet)
14  open: number // open initiatives
15  waiting: string[] // every ask card, "#n action staged"
16  watching: number
17  acts: string[] // autonomous actions in the last 24 h, newest first
18  brief: string // the morning brief's first lines
19  tick: string // "12-31 06:04 propose success" or "none"
20  ceiling: string // "$1.07/$10"
21  arcAlarms: string[] // "30 DEAD STALE" per workstream with an alarm
22  threads: Thread[] // threads tagged owner: the world is waiting on you
23  errors: number // sections or rows the board could not parse
24  wipInitiatives: number // limit from ceiling.json (default 5)
25  askBackpressure: number // limit from ceiling.json (default 4)
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'overlord-board': { board: Board | null; readAt: number; settings: { file: string; staleMin: number } }
31  }
32}
33