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…

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):
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.✖ 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.
| Variable | Default | Meaning |
|---|---|---|
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.json | the board file; honoured only as an absolute path |
OVERLORD_BOARD_STALE_MIN | 60 | minutes before the band says the board is stale |
OVERLORD_BOARD_PYTHON | python3 | the 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.
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.
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.
hooks/register.tsx 271 lines1import { 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}
271types/index.d.ts 33 lines1// 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