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

<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>
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.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A 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-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your 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-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One 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-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks 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 |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds 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-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What 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>
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.

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, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 329 lines1// 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}
329hooks/map.ts 271 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
271hooks/tree.ts 81 lines1// 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}
81types/index.d.ts 37 lines1// 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