Run mission-skill missions with a live status pane and per-role Claude model routing

A Claude Code mod that runs mission-skill missions with a live Mission pane and per-role Claude model routing.
/plugin install mission --marketplace brogrammerMW/mission-mod
Answer y to add the marketplace, then pick a scope.
| Command | What it does |
|---|---|
/mission | Opens the pane as a viewer of the latest mission in .missions/ |
/mission <goal> | Turns routing on and starts a mission-skill mission for the goal |
/mission stop | Turns routing off; the pane stays open |
Change each role's model from the pickers in the pane. Your choices are kept across sessions.
mission-skill skill that writes its progress to .missions/<id>/log.jsonl. The pane reads that log; without it, the pane shows standby.claude plugin validate .
claude plugin test .
See NOTICE.md.
hooks/register.tsx 367 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { ModelAlias, Models, MissionView } from '../types'
5import { summarize } from './summary'
6import { hms, lane, missionClock, pill, shorten, splitId, timer } from './format'
7
8const PANE = 'mission'
9const MIN_COLS = 34
10// Below this body width the pane switches to its compact labels.
11const NARROW = 56
12const FULL_ID: Record<ModelAlias, string> = {
13 fable: 'claude-fable-5-1',
14 opus: 'claude-opus-5-5',
15 sonnet: 'claude-sonnet-5-5',
16 haiku: 'claude-haiku-4-5-20251001',
17}
18const DEFAULT_MODELS: Models = { orchestrator: 'opus', worker: 'sonnet', reviewer: 'fable' }
19// Theme color names, so the pane follows light, dark and color-blind themes.
20const C = {
21 main: 'claude', agent: 'suggestion', arch: 'merged', ok: 'success', amber: 'warning',
22 warn: 'error', dim: 'inactive', faint: 'subtle', text: 'text',
23}
24const ALIASES = Object.keys(FULL_ID).map(value => ({ value, label: value }))
25
26const models = atom({ plugin: 'mission', key: 'models' } as const, DEFAULT_MODELS)
27const view = atom({ plugin: 'mission', key: 'view' } as const, null)
28const isArmed = atom({ plugin: 'mission', key: 'isArmed' } as const, false)
29const goal = atom({ plugin: 'mission', key: 'goal' } as const, '')
30const now = atom({ plugin: 'mission', key: 'now' } as const, 0)
31
32let poll: Timer | undefined
33
34// Which mission role an Agent call plays, from the skill's own role files only:
35// a loose word or subagent name would reroute unrelated agents while armed.
36export function roleOf(prompt: string): 'worker' | 'reviewer' | null {
37 if (/validator-role\.md/.test(prompt)) return 'reviewer'
38 if (/worker-role\.md|\.mission-context\.md|\bworker `?w-\d/i.test(prompt)) return 'worker'
39 return null
40}
41
42async function latestLog($: Engine): Promise<string | null> {
43 const root = `${await $.session.cwd()}/.missions`
44 if (!(await $.fs.exists(root))) return null
45 let best: { path: string; mtime: number } | null = null
46 for (const entry of await $.fs.list(root)) {
47 if (entry.kind !== 'dir') continue
48 const path = `${root}/${entry.name}/log.jsonl`
49 try {
50 const { mtimeMs } = await $.fs.stat(path)
51 if (!best || mtimeMs > best.mtime) best = { path, mtime: mtimeMs }
52 } catch { /* no log yet */ }
53 }
54 return best && (await $.fs.read(best.path))
55}
56
57async function refresh($: Engine) {
58 try {
59 const log = await latestLog($)
60 const next = log ? summarize(log) : null
61 const prev = await read($, view)
62 if (JSON.stringify(next) !== JSON.stringify(prev)) await update($, view, () => next)
63 // Keep the T+ clock moving only while a mission runs.
64 if (next?.status === 'running') { const t = await $.clock.now(); await update($, now, () => t) }
65 if (next && next.status !== 'running' && (await read($, isArmed))) {
66 await update($, isArmed, () => false)
67 $.ui.toast(`Mission ${next.status}: ${next.id}`)
68 }
69 } catch (err) {
70 $.ui.log(`mission: refresh failed: ${String(err)}`, { to: 'debug' })
71 }
72}
73
74// The dock takes a quarter of the terminal; a width the person dragged still wins.
75async function openPane($: Engine, terminalColumns: number) {
76 await $.ui.open({ id: PANE, title: 'Mission', columns: Math.max(MIN_COLS, Math.round(terminalColumns / 4)) })
77 await startPolling($)
78}
79
80// `poll` is a module variable, and a hot reload drops the old load's timers, so
81// session.start (which fires again on reload) calls this too.
82async function startPolling($: Engine) {
83 await refresh($)
84 poll ??= $.clock.every(1000, () => void refresh($))
85}
86
87async function setModel($: Engine, role: keyof Models, value: string) {
88 const next = await update($, models, m => ({ ...m, [role]: value }))
89 await $.store.set('models', next)
90 const running = (await read($, view))?.status === 'running'
91 if (running && (await read($, isArmed))) $.ui.toast('Changed mid-mission: re-validate the last passed phase (mission-skill rule).')
92}
93
94// Starts the run once the command has returned and the session is idle; if the
95// prompt does not enter, leave it in the composer so one Enter starts it.
96async function startMission($: Engine, text: string) {
97 try {
98 const entered = await $.prompt.submit({ text })
99 if (entered.drop === undefined) return
100 $.ui.toast(`Mission prompt was refused: ${String(entered.drop)}`)
101 } catch (err) {
102 $.ui.log(`mission: submit failed: ${String(err)}`, { to: 'debug' })
103 }
104 await $.prompt.fill({ text })
105 $.ui.toast('Press Enter to start the mission.')
106}
107
108export const register: Register = on => {
109 on('session.start', async ($, e, next) => {
110 await $.command.register({ name: 'mission', description: 'Mission pane; `/mission <goal>` starts a mission-skill mission' })
111 const saved = (await $.store.get('models')) as Models | undefined
112 if (saved) await update($, models, () => ({ ...DEFAULT_MODELS, ...saved }))
113 // $.state outlives a reload: pick a live mission back up.
114 if ((await read($, view))?.status === 'running' || (await read($, isArmed))) void startPolling($)
115 return next(e)
116 })
117
118 on('command.run', { command: 'mission' }, async ($, e) => {
119 await openPane($, e.presentation.columns)
120 const args = e.args.trim()
121 if (args === 'stop') {
122 await update($, isArmed, () => false)
123 return { text: 'Mission routing off. The pane stays as a viewer.' }
124 }
125 if (!args) return { text: 'Mission pane opened.' }
126 await update($, isArmed, () => true)
127 await update($, goal, () => args)
128 $.clock.after(250, () => void startMission($, `Use the mission-skill skill to run this mission: ${args}`))
129 const m = await read($, models)
130 return { text: `Mission armed: orchestrator ${m.orchestrator}, workers ${m.worker}, reviewer ${m.reviewer}.` }
131 })
132
133 on('ui.close', ($, e, next) => {
134 if (e.id === PANE) { poll?.cancel(); poll = undefined }
135 return next(e)
136 })
137
138 on('prompt.compose', async ($, e, next) => {
139 const composed = await next(e)
140 if (!(await read($, isArmed))) return composed
141 const m = await read($, models)
142 const orch = m.orchestrator === 'session' ? 'the session model' : m.orchestrator
143 const text = [
144 'Mission mod is active for this session.',
145 '- Mission Control is the `mission` pane in Claude Code. Skip the Mission Control URL gate: do not run mission-control.sh, start a server, or open a browser. Keep calling every lifecycle script (log-event, start-phase, progress, validate-result, ...): the pane reads .missions/<id>/log.jsonl.',
146 `- Model routing replaces the skill's grok/gpt defaults: orchestrator ${orch}; workers: Agent tool with model "${m.worker}"; validator: a fresh Agent with model "${m.reviewer}". Ignore the Cursor model slugs the scripts print.`,
147 `- When calling approve-spec.sh pass --orchestrator ${orch.replace(/ /g, '-')} --worker ${m.worker} --validator ${m.reviewer}.`,
148 ].join('\n')
149 return { sections: [...composed.sections, { id: 'mission-routing', text, scope: 'session' }] }
150 })
151
152 // Enforce the picked models even if the orchestrator forgets the `model` field.
153 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
154 const role = roleOf(e.prompt)
155 const m = await Promise.all([read($, isArmed), read($, models)]).catch(() => null)
156 if (!role || !m || !m[0]) return next(e)
157 return next({ ...e, model: role === 'worker' ? m[1].worker : m[1].reviewer })
158 })
159
160 on('turn.step', async function* ($, e, next) {
161 const m = await read($, models)
162 const isMain = e.agentId === undefined
163 if (!isMain || m.orchestrator === 'session' || !(await read($, isArmed))) return yield* next(e)
164 return yield* next({ ...e, model: FULL_ID[m.orchestrator] })
165 })
166
167 // Layout and palette follow scasella/claude-flightdeck (MIT): a centered title bar with a
168 // legend, one rounded card per role in that role's theme color, swimlanes, and a log card.
169 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
170 const els = $.ui.resolve(e)
171 const { Box, Text, Button } = els
172 // The mobile app draws no Select yet: there the pane is a viewer only.
173 const Select = 'Select' in els ? els.Select : null
174 const m = await read($, models)
175 const v: MissionView | null = await read($, view)
176 const armed = await read($, isArmed)
177 const pending = await read($, goal)
178 const nowMs = (await read($, now)) || (await $.clock.now())
179 // Size to the pane's own body, not the whole terminal (viewport is the surface).
180 const cols = Math.max(24, e.props.bodyColumns)
181 const narrow = cols < NARROW
182 const rows = e.viewport?.rows ?? 40
183 const inner = cols - 4
184
185 const Gauge = ({ percent, width, color }: { percent: number; width: number; color: string }) => {
186 const g = pill(percent, width)
187 return <Text><Text color={color}>{g.fill}</Text><Text color={C.faint}>{g.empty}</Text></Text>
188 }
189 const Divider = () => <Text color={C.faint}>{'─'.repeat(cols)}</Text>
190 const picker = (role: keyof Models, color: string) => Select
191 ? <Select key={`model-${role}`} value={m[role]}
192 options={role === 'orchestrator' ? [{ value: 'session', label: 'session model' }, ...ALIASES] : ALIASES}
193 onSelect={(value: string) => void setModel($, role, value)} />
194 : <Text color={color} bold>{m[role]}</Text>
195
196 const statusWord = !v ? (armed && pending ? 'STARTING' : 'STANDBY') : v.status.toUpperCase()
197 const statusColor = !v ? (armed ? C.amber : C.dim) : v.status === 'complete' ? C.ok : v.status === 'aborted' ? C.warn : C.amber
198 const spanEnd = v?.endedAt ? Date.parse(v.endedAt) : nowMs
199 const routing = <Text color={armed ? C.ok : C.dim}>{armed ? 'ROUTING ON' : 'ROUTING OFF'}</Text>
200 const titleBar = (
201 <Box flexDirection="column" alignItems="center">
202 <Text bold wrap="truncate">
203 <Text color={C.text}>MISSION</Text>
204 <Text color={C.dim}> · </Text>
205 <Text color={statusColor}>{statusWord}</Text>
206 {v && <Text color={C.dim}> · </Text>}
207 {v && <Text color={C.main}>{missionClock(v.startedAt, spanEnd)}</Text>}
208 {!narrow && <Text color={C.dim}> · </Text>}
209 {!narrow && routing}
210 </Text>
211 {narrow && <Text bold>{routing}</Text>}
212 <Text wrap="truncate">
213 <Text color={C.main}>■</Text><Text color={C.dim}>{narrow ? ' orch ' : ' orchestrator '}</Text>
214 <Text color={C.agent}>■</Text><Text color={C.dim}>{narrow ? ' work ' : ' workers '}</Text>
215 <Text color={C.arch}>■</Text><Text color={C.dim}>{narrow ? ' review' : ' reviewer'}</Text>
216 </Text>
217 </Box>
218 )
219
220 // ---- orchestrator card
221 const total = v ? Math.max(v.totalPhases, v.phases.length) : 0
222 const passed = v ? v.phases.filter(p => p.verdict === 'PASS').length : 0
223 const overall = total ? Math.round((passed / total) * 100) : 0
224 const active = v?.phases.find(p => !p.verdict)
225 const { name, tag } = v ? splitId(v.id) : { name: pending || 'no mission yet', tag: '' }
226 const orchestratorCard = (
227 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1}>
228 <Box justifyContent="space-between">
229 <Text color={C.main} bold>ORCHESTRATOR · {m.orchestrator === 'session' ? 'session' : m.orchestrator}</Text>
230 <Text color={v?.status === 'running' ? C.main : C.dim}>{v?.status === 'running' ? '● working' : v ? '✓ done' : '○ idle'}</Text>
231 </Box>
232 <Text wrap="truncate-end"><Text bold>{shorten(name, inner - 10)}</Text><Text color={C.faint}>{tag && ` ${tag}`}</Text></Text>
233 {v ? (
234 <Text wrap="truncate">
235 <Text color={C.dim}>phases </Text><Gauge percent={overall} width={Math.min(12, Math.max(4, total * 3))} color={C.main} />
236 <Text color={C.main} bold> {passed}/{total || '?'}</Text>
237 <Text color={C.dim}>{active ? ` now ${active.slug}` : ''}{v.preset ? ` · ${v.preset}` : ''}</Text>
238 </Text>
239 ) : (
240 <Text color={C.dim}>{armed && pending ? 'running intake · .missions/ appears after the spec' : 'start one with /mission <goal>'}</Text>
241 )}
242 <Box gap={1}><Text color={C.dim}>model</Text>{picker('orchestrator', C.main)}</Box>
243 </Box>
244 )
245
246 // ---- reviewer card: a timeline of verdicts across the mission
247 const reviews = v?.reviews ?? []
248 const trackW = Math.max(10, inner)
249 const marks = new Map<number, { glyph: string; color: string }>()
250 if (v) for (const r of reviews) {
251 const x = lane(Date.parse(r.at), Date.parse(r.at), Date.parse(v.startedAt), spanEnd, trackW - 1).before
252 marks.set(x, r.verdict === 'PASS' ? { glyph: '◆', color: C.ok } : { glyph: '◇', color: C.warn })
253 }
254 const track = Array.from({ length: trackW }, (_, i) => marks.get(i))
255 const lastReview = reviews.at(-1)
256 const reviewerCard = (
257 <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1}>
258 <Box justifyContent="space-between">
259 <Text color={C.arch} bold>REVIEWER · {m.reviewer}</Text>
260 <Text color={C.dim}>reviews <Text color={C.arch} bold>{reviews.length}</Text></Text>
261 </Box>
262 <Text wrap="truncate">
263 {track.map((t, i) => t ? <Text key={`t-${i}`} color={t.color} bold>{t.glyph}</Text> : <Text key={`t-${i}`} color={C.arch}>─</Text>)}
264 </Text>
265 <Text color={C.dim} wrap="truncate">
266 {lastReview
267 ? <Text>last <Text color={lastReview.verdict === 'PASS' ? C.ok : C.warn} bold>phase {lastReview.phase} {lastReview.verdict}</Text> · {timer(nowMs - Date.parse(lastReview.at))} ago</Text>
268 : 'no reviews yet'}
269 </Text>
270 <Text wrap="truncate">
271 {(v?.phases ?? []).map(p => (
272 <Text key={`ph-${p.n}`} color={p.verdict === 'PASS' ? C.ok : p.verdict === 'FAIL' ? C.warn : C.dim}>
273 {p.verdict === 'PASS' ? '◆' : '◇'} {shorten(p.slug, narrow ? 8 : 14)}{' '}
274 </Text>
275 ))}
276 </Text>
277 <Box gap={1}><Text color={C.dim}>model</Text>{picker('reviewer', C.arch)}</Box>
278 </Box>
279 )
280
281 // ---- workers: swimlanes on a track spanning the whole mission
282 const ws = v?.workers ?? []
283 const running = ws.filter(w => w.state === 'running').length
284 const labelW = Math.min(18, Math.max(10, Math.floor(cols / 4)))
285 const laneW = Math.max(8, cols - labelW - 12)
286 const workerColor = (w: { state: string }) => (w.state === 'blocked' ? C.warn : w.state === 'done' ? C.ok : C.agent)
287 const workersSection = (
288 <Box flexDirection="column">
289 <Box justifyContent="space-between">
290 <Text bold wrap="truncate">workers · {running} running · {ws.length} total</Text>
291 </Box>
292 <Box gap={1}><Text color={C.dim}>model</Text>{picker('worker', C.agent)}</Box>
293 {ws.length === 0 && <Text color={C.faint}>none dispatched yet</Text>}
294 {v && ws.map((w, i) => {
295 const from = Date.parse(w.startedAt)
296 const to = w.endedAt ? Date.parse(w.endedAt) : nowMs
297 const g = lane(from, to, Date.parse(v.startedAt), spanEnd, laneW)
298 const color = workerColor(w)
299 return (
300 <Box key={`lane-${w.id}`} flexDirection="column">
301 <Box>
302 <Text color={color}>{w.state === 'done' ? '✓' : w.state === 'blocked' ? '✕' : '●'} </Text>
303 <Box width={labelW} flexShrink={0}><Text wrap="truncate">{i + 1}: {w.task || w.id}</Text></Box>
304 <Text color={C.faint}>{' ' + '·'.repeat(g.before)}</Text>
305 <Text color={color}>{'━'.repeat(g.bar)}</Text>
306 <Text color={C.faint}>{'·'.repeat(g.after) + ' '}</Text>
307 <Text color={C.dim}>{timer(to - from)}</Text>
308 </Box>
309 {w.state === 'running' && (
310 <Box paddingLeft={2}>
311 <Gauge percent={w.percent} width={8} color={C.agent} />
312 <Text color={C.dim} wrap="truncate-end"> {w.percent}% · {w.current}</Text>
313 </Box>
314 )}
315 </Box>
316 )
317 })}
318 </Box>
319 )
320
321 // ---- status strip, like flightdeck's last-turn card
322 const retries = v ? reviews.filter(r => r.verdict === 'FAIL').length : 0
323 const strip = v && (
324 <Box borderStyle="round" borderColor={statusColor} paddingX={1}>
325 <Text wrap="truncate">
326 <Text color={statusColor}>{v.status === 'complete' ? '✓' : v.status === 'aborted' ? '✕' : '◐'} </Text>
327 <Text>{v.status === 'running' ? `${narrow ? 'ph' : 'phase'} ${active?.n ?? passed + 1}` : v.status} · {ws.length}{narrow ? 'w' : ' workers'} · {reviews.length}{narrow ? 'r' : ' reviews'} · </Text>
328 <Text color={retries ? C.amber : C.dim}>{retries}{narrow ? '↻' : retries === 1 ? ' retry' : ' retries'}</Text>
329 </Text>
330 </Box>
331 )
332
333 // ---- mission log card
334 const roleColor = { orchestrator: C.main, worker: C.agent, reviewer: C.arch, system: C.dim }
335 const used = 24 + ws.length * 2
336 const room = Math.max(4, rows - used)
337 const lines = (v?.updates ?? []).slice(-room)
338 const logCard = (
339 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1}>
340 <Text color={C.dim}>mission log</Text>
341 {lines.length === 0 && <Text color={C.faint}>nothing yet</Text>}
342 {lines.map((l, i) => (
343 <Box key={`log-${i}`}>
344 <Box width={narrow ? 6 : 9} flexShrink={0}><Text color={C.faint}>{narrow ? hms(l.at).slice(0, 5) : hms(l.at)}</Text></Box>
345 <Box width={narrow ? 8 : 14} flexShrink={0}><Text color={roleColor[l.role]} bold wrap="truncate">{l.who}</Text></Box>
346 <Text color={l.tone === 'warn' ? C.warn : l.tone === 'ok' ? C.ok : C.text} wrap="truncate">{l.text}</Text>
347 </Box>
348 ))}
349 </Box>
350 )
351
352 return (
353 <Box flexDirection="column" gap={1}>
354 {titleBar}
355 {orchestratorCard}
356 <Divider />
357 {reviewerCard}
358 <Divider />
359 {workersSection}
360 {strip}
361 {logCard}
362 {armed && <Button key="stop" plain label="stop routing" onPress={() => void update($, isArmed, () => false)} />}
363 </Box>
364 )
365 })
366}
367hooks/summary.ts 81 lines1import type { LogLine, MissionView, Worker } from '../types'
2
3type Event = Record<string, unknown> & { ts?: string; kind?: string }
4
5const str = (v: unknown) => (v === undefined || v === null ? '' : String(v))
6const num = (v: unknown) => (typeof v === 'number' ? v : Number(v) || 0)
7// progress.sh is called with 0..1 in the docs and 0..100 in practice.
8const pct = (v: unknown) => { const p = num(v); return Math.round(p <= 1 ? p * 100 : p) }
9
10export function describe(e: Event): string | null {
11 const w = str(e.worker_id)
12 switch (e.kind) {
13 case 'mission_start': return `mission started · ${str(e.slug || e.id)}`
14 case 'spec_approved': return `spec approved · ${num(e.total_phases)} phases · ${str(e.preset) || 'preset ?'}`
15 case 'phase_start': return `phase ${num(e.phase)} started · ${str(e.slug)}`
16 case 'dispatch': return `dispatched ${w} → ${str(e.task)}`
17 case 'progress': return `${pct(e.percent)}% · ${str(e.current)}`
18 case 'complete': return 'done'
19 case 'blocker': return `blocked · ${str(e.reason)}`
20 case 'validate_start': return `reviewing phase ${num(e.phase)}`
21 case 'validate_result': return `phase ${num(e.phase)} · ${str(e.verdict).toUpperCase()}`
22 case 'forge': return `forged skill ${str(e.name)}`
23 case 'checkpoint': case 'checkpoint_auto_approved': return `checkpoint phase ${num(e.phase)} · ${str(e.decision || 'auto-approved')}`
24 case 'heartbeat': return str(e.summary)
25 case 'budget_warning': case 'budget_exhausted': return `${e.kind.replace('_', ' ')} · ${str(e.reason || e.detail)}`
26 case 'mission_complete': return 'mission complete'
27 case 'mission_aborted': return `mission aborted · ${str(e.reason)}`
28 default: return e.kind ? e.kind.replaceAll('_', ' ') : e.msg ? str(e.msg) : null
29 }
30}
31
32function speaker(e: Event): Pick<LogLine, 'who' | 'role'> {
33 if (e.kind === 'progress' || e.kind === 'complete' || e.kind === 'blocker') return { who: str(e.worker_id) || 'worker', role: 'worker' }
34 if (e.kind === 'validate_start' || e.kind === 'validate_result' || e.actor === 'validator') return { who: 'reviewer', role: 'reviewer' }
35 if (e.actor === 'system' || e.kind === 'mission_start') return { who: 'mission', role: 'system' }
36 return { who: 'orchestrator', role: 'orchestrator' }
37}
38
39function tone(e: Event): LogLine['tone'] {
40 const verdict = str(e.verdict).toUpperCase()
41 if (e.kind === 'blocker' || e.kind === 'mission_aborted' || e.kind === 'budget_exhausted' || verdict === 'FAIL') return 'warn'
42 if (e.kind === 'complete' || e.kind === 'mission_complete' || verdict === 'PASS') return 'ok'
43 return 'info'
44}
45
46export function summarize(log: string): MissionView | null {
47 const events: Event[] = log.split('\n').flatMap(line => {
48 try { return line.trim() ? [JSON.parse(line) as Event] : [] } catch { return [] }
49 })
50 const start = events.find(e => e.kind === 'mission_start')
51 if (!start) return null
52
53 const view: MissionView = {
54 id: str(start.id), status: 'running', startedAt: str(start.ts), endedAt: '', preset: '',
55 totalPhases: 0, phases: [], workers: [], reviews: [], updates: [],
56 }
57 const workers = new Map<string, Worker>()
58 for (const e of events) {
59 const id = str(e.worker_id)
60 const text = describe(e)
61 if (text) view.updates.push({ at: str(e.ts), ...speaker(e), text, tone: tone(e) })
62 if (e.kind === 'spec_approved') { view.totalPhases = num(e.total_phases); view.preset = str(e.preset) }
63 if (e.kind === 'phase_start') view.phases.push({ n: num(e.phase), slug: str(e.slug), verdict: '' })
64 if (e.kind === 'validate_result') {
65 const verdict = str(e.verdict).toUpperCase()
66 view.reviews.push({ phase: num(e.phase), verdict, at: str(e.ts) })
67 view.phases = view.phases.map(p => (p.n === num(e.phase) ? { ...p, verdict } : p))
68 }
69 if (e.kind === 'dispatch') {
70 workers.set(id, { id, phase: num(e.phase), task: str(e.task), model: str(e.model), subagent: str(e.subagent), percent: 0, current: 'dispatched', state: 'running', startedAt: str(e.ts), endedAt: '' })
71 }
72 const w = workers.get(id)
73 if (w && e.kind === 'progress') workers.set(id, { ...w, percent: pct(e.percent), current: str(e.current) })
74 if (w && e.kind === 'complete') workers.set(id, { ...w, percent: 100, current: 'done', state: 'done', endedAt: str(e.ts) })
75 if (w && e.kind === 'blocker') workers.set(id, { ...w, current: str(e.reason), state: 'blocked', endedAt: str(e.ts) })
76 if (e.kind === 'mission_complete') { view.status = 'complete'; view.endedAt = str(e.ts) }
77 if (e.kind === 'mission_aborted') { view.status = 'aborted'; view.endedAt = str(e.ts) }
78 }
79 return { ...view, workers: [...workers.values()], updates: view.updates.slice(-60) }
80}
81hooks/format.ts 58 lines1// Same look as Claude Code's own pill progress bar: filled ▰ in color, empty ▱ dimmed.
2export function pill(percent: number, width: number): { fill: string; empty: string } {
3 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
4 return { fill: '▰'.repeat(filled), empty: '▱'.repeat(width - filled) }
5}
6
7// Mission clock: T+MM:SS under an hour, T+H:MM:SS after.
8export function missionClock(startIso: string, nowMs: number): string {
9 const start = Date.parse(startIso)
10 if (Number.isNaN(start)) return 'T+--:--'
11 const s = Math.max(0, Math.floor((nowMs - start) / 1000))
12 const two = (n: number) => String(n).padStart(2, '0')
13 const h = Math.floor(s / 3600)
14 const m = Math.floor((s % 3600) / 60)
15 return h > 0 ? `T+${h}:${two(m)}:${two(s % 60)}` : `T+${two(m)}:${two(s % 60)}`
16}
17
18// A section rule: "── CREW ──────────" filled to the pane width.
19export function rule(label: string, width: number): { head: string; tail: string } {
20 const head = `── ${label} `
21 return { head, tail: '─'.repeat(Math.max(2, width - head.length)) }
22}
23
24// "20261006-jev-explainer-html-e6e5a6e6" → name "jev-explainer-html", tag "e6e5a6e6"
25export function splitId(id: string): { name: string; tag: string } {
26 const parts = id.split('-')
27 const hasDate = /^\d{8}$/.test(parts[0] ?? '')
28 const hasTag = parts.length > 2 && /^[0-9a-f]{8}$/.test(parts.at(-1) ?? '')
29 const name = parts.slice(hasDate ? 1 : 0, hasTag ? -1 : undefined).join('-')
30 return { name: name || id, tag: hasTag ? parts.at(-1)! : '' }
31}
32
33// A swimlane: where a bar sits on a track spanning the whole mission, in cells.
34export function lane(fromMs: number, toMs: number, spanStart: number, spanEnd: number, width: number) {
35 const span = Math.max(1, spanEnd - spanStart)
36 const at = (t: number) => Math.round(((Math.min(Math.max(t, spanStart), spanEnd) - spanStart) / span) * width)
37 const before = Math.min(width - 1, at(fromMs))
38 const bar = Math.max(1, Math.min(width - before, at(toMs) - before))
39 return { before, bar, after: width - before - bar }
40}
41
42// Elapsed as m:ss, or h:mm past an hour.
43export function timer(ms: number): string {
44 const s = Math.max(0, Math.floor(ms / 1000))
45 const m = Math.floor(s / 60)
46 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
47}
48
49// "2026-10-07T00:10:52Z" → "20:10:52" on a New York machine: the computer's own time zone.
50export function hms(iso: string): string {
51 const d = new Date(iso)
52 if (Number.isNaN(d.getTime())) return '--:--:--'
53 const two = (n: number) => String(n).padStart(2, '0')
54 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
55}
56
57export const shorten = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
58types/index.d.ts 22 lines1export type ModelAlias = 'fable' | 'opus' | 'sonnet' | 'haiku'
2export type Models = { orchestrator: ModelAlias | 'session'; worker: ModelAlias; reviewer: ModelAlias }
3
4export type Worker = {
5 id: string; phase: number; task: string; model: string; subagent: string
6 percent: number; current: string; state: 'running' | 'done' | 'blocked'
7 startedAt: string; endedAt: string
8}
9export type Phase = { n: number; slug: string; verdict: string }
10export type Review = { phase: number; verdict: string; at: string }
11export type LogLine = { at: string; who: string; role: 'orchestrator' | 'worker' | 'reviewer' | 'system'; text: string; tone: 'ok' | 'warn' | 'info' }
12export type MissionView = {
13 id: string; status: 'running' | 'complete' | 'aborted'; startedAt: string; endedAt: string; preset: string
14 totalPhases: number; phases: Phase[]; workers: Worker[]; reviews: Review[]; updates: LogLine[]
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 mission: { models: Models; view: MissionView | null; isArmed: boolean; goal: string; now: number }
20 }
21}
22