diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1593a31..b5269b5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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": [ diff --git a/docs/mods.md b/docs/mods.md index 38e84c5..492638e 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -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 diff --git a/mods/mission-control/.claude-plugin/plugin.json b/mods/mission-control/.claude-plugin/plugin.json index cfa1114..8ef90aa 100644 --- a/mods/mission-control/.claude-plugin/plugin.json +++ b/mods/mission-control/.claude-plugin/plugin.json @@ -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", diff --git a/mods/mission-control/hooks/register.tsx b/mods/mission-control/hooks/register.tsx index 389724c..e3d36b4 100644 --- a/mods/mission-control/hooks/register.tsx +++ b/mods/mission-control/hooks/register.tsx @@ -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 @@ -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 } @@ -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 } diff --git a/mods/mission-control/tests/mission-control.test.tsx b/mods/mission-control/tests/mission-control.test.tsx index 644ab57..69ea1e7 100644 --- a/mods/mission-control/tests/mission-control.test.tsx +++ b/mods/mission-control/tests/mission-control.test.tsx @@ -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 = {} @@ -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 } } })) @@ -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)