Windows fork of mission-control: /mission opens a live map of the main agent, its subagents and every tool call, and a code map the desktop app draws itself.

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">
A Windows fork of hamzafer's mission-control. /mission opens a live map of:
/mission code draws a map of the files this session touched, with each file's imports.

Upstream drew its code map by running Chrome from a macOS path, and only into a terminal. This fork draws the map with the desktop app's own Svg element, sized to the pane.
↗ Browser sends the tree and a 1400×860 map to the Mod Hub's Mission page, which is easier to read full size. It re-sends every 2 s when something changed.
2 of its 10 tests fail, the same 2 that fail on unmodified upstream with this engine (a terminal PNG path, and band order).
MIT. Upstream's copyright is kept in LICENSE. On macOS or Linux, use the original.
hooks/register.tsx 460 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.
6// Windows fork of hamzafer/claude-code-mods mission-control 0.1.3. What differs:
7// - the desktop app draws the code map itself, as an Svg element: no Chrome, no PNG
8// - in a terminal, the PNG comes from Chrome or Edge at their Windows paths, driven through Git Bash
9// - Windows paths (C:\a\b) are read with forward slashes, so titles and imports resolve
10// - the desktop map is drawn at the pane's own size, so its text stays readable
11// - ↗ Browser: the same live view, full size, in the Mod Hub's #/mission page
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { MapFile, MissionNode } from '../types'
16import { importsOf, svg } from './map'
17import { label, lines, summary } from './tree'
18import { bandsFile, parseOrder, stackBands } from './bands'
19
20const PANE = 'mission-control-win'
21// The first of these that exists draws the terminal's PNG: Chrome on Windows, then Edge, then Chrome on macOS.
22const BROWSERS = [
23 'C:/Program Files/Google/Chrome/Application/chrome.exe',
24 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
25 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
26 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
27 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
28]
29const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
30const MAX_NODES = 300
31const MAX_FILES = 14
32const BAND_AFTER_MS = 30_000 // the band stays this long after a turn ends
33const HUB_MAP = { width: 1400, height: 860 } // the map the browser view shows, full size
34const PUSH_MS = 2_000
35// Tools that are Claude Code's own bookkeeping, not work: kept out of the tree.
36const INTERNAL = new Set(['SubagentHandback', 'ToolSearch', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
37// Files that belong on a code map: source, config and docs, not images or binaries.
38export 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
39
40// Held by the host, so a hot reload keeps the picture.
41const nodes = atom({ plugin: 'mission-control-win', key: 'nodes' } as const, [] as MissionNode[])
42const files = atom({ plugin: 'mission-control-win', key: 'files' } as const, [] as MapFile[])
43const view = atom({ plugin: 'mission-control-win', key: 'view' } as const, 'who' as 'who' | 'code')
44const frame = atom({ plugin: 'mission-control-win', key: 'frame' } as const, null as { file: string; n: number } | null)
45const turn = atom({ plugin: 'mission-control-win', key: 'turn' } as const, 0)
46const now = atom({ plugin: 'mission-control-win', key: 'now' } as const, 0)
47
48// Drawing bookkeeping; a reload starts it over.
49const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '', isTerminal: false }
50const changes = new Map<string, string[]>() // path → this turn's edits, for the one-line "why"
51
52// Under the cap, the oldest tool calls go first; main and the agents always stay.
53export function cap(list: MissionNode[]) {
54 if (list.length <= MAX_NODES) return list
55 const tools = list.filter(n => n.kind === 'tool')
56 const room = Math.max(0, MAX_NODES - (list.length - tools.length))
57 const keep = new Set(room === 0 ? [] : tools.slice(-room))
58 return list.filter(n => n.kind !== 'tool' || keep.has(n))
59}
60
61// The first `max` characters, cut at a word.
62function words(text: string, max: number) {
63 const t = text.replace(/\s+/g, ' ').trim()
64 if (t.length <= max) return t
65 const cut = t.lastIndexOf(' ', max)
66 return `${t.slice(0, cut > max * 0.5 ? cut : max)}…`
67}
68
69let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
70
71export const register: Register = on => {
72 on('session.start', async ($, e, next) => {
73 const r = await next(e)
74 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(() => {})
75 $.clock.every(1000, () => {
76 void (async () => {
77 const busy = (await read($, nodes)).some(n => n.status === 'running')
78 if (busy) await update($, now, () => Date.now())
79 })().catch(() => {})
80 })
81 // The browser view: send what changed to a running Mod Hub, every couple of seconds.
82 $.clock.every(PUSH_MS, () => {
83 void pushMission($).catch(() => {})
84 })
85 return r
86 })
87
88 on('turn.start', async ($, e, next) => {
89 const t = (await read($, turn)) + 1
90 await update($, turn, () => t)
91 changes.clear()
92 const main: MissionNode = { id: 'main', parent: null, kind: 'main', label: `main · ${words(e.text, 50)}`, family: 'main', status: 'running', start: Date.now() }
93 // A background agent still running from the last turn carries over, with its open calls.
94 await update($, nodes, list => {
95 const agents = list.filter(n => n.kind === 'agent' && n.status === 'running').map(a => ({ ...a, parent: 'main' }))
96 const ids = new Set(agents.map(a => a.id))
97 const open = list.filter(n => n.kind === 'tool' && n.status === 'running' && ids.has(n.parent ?? ''))
98 return [main, ...agents, ...open]
99 })
100 return next(e)
101 })
102
103 on('agent.spawn', async ($, e, next) => {
104 const r = await next(e)
105 if (r.agentId) {
106 const id = r.agentId
107 const agent: MissionNode = { id, parent: e.parentAgentId ?? 'main', kind: 'agent', label: e.description || e.subagentType, family: 'agent', status: 'running', start: Date.now() }
108 await update($, nodes, list => cap([...list, agent]))
109 }
110 return r
111 })
112
113 on('tool.call', async ($, e, next) => {
114 if (e.tool === 'Agent') return next(e) // its subagent shows up through agent.spawn
115 if (INTERNAL.has(e.tool)) return next(e) // bookkeeping, not work a person would follow
116 const args = e as unknown as Record<string, unknown>
117 const { label: text, family } = label(args)
118 const id = e.tool_use_id
119 await update($, nodes, list => {
120 // A call from an agent this tree does not know hangs off main rather than nowhere.
121 const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
122 return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
123 })
124 const path = typeof args.file_path === 'string' ? args.file_path : undefined
125 if (path) await touch($, path, e.tool === 'Read' ? 'read' : e.tool === 'Write' ? 'write' : e.tool === 'Edit' ? 'edit' : 'search', false)
126
127 let failed = true
128 let r: Awaited<ReturnType<typeof next>>
129 try {
130 r = await next(e)
131 failed = r.deny !== undefined || r.isError === true
132 } finally {
133 await update($, nodes, list => list.map(n => (n.id === id ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n)))
134 }
135 if (path && !failed && (e.tool === 'Edit' || e.tool === 'Write')) {
136 const key = path.replace(/\\/g, '/')
137 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)}`
138 changes.set(key, [...(changes.get(key) ?? []), what])
139 await touch($, path, e.tool === 'Edit' ? 'edit' : 'write', true)
140 }
141 return r
142 })
143
144 on('turn.complete', async ($, e, next) => {
145 const r = await next(e)
146 const id = e.agentId ?? 'main'
147 const failed = e.reason === 'error' || e.reason === 'aborted'
148 const isMain = !e.agentId
149 // The main turn ending closes everything it left marked running, so the clock can rest.
150 await update($, nodes, list => {
151 // Only main's own leftovers: a background agent still running keeps its open calls.
152 const live = new Set(list.filter(n => n.kind === 'agent' && n.status === 'running' && n.id !== id).map(n => n.id))
153 const close = (n: MissionNode) => n.id === id || (isMain && n.kind === 'tool' && !live.has(n.parent ?? ''))
154 return list.map(n => (close(n) && n.status === 'running' ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n))
155 })
156 if (isMain) {
157 const t = await read($, turn)
158 void explain($, t, new Map(changes)).catch(() => {}) // in the background, so the turn ends at once
159 }
160 return r
161 })
162
163 on('command.run', { command: 'mission' }, async ($, e) => {
164 const arg = e.args.trim().toLowerCase()
165 if (arg === 'code' || arg === 'who') await update($, view, () => arg)
166 await $.ui.open({ id: PANE, title: 'Mission Control', focus: true })
167 void renderSoon($)
168 const s = summary(await read($, nodes))
169 return { text: `Mission Control: ${s.agents} agents · ${s.tools} tool calls · ${(await read($, files)).length} files. w: who · c: code · q: close` }
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Text, Button } = $.ui.resolve(e)
174 await read($, now) // subscribes the pane to the clock
175 const v = await read($, view)
176 const wasTerminal = draw.isTerminal
177 draw.isTerminal = e.surface === 'terminal'
178 if (v === 'code' && draw.isTerminal && !wasTerminal) void renderSoon($) // the PNG is drawn only once a terminal shows the map
179 const rows = Math.max(6, (e.props.scroll?.bodyRows ?? 30) - 3)
180 const columns = Math.max(20, e.props.bodyColumns)
181 const head = (
182 <Box flexDirection="row" gap={1}>
183 <Button key="who" label="Who" hotkey="w" variant={v === 'who' ? 'primary' : undefined} onPress={() => update($, view, () => 'who')} />
184 <Button key="code" label="Code" hotkey="c" variant={v === 'code' ? 'primary' : undefined} onPress={() => void setCode($)} />
185 <Button key="browser" label="↗ Browser" hotkey="b" onPress={() => void openInBrowser($)} />
186 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
187 </Box>
188 )
189
190 if (v === 'code') {
191 if (e.surface !== 'terminal') {
192 const table = $.ui.resolve(e)
193 if (!('Svg' in table)) {
194 return (
195 <Box flexDirection="column">
196 {head}
197 <Text dimColor>This surface draws no pictures, so the code map has nowhere to go.</Text>
198 </Box>
199 )
200 }
201 const { Svg } = table
202 const list = await read($, files)
203 // Drawn at the pane's own width and shape, so the text is its real size instead of a thumbnail's;
204 // the clock read above moves the glow on while Claude works.
205 const size = paneMap(columns, rows)
206 return (
207 <Box flexDirection="column" gap={1}>
208 {head}
209 <Svg key="map" source={svg(list, { ...size, at: Date.now(), turn: await read($, turn) })} alt={`Code map of ${list.length} files Claude touched`} width={size.width} height={size.height} isInteractive />
210 <Text dimColor>{list.length ? 'Hover a card for its path · ↗ Browser opens it full size' : 'Files Claude reads or edits show up here.'}</Text>
211 </Box>
212 )
213 }
214 const { Image } = $.ui.resolve(e)
215 // A fixed 16:10 shape from the pane's width: lines coming and going above the prompt
216 // change the pane's height, and must not resize (and jolt) the picture.
217 const imageRows = Math.min(rows, Math.round(columns * 0.3))
218 if (draw.size.columns !== columns || draw.size.rows !== imageRows) {
219 draw.size = { columns, rows: imageRows }
220 void renderSoon($)
221 }
222 const f = await read($, frame)
223 return (
224 <Box flexDirection="column">
225 {head}
226 {f ? (
227 <Image key="map" source={{ file: f.file, format: 'png', generation: f.n }} columns={draw.size.columns} rows={draw.size.rows} alt="Code map" />
228 ) : (
229 <Text dimColor>{draw.error || 'Drawing the code map…'}</Text>
230 )}
231 </Box>
232 )
233 }
234
235 const at = Date.now()
236 const ls = lines(await read($, nodes), at)
237 const s = summary(await read($, nodes))
238 return (
239 <Box flexDirection="column">
240 {head}
241 <Text dimColor>{`${s.agents} agents (${s.running} running) · ${s.tools} tool calls${s.failed ? ` · ${s.failed} failed` : ''}`}</Text>
242 {ls.length === 0 && <Text dimColor>Nothing yet. Start a task and watch it fill in.</Text>}
243 {ls.slice(-rows + 1).map(l => (
244 <Text wrap="truncate-end">
245 <Text dimColor>{l.prefix}</Text>
246 <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'}>
247 {l.text}
248 </Text>
249 </Text>
250 ))}
251 </Box>
252 )
253 })
254
255 // The band: one summary line while Claude works, and for a short while after.
256 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257 const rest = await next(e) // what other mods and Claude Code draw here stays
258 const list = await read($, nodes)
259 const main = list.find(n => n.kind === 'main')
260 const s = summary(list)
261 const isRecent = main !== undefined && (main.status === 'running' || Date.now() - (main.end ?? 0) < BAND_AFTER_MS)
262 if (e.props.hasSurvey || !isRecent || s.tools + s.agents === 0) return rest
263 const t = await read($, turn)
264 const edited = (await read($, files)).filter(f => f.changedTurn === t).length
265 const { Text } = $.ui.resolve(e)
266 const mine = (
267 <Text wrap="truncate-end">
268 <Text color="blue" bold>{' ◈ mission: '}</Text>
269 <Text>{`${s.agents ? `${s.running}/${s.agents} agents · ` : ''}${s.tools} tools${s.failed ? ` · ${s.failed} failed` : ''}${edited ? ` · ${edited} files changed` : ''}`}</Text>
270 <Text dimColor>{' · /mission'}</Text>
271 </Text>
272 )
273 return stackBands(rest, 'mission-control-win', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
274 })
275}
276
277// The band order file, as the claude-code-mods checklist saves it; [] on any error.
278async function readBandOrder($: EngineInterface): Promise<string[]> {
279 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))?.replace(/\\/g, '/')
280 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), home)
281 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
282}
283
284// Forgets the cached band order, for tests.
285export function resetBandOrder() {
286 bandOrder = undefined
287}
288
289async function setCode($: EngineInterface) {
290 await update($, view, () => 'code')
291 await renderSoon($)
292}
293
294// Notes a file Claude touched, with the relative imports it has.
295async function touch($: EngineInterface, rawPath: string, act: MapFile['act'], isChange: boolean) {
296 const path = rawPath.replace(/\\/g, '/') // C:\a\b.ts reads as C:/a/b.ts, so titles and relative imports resolve
297 if (!CODE_FILE.test(path)) return // an image, a PDF, a lockfile: not code to map
298 const t = await read($, turn)
299 const text = await $.fs.read(path).catch(() => '')
300 const imports = typeof text === 'string' ? importsOf(path, text.slice(0, 200_000)) : [] // imports sit near the top
301 await update($, files, list => {
302 const old = list.find(f => f.path === path)
303 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 }
304 return [...list.filter(f => f.path !== path), next].sort((a, b) => b.at - a.at).slice(0, MAX_FILES)
305 })
306 await renderSoon($)
307 $.clock.after(5500, () => void renderSoon($).catch(() => {})) // again once the glow has faded
308}
309
310// One plain line per changed file, from what changed this turn.
311async function explain($: EngineInterface, forTurn: number, edits: Map<string, string[]>) {
312 if (edits.size === 0) return
313 const paths = [...edits.keys()]
314 const plain = (t: string) => t.replace(/[<>"]/g, ' ')
315 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')
316 const r = await $.model.complete({
317 model: 'haiku',
318 maxTokens: 400,
319 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.',
320 prompt: `${body}\n\nReply with the JSON array only: ${paths.length} strings.`,
321 })
322 if (!r.isAnswered) return
323 let whys: unknown
324 try {
325 whys = JSON.parse(r.text.slice(r.text.indexOf('['), r.text.lastIndexOf(']') + 1))
326 } catch {
327 return
328 }
329 if (!Array.isArray(whys)) return
330 // By position, so nothing has to match a path the model may have rewritten.
331 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]))
332 // A slow answer from an earlier turn must not label this turn's changes.
333 if ((await read($, turn)) !== forTurn) return
334 await update($, files, list => list.map(f => (byPath.get(f.path) && f.changedTurn === forTurn ? { ...f, why: byPath.get(f.path) } : f)))
335 await renderSoon($)
336}
337
338// Draws the code map at most about once a second, only while someone looks at it.
339async function renderSoon($: EngineInterface) {
340 if (draw.pending) {
341 draw.dirty = true // drawing now: draw once more when it is done
342 return
343 }
344 if ((await read($, view)) !== 'code') return
345 if (!draw.isTerminal) return // the desktop draws the Svg itself: no Chrome to run
346 const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
347 if (!isUp) return
348 draw.pending = true
349 draw.dirty = false
350 $.clock.after(700, () => {
351 void renderMap($)
352 .catch(() => {})
353 .finally(() => {
354 draw.pending = false // only now: two Chromes must never share the profile
355 if (draw.dirty) void renderSoon($).catch(() => {})
356 })
357 })
358}
359
360// The first browser in BROWSERS that is installed; undefined with none.
361async function browserOf($: EngineInterface): Promise<string | undefined> {
362 for (const b of BROWSERS) if (await $.fs.exists(b).catch(() => false)) return b
363 return undefined
364}
365
366async function renderMap($: EngineInterface) {
367 const CHROME = await browserOf($)
368 if (!CHROME) {
369 draw.error = 'The code map needs Google Chrome or Microsoft Edge installed.'
370 await update($, now, () => Date.now()) // redraw the pane with the message
371 return
372 }
373 draw.error = ''
374 const tmp = ((await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp').replace(/\\/g, '/').replace(/\/$/, '')
375 const dir = `${tmp}/mission-control`
376 const n = ++draw.n
377 const width = Math.round(draw.size.columns * 9)
378 const height = Math.round(draw.size.rows * 19)
379 const svgPath = `${dir}/map.svg`
380 const png = `${dir}/map-${n % 2}.png` // two files in turn, so the shown one is never half written
381 await $.fs.write(svgPath, svg(await read($, files), { width, height, at: Date.now(), turn: await read($, turn) }))
382 // Headless Chrome writes the screenshot but does not always exit: wait for the file,
383 // then close that Chrome (its own throwaway profile, never the person's browser).
384 const script =
385 '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 ' +
386 '--user-data-dir="$3" --window-size="$4" --screenshot="$2" "$5" >/dev/null 2>&1 & pid=$!; ' +
387 '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" ]'
388 const shell = (await $.fs.exists(GIT_BASH).catch(() => false)) ? GIT_BASH : 'sh'
389 const url = /^[A-Za-z]:\//.test(svgPath) ? `file:///${encodeURI(svgPath)}` : `file://${encodeURI(svgPath)}`
390 const r = await $.process.run([shell, '-c', script, 'mission-control-win', CHROME, png, `${dir}/chrome`, `${width},${height}`, url], { timeoutMs: 20_000 })
391 if (r.exitCode === 0) await update($, frame, () => ({ file: png, n }))
392}
393
394// The desktop map's size in CSS pixels, from the pane's columns and rows: as wide as the pane,
395// and taller than wide in a narrow pane, so the cards keep a readable size.
396export function paneMap(columns: number, rows: number) {
397 const width = Math.max(340, Math.min(1400, Math.round(columns * 8.2)))
398 const height = Math.max(360, Math.min(Math.round(rows * 19), Math.round(width * (width < 700 ? 1.25 : 0.62))))
399 return { width, height }
400}
401
402// ── The browser view (Mod Hub #/mission) ──────────────────────────────────
403
404type Hub = { port: number; token: string }
405let lastSent = ''
406
407async function hubInfo($: EngineInterface): Promise<Hub | null> {
408 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))?.replace(/\\/g, '/')
409 const dir = (await $.env.get('CLAUDE_CONFIG_DIR'))?.replace(/\\/g, '/') ?? `${home}/.claude`
410 const text = await $.fs.read(`${dir}/mod-hub/server.json`).catch(() => '')
411 try {
412 const h = JSON.parse(typeof text === 'string' ? text : '') as Hub
413 return typeof h.port === 'number' && typeof h.token === 'string' ? h : null
414 } catch {
415 return null
416 }
417}
418
419// Sends the agent tree and the full-size code map to the hub when either changed.
420async function pushMission($: EngineInterface, force = false) {
421 const hub = await hubInfo($)
422 if (!hub) return
423 const list = await read($, nodes)
424 const mapFiles = await read($, files)
425 const t = await read($, turn)
426 const at = Date.now()
427 const signature = JSON.stringify([list.map(n => [n.id, n.status, n.label]), mapFiles.map(f => [f.path, f.act, f.at, f.why]), t])
428 const isBusy = list.some(n => n.status === 'running')
429 if (!force && signature === lastSent && !isBusy) return
430 const body = {
431 at,
432 turn: t,
433 summary: summary(list),
434 lines: lines(list, at).slice(-200).map(l => ({ prefix: l.prefix, text: l.text, status: l.status, kind: l.node.kind, family: l.node.family })),
435 files: mapFiles.map(f => ({ path: f.path, act: f.act, changed: f.changedTurn === t, why: f.why ?? '' })),
436 svg: svg(mapFiles, { ...HUB_MAP, at, turn: t }),
437 }
438 const r = await $.http.fetch(`http://127.0.0.1:${hub.port}/api/mission`, {
439 method: 'POST',
440 headers: { 'content-type': 'application/json', 'x-mod-hub-token': hub.token },
441 body: JSON.stringify(body),
442 }).catch(() => null)
443 if (r?.ok) lastSent = signature
444}
445
446// Opens the hub's #/mission page; starts the hub through /hub when none runs (mod-hub installed).
447async function openInBrowser($: EngineInterface) {
448 const hub = await hubInfo($)
449 const ping = hub ? await $.http.fetch(`http://127.0.0.1:${hub.port}/api/ping`).catch(() => null) : null
450 if (!hub || ping?.status !== 200) {
451 $.ui.toast('Starting the Mod Hub. Press ↗ Browser again once it opens')
452 await $.command.run({ command: 'hub' }).catch(() => $.ui.toast('The browser view needs mod-hub: install it, then run /hub'))
453 return
454 }
455 await pushMission($, true)
456 const url = `http://127.0.0.1:${hub.port}/#/mission`
457 const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
458 await $.process.run(isWindows ? ['explorer.exe', url] : ['open', url], { timeoutMs: 10_000 }).catch(() => null)
459}
460hooks/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}
81hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/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-win': {
28 nodes: MissionNode[]
29 files: MapFile[]
30 view: 'who' | 'code'
31 frame: MissionFrame
32 turn: number
33 now: number
34 }
35 }
36}
37