SLOPSHOPPER

mission-control

/mission opens a live map of the main agent, its subagents and every tool call.

newpaneguardcommandtimeragents
v0.1.2no licenseupdated 2026-10-15aidan-nunn-sys/claude-config/mods/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ [ Close ] │ ┃ 0 agents (0 running) · 9 tool calls · 1 ⏺ Read(src/auth.ts) │ ┃ failed ⎿ Read 6 lines │ ┃ Nothing yet. Start a task and watch it fill ⏺ Update(src/auth.ts) │ ┃ in. ⎿ 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 │ │ › /mission │ ⎿ mission-control: Mission Control: 0 agents · 9 tool calls · q: c │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission Control
[ Close ] 0 agents (0 running) · 9 tool calls · 1 failed Nothing yet. Start a task and watch it fill in.
README

claude-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 3 files
hooks/register.tsx 162 lines
1// Mission Control: /mission opens a pane showing the current turn's main agent, its
2// subagents and every tool call they make, live. A band line sums it up while Claude works.
3// (Upstream's code-map view, with its Haiku diff summaries and headless Chrome, is stripped.)
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { MissionNode, XAgent } from '../types'
8import { label, lines, summary } from './tree'
9
10const PANE = 'mission-control'
11const MAX_NODES = 300
12// Tools that are Claude Code's own bookkeeping, not work: kept out of the tree.
13const INTERNAL = new Set(['SubagentHandback', 'ToolSearch', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
14
15// Held by the host, so a hot reload keeps the picture.
16const nodes = atom({ plugin: 'mission-control', key: 'nodes' } as const, [] as MissionNode[])
17const turn = atom({ plugin: 'mission-control', key: 'turn' } as const, 0)
18const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)
19
20// xagent's external agents (Codex, Copilot, Antigravity), read-only: leaves under main.
21const external = atom({ plugin: 'xagent', key: 'agents' } as const, [] as XAgent[])
22
23// The tree's nodes plus xagent's agents. With no main node yet they stand at the root.
24async function everyone($: EngineInterface): Promise<MissionNode[]> {
25  const own = await read($, nodes)
26  const parent = own.some(n => n.id === 'main') ? 'main' : null
27  const ext = (await read($, external).catch(() => [] as XAgent[])).map(x => ({
28    id: `xagent-${x.id}`, parent, kind: 'agent' as const, label: `[${x.backend}] ${x.task}`, family: 'agent', status: x.status, start: x.startedAt, end: x.endedAt, detail: x.last,
29  }))
30  return [...own, ...ext]
31}
32
33// Under the cap, the oldest tool calls go first; main and the agents always stay.
34export function cap(list: MissionNode[]) {
35  if (list.length <= MAX_NODES) return list
36  const tools = list.filter(n => n.kind === 'tool')
37  const room = Math.max(0, MAX_NODES - (list.length - tools.length))
38  const keep = new Set(room === 0 ? [] : tools.slice(-room))
39  return list.filter(n => n.kind !== 'tool' || keep.has(n))
40}
41
42// The first `max` characters, cut at a word.
43function words(text: string, max: number) {
44  const t = text.replace(/\s+/g, ' ').trim()
45  if (t.length <= max) return t
46  const cut = t.lastIndexOf(' ', max)
47  return `${t.slice(0, cut > max * 0.5 ? cut : max)}…`
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    const r = await next(e)
53    await $.command.register({ name: 'mission', description: 'Open the live map of agents and tool calls', immediate: true }).catch(() => {})
54    $.clock.every(1000, () => {
55      void (async () => {
56        const busy = (await everyone($)).some(n => n.status === 'running')
57        if (busy) await update($, now, () => Date.now())
58      })().catch(() => {})
59    })
60    return r
61  })
62
63  on('turn.start', async ($, e, next) => {
64    const t = (await read($, turn)) + 1
65    await update($, turn, () => t)
66    const main: MissionNode = { id: 'main', parent: null, kind: 'main', label: `main · ${words(e.text, 50)}`, family: 'main', status: 'running', start: Date.now() }
67    // A background agent still running from the last turn carries over, with its open calls.
68    await update($, nodes, list => {
69      const agents = list.filter(n => n.kind === 'agent' && n.status === 'running').map(a => ({ ...a, parent: 'main' }))
70      const ids = new Set(agents.map(a => a.id))
71      const open = list.filter(n => n.kind === 'tool' && n.status === 'running' && ids.has(n.parent ?? ''))
72      return [main, ...agents, ...open]
73    })
74    return next(e)
75  })
76
77  on('agent.spawn', async ($, e, next) => {
78    const r = await next(e)
79    if (r.agentId) {
80      const id = r.agentId
81      const agent: MissionNode = { id, parent: e.parentAgentId ?? 'main', kind: 'agent', label: e.description || e.subagentType, family: 'agent', status: 'running', start: Date.now() }
82      await update($, nodes, list => cap([...list, agent]))
83    }
84    return r
85  })
86
87  on('tool.call', async ($, e, next) => {
88    if (e.tool === 'Agent') return next(e) // its subagent shows up through agent.spawn
89    if (INTERNAL.has(e.tool)) return next(e) // bookkeeping, not work a person would follow
90    const args = e as unknown as Record<string, unknown>
91    const { label: text, family } = label(args)
92    const id = e.tool_use_id
93    await update($, nodes, list => {
94      // A call from an agent this tree does not know hangs off main rather than nowhere.
95      const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
96      return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
97    })
98
99    let failed = true
100    let r: Awaited<ReturnType<typeof next>>
101    try {
102      r = await next(e)
103      failed = r.deny !== undefined || r.isError === true
104    } finally {
105      await update($, nodes, list => list.map(n => (n.id === id ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n)))
106    }
107    return r
108  })
109
110  on('turn.complete', async ($, e, next) => {
111    const r = await next(e)
112    const id = e.agentId ?? 'main'
113    const failed = e.reason === 'error' || e.reason === 'aborted'
114    const isMain = !e.agentId
115    // The main turn ending closes everything it left marked running, so the clock can rest.
116    await update($, nodes, list => {
117      // Only main's own leftovers: a background agent still running keeps its open calls.
118      const live = new Set(list.filter(n => n.kind === 'agent' && n.status === 'running' && n.id !== id).map(n => n.id))
119      const close = (n: MissionNode) => n.id === id || (isMain && n.kind === 'tool' && !live.has(n.parent ?? ''))
120      return list.map(n => (close(n) && n.status === 'running' ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n))
121    })
122    return r
123  })
124
125  on('command.run', { command: 'mission' }, async $ => {
126    await $.ui.open({ id: PANE, title: 'Mission Control', focus: true })
127    const s = summary(await everyone($))
128    return { text: `Mission Control: ${s.agents} agents · ${s.tools} tool calls · q: close` }
129  })
130
131  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132    const { Box, Text, Button } = $.ui.resolve(e)
133    await read($, now) // subscribes the pane to the clock
134    const rows = Math.max(6, (e.props.scroll?.bodyRows ?? 30) - 3)
135    const head = (
136      <Box flexDirection="row" gap={1}>
137        <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
138      </Box>
139    )
140
141    const at = Date.now()
142    const all = await everyone($)
143    const ls = lines(all, at)
144    const s = summary(all)
145    return (
146      <Box flexDirection="column">
147        {head}
148        <Text dimColor>{`${s.agents} agents (${s.running} running) · ${s.tools} tool calls${s.failed ? ` · ${s.failed} failed` : ''}`}</Text>
149        {ls.length === 0 && <Text dimColor>Nothing yet. Start a task and watch it fill in.</Text>}
150        {ls.slice(-rows + 1).map(l => (
151          <Text wrap="truncate-end">
152            <Text dimColor>{l.prefix}</Text>
153            <Text color={l.status === 'running' ? 'yellow' : l.status === 'failed' ? 'red' : l.node.kind === 'tool' ? undefined : 'green'} bold={l.node.kind !== 'tool'} dimColor={l.status === 'done' && l.node.kind === 'tool'}>
154              {l.text}
155            </Text>
156          </Text>
157        ))}
158      </Box>
159    )
160  })
161}
162
hooks/tree.ts 81 lines
1// The "who" view: main, its subagents and their tool calls, as tree lines.
2import type { MissionNode } from '../types'
3
4// A short label and a family for one tool call.
5export function label(e: Record<string, unknown>): { label: string; family: string } {
6  const tool = String(e.tool)
7  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
8  const file = (p: string) => p.split('/').slice(-2).join('/')
9  if (tool === 'Bash') return { label: s('description') || s('command').slice(0, 60), family: 'bash' }
10  if (tool === 'Edit' || tool === 'Write') return { label: file(s('file_path')), family: 'edit' }
11  if (tool === 'Read') return { label: file(s('file_path')), family: 'read' }
12  if (tool === 'Grep' || tool === 'Glob') return { label: s('pattern').slice(0, 40), family: 'search' }
13  if (tool.startsWith('mcp__')) {
14    const [, server = '', action = ''] = tool.split('__')
15    if (/playwright/.test(server)) return { label: action.replace(/^browser_/, ''), family: 'browser' }
16    return { label: action, family: `mcp:${server.replace(/^(plugin_|claude_ai_)/, '').replace(/_.*$/, '')}` }
17  }
18  return { label: tool, family: tool.toLowerCase() }
19}
20
21const ICON: Record<string, string> = { bash: '$', edit: '✎', read: '◉', search: '⌕', browser: '◎' }
22export const icon = (n: MissionNode) =>
23  n.kind === 'main' ? '◆' : n.kind === 'agent' ? '●' : (ICON[n.family] ?? (n.family.startsWith('mcp:') ? '⌁' : '·'))
24
25export function took(n: Pick<MissionNode, 'start' | 'end'>, at = Date.now()) {
26  const s = Math.max(0, ((n.end ?? at) - n.start) / 1000)
27  if (s < 10) return `${s.toFixed(1)}s`
28  if (s < 60) return `${Math.round(s)}s`
29  return `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, '0')}s`
30}
31
32export type Line = { prefix: string; node: MissionNode; text: string; count: number; status: MissionNode['status'] }
33
34// Tree lines, depth first. Runs of the same tool family under one parent fold into one
35// line ("◎ browser: navigate → click → screenshot ×3") so a busy agent stays readable.
36export function lines(nodes: MissionNode[], at = Date.now()): Line[] {
37  const kids = new Map<string | null, MissionNode[]>()
38  for (const n of nodes) kids.set(n.parent, [...(kids.get(n.parent) ?? []), n])
39  const out: Line[] = []
40  const walk = (parent: string | null, prefix: string) => {
41    const groups: MissionNode[][] = []
42    for (const n of kids.get(parent) ?? []) {
43      const last = groups.at(-1)
44      const head = last?.[0]
45      if (last && head && n.kind === 'tool' && head.kind === 'tool' && head.family === n.family) last.push(n)
46      else groups.push([n])
47    }
48    groups.forEach((g, i) => {
49      const isLast = i === groups.length - 1
50      const n = g[0] as MissionNode
51      const branch = parent === null ? '' : isLast ? '└─ ' : '├─ '
52      const status = g.some(x => x.status === 'running') ? 'running' : g.some(x => x.status === 'failed') ? 'failed' : 'done'
53      const start = Math.min(...g.map(x => x.start))
54      const end = status === 'running' ? undefined : Math.max(...g.map(x => x.end ?? x.start))
55      let text: string
56      if (n.kind === 'tool') {
57        const name = n.family.startsWith('mcp:') ? n.family.slice(4) : n.family
58        const acts = g.map(x => x.label)
59        const shown = acts.length > 4 ? [...acts.slice(0, 3), '…', acts.at(-1)] : acts
60        const time = took({ start, end }, at)
61        text = `${icon(n)} ${name}: ${shown.join(' → ')}${g.length > 1 ? `  ×${g.length}` : ''}${time === '0.0s' ? '' : `  ${time}`}`
62      } else {
63        const tools = nodes.filter(x => x.parent === n.id && x.kind === 'tool').length
64        text = `${icon(n)} ${n.label}  ${took(n, at)}${n.kind === 'agent' ? ` · ${n.detail ?? `${tools} tools`}` : ''}`
65      }
66      out.push({ prefix: prefix + branch, node: n, text, count: g.length, status })
67      if (n.kind !== 'tool') walk(n.id, parent === null ? '' : prefix + (isLast ? '   ' : '│  '))
68    })
69  }
70  walk(null, '')
71  return out
72}
73
74export function summary(nodes: MissionNode[]) {
75  const agents = nodes.filter(n => n.kind === 'agent')
76  const running = agents.filter(n => n.status === 'running').length
77  const tools = nodes.filter(n => n.kind === 'tool').length
78  const failed = nodes.filter(n => n.kind === 'tool' && n.status === 'failed').length
79  return { agents: agents.length, running, tools, failed }
80}
81
types/index.d.ts 38 lines
1// One box in the "who" tree: the main loop, a subagent, or one tool call.
2export type MissionNode = {
3  id: string
4  parent: string | null
5  kind: 'main' | 'agent' | 'tool'
6  label: string
7  family: string // for tools: 'bash', 'edit', 'read', 'browser', 'mcp:<server>', ...
8  status: 'running' | 'done' | 'failed'
9  start: number
10  end?: number
11  detail?: string // what an external agent is doing now (xagent's `last`)
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'mission-control': {
17      nodes: MissionNode[]
18      turn: number
19      now: number
20    }
21    xagent: { agents: XAgent[] }
22  }
23}
24
25// xagent's agents, as xagent writes them (mods/xagent/types: XAgent). Read-only here.
26export type XAgent = {
27  id: string
28  backend: 'codex' | 'copilot' | 'agy'
29  model: string
30  effort: string
31  task: string
32  status: 'running' | 'done' | 'failed'
33  startedAt: number
34  endedAt?: number
35  last: string
36  quota: string
37}
38