SLOPSHOPPER

mission

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

newbandguardcommandmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mission ⎿ mission: Mission Control: 0 agents · 9 tool calls · 3 files. Opened file:///tmp/mission-control/ig75vgic/index.html#who ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Zhitong's Claude Code mods. Forked from hamzafer/claude-code-mods (MIT).

mission

/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:

  • The terminal pane is gone: /mission opens the web page. No Chrome screenshot is needed any more.
  • The page (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.
  • The per-file "why" is asked from the model (haiku) only once /mission has opened the page, then once per change.
  • Nothing is written until /mission runs, and /mission off stops it. Writes are skipped when the data has not changed.
  • Arrows come from per-language adapters (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.
  • Each session has its own page folder, so several Claude Code sessions can run /mission side by side without sharing data.
  • Kept from upstream: the one-line band above the prompt while Claude works.

Install

claude plugin marketplace add zhitongLIU/claude-code-mods
claude plugin install mission@zhitong-mods

Restart Claude Code (or run /reload-plugins), then:

CommandWhat it does
/missionOpen the page on the Who tab and keep it updated.
/mission codeOpen the page on the Code tab.
/mission offStop 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.

How it works

FileRole
hooks/register.tsxTracks 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.tsLanguage adapters: what each file links to, and which names it defines.
hooks/map.tsmapData() turns the touched files into plain data: cards by link depth, edges, counts.
hooks/tree.tsThe Who tree lines.
hooks/template.tsThe page: tabs, the tree, the card layout and the arrows. Restyle it here.

Develop

CLAUDE_CONFIG_DIR=$(mktemp -d) claude plugin test mods/mission-control

License

MIT, see LICENSE. Original work by Hamza Zafar.

Source 6 files
hooks/register.tsx 279 lines
1// 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}
279
hooks/lang.ts 95 lines
1// 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}
95
hooks/map.ts 130 lines
1// 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}
130
hooks/template.ts 196 lines
1// 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`
196
hooks/tree.ts 81 lines
1// The "who" view: main, its subagents and their tool calls, as tree lines.
2import type { MissionNode } from '../types'
3
4// A short label and a family for one tool call.
5export function label(e: Record<string, unknown>): { label: string; family: string } {
6  const tool = String(e.tool)
7  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
8  const file = (p: string) => p.split('/').slice(-2).join('/')
9  if (tool === 'Bash') return { label: s('description') || s('command').slice(0, 60), family: 'bash' }
10  if (tool === 'Edit' || tool === 'Write') return { label: file(s('file_path')), family: 'edit' }
11  if (tool === 'Read') return { label: file(s('file_path')), family: 'read' }
12  if (tool === 'Grep' || tool === 'Glob') return { label: s('pattern').slice(0, 40), family: 'search' }
13  if (tool.startsWith('mcp__')) {
14    const [, server = '', action = ''] = tool.split('__')
15    if (/playwright/.test(server)) return { label: action.replace(/^browser_/, ''), family: 'browser' }
16    return { label: action, family: `mcp:${server.replace(/^(plugin_|claude_ai_)/, '').replace(/_.*$/, '')}` }
17  }
18  return { label: tool, family: tool.toLowerCase() }
19}
20
21const ICON: Record<string, string> = { bash: '$', edit: '✎', read: '◉', search: '⌕', browser: '◎' }
22export const icon = (n: MissionNode) =>
23  n.kind === 'main' ? '◆' : n.kind === 'agent' ? '●' : (ICON[n.family] ?? (n.family.startsWith('mcp:') ? '⌁' : '·'))
24
25export function took(n: Pick<MissionNode, 'start' | 'end'>, at = Date.now()) {
26  const s = Math.max(0, ((n.end ?? at) - n.start) / 1000)
27  if (s < 10) return `${s.toFixed(1)}s`
28  if (s < 60) return `${Math.round(s)}s`
29  return `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, '0')}s`
30}
31
32export type Line = { prefix: string; node: MissionNode; text: string; count: number; status: MissionNode['status'] }
33
34// Tree lines, depth first. Runs of the same tool family under one parent fold into one
35// line ("◎ browser: navigate → click → screenshot ×3") so a busy agent stays readable.
36export function lines(nodes: MissionNode[], at = Date.now()): Line[] {
37  const kids = new Map<string | null, MissionNode[]>()
38  for (const n of nodes) kids.set(n.parent, [...(kids.get(n.parent) ?? []), n])
39  const out: Line[] = []
40  const walk = (parent: string | null, prefix: string) => {
41    const groups: MissionNode[][] = []
42    for (const n of kids.get(parent) ?? []) {
43      const last = groups.at(-1)
44      const head = last?.[0]
45      if (last && head && n.kind === 'tool' && head.kind === 'tool' && head.family === n.family) last.push(n)
46      else groups.push([n])
47    }
48    groups.forEach((g, i) => {
49      const isLast = i === groups.length - 1
50      const n = g[0] as MissionNode
51      const branch = parent === null ? '' : isLast ? '└─ ' : '├─ '
52      const status = g.some(x => x.status === 'running') ? 'running' : g.some(x => x.status === 'failed') ? 'failed' : 'done'
53      const start = Math.min(...g.map(x => x.start))
54      const end = status === 'running' ? undefined : Math.max(...g.map(x => x.end ?? x.start))
55      let text: string
56      if (n.kind === 'tool') {
57        const name = n.family.startsWith('mcp:') ? n.family.slice(4) : n.family
58        const acts = g.map(x => x.label)
59        const shown = acts.length > 4 ? [...acts.slice(0, 3), '…', acts.at(-1)] : acts
60        const time = took({ start, end }, at)
61        text = `${icon(n)} ${name}: ${shown.join(' → ')}${g.length > 1 ? `  ×${g.length}` : ''}${time === '0.0s' ? '' : `  ${time}`}`
62      } else {
63        const tools = nodes.filter(x => x.parent === n.id && x.kind === 'tool').length
64        text = `${icon(n)} ${n.label}  ${took(n, at)}${n.kind === 'agent' ? ` · ${tools} tools` : ''}`
65      }
66      out.push({ prefix: prefix + branch, node: n, text, count: g.length, status })
67      if (n.kind !== 'tool') walk(n.id, parent === null ? '' : prefix + (isLast ? '   ' : '│  '))
68    })
69  }
70  walk(null, '')
71  return out
72}
73
74export function summary(nodes: MissionNode[]) {
75  const agents = nodes.filter(n => n.kind === 'agent')
76  const running = agents.filter(n => n.status === 'running').length
77  const tools = nodes.filter(n => n.kind === 'tool').length
78  const failed = nodes.filter(n => n.kind === 'tool' && n.status === 'failed').length
79  return { agents: agents.length, running, tools, failed }
80}
81
types/index.d.ts 36 lines
1// One box in the "who" tree: the main loop, a subagent, or one tool call.
2export type MissionNode = {
3  id: string
4  parent: string | null
5  kind: 'main' | 'agent' | 'tool'
6  label: string
7  family: string // for tools: 'bash', 'edit', 'read', 'browser', 'mcp:<server>', ...
8  status: 'running' | 'done' | 'failed'
9  start: number
10  end?: number
11}
12
13// One file in the code map.
14export type MapFile = {
15  path: string
16  act: 'read' | 'edit' | 'write' | 'search'
17  at: number
18  changedTurn: number // the turn number it last changed in, 0 if never
19  imports: string[] // resolved import targets, extension stripped
20  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