A dashboard for the development-workflow: the run, who may write the tree, the subagents, permission checks, advisor consults and run metrics, beside the…

Personal Arch Linux dotfiles for a niri (Wayland, scrollable-tiling) session with a custom QuickShell desktop shell, managed with dotter.
| Path | What |
|---|---|
config/niri/ | niri compositor (split into input/output/layout/workspace/window_rules/bindings) |
config/quickshell/ | the desktop shell (bar, launcher, lock, notifications, …) — see its own CLAUDE.md |
config/dotfiles/config.json | shared runtime config read by the shell (wallpaper, VPN, lock) + wallpapers |
config/nushell/ | nushell (login shell) split into config/aliases/completions/init |
config/uwsm/env | host-agnostic session env (GPU env is per-host, see below) |
config/herdr/ | herdr terminal workspace manager: config.toml, the herdr-lazy plugin list + lockfile, per-plugin configs |
config/{kitty,nvim,atuin,mpv,wireplumber,…} | misc app configs |
hosts/<name>/ | per-host hardware (monitor layout, GPU env) |
applications/ | Hidden=true stubs that hide stock desktop entries from the launcher |
Core: niri, uwsm, quickshell (qs), nushell, dotter. Shell/session: kitty, keyd (Super→Ctrl remaps), pipewire + wireplumber, polkit-gnome, 1password (SSH agent + autostart), qt6ct. Clipboard/media: cliphist + wl-clipboard (clipboard history), playerctl (media keys). Tools sourced by nushell: atuin, zoxide, carapace, bat. VPN (optional): tailscale, openvpn. Fonts: a JetBrainsMono Nerd Font.
No jq required — scripts parse JSON without it.
git clone <repo-url> ~/dotfiles
cd ~/dotfiles
# select this machine's host package (see "Per-host" below)
cp .dotter/local.toml.example .dotter/local.toml
$EDITOR .dotter/local.toml # set packages = ["default", "host-<name>"]
dotter deploy
nushell sources tool-init files from ~/.local/share/<tool>/init.nu; generate them once per host for the tools you use (a missing one degrades gracefully to an empty stub, so the shell still starts):
mkdir -p ~/.local/share/{atuin,zoxide}
atuin init nu | save -f ~/.local/share/atuin/init.nu
zoxide init nushell | save -f ~/.local/share/zoxide/init.nu
config/herdr/plugins.list declares the plugin set and plugins.lock pins it to exact commits; herdr-lazy reads them in place via HERDR_LAZY_LIST (set in config/uwsm/env), so neither file is symlinked. On a new host, after dotter deploy and a fresh graphical session (the export has to be in the environment herdr's server inherits):
herdr plugin install natori-hrj/herdr-lazy # only the manager; the rest comes from the lock
herdr-lazy restore # exact commits from plugins.lock ("sync" takes the latest)
herdr-lazy is not on $PATH — it lives in herdr's plugin dir under a hashed name; run it from the manage pane (prefix+shift+l) or via herdr plugin list --json. Several plugins (mirror, navigator, lazy) build from source and herdr builds with a minimal PATH that excludes ~/.cargo/bin, so the host needs a system cargo/rustc (/usr/bin), not just rustup. herdr integration status should report claude: current — it reads that from .claude/hooks/herdr-agent-state.sh, which this repo already deploys.
Host-agnostic personal settings live in config/dotfiles/config.json, deployed to ~/.config/dotfiles/config.json and read at runtime by the shell (hot-reloads on change; falls back to built-in defaults if missing):
{
"wallpaper": { "dark": "~/.config/dotfiles/wallpaper-dark.png",
"light": "~/.config/dotfiles/wallpaper-light.png" },
"vpn": { "backend": "tailscale", "privileged": "doas",
"vpnCtl": "/usr/local/bin/vpn-ctl", "openvpn": ["animativ"] },
"lock": { "name": "", "avatar": "", "timeout": 300 }
}
wallpaper (one path, or a dark/light pair shown by the theme mode) and lock.*. The pair's files come from hosts/<host>/wallpaper-{dark,light}.png, at each machine's resolution.config/quickshell/menus/vpn.sh reads vpn.openvpn[], vpn.privileged, vpn.vpnCtl.This file is the same on every host; edit it to change wallpaper/VPN endpoints.
Things that differ per machine (monitors, GPU) live under hosts/<name>/ and are deployed by a per-host dotter package selected in the (gitignored) .dotter/local.toml:
hosts/<name>/niri-output.kdl → ~/.config/niri/output.kdl (monitor layout; config.kdl does include "output.kdl").hosts/<name>/uwsm-gpu-env → ~/.config/uwsm/env.d/gpu (GPU vars; uwsm auto-sources env*.d/*).hosts/pc/herdr-mirror-hosts.toml → ~/.config/herdr-mirror/hosts.toml (which machine herdr-mirror mirrors — target names the other host and its ~/.ssh/config alias, so this one can never be shared).hosts/pc/etc/ — source of the pc's system config in /etc (sysctl, udev rules, modprobe, resolved drop-ins, warp-split-dns.service, zram, doas.conf). Not deployed by dotter: these must be root-owned copies (they are read before /home is mounted, so symlinks would dangle), and a root-owned dotter template calls sudo on every deploy — a deploy without a terminal then locks the account via faillock. Install by hand after editing: `` sudo rsync -r --chmod=F644,D755 --exclude doas.conf hosts/pc/etc/ /etc/ sudo install -m400 hosts/pc/etc/doas.conf /etc/doas.conf sudo sysctl --system && sudo udevadm control --reload && sudo systemctl daemon-reload ``.dotter/local.toml is also read by .claude/hooks/prompt-context, which names the machine in every Claude Code session — both hosts report hostname ciek, so the selected host-* package is the only thing that tells them apart. A checkout without that file — run directly, not through the deployed ~/.claude/hooks/ symlink — reports [CTX] unknown (…).
To add a machine: copy an existing hosts/<host> dir (e.g. hosts/mac) to hosts/<name>, fill in the files (niri msg outputs lists connectors), add a [host-<name>.files] block in .dotter/global.toml, and select it in local.toml.
keyd (config/keyd/default.conf): Mod+C/V/X/… are remapped to the matching Ctrl+key at the evdev layer by keyd (a root systemd service), not by niri — so they're deliberately absent from bindings.kdl. Copy/paste use Ctrl/Shift+Insert so terminals copy while Ctrl+C stays SIGINT. Needs keyd installed and keyd.service enabled on a fresh host.config.toml and the per-plugin configs are; session.json, plugins.json, plugins/github/*, the logs and sockets under ~/.config/herdr, and everything in ~/.local/state/herdr* are per-host runtime state. plugins.list/plugins.lock are pointed at, not symlinked: herdr-lazy replaces the lock with rename(2), which would swap a symlink for a regular file and silently detach it from the repo. If herdr ever rewrites config.toml the same way (herdr config reset-keys, onboarding), check ls -l ~/.config/herdr/config.toml still points here.unlock IPC (config/quickshell/IslandState.qml): qs ipc call island unlock clears the lock without authentication — a deliberate recovery hatch, reachable only by processes already running as you.hooks/register.tsx 406 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PulpitAgent, PulpitDenial, PulpitRun } from '../types'
5import {
6 EMPTY_ADVISOR,
7 EMPTY_GATE,
8 EMPTY_METRICS,
9 PANE_ID,
10 addAgent,
11 addConsults,
12 addDenial,
13 addIntervention,
14 agentLine,
15 bandLines,
16 consultLine,
17 countVerdict,
18 denialLine,
19 gateCounts,
20 gateOf,
21 isFinished,
22 mainCheckout,
23 metricsLines,
24 parseRun,
25 shortSha,
26 startedTask,
27 stateFile,
28 stepAgent,
29 syncAgents,
30 userWait,
31 watchRun,
32 writerLine,
33 writerOf,
34} from './core'
35
36const run = atom({ plugin: 'pulpit', key: 'run' } as const, null)
37const writer = atom({ plugin: 'pulpit', key: 'writer' } as const, null)
38const hasOpened = atom({ plugin: 'pulpit', key: 'hasOpened' } as const, false)
39const agents = atom({ plugin: 'pulpit', key: 'agents' } as const, [])
40const now = atom({ plugin: 'pulpit', key: 'now' } as const, 0)
41const gateChecks = atom({ plugin: 'pulpit', key: 'gate' } as const, EMPTY_GATE)
42const advisorConsults = atom({ plugin: 'pulpit', key: 'advisor' } as const, EMPTY_ADVISOR)
43const metrics = atom({ plugin: 'pulpit', key: 'metrics' } as const, EMPTY_METRICS)
44const idleSince = atom({ plugin: 'pulpit', key: 'idleSince' } as const, null)
45
46const POLL_MS = 2000
47const CLOCK_MS = 1000
48
49// What the poll remembers between ticks: which root the state file was resolved for, and the mtime it was last parsed at.
50type Poll = { root?: string; path?: string; mtimeMs?: number }
51
52async function stateFileOf($: EngineInterface, root: string): Promise<string | undefined> {
53 const listing = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root })
54 const checkout = listing.exitCode === 0 ? mainCheckout(listing.stdout) : undefined
55 return checkout && stateFile(checkout)
56}
57
58// Any failure (git missing, no file, malformed JSON) hides the Run panel: never an error line.
59async function refresh($: EngineInterface, poll: Poll) {
60 let found: PulpitRun | null = null
61 let root: string | undefined
62 // recorded only once `run` holds the parse, so a spawn's refresh mid-parse re-reads instead of returning
63 let seen: number | undefined
64 try {
65 root = await $.session.root()
66 if (root !== poll.root) {
67 poll.path = await stateFileOf($, root)
68 poll.root = root
69 poll.mtimeMs = undefined
70 }
71 if (poll.path !== undefined) {
72 const { mtimeMs } = await $.fs.stat(poll.path)
73 if (mtimeMs === poll.mtimeMs) return
74 seen = mtimeMs
75 found = parseRun(await $.fs.read(poll.path)) ?? null
76 }
77 } catch {
78 poll.mtimeMs = undefined
79 seen = undefined
80 }
81 const current = await read($, run)
82 if (JSON.stringify(current) !== JSON.stringify(found)) await update($, run, () => found)
83 if (seen !== undefined) poll.mtimeMs = seen
84 await trackMetrics($, root, found)
85}
86
87// Runs when the run or the session's root changed (an unchanged file returns above). Failures stay here: the Run panel is drawn already.
88async function trackMetrics($: EngineInterface, root: string | undefined, found: PulpitRun | null) {
89 try {
90 const at = await $.clock.now()
91 const current = await read($, metrics)
92 if (JSON.stringify(watchRun(current, found, root, at)) !== JSON.stringify(current)) {
93 await update($, metrics, latest => watchRun(latest, found, root, at))
94 }
95 } catch {
96 // the figures miss one look
97 }
98}
99
100// The writer the agent rows imply; its own atom because the panel and the band read it without the rows.
101async function publishWriter($: EngineInterface) {
102 const who = writerOf(await read($, agents))
103 if ((await read($, writer)) !== who) await update($, writer, () => who)
104}
105
106// Statuses come from `$.agent.list()`; the clock atom moves while an agent is in flight so open drawings redraw.
107async function syncAgentRows($: EngineInterface) {
108 try {
109 const listed = await $.agent.list()
110 const at = await $.clock.now()
111 const rows = await read($, agents)
112 if (syncAgents(rows, listed, at) !== rows) await update($, agents, current => syncAgents(current, listed, at))
113 if ((await read($, agents)).some(row => !isFinished(row)) || (await read($, metrics)).isCounting) await update($, now, () => at)
114 await publishWriter($)
115 } catch {
116 // an agent list that fails leaves the rows as they were, never an error line
117 }
118}
119
120type Spawned = Pick<PulpitAgent, 'id' | 'type' | 'description' | 'model'>
121
122// The run is re-read first: the ledger may have changed within the poll interval before the dispatch.
123async function trackSpawn($: EngineInterface, poll: Poll, spawned: Spawned) {
124 await refresh($, poll)
125 const at = await $.clock.now()
126 const task = spawned.type === 'implementer' ? startedTask(await read($, run)) : undefined
127 const row: PulpitAgent = { ...spawned, status: 'running', startedAt: at, ...(task === undefined ? {} : { task }) }
128 await update($, agents, rows => addAgent(rows, row))
129 await update($, now, () => at)
130 await publishWriter($)
131}
132
133async function trackStep($: EngineInterface, agentId: string, model: string, effort: PulpitAgent['effort']) {
134 const rows = await read($, agents)
135 if (stepAgent(rows, agentId, model, effort) !== rows) await update($, agents, current => stepAgent(current, agentId, model, effort))
136}
137
138// A verdict seen on its way up. A query (no tool_use_id) is no session check; a deny is a denial, counted once per call.
139// Bookkeeping failures stay here: a throw would send the `.catch` back down the chain for a second run of the check.
140async function trackCheck(
141 $: EngineInterface,
142 input: { tool: string; tool_use_id?: string; agentId?: string },
143 result: { decision: string; rule?: string; reason?: string },
144) {
145 try {
146 if (input.tool_use_id === undefined) return
147 if (result.decision !== 'deny') {
148 await update($, gateChecks, current => countVerdict(current, result))
149 return
150 }
151 const subagent = input.agentId === undefined ? undefined : (await read($, agents)).find(row => row.id === input.agentId)?.type ?? 'subagent'
152 await recordDenial($, { id: input.tool_use_id, tool: input.tool, reason: result.reason ?? '', ...(subagent === undefined ? {} : { agentType: subagent }) })
153 } catch {
154 // the counters miss one check; the call itself is untouched
155 }
156}
157
158async function recordDenial($: EngineInterface, denial: Omit<PulpitDenial, 'at'>) {
159 const at = await $.clock.now()
160 await update($, gateChecks, current => addDenial(current, { ...denial, at }))
161}
162
163// The stage is read when the step comes back: the advisor ran inside that response.
164async function trackConsults($: EngineInterface, poll: Poll, uses: readonly { name: string; startedAt: number; endedAt?: number }[]) {
165 try {
166 if (!uses.some(use => use.name === 'advisor')) return
167 await refresh($, poll)
168 const stage = (await read($, run))?.stage ?? ''
169 await update($, advisorConsults, current => addConsults(current, uses, stage))
170 } catch {
171 // the consult goes uncounted; the step's result is untouched
172 }
173}
174
175// Called before `next`, which resolves only once the prompt's turn has started: the gap is read while it is still open. A prompt typed
176// over a running turn (`turnId`) starts none and waited for nothing; any other starts one, so it ends the idle gap, whoever sent it.
177// shortcut: the subagent check is made at the prompt, not over the gap, so one that finished mid-gap leaves the whole gap counted.
178async function trackPrompt($: EngineInterface, input: { origin: { kind: string }; turnId?: string }) {
179 try {
180 const at = await $.clock.now()
181 const idle = input.turnId === undefined ? await read($, idleSince) : null
182 if (idle !== null) await update($, idleSince, () => null)
183 if (input.origin.kind !== 'composer') return
184 const hasRunning = (await $.agent.list()).some(agent => agent.status === 'running')
185 await update($, metrics, current => addIntervention(current, userWait(idle, at, hasRunning)))
186 } catch {
187 // the figures miss one prompt; the prompt itself is untouched
188 }
189}
190
191// The end of a main-loop turn: the person is waited on from here.
192async function markIdle($: EngineInterface) {
193 try {
194 const at = await $.clock.now()
195 await update($, idleSince, () => at)
196 } catch {
197 // the next wait is not counted
198 }
199}
200
201// The pane waits undrawn on a narrow terminal and is still listed, so listed means open.
202async function openUnlessClosed($: EngineInterface) {
203 const isListed = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
204 // session.start runs again on every module reload: a pane opened before and gone now was closed by the person.
205 if (isListed || !(await read($, hasOpened))) {
206 await update($, hasOpened, () => true)
207 if (!isListed) void $.ui.open({ id: PANE_ID, title: PANE_ID })
208 }
209}
210
211export const register: Register = on => {
212 const poll: Poll = {}
213 let tick: Timer | undefined
214 let clockTick: Timer | undefined
215
216 on('session.start', async ($, e, next) => {
217 await $.command.register({ name: 'pulpit', description: 'Toggle the pulpit dashboard pane' })
218 await refresh($, poll)
219 tick?.cancel()
220 tick = $.clock.every(POLL_MS, () => refresh($, poll))
221 clockTick?.cancel()
222 clockTick = $.clock.every(CLOCK_MS, () => syncAgentRows($))
223 await openUnlessClosed($)
224
225 return next(e)
226 })
227
228 // Observers: the result of `next` is passed on unchanged; the `.catch` replays it if the bookkeeping throws.
229 on('agent.spawn', async ($, e, next) => {
230 const result = await next(e)
231 if (result.deny === undefined && result.agentId !== undefined) {
232 await trackSpawn($, poll, { id: result.agentId, type: e.subagentType, description: e.description, model: result.model })
233 }
234
235 return result
236 }).catch(($, e, next) => next(e))
237
238 on('turn.step', async function* ($, e, next) {
239 if (e.agentId !== undefined) await trackStep($, e.agentId, e.model, e.effort)
240 const result = yield* next(e)
241 // a subagent's consults are not counted
242 if (e.agentId === undefined) await trackConsults($, poll, result.serverToolUses ?? [])
243
244 return result
245 }).catch(async function* ($, e, next) {
246 return yield* next(e)
247 })
248
249 on('tool.check', async ($, e, next) => {
250 const result = await next(e)
251 await trackCheck($, e, result)
252
253 return result
254 }).catch(($, e, next) => next(e))
255
256 // An ask the classifier or the person refused; the same call may already have been a tool.check deny.
257 on('classic.PermissionDenied', async ($, e, next) => {
258 const result = await next(e)
259 try {
260 await recordDenial($, {
261 id: e.tool_use_id,
262 tool: e.tool_name,
263 reason: e.reason,
264 ...(e.agent_id === undefined ? {} : { agentType: e.agent_type ?? 'subagent' }),
265 })
266 } catch {
267 // the counters miss one denial; the hook's result is untouched
268 }
269
270 return result
271 }).catch(($, e, next) => next(e))
272
273 on('prompt.submit', async ($, e, next) => {
274 await trackPrompt($, e)
275
276 return next(e)
277 }).catch(($, e, next) => next(e))
278
279 on('turn.complete', async ($, e, next) => {
280 const result = await next(e)
281 if (e.agentId === undefined) await markIdle($)
282
283 return result
284 }).catch(($, e, next) => next(e))
285
286 on('command.run', { command: 'pulpit' }, async $ => {
287 const isPlaced = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
288 if (isPlaced) {
289 await $.ui.close({ id: PANE_ID })
290 // the band reads isPlaced while drawing; nothing else redraws it after a close
291 $.ui.invalidate('ui.render')
292
293 return { text: 'pulpit closed.' }
294 }
295 await $.ui.open({ id: PANE_ID, title: PANE_ID })
296 $.ui.invalidate('ui.render')
297
298 return { text: 'pulpit opened.' }
299 })
300
301 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
302 const { Box, Text } = $.ui.resolve(e)
303 const current = await read($, run)
304 const who = await read($, writer)
305 const rows = await read($, agents)
306 const clock = await read($, now)
307 const checks = await read($, gateChecks)
308 const consults = await read($, advisorConsults)
309 const figures = current === null ? [] : metricsLines(await read($, metrics), clock)
310 const hasChecks = checks.byRule + checks.byMode + checks.asked + checks.denied > 0
311 const gate = current === null ? undefined : gateOf(current)
312
313 return (
314 <Box flexDirection="column">
315 {current === null && who === null && rows.length === 0 && !hasChecks && consults.count === 0 && <Text dimColor>no active run</Text>}
316 {current !== null && (
317 <Box flexDirection="column" marginBottom={1}>
318 <Text bold>Run</Text>
319 <Text>
320 {current.task} · {current.stage}
321 </Text>
322 {gate !== undefined && (
323 <Text bold color="warning">
324 ✋ {gate}
325 </Text>
326 )}
327 {current.ledger.map(entry => (
328 <Text
329 {...(entry.status === 'blocked' ? { color: 'error' } : {})}
330 {...(entry.status === 'done' ? { dimColor: true } : {})}
331 >
332 {/^\d+$/.test(entry.n) ? `task ${entry.n}` : entry.n} · {entry.status}
333 {entry.note ? ` — ${entry.note}` : ''}
334 </Text>
335 ))}
336 {current.worktree && <Text dimColor>worktree {current.worktree}</Text>}
337 {current.branch && <Text dimColor>branch {current.branch}</Text>}
338 {current.base && <Text dimColor>base {shortSha(current.base)}</Text>}
339 </Box>
340 )}
341 {(current !== null || who !== null) && (
342 <Box flexDirection="column">
343 <Text bold>Writer</Text>
344 <Text>{writerLine(who, current)}</Text>
345 </Box>
346 )}
347 {rows.length > 0 && (
348 <Box flexDirection="column" marginTop={1}>
349 <Text bold>Roles</Text>
350 {rows.map(row => (
351 <Text
352 {...(row.status === 'failed' || row.status === 'killed' ? { color: 'error' } : {})}
353 {...(row.status === 'completed' || row.status === 'ended' ? { dimColor: true } : {})}
354 >
355 {agentLine(row, clock)}
356 </Text>
357 ))}
358 </Box>
359 )}
360 {hasChecks && (
361 <Box flexDirection="column" marginTop={1}>
362 <Text bold>Gate</Text>
363 <Text>{gateCounts(checks)}</Text>
364 {checks.denials.map(denial => (
365 <Text>{denialLine(denial)}</Text>
366 ))}
367 </Box>
368 )}
369 {consults.count > 0 && (
370 <Box flexDirection="column" marginTop={1}>
371 <Text bold>Advisor ({consults.count})</Text>
372 {consults.recent.map(consult => (
373 <Text>{consultLine(consult)}</Text>
374 ))}
375 </Box>
376 )}
377 {figures.length > 0 && (
378 <Box flexDirection="column" marginTop={1}>
379 <Text bold>Metrics</Text>
380 {figures.map(line => (
381 <Text>{line}</Text>
382 ))}
383 </Box>
384 )}
385 </Box>
386 )
387 })
388
389 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
390 const isPlaced = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
391 if (e.props.hasSurvey || isPlaced) return next(e)
392 const running = (await read($, agents)).filter(row => !isFinished(row)).length
393 const lines = bandLines(await read($, run), await read($, writer), running, (await read($, gateChecks)).denials.at(-1) ?? null)
394 if (lines.length === 0) return next(e)
395 const { Box, Text } = $.ui.resolve(e)
396
397 return (
398 <Box flexDirection="column">
399 {lines.map(line => (
400 <Text wrap="truncate-end">{line}</Text>
401 ))}
402 </Box>
403 )
404 })
405}
406hooks/core.ts 273 lines1// Pure parts of pulpit: no `$`, no drawing. Reducers and formatters the hooks in register.tsx lean on.
2import type {
3 PulpitAdvisor,
4 PulpitAgent,
5 PulpitConsult,
6 PulpitDenial,
7 PulpitGate,
8 PulpitLedgerEntry,
9 PulpitMetrics,
10 PulpitRun,
11} from '../types'
12
13export const PANE_ID = 'pulpit'
14
15const optionalText = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
16
17// Mirrors flow-state's load_at: a wrongly typed field means the file is not the contract, so no run.
18export function parseRun(text: string): PulpitRun | undefined {
19 let raw: unknown
20 try {
21 raw = JSON.parse(text)
22 } catch {
23 return undefined
24 }
25 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
26 const state = raw as Record<string, unknown>
27 for (const key of ['stage', 'worktree', 'branch', 'task']) {
28 if (key in state && typeof state[key] !== 'string') return undefined
29 }
30 const ledger: PulpitLedgerEntry[] = []
31 if (Array.isArray(state.tasks)) {
32 for (const entry of state.tasks) {
33 if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
34 const { n, status, note } = entry as Record<string, unknown>
35 const task: PulpitLedgerEntry = { n: String(n), status: String(status) }
36 if (typeof note === 'string') task.note = note
37 ledger.push(task)
38 }
39 }
40 return {
41 task: optionalText(state.task) ?? '',
42 stage: optionalText(state.stage) ?? '',
43 plan: optionalText(state.plan),
44 worktree: optionalText(state.worktree),
45 branch: optionalText(state.branch),
46 base: optionalText(state.base),
47 ledger,
48 }
49}
50
51// The pending ✋ decision, as the words after the hand.
52export function gateOf(run: PulpitRun): string | undefined {
53 if (run.stage === 'plan' && run.plan) return 'go'
54 const reviewed = run.ledger.some(entry => entry.n === 'branch-review' && entry.status === 'done')
55 if (reviewed && run.stage !== 'finish') return 'merge / PR / keep / discard'
56 return undefined
57}
58
59// flow-state's resolution: the first entry of `git worktree list --porcelain` is the main checkout.
60export function mainCheckout(porcelain: string): string | undefined {
61 const first = porcelain.split('\n')[0] ?? ''
62 const path = first.startsWith('worktree ') ? first.slice('worktree '.length) : ''
63 return path === '' ? undefined : path
64}
65
66export function stateFile(checkout: string): string {
67 return `${checkout}/.flow/run/state.json`
68}
69
70export function shortSha(sha: string): string {
71 return sha.slice(0, 7)
72}
73
74// The latest ledger entry `started` with a task number; a named gate entry (`branch-review`) is not a task.
75export function startedTask(run: PulpitRun | null): string | undefined {
76 return run?.ledger.filter(entry => entry.status === 'started' && /^\d+$/.test(entry.n)).at(-1)?.n
77}
78
79// The caller passes only a type that writes (see writerOf).
80export function writerLine(writer: string | null, run: PulpitRun | null): string {
81 if (writer === null) return 'writes: controller'
82 if (writer !== 'implementer' || run === null) return `writes: ${writer}`
83 const task = startedTask(run)
84 const where = run.worktree?.split('/').filter(Boolean).at(-1)
85 return ['writes: implementer', task && `task ${task}`, where].filter(Boolean).join(' · ')
86}
87
88const BAND_REASON_CHARS = 60
89
90const agentsRunning = (count: number) => `${count} ${count === 1 ? 'agent' : 'agents'} running`
91
92// At most two rows: the gate, the writer and the running count, then the latest denial. With all of them the gate shares the
93// writer's row. The writer is named when there is a run or a writer in flight: "controller" alone is no news without a run.
94export function bandLines(run: PulpitRun | null, writer: string | null, running = 0, denial: PulpitDenial | null = null): string[] {
95 const gate = run === null ? undefined : gateOf(run)
96 const who = run === null && writer === null ? undefined : writerLine(writer, run)
97 const state = [who, running > 0 ? agentsRunning(running) : undefined].filter(Boolean).join(' · ')
98 const reason = denial?.reason.replace(/\s+/g, ' ').trim().slice(0, BAND_REASON_CHARS)
99 const denied = denial && ['denied:', denial.tool, reason && `— ${reason}`].filter(Boolean).join(' ')
100 const lines = [gate && `✋ ${gate}`, state, denied].filter((line): line is string => Boolean(line))
101 return lines.length > 2 ? [`${lines[0]} · ${lines[1]}`, ...lines.slice(2)] : lines
102}
103
104// hooks/flow-context READ_ONLY_AGENTS: every other type counts as a writer, the same default.
105const READ_ONLY_AGENTS = new Set(['branch-reviewer', 'plan-red-team', 'explorer', 'Explore'])
106// `ended` is ours: the engine drops a subagent from `$.agent.list()` when it ends (probed on 2.1.296 with `claude -p`: running, then
107// absent, never `completed`), so a running row that leaves the list ended without us knowing how.
108const FINISHED = new Set(['completed', 'failed', 'killed', 'ended'])
109const KEPT_FINISHED = 6
110// A spawn joins the list a moment later; a row younger than this is not yet missed from it.
111const LISTING_GRACE_MS = 2000
112
113export const isFinished = (row: PulpitAgent) => FINISHED.has(row.status)
114
115export function addAgent(rows: PulpitAgent[], row: PulpitAgent): PulpitAgent[] {
116 return [...rows.filter(existing => existing.id !== row.id), row]
117}
118
119// Returns `rows` itself when the step changes nothing, so the caller can skip the write.
120export function stepAgent(rows: PulpitAgent[], id: string, model: string, effort: PulpitAgent['effort']): PulpitAgent[] {
121 const row = rows.find(existing => existing.id === id)
122 if (row === undefined || (row.model === model && row.effort === effort)) return rows
123 const { effort: _before, ...rest } = row
124 return rows.map(existing => (existing === row ? { ...rest, model, ...(effort === undefined ? {} : { effort }) } : existing))
125}
126
127// Takes each row's status from `$.agent.list()`, stamps when it finished or resumed, drops the oldest finished rows beyond the last 6.
128// Returns `rows` itself when nothing changed. A running row the list stops naming has `ended`; a finished one stays as it was.
129export function syncAgents(rows: PulpitAgent[], listed: readonly { id: string; status: string }[], now: number): PulpitAgent[] {
130 const status = new Map(listed.map(entry => [entry.id, entry.status]))
131 let isChanged = false
132 const synced = rows.map(row => {
133 const seen = status.get(row.id)
134 if (seen === undefined && !isFinished(row) && now - row.startedAt >= LISTING_GRACE_MS) {
135 isChanged = true
136 return { ...row, status: 'ended', endedAt: now }
137 }
138 if (seen === undefined || seen === row.status) return row
139 isChanged = true
140 const updated = { ...row, status: seen }
141 if (isFinished(updated) && !isFinished(row)) return { ...updated, endedAt: now }
142 if (!isFinished(updated) && isFinished(row)) {
143 const { endedAt: _ended, ...running } = updated
144 return { ...running, startedAt: now }
145 }
146 return updated
147 })
148 const finished = synced.filter(isFinished).sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
149 const dropped = new Set(finished.slice(0, Math.max(0, finished.length - KEPT_FINISHED)))
150 if (dropped.size > 0) return synced.filter(row => !dropped.has(row))
151 return isChanged ? synced : rows
152}
153
154// The type of the latest spawned subagent in flight that may write; null leaves the controller the writer.
155export function writerOf(rows: PulpitAgent[]): string | null {
156 return rows.filter(row => row.status === 'running' && !READ_ONLY_AGENTS.has(row.type)).at(-1)?.type ?? null
157}
158
159export function formatDuration(ms: number): string {
160 const seconds = Math.max(0, Math.floor(ms / 1000))
161 const two = (n: number) => String(n).padStart(2, '0')
162 if (seconds < 60) return `${seconds}s`
163 if (seconds < 3600) return `${Math.floor(seconds / 60)}m${two(seconds % 60)}s`
164 return `${Math.floor(seconds / 3600)}h${two(Math.floor((seconds % 3600) / 60))}m`
165}
166
167// One Roles row: role · task · model · effort · description · clock, or what it ended as and how long it took.
168export function agentLine(row: PulpitAgent, now: number): string {
169 const label = { completed: 'done', failed: 'failed', killed: 'failed', ended: 'ended' }[row.status]
170 const tail = label === undefined ? formatDuration(now - row.startedAt) : `${label} ${formatDuration((row.endedAt ?? now) - row.startedAt)}`
171 return [row.type, row.task && `task ${row.task}`, row.model, row.effort, row.description, tail]
172 .filter(part => part !== undefined && part !== '')
173 .join(' · ')
174}
175
176export const EMPTY_GATE: PulpitGate = { byRule: 0, byMode: 0, asked: 0, denied: 0, denials: [] }
177export const EMPTY_ADVISOR: PulpitAdvisor = { count: 0, recent: [] }
178const KEPT_DENIALS = 5
179const KEPT_CONSULTS = 5
180
181// Allow and ask only: a deny is addDenial's, because the same refusal may come again as a classic.PermissionDenied.
182export function countVerdict(gate: PulpitGate, result: { decision: string; rule?: string }): PulpitGate {
183 if (result.decision === 'ask') return { ...gate, asked: gate.asked + 1 }
184 if (result.decision !== 'allow') return gate
185 return result.rule === undefined ? { ...gate, byMode: gate.byMode + 1 } : { ...gate, byRule: gate.byRule + 1 }
186}
187
188// shortcut: dedup knows only the last 5 ids; a duplicate arriving after five newer denials counts again, never in practice.
189export function addDenial(gate: PulpitGate, denial: PulpitDenial): PulpitGate {
190 if (gate.denials.some(seen => seen.id === denial.id)) return gate
191 return { ...gate, denied: gate.denied + 1, denials: [...gate.denials, denial].slice(-KEPT_DENIALS) }
192}
193
194export const gateCounts = (gate: PulpitGate) =>
195 `allowed ${gate.byRule} by rule · ${gate.byMode} by mode · asked ${gate.asked} · denied ${gate.denied}`
196
197export function formatClock(ms: number): string {
198 const at = new Date(ms)
199 return [at.getHours(), at.getMinutes(), at.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
200}
201
202export const denialLine = (denial: PulpitDenial) =>
203 [formatClock(denial.at), denial.tool, denial.agentType, denial.reason].filter(part => part !== undefined && part !== '').join(' · ')
204
205// Only the advisor's: a step lists every tool the API ran itself.
206export function addConsults(
207 advisor: PulpitAdvisor,
208 uses: readonly { name: string; startedAt: number; endedAt?: number }[],
209 stage: string,
210): PulpitAdvisor {
211 const consults = uses
212 .filter(use => use.name === 'advisor')
213 .map((use): PulpitConsult => ({
214 startedAt: use.startedAt,
215 stage,
216 ...(use.endedAt === undefined ? {} : { durationMs: use.endedAt - use.startedAt }),
217 }))
218 if (consults.length === 0) return advisor
219 return { count: advisor.count + consults.length, recent: [...advisor.recent, ...consults].slice(-KEPT_CONSULTS) }
220}
221
222export const consultLine = (consult: PulpitConsult) =>
223 [formatClock(consult.startedAt), consult.durationMs === undefined ? '?' : formatDuration(consult.durationMs), consult.stage]
224 .filter(part => part !== '')
225 .join(' · ')
226
227export const EMPTY_METRICS: PulpitMetrics = { run: '', stage: '', isCounting: false, interventions: 0, waitMs: 0 }
228const PAST_PLAN = new Set(['isolate', 'implement', 'inline', 'finish'])
229const GO_STAGES = new Set(['isolate', 'implement', 'inline'])
230
231// Paths are compared as written: no realpath. A run with no worktree recorded (inline) was found from this session's own repo, so the
232// session is inside it.
233export function isInside(root: string | undefined, worktree: string | undefined): boolean {
234 if (root === undefined) return false
235 if (worktree === undefined) return true
236 const base = worktree.replace(/\/+$/, '')
237 return root === base || root.startsWith(`${base}/`)
238}
239
240// What one look at the run (and where the session is) does to the figures. No run keeps them: the read may have failed for a tick, and
241// a go seen at that moment must not be lost; only a different task starts them again.
242export function watchRun(metrics: PulpitMetrics, run: PulpitRun | null, root: string | undefined, at: number): PulpitMetrics {
243 if (run === null) return metrics.isCounting ? { ...metrics, isCounting: false } : metrics
244 const own = metrics.run === run.task ? metrics : { ...EMPTY_METRICS, run: run.task }
245 const isCounting = PAST_PLAN.has(run.stage) && isInside(root, run.worktree)
246 return {
247 ...own,
248 stage: run.stage,
249 isCounting,
250 ...(own.stage === 'plan' && GO_STAGES.has(run.stage) ? { goAt: at } : {}),
251 ...(isCounting && own.joinedAt === undefined ? { joinedAt: at } : {}),
252 }
253}
254
255export function addIntervention(metrics: PulpitMetrics, waitMs: number): PulpitMetrics {
256 if (!metrics.isCounting) return metrics
257 return { ...metrics, interventions: metrics.interventions + 1, waitMs: metrics.waitMs + waitMs }
258}
259
260// The wait a prompt ends: nothing without a turn end on record, and nothing while a subagent works (the person is not what is waited on).
261export const userWait = (idleSince: number | null, at: number, hasRunning: boolean) =>
262 idleSince === null || hasRunning ? 0 : Math.max(0, at - idleSince)
263
264export function metricsLines(metrics: PulpitMetrics, now: number): string[] {
265 if (!metrics.isCounting) return []
266 const [label, since] = metrics.goAt === undefined ? ['since joined', metrics.joinedAt] : ['since go', metrics.goAt]
267 return [
268 `${label} ${formatDuration(now - (since ?? now))}`,
269 `interventions ${metrics.interventions}`,
270 `user wait ${formatDuration(metrics.waitMs)}`,
271 ]
272}
273types/index.d.ts 122 lines1// What pulpit keeps in `$.state`, and the shapes it reads from `flow-state`'s state.json.
2
3export type PulpitLedgerEntry = {
4 /** `n` of `flow-state task N STATUS`: a task number, or a named gate such as `branch-review`. */
5 n: string
6 /** `started`, `done` or `blocked` (anything else is shown as written). */
7 status: string
8 note?: string
9}
10
11export type PulpitRun = {
12 task: string
13 stage: string
14 plan?: string
15 worktree?: string
16 branch?: string
17 /** The SHA the run's worktree branched from. */
18 base?: string
19 ledger: PulpitLedgerEntry[]
20}
21
22export type PulpitAgent = {
23 /** The id `agent.spawn` answers and `$.agent.list()` lists it by. */
24 id: string
25 /** `subagentType`: the role. */
26 type: string
27 description: string
28 /** The model of the agent's latest step; before its first step, what `agent.spawn` resolved. */
29 model: string
30 /** The effort of the agent's latest step; absent before its first step or for a model without effort. */
31 effort?: string | number
32 /** The ledger entry the run had `started` when an implementer was spawned. */
33 task?: string
34 /** `$.agent.list()`'s status as last seen. */
35 status: string
36 /** When it was spawned, or last resumed (ms, `$.clock.now()`). */
37 startedAt: number
38 /** When it was first seen finished; absent while it is not. */
39 endedAt?: number
40}
41
42export type PulpitDenial = {
43 /** The `tool_use_id` of the refused call: the key that makes one denial of a `tool.check` deny and a `classic.PermissionDenied`. */
44 id: string
45 /** `$.clock.now()` when it was seen (ms). */
46 at: number
47 tool: string
48 /** The `agent_type` of the subagent whose call it was; absent for the main loop. */
49 agentType?: string
50 reason: string
51}
52
53export type PulpitGate = {
54 /** `tool.check` allowed by a settings rule (the result carries `rule`). */
55 byRule: number
56 /** `tool.check` allowed by the mode (no `rule`). */
57 byMode: number
58 /** `tool.check` said ask; who settled it is not known. */
59 asked: number
60 /** Every denial seen, one per `tool_use_id`. */
61 denied: number
62 /** The last 5 denials, oldest first. */
63 denials: PulpitDenial[]
64}
65
66export type PulpitConsult = {
67 startedAt: number
68 /** Absent when the response ended before the advisor's result. */
69 durationMs?: number
70 /** The run's stage when the step came back; empty without a run. */
71 stage: string
72}
73
74export type PulpitAdvisor = {
75 /** Main-loop consults this session. */
76 count: number
77 /** The latest 5, oldest first. */
78 recent: PulpitConsult[]
79}
80
81export type PulpitMetrics = {
82 /** The run these figures belong to (its `task`); empty before a run was seen. A different task starts them again. */
83 run: string
84 /** The run's stage at the last look: a go is a plan that is no longer plan. */
85 stage: string
86 /** `$.clock.now()` when this session saw the stage go from `plan` to `isolate`, `implement` or `inline`. */
87 goAt?: number
88 /** When this session first found itself inside the run's worktree past `plan`. */
89 joinedAt?: number
90 /** The session is inside the run's worktree and the stage is past `plan`: only then are figures kept and shown. */
91 isCounting: boolean
92 /** Prompts of the person's own (`origin.kind === 'composer'`) while counting. */
93 interventions: number
94 /** Milliseconds the session sat idle, from a main-loop turn's end to the person's next prompt, with no subagent running. */
95 waitMs: number
96}
97
98declare module 'claude-code' {
99 interface PluginState {
100 pulpit: {
101 /** The run in `<main checkout>/.flow/run/state.json`; null when there is none or it is unusable. */
102 run: PulpitRun | null
103 /** The `agent_type` of a writer subagent in flight; null while the controller writes. */
104 writer: string | null
105 /** The subagents spawned this session, in spawn order; finished ones beyond the last 6 are dropped. */
106 agents: PulpitAgent[]
107 /** `$.clock.now()` as the 1 s tick last saw it while a subagent is in flight; the Roles clocks read it, so a write redraws them. */
108 now: number
109 /** What `tool.check` and `classic.PermissionDenied` showed this session. */
110 gate: PulpitGate
111 /** The main loop's advisor consults this session. */
112 advisor: PulpitAdvisor
113 /** This session's figures for the open run. */
114 metrics: PulpitMetrics
115 /** `$.clock.now()` at the end of the latest main-loop turn, while no turn has started since; null otherwise. */
116 idleSince: number | null
117 /** True once the pane has been opened this session; a missing pane after that was closed by the person. */
118 hasOpened: boolean
119 }
120 }
121}
122