diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index da4918f..6b38134 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.5",
+ "version": "0.1.6",
"source": "./mods/mission-control",
"category": "See what's happening",
"tags": [
diff --git a/README.md b/README.md
index 14be484..7fc661d 100644
--- a/README.md
+++ b/README.md
@@ -161,7 +161,7 @@ Install it like any mod, restart, and type `/mission` (or `/mission code` to ope
- ๐ **Orange** while it writes
- ๐ข **Green** when done, with one line on what changed
-> The Code view runs on macOS and Linux and needs Google Chrome or Chromium and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
+> The Code view runs on macOS and Linux and needs Google Chrome or Chromium and a terminal that shows images (Ghostty, kitty, iTerm2). On Windows it lists the map as lines, and `b` opens the drawn map in your browser. The Who view works everywhere.
## ๐ More
diff --git a/docs/mods.md b/docs/mods.md
index cacd9de..3232cb0 100644
--- a/docs/mods.md
+++ b/docs/mods.md
@@ -36,6 +36,7 @@ Shows every Claude Code subagent, tool call and touched file in a live pane.
- `/mission code` opens straight to the code map, `/mission who` to the agents
- 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)
+- On Windows, whose terminals show no images, the Code view lists the map as lines, and `b` opens the drawn map in your browser, which follows each change
- 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 915dd60..58451c0 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.5",
+ "version": "0.1.6",
"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/map.ts b/mods/mission-control/hooks/map.ts
index f84e1c4..ec74b01 100644
--- a/mods/mission-control/hooks/map.ts
+++ b/mods/mission-control/hooks/map.ts
@@ -85,8 +85,8 @@ const esc = (s: string) =>
export type Look = { width: number; height: number; at: number; turn: number }
// The whole picture: boxes per file, arrows per import, a glow on what is being touched now.
-const SANS = `-apple-system, 'SF Pro Text', 'Helvetica Neue', sans-serif`
-const MONO = `'SF Mono', Menlo, monospace`
+const SANS = `-apple-system, 'SF Pro Text', 'Helvetica Neue', 'Segoe UI', sans-serif`
+const MONO = `'SF Mono', Menlo, Consolas, monospace`
const C = {
bg: '#0d1015', grid: '#1a1f28', card: '#151922', cardLive: '#1b2130', line: '#2a3140', text: '#e8ebf2',
dim: '#7d8698', faint: '#4b5263', edge: '#3a4252', edgeHot: '#6b7a96',
@@ -246,6 +246,32 @@ export function svg(files: MapFile[], look: Look): string {
return o.join('\n')
}
+export type MapLine = { mark: string; tone: 'read' | 'edit' | 'changed' | 'dim'; title: string; imports: string[]; why?: string }
+
+// The map as lines, for a terminal that draws no pictures: newest first, each file's
+// state, the files it imports and the one-line why.
+export function textMap(files: MapFile[], look: { at: number; turn: number }): MapLine[] {
+ const all = files.map(f => f.path)
+ const es = edges(files)
+ return files.map(f => {
+ const isLive = look.at - f.at < 5000
+ const isChanged = f.changedTurn === look.turn && look.turn > 0
+ const isReading = f.act === 'read' || f.act === 'search'
+ return {
+ mark: isLive ? (isReading ? 'โ' : 'โ') : isChanged ? 'โ' : 'ยท',
+ tone: isLive ? (isReading ? 'read' : 'edit') : isChanged ? 'changed' : 'dim',
+ title: titleOf(f.path, all),
+ imports: es.filter(e => e.from === f.path).map(e => titleOf(e.to, all)),
+ why: f.why,
+ }
+ })
+}
+
+// The picture as a page that reloads itself, so a browser keeps showing the latest.
+export function page(picture: string) {
+ return `
Code map\n${picture}\n`
+}
+
// A file's name, or with several of that name the nearest folder that tells them apart:
// three `hooks/register.tsx` read as `merge-gate โบ register.tsx`, `snake โบ register.tsx`.
export function titleOf(path: string, all: string[]) {
diff --git a/mods/mission-control/hooks/register.tsx b/mods/mission-control/hooks/register.tsx
index 03e733d..24cd9d9 100644
--- a/mods/mission-control/hooks/register.tsx
+++ b/mods/mission-control/hooks/register.tsx
@@ -7,7 +7,7 @@ import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, Timer } from 'claude-code'
import type { MapFile, MissionNode } from '../types'
-import { importsOf, svg } from './map'
+import { importsOf, page, svg, textMap } from './map'
import { label, lines, summary } from './tree'
import { bandsFile, parseOrder, stackBands } from './bands'
@@ -37,9 +37,11 @@ const turn = atom({ plugin: 'mission-control', key: 'turn' } as const, 0)
const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)
// Drawing bookkeeping; a reload starts it over.
-const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '' }
+const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '', isPageOpen: false }
const changes = new Map() // path โ this turn's edits, for the one-line "why"
const MAX_CHANGES = 5 // the first edit and the latest few per file are enough for the "why"
+// No Windows terminal draws pictures: there the map is lines in the pane and a page in the browser.
+let isWindows = false
// Under the cap, the oldest tool calls go first; main and the agents always stay.
export function cap(list: MissionNode[]) {
@@ -65,6 +67,7 @@ export const register: Register = on => {
on('session.start', async ($, e, next) => {
const r = await next(e)
+ isWindows = (await $.env.get('OS')) === 'Windows_NT'
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(() => {})
timer?.cancel()
timer = $.clock.every(1000, () => {
@@ -112,7 +115,8 @@ export const register: Register = on => {
const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
})
- const path = typeof args.file_path === 'string' ? args.file_path : undefined
+ const raw = typeof args.file_path === 'string' ? args.file_path : undefined
+ const path = raw && isWindows ? raw.replace(/\\/g, '/') : raw // one separator, so names and imports resolve
if (path) await touch($, path, e.tool === 'Read' ? 'read' : e.tool === 'Write' ? 'write' : e.tool === 'Edit' ? 'edit' : 'search', false)
let failed = true
@@ -170,6 +174,7 @@ export const register: Register = on => {
)
@@ -183,6 +188,27 @@ export const register: Register = on => {
)
}
+ if (isWindows) {
+ const t = await read($, turn)
+ const list = await read($, files)
+ const ls = textMap(list, { at: Date.now(), turn: t })
+ const changed = list.filter(f => f.changedTurn === t && t > 0).length
+ return (
+
+ {head}
+ {`${list.length} files ยท ${changed} changed ยท b: the drawn map in your browser`}
+ {draw.error !== '' && {draw.error}}
+ {ls.length === 0 && Files Claude reads or edits show up here.}
+ {ls.slice(0, rows - 1).map(l => (
+
+ {`${l.mark} `}
+ {l.title}
+ {(l.imports.length > 0 || l.why) && {`${l.imports.length ? ` โ ${l.imports.join(', ')}` : ''}${l.why ? ` ${l.why}` : ''}`}}
+
+ ))}
+
+ )
+ }
const { Image } = $.ui.resolve(e)
// A fixed 16:10 shape from the pane's width: lines coming and going above the prompt
// change the pane's height, and must not resize (and jolt) the picture.
@@ -311,13 +337,17 @@ async function renderSoon($: EngineInterface) {
draw.dirty = true // drawing now: draw once more when it is done
return
}
- if ((await read($, view)) !== 'code') return
- const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
- if (!isUp) return
+ if (isWindows) {
+ if (!draw.isPageOpen) return // the browser page is the picture, kept fresh once opened
+ } else {
+ if ((await read($, view)) !== 'code') return
+ const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
+ if (!isUp) return
+ }
draw.pending = true
draw.dirty = false
$.clock.after(700, () => {
- void renderMap($)
+ void (isWindows ? renderPage($) : renderMap($))
.catch(() => {})
.finally(() => {
draw.pending = false // only now: two Chromes must never share the profile
@@ -362,3 +392,28 @@ async function renderMap($: EngineInterface) {
draw.error = `${chrome} could not draw the code map.`
await update($, now, () => Date.now()) // redraw the pane with the message
}
+
+async function pagePath($: EngineInterface) {
+ const tmp = ((await $.env.get('TEMP')) ?? 'C:\\Windows\\Temp').replace(/\\$/, '')
+ return `${tmp}\\mission-control-map.html` // right in TEMP, which always exists: the engine makes no folders
+}
+
+// The drawn map as a page the browser reloads: no Chrome to run, the browser draws the SVG.
+async function renderPage($: EngineInterface) {
+ await $.fs.write(await pagePath($), page(svg(await read($, files), { width: 1400, height: 820, at: Date.now(), turn: await read($, turn) })))
+}
+
+// Opens the drawn map in the default browser; from then on each change redraws it.
+async function openPage($: EngineInterface) {
+ try {
+ await renderPage($)
+ } catch {
+ draw.isPageOpen = false
+ draw.error = `Could not write ${await pagePath($)}.`
+ await update($, now, () => Date.now()) // redraw the pane with the message
+ return
+ }
+ draw.isPageOpen = true
+ draw.error = ''
+ await $.process.run(['explorer.exe', await pagePath($)]).catch(() => {}) // explorer exits 1 even when it opened the page
+}
diff --git a/mods/mission-control/tests/mission-control.test.tsx b/mods/mission-control/tests/mission-control.test.tsx
index f73cafc..1455824 100644
--- a/mods/mission-control/tests/mission-control.test.tsx
+++ b/mods/mission-control/tests/mission-control.test.tsx
@@ -9,10 +9,12 @@ 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; renderFails?: boolean; bands?: string; beneath?: unknown } = {}) {
+function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFails?: boolean; bands?: string; beneath?: unknown; env?: Record; pageFails?: boolean } = {}) {
const runs: string[][] = []
resetBandOrder()
const writes: Record = {}
+ // A path as the mod wrote it: on Windows the engine hands `/tmp/x` on as `C:\tmp\x`.
+ const posix = (p: string) => p.replace(/^[A-Za-z]:/, '').replace(/\\/g, '/')
on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd }))
on('command.register', () => ({ value: undefined }))
const clock = mock.clock(on)
@@ -21,10 +23,13 @@ function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFa
on('turn.complete', () => ({ text: '' }))
on('agent.spawn', () => ({ model: 'sonnet', agentId: 'ag1' }))
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.chromeAt !== undefined ? e.path === opts.chromeAt : !opts.noChrome }))
- on('env.get', () => ({ value: '/tmp/' }))
+ on('fs.read', (_$: any, e: any) => ({ value: e.path.endsWith('login.tsx') ? "import { start } from '../auth/session'\n" : posix(e.path).endsWith('/bands.json') ? opts.bands : '' }))
+ on('fs.write', (_$: any, e: any) => {
+ if (opts.pageFails && e.path.endsWith('.html')) throw new Error('EACCES')
+ return (writes[posix(e.path)] = e.text), { value: undefined }
+ })
+ on('fs.exists', (_$: any, e: any) => ({ value: posix(e.path).endsWith('/bands.json') ? opts.bands !== undefined : opts.chromeAt !== undefined ? e.path === opts.chromeAt : !opts.noChrome }))
+ on('env.get', (_$: any, e: any) => ({ value: opts.env?.[e.name] ?? '/tmp/' }))
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 }] }))
@@ -214,6 +219,46 @@ describe('mission-control', () => {
expect(summary([]).tools).toBe(0)
})
+ test('on Windows the code view is lines, and b opens the drawn map in a browser', async ($, on) => {
+ const { runs, writes, clock } = engine(on, { env: { OS: 'Windows_NT', TEMP: 'C:\\Users\\me\\AppData\\Local\\Temp' } })
+ const html = () => Object.entries(writes).find(([p]) => p.endsWith('/mission-control-map.html'))?.[1] ?? ''
+ await $.session.start({ surface: 'terminal', isInteractive: true, cwd: 'C:\\repo' } as any)
+ await $.turn.start({ text: 'fix the login bug', turnId: 't1' } as any)
+ await $.tool.call({ tool: 'Read', file_path: 'C:\\repo\\src\\pages\\login.tsx' } as any)
+ await $.tool.call({ tool: 'Edit', file_path: 'C:\\repo\\src\\auth\\session.ts', old_string: 'a', new_string: 'b' } as any)
+ await $.command.run({ command: 'mission', args: 'code' } as any)
+ const pane = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
+ expect(await pane.find({ type: 'Text', text: /2 files ยท 1 changed/ })).toBeDefined()
+ expect(await pane.find({ type: 'Text', text: /^login\.tsx$/ })).toBeDefined() // the name, not the whole C:\ path
+ expect(await pane.find({ type: 'Text', text: /โ session\.ts/ })).toBeDefined() // the import across backslash paths
+ expect(await pane.find({ type: 'Image' })).toBeUndefined()
+ await clock.advance(800)
+ expect(runs).toHaveLength(0) // no Chrome, and no page until it is asked for
+
+ await pane.press({ key: 'open' })
+ expect(html()).toContain('http-equiv="refresh"')
+ expect(html()).toContain('session.ts')
+ expect(runs.some(a => a[0] === 'explorer.exe' && a[1] === 'C:\\Users\\me\\AppData\\Local\\Temp\\mission-control-map.html')).toBe(true) // right in TEMP: no folder to make
+ // Once open, the page follows each change.
+ await $.tool.call({ tool: 'Read', file_path: 'C:\\repo\\README.md' } as any)
+ await clock.advance(800)
+ expect(html()).toContain('README.md')
+ await pane.unmount()
+ })
+
+ test('on Windows a page that cannot be written says so, and opens nothing', async ($, on) => {
+ const { runs } = engine(on, { env: { OS: 'Windows_NT', TEMP: 'C:\\Temp' }, pageFails: true })
+ await $.session.start({ surface: 'terminal', isInteractive: true, cwd: 'C:\\repo' } 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 pane.press({ key: 'open' })
+ expect(runs.some(a => a[0] === 'explorer.exe')).toBe(false)
+ await pane.unmount()
+ const again = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
+ expect(await again.find({ type: 'Text', text: /Could not write C:\\Temp\\mission-control-map\.html/ })).toBeDefined()
+ await again.unmount()
+ })
+
test('a second session.start keeps one clock, not two', async ($, on) => {
const { clock } = engine(on)
let ticks = 0 // each tick with work running writes now once
diff --git a/site/mods/mission-control/index.html b/site/mods/mission-control/index.html
index d73bc7e..00f2a52 100644
--- a/site/mods/mission-control/index.html
+++ b/site/mods/mission-control/index.html
@@ -25,7 +25,7 @@
-
+
@@ -43,7 +43,7 @@
mission-control
Every subagent, every tool call and every file they touch, in a pane next to the chat.
-
โ See what's happeningVersion 0.1.5
+
โ See what's happeningVersion 0.1.6
Shown at 4x: two subagents building a logout feature across four files.
@@ -80,6 +80,7 @@
mission-control
Claude Code 2.1.287 or newer
The Code view runs on macOS and Linux and needs Google Chrome or Chromium and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
+
On Windows, whose terminals show no images, the Code view lists the map as lines, and b opens the drawn map in your browser.