SLOPSHOPPER

mission

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

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-07brogrammerMW/claude-mission-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission
│ ┃ Mission ✕ › fix the failing auth test and add an audit log call │ ┃ MISSION · STANDBY · ROUTING OFF │ ┃ ■ orchestrator ■ workers ■ reviewer ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ ORCHESTRATOR · opus ○ idle │ ⎿ Added 2 lines, removed 1 line │ ┃ │ no mission yet │ ⏺ Bash(bun test) │ ┃ │ start one with /mission <goal> │ ⎿ 3 pass, 1 fail │ ┃ │ model opus ▾ │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ──────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────── │ ┃ › /mission │ ┃ ╭──────────────────────────────────────────╮ ⎿ mission: Mission pane opened. │ ┃ │ REVIEWER · fable reviews 0 │ │ ┃ │ ───────────────────────────────────────… │ │ ┃ │ no reviews yet │ │ ┃ │ │ │ ┃ │ model fable ▾ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── │ ┃ │ ┃ workers · 0 running · 0 total ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission
MISSION · STANDBY · ROUTING OFF ■ orchestrator ■ workers ■ reviewer ╭──────────────────────────────────────────────────────────╮ │ ORCHESTRATOR · opus ○ idle │ │ no mission yet │ │ start one with /mission <goal> │ │ model opus ▾ │ ╰──────────────────────────────────────────────────────────╯ ──────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ REVIEWER · fable reviews 0 │ │ ──────────────────────────────────────────────────── │ │ no reviews yet │ │ │ │ model fable ▾ │ ╰──────────────────────────────────────────────────────────╯ ──────────────────────────────────────────────────────── workers · 0 running · 0 total model sonnet ▾ none dispatched yet ╭──────────────────────────────────────────────────────────╮ │ mission log │ │ nothing yet │ ╰──────────────────────────────────────────────────────────╯
README

mission

A Claude Code mod that runs mission-skill missions with a live Mission pane and per-role Claude model routing.

  • Pane: a status bar, an orchestrator card, a reviewer timeline, worker swimlanes with progress, and a mission log. It docks at a quarter of the terminal width and switches to compact labels when narrow. Log times show in your computer's time zone.
  • Routing: while a mission runs, the orchestrator, workers and validator each run on the model you pick in the pane (Fable, Opus, Sonnet or Haiku). Only real mission workers and validators are rerouted; other agents keep their own model.

Install

/plugin install mission --marketplace brogrammerMW/mission-mod

Answer y to add the marketplace, then pick a scope.

Use

CommandWhat it does
/missionOpens 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 stopTurns routing off; the pane stays open

Change each role's model from the pickers in the pane. Your choices are kept across sessions.

Requirements

  • Claude Code with mods (plugin hooks) support.
  • A mission-skill skill that writes its progress to .missions/<id>/log.jsonl. The pane reads that log; without it, the pane shows standby.

Develop

claude plugin validate .
claude plugin test .

Credits

See NOTICE.md.

Source 4 files
hooks/register.tsx 367 lines
1import { 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}
367
hooks/summary.ts 81 lines
1import 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}
81
hooks/format.ts 58 lines
1// 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)
58
types/index.d.ts 22 lines
1export 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