SLOPSHOPPER

pulpit

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

newpanebandguardcommandprompt
v0.1.0no licenseupdated 2026-10-15mpeciakk/dotfiles/.claude/mods/pulpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulpit
│ ┃ pulpit ✕ › fix the failing auth test and add an audit log call │ ┃ no active run │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /pulpit │ ⎿ pulpit: pulpit closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pulpit
no active run
README

dotfiles

Personal Arch Linux dotfiles for a niri (Wayland, scrollable-tiling) session with a custom QuickShell desktop shell, managed with dotter.

What's here

PathWhat
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.jsonshared runtime config read by the shell (wallpaper, VPN, lock) + wallpapers
config/nushell/nushell (login shell) split into config/aliases/completions/init
config/uwsm/envhost-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

Dependencies

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.

Install on a new host

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

herdr plugins

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.

Shared runtime config

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 }
}
  • The shell reads 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.

Per-host hardware

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.

Known caveats

  • Super shortcuts via 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.
  • herdr's own state is not in this repo. Only 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.
  • Lock 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.
Source 3 files
hooks/register.tsx 406 lines
1import { 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}
406
hooks/core.ts 273 lines
1// 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}
273
types/index.d.ts 122 lines
1// 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