Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
{
"name": "mission-control",
"description": "/mission opens a live map of the main agent, its subagents and every tool call, plus a code map of the files they touch.",
"version": "0.1.3",
"version": "0.1.4",
"source": "./mods/mission-control",
"category": "See what's happening",
"tags": [
Expand Down
4 changes: 2 additions & 2 deletions docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ Shows every Claude Code subagent, tool call and touched file in a live pane.

- Press `w` for Who, `c` for Code and `q` to close
- `/mission code` opens straight to the code map, `/mission who` to the agents
- Headless Chrome (`/Applications/Google Chrome.app`) draws the code map as an image, in a throwaway profile
- The Code view is macOS only and needs a terminal that shows images (Ghostty, kitty, iTerm2)
- Headless Chrome draws the code map as an image, in a throwaway profile. It uses the first of `/Applications/Google Chrome.app` (macOS), `google-chrome-stable`, `google-chrome`, `chromium-browser` or `chromium` in `/usr/bin` (Linux)
- The Code view runs on macOS and Linux and needs a terminal that shows images (Ghostty, kitty, iTerm2)
- A file glows blue while Claude reads it, orange while it edits it, and turns green with a check once changed
- One Haiku call after each turn writes the line under each changed file

Expand Down
2 changes: 1 addition & 1 deletion mods/mission-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mission-control",
"version": "0.1.3",
"version": "0.1.4",
"description": "/mission opens a live map of the main agent, its subagents and every tool call, plus a code map of the files they touch.",
"author": {
"name": "Hamza Zafar",
Expand Down
29 changes: 24 additions & 5 deletions mods/mission-control/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import { label, lines, summary } from './tree'
import { bandsFile, parseOrder, stackBands } from './bands'

const PANE = 'mission-control'
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
// The first of these that exists draws the code map: Chrome on macOS, then Linux builds.
const CHROMES = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
]
const MAX_NODES = 300
const MAX_FILES = 14
const BAND_AFTER_MS = 30_000 // the band stays this long after a turn ends
Expand Down Expand Up @@ -315,8 +322,15 @@ async function renderSoon($: EngineInterface) {
}

async function renderMap($: EngineInterface) {
if (!(await $.fs.exists(CHROME))) {
draw.error = `The code map needs Google Chrome at ${CHROME}.`
let chrome = ''
for (const path of CHROMES) {
if (await $.fs.exists(path)) {
chrome = path
break
}
}
if (!chrome) {
draw.error = `The code map needs Google Chrome or Chromium at one of: ${CHROMES.join(', ')}.`
await update($, now, () => Date.now()) // redraw the pane with the message
return
}
Expand All @@ -335,6 +349,11 @@ async function renderMap($: EngineInterface) {
'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 ' +
'--user-data-dir="$3" --window-size="$4" --screenshot="$2" "$5" >/dev/null 2>&1 & pid=$!; ' +
'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" ]'
const r = await $.process.run(['sh', '-c', script, 'mission-control', CHROME, png, `${dir}/chrome`, `${width},${height}`, `file://${encodeURI(svgPath)}`], { timeoutMs: 20_000 })
if (r.exitCode === 0) await update($, frame, () => ({ file: png, n }))
const r = await $.process.run(['sh', '-c', script, 'mission-control', chrome, png, `${dir}/chrome`, `${width},${height}`, `file://${encodeURI(svgPath)}`], { timeoutMs: 20_000 })
if (r.exitCode === 0) {
await update($, frame, () => ({ file: png, n }))
return
}
draw.error = `${chrome} could not draw the code map.`
await update($, now, () => Date.now()) // redraw the pane with the message
}
32 changes: 29 additions & 3 deletions mods/mission-control/tests/mission-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const PANE = { component: 'Pane', requestId: 'mission-control', props: { title:
const BAND = { component: 'AbovePrompt', props: { hasSurvey: false, isWorking: true, maxRows: 10, bodyColumns: 120 } }

// Stands for the engine beneath the mod.
function engine(on: any, opts: { noChrome?: boolean; bands?: string; beneath?: unknown } = {}) {
function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFails?: boolean; bands?: string; beneath?: unknown } = {}) {
const runs: string[][] = []
resetBandOrder()
const writes: Record<string, string> = {}
Expand All @@ -23,9 +23,9 @@ function engine(on: any, opts: { noChrome?: boolean; bands?: string; beneath?: u
on('tool.call', () => ({ result: {}, text: 'ok' }))
on('fs.read', (_$: any, e: any) => ({ value: e.path.endsWith('login.tsx') ? "import { start } from '../auth/session'\n" : e.path.endsWith('/bands.json') ? opts.bands : '' }))
on('fs.write', (_$: any, e: any) => ((writes[e.path] = e.text), { value: undefined }))
on('fs.exists', (_$: any, e: any) => ({ value: e.path.endsWith('/bands.json') ? opts.bands !== undefined : !opts.noChrome }))
on('fs.exists', (_$: any, e: any) => ({ value: e.path.endsWith('/bands.json') ? opts.bands !== undefined : opts.chromeAt !== undefined ? e.path === opts.chromeAt : !opts.noChrome }))
on('env.get', () => ({ value: '/tmp/' }))
on('process.run', (_$: any, e: any) => (runs.push(e.argv), { value: { exitCode: 0, stdout: '', stderr: '' } }))
on('process.run', (_$: any, e: any) => (runs.push(e.argv), { value: { exitCode: opts.renderFails && e.argv[0] === 'sh' ? 1 : 0, stdout: '', stderr: '' } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
on('ui.panes', () => ({ value: [{ id: 'mission-control', title: 'Mission Control', isShown: true, isFocused: true, isPlaced: true }] }))
on('model.complete', () => ({ value: { isAnswered: true, text: '["tokens now expire after an hour"]', usage: { input_tokens: 1, output_tokens: 1 } } }))
Expand Down Expand Up @@ -73,6 +73,32 @@ describe('mission-control', () => {
await pane.unmount()
})

test('on Linux the code map uses Chromium when that is the browser installed', async ($, on) => {
const { runs } = engine(on, { chromeAt: '/usr/bin/chromium-browser' })
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/repo' } as any)
await $.turn.start({ text: 'go', turnId: 't1' } as any)
await $.command.run({ command: 'mission', args: 'code' } as any)
const pane = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
await $.tool.call({ tool: 'Read', file_path: '/repo/src/a.ts' } as any)
await mockClock.advance(800)
expect(await pane.find({ type: 'Text', text: /needs Google Chrome/ })).toBeUndefined()
expect(runs.some(a => a[0] === 'sh' && a.includes('/usr/bin/chromium-browser'))).toBe(true)
await pane.unmount()
})

test('a browser that exists but cannot draw says so instead of drawing forever', async ($, on) => {
engine(on, { chromeAt: '/usr/bin/chromium-browser', renderFails: true })
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/repo' } as any)
await $.turn.start({ text: 'go', turnId: 't1' } as any)
await $.command.run({ command: 'mission', args: 'code' } as any)
const pane = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
await $.tool.call({ tool: 'Read', file_path: '/repo/src/a.ts' } as any)
await mockClock.advance(800)
expect(await pane.find({ type: 'Text', text: /\/usr\/bin\/chromium-browser could not draw the code map/ })).toBeDefined()
expect(await pane.find({ type: 'Image' })).toBeUndefined()
await pane.unmount()
})

test('a background agent still running keeps its open calls when main ends', async ($, on) => {
engine(on)
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/repo' } as any)
Expand Down
Loading