SLOPSHOPPER

control-center

/control: a live pane of every background agent, grouped by what it waits on (needs input, plan validation, draft ready, working, waiting review, done)

panecommandtoaststatusprocesstimer
v0.1.0no licenseupdated 2026-10-24flovilmart/dotclaude/skills/control-center
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control-center
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ agents: nothing on you · 0 working updated 0 │ ┃ [ Open all on me ] [ Refresh ] ⏺ 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 │ │ › /control │ ⎿ control-center: Control center opened: press an agent to bring i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ control-center: agents: nothing on you · 0 working

Draws

Pane · Agents
agents: nothing on you · 0 working updated 0s ago [ Open all on me ] [ Refresh ]
Source 3 files
hooks/register.tsx 119 lines
1// /control: a live pane of every background agent, grouped by what it waits on.
2//
3// The data is `inbox board` (nu/shell/inbox.nu): `claude agents --json`, the watcher's waiting.json and the
4// coordinators' state files. Nothing runs until /control opens the pane; then it refreshes every 15 s, keeps
5// a summary in the status line, and toasts an agent that starts waiting on you. Pressing an agent brings it
6// up in tmux (`inbox attach`); `o` opens every agent waiting on you (`inbox open`), `r` refreshes.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Board } from '../types'
11import { COLORS, ON_ME, age, arrivals, grouped, parseBoard, splitDetail, summary, urlLabel } from './board'
12
13const PANE = 'control'
14const EVERY_MS = 15_000
15const board = atom({ plugin: 'control-center', key: 'board' } as const, { agents: [], updatedAt: 0, error: null } as Board)
16
17const inbox = (cmd: string) => ['nu', '--no-config-file', '-c', `use ~/.claude/nu/shell/inbox.nu; ${cmd}`]
18
19let timer: { cancel: () => void } | null = null
20let refreshing = false
21
22async function refresh($: EngineInterface) {
23  if (refreshing) return
24  refreshing = true
25  try {
26    const run = await $.process.run(inbox('inbox board | to json'), { timeoutMs: 20_000 })
27    const now = await $.clock.now()
28    if (run.exitCode !== 0) {
29      await update($, board, b => ({ ...b, updatedAt: now, error: run.stderr.trim().split('\n').at(-1) ?? 'inbox board failed' }))
30      return
31    }
32    const agents = parseBoard(run.stdout)
33    const before = (await read($, board)).agents
34    await update($, board, () => ({ agents, updatedAt: now, error: null }))
35    $.ui.status(summary(agents))
36    for (const a of arrivals(before, agents)) $.ui.toast(`${a.label || a.name || a.id} · ${a.next || a.waiting}`)
37  } catch (err) {
38    const now = await $.clock.now()
39    await update($, board, b => ({ ...b, updatedAt: now, error: String(err) }))
40  } finally {
41    refreshing = false
42  }
43}
44
45async function attach($: EngineInterface, id: string) {
46  if (!/^[A-Za-z0-9-]+$/.test(id)) return
47  const run = await $.process.run(inbox(`inbox attach ${id} | to json`))
48  if (run.exitCode !== 0) $.ui.toast(`Not in tmux? Run: claude attach ${id}`)
49}
50
51async function openAll($: EngineInterface) {
52  const run = await $.process.run(inbox('inbox open'), { timeoutMs: 60_000 })
53  $.ui.toast(run.exitCode === 0 ? run.stdout.trim().split('\n').at(-1) ?? 'Opened' : 'inbox open needs tmux')
54}
55
56export const register: Register = on => {
57  on('session.start', async ($, e, next) => {
58    await $.command.register({
59      name: 'control',
60      description: 'Control center: every background agent, grouped by what it waits on',
61    })
62
63    return next(e)
64  })
65
66  on('command.run', { command: 'control' }, async $ => {
67    await $.ui.open({ id: PANE, title: 'Agents', focus: true })
68    if (timer === null) timer = $.clock.every(EVERY_MS, () => void refresh($))
69    await refresh($)
70
71    return { text: 'Control center opened: press an agent to bring it up in tmux, o opens everything on you, r refreshes.' }
72  })
73
74  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75    const { Box, Text, Button, Link } = $.ui.resolve(e)
76    const b = await read($, board)
77    const now = await $.clock.now()
78    const ago = b.updatedAt === 0 ? 'loading…' : `updated ${Math.round((now - b.updatedAt) / 1000)}s ago`
79
80    return (
81      <Box flexDirection="column">
82        <Box flexDirection="row" gap={1}>
83          <Text bold>{summary(b.agents)}</Text>
84          <Text dimColor>{ago}</Text>
85        </Box>
86        <Box flexDirection="row" gap={1}>
87          <Button key="open-all" label="Open all on me" hotkey="o" onPress={() => void openAll($)} />
88          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
89        </Box>
90        {b.error !== null && <Text color="red">{b.error}</Text>}
91        {grouped(b.agents).map(g => (
92          <Box key={g.group} flexDirection="column" marginTop={1}>
93            <Text bold color={COLORS[g.group]} dimColor={COLORS[g.group] === undefined}>
94              {g.group} ({g.agents.length})
95            </Text>
96            {g.agents.map(a => {
97              const d = splitDetail(a.detail)
98              const next = a.next || d.text
99              return (
100                <Box key={a.id} flexDirection="column">
101                  <Button key={a.id} onPress={() => void attach($, a.id)}>
102                    {a.label || a.name || a.id} <Text dimColor>{age(a.started, now)}</Text>
103                  </Button>
104                  {(next !== '' || d.urls.length > 0) && (
105                    <Box flexDirection="row" gap={1} marginLeft={2}>
106                      {next !== '' && <Text dimColor={!ON_ME.includes(a.waiting)} wrap="truncate-end">→ {next}</Text>}
107                      {d.urls.map(u => <Link key={u} href={u} label={urlLabel(u)} />)}
108                    </Box>
109                  )}
110                </Box>
111              )
112            })}
113          </Box>
114        ))}
115      </Box>
116    )
117  })
118}
119
hooks/board.ts 83 lines
1// The control center's logic, without the engine: what `inbox board` printed, grouped and summarised.
2import type { Agent } from '../types'
3
4/** The groups, in the order drawn: the waits on the owner, then the rest (nu/shell/inbox.nu GROUPS) */
5export const GROUPS = ['needs input', 'needs plan validation', 'draft ready', 'working', 'waiting review', 'done'] as const
6
7/** The groups waiting on the owner */
8export const ON_ME: readonly string[] = GROUPS.slice(0, 3)
9
10const SHORT: Record<string, string> = {
11  'needs input': 'input',
12  'needs plan validation': 'plan',
13  'draft ready': 'draft',
14  working: 'working',
15  'waiting review': 'review',
16  done: 'done',
17}
18
19export const COLORS: Record<string, string> = {
20  'needs input': 'red',
21  'needs plan validation': 'yellow',
22  'draft ready': 'cyan',
23  working: 'green',
24}
25
26/** `inbox board | to json` → agents; a row missing an id or a group is dropped */
27export function parseBoard(stdout: string): Agent[] {
28  const rows: unknown = JSON.parse(stdout.trim() === '' ? '[]' : stdout)
29  if (!Array.isArray(rows)) return []
30  return rows.flatMap((r: any) =>
31    typeof r?.id === 'string' && typeof r?.waiting === 'string'
32      ? [{
33          id: r.id, name: String(r.name ?? ''), label: String(r.label ?? r.name ?? ''), waiting: r.waiting,
34          detail: String(r.detail ?? ''), next: String(r.next ?? ''), cwd: String(r.cwd ?? ''), started: String(r.started ?? ''),
35        }]
36      : [],
37  )
38}
39
40/** Agents by group, in GROUPS order, empty groups left out (an unknown group goes last) */
41export function grouped(agents: readonly Agent[]): { group: string; agents: Agent[] }[] {
42  const order = (g: string) => (GROUPS as readonly string[]).indexOf(g) === -1 ? GROUPS.length : (GROUPS as readonly string[]).indexOf(g)
43  const groups = [...new Set(agents.map(a => a.waiting))].sort((a, b) => order(a) - order(b))
44  return groups.map(group => ({ group, agents: agents.filter(a => a.waiting === group) }))
45}
46
47/** The status line: what waits on the owner, e.g. "agents: 2 input · 1 plan · 3 draft" */
48export function summary(agents: readonly Agent[]): string {
49  const parts = ON_ME.flatMap(g => {
50    const n = agents.filter(a => a.waiting === g).length
51    return n === 0 ? [] : [`${n} ${SHORT[g]}`]
52  })
53  const working = agents.filter(a => a.waiting === 'working').length
54  return parts.length === 0 ? `agents: nothing on you · ${working} working` : `agents: ${parts.join(' · ')}`
55}
56
57/** Agents that moved into a group on the owner since the previous board */
58export function arrivals(before: readonly Agent[], after: readonly Agent[]): Agent[] {
59  const was = new Map(before.map(a => [a.id, a.waiting]))
60  return after.filter(a => ON_ME.includes(a.waiting) && was.get(a.id) !== a.waiting && before.length > 0)
61}
62
63/** The URLs in a detail, and the text around them */
64export function splitDetail(detail: string): { text: string; urls: string[] } {
65  const words = detail.split(/\s+/).filter(w => w !== '')
66  const urls = words.filter(w => /^https?:\/\//.test(w))
67  return { text: words.filter(w => !urls.includes(w)).join(' '), urls }
68}
69
70/** A short label for a PR or issue URL: "#3994", else the URL */
71export function urlLabel(url: string): string {
72  const m = url.match(/\/pull\/(\d+)/)
73  return m ? `#${m[1]}` : url
74}
75
76/** "5m", "2h", "3d" since a nushell datetime string */
77export function age(started: string, now: number): string {
78  const t = Date.parse(started)
79  if (Number.isNaN(t)) return ''
80  const m = Math.max(0, Math.round((now - t) / 60000))
81  return m < 60 ? `${m}m` : m < 60 * 24 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`
82}
83
types/index.d.ts 30 lines
1/** One background session as `inbox board` lists it */
2export type Agent = {
3  id: string
4  name: string
5  /** the ticket and its short title, "PROD-1 · Fix login" (the name, or the name and the state file's title) */
6  label: string
7  /** one of GROUPS */
8  waiting: string
9  detail: string
10  /** what to do about it, one line: an instruction when it waits on the owner, else its phase */
11  next: string
12  cwd: string
13  /** when it started, as nushell writes a datetime */
14  started: string
15}
16
17export type Board = {
18  agents: Agent[]
19  /** epoch ms of the last refresh, 0 before the first */
20  updatedAt: number
21  /** why the last refresh failed, or null */
22  error: string | null
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'control-center': { board: Board }
28  }
29}
30