A side panel with your Pi agents: running and recent runs, run output, and what waits for your review

Run your own Pi coding agents on an always-on Linux machine and review their work from ChatGPT, Claude, Codex or a browser. Agents are plain Markdown files. Runs happen in isolated clones and never merge anything themselves: every change comes back as a saved review, and for GitHub repositories as a draft pull request. Memory is something the agent proposes and you accept.
This is the system its author uses every day, not a sample. The fixture demo shows the dashboard with sample data. The rest of this README explains how to run the whole thing yourself.
ChatGPT / Claude / Codex ──MCP (OAuth via Cloudflare Access)──┐
▼
Mac or laptop ── agent CLI, panels ──token──▶ Hub (Cloudflare Worker + D1 + R2 + Durable Object)
▲
one outbound WebSocket, machine token │
│
Runner (Docker on a Linux box: supervisor + Pi + bubblewrap)
└─ review publisher (optional) ──▶ GitHub draft PRs
| Path | What |
|---|---|
agent-workflow/ | The agent CLI (agents as files, reviewed memory, evals, improvement loop), the operations catalog every surface uses (ops.mjs), the MCP server, the dashboard (ui/) and the runner supervisor (dev-supervisor.mjs, Linux sandbox, recovery) |
agent-readonly-service/ | The Hub: a Cloudflare Worker with MCP, Access-verified sign-in, D1 (runs, ratings, memory, decisions), R2 review bundles and the runner relay |
tools/dok-runner/ | The runner container: Dockerfile, Compose file, install and health scripts |
tools/review-publisher/ | Optional GitHub App publisher that turns saved reviews into draft PRs |
tools/run-artifact/ | Imports a run's review bundle into a local clone |
plugins/ | One plugin folder for Claude Code, Codex and Pi, plus a Claude Code side panel |
demo/, cloudflare/ | The fixture demo and its static deployment |
Each folder has its own README with the details. agent-workflow/RUNNERS.md describes how runs are routed. The runner is called dok in code and docs, after the machine it runs on; dok-dev is its id in the relay protocol.
npm ci && npm run demo # prints a local URL
You need Node 26, a Cloudflare account, a Linux host with Docker (the runner) and a model login that Pi supports.
sh npm ci && npm run setup && npm test `` Some tests need the checkout to be inside your home folder.~/.agents (AGENT_HOME), which is a git repository you own. Link the CLI and write your first agent: ``sh ln -s "$PWD/agent-workflow/agent.mjs" ~/.local/bin/agent agent list agent run <name> "a question" ` agent-workflow/README.md` documents the definition format (tools, model, workspace, memory scopes), evals and memory review.agent-readonly-service/hub.example.json to ~/.config/agents-hub/hub.json and fill it in.agent-readonly-service/migrations/ with cf./mcp.DOK_RELAY_TOKEN, MAC_TOKEN and, optionally, REVIEW_PUBLISHER_TOKEN.sh cd agent-readonly-service && npx cf deploy --dry-run && npx cf deploy ``Without the config file the build stops, so a deploy can never bind placeholders. Run HUB_LOCAL=1 with cf dev to work on it locally.
tools/dok-runner/ to the Linux host and build the image.agents-home volume and write the runner config (~/.config/agent-workflow/dok.json inside the volume: Hub endpoint, machine token, repositories, limits).tools/dok-runner/README.md covers installs, upgrades and rollback. Optional private Pi extensions are passed as a build context (AGENTS_EXTENSIONS) and are never part of this repository.
/mcp URL as a custom connector in ChatGPT or Claude.plugins/ in Claude Code, Codex or Pi (see plugins/README.md).agent ui opens the dashboard in a browser.tools/review-publisher/ next to the runner, so that reviews for enabled repositories become draft PRs.Deployment values (account ids, Access settings, hostnames, tokens) live in config files outside the repository. tools/public-check/check.mjs fails if any value from a private list appears in tracked files or in history.
Development started in a private workspace on 2026-10-09. The commits before the merge into this repository are that real history, filtered to these paths and scrubbed of deployment details. See history/README.md.
MIT. The vendored MCP Apps bridge keeps its upstream license (agent-workflow/ui/vendor/ext-apps-LICENSE).
hooks/register.tsx 379 lines1/**
2 * Agents panel: your Pi agents beside the conversation. Everything comes from the operations catalog
3 * (`agent-workflow/ops.mjs`) through `agent call <op>`; this mod only draws. Accept, Reject and Start are your
4 * clicks; the model never sees or presses them.
5 */
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface as Engine, Register } from 'claude-code'
8
9import type { AgentInfo, Detail, Form, Pending, Run, Snapshot, Tab } from '../types'
10
11// Installed plugins are copied into a cache, so the CLI is found on PATH: link agent-workflow/agent.mjs as `agent`
12// (e.g. ~/.local/bin/agent); its shebang finds node on PATH too. Apps started from the Dock get a minimal PATH,
13// so the usual user folders are added in front.
14const LAUNCH = ['/bin/sh', '-c', 'PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" exec agent "$@"', 'agent']
15const PANE = 'agents'
16const RECENT = 50
17const FAST_MS = 4000 // while something runs
18const SLOW_MS = 30000
19
20const snapshot = atom({ plugin: 'agents-panel', key: 'snapshot' } as const, null)
21const detail = atom({ plugin: 'agents-panel', key: 'detail' } as const, null)
22const error = atom({ plugin: 'agents-panel', key: 'error' } as const, null)
23const tab = atom({ plugin: 'agents-panel', key: 'tab' } as const, 'runs')
24const rejecting = atom({ plugin: 'agents-panel', key: 'rejecting' } as const, null)
25const form = atom({ plugin: 'agents-panel', key: 'form' } as const, null)
26const starting = atom({ plugin: 'agents-panel', key: 'starting' } as const, false)
27
28// The module's own: the session's folder (for agents that work "here") and the one refresh timer.
29let sessionCwd = ''
30let timer: { cancel(): void } | undefined
31
32async function call($: Engine, op: string, args: Record<string, unknown> = {}) {
33 const { exitCode, stdout, stderr } = await $.process.run([...LAUNCH, 'call', op, JSON.stringify(args)], { timeoutMs: 40000 })
34 if (exitCode !== 0) throw new Error((stderr || stdout).trim().split('\n').pop() || `agent call ${op} failed`)
35 return JSON.parse(stdout)
36}
37
38const firstLine = (t: unknown, n = 60) => {
39 const s = String(t ?? '').trim().split('\n')[0] ?? ''
40 return s.length > n ? `${s.slice(0, n - 1)}…` : s
41}
42
43function span(ms: number) {
44 const s = Math.max(0, Math.round(ms / 1000))
45 if (s < 60) return `${s}s`
46 if (s < 3600) return `${Math.round(s / 60)}m`
47 if (s < 86400) return `${Math.round(s / 3600)}h`
48 return `${Math.round(s / 86400)}d`
49}
50const ago = (iso: string | undefined, now: number) => (iso ? span(now - Date.parse(iso)) : '')
51
52const GLYPH: Record<string, string> = { running: '●', completed: '✓', failed: '✕', interrupted: '✕' }
53const statusColor = (status: string) =>
54 status === 'completed' ? 'success' : status === 'running' ? 'warning' : status === 'failed' || status === 'interrupted' ? 'error' : 'subtle'
55
56const fail = ($: Engine) => (err: unknown) => update($, error, () => String((err as Error)?.message ?? err))
57
58async function refresh($: Engine): Promise<Snapshot | null> {
59 try {
60 const d = await call($, 'open_dashboard')
61 const runs: Run[] = (d.runs ?? []).map((r: Run) => ({
62 run_id: r.run_id, agent: r.agent, status: r.status, runner: r.runner, task: firstLine(r.task ?? (r as Run & { title?: string }).title, 200), started: r.started, ended: r.ended, tool_calls: r.tool_calls, progress_summary: r.progress_summary,
63 }))
64 const snap: Snapshot = {
65 at: d.generated_at,
66 runnerSelection: d.runner_selection === 1,
67 targets: (d.run_targets ?? []).map((t: any) => ({ ...t, agents: (t.agents ?? []).map((a: any) => ({ ...a, here: a.workspace === 'here' })) })),
68 startRequests: d.start_requests ?? [],
69 source: d.source ?? 'local',
70 decisionMessage: d.decision_message ?? '',
71 counts: { running: d.status?.running ?? runs.filter(r => r.status === 'running').length,
72 review: d.status?.pending ? d.status.pending.memory + d.status.pending.proposals : (d.pending ?? []).length },
73 agents: (d.agents ?? []).map((a: { name: string; description?: string; workspace?: string; last_run?: AgentInfo['lastRun'] }) => ({
74 name: a.name, description: firstLine(a.description, 120), here: a.workspace === 'here', lastRun: a.last_run,
75 })),
76 running: runs.filter(r => r.status === 'running'),
77 recent: runs.filter(r => r.status !== 'running').slice(-RECENT).reverse(),
78 pending: (d.pending ?? []).map((p: Pending) => ({ id: p.id, kind: p.kind, agent: p.agent, why: p.why, body: String(p.body ?? '').slice(0, 2000), hub: p.hub === true || d.source === 'hub' })),
79 }
80 await update($, snapshot, () => snap)
81 await update($, error, () => null)
82 const { running, review } = snap.counts
83 $.ui.status(running || review
84 ? `agents: ${[running && `${running} running`, review && `${review} to review`].filter(Boolean).join(' · ')}`
85 : undefined)
86 return snap
87 } catch (err) {
88 await fail($)(err)
89 return null
90 }
91}
92
93// One timer chain: fast while a run is going (and the open run's output follows it), slow otherwise.
94async function tick($: Engine) {
95 timer?.cancel()
96 const snap = await refresh($)
97 const shown = await read($, detail)
98 if (shown && ['running', 'unknown'].includes(shown.status)) await openRun($, shown.run_id)
99 timer = $.clock.after(snap?.running.length ? FAST_MS : SLOW_MS, () => void tick($))
100}
101
102async function openRun($: Engine, runId: string) {
103 try {
104 const r = await call($, 'get_run', { run_id: runId })
105 const text = String(r.output || (r.status === 'running' ? '_Still running…_' : '_No output._'))
106 const took = r.ended && r.started ? span(Date.parse(r.ended) - Date.parse(r.started)) : ''
107 const tokens = r.usage?.total_tokens ?? r.usage?.totalTokens
108 const meta = [r.runner ?? 'mac', String(r.model ?? '').split('/').pop(), took, r.tool_calls != null && `${r.tool_calls} tool calls`, tokens && `${tokens} tokens`]
109 .filter(Boolean).join(' · ')
110 await update($, detail, () => ({ run_id: r.run_id, agent: r.agent, status: r.status, runner: r.runner, task: String(r.task ?? r.title ?? ''), text: text.slice(0, 20000), meta,
111 trace: r.trace ?? [['Run context', 'Unavailable — update local operations to view recorded context']],
112 error: r.error, partial: r.output_partial === true, failureRows: r.failure_rows ?? [], progressRows: r.progress_rows ?? [], configurationRows: r.configuration_rows ?? [] }))
113 } catch (err) {
114 await fail($)(err)
115 }
116}
117
118async function decide($: Engine, id: string, decision: 'accept' | 'reject', reason?: string) {
119 try {
120 const snap = await read($, snapshot)
121 if (snap?.pending.find(p => p.id === id)?.hub) throw new Error(snap.decisionMessage)
122 await call($, 'review_decide', reason ? { id, decision, reason } : { id, decision })
123 await update($, rejecting, () => null)
124 await refresh($)
125 } catch (err) {
126 await fail($)(err)
127 }
128}
129
130async function start($: Engine, f: Form, task: string) {
131 if (!task.trim() || await read($, starting)) return
132 try {
133 const snap = await read($, snapshot)
134 if (!snap?.runnerSelection) throw new Error('Update the local operations before selecting a runner.')
135 const target = snap.targets.find(t => t.id === f.runner), agent = target?.agents.find(a => a.name === f.agent)
136 if (!target?.available || !agent || agent.reason) throw new Error(target?.reason ?? agent?.reason ?? 'Select an available agent.')
137 await update($, starting, () => true)
138 const args: Record<string, unknown> = { runner: f.runner, agent: f.agent, task: task.trim(), request_id: crypto.randomUUID() }
139 if (f.runner === 'mac' && f.cwd) args.cwd = f.cwd
140 const result = await call($, 'start_selected_run', args)
141 await tick($)
142 if (!result.run_id) throw new Error(result.message ?? 'Start unresolved. Check the saved request below.')
143 await update($, form, () => null)
144 await update($, tab, () => 'runs' as Tab)
145 await openRun($, result.run_id)
146 } catch (err) { await fail($)(err) }
147 finally { await update($, starting, () => false) }
148}
149
150async function checkStart($: Engine, requestId: string) {
151 try {
152 const result = await call($, 'get_selected_start', { request_id: requestId })
153 await tick($)
154 if (result.run_id) await openRun($, result.run_id)
155 else throw new Error(result.message ?? 'Start still unresolved; no new task was submitted.')
156 } catch (err) { await fail($)(err) }
157}
158
159async function openStart($: Engine) {
160 await refresh($)
161 await update($, form, () => ({ runner: 'dok', agent: '', cwd: '' }))
162 await update($, tab, () => 'start' as Tab)
163}
164
165export const register: Register = on => {
166 on('session.start', async ($, e, next) => {
167 const started = await next(e)
168 sessionCwd = e.cwd
169 await $.command.register({ name: 'agents-panel', description: 'Open the Agents panel: runs, output, review, start a run' })
170 void $.ui.open({ id: PANE, title: 'Agents' })
171 void tick($)
172 return started
173 })
174
175 on('command.run', { command: 'agents-panel' }, async $ => {
176 await $.ui.open({ id: PANE, title: 'Agents', focus: true })
177 await refresh($)
178 return { text: 'Agents panel opened.' }
179 })
180
181 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182 const els = $.ui.resolve(e)
183 const { Box, Text, Button, Markdown } = els
184 // Fields exist on every surface but mobile: there Reject skips the reason and the Start tab is hidden.
185 const Input = 'Input' in els ? els.Input : undefined
186 const Select = 'Select' in els ? els.Select : undefined
187 const snap = await read($, snapshot)
188 const shown = await read($, detail)
189 const err = await read($, error)
190 const current = await read($, tab)
191 const rejectId = await read($, rejecting)
192 const f = await read($, form)
193 const isStarting = await read($, starting)
194 const now = snap ? Date.parse(snap.at) : 0
195 const width = Math.max(30, (e.props.bodyColumns ?? 50) - 6)
196
197 const chip = (key: string, label: string, color: string) => (
198 <Box key={key} borderStyle="round" borderDimColor paddingX={1}>
199 <Text color={color}>{label}</Text>
200 </Box>
201 )
202
203 const header = (
204 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
205 {snap && chip('c-agents', `${snap.agents.length} agents`, 'text')}
206 {snap && chip('c-running', `${snap.counts.running} running`, snap.counts.running ? 'warning' : 'subtle')}
207 {snap && chip('c-review', `${snap.counts.review} to review`, snap.counts.review ? 'warning' : 'subtle')}
208 <Button key="refresh" plain dimColor hotkey="r" onPress={() => void tick($)}>Refresh</Button>
209 </Box>
210 )
211
212 const tabs = (
213 <Box flexDirection="row" gap={1}>
214 {([['runs', 'Runs', '1'], ['review', `Review${snap?.pending.length ? ` (${snap.pending.length})` : ''}`, '2'], ['start', 'Start a run', '3'], ['agents', 'Agents', '4']] as const)
215 .filter(([id]) => id !== 'start' || (Input && Select)).map(([id, label, key]) => (
216 <Button key={`tab-${id}`} hotkey={key} variant={current === id ? 'primary' : undefined} dimColor={current !== id}
217 onPress={() => void (id === 'start' ? openStart($) : update($, tab, () => id as Tab))}>{label}</Button>
218 ))}
219 </Box>
220 )
221
222 const errorLine = err && (
223 <Box borderStyle="round" borderColor="error" paddingX={1}>
224 <Text color="error" wrap="wrap">{err}</Text>
225 </Box>
226 )
227
228 // ---- one run, full output ----
229 if (shown) {
230 return (
231 <Box flexDirection="column" gap={1}>
232 <Box flexDirection="row" gap={1} alignItems="center">
233 <Button key="back" hotkey="b" onPress={() => void update($, detail, () => null)}>← Back</Button>
234 <Text color={statusColor(shown.status)}>{`${GLYPH[shown.status] ?? '·'} ${shown.status}`}</Text>
235 <Text bold>{shown.agent}</Text>
236 </Box>
237 {errorLine}
238 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
239 <Text dimColor>Task</Text>
240 <Text wrap="wrap">{shown.task}</Text>
241 </Box>
242 {shown.meta && <Text dimColor>{shown.meta}</Text>}
243 {(shown.progressRows ?? []).map(([label, value]) => <Text key={`progress-${label}`} wrap="wrap">{`${label}: ${value}`}</Text>)}
244 {shown.error && <Text color="error" wrap="wrap">{`Error: ${shown.error}`}</Text>}
245 {(shown.failureRows ?? []).map(([label, value]) => <Text key={`failure-${label}`} wrap="wrap">{`${label}: ${value}`}</Text>)}
246 <Text bold>Configuration observed for this run</Text>
247 {(shown.configurationRows ?? [['Configuration', 'Unavailable — reopen this run to load recorded configuration']]).map(([label, value]) => <Text key={`configuration-${label}`} wrap="wrap">{`${label}: ${value}`}</Text>)}
248 {shown.partial && <Text bold>Partial output</Text>}
249 <Markdown key="output" text={shown.text} />
250 <Box flexDirection="column">
251 <Text bold>Recorded run context</Text>
252 {(shown.trace ?? [['Run context', 'Unavailable — reopen this run to load recorded context']]).map(([label, value]) => <Text key={`trace-${label}`} wrap="wrap">{`${label}: ${value}`}</Text>)}
253 </Box>
254 <Text dimColor>{shown.run_id}</Text>
255 </Box>
256 )
257 }
258
259 const runRow = (r: Run) => (
260 <Box key={`row-${r.run_id}`} flexDirection="column" paddingX={1} hover={{ backgroundColor: 'subtle' }}>
261 <Box flexDirection="row" gap={1} justifyContent="space-between">
262 <Box flexDirection="row" gap={1}>
263 <Text color={statusColor(r.status)}>{`${GLYPH[r.status] ?? '·'} ${r.runner ?? 'mac'}`}</Text>
264 <Button key={`run-${r.run_id}`} plain onPress={() => void openRun($, r.run_id)}>{r.agent}</Button>
265 </Box>
266 <Text dimColor>{r.status === 'running' ? `running ${ago(r.started, now)}` : `${ago(r.ended ?? r.started, now)} ago`}</Text>
267 </Box>
268 <Text dimColor wrap="truncate-end">{firstLine(r.task, width)}</Text>
269 {r.progress_summary && <Text wrap="wrap">{r.progress_summary}</Text>}
270 </Box>
271 )
272
273 const runsView = snap && (
274 <Box flexDirection="column" gap={1}>
275 {snap.running.length > 0 && (
276 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingY={0}>
277 <Box paddingX={1}><Text bold color="warning">Running</Text></Box>
278 {snap.running.map(runRow)}
279 </Box>
280 )}
281 <Box flexDirection="column" borderStyle="round" borderDimColor>
282 <Box paddingX={1}><Text bold>Recent runs</Text></Box>
283 {snap.recent.length === 0 && <Box paddingX={1}><Text dimColor>No runs yet.</Text></Box>}
284 {snap.recent.map(runRow)}
285 </Box>
286 </Box>
287 )
288
289 const reviewCard = (p: Pending) => (
290 <Box key={`card-${p.id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap={1}>
291 <Text color="suggestion">{`${p.kind}${p.agent ? ` · from ${p.agent}` : ''}${p.why ? ` · ${p.why}` : ''}`}</Text>
292 <Markdown key={`body-${p.id}`} text={p.body} />
293 {p.hub ? <Text dimColor wrap="wrap">{snap?.decisionMessage}</Text> : rejectId === p.id && Input ? (
294 <Box flexDirection="column" gap={1}>
295 <Input key={`reason-${p.id}`} label="Why reject? (optional)" placeholder="Reason" submitLabel="Reject" autoFocus
296 onSubmit={(value: string) => void decide($, p.id, 'reject', value.trim() || undefined)} />
297 <Button key={`cancel-${p.id}`} plain dimColor onPress={() => void update($, rejecting, () => null)}>Cancel</Button>
298 </Box>
299 ) : (
300 <Box flexDirection="row" gap={1}>
301 <Button key={`accept-${p.id}`} variant="primary" onPress={() => void decide($, p.id, 'accept')}>Accept</Button>
302 <Button key={`reject-${p.id}`} onPress={() => void (Input ? update($, rejecting, () => p.id) : decide($, p.id, 'reject'))}>{Input ? 'Reject…' : 'Reject'}</Button>
303 </Box>
304 )}
305 </Box>
306 )
307
308 const reviewView = snap && (
309 <Box flexDirection="column" gap={1}>
310 {snap.pending.length === 0 && <Text dimColor>Nothing waits for your review.</Text>}
311 {snap.pending.map(reviewCard)}
312 </Box>
313 )
314
315 const agentsView = snap && (
316 <Box flexDirection="column" gap={1}>
317 {snap.agents.map(a => (
318 <Box key={`agent-${a.name}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
319 <Text bold>{a.name}</Text>
320 <Text dimColor wrap="wrap">{a.description}</Text>
321 {a.lastRun ? <Button key={`last-${a.name}`} plain onPress={() => void openRun($, a.lastRun!.run_id)}>
322 {`Last run: ${a.lastRun.status}${a.lastRun.ended || a.lastRun.started ? ` · ${ago(a.lastRun.ended ?? a.lastRun.started, now)} ago` : ''}`}
323 </Button> : <Text dimColor>No recorded run.</Text>}
324 </Box>
325 ))}
326 </Box>
327 )
328
329 const runner = f?.runner ?? 'dok'
330 const target = snap?.targets.find(t => t.id === runner)
331 const choices = target?.agents ?? []
332 const chosen = choices.find(a => a.name === f?.agent) ?? choices[0]
333 const startView = snap && Input && Select && (
334 <Box flexDirection="column" gap={1} borderStyle="round" borderDimColor paddingX={1}>
335 <Text bold>{runner === 'mac' ? 'Runs on this Mac' : 'Runs on dok'}</Text>
336 {!snap.runnerSelection && <Text color="error">Update the local operations before selecting a runner.</Text>}
337 <Select key="runner" label="Runner" value={runner}
338 options={[{ key: 'dok', value: 'dok', label: 'dok' }, { key: 'mac', value: 'mac', label: 'This Mac' }]}
339 onSelect={(value: string) => { if (!isStarting) void update($, form, () => ({ runner: value as 'mac' | 'dok', agent: '', cwd: '' })) }} />
340 {target?.reason && <Text color="warning" wrap="wrap">{target.reason}</Text>}
341 {chosen && <Select key="agent" label="Agent" value={chosen.name}
342 options={choices.map(a => ({ key: `opt-${a.name}`, value: a.name, label: a.name }))}
343 onSelect={(value: string) => { if (!isStarting) void update($, form, () => ({ runner, agent: value, cwd: '' })) }} />}
344 {chosen && <Text dimColor wrap="wrap">{chosen.description}</Text>}
345 {chosen?.reason && <Text color="warning" wrap="wrap">{chosen.reason}</Text>}
346 <Text dimColor wrap="wrap">{runner === 'mac'
347 ? `Local directory: ${chosen?.here ? sessionCwd : chosen?.workspace ?? 'not selected'}`
348 : `Remote checkout: ${chosen?.repo ?? 'not available'}. Tasks that change files return review branches; this Mac's working tree is untouched.`}</Text>
349 {chosen?.model && <Text dimColor>{`Model: ${chosen.model}`}</Text>}
350 <Input key="task" label="Task" placeholder="What should it do?" submitLabel={isStarting ? 'Starting…' : 'Start'}
351 onSubmit={(value: string) => void start($, { runner, agent: chosen?.name ?? '', cwd: runner === 'mac' && chosen?.here ? sessionCwd : '' }, value)} />
352 {isStarting && <Text dimColor>Waiting for the original start response…</Text>}
353 {snap.startRequests.map(r => (
354 <Box key={`request-${r.request_id}`} flexDirection="column">
355 <Text wrap="wrap">{`${r.runner} · ${r.state} · ${firstLine(r.task, 80)}`}</Text>
356 <Text dimColor>{r.request_id}</Text>
357 {r.run_id ? <Button key={`request-run-${r.request_id}`} onPress={() => void openRun($, r.run_id!)}>Open run</Button>
358 : r.state === 'unknown' ? <Button key={`check-${r.request_id}`} onPress={() => void checkStart($, r.request_id)}>Check original request</Button> : <Text dimColor>{r.message}</Text>}
359 </Box>
360 ))}
361 </Box>
362 )
363
364 return (
365 <Box flexDirection="column" gap={1}>
366 {header}
367 {snap && <Text dimColor>{snap.source === 'hub' ? 'Source: Agents Hub' : `Source: ${snap.source}`}</Text>}
368 {tabs}
369 {errorLine}
370 {!snap && !err && <Text dimColor>Loading…</Text>}
371 {current === 'runs' && runsView}
372 {current === 'review' && reviewView}
373 {current === 'agents' && agentsView}
374 {current === 'start' && startView}
375 </Box>
376 )
377 })
378}
379types/index.d.ts 36 lines1export type Run = { run_id: string; agent: string; status: string; runner?: string; task: string; started: string; ended?: string; tool_calls?: number; progress_summary?: string }
2export type Pending = { id: string; kind: string; agent?: string; why?: string; body: string; hub?: boolean }
3export type AgentInfo = { name: string; description: string; here: boolean; workspace?: string; model?: string; repo?: string; reason?: string; lastRun?: { run_id: string; status: string; started?: string; ended?: string } | null }
4export type StartRequest = { request_id: string; runner: string; agent: string; task: string; state: string; run_id?: string; message?: string }
5export type RunTarget = { id: 'mac' | 'dok'; label: string; available: boolean; reason?: string; agents: AgentInfo[] }
6export type Snapshot = {
7 runnerSelection: boolean
8 targets: RunTarget[]
9 startRequests: StartRequest[]
10 at: string
11 agents: AgentInfo[]
12 running: Run[]
13 recent: Run[]
14 pending: Pending[]
15 source: string
16 decisionMessage: string
17 counts: { running: number; review: number }
18}
19export type Detail = { run_id: string; agent: string; status: string; runner?: string; task: string; text: string; meta: string; trace: [string, string][]; error?: string; partial?: boolean; failureRows?: [string, string][]; progressRows?: [string, string][]; configurationRows?: [string, string][] }
20export type Tab = 'runs' | 'review' | 'start' | 'agents'
21export type Form = { runner: 'mac' | 'dok'; agent: string; cwd: string }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'agents-panel': {
26 snapshot: Snapshot | null
27 detail: Detail | null
28 error: string | null
29 tab: Tab
30 rejecting: string | null
31 form: Form | null
32 starting: boolean
33 }
34 }
35}
36