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

Zhitong's Claude Code mods. Forked from hamzafer/claude-code-mods (MIT).
/mission opens a live web page with two tabs, Who (the main agent, its subagents and every tool call) and Code (a map of the files Claude reads and edits, the imports between them, and one line on why each changed). The page refreshes itself every second. There is no side pane.
It opens in a cmux browser split when run inside cmux, otherwise in your default browser.
Changes from upstream mission-control:
/mission opens the web page. No Chrome screenshot is needed any more.hooks/template.ts) is plain HTML and CSS fed by a small data.js, not a hand-built SVG. Text wraps by itself, so nothing is cut with …, and restyling means editing HTML/CSS./mission has opened the page, then once per change./mission runs, and /mission off stops it. Writes are skipped when the data has not changed.hooks/lang.ts): JS/TS and Python follow relative imports, Ruby follows require_relative and the class or module names a file mentions (RequestChorusOtp links to request_chorus_otp.rb), as Rails loads code by name. Add a language by adding an adapter./mission side by side without sharing data.claude plugin marketplace add zhitongLIU/claude-code-mods
claude plugin install mission@zhitong-mods
Restart Claude Code (or run /reload-plugins), then:
| Command | What it does |
|---|---|
/mission | Open the page on the Who tab and keep it updated. |
/mission code | Open the page on the Code tab. |
/mission off | Stop updating the page. |
In the page: w Who, c Code. The tab is kept in the URL hash (#who, #code).
Closing the browser tab does not stop the updates, because the mod cannot see it close. Run /mission off when you are done, which also stops the model calls for the "why" lines.
| File | Role |
|---|---|
hooks/register.tsx | Tracks agents, tool calls and files. After /mission, writes index.html once and data.js on every change to $TMPDIR/mission-control/<session id>/, and opens the page. |
hooks/lang.ts | Language adapters: what each file links to, and which names it defines. |
hooks/map.ts | mapData() turns the touched files into plain data: cards by link depth, edges, counts. |
hooks/tree.ts | The Who tree lines. |
hooks/template.ts | The page: tabs, the tree, the card layout and the arrows. Restyle it here. |
CLAUDE_CONFIG_DIR=$(mktemp -d) claude plugin test mods/mission-control
MIT, see LICENSE. Original work by Hamza Zafar.
hooks/register.tsx 279 lines1// Mission Control: /mission opens a live web page with two tabs for the current turn.
2// who · the main loop, its subagents and every tool call they make, live
3// code · a 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. The page re-reads data.js every second.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { MapFile, MissionNode } from '../types'
10import { linksOf } from './lang'
11import { mapData } from './map'
12import { TEMPLATE } from './template'
13import { label, lines, summary } from './tree'
14
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', key: 'nodes' } as const, [] as MissionNode[])
25const files = atom({ plugin: 'mission', key: 'files' } as const, [] as MapFile[])
26const turn = atom({ plugin: 'mission', key: 'turn' } as const, 0)
27const sid = atom({ plugin: 'mission', key: 'sid' } as const, '') // this session's folder name, so sessions never share a page
28
29// The web page's bookkeeping; a reload starts it over. `on` once /mission has opened the page.
30const page = { on: false, pending: false, dirty: false, last: '', written: false }
31// The edits awaiting their one-line "why" live on the files atom (MapFile.edits), so a reload keeps
32// them; the "why" is asked for lazily: no model call while nobody watches.
33const asking = new Set<string>() // paths a model call is already explaining
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 update($, sid, () => String((r as { sessionId?: string }).sessionId ?? Math.random().toString(36).slice(2, 10)).replace(/[^\w-]/g, ''))
56 await $.command.register({ name: 'mission', description: 'Open the live web page of agents, tool calls and the code map; /mission off stops it', argumentHint: '[who|code|off]' }).catch(() => {})
57 $.clock.every(1000, () => {
58 void (async () => {
59 const busy = (await read($, nodes)).some(n => n.status === 'running')
60 if (busy) await publish($) // the clocks on running calls tick
61 })().catch(() => {})
62 })
63 return r
64 })
65
66 on('turn.start', async ($, e, next) => {
67 const t = (await read($, turn)) + 1
68 await update($, turn, () => t)
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 await publish($)
78 return next(e)
79 })
80
81 on('agent.spawn', async ($, e, next) => {
82 const r = await next(e)
83 if (r.agentId) {
84 const id = r.agentId
85 const agent: MissionNode = { id, parent: e.parentAgentId ?? 'main', kind: 'agent', label: e.description || e.subagentType, family: 'agent', status: 'running', start: Date.now() }
86 await update($, nodes, list => cap([...list, agent]))
87 await publish($)
88 }
89 return r
90 })
91
92 on('tool.call', async ($, e, next) => {
93 if (e.tool === 'Agent') return next(e) // its subagent shows up through agent.spawn
94 if (INTERNAL.has(e.tool)) return next(e) // bookkeeping, not work a person would follow
95 const args = e as unknown as Record<string, unknown>
96 const { label: text, family } = label(args)
97 const id = e.tool_use_id
98 await update($, nodes, list => {
99 // A call from an agent this tree does not know hangs off main rather than nowhere.
100 const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
101 return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
102 })
103 await publish($)
104 const path = typeof args.file_path === 'string' ? args.file_path : undefined
105 if (path) await touch($, path, e.tool === 'Read' ? 'read' : e.tool === 'Write' ? 'write' : e.tool === 'Edit' ? 'edit' : 'search', false)
106
107 let failed = true
108 let r: Awaited<ReturnType<typeof next>>
109 try {
110 r = await next(e)
111 failed = r.deny !== undefined || r.isError === true
112 } finally {
113 await update($, nodes, list => list.map(n => (n.id === id ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n)))
114 await publish($)
115 }
116 if (path && !failed && (e.tool === 'Edit' || e.tool === 'Write')) {
117 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)}`
118 await touch($, path, e.tool === 'Edit' ? 'edit' : 'write', true, what)
119 }
120 return r
121 })
122
123 on('turn.complete', async ($, e, next) => {
124 const r = await next(e)
125 const id = e.agentId ?? 'main'
126 const failed = e.reason === 'error' || e.reason === 'aborted'
127 const isMain = !e.agentId
128 // The main turn ending closes everything it left marked running, so the clock can rest.
129 await update($, nodes, list => {
130 // Only main's own leftovers: a background agent still running keeps its open calls.
131 const live = new Set(list.filter(n => n.kind === 'agent' && n.status === 'running' && n.id !== id).map(n => n.id))
132 const close = (n: MissionNode) => n.id === id || (isMain && n.kind === 'tool' && !live.has(n.parent ?? ''))
133 return list.map(n => (close(n) && n.status === 'running' ? { ...n, status: failed ? 'failed' : 'done', end: Date.now() } : n))
134 })
135 await publish($)
136 if (isMain && page.on) void explainPending($).catch(() => {}) // in the background, so the turn ends at once
137 return r
138 })
139
140 on('command.run', { command: 'mission' }, async ($, e) => {
141 const arg = e.args.trim().toLowerCase()
142 const s = summary(await read($, nodes))
143 if (arg === 'off') {
144 page.on = false
145 return { text: 'Mission Control: page stopped. /mission opens it again.' }
146 }
147 page.on = true
148 await writePage($)
149 const opened = await openPage($, arg === 'code' ? 'code' : 'who')
150 void explainPending($).catch(() => {}) // the map is watched now: explain what changed while it was not
151 return { text: `Mission Control: ${s.agents} agents · ${s.tools} tool calls · ${(await read($, files)).length} files. ${opened}` }
152 })
153
154 // The band: one summary line while Claude works, and for a short while after.
155 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156 const rest = await next(e) // what other mods and Claude Code draw here stays
157 const list = await read($, nodes)
158 const main = list.find(n => n.kind === 'main')
159 const s = summary(list)
160 const isRecent = main !== undefined && (main.status === 'running' || Date.now() - (main.end ?? 0) < BAND_AFTER_MS)
161 if (e.props.hasSurvey || !isRecent || s.tools + s.agents === 0) return rest
162 const t = await read($, turn)
163 const edited = (await read($, files)).filter(f => f.changedTurn === t).length
164 const { Box, Text } = $.ui.resolve(e)
165 return (
166 <Box flexDirection="column">
167 <Text wrap="truncate-end">
168 <Text color="blue" bold>{' ◈ mission: '}</Text>
169 <Text>{`${s.agents ? `${s.running}/${s.agents} agents · ` : ''}${s.tools} tools${s.failed ? ` · ${s.failed} failed` : ''}${edited ? ` · ${edited} files changed` : ''}`}</Text>
170 <Text dimColor>{' · /mission'}</Text>
171 </Text>
172 {rest}
173 </Box>
174 )
175 })
176}
177
178// Notes a file Claude touched, with what it links to (imports and mentioned names, per language).
179async function touch($: EngineInterface, path: string, act: MapFile['act'], isChange: boolean, what?: string) {
180 if (!CODE_FILE.test(path)) return // an image, a PDF, a lockfile: not code to map
181 const t = await read($, turn)
182 const text = await $.fs.read(path).catch(() => '')
183 const links = typeof text === 'string' ? linksOf(path, text.slice(0, 200_000)) : { imports: [], names: [] } // imports sit near the top
184 await update($, files, list => {
185 const old = list.find(f => f.path === path)
186 const edits = isChange && what ? (old?.editsTurn === t ? [...(old.edits ?? []), what] : [what]) : old?.edits
187 const next: MapFile = { path, act, at: Date.now(), changedTurn: isChange ? t : (old?.changedTurn ?? 0), imports: links.imports.length ? links.imports : (old?.imports ?? []), names: links.names.length ? links.names : (old?.names ?? []), why: isChange ? undefined : old?.why, edits, editsTurn: isChange && what ? t : old?.editsTurn }
188 return [...list.filter(f => f.path !== path), next].sort((a, b) => b.at - a.at).slice(0, MAX_FILES)
189 })
190 await publish($)
191 $.clock.after(5500, () => void publish($).catch(() => {})) // again once the glow has faded
192}
193
194// One plain line per changed file that has none yet, from what changed in its last turn.
195async function explainPending($: EngineInterface) {
196 const todo = (await read($, files)).filter(f => f.edits?.length && !asking.has(f.path)).map(f => [f.path, { turn: f.editsTurn ?? 0, edits: f.edits ?? [] }] as const)
197 if (todo.length === 0) return
198 const paths = todo.map(([path]) => path)
199 for (const path of paths) asking.add(path)
200 try {
201 const plain = (t: string) => t.replace(/[<>"]/g, ' ')
202 const body = todo.map(([p, { edits }], i) => `<file n="${i + 1}" name="${plain(p.split('/').slice(-2).join('/'))}">\n${plain(edits.join('\n').slice(0, 1500))}\n</file>`).join('\n')
203 const r = await $.model.complete({
204 model: 'haiku',
205 maxTokens: 400,
206 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.',
207 prompt: `${body}\n\nReply with the JSON array only: ${paths.length} strings.`,
208 })
209 if (!r.isAnswered) return
210 let whys: unknown
211 try {
212 whys = JSON.parse(r.text.slice(r.text.indexOf('['), r.text.lastIndexOf(']') + 1))
213 } catch {
214 return
215 }
216 if (!Array.isArray(whys)) return
217 // By position, so nothing has to match a path the model may have rewritten.
218 const byPath = new Map(todo.map(([p, { turn: t }], i) => [p, { turn: t, why: typeof whys[i] === 'string' ? (whys[i] as string).replace(/\s*—\s*/g, ', ').slice(0, 80) : undefined }]))
219 // A file edited again since was queued anew under a later turn: this answer must not label it.
220 // The edits are dropped with it, unless the file changed again since: then they stay queued for that later turn.
221 await update($, files, list => list.map(f => (byPath.get(f.path)?.why && f.editsTurn === byPath.get(f.path)?.turn ? { ...f, why: byPath.get(f.path)?.why, edits: undefined } : f)))
222 await publish($)
223 } finally {
224 for (const path of paths) asking.delete(path)
225 }
226}
227
228// Writes the page's data about once a second, only once /mission has opened it.
229async function publish($: EngineInterface) {
230 if (!page.on) return
231 if (page.pending) {
232 page.dirty = true // writing now: write once more when it is done
233 return
234 }
235 page.pending = true
236 page.dirty = false
237 $.clock.after(400, () => {
238 void (page.on ? writePage($) : Promise.resolve()) // /mission off may have come in meanwhile
239 .catch(() => {})
240 .finally(() => {
241 page.pending = false
242 if (page.dirty) void publish($).catch(() => {})
243 })
244 })
245}
246
247async function pageDir($: EngineInterface) {
248 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
249 return `${tmp}/mission-control/${(await read($, sid)) || 'session'}`
250}
251
252// The page is one static file; data.js is all that changes.
253async function writePage($: EngineInterface) {
254 const dir = await pageDir($)
255 const at = Date.now()
256 const list = await read($, nodes)
257 const data = {
258 ...mapData(await read($, files), { at, turn: await read($, turn) }),
259 who: { summary: summary(list), lines: lines(list, at).map(l => ({ prefix: l.prefix, text: l.text, status: l.status, kind: l.node.kind })) },
260 }
261 const json = JSON.stringify(data).replace(/</g, '\\u003c')
262 if (json === page.last) return // nothing changed since the last write
263 if (!page.written) {
264 await $.fs.write(`${dir}/index.html`, TEMPLATE)
265 page.written = true
266 }
267 await $.fs.write(`${dir}/data.js`, `window.MAP = ${json};`)
268 page.last = json
269}
270
271// In a cmux split when this runs inside cmux, otherwise in the default browser.
272async function openPage($: EngineInterface, tab: 'who' | 'code') {
273 const url = `file://${encodeURI(await pageDir($))}/index.html#${tab}`
274 const inCmux = Boolean(await $.env.get('CMUX_WORKSPACE_ID'))
275 const argv = inCmux ? ['cmux', 'browser', 'open-split', url, '--focus', 'true'] : ['open', url]
276 const r = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => ({ exitCode: 1 }))
277 return r.exitCode === 0 ? `Opened ${url}` : `Could not open a browser. Open ${url} yourself.`
278}
279hooks/lang.ts 95 lines1// Language adapters: how the code map links files in each language.
2// An adapter says, for one family of file extensions,
3// links · what a file's text points at: relative file imports (resolved to paths, extension
4// stripped) and the names it mentions (classes, modules)
5// defines · the names a file is known to define, from its path alone
6// map.ts turns imports into arrows between the files, and a mentioned name that another touched
7// file defines into one more arrow. To support a language, add an adapter to ADAPTERS.
8
9export type Links = { imports: string[]; names: string[] }
10
11export type Lang = {
12 name: string
13 ext: RegExp
14 links(path: string, text: string): Links
15 defines(path: string): string[]
16}
17
18export const stripExt = (p: string) => p.replace(/\.(tsx?|jsx?|mjs|cjs|py|go|rs|rb|vue|svelte)$/, '').replace(/\/index$/, '')
19
20const dirOf = (path: string) => path.slice(0, path.lastIndexOf('/'))
21
22function join(dir: string, rel: string) {
23 const parts = dir.split('/')
24 for (const seg of rel.split('/')) {
25 if (seg === '..') parts.pop()
26 else if (seg !== '.') parts.push(seg)
27 }
28 return parts.join('/')
29}
30
31const NONE: string[] = []
32const MAX_NAMES = 400
33
34const javascript: Lang = {
35 name: 'js',
36 ext: /\.(tsx?|jsx?|mjs|cjs|vue|svelte)$/i,
37 links(path, text) {
38 const found = new Set<string>()
39 const re = /(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)['"](\.{1,2}\/[^'"]+)['"]/g
40 for (const m of text.matchAll(re)) found.add(stripExt(join(dirOf(path), m[1] as string)))
41 return { imports: [...found], names: NONE }
42 },
43 defines: () => NONE,
44}
45
46const python: Lang = {
47 name: 'py',
48 ext: /\.py$/i,
49 links(path, text) {
50 const found = new Set<string>()
51 const re = /^\s*from\s+(\.+)([\w.]*)\s+import/gm
52 for (const m of text.matchAll(re)) {
53 let base = dirOf(path)
54 for (let i = 0; i < (m[1] as string).length - 1; i++) base = base.slice(0, base.lastIndexOf('/'))
55 if (m[2]) found.add(`${base}/${(m[2] as string).replace(/\./g, '/')}`)
56 }
57 return { imports: [...found], names: NONE }
58 },
59 defines: () => NONE,
60}
61
62// Rails loads code by constant name, not by import: a file links to another when it mentions
63// the class or module that the other one is named after (request_chorus_otp.rb: RequestChorusOtp).
64const RUBY_TOO_COMMON = new Set(['Base', 'Error', 'Config', 'Test', 'Spec', 'Main', 'Helper', 'Concern', 'Module', 'Class', 'Object', 'Result', 'Struct'])
65const camelize = (s: string) => s.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
66
67const ruby: Lang = {
68 name: 'rb',
69 ext: /\.rb$/i,
70 links(path, text) {
71 const code = text.split('\n').filter(l => !/^\s*#/.test(l)).join('\n')
72 const imports = new Set<string>()
73 for (const m of code.matchAll(/\brequire_relative\s*\(?\s*['"]([^'"]+)['"]/g)) imports.add(stripExt(join(dirOf(path), m[1] as string)))
74 for (const m of code.matchAll(/\brequire\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g)) imports.add(stripExt(join(dirOf(path), m[1] as string)))
75 const names = new Set<string>()
76 for (const m of code.matchAll(/\b[A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*/g)) {
77 for (const seg of m[0].split('::')) names.add(seg)
78 if (names.size >= MAX_NAMES) break
79 }
80 return { imports: [...imports], names: [...names] }
81 },
82 defines(path) {
83 const name = camelize((path.split('/').pop() ?? '').replace(/\.rb$/i, ''))
84 return name.length < 4 || RUBY_TOO_COMMON.has(name) ? NONE : [name]
85 },
86}
87
88export const ADAPTERS: Lang[] = [javascript, python, ruby]
89
90export const langFor = (path: string) => ADAPTERS.find(l => l.ext.test(path))
91
92export function linksOf(path: string, text: string): Links {
93 return langFor(path)?.links(path, text) ?? { imports: NONE, names: NONE }
94}
95hooks/map.ts 130 lines1// The "code" view: files Claude touched, the imports between them, handed to the HTML page as data.
2import type { MapFile } from '../types'
3import { langFor, linksOf, stripExt } from './lang'
4
5// Relative imports in a source file, resolved against its folder, extension stripped.
6export function importsOf(path: string, text: string): string[] {
7 return linksOf(path, text).imports
8}
9
10export type Edge = { from: string; to: string }
11
12// An arrow from each file to the files it imports, and to the files whose class or module
13// it mentions (per language, see lang.ts). Only files on the map can be linked.
14export function edges(files: MapFile[]): Edge[] {
15 const byBase = new Map(files.map(f => [stripExt(f.path), f.path]))
16 const owners = new Map<string, string>() // "lang:Name" → the file that defines it
17 for (const f of files) {
18 const lang = langFor(f.path)
19 for (const name of lang?.defines(f.path) ?? []) owners.set(`${lang?.name}:${name}`, f.path)
20 }
21 const out: Edge[] = []
22 const add = (from: string, to: string | undefined) => {
23 if (to && to !== from && !out.some(e => e.from === from && e.to === to)) out.push({ from, to })
24 }
25 for (const f of files) {
26 for (const target of f.imports) add(f.path, byBase.get(target))
27 const lang = langFor(f.path)
28 for (const name of f.names ?? []) add(f.path, owners.get(`${lang?.name}:${name}`))
29 }
30 return out
31}
32
33// Columns by import depth: a file goes one column right of the files that import it.
34export function layout(files: MapFile[], es: Edge[]) {
35 const depth = new Map(files.map(f => [f.path, 0]))
36 for (let pass = 0; pass < files.length; pass++) {
37 let moved = false
38 for (const e of es) {
39 const want = (depth.get(e.from) ?? 0) + 1
40 if (want > (depth.get(e.to) ?? 0) && want < 5) {
41 depth.set(e.to, want)
42 moved = true
43 }
44 }
45 if (!moved) break
46 }
47 const columns: string[][] = []
48 for (const f of files) {
49 const d = depth.get(f.path) ?? 0
50 ;(columns[d] ??= []).push(f.path)
51 }
52 return columns.filter(Boolean)
53}
54
55export type Look = { at: number; turn: number }
56
57export type MapCard = {
58 id: string
59 title: string
60 sub: string
61 why?: string
62 state: 'read' | 'edit' | 'changed' | null // what the card glows for: being read, being edited, changed this turn
63 live: boolean // touched in the last few seconds
64}
65
66export type MapData = {
67 files: number
68 changed: number
69 now: string // "reading login.tsx", or ''
70 nowState: 'read' | 'edit' | null
71 columns: MapCard[][] // by import depth
72 edges: { from: string; to: string; hot: boolean }[]
73 extra: string[] // files only read and linked to nothing
74}
75
76// Everything the page draws, as plain data: the template lays it out and draws the arrows.
77export function mapData(files: MapFile[], look: Look): MapData {
78 const { at, turn } = look
79 const isChanged = (f: MapFile) => f.changedTurn === turn && turn > 0
80 const isLive = (f: MapFile) => at - f.at < 5000
81 const es = edges(files)
82 const linked = new Set(es.flatMap(e => [e.from, e.to]))
83 const main = files.filter(f => linked.has(f.path) || isChanged(f) || isLive(f) || f.act !== 'read')
84 const extra = files.filter(f => !main.includes(f))
85 const mainEs = es.filter(e => main.some(f => f.path === e.from) && main.some(f => f.path === e.to))
86 const byPath = new Map(main.map(f => [f.path, f]))
87 const title = (f: MapFile) => titleOf(f.path, files.map(x => x.path))
88 const readLike = (f: MapFile) => f.act === 'read' || f.act === 'search'
89
90 const card = (p: string): MapCard => {
91 const f = byPath.get(p) as MapFile
92 const live = isLive(f)
93 const state = live ? (readLike(f) ? 'read' : 'edit') : isChanged(f) ? 'changed' : null
94 return { id: p, title: title(f), sub: folder(p, 2), why: f.why, state, live }
95 }
96 const newest = files.filter(isLive).sort((a, b) => b.at - a.at)[0]
97 return {
98 files: files.length,
99 changed: files.filter(isChanged).length,
100 now: newest ? `${newest.act === 'read' ? 'reading' : newest.act === 'search' ? 'searching' : 'editing'} ${title(newest)}` : '',
101 nowState: newest ? (readLike(newest) ? 'read' : 'edit') : null,
102 columns: layout(main, mainEs).map(col => col.map(card)),
103 edges: mainEs.map(e => ({ ...e, hot: [e.from, e.to].some(p => isChanged(byPath.get(p) as MapFile) || isLive(byPath.get(p) as MapFile)) })),
104 extra: extra.map(title),
105 }
106}
107
108// A file's name, or with several of that name the nearest folder that tells them apart:
109// three `hooks/register.tsx` read as `merge-gate › register.tsx`, `snake › register.tsx`.
110export function titleOf(path: string, all: string[]) {
111 const name = base(path)
112 const same = all.filter(p => base(p) === name && p !== path)
113 if (same.length === 0) return name
114 const parts = path.split('/')
115 for (let k = 2; k <= parts.length; k++) {
116 const seg = parts[parts.length - k]
117 if (same.every(p => p.split('/').at(-k) !== seg)) return `${seg} › ${name}`
118 }
119 return `${folder(path)}/${name}`
120}
121
122function base(path: string) {
123 return path.split('/').pop() ?? path
124}
125
126// The last `n` folders of a path (`n` 1: the parent).
127function folder(path: string, n = 1) {
128 return path.split('/').slice(-1 - n, -1).join('/')
129}
130hooks/template.ts 196 lines1// The Mission Control page: plain HTML and CSS, written to $TMPDIR/mission-control/index.html.
2// Two tabs, who (the agent and tool-call tree) and code (the file map), both drawn from
3// window.MAP in data.js (see writePage in register.tsx), which it re-reads every second.
4// Restyle it here; nothing else needs to change. The tab lives in the URL hash (#who, #code).
5// Keep this free of backticks and dollar-brace sequences: it is one template literal.
6export const TEMPLATE = `<!doctype html>
7<meta charset="utf-8">
8<title>Mission Control</title>
9<style>
10 :root { --bg:#0d1015; --card:#151922; --card-live:#1b2130; --line:#2a3140; --text:#e8ebf2; --dim:#7d8698; --faint:#4b5263;
11 --edge:#3a4252; --edge-hot:#6b7a96; --read:#5aa9ff; --edit:#ffa24c; --changed:#3ddc84; }
12 * { box-sizing: border-box; }
13 html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden;
14 font: 14px -apple-system, 'SF Pro Text', 'Helvetica Neue', sans-serif; }
15 body { display: flex; flex-direction: column; background-image: radial-gradient(#1a1f28 1px, transparent 1px); background-size: 22px 22px; }
16 header { display: flex; align-items: center; gap: 8px; padding: 18px 24px 0; flex: none; }
17 h1 { margin: 0 12px 0 0; font-size: 20px; }
18 .tabs { display: flex; gap: 6px; margin-right: 12px; }
19 .tab { padding: 4px 14px; border: 1px solid var(--line); border-radius: 8px; background: #161b24; color: var(--dim); font: inherit; font-size: 13px; cursor: pointer; }
20 .tab.on { background: #26324a; color: var(--text); border-color: #3a4a6a; }
21 #who { flex: 1; min-height: 0; overflow: auto; padding: 18px 24px; font: 13.5px 'SF Mono', Menlo, monospace; line-height: 22px; }
22 .line { white-space: pre; }
23 .line .p { color: var(--faint); }
24 .line.running { color: #e8c547; } .line.failed { color: #ff6b6b; } .line.done.tool { color: var(--dim); } .line.done.main, .line.done.agent { color: var(--changed); font-weight: 600; }
25 .none { color: var(--dim); font: 14px -apple-system, sans-serif; }
26 .pill { padding: 3px 11px; border: 1px solid var(--line); border-radius: 12px; background: #161b24; font-size: 12.5px; color: var(--dim); }
27 .pill.on { color: var(--changed); }
28 .now { display: flex; align-items: center; gap: 7px; margin-left: 8px; font-size: 13px; }
29 .now i, .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
30 .empty { margin: auto; color: var(--dim); font-size: 16px; }
31 #stage { flex: 1; min-height: 0; position: relative; overflow: hidden; }
32 #graph { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
33 #edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
34 .cols { display: flex; gap: 64px; align-items: center; position: relative; }
35 .col { display: flex; flex-direction: column; gap: 18px; width: max-content; max-width: 380px; min-width: 170px; }
36 .card { position: relative; padding: 10px 16px 12px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card);
37 box-shadow: 0 4px 12px rgba(0,0,0,.45); }
38 .card.live { background: var(--card-live); border-width: 1.5px; }
39 .card.read { border-color: var(--read); box-shadow: 0 0 22px rgba(90,169,255,.35); }
40 .card.edit { border-color: var(--edit); box-shadow: 0 0 22px rgba(255,162,76,.35); }
41 .card.changed::before, .card.read::before, .card.edit::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3.5px; border-radius: 2px; background: var(--accent); }
42 .card.read { --accent: var(--read); } .card.edit { --accent: var(--edit); } .card.changed { --accent: var(--changed); }
43 .name { display: flex; justify-content: space-between; gap: 12px; font-weight: 650; font-size: 14.5px; white-space: nowrap; }
44 .badge { color: var(--accent); font-weight: 400; font-size: 13px; }
45 .sub { margin-top: 2px; font: 11px 'SF Mono', Menlo, monospace; color: var(--faint); white-space: nowrap; }
46 .why { margin-top: 8px; font-size: 12.5px; line-height: 17px; color: #aeb6c6; overflow-wrap: anywhere; }
47 footer { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 24px 16px; }
48 footer:empty { display: none; }
49 footer b { font-weight: 400; font-size: 11.5px; color: var(--faint); margin-right: 4px; }
50 .chip { padding: 3px 11px; border: 1px solid var(--line); border-radius: 12px; background: #141820; font-size: 12px; color: var(--dim); }
51</style>
52<header id="head"></header>
53<div id="who" hidden></div>
54<div id="stage"><div id="graph"><svg id="edges"></svg><div class="cols" id="cols"></div></div></div>
55<footer id="foot"></footer>
56<script src="data.js"></script>
57<script>
58 function tab() { return location.hash === '#code' ? 'code' : 'who'; }
59 var SVGNS = 'http://www.w3.org/2000/svg';
60 function h(tag, cls, text) {
61 var el = document.createElement(tag);
62 if (cls) el.className = cls;
63 if (text != null) el.textContent = text;
64 return el;
65 }
66 function render(M) {
67 var head = document.getElementById('head'), cols = document.getElementById('cols'), foot = document.getElementById('foot');
68 head.textContent = ''; cols.textContent = ''; foot.textContent = '';
69 head.appendChild(h('h1', '', 'Mission Control'));
70 var tabs = h('div', 'tabs');
71 [['who', 'Who (w)'], ['code', 'Code (c)']].forEach(function (t) {
72 var b = h('button', 'tab' + (tab() === t[0] ? ' on' : ''), t[1]);
73 b.onclick = function () { location.hash = t[0]; render(window.MAP); };
74 tabs.appendChild(b);
75 });
76 head.appendChild(tabs);
77 var S = M.who.summary;
78 head.appendChild(h('span', 'pill', S.agents + ' agents (' + S.running + ' running) \u00b7 ' + S.tools + ' tool calls' + (S.failed ? ' \u00b7 ' + S.failed + ' failed' : '')));
79 head.appendChild(h('span', 'pill', M.files + ' files'));
80 head.appendChild(h('span', 'pill' + (M.changed ? ' on' : ''), M.changed + ' changed'));
81 if (M.now) {
82 var now = h('span', 'now', M.now);
83 now.style.color = M.nowState === 'read' ? 'var(--read)' : 'var(--edit)';
84 now.insertBefore(h('i'), now.firstChild);
85 head.appendChild(now);
86 }
87 var whoEl = document.getElementById('who'), stageEl = document.getElementById('stage');
88 whoEl.hidden = tab() !== 'who'; stageEl.hidden = tab() !== 'code'; foot.hidden = tab() !== 'code';
89 if (tab() === 'who') {
90 whoEl.textContent = '';
91 if (!M.who.lines.length) whoEl.appendChild(h('div', 'none', 'Nothing yet. Start a task and watch it fill in.'));
92 M.who.lines.forEach(function (l) {
93 var row = h('div', 'line ' + l.status + ' ' + l.kind);
94 row.appendChild(h('span', 'p', l.prefix));
95 row.appendChild(document.createTextNode(l.text));
96 whoEl.appendChild(row);
97 });
98 return;
99 }
100 if (!M.files) {
101 document.getElementById('graph').style.display = 'none';
102 if (!document.querySelector('.empty')) document.getElementById('stage').appendChild(h('div', 'empty', 'Files Claude reads or edits show up here'));
103 return;
104 }
105 document.getElementById('graph').style.display = '';
106 var empty = document.querySelector('.empty'); if (empty) empty.remove();
107
108 var cards = {};
109 M.columns.forEach(function (col, ci) {
110 var colEl = h('div', 'col');
111 col.forEach(function (c) {
112 var el = h('div', 'card' + (c.live ? ' live' : '') + (c.state ? ' ' + c.state : ''));
113 var name = h('div', 'name', c.title);
114 var badge = c.live ? (c.state === 'edit' ? '\\u270e' : '\\u25cf') : c.state === 'changed' ? '\\u2713' : '';
115 if (badge) name.appendChild(h('span', 'badge', badge));
116 el.appendChild(name);
117 el.appendChild(h('div', 'sub', c.sub));
118 if (c.why) el.appendChild(h('div', 'why', c.why));
119 colEl.appendChild(el);
120 cards[c.id] = { el: el, col: ci };
121 });
122 cols.appendChild(colEl);
123 });
124 M.extra.length && foot.appendChild(h('b', '', 'ALSO READ'));
125 M.extra.forEach(function (t) { foot.appendChild(h('span', 'chip', t)); });
126
127 // Measure at 1x, draw the arrows in the graph's own coordinates, then scale the whole graph to fit.
128 var graph = document.getElementById('graph'), stage = document.getElementById('stage'), svg = document.getElementById('edges');
129 graph.style.transform = '';
130 var gw = graph.offsetWidth, gh = graph.offsetHeight;
131 svg.setAttribute('width', gw); svg.setAttribute('height', gh);
132 svg.textContent = '';
133 var defs = document.createElementNS(SVGNS, 'defs');
134 defs.innerHTML = '<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="#6b7a96"/></marker>';
135 svg.appendChild(defs);
136 var gr = graph.getBoundingClientRect();
137 function box(id) {
138 var r = cards[id].el.getBoundingClientRect();
139 return { x: r.left - gr.left, y: r.top - gr.top, w: r.width, h: r.height, col: cards[id].col };
140 }
141 M.edges.forEach(function (e) {
142 if (!cards[e.from] || !cards[e.to]) return;
143 var a = box(e.from), b = box(e.to), d;
144 var x1 = a.x + a.w, y1 = a.y + Math.min(a.h / 2, 26), x2 = b.x - 4, y2 = b.y + Math.min(b.h / 2, 26);
145 if (b.col - a.col > 1) {
146 // Skips a column: arc over the cards between, so the arrow is never hidden behind one.
147 var peak = a.y < b.y ? a.y : b.y;
148 Object.keys(cards).forEach(function (id) {
149 var c = box(id);
150 if (c.col > a.col && c.col < b.col && c.y < peak) peak = c.y;
151 });
152 peak -= 34;
153 var ax = a.x + a.w * 0.7, bx = b.x + b.w * 0.3;
154 d = 'M' + ax + ',' + a.y + ' C' + ax + ',' + peak + ' ' + bx + ',' + peak + ' ' + bx + ',' + (b.y - 4);
155 } else if (x2 > x1 + 8) {
156 var mid = x1 + (x2 - x1) / 2;
157 d = 'M' + x1 + ',' + y1 + ' C' + mid + ',' + y1 + ' ' + mid + ',' + y2 + ' ' + x2 + ',' + y2;
158 } else {
159 var ay = a.y + a.h, ac = a.x + a.w / 2, bc = b.x + b.w / 2;
160 d = 'M' + ac + ',' + ay + ' C' + ac + ',' + (ay + 30) + ' ' + bc + ',' + (b.y - 30) + ' ' + bc + ',' + (b.y - 4);
161 }
162 var p = document.createElementNS(SVGNS, 'path');
163 p.setAttribute('d', d); p.setAttribute('fill', 'none');
164 p.setAttribute('stroke', e.hot ? '#6b7a96' : '#3a4252'); p.setAttribute('stroke-width', e.hot ? 2 : 1.5);
165 p.setAttribute('marker-end', 'url(#a)');
166 svg.appendChild(p);
167 });
168 var pad = 24, s = Math.min(1.5, (stage.clientWidth - 2 * pad) / gw, (stage.clientHeight - 2 * pad) / gh);
169 graph.style.transform = 'translate(' + (stage.clientWidth - gw * s) / 2 + 'px,' + (stage.clientHeight - gh * s) / 2 + 'px) scale(' + s + ')';
170 }
171
172 var last = JSON.stringify(window.MAP);
173 render(window.MAP);
174 window.addEventListener('resize', function () { render(window.MAP); });
175 window.addEventListener('hashchange', function () { render(window.MAP); });
176 window.addEventListener('keydown', function (e) {
177 if (e.metaKey || e.ctrlKey || e.altKey) return;
178 if (e.key === 'w') { location.hash = 'who'; render(window.MAP); }
179 if (e.key === 'c') { location.hash = 'code'; render(window.MAP); }
180 });
181 {
182 setInterval(function () {
183 var s = document.createElement('script');
184 s.src = 'data.js?t=' + Date.now();
185 s.onload = function () {
186 var now = JSON.stringify(window.MAP);
187 if (now !== last) { last = now; render(window.MAP); }
188 s.remove();
189 };
190 s.onerror = function () { s.remove(); };
191 document.head.appendChild(s);
192 }, 1000);
193 }
194</script>
195`
196hooks/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 36 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 names?: string[] // class and module names the file mentions, for languages that link by name
21 why?: string
22 edits?: string[] // what its last turn changed, kept until the one-line why is asked for
23 editsTurn?: number // the turn those edits belong to
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 mission: {
29 nodes: MissionNode[]
30 files: MapFile[]
31 turn: number
32 sid: string
33 }
34 }
35}
36