SLOPSHOPPER

mission-control

/mission opens a live map of the main agent, its subagents and every tool call, plus a code map of the files they touch.

newpanebandguardcommandmodel
★ 167v0.1.2MITupdated 2026-10-03hamzafer/claude-code-mods/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 │ ┃ [ Who ] [ Code ] [ 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 · 3 fi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 4 files
hooks/register.tsx 329 lines
1// Mission Control: /mission opens a pane with two views of the current turn.
2//   who  · the main loop, its subagents and every tool call they make, live
3//   code · a rendered map of the files Claude reads and edits, the imports between
4//          them, a glow on what it touches right now, and one line on each change
5// A band line sums it up while Claude works.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { MapFile, MissionNode } from '../types'
10import { importsOf, svg } from './map'
11import { label, lines, summary } from './tree'
12
13const PANE = 'mission-control'
14const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
15const MAX_NODES = 300
16const MAX_FILES = 14
17const BAND_AFTER_MS = 30_000 // the band stays this long after a turn ends
18// Tools that are Claude Code's own bookkeeping, not work: kept out of the tree.
19const INTERNAL = new Set(['SubagentHandback', 'ToolSearch', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
20// Files that belong on a code map: source, config and docs, not images or binaries.
21export const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|py|go|rs|rb|java|kt|swift|c|h|cc|cpp|hpp|cs|php|vue|svelte|css|scss|html|md|mdx|json|ya?ml|toml|sh|sql|lua|ex|exs)$/i
22
23// Held by the host, so a hot reload keeps the picture.
24const nodes = atom({ plugin: 'mission-control', key: 'nodes' } as const, [] as MissionNode[])
25const files = atom({ plugin: 'mission-control', key: 'files' } as const, [] as MapFile[])
26const view = atom({ plugin: 'mission-control', key: 'view' } as const, 'who' as 'who' | 'code')
27const frame = atom({ plugin: 'mission-control', key: 'frame' } as const, null as { file: string; n: number } | null)
28const turn = atom({ plugin: 'mission-control', key: 'turn' } as const, 0)
29const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)
30
31// Drawing bookkeeping; a reload starts it over.
32const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '' }
33const changes = new Map<string, string[]>() // path → this turn's edits, for the one-line "why"
34
35// Under the cap, the oldest tool calls go first; main and the agents always stay.
36export function cap(list: MissionNode[]) {
37  if (list.length <= MAX_NODES) return list
38  const tools = list.filter(n => n.kind === 'tool')
39  const room = Math.max(0, MAX_NODES - (list.length - tools.length))
40  const keep = new Set(room === 0 ? [] : tools.slice(-room))
41  return list.filter(n => n.kind !== 'tool' || keep.has(n))
42}
43
44// The first `max` characters, cut at a word.
45function words(text: string, max: number) {
46  const t = text.replace(/\s+/g, ' ').trim()
47  if (t.length <= max) return t
48  const cut = t.lastIndexOf(' ', max)
49  return `${t.slice(0, cut > max * 0.5 ? cut : max)}…`
50}
51
52export const register: Register = on => {
53  on('session.start', async ($, e, next) => {
54    const r = await next(e)
55    await $.command.register({ name: 'mission', description: 'Open the live map of agents and tool calls; /mission code opens the code map', argumentHint: '[who|code]' }).catch(() => {})
56    $.clock.every(1000, () => {
57      void (async () => {
58        const busy = (await read($, nodes)).some(n => n.status === 'running')
59        if (busy) await update($, now, () => Date.now())
60      })().catch(() => {})
61    })
62    return r
63  })
64
65  on('turn.start', async ($, e, next) => {
66    const t = (await read($, turn)) + 1
67    await update($, turn, () => t)
68    changes.clear()
69    const main: MissionNode = { id: 'main', parent: null, kind: 'main', label: `main · ${words(e.text, 50)}`, family: 'main', status: 'running', start: Date.now() }
70    // A background agent still running from the last turn carries over, with its open calls.
71    await update($, nodes, list => {
72      const agents = list.filter(n => n.kind === 'agent' && n.status === 'running').map(a => ({ ...a, parent: 'main' }))
73      const ids = new Set(agents.map(a => a.id))
74      const open = list.filter(n => n.kind === 'tool' && n.status === 'running' && ids.has(n.parent ?? ''))
75      return [main, ...agents, ...open]
76    })
77    return next(e)
78  })
79
80  on('agent.spawn', async ($, e, next) => {
81    const r = await next(e)
82    if (r.agentId) {
83      const id = r.agentId
84      const agent: MissionNode = { id, parent: e.parentAgentId ?? 'main', kind: 'agent', label: e.description || e.subagentType, family: 'agent', status: 'running', start: Date.now() }
85      await update($, nodes, list => cap([...list, agent]))
86    }
87    return r
88  })
89
90  on('tool.call', async ($, e, next) => {
91    if (e.tool === 'Agent') return next(e) // its subagent shows up through agent.spawn
92    if (INTERNAL.has(e.tool)) return next(e) // bookkeeping, not work a person would follow
93    const args = e as unknown as Record<string, unknown>
94    const { label: text, family } = label(args)
95    const id = e.tool_use_id
96    await update($, nodes, list => {
97      // A call from an agent this tree does not know hangs off main rather than nowhere.
98      const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
99      return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
100    })
101    const path = typeof args.file_path === 'string' ? args.file_path : undefined
102    if (path) await touch($, path, e.tool === 'Read' ? 'read' : e.tool === 'Write' ? 'write' : e.tool === 'Edit' ? 'edit' : 'search', false)
103
104    let failed = true
105    let r: Awaited<ReturnType<typeof next>>
106    try {
107      r = await next(e)
108      failed = r.deny !== undefined || r.isError === true
109    } finally {
110      await update($, nodes, list => list.map(n => (n.id === id ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n)))
111    }
112    if (path && !failed && (e.tool === 'Edit' || e.tool === 'Write')) {
113      const what = e.tool === 'Edit' ? `- ${String(args.old_string ?? '').slice(0, 300)}\n+ ${String(args.new_string ?? '').slice(0, 300)}` : `wrote ${String(args.content ?? '').slice(0, 400)}`
114      changes.set(path, [...(changes.get(path) ?? []), what])
115      await touch($, path, e.tool === 'Edit' ? 'edit' : 'write', true)
116    }
117    return r
118  })
119
120  on('turn.complete', async ($, e, next) => {
121    const r = await next(e)
122    const id = e.agentId ?? 'main'
123    const failed = e.reason === 'error' || e.reason === 'aborted'
124    const isMain = !e.agentId
125    // The main turn ending closes everything it left marked running, so the clock can rest.
126    await update($, nodes, list => {
127      // Only main's own leftovers: a background agent still running keeps its open calls.
128      const live = new Set(list.filter(n => n.kind === 'agent' && n.status === 'running' && n.id !== id).map(n => n.id))
129      const close = (n: MissionNode) => n.id === id || (isMain && n.kind === 'tool' && !live.has(n.parent ?? ''))
130      return list.map(n => (close(n) && n.status === 'running' ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n))
131    })
132    if (isMain) {
133      const t = await read($, turn)
134      void explain($, t, new Map(changes)).catch(() => {}) // in the background, so the turn ends at once
135    }
136    return r
137  })
138
139  on('command.run', { command: 'mission' }, async ($, e) => {
140    const arg = e.args.trim().toLowerCase()
141    if (arg === 'code' || arg === 'who') await update($, view, () => arg)
142    await $.ui.open({ id: PANE, title: 'Mission Control', focus: true })
143    void renderSoon($)
144    const s = summary(await read($, nodes))
145    return { text: `Mission Control: ${s.agents} agents · ${s.tools} tool calls · ${(await read($, files)).length} files. w: who · c: code · q: close` }
146  })
147
148  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149    const { Box, Text, Button } = $.ui.resolve(e)
150    await read($, now) // subscribes the pane to the clock
151    const v = await read($, view)
152    const rows = Math.max(6, (e.props.scroll?.bodyRows ?? 30) - 3)
153    const columns = Math.max(20, e.props.bodyColumns)
154    const head = (
155      <Box flexDirection="row" gap={1}>
156        <Button key="who" label="Who" hotkey="w" variant={v === 'who' ? 'primary' : undefined} onPress={() => update($, view, () => 'who')} />
157        <Button key="code" label="Code" hotkey="c" variant={v === 'code' ? 'primary' : undefined} onPress={() => void setCode($)} />
158        <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
159      </Box>
160    )
161
162    if (v === 'code') {
163      if (e.surface !== 'terminal') {
164        return (
165          <Box flexDirection="column">
166            {head}
167            <Text dimColor>The code map draws in the terminal.</Text>
168          </Box>
169        )
170      }
171      const { Image } = $.ui.resolve(e)
172      // A fixed 16:10 shape from the pane's width: lines coming and going above the prompt
173      // change the pane's height, and must not resize (and jolt) the picture.
174      const imageRows = Math.min(rows, Math.round(columns * 0.3))
175      if (draw.size.columns !== columns || draw.size.rows !== imageRows) {
176        draw.size = { columns, rows: imageRows }
177        void renderSoon($)
178      }
179      const f = await read($, frame)
180      return (
181        <Box flexDirection="column">
182          {head}
183          {f ? (
184            <Image key="map" source={{ file: f.file, format: 'png', generation: f.n }} columns={draw.size.columns} rows={draw.size.rows} alt="Code map" />
185          ) : (
186            <Text dimColor>{draw.error || 'Drawing the code map…'}</Text>
187          )}
188        </Box>
189      )
190    }
191
192    const at = Date.now()
193    const ls = lines(await read($, nodes), at)
194    const s = summary(await read($, nodes))
195    return (
196      <Box flexDirection="column">
197        {head}
198        <Text dimColor>{`${s.agents} agents (${s.running} running) · ${s.tools} tool calls${s.failed ? ` · ${s.failed} failed` : ''}`}</Text>
199        {ls.length === 0 && <Text dimColor>Nothing yet. Start a task and watch it fill in.</Text>}
200        {ls.slice(-rows + 1).map(l => (
201          <Text wrap="truncate-end">
202            <Text dimColor>{l.prefix}</Text>
203            <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'}>
204              {l.text}
205            </Text>
206          </Text>
207        ))}
208      </Box>
209    )
210  })
211
212  // The band: one summary line while Claude works, and for a short while after.
213  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
214    const rest = await next(e) // what other mods and Claude Code draw here stays
215    const list = await read($, nodes)
216    const main = list.find(n => n.kind === 'main')
217    const s = summary(list)
218    const isRecent = main !== undefined && (main.status === 'running' || Date.now() - (main.end ?? 0) < BAND_AFTER_MS)
219    if (e.props.hasSurvey || !isRecent || s.tools + s.agents === 0) return rest
220    const t = await read($, turn)
221    const edited = (await read($, files)).filter(f => f.changedTurn === t).length
222    const { Box, Text } = $.ui.resolve(e)
223    return (
224      <Box flexDirection="column">
225        <Text wrap="truncate-end">
226          <Text color="blue" bold>{' ◈ mission: '}</Text>
227          <Text>{`${s.agents ? `${s.running}/${s.agents} agents · ` : ''}${s.tools} tools${s.failed ? ` · ${s.failed} failed` : ''}${edited ? ` · ${edited} files changed` : ''}`}</Text>
228          <Text dimColor>{'  ·  /mission'}</Text>
229        </Text>
230        {rest}
231      </Box>
232    )
233  })
234}
235
236async function setCode($: EngineInterface) {
237  await update($, view, () => 'code')
238  await renderSoon($)
239}
240
241// Notes a file Claude touched, with the relative imports it has.
242async function touch($: EngineInterface, path: string, act: MapFile['act'], isChange: boolean) {
243  if (!CODE_FILE.test(path)) return // an image, a PDF, a lockfile: not code to map
244  const t = await read($, turn)
245  const text = await $.fs.read(path).catch(() => '')
246  const imports = typeof text === 'string' ? importsOf(path, text.slice(0, 200_000)) : [] // imports sit near the top
247  await update($, files, list => {
248    const old = list.find(f => f.path === path)
249    const next: MapFile = { path, act, at: Date.now(), changedTurn: isChange ? t : (old?.changedTurn ?? 0), imports: imports.length ? imports : (old?.imports ?? []), why: isChange ? undefined : old?.why }
250    return [...list.filter(f => f.path !== path), next].sort((a, b) => b.at - a.at).slice(0, MAX_FILES)
251  })
252  await renderSoon($)
253  $.clock.after(5500, () => void renderSoon($).catch(() => {})) // again once the glow has faded
254}
255
256// One plain line per changed file, from what changed this turn.
257async function explain($: EngineInterface, forTurn: number, edits: Map<string, string[]>) {
258  if (edits.size === 0) return
259  const paths = [...edits.keys()]
260  const plain = (t: string) => t.replace(/[<>"]/g, ' ')
261  const body = paths.map((p, i) => `<file n="${i + 1}" name="${plain(p.split('/').slice(-2).join('/'))}">\n${plain((edits.get(p) ?? []).join('\n').slice(0, 1500))}\n</file>`).join('\n')
262  const r = await $.model.complete({
263    model: 'haiku',
264    maxTokens: 400,
265    system: 'For each file, say in plain words what changed, at most 60 characters, no em dashes. Reply with a JSON array of strings only, one per file, in the same order.',
266    prompt: `${body}\n\nReply with the JSON array only: ${paths.length} strings.`,
267  })
268  if (!r.isAnswered) return
269  let whys: unknown
270  try {
271    whys = JSON.parse(r.text.slice(r.text.indexOf('['), r.text.lastIndexOf(']') + 1))
272  } catch {
273    return
274  }
275  if (!Array.isArray(whys)) return
276  // By position, so nothing has to match a path the model may have rewritten.
277  const byPath = new Map(paths.map((p, i) => [p, typeof whys[i] === 'string' ? (whys[i] as string).replace(/\s*—\s*/g, ', ').slice(0, 80) : undefined]))
278  // A slow answer from an earlier turn must not label this turn's changes.
279  if ((await read($, turn)) !== forTurn) return
280  await update($, files, list => list.map(f => (byPath.get(f.path) && f.changedTurn === forTurn ? { ...f, why: byPath.get(f.path) } : f)))
281  await renderSoon($)
282}
283
284// Draws the code map at most about once a second, only while someone looks at it.
285async function renderSoon($: EngineInterface) {
286  if (draw.pending) {
287    draw.dirty = true // drawing now: draw once more when it is done
288    return
289  }
290  if ((await read($, view)) !== 'code') return
291  const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
292  if (!isUp) return
293  draw.pending = true
294  draw.dirty = false
295  $.clock.after(700, () => {
296    void renderMap($)
297      .catch(() => {})
298      .finally(() => {
299        draw.pending = false // only now: two Chromes must never share the profile
300        if (draw.dirty) void renderSoon($).catch(() => {})
301      })
302  })
303}
304
305async function renderMap($: EngineInterface) {
306  if (!(await $.fs.exists(CHROME))) {
307    draw.error = `The code map needs Google Chrome at ${CHROME}.`
308    await update($, now, () => Date.now()) // redraw the pane with the message
309    return
310  }
311  draw.error = ''
312  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
313  const dir = `${tmp}/mission-control`
314  const n = ++draw.n
315  const width = Math.round(draw.size.columns * 9)
316  const height = Math.round(draw.size.rows * 19)
317  const svgPath = `${dir}/map.svg`
318  const png = `${dir}/map-${n % 2}.png` // two files in turn, so the shown one is never half written
319  await $.fs.write(svgPath, svg(await read($, files), { width, height, at: Date.now(), turn: await read($, turn) }))
320  // Headless Chrome writes the screenshot but does not always exit: wait for the file,
321  // then close that Chrome (its own throwaway profile, never the person's browser).
322  const script =
323    'mkdir -p "$(dirname "$2")"; rm -f "$2"; "$1" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=2 --no-first-run --no-default-browser-check ' +
324    '--user-data-dir="$3" --window-size="$4" --screenshot="$2" "$5" >/dev/null 2>&1 & pid=$!; ' +
325    'i=0; while [ ! -s "$2" ] && [ $i -lt 150 ]; do sleep 0.1; i=$((i+1)); done; sleep 0.2; pkill -P $pid 2>/dev/null; kill $pid 2>/dev/null; [ -s "$2" ]'
326  const r = await $.process.run(['sh', '-c', script, 'mission-control', CHROME, png, `${dir}/chrome`, `${width},${height}`, `file://${encodeURI(svgPath)}`], { timeoutMs: 20_000 })
327  if (r.exitCode === 0) await update($, frame, () => ({ file: png, n }))
328}
329
hooks/map.ts 271 lines
1// The "code" view: files Claude touched, the imports between them, drawn as an SVG.
2import type { MapFile } from '../types'
3
4const stripExt = (p: string) => p.replace(/\.(tsx?|jsx?|mjs|cjs|py|go|rs|rb|vue|svelte)$/, '').replace(/\/index$/, '')
5
6function join(dir: string, rel: string) {
7  const parts = dir.split('/')
8  for (const seg of rel.split('/')) {
9    if (seg === '..') parts.pop()
10    else if (seg !== '.') parts.push(seg)
11  }
12  return parts.join('/')
13}
14
15// Relative imports in a source file, resolved against its folder, extension stripped.
16export function importsOf(path: string, text: string): string[] {
17  const dir = path.slice(0, path.lastIndexOf('/'))
18  const found = new Set<string>()
19  const js = /(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)['"](\.{1,2}\/[^'"]+)['"]/g
20  for (const m of text.matchAll(js)) found.add(stripExt(join(dir, m[1] as string)))
21  const py = /^\s*from\s+(\.+)([\w.]*)\s+import/gm
22  for (const m of text.matchAll(py)) {
23    const ups = (m[1] as string).length - 1
24    let base = dir
25    for (let i = 0; i < ups; i++) base = base.slice(0, base.lastIndexOf('/'))
26    if (m[2]) found.add(`${base}/${(m[2] as string).replace(/\./g, '/')}`)
27  }
28  return [...found]
29}
30
31export type Edge = { from: string; to: string }
32
33export function edges(files: MapFile[]): Edge[] {
34  const byBase = new Map(files.map(f => [stripExt(f.path), f.path]))
35  const out: Edge[] = []
36  for (const f of files) for (const target of f.imports) {
37    const to = byBase.get(target)
38    if (to && to !== f.path) out.push({ from: f.path, to })
39  }
40  return out
41}
42
43// Columns by import depth: a file goes one column right of the files that import it.
44export function layout(files: MapFile[], es: Edge[]) {
45  const depth = new Map(files.map(f => [f.path, 0]))
46  for (let pass = 0; pass < files.length; pass++) {
47    let moved = false
48    for (const e of es) {
49      const want = (depth.get(e.from) ?? 0) + 1
50      if (want > (depth.get(e.to) ?? 0) && want < 5) {
51        depth.set(e.to, want)
52        moved = true
53      }
54    }
55    if (!moved) break
56  }
57  const columns: string[][] = []
58  for (const f of files) {
59    const d = depth.get(f.path) ?? 0
60    ;(columns[d] ??= []).push(f.path)
61  }
62  return columns.filter(Boolean)
63}
64
65// Cut to `max` characters at a word, with an ellipsis.
66export function fit(text: string, max: number) {
67  if (text.length <= max) return text
68  const cut = text.slice(0, max - 1)
69  const space = cut.lastIndexOf(' ')
70  return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
71}
72
73// The first line that fits at a word, then the rest.
74export function wrap(text: string, per: number): [string, string] {
75  if (text.length <= per) return [text, '']
76  const cut = text.lastIndexOf(' ', per)
77  const at = cut > per * 0.5 ? cut : per
78  return [text.slice(0, at), text.slice(at).trim()]
79}
80
81// Escapes markup, and drops control characters, one of which turns the picture into an XML error.
82const esc = (s: string) =>
83  s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
84
85export type Look = { width: number; height: number; at: number; turn: number }
86
87// The whole picture: boxes per file, arrows per import, a glow on what is being touched now.
88const SANS = `-apple-system, 'SF Pro Text', 'Helvetica Neue', sans-serif`
89const MONO = `'SF Mono', Menlo, monospace`
90const C = {
91  bg: '#0d1015', grid: '#1a1f28', card: '#151922', cardLive: '#1b2130', line: '#2a3140', text: '#e8ebf2',
92  dim: '#7d8698', faint: '#4b5263', edge: '#3a4252', edgeHot: '#6b7a96',
93  read: '#5aa9ff', edit: '#ffa24c', changed: '#3ddc84',
94}
95
96type Card = { f: MapFile; x: number; y: number; w: number; h: number; title: string; sub: string; why: [string, string] }
97
98// The whole picture: the files that changed or link to others as cards with arrows, the
99// rest as small chips; a glow on what is being touched right now.
100export function svg(files: MapFile[], look: Look): string {
101  const { width: W, height: H, at, turn } = look
102  const isChanged = (f: MapFile) => f.changedTurn === turn && turn > 0
103  const isLive = (f: MapFile) => at - f.at < 5000
104  const es = edges(files)
105  const linked = new Set(es.flatMap(e => [e.from, e.to]))
106  const main = files.filter(f => linked.has(f.path) || isChanged(f) || isLive(f) || f.act !== 'read')
107  const extra = files.filter(f => !main.includes(f))
108  const mainEs = es.filter(e => main.some(f => f.path === e.from) && main.some(f => f.path === e.to))
109
110  const title = (f: MapFile) => titleOf(f.path, files.map(x => x.path))
111
112  // Cards sized to their text, laid out in import-depth columns, centered as a whole.
113  const cols = layout(main, mainEs)
114  const colOf = new Map(cols.flatMap((col, ci) => col.map(p => [p, ci] as const)))
115  const gapX = 64
116  const gapY = 18
117  const cards = new Map<string, Card>()
118  const colW: number[] = []
119  const colH: number[] = []
120  cols.forEach((col, ci) => {
121    let w = 170
122    for (const p of col) {
123      const f = main.find(m => m.path === p) as MapFile
124      w = Math.max(w, Math.min(280, title(f).length * 8.6 + 64))
125    }
126    colW[ci] = w
127    let y = 0
128    for (const p of col) {
129      const f = main.find(m => m.path === p) as MapFile
130      const why = f.why ? wrap(f.why, Math.floor((w - 32) / 7.1)) : (['', ''] as [string, string])
131      const h = 52 + (why[0] ? 20 : 0) + (why[1] ? 17 : 0)
132      cards.set(p, { f, x: 0, y, w, h, title: title(f), sub: folder(f.path, 2), why: [why[0], fit(why[1], Math.floor((w - 32) / 7.1))] })
133      y += h + gapY
134    }
135    colH[ci] = y - gapY
136  })
137  const top = 76
138  const bottom = extra.length ? 64 : 24
139  const totalW = colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols.length - 1)
140  // Laid out at 1x, then the whole graph scales as one group, text and all: up to 1.5x to
141  // fill a roomy pane, down to fit a small one.
142  const maxH = Math.max(1, ...colH)
143  const scale = Math.min(1.5, (W - 48) / Math.max(1, totalW), (H - top - bottom - 40) / maxH)
144  let x0 = 0
145  cols.forEach((col, ci) => {
146    const y0 = (maxH - (colH[ci] ?? 0)) / 2
147    for (const p of col) {
148      const c = cards.get(p) as Card
149      c.x = x0
150      c.y = y0 + c.y
151    }
152    x0 += (colW[ci] ?? 0) + gapX
153  })
154  const tx = (W - totalW * scale) / 2
155  const ty = top + (H - top - bottom - maxH * scale) / 2
156
157  const live = files.filter(isLive).sort((a, b) => b.at - a.at)[0]
158  const changed = files.filter(isChanged).length
159  const o: string[] = []
160  o.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`)
161  o.push(`<defs>
162<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="${C.grid}"/></pattern>
163<marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,1L9,5L0,9z" fill="${C.edgeHot}"/></marker>
164<filter id="glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="9"/></filter>
165<filter id="shadow" x="-20%" y="-20%" width="140%" height="160%"><feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.45"/></filter>
166</defs>`)
167  o.push(`<rect width="${W}" height="${H}" fill="${C.bg}"/><rect width="${W}" height="${H}" fill="url(#dots)"/>`)
168
169  // Header: title, counts as pills, and what is happening right now.
170  o.push(`<text x="24" y="40" font-family="${SANS}" font-size="20" font-weight="700" fill="${C.text}">Code map</text>`)
171  let px = 132
172  for (const [label, color] of [[`${files.length} files`, C.dim], [`${changed} changed`, changed ? C.changed : C.dim]] as const) {
173    const w = label.length * 7.4 + 22
174    o.push(`<rect x="${px}" y="23" width="${w}" height="24" rx="12" fill="#161b24" stroke="${C.line}"/><text x="${px + w / 2}" y="39.5" text-anchor="middle" font-family="${SANS}" font-size="12.5" fill="${color}">${label}</text>`)
175    px += w + 8
176  }
177  if (live) {
178    const verb = live.act === 'read' ? 'reading' : live.act === 'search' ? 'searching' : 'editing'
179    const color = live.act === 'read' || live.act === 'search' ? C.read : C.edit
180    o.push(`<circle cx="${px + 10}" cy="35" r="4.5" fill="${color}"/><text x="${px + 21}" y="39.5" font-family="${SANS}" font-size="13" fill="${color}">${verb} ${esc(title(live))}</text>`)
181  }
182
183  if (files.length === 0) {
184    o.push(`<text x="${W / 2}" y="${H / 2}" text-anchor="middle" font-family="${SANS}" font-size="16" fill="${C.dim}">Files Claude reads or edits show up here</text>`)
185  }
186
187  // Arrows first, so cards sit on top of them; all of it in the scaled group.
188  o.push(`<g transform="translate(${tx.toFixed(1)},${ty.toFixed(1)}) scale(${scale.toFixed(3)})">`)
189  for (const e of mainEs) {
190    const a = cards.get(e.from)
191    const b = cards.get(e.to)
192    if (!a || !b) continue
193    const hot = isChanged(a.f) || isChanged(b.f) || isLive(a.f) || isLive(b.f)
194    const x1 = a.x + a.w
195    const y1 = a.y + Math.min(a.h / 2, 26)
196    const x2 = b.x - 4
197    const y2 = b.y + Math.min(b.h / 2, 26)
198    const from = colOf.get(e.from) ?? 0
199    const to = colOf.get(e.to) ?? 0
200    let d: string
201    if (to - from > 1) {
202      // Skips a column: arc over the cards between, so the arrow is never hidden behind one.
203      const between = [...cards.values()].filter(c => (colOf.get(c.f.path) ?? 0) > from && (colOf.get(c.f.path) ?? 0) < to)
204      const peak = Math.min(a.y, b.y, ...between.map(c => c.y)) - 34
205      const ax = a.x + a.w * 0.7
206      const bx = b.x + b.w * 0.3
207      d = `M${ax},${a.y} C${ax},${peak} ${bx},${peak} ${bx},${b.y - 4}`
208    } else if (x2 > x1 + 8) {
209      d = `M${x1},${y1} C${x1 + (x2 - x1) / 2},${y1} ${x1 + (x2 - x1) / 2},${y2} ${x2},${y2}`
210    } else {
211      d = `M${a.x + a.w / 2},${a.y + a.h} C${a.x + a.w / 2},${a.y + a.h + 30} ${b.x + b.w / 2},${b.y - 30} ${b.x + b.w / 2},${b.y - 4}`
212    }
213    o.push(`<path d="${d}" fill="none" stroke="${hot ? C.edgeHot : C.edge}" stroke-width="${hot ? 2 : 1.5}" marker-end="url(#a)"/>`)
214  }
215
216  for (const c of cards.values()) {
217    const { f } = c
218    const liveNow = isLive(f)
219    const accent = liveNow ? (f.act === 'read' || f.act === 'search' ? C.read : C.edit) : isChanged(f) ? C.changed : null
220    if (liveNow && accent) o.push(`<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" fill="${accent}" opacity="0.35" filter="url(#glow)"/>`)
221    o.push(`<g filter="url(#shadow)"><rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" fill="${liveNow ? C.cardLive : C.card}" stroke="${liveNow && accent ? accent : C.line}" stroke-width="${liveNow ? 1.5 : 1}"/></g>`)
222    if (accent) o.push(`<rect x="${c.x}" y="${c.y + 10}" width="3.5" height="${c.h - 20}" rx="1.75" fill="${accent}"/>`)
223    o.push(`<text x="${c.x + 18}" y="${c.y + 24}" font-family="${SANS}" font-size="14.5" font-weight="650" fill="${C.text}">${esc(c.title)}</text>`)
224    o.push(`<text x="${c.x + 18}" y="${c.y + 41}" font-family="${MONO}" font-size="11" fill="${C.faint}">${esc(c.sub)}</text>`)
225    const badge = liveNow ? (f.act === 'read' ? '●' : f.act === 'search' ? '●' : '✎') : isChanged(f) ? '✓' : ''
226    if (badge && accent) o.push(`<text x="${c.x + c.w - 14}" y="${c.y + 24}" text-anchor="end" font-family="${SANS}" font-size="13" fill="${accent}">${badge}</text>`)
227    if (c.why[0]) o.push(`<text x="${c.x + 18}" y="${c.y + 63}" font-family="${SANS}" font-size="12.5" fill="#aeb6c6">${esc(c.why[0])}</text>`)
228    if (c.why[1]) o.push(`<text x="${c.x + 18}" y="${c.y + 80}" font-family="${SANS}" font-size="12.5" fill="#aeb6c6">${esc(c.why[1])}</text>`)
229  }
230
231  o.push('</g>')
232
233  // Files only read and linked to nothing: small chips along the bottom.
234  if (extra.length) {
235    o.push(`<text x="24" y="${H - 36}" font-family="${SANS}" font-size="11.5" fill="${C.faint}">ALSO READ</text>`)
236    let cx = 100
237    for (const f of extra) {
238      const label = title(f)
239      const w = label.length * 7 + 22
240      if (cx + w > W - 24) break
241      o.push(`<rect x="${cx}" y="${H - 52}" width="${w}" height="24" rx="12" fill="#141820" stroke="${C.line}"/><text x="${cx + w / 2}" y="${H - 35.5}" text-anchor="middle" font-family="${SANS}" font-size="12" fill="${C.dim}">${esc(label)}</text>`)
242      cx += w + 8
243    }
244  }
245  o.push('</svg>')
246  return o.join('\n')
247}
248
249// A file's name, or with several of that name the nearest folder that tells them apart:
250// three `hooks/register.tsx` read as `merge-gate › register.tsx`, `snake › register.tsx`.
251export function titleOf(path: string, all: string[]) {
252  const name = base(path)
253  const same = all.filter(p => base(p) === name && p !== path)
254  if (same.length === 0) return name
255  const parts = path.split('/')
256  for (let k = 2; k <= parts.length; k++) {
257    const seg = parts[parts.length - k]
258    if (same.every(p => p.split('/').at(-k) !== seg)) return `${seg} › ${name}`
259  }
260  return `${folder(path)}/${name}`
261}
262
263function base(path: string) {
264  return path.split('/').pop() ?? path
265}
266
267// The last `n` folders of a path (`n` 1: the parent).
268function folder(path: string, n = 1) {
269  return path.split('/').slice(-1 - n, -1).join('/')
270}
271
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' ? ` · ${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 37 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}
12
13// One file in the code map.
14export type MapFile = {
15  path: string
16  act: 'read' | 'edit' | 'write' | 'search'
17  at: number
18  changedTurn: number // the turn number it last changed in, 0 if never
19  imports: string[] // resolved import targets, extension stripped
20  why?: string
21}
22
23export type MissionFrame = { file: string; n: number } | null
24
25declare module 'claude-code' {
26  interface PluginState {
27    'mission-control': {
28      nodes: MissionNode[]
29      files: MapFile[]
30      view: 'who' | 'code'
31      frame: MissionFrame
32      turn: number
33      now: number
34    }
35  }
36}
37