SLOPSHOPPER

mission-control

Live plan, % done, time left and a one-line 'now' for long tasks, in a pane and on the working row.

newpanespinnerguardcommandtoast
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission ✕ › fix the failing auth test and add an audit log call │ ┃ No mission yet. Claude posts a plan here │ ┃ when it starts a multi-step task. ⏺ Read(src/auth.ts) │ ┃ [ Close ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mission │ ⎿ mission-control: Mission pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission
No mission yet. Claude posts a plan here when it starts a multi-step task. [ Close ]
README

Mission Control

What it does: a live progress view for long tasks: the plan, % done, time elapsed, time left, and one line on what Claude is doing right now. It replaces scrolling through verbose updates.

Where you see it:

  • The dock (Mod Hub's line above the prompt): ◎ 64% ⣿⣿⣿⣿⣷⣀⣀ 3/5 ~8m — <now> while working, then ✓ done 6m53s until your next prompt.
  • The "Working…" row while Claude works. On the desktop it's an animated, colored line drawn by hooks/row.tsx on its own frame clock: `` ⠹ 37% ━━━━━━ 2/4 · 4m 33s · ~12m left · plan › ` a spinner that cycles through cyans and teals (the dock's Plan color), the % done, a short bar shaded cyan to teal with a glint sweeping through it (the cell being filled pulses), the step number, time on the task, and time left: green on pace, amber with step running long once the current step has run past its share, grey while it's a rough guess. plan › (or a click anywhere on the row) opens the Mission pane with the whole checklist and the "now" trail; it then reads hide plan. While the row shows, the dock above the prompt stays out of the way; once Claude stops, it shows the same progress there. The app adds its own tokens and status after it. On the terminal the row keeps the engine's own animation, with its text rewritten: 37% · step 2/4 · 4m 33s · ~12m left`.
  • plan › on the working row, or /mission, toggles a pane with the checklist (✓ done with time taken, ▶ current, ○ to do) and a Trail: the last "now" lines with timestamps, a condensed history of the work.

How it works: the mod gives Claude a tool, mcp__mission-control__plan. For tasks with 3+ steps or over ~2 minutes, Claude posts a plan (3-8 steps, each sized 1-3), then updates it as steps start and finish. If Claude re-sends the whole list instead (the same title, or each step with a status, even written as title or plain strings), the board takes the new list and keeps its clock; a list it can't read changes nothing. The mod does the maths:

  • % done = finished step sizes + partial credit for the current step.
  • Time left = remaining size × pace. Pace comes from this task's finished steps, blended with your earlier tasks in the same project.
  • Shown as ≈ ("rough") until a step finishes or history exists.
  • Only active time counts. Time waiting for you between turns does not.

How accurate is it? Every time the board shows a time left, the mod notes it. When the task finishes, it compares each guess with the time that was actually left and keeps the median miss (a guess of 4m when 2m were left is 100% off; a 1-minute floor stops the last few seconds from skewing it). The Mission pane then shows Past estimates here: off by ~X% on average (N tasks), averaged over the project's last 20 tasks. Expect early guesses to be off by 2-3×; they tighten once a step or two finishes, and as the project builds up history.

Cost: a short hidden reminder (~60 tokens) is attached to each prompt you send, plus a few small tool calls per task. No extra model calls.

Data saved: ~/.claude/mods-data/mission-control/<session>.json: finished tasks' size, duration and estimate miss, used to calibrate future estimates and to score them.

Limits: the estimate is only as good as the plan. If scope grows, Claude adds steps and the % drops. It's an estimate, not a promise.

Source 3 files
hooks/register.tsx 587 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mission, MissionStep, MissionSummary } from '../types'
5
6// Claude reports its plan through a tool this mod registers; the mod turns it
7// into % done and time left, shown on the working row, in a band above the
8// prompt, and in a pane (/mission). Nothing here guesses at the model's
9// thinking: the "Now" line is what Claude itself says it is doing.
10
11const TOOL = 'mcp__mission-control__plan'
12const PANE = 'mission'
13// An unfinished board Claude stops updating goes after this many turns.
14const STALE_TURNS = 2
15const DEFAULT_MS_PER_UNIT = 120_000
16const mission = atom({ plugin: 'mission-control', key: 'mission' } as const, null)
17const summaryRef = atom({ plugin: 'mission-control', key: 'summary' } as const, null)
18const isOpen = atom({ plugin: 'mission-control', key: 'isOpen' } as const, false)
19
20type PlanInput = {
21  title?: string
22  steps?: unknown[]
23  add?: unknown[]
24  start?: number
25  done?: number[]
26  now?: string
27  finished?: boolean
28}
29
30type MissionRecord = { project: string; title: string; units: number; activeMs: number; at: number; estimateError?: number }
31// Past tasks a project's accuracy averages over.
32const ACCURACY_TASKS = 20
33// Under a minute left, a few seconds off is a large share: the floor keeps the score fair.
34const ERROR_FLOOR_MS = 60_000
35
36type Progress = {
37  pct: number
38  elapsed: number
39  leftMs: number | null
40  isRough: boolean
41  /** The current step has run past the time its size gives it at this pace. */
42  isOver: boolean
43  stepNo: number
44  total: number
45}
46
47const DESCRIPTION = [
48  'Mission board: shows the person a live plan, % done and time left while you work.',
49  'Use it for any task with 3+ steps or likely to take more than ~2 minutes; skip it for quick answers.',
50  'Start: call once with `title` (<= 60 chars) and `steps` (3-8, plain words, each with `size` 1 small / 2 medium / 3 large).',
51  'Then call it as you go: `start` with the step number you begin, `done` with step numbers you finished,',
52  'and `now`: one plain line (<= 90 chars) on what you are doing or figuring out right now.',
53  'Use `add` if the scope grows. Call with `finished: true` when the whole task is complete.',
54  'To update, prefer `start`/`done`; sending `steps` again under the same title restates the plan (each step its `text` and `status`) and keeps its clock.',
55  'Keep calls brief; they cost the person nothing and replace a long scroll of updates.',
56].join(' ')
57
58const SCHEMA = {
59  type: 'object',
60  properties: {
61    title: { type: 'string', description: 'Short task title. Give it with steps to start a new plan.' },
62    steps: {
63      type: 'array',
64      description: '3-8 steps. Giving steps starts a new plan, or restates the running one under its title.',
65      items: {
66        type: 'object',
67        properties: {
68          text: { type: 'string' },
69          size: { type: 'integer', minimum: 1, maximum: 3 },
70          status: { type: 'string', enum: ['todo', 'doing', 'done'] },
71        },
72        required: ['text'],
73      },
74    },
75    add: {
76      type: 'array',
77      description: 'Steps to append when the scope grows.',
78      items: {
79        type: 'object',
80        properties: { text: { type: 'string' }, size: { type: 'integer', minimum: 1, maximum: 3 } },
81        required: ['text'],
82      },
83    },
84    start: { type: 'integer', description: 'Step number (1-based) you are starting now.' },
85    done: { type: 'array', items: { type: 'integer' }, description: 'Step numbers (1-based) now finished.' },
86    now: { type: 'string', description: 'One plain line: what you are doing or working out right now.' },
87    finished: { type: 'boolean', description: 'True when the whole task is complete.' },
88  },
89}
90
91const oneLine = (text: string, max: number) => {
92  const flat = text.replace(/\s+/g, ' ').trim()
93  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
94}
95
96const fmt = (ms: number) => {
97  const s = Math.max(0, Math.round(ms / 1000))
98  if (s < 60) return `${s}s`
99  const m = Math.floor(s / 60)
100  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104const fmtLeft = (ms: number) => {
105  const m = Math.round(ms / 60000)
106  if (m < 1) return '<1m'
107  if (m < 60) return `${m}m`
108  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
109}
110
111const bar = (pct: number, width: number) => {
112  const full = Math.round(pct * width)
113  return '█'.repeat(full) + '░'.repeat(Math.max(0, width - full))
114}
115
116const projectOf = (dir: string) => {
117  const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
118  return main.split(/[\\/]/).filter(Boolean).pop() ?? main
119}
120
121// Claude sometimes sends steps in another shape: a `title` instead of `text`,
122// plain strings, a `status` on each. Every step it names is read, so a plan is
123// never emptied by its own wording.
124const STEP_TEXT = ['text', 'title', 'content', 'name', 'step'] as const
125
126const parseStep = (raw: unknown) => {
127  const s = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
128  const key = STEP_TEXT.find(k => typeof s[k] === 'string' && (s[k] as string).trim() !== '')
129  const text = typeof raw === 'string' ? raw : key ? (s[key] as string) : ''
130  const size = Number(s.size)
131  const status = String(s.status ?? '').toLowerCase().replace(/[\s_-]/g, '')
132  return {
133    text: oneLine(text, 80),
134    size: Number.isFinite(size) && size > 0 ? Math.min(3, Math.max(1, Math.round(size))) : null,
135    status: /^(done|complete|completed|finished)$/.test(status)
136      ? ('done' as const)
137      : /^(doing|inprogress|active|running|started|current)$/.test(status)
138        ? ('doing' as const)
139        : /^(todo|pending|notstarted|open)$/.test(status)
140          ? ('todo' as const)
141          : null,
142  }
143}
144
145// `old`: the plan being restated, whose steps keep their size and times when the text matches.
146const toSteps = (list: unknown[], old: MissionStep[] = []): MissionStep[] =>
147  list
148    .map(parseStep)
149    .filter(p => p.text !== '')
150    .slice(0, 12)
151    .map(p => {
152      const prev = old.find(o => o.text === p.text)
153      const status = p.status ?? prev?.status ?? 'todo'
154      return {
155        text: p.text,
156        size: p.size ?? prev?.size ?? 1,
157        status,
158        ...(status !== 'todo' && prev?.startActive !== undefined ? { startActive: prev.startActive } : {}),
159        ...(status === 'done' && prev?.status === 'done' && prev.doneActive !== undefined ? { doneActive: prev.doneActive } : {}),
160      }
161    })
162
163const activeAt = (m: Mission, now: number) => m.activeMs + (m.runningSince === null ? 0 : now - m.runningSince)
164
165const progress = (m: Mission, now: number): Progress => {
166  const elapsed = activeAt(m, now)
167  const units = m.steps.reduce((a, s) => a + s.size, 0) || 1
168  const doneSteps = m.steps.filter(s => s.status === 'done')
169  const doneUnits = doneSteps.reduce((a, s) => a + s.size, 0)
170  const lastDone = Math.max(0, ...doneSteps.map(s => s.doneActive ?? 0))
171
172  // Pace (ms per unit of size): what this mission has shown so far, blended
173  // with earlier missions while little is done; a flat default before either.
174  const observed = doneUnits > 0 && lastDone > 0 ? lastDone / doneUnits : null
175  const trust = Math.min(1, (doneUnits / units) * 2)
176  const pace =
177    observed !== null && m.calibration !== null
178      ? observed * trust + m.calibration * (1 - trust)
179      : observed ?? m.calibration ?? DEFAULT_MS_PER_UNIT
180  const isRough = observed === null && m.calibration === null
181
182  const cur = m.steps.find(s => s.status === 'doing')
183  const spent = cur ? elapsed - (cur.startActive ?? elapsed) : 0
184  const partial = cur ? Math.min(0.9, spent / (pace * cur.size)) * cur.size : 0
185
186  const pct = m.isFinished ? 1 : Math.min(0.99, (doneUnits + partial) / units)
187  const leftMs = m.isFinished ? 0 : Math.max(0, (units - doneUnits - partial) * pace)
188  const stepNo = cur ? m.steps.indexOf(cur) + 1 : Math.min(m.steps.length, doneSteps.length + 1)
189  const isOver = !m.isFinished && cur !== undefined && spent > pace * cur.size
190  return { pct, elapsed, leftMs, isRough, isOver, stepNo, total: m.steps.length }
191}
192
193const summary = (m: Mission, p: Progress) => {
194  if (m.isFinished) return `done in ${fmt(p.elapsed)}`
195  const left = p.leftMs === null ? 'estimating' : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)} left`
196  return `step ${p.stepNo}/${p.total} · ${fmt(p.elapsed)} · ${left}`
197}
198
199const dataDir = async ($: EngineInterface) => {
200  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
201  return `${home.replace(/\\/g, '/')}/.claude/mods-data/mission-control`
202}
203
204const loadRecords = async ($: EngineInterface): Promise<MissionRecord[]> => {
205  const dir = await dataDir($)
206  if (!(await $.fs.exists(dir))) return []
207  const out: MissionRecord[] = []
208  for (const f of await $.fs.list(dir)) {
209    if (!f.name.endsWith('.json')) continue
210    try {
211      out.push(...(JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as MissionRecord[]))
212    } catch {
213      // skip a file that does not parse
214    }
215  }
216  return out.sort((a, b) => a.at - b.at)
217}
218
219const calibrationFor = (records: MissionRecord[], project: string) => {
220  const all = records.filter(r => r.units > 0 && r.activeMs > 0)
221  const mine = all.filter(r => r.project === project)
222  const pool = (mine.length >= 3 ? mine : all).slice(-20).map(r => r.activeMs / r.units)
223  if (pool.length === 0) return null
224  return [...pool].sort((a, b) => a - b)[Math.floor(pool.length / 2)]!
225}
226
227// How far off the board's time-left estimates were, scored at the finish: the
228// median, over every estimate given, of |estimated - actual time left| / actual.
229const scoreEstimates = (m: Mission, totalMs: number) => {
230  const errors = (m.estimates ?? [])
231    .filter(e => e.atActive < totalMs)
232    .map(e => Math.abs(e.leftMs - (totalMs - e.atActive)) / Math.max(totalMs - e.atActive, ERROR_FLOOR_MS))
233  if (errors.length === 0) return null
234  return [...errors].sort((a, b) => a - b)[Math.floor(errors.length / 2)]!
235}
236
237// The project's average miss over its last scored tasks, for "Past estimates: off by ~X%".
238const accuracyOf = (records: MissionRecord[], project: string) => {
239  const scored = records.filter(r => r.project === project && typeof r.estimateError === 'number').slice(-ACCURACY_TASKS)
240  if (scored.length === 0) return null
241  const avg = scored.reduce((a, r) => a + (r.estimateError ?? 0), 0) / scored.length
242  return { avgErrorPct: Math.round(avg * 100), tasks: scored.length }
243}
244
245// Saves the finished task, then shows the project's accuracy with it counted.
246const finishRecord = async ($: EngineInterface, m: Mission) => {
247  await saveRecord($, m)
248  const accuracy = accuracyOf(await loadRecords($), m.project)
249  await update($, mission, cur => (cur && cur.id === m.id ? { ...cur, accuracy } : cur))
250  await publish($)
251}
252
253const saveRecord = async ($: EngineInterface, m: Mission) => {
254  const file = `${await dataDir($)}/${await $.session.id()}.json`
255  const list = (await $.fs.exists(file)) ? (JSON.parse(await $.fs.read(file)) as MissionRecord[]) : []
256  const err = scoreEstimates(m, m.activeMs)
257  list.push({
258    project: m.project,
259    title: m.title,
260    units: m.steps.reduce((a, s) => a + s.size, 0),
261    activeMs: m.activeMs,
262    at: await $.clock.now(),
263    ...(err === null ? {} : { estimateError: Math.round(err * 1000) / 1000 }),
264  })
265  await $.fs.write(file, JSON.stringify(list.slice(-500)))
266}
267
268// What the dock shows; written on every change and once a second while running.
269const publish = async ($: EngineInterface) => {
270  const m = await read($, mission)
271  // A board with no steps (left by an older version) shows nothing until Claude posts its plan.
272  if (!m || m.steps.length === 0) {
273    if ((await read($, summaryRef)) !== null) await update($, summaryRef, () => null)
274    return
275  }
276  const p = progress(m, await $.clock.now())
277  const fresh: MissionSummary = {
278    title: m.title,
279    pct: p.pct,
280    stepNo: p.stepNo,
281    total: p.total,
282    left: m.isFinished || p.leftMs === null ? null : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)}`,
283    elapsedMs: p.elapsed,
284    isFinished: m.isFinished,
285    now: m.now,
286    accuracy: m.accuracy ?? null,
287  }
288  await update($, summaryRef, () => fresh)
289}
290
291const togglePane = async ($: EngineInterface) => {
292  if ((await $.ui.panes()).some(p => p.id === PANE)) {
293    await $.ui.close({ id: PANE })
294    // A plugin's own close does not reach its own ui.close hook: reset the flag here.
295    await update($, isOpen, () => false)
296    return 'Mission pane closed.'
297  }
298  const opened = await $.ui.open({ id: PANE, title: 'Mission' })
299  if (opened.isPlaced) await update($, isOpen, () => true)
300  else $.ui.toast(`Mission pane could not open (${opened.reason}).`)
301  return opened.isPlaced ? 'Mission pane opened.' : `Could not open the pane (${opened.reason}).`
302}
303
304let touched = false
305let idleTurns = 0
306
307export const register: Register = on => {
308  on('session.start', async ($, e, next) => {
309    await $.tool.register({ name: 'plan', description: DESCRIPTION, inputSchema: SCHEMA })
310    await $.command.register({ name: 'mission', description: 'Open the Mission Control pane (plan, % done, time left)' })
311    // Once a second while a mission runs, so elapsed and time left tick in the dock and pane.
312    $.clock.every(1000, async () => {
313      const m = await read($, mission)
314      if (m && m.runningSince !== null && !m.isFinished) await publish($)
315    })
316    await publish($)
317    return next(e)
318  })
319
320  // A short reminder beside each prompt the person sends, so long tasks get a board.
321  on('prompt.submit', async ($, e, next) => {
322    if (e.origin.kind === 'plugin') return next(e)
323    const m = await read($, mission)
324    if (m?.isFinished) {
325      await update($, mission, () => null)
326      await publish($)
327    }
328    const open = m && !m.isFinished
329      ? ` The board currently shows "${m.title}" (${m.steps.filter(s => s.status === 'done').length}/${m.steps.length} steps done): update it if you continue that work, or post new steps for a new task.`
330      : ''
331    const note =
332      `[mission-control] For multi-step work (3+ steps or over ~2 minutes), first post a short plan with ${TOOL}, ` +
333      `then update it as steps start and finish, with a one-line "now". Skip it for quick answers.${open}`
334    return next({ ...e, context: [...(e.context ?? []), note] })
335  })
336
337  on('turn.start', async ($, e, next) => {
338    touched = false
339    const now = await $.clock.now()
340    await update($, mission, m => (m && !m.isFinished && m.runningSince === null ? { ...m, runningSince: now } : m))
341    await publish($)
342    return next(e)
343  })
344
345  on('turn.complete', async ($, e, next) => {
346    if (e.agentId !== undefined) return next(e)
347    const now = await $.clock.now()
348    await update($, mission, m =>
349      m && m.runningSince !== null ? { ...m, activeMs: activeAt(m, now), runningSince: null } : m,
350    )
351    const m = await read($, mission)
352    idleTurns = touched ? 0 : idleTurns + 1
353    if (m && !m.isFinished && idleTurns >= STALE_TURNS) await update($, mission, () => null)
354    await publish($)
355    return next(e)
356  })
357
358  on('tool.call', { tool: TOOL }, async ($, e) => {
359    if (e.agentId !== undefined) return { result: 'The mission board follows the main session only; nothing changed.' }
360    const input = e as unknown as PlanInput
361    touched = true
362    idleTurns = 0
363    const now = await $.clock.now()
364    let m = await read($, mission)
365
366    if (Array.isArray(input.steps) && input.steps.length > 0) {
367      const title = oneLine(input.title ?? 'Current task', 60)
368      const parsed = input.steps.map(parseStep).filter(p => p.text !== '')
369      // Nothing readable in the list: the board stays as it was.
370      if (parsed.length === 0) return { result: 'Nothing changed: give each step its words in `text`, e.g. { "text": "Fix the parser", "size": 2 }.' }
371      // The running plan sent again (same title, or steps carrying a status):
372      // the new list replaces its steps, and it keeps its clock and trail.
373      if (m && !m.isFinished && (title === m.title || parsed.some(p => p.status !== null))) {
374        m = { ...m, title, steps: toSteps(input.steps, m.steps) }
375      } else {
376        const project = projectOf(await $.session.cwd())
377        const records = await loadRecords($).catch(() => [])
378        m = {
379          id: String(now),
380          title,
381          project,
382          steps: toSteps(input.steps),
383          now: '',
384          notes: [],
385          activeMs: 0,
386          runningSince: now,
387          isFinished: false,
388          calibration: calibrationFor(records, project),
389          estimates: [],
390          accuracy: accuracyOf(records, project),
391        }
392      }
393    }
394    if (!m) return { result: 'No plan yet: call again with a title and steps.' }
395
396    const t = activeAt(m, now)
397    const steps = m.steps.map(s => ({ ...s }))
398    if (Array.isArray(input.add)) steps.push(...toSteps(input.add))
399    for (const n of input.done ?? []) {
400      const s = steps[n - 1]
401      if (s && s.status !== 'done') Object.assign(s, { status: 'done', doneActive: t, startActive: s.startActive ?? t })
402    }
403    if (typeof input.start === 'number') {
404      const k = input.start - 1
405      // Plans run in order: starting step k closes the ones before it.
406      steps.forEach((s, i) => {
407        if (i < k && s.status !== 'done') Object.assign(s, { status: 'done', doneActive: t, startActive: s.startActive ?? t })
408        if (i > k && s.status === 'doing') s.status = 'todo'
409      })
410      const s = steps[k]
411      if (s && s.status !== 'done') Object.assign(s, { status: 'doing', startActive: t })
412    }
413    // Steps that arrived already done or under way (a restated list) count from now.
414    for (const s of steps) {
415      if (s.status !== 'todo') s.startActive ??= t
416      if (s.status === 'done') s.doneActive ??= t
417    }
418
419    let upd: Mission = {
420      ...m,
421      steps,
422      title: input.title && !input.steps ? oneLine(input.title, 60) : m.title,
423      runningSince: m.runningSince ?? now,
424    }
425    if (typeof input.now === 'string' && input.now.trim() !== '') {
426      const text = oneLine(input.now, 90)
427      const last = upd.notes.at(-1)
428      upd = { ...upd, now: text, notes: last?.text === text ? upd.notes : [...upd.notes, { atActive: t, text }].slice(-12) }
429    }
430    // The time left the board shows now: kept, to score against the real finish.
431    if (input.finished !== true) {
432      const est = progress(upd, now).leftMs
433      if (est !== null) upd = { ...upd, estimates: [...(upd.estimates ?? []), { atActive: t, leftMs: Math.round(est) }].slice(-40) }
434    }
435    if (input.finished === true) {
436      upd = {
437        ...upd,
438        steps: upd.steps.map(s => (s.status === 'done' ? s : { ...s, status: 'done', doneActive: t, startActive: s.startActive ?? t })),
439        isFinished: true,
440        activeMs: t,
441        runningSince: null,
442        now: '',
443      }
444      const done = upd
445      void finishRecord($, done).catch(() => undefined)
446    }
447
448    const final = upd
449    await update($, mission, () => final)
450    await publish($)
451    const p = progress(final, now)
452    return { result: `Board updated: ${Math.round(p.pct * 100)}%, ${summary(final, p)}.` }
453  })
454
455  // The working row ("Working…" on the desktop) carries the progress while Claude
456  // works (the dock leaves its own progress out meanwhile). The desktop draws an
457  // animated, colored row with a "plan ›" link (hooks/row.tsx, on its own frame
458  // clock); elsewhere the row's text is rewritten. Named `Client` with a
459  // literal module path: the engine finds the row's module by reading this source.
460  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
461    await read($, summaryRef)
462    const m = await read($, mission)
463    if (!m || m.isFinished || m.runningSince === null || m.steps.length === 0 || e.props.message !== null) return next(e)
464    const p = progress(m, await $.clock.now())
465    const elements = $.ui.resolve(e)
466    const Client = 'Client' in elements && e.surface === 'desktop' ? elements.Client : undefined
467    if (Client) {
468      const { Box } = elements
469      const row = {
470        pct: p.pct,
471        stepNo: p.stepNo,
472        total: p.total,
473        elapsed: fmt(p.elapsed),
474        left: p.leftMs === null ? null : `${p.isRough ? '≈' : '~'}${fmtLeft(p.leftMs)} left`,
475        pace: p.isRough ? 'rough' : p.isOver ? 'slow' : 'ok',
476        isPlanOpen: await read($, isOpen),
477      }
478      return (
479        <Box flexDirection="row">
480          <Client key="mission:row" module="./row.tsx" width="100%" props={row} />
481        </Box>
482      )
483    }
484    const message = `${Math.round(p.pct * 100)}% · ${summary(m, p)}`
485    return next({ ...e, props: { ...e.props, message } })
486  })
487
488  // A click on the working row's "plan ›" toggles the Mission pane.
489  on('ui.message', { component: 'Spinner' }, async ($, e, next) => {
490    if (e.element !== 'mission:row') return next(e)
491    if ((e.data as { open?: unknown } | null)?.open === 'plan') await togglePane($)
492    return {}
493  })
494
495  on('command.run', { command: 'mission' }, async $ => ({ text: await togglePane($) }))
496
497  // A press on the dock's Plan button.
498  on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
499    const done = await next(e)
500    // Only a write that landed: update() retries a missed one, which would toggle twice.
501    const sig = e.value as { target?: string; action?: string } | null
502    if (done.value?.isSet !== true || !sig) return done
503    if (sig.target === 'mission-control') await togglePane($)
504    // Review: show the panel, never close it.
505    else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
506    return done
507  })
508
509  on('ui.close', { id: PANE }, async ($, e, next) => {
510    const done = await next(e)
511    await update($, isOpen, () => false)
512    return done
513  })
514
515  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
516    const { Box, Text, Button } = $.ui.resolve(e)
517    await read($, summaryRef)
518    const m = await read($, mission)
519    if (!m) {
520      return (
521        <Box flexDirection="column">
522          <Text dimColor>No mission yet. Claude posts a plan here when it starts a multi-step task.</Text>
523          <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
524        </Box>
525      )
526    }
527    const p = progress(m, await $.clock.now())
528    const width = Math.max(10, Math.min(30, (e.props.bodyColumns ?? 40) - 8))
529    return (
530      <Box flexDirection="column" gap={1}>
531        <Box flexDirection="column">
532          <Text bold>{m.title}</Text>
533          <Box flexDirection="row" gap={1}>
534            <Text color={m.isFinished ? 'success' : 'claude'}>{bar(p.pct, width)}</Text>
535            <Text bold>{Math.round(p.pct * 100)}%</Text>
536          </Box>
537          <Text dimColor>
538            elapsed {fmt(p.elapsed)}
539            {m.isFinished ? ' · finished' : p.leftMs === null ? '' : ` · ${p.isRough ? 'about' : '~'} ${fmtLeft(p.leftMs)} left`}
540            {p.isRough && !m.isFinished ? ' (rough until a step finishes)' : ''}
541          </Text>
542          {m.accuracy && (
543            <Text dimColor>
544              Past estimates here: off by ~{m.accuracy.avgErrorPct}% on average ({m.accuracy.tasks} {m.accuracy.tasks === 1 ? 'task' : 'tasks'})
545            </Text>
546          )}
547        </Box>
548        <Box flexDirection="column">
549          {m.steps.map((s, i) => {
550            const took =
551              s.status === 'done' && s.doneActive !== undefined && s.startActive !== undefined && s.doneActive > s.startActive
552                ? `  ${fmt(s.doneActive - s.startActive)}`
553                : s.status === 'doing' && s.startActive !== undefined
554                  ? `  ${fmt(p.elapsed - s.startActive)}…`
555                  : ''
556            return (
557              <Text
558                dimColor={s.status === 'done'}
559                bold={s.status === 'doing'}
560                color={s.status === 'doing' ? 'claude' : undefined}
561                wrap="truncate-end"
562              >
563                {s.status === 'done' ? '✓' : s.status === 'doing' ? '▶' : '○'} {i + 1}. {s.text}
564                {took}
565              </Text>
566            )
567          })}
568        </Box>
569        {m.notes.length > 0 && (
570          <Box flexDirection="column">
571            <Text bold>Trail</Text>
572            {m.notes.slice(-8).map(n => (
573              <Text dimColor={n.text !== m.now} wrap="truncate-end">
574                {fmt(n.atActive).padStart(7)}  {n.text}
575              </Text>
576            ))}
577          </Box>
578        )}
579        <Box flexDirection="row" gap={1}>
580          <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
581          {m.isFinished && <Button key="clear" label="Clear" onPress={async () => { await update($, mission, () => null); await publish($) }} />}
582        </Box>
583      </Box>
584    )
585  })
586}
587
hooks/row.tsx 109 lines
1import type { ClientModule } from 'claude-code'
2
3// The working row on the desktop, drawn by the surface so it animates on its
4// own frame clock (nothing goes back to the hooks module per frame): a spinner
5// that cycles color, the % done, a short gradient bar with a glint running
6// through it and a pulsing leading edge, the step number, time on task, time
7// left (amber when the step runs long), and a "plan ›" link. A click on the
8// row tells the hooks module ({ open: 'plan' }), which toggles the Mission
9// pane with the whole checklist.
10
11type Props = {
12  pct: number
13  stepNo: number
14  total: number
15  elapsed: string
16  left: string | null
17  pace: 'ok' | 'slow' | 'rough'
18  isPlanOpen: boolean
19}
20type State = { frame: number; isHover: boolean }
21
22const FRAME_MS = 80
23const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
24// The spinner's colors (cyan to teal, the dock's Plan color), cycled smoothly;
25// the bar runs from the first to the fourth.
26const HUES = ['#22d3ee', '#38bdf8', '#67e8f9', '#2dd4bf', '#5eead4', '#2dd4bf', '#67e8f9', '#38bdf8']
27const BAR_CELLS = 6
28const TRACK = '#3f3f46'
29const GLINT = '#f0f9ff'
30const ACCENT = '#22d3ee'
31const ACCENT_HOVER = '#a5f3fc'
32const PACE = { ok: '#4ade80', slow: '#facc15', rough: '#9ca3af' } as const
33
34const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
35const mix = (from: string, to: string, t: number) => {
36  const a = rgb(from)
37  const b = rgb(to)
38  return `#${a.map((v, i) => Math.round(v + (b[i]! - v) * t).toString(16).padStart(2, '0')).join('')}`
39}
40const hueAt = (frame: number) => {
41  const pos = (frame / 3) % HUES.length
42  const k = Math.floor(pos)
43  return mix(HUES[k]!, HUES[(k + 1) % HUES.length]!, pos - k)
44}
45// 0..1, once round every ~1.5 s.
46const pulse = (frame: number) => (Math.sin(frame / 3) + 1) / 2
47
48const Row: ClientModule<Props, State> = (props, surface) => {
49  const { Box, Text } = surface.elements
50  if (surface.state === undefined) {
51    surface.setState({ frame: 0, isHover: false })
52    surface.every(FRAME_MS, () => {
53      const cur = surface.state
54      if (cur) surface.setState({ ...cur, frame: cur.frame + 1 })
55    })
56  }
57  const st = surface.state ?? { frame: 0, isHover: false }
58  const frame = st.frame
59
60  // Set on every draw so the listener always reads the current state.
61  surface.onPointer(e => {
62    const now = surface.state ?? { frame: 0, isHover: false }
63    if (e.type === 'up') surface.post({ open: 'plan' })
64    const isHover = e.type !== 'leave'
65    if (isHover !== now.isHover) surface.setState({ ...now, isHover })
66  })
67
68  // Full cells shade cyan to teal with a glint sweeping across them; the
69  // cell being filled pulses; the rest is track.
70  const pct = Math.max(0, Math.min(1, props.pct))
71  const full = Math.floor(pct * BAR_CELLS)
72  const sweep = (frame % (full + 6)) - 3
73  const cells = Array.from({ length: BAR_CELLS }, (_, i) => {
74    if (i < full) {
75      const base = mix(HUES[0]!, HUES[3]!, i / (BAR_CELLS - 1))
76      const d = Math.abs(i - sweep)
77      return <Text color={d === 0 ? mix(base, GLINT, 0.7) : d === 1 ? mix(base, GLINT, 0.3) : base}>━</Text>
78    }
79    if (i === full && pct < 1) return <Text color={mix(TRACK, HUES[3]!, 0.3 + 0.7 * pulse(frame))}>━</Text>
80    return <Text color={TRACK}>━</Text>
81  })
82  const left = props.left === null ? 'estimating…' : props.pace === 'slow' ? `${props.left} · step running long` : props.left
83
84  return (
85    <Box flexDirection="row" gap={1} flexWrap="nowrap" overflow="hidden" alignItems="center" height={1}>
86      <Text bold color={hueAt(frame)}>
87        {SPIN[frame % SPIN.length]}
88      </Text>
89      <Text bold color="#f4f4f5">
90        {`${Math.round(pct * 100)}%`}
91      </Text>
92      <Box flexDirection="row" flexShrink={0}>
93        {cells}
94      </Box>
95      <Text bold color={ACCENT}>{`${props.stepNo}/${props.total}`}</Text>
96      <Text color="#71717a">·</Text>
97      <Text color="#9ca3af">{props.elapsed}</Text>
98      <Text color="#71717a">·</Text>
99      <Text color={props.left === null ? PACE.rough : PACE[props.pace]}>{left}</Text>
100      <Text color="#71717a">·</Text>
101      <Text color={st.isHover ? ACCENT_HOVER : ACCENT} underline={st.isHover}>
102        {props.isPlanOpen ? 'hide plan' : 'plan ›'}
103      </Text>
104    </Box>
105  )
106}
107
108export default Row
109
types/index.d.ts 51 lines
1export type MissionStep = {
2  text: string
3  /** Relative effort, 1 (small) to 3 (large). */
4  size: number
5  status: 'todo' | 'doing' | 'done'
6  /** Active time (ms into the mission) when the step started / finished. */
7  startActive?: number
8  doneActive?: number
9}
10
11export type MissionNote = { atActive: number; text: string }
12
13export type Mission = {
14  id: string
15  title: string
16  project: string
17  steps: MissionStep[]
18  now: string
19  notes: MissionNote[]
20  /** Active time banked before `runningSince`; turns waiting on the person do not count. */
21  activeMs: number
22  runningSince: number | null
23  isFinished: boolean
24  /** ms per unit of step size from earlier missions, when there are any. */
25  calibration: number | null
26  /** Each time-left estimate the board gave (active ms in, ms left), scored when the task finishes. */
27  estimates?: { atActive: number; leftMs: number }[]
28  /** How far off this project's past estimates were; null until a task with estimates finishes. */
29  accuracy?: { avgErrorPct: number; tasks: number } | null
30}
31
32/** What the dock shows, refreshed every second while a mission runs. */
33export type MissionSummary = {
34  title: string
35  pct: number
36  stepNo: number
37  total: number
38  left: string | null
39  elapsedMs: number
40  isFinished: boolean
41  now: string
42  /** How far off past time-left estimates were in this project; null until a task with estimates finishes. */
43  accuracy?: { avgErrorPct: number; tasks: number } | null
44}
45
46declare module 'claude-code' {
47  interface PluginState {
48    'mission-control': { mission: Mission | null; summary: MissionSummary | null; isOpen: boolean }
49  }
50}
51