From def96dc01c2e1a3ab7e9df4b00b51e7856743a84 Mon Sep 17 00:00:00 2001 From: Luis Paz Date: Thu, 22 Oct 2026 22:46:24 -0500 Subject: [PATCH 1/2] fix(mission-control): find Chrome or Chromium on Linux for the code map The code map only looked for Chrome at the macOS app path, so on Linux it always showed 'needs Google Chrome'. Probe a fixed list of macOS and Linux browser paths and use the first that exists. --- .claude-plugin/marketplace.json | 2 +- docs/mods.md | 4 ++-- .../.claude-plugin/plugin.json | 2 +- mods/mission-control/hooks/register.tsx | 22 +++++++++++++++---- .../tests/mission-control.test.tsx | 17 ++++++++++++-- 5 files changed, 37 insertions(+), 10 deletions(-) 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..52dae71 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,6 @@ 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 }) + 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 })) } diff --git a/mods/mission-control/tests/mission-control.test.tsx b/mods/mission-control/tests/mission-control.test.tsx index 644ab57..8542fb7 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; bands?: string; beneath?: unknown } = {}) { const runs: string[][] = [] resetBandOrder() const writes: Record = {} @@ -23,7 +23,7 @@ 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('ui.open', () => ({ value: { isPlaced: true } })) @@ -73,6 +73,19 @@ 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 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) From e4ca6713412a7d4c833f7e80c9ee9cb7bd01d620 Mon Sep 17 00:00:00 2001 From: Luis Paz Date: Thu, 22 Oct 2026 22:49:49 -0500 Subject: [PATCH 2/2] fix(mission-control): show an error when the browser cannot draw the code map A browser path that exists but fails to render (for example a Snap launcher) left the pane on 'Drawing the code map...' forever. Show which browser failed instead. --- mods/mission-control/hooks/register.tsx | 7 ++++++- .../tests/mission-control.test.tsx | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/mods/mission-control/hooks/register.tsx b/mods/mission-control/hooks/register.tsx index 52dae71..e3d36b4 100644 --- a/mods/mission-control/hooks/register.tsx +++ b/mods/mission-control/hooks/register.tsx @@ -350,5 +350,10 @@ async function renderMap($: EngineInterface) { '--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 })) + 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 8542fb7..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; chromeAt?: string; 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 = {} @@ -25,7 +25,7 @@ function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; 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.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 } } })) @@ -86,6 +86,19 @@ describe('mission-control', () => { 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)