SLOPSHOPPER

mission-control

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

newpanebandguardcommandmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ [ Who ] [ Code ] [ Close ] │ ┃ 0 agents (0 running) · 9 tool calls · 1 ⏺ Read(src/auth.ts) │ ┃ failed ⎿ Read 6 lines │ ┃ Nothing yet. Start a task and watch it fill ⏺ Update(src/auth.ts) │ ┃ in. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mission │ ⎿ mission-control: Mission Control: 0 agents · 9 tool calls · 3 fi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

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