SLOPSHOPPER

mission-control-win

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

newpanebandguardcommandtoast
v0.1.3-win.2MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/mission-control-win
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control-win
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ [ Who ] [ Code ] [ ↗ Browser ] [ 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-win: Mission Control: 0 agents · 9 tool calls · │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

mission-control-win

A Windows fork of hamzafer's mission-control. /mission opens a live map of:

  • the main agent
  • its subagents
  • every tool call, with its status

/mission code draws a map of the files this session touched, with each file's imports.

Mission page in the Mod Hub

Why a fork

Upstream drew its code map by running Chrome from a macOS path, and only into a terminal. This fork draws the map with the desktop app's own Svg element, sized to the pane.

↗ Browser sends the tree and a 1400×860 map to the Mod Hub's Mission page, which is easier to read full size. It re-sends every 2 s when something changed.

Known gaps

2 of its 10 tests fail, the same 2 that fail on unmodified upstream with this engine (a terminal PNG path, and band order).

MIT. Upstream's copyright is kept in LICENSE. On macOS or Linux, use the original.

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