SLOPSHOPPER

opus-conductor

Opus 5.5 conducts a mission: plans sized tasks, delegates hard labor to Sonnet 5.5 workers and narrow jobs to Haiku 5.5 scouts at the reasoning effort each…

newpanebandguardtoaststatus
v1.4.0no licenseupdated 2026-10-08okashiina/illa-mods/opus-conductor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opus-conductor
│ ┃ opus-conductor ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Opus Conductor │ ┃ No mission yet. Type /conduct, pick conduct ● opus-conductor: opus-conductor: tools did not register (TypeError: │ ┃ (opus-conductor), and describe what done ⏺ Read(src/auth.ts) │ ┃ looks like. Opus 5.5 plans it, Sonnet 5.5 ⎿ Read 6 lines │ ┃ workers build, Haiku 5.5 scouts look things ⏺ Update(src/auth.ts) │ ┃ up, and it all shows up here. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ AGENTS 0 working · 0 done ⎿ 3 pass, 1 fail │ ┃ No agents launched yet. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Autopilot: on ] │ ✻ Worked for 42s · done 4:20 PM │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · opus-conductor
◆ Opus Conductor No mission yet. Type /conduct, pick conduct (opus-conductor), and describe what done looks like. Opus 5.5 plans it, Sonnet 5.5 workers build, Haiku 5.5 scouts look things up, and it all shows up here. AGENTS 0 working · 0 done No agents launched yet. [ Autopilot: on ]
README

opus-conductor

Opus 5.5 is the thinker, manager and integrator. Sonnet 5.5 workers do the hard labor, and Haiku 5.5 scouts do fast, narrow jobs. It's the SOL orchestrator idea (Sol → Opus, Luna → Sonnet) with a /goal-style live board.

Use

TypeDoes
/opus-conductor:conduct <what done looks like>Starts a mission. Opus plans sized tasks, picks a helper and a reasoning effort for each, delegates, then verifies and integrates.
/opus-conductor:conductOpens the live board.
/opus-conductor:conduct stop · auto on · auto off · clearStops the mission, toggles autopilot, or clears the board.

In the desktop app, type /conduct and pick conduct (opus-conductor) from the menu.

  • Board: a progress ring, every agent with its model, its live activity and its self-reported %, and the task plan. There's also a band above the prompt (c opens the board) and a status line.
  • Who does what:
ModelRoleGets
Opus 5.5conductorArchitecture, design, diagnosis, security, integration, review.
Sonnet 5.5workerImplementation, refactors, tests, multi-step debugging, agentic coding.
Haiku 5.5scoutLookups, file and log sweeps, extraction, summaries, triage, running a check and reporting it, browser checks.

Haiku 5.5 costs $0.10 / $0.50 per MTok for prompts up to 100K tokens. It is strong at narrow work (OSWorld 72%) but well behind Sonnet on complex agentic coding (Terminal-Bench 4.0: 39% vs 71%), so it never gets design or hard coding. Its cyber safeguards also block pentest-style work.

  • Reasoning effort, picked per task by Opus: the Agent tool can't set effort per call, so there is one agent type per effort.
Effortopus-conductor:worker-* (Sonnet 5.5)opus-conductor:scout-* (Haiku 5.5)
lowmechanical edits from an exact specsingle lookups, greps, one value, one named check
mediumclear-brief implementation (the default for agentic coding)summaries, triage, extraction across several files
highnontrivial implementation, cross-module refactors, tricky tests, bounded debuggingcareful cross-checks, browser flows, costly-if-wrong sweeps
xhighhard multi-step debugging, long multi-file builds where a miss is costlynot offered

Opus starts workers at medium and scouts at low. It raises one level for each thing that makes a task harder: unfamiliar code, subtle invariants, many interacting files, a fix that must hold under tests. It lowers one level for repetitive work. Task size alone never raises effort. Plan tasks record their effort, and the board shows it on each task and agent. Opus's own effort is the session's setting.

  • Model steering during a mission:
  • Subagents with no model set run on Sonnet 5.5.
  • Explore agents run on Haiku 5.5.
  • Any older Haiku request is lifted to Haiku 5.5.
  • An explicit Opus or Sonnet choice is kept.
  • Autopilot: it nudges Opus to continue, at most 8 times, while tasks are open and no worker is running. Opus can pause it to ask you a question.
  • Session model: run the session on Opus 5.5 for the intended split. If it isn't, the board shows a warning when Opus first hands off a task.

Develop

  • Run claude plugin validate . and claude plugin test . in this folder.
  • An installed copy is read from this folder. After editing, run /reload-plugins in a session to pick up the changes.
  • Develop risky changes on a copy with claude --plugin-dir <copy> so running sessions are untouched.
Source 3 files
hooks/register.tsx 619 lines
1// Opus Conductor: Opus 5.5 thinks, plans and integrates; Sonnet 5.5 workers do
2// the hard labor. Adapted from the SOL orchestrator skill (Sol -> Opus, Luna ->
3// Sonnet) with a /goal-style progress meter.
4// - /conduct <goal> (commands/conduct.md): hands Opus the conductor brief; the
5//   hook starts the mission board and opens the pane.
6// - The model gets mcp__opus-conductor__mission (the plan board) and the
7//   opus-conductor:worker agent type (Sonnet 5.5). Workers report their own %
8//   with mcp__opus-conductor__report.
9// - Pane: progress ring (desktop), mission bar, every agent with its model,
10//   what it's doing right now, its %, and the plan. A band above the prompt and
11//   a status line show the same in short.
12// - Autopilot: while the mission is unfinished and no worker is running, an
13//   ended turn is followed by a "continue" prompt (at most maxContinues times).
14
15import { atom, read, update } from 'claude-code'
16import type { Elements, EngineInterface, Register } from 'claude-code'
17
18import type { AgentRow, Mission, ConductorSettings, Task } from '../types'
19import {
20  HAIKU,
21  SCOUT,
22  SCOUT_EFFORTS,
23  SONNET,
24  WORKER,
25  WORKER_EFFORTS,
26  agentName,
27  applyAction,
28  clip,
29  continuation,
30  describeCall,
31  effortOf,
32  eta,
33  isStopWord,
34  minutes,
35  missionToolSpec,
36  newMission,
37  progress,
38  reportToolSpec,
39  scoutPrompt,
40  steerModel,
41  taskIdIn,
42  workerPrompt,
43} from './mission'
44
45const PLUGIN = 'opus-conductor'
46const PANE = 'opus-conductor'
47const RECENT_MS = 10 * 60000
48
49const missionAtom = atom({ plugin: 'opus-conductor', key: 'mission' } as const, null)
50const agentsAtom = atom({ plugin: 'opus-conductor', key: 'agents' } as const, [])
51const settingsAtom = atom({ plugin: 'opus-conductor', key: 'settings' } as const, { auto: true, maxContinues: 8 } as ConductorSettings)
52
53const WORKER_USE: Record<string, string> = {
54  low: 'mechanical edits from an exact spec, renames, formatting, boilerplate',
55  medium: 'clear-brief implementation and coordinated small changes (the default for agentic coding)',
56  high: 'nontrivial implementation, refactors across modules, tests with tricky cases, bounded debugging',
57  xhigh: 'hard multi-step debugging or long multi-file builds where a miss is costly',
58}
59
60const SCOUT_USE: Record<string, string> = {
61  low: 'single lookups, greps, pulling one value, running one named check',
62  medium: 'summaries, triage, extraction across several files or docs',
63  high: 'careful cross-checking, browser flows, sweeps where a wrong answer is costly',
64}
65
66const COLOR = { opus: '#D97757', sonnet: '#6A9BCC', haiku: '#3FB8AF', done: '#3FB950', wait: '#D29922', fail: '#F85149', track: '#8B949E' }
67
68type $ = EngineInterface
69type Kit = Elements[keyof Elements]
70
71function isLive(m: Mission | null): m is Mission {
72  return !!m && (m.status === 'running' || m.status === 'waiting')
73}
74
75async function sync($: $) {
76  const m = await read($, missionAtom)
77  const agents = await read($, agentsAtom)
78  const running = agents.filter(a => a.status === 'running').length
79  if (isLive(m)) {
80    const p = progress(m, agents)
81    const word = m.status === 'waiting' ? 'waiting on you' : m.planAt ? `${p.pct}%` : 'planning'
82    $.ui.status(`◆ conduct ${word}${running ? ` · ${running} agent${running > 1 ? 's' : ''}` : ''}`)
83  } else if (running) {
84    $.ui.status(`◆ ${running} agent${running > 1 ? 's' : ''} running`)
85  } else {
86    $.ui.status(undefined)
87  }
88}
89
90async function setMission($: $, fn: (m: Mission | null) => Mission | null) {
91  await update($, missionAtom, m => fn(m ?? null))
92  await sync($)
93}
94
95async function setAgents($: $, fn: (list: AgentRow[]) => AgentRow[]) {
96  await update($, agentsAtom, list => fn([...(list ?? [])]).slice(-40))
97  await sync($)
98}
99
100async function openPane($: $) {
101  await $.ui.open({ id: PANE, title: 'Opus Conductor' })
102}
103
104export const register: Register = on => {
105  // Recomputed on every load: session.start registers them again.
106  let missionTool = `mcp__${PLUGIN}__mission`
107  let reportTool = `mcp__${PLUGIN}__report`
108  const workerType = `${PLUGIN}:${WORKER}`
109  const scoutType = `${PLUGIN}:${SCOUT}`
110  let warnedModel = false
111
112  on('session.start', async ($, e, next) => {
113    const saved = await $.store.get('settings').catch(() => undefined)
114    if (saved && typeof saved === 'object') await update($, settingsAtom, s => ({ ...s, ...(saved as Partial<ConductorSettings>) }))
115    try {
116      missionTool = (await $.tool.register(missionToolSpec('mission'))).tool
117      reportTool = (await $.tool.register(reportToolSpec('report'))).tool
118    } catch (err) {
119      $.ui.log(`opus-conductor: tools did not register (${String(err)})`)
120    }
121    // one agent type per (kind, effort): Opus picks the effort by picking the type
122    for (const effort of WORKER_EFFORTS) {
123      try {
124        await $.agent.register({
125          name: agentName('worker', effort),
126          description: `Sonnet 5.5 worker at ${effort} effort, for the Opus Conductor: ONE well-scoped outcome with a full delegation contract. Fits ${WORKER_USE[effort]}.`,
127          prompt: workerPrompt(reportTool),
128          model: SONNET,
129          effort,
130        })
131      } catch (err) {
132        $.ui.log(`opus-conductor: worker-${effort} did not register (${String(err)})`)
133      }
134    }
135    for (const effort of SCOUT_EFFORTS) {
136      try {
137        await $.agent.register({
138          name: agentName('scout', effort),
139          description: `Haiku 5.5 scout at ${effort} effort, for the Opus Conductor: ONE narrow job with a checkable answer. Fits ${SCOUT_USE[effort]}. Not for design, complex coding or security work.`,
140          prompt: scoutPrompt(reportTool),
141          model: HAIKU,
142          effort,
143        })
144      } catch (err) {
145        $.ui.log(`opus-conductor: scout-${effort} did not register (${String(err)})`)
146      }
147    }
148    // elapsed times and ETAs move while nothing is written
149    $.clock.every(5000, () => {
150      void read($, missionAtom).then(m => {
151        if (isLive(m)) $.ui.invalidate('ui.render')
152      })
153    })
154    await sync($)
155
156    return next(e)
157  })
158
159  // ---------- the command ----------
160
161  // /conduct is the plugin's own markdown command (commands/conduct.md), so every
162  // surface lists it. Its body is the brief Opus reads; this hook adds the board,
163  // and answers the control words itself without starting a turn.
164  on('command.run', { command: ['conduct', 'opus-conductor:conduct'] }, async ($, e, next) => {
165    const args = e.args.trim()
166    const now = await $.clock.now()
167    const [key, value, ...rest] = args.toLowerCase().split(/\s+/)
168
169    if (!args) {
170      await openPane($)
171      return { text: 'Opus Conductor board opened.' }
172    }
173    if (isStopWord(args)) {
174      await setMission($, m => (isLive(m) ? { ...m, status: 'stopped', endedAt: now } : m))
175      return { text: 'Mission stopped. Running agents finish on their own.' }
176    }
177    if (key === 'auto' && (value === 'on' || value === 'off') && !rest.length) {
178      const saved = await update($, settingsAtom, s => ({ ...s, auto: value === 'on' }))
179      await $.store.set('settings', saved).catch(() => undefined)
180      return { text: `Autopilot ${value}.` }
181    }
182    if (key === 'clear' && !value) {
183      await setMission($, () => null)
184      await setAgents($, list => list.filter(a => a.status === 'running'))
185      return { text: 'Conductor board cleared.' }
186    }
187
188    await setMission($, () => newMission(args, now))
189    await setAgents($, list => list.filter(a => a.status === 'running'))
190    await openPane($)
191    return next(e)
192  })
193
194  on('prompt.submit', async ($, e, next) => {
195    // the person speaking resumes a waiting mission and refills the autopilot
196    if (e.origin.kind === 'composer') {
197      await setMission($, m => (isLive(m) ? { ...m, status: 'running', continues: 0 } : m))
198    }
199    return next(e)
200  })
201
202  // ---------- tools: the board, worker reports, and what everyone is doing ----------
203
204  on('tool.call', async ($, e, next) => {
205    const now = await $.clock.now()
206    const input = e as unknown as Record<string, unknown>
207
208    if (e.tool === missionTool) {
209      let reply = ''
210      await setMission($, m => {
211        const first = clip((input.tasks as { title?: string }[] | undefined)?.[0]?.title ?? 'Mission', 120)
212        const action = String(input.action ?? '')
213        // a finished mission is history: a new plan is a new mission, more work reopens it
214        const base =
215          !m || m.status === 'stopped' || (m.status === 'done' && action === 'plan')
216            ? newMission(first, now)
217            : m.status === 'done' && (action === 'add' || action === 'start')
218              ? { ...m, status: 'running' as const, endedAt: 0, continues: 0 }
219              : m
220        const r = applyAction(base, input, now)
221        reply = r.text
222        return r.mission
223      })
224      if ((await read($, missionAtom))?.status === 'done') $.ui.toast('◆ Mission complete')
225      return { result: reply }
226    }
227
228    if (e.tool === reportTool) {
229      if (!e.agentId) return { result: 'Only delegated workers report progress; Opus updates the board with the mission tool.' }
230      const pct = Math.max(0, Math.min(100, Math.round(Number(input.pct) || 0)))
231      await setAgents($, list => list.map(a => (a.id === e.agentId ? { ...a, pct, doing: clip(input.doing, 90) || a.doing } : a)))
232      return { result: 'Reported.' }
233    }
234
235    const doing = describeCall(String(e.tool), input)
236    if (e.agentId) {
237      const id = e.agentId
238      const known = (await read($, agentsAtom)).some(a => a.id === id)
239      if (known) await setAgents($, list => list.map(a => (a.id === id ? { ...a, tools: a.tools + 1, doing } : a)))
240    } else if (e.tool !== 'Agent') {
241      const m = await read($, missionAtom)
242      if (isLive(m)) await setMission($, x => (x ? { ...x, doing } : x))
243    }
244
245    return next(e)
246  })
247
248  // ---------- agents: steer models, and put each one on the board ----------
249
250  on('agent.spawn', async ($, e, next) => {
251    const m = await read($, missionAtom)
252    let input = e
253    if (isLive(m) && !e.fork) {
254      // Opus manages: labor on Sonnet 5.5, narrow read-heavy jobs on Haiku 5.5
255      const model = steerModel(e.model, e.subagentType, { worker: workerType, scout: scoutType })
256      if (model) input = { ...e, model }
257      if (!e.parentAgentId && !/opus/i.test(e.parentModel) && !warnedModel) {
258        warnedModel = true
259        $.ui.toast(`Conductor is running on ${e.parentModel}, not Opus 5.5: switch the session model for the full effect`)
260      }
261    }
262
263    const r = await next(input)
264    if (!r.agentId) return r
265
266    const now = await $.clock.now()
267    const taskId = taskIdIn(e.description, e.prompt)
268    const ownType = e.subagentType.startsWith(workerType) || e.subagentType.startsWith(scoutType)
269    const effort = ownType ? effortOf(e.subagentType) : undefined
270    const row: AgentRow = {
271      id: r.agentId,
272      label: clip(e.description || e.name || e.subagentType, 60),
273      model: r.model,
274      type: e.subagentType,
275      startedAt: now,
276      endedAt: 0,
277      status: 'running',
278      tools: 0,
279      doing: 'starting',
280      pct: 0,
281      taskId,
282      summary: '',
283      ...(effort ? { effort } : {}),
284    }
285    await setAgents($, list => [...list.filter(a => a.id !== row.id), row])
286    if (taskId && isLive(m)) {
287      await setMission($, x =>
288        x ? { ...x, tasks: x.tasks.map(t => (t.id !== taskId ? t : { ...t, ...(t.status === 'pending' ? { status: 'active' as const, startedAt: now } : {}), agentId: row.id, ...(effort && !t.effort ? { effort } : {}) })) } : x,
289      )
290    }
291    return r
292  })
293
294  on('turn.complete', async ($, e, next) => {
295    const r = await next(e)
296    const now = await $.clock.now()
297
298    if (e.agentId) {
299      const id = e.agentId
300      const status = e.isAborted ? 'stopped' : e.reason === 'answer' ? 'done' : 'failed'
301      await setAgents($, list =>
302        list.map(a => (a.id === id ? { ...a, status, endedAt: now, pct: status === 'done' ? 100 : a.pct, doing: status === 'done' ? 'handed back to Opus' : status, summary: clip(e.answer, 220) } : a)),
303      )
304      return r
305    }
306
307    const m = await read($, missionAtom)
308    if (!m || m.status !== 'running') return r
309    const agents = await read($, agentsAtom)
310    const p = progress(m, agents)
311    if (p.n > 0 && p.doneN === p.n) {
312      await setMission($, x => (x ? { ...x, status: 'done', endedAt: now, doing: 'done' } : x))
313      $.ui.toast(`◆ Mission done in ${minutes(now - m.startedAt)}: ${clip(m.title, 60)}`)
314      return r
315    }
316
317    // Autopilot: a running worker wakes Opus with its notification; otherwise nudge.
318    const settings = await read($, settingsAtom)
319    const working = agents.some(a => a.status === 'running')
320    if (settings.auto && e.reason === 'answer' && !e.isAborted && !working && m.continues < settings.maxContinues) {
321      await setMission($, x => (x ? { ...x, continues: x.continues + 1, doing: 'continuing (autopilot)' } : x))
322      void $.prompt.submit({ text: continuation(m) })
323    } else {
324      await setMission($, x => (x ? { ...x, doing: working ? 'waiting on workers' : 'idle' } : x))
325    }
326    return r
327  })
328
329  // ---------- drawing ----------
330
331  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
332    const table = $.ui.resolve(e)
333    const m = await read($, missionAtom)
334    const agents = await read($, agentsAtom)
335    const settings = await read($, settingsAtom)
336    const now = await $.clock.now()
337    const width = Math.max(40, e.props.bodyColumns || 80)
338    return drawPane($, table, { m, agents, settings, now, width })
339  })
340
341  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
342    const m = await read($, missionAtom)
343    const now = await $.clock.now()
344    const recent = !!m && m.status === 'done' && now - m.endedAt < RECENT_MS
345    if (e.props.hasSurvey || !m || !(isLive(m) || recent)) return next(e)
346
347    const below = await next(e)
348    const { Box, Text, Button } = $.ui.resolve(e)
349    const agents = await read($, agentsAtom)
350    const p = progress(m, agents)
351    const running = agents.filter(a => a.status === 'running')
352    const width = Math.max(30, e.props.bodyColumns || 80)
353    const tone = m.status === 'done' ? COLOR.done : m.status === 'waiting' ? COLOR.wait : COLOR.opus
354    const right = m.status === 'done' ? 'done ✓' : m.status === 'waiting' ? 'waiting on you' : m.planAt ? `${p.pct}%` : 'planning…'
355
356    return (
357      <Box flexDirection="column">
358        <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
359          <Text bold wrap="truncate-end">
360            <Text color={COLOR.opus}>◆ </Text>
361            {m.title}
362          </Text>
363          <Box flexDirection="row" columnGap={2} flexShrink={0}>
364            <Text bold color={tone}>{right}</Text>
365            <Button key="conduct-open" label="board" hotkey="c" plain onPress={() => openPane($)} />
366          </Box>
367        </Box>
368        {m.planAt > 0 && <Text color={tone}>{textBar(p.fraction, Math.min(width - 2, 80))}</Text>}
369        <Text dimColor wrap="truncate-end">
370          {running.length
371            ? running.map(a => `${modelName(a.model)} ${a.pct ? a.pct + '% ' : ''}· ${a.doing}`).join('  |  ')
372            : `Opus: ${m.doing}`}
373        </Text>
374        {below}
375      </Box>
376    )
377  })
378}
379
380// ---------- pieces ----------
381
382function modelName(id: string): string {
383  const hit = /(opus|sonnet|haiku|fable)[-_ ]?(\d+)?(?:[-.](\d+))?/i.exec(id)
384  if (!hit) return clip(id || 'inherit', 16)
385  const word = hit[1] ?? ''
386  const name = word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
387  return hit[2] ? `${name} ${hit[2]}${hit[3] ? '.' + hit[3] : ''}` : name
388}
389
390function modelColor(id: string): string {
391  return /opus/i.test(id) ? COLOR.opus : /sonnet/i.test(id) ? COLOR.sonnet : /haiku/i.test(id) ? COLOR.haiku : COLOR.track
392}
393
394function ownerColor(owner: Task['owner']): string {
395  return owner === 'opus' ? COLOR.opus : owner === 'haiku' ? COLOR.haiku : COLOR.sonnet
396}
397
398const OWNER_LABEL: Record<Task['owner'], string> = { opus: 'Opus  ', sonnet: 'Sonnet', haiku: 'Haiku ' }
399
400/** a fixed-width effort tag so task titles stay aligned */
401const EFFORT_TAG: Record<NonNullable<Task['effort']>, string> = { low: 'low ', medium: 'med ', high: 'high', xhigh: 'xhi ' }
402
403function textBar(fraction: number, width: number): string {
404  const w = Math.max(8, width)
405  const n = Math.round(Math.max(0, Math.min(1, fraction)) * w)
406  return '█'.repeat(n) + '░'.repeat(w - n)
407}
408
409function ringSvg(fraction: number, label: string, color: string): string {
410  const r = 30
411  const c = 2 * Math.PI * r
412  const dash = (Math.max(0, Math.min(1, fraction)) * c).toFixed(1)
413  return `<svg xmlns="http://www.w3.org/2000/svg" width="76" height="76" viewBox="0 0 76 76"><circle cx="38" cy="38" r="${r}" fill="none" stroke="${COLOR.track}" stroke-opacity="0.25" stroke-width="7"/><circle cx="38" cy="38" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${dash} ${c.toFixed(1)}" transform="rotate(-90 38 38)"/><text x="38" y="43" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" font-size="16" font-weight="700" fill="${color}">${label}</text></svg>`
414}
415
416function barSvg(fraction: number, color: string, width: number): string {
417  const f = Math.max(0, Math.min(1, fraction))
418  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="4" fill="${COLOR.track}" fill-opacity="0.25"/><rect width="${(f * width).toFixed(1)}" height="8" rx="4" fill="${color}"/></svg>`
419}
420
421/** One segment per live task, colored by where it stands: the plan at a glance. */
422function segmentsSvg(tasks: readonly Task[], width: number): string {
423  const live = tasks.filter(t => t.status !== 'dropped')
424  if (!live.length) return barSvg(0, COLOR.track, width)
425  const gap = 3
426  const w = (width - gap * (live.length - 1)) / live.length
427  const rects = live
428    .map((t, i) => {
429      const color = t.status === 'done' ? COLOR.done : t.status === 'active' ? ownerColor(t.owner) : COLOR.track
430      const opacity = t.status === 'pending' ? 0.3 : 1
431      return `<rect x="${(i * (w + gap)).toFixed(1)}" width="${Math.max(2, w).toFixed(1)}" height="6" rx="3" fill="${color}" fill-opacity="${opacity}"/>`
432    })
433    .join('')
434  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6">${rects}</svg>`
435}
436
437function segmentsText(tasks: readonly Task[]): string {
438  return tasks
439    .filter(t => t.status !== 'dropped')
440    .map(t => (t.status === 'done' ? '■' : t.status === 'active' ? '▣' : '□'))
441    .join('')
442}
443
444type View = { m: Mission | null; agents: readonly AgentRow[]; settings: ConductorSettings; now: number; width: number }
445
446function drawPane($: EngineInterface, table: Kit, v: View) {
447  const { Box, Text, Button } = table
448  const Svg = 'Svg' in table ? table.Svg : undefined
449  const { m, agents, settings, now, width } = v
450  const bar = (fraction: number, color: string, cols: number) =>
451    Svg ? <Svg source={barSvg(fraction, color, Math.max(80, cols * 7))} alt={`${Math.round(fraction * 100)} percent`} /> : <Text color={color}>{textBar(fraction, cols)}</Text>
452
453  const header = (() => {
454    if (!m) {
455      return (
456        <Box flexDirection="column">
457          <Text bold color={COLOR.opus}>◆ Opus Conductor</Text>
458          <Text dimColor>No mission yet. Type /conduct, pick conduct (opus-conductor), and describe what done looks like. Opus 5.5 plans it, Sonnet 5.5 workers build, Haiku 5.5 scouts look things up, and it all shows up here.</Text>
459        </Box>
460      )
461    }
462    const p = progress(m, agents)
463    const left = eta(m, agents, now)
464    const tone = m.status === 'done' ? COLOR.done : m.status === 'waiting' ? COLOR.wait : m.status === 'stopped' ? COLOR.track : COLOR.opus
465    const word =
466      m.status === 'done' ? `Done ✓ in ${minutes(m.endedAt - m.startedAt)}` : m.status === 'stopped' ? 'Stopped' : m.status === 'waiting' ? 'Waiting on you' : m.planAt ? `${p.doneN} of ${p.n} tasks done` : 'Planning…'
467    const meta = [
468      m.status === 'running' || m.status === 'waiting' ? `⏱ ${minutes(now - m.startedAt)}` : '',
469      left !== null ? `~${minutes(left)} left` : '',
470      settings.auto ? `autopilot ${m.continues}/${settings.maxContinues}` : 'autopilot off',
471    ].filter(Boolean)
472    const live = m.status === 'running' || m.status === 'waiting'
473    return (
474      <Box flexDirection="column" rowGap={0}>
475        <Box flexDirection="row" columnGap={2} alignItems="center">
476          {Svg && <Svg source={ringSvg(p.fraction, `${p.pct}%`, tone)} alt={`Mission ${p.pct} percent done`} width={76} height={76} />}
477          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
478            <Text bold color={COLOR.opus}>◆ OPUS CONDUCTOR</Text>
479            <Text bold wrap="wrap">{m.title}</Text>
480            <Text color={tone} bold>{Svg ? word : `${p.pct}% · ${word}`}</Text>
481          </Box>
482        </Box>
483        {m.planAt > 0 &&
484          (Svg ? (
485            <Svg source={segmentsSvg(m.tasks, Math.max(120, Math.min(width, 90) * 7))} alt={`${p.doneN} of ${p.n} tasks done`} />
486          ) : (
487            <Text color={tone}>{segmentsText(m.tasks)}</Text>
488          ))}
489        <Text dimColor wrap="truncate-end">{meta.join('  ·  ')}</Text>
490        {live && (
491          <Text wrap="truncate-end">
492            <Text color={COLOR.opus} bold>Opus 5.5 </Text>
493            <Text dimColor>{m.status === 'waiting' ? `asks: ${m.note}` : m.doing}</Text>
494          </Text>
495        )}
496      </Box>
497    )
498  })()
499
500  const running = agents.filter(a => a.status === 'running')
501  const finished = agents.filter(a => a.status !== 'running').reverse()
502  const shownFinished = finished.slice(0, 5)
503
504  const runningCard = (a: AgentRow) => {
505    const tone = modelColor(a.model)
506    return (
507      <Box flexDirection="column" key={`agent-${a.id}`} borderStyle="round" borderColor={tone} paddingX={1}>
508        <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
509          <Text wrap="truncate-end">
510            <Text color={tone}>● </Text>
511            <Text bold color={tone}>{modelName(a.model)}</Text>
512            <Text dimColor>{a.effort ? ` · ${a.effort} ` : ' '}</Text>
513            <Text bold>{a.label}</Text>
514          </Text>
515          <Text dimColor>{`${minutes(now - a.startedAt)} · ${a.tools} tools`}</Text>
516        </Box>
517        <Box flexDirection="row" columnGap={1} alignItems="center">
518          {bar(a.pct / 100, tone, Math.min(24, Math.max(8, width - 30)))}
519          <Text dimColor>{a.pct ? `${a.pct}%` : 'working…'}</Text>
520        </Box>
521        <Text dimColor wrap="truncate-end">{`▸ ${a.doing}`}</Text>
522      </Box>
523    )
524  }
525
526  const finishedRow = (a: AgentRow) => {
527    const icon = a.status === 'done' ? '✓' : a.status === 'failed' ? '✗' : '■'
528    const tone = a.status === 'done' ? COLOR.done : a.status === 'failed' ? COLOR.fail : COLOR.track
529    return (
530      <Box flexDirection="row" justifyContent="space-between" columnGap={1} key={`agent-${a.id}`}>
531        <Text wrap="truncate-end">
532          <Text color={tone}>{icon} </Text>
533          <Text color={modelColor(a.model)}>{modelName(a.model)}</Text>
534          <Text dimColor>{a.effort ? ` · ${a.effort} ` : ' '}</Text>
535          <Text dimColor>{a.label}</Text>
536        </Text>
537        <Text dimColor>{`${minutes(a.endedAt - a.startedAt)} · ${a.tools} tools`}</Text>
538      </Box>
539    )
540  }
541
542  const taskRow = (t: Task) => {
543    const icon = t.status === 'done' ? '✓' : t.status === 'active' ? '▶' : t.status === 'dropped' ? '×' : '○'
544    const tone = t.status === 'done' ? COLOR.done : t.status === 'active' ? ownerColor(t.owner) : COLOR.track
545    return (
546      <Text wrap="truncate-end" key={`task-${t.id}`}>
547        <Text color={tone}>{icon} </Text>
548        <Text dimColor>{`#${t.id} ${t.size} `}</Text>
549        <Text color={ownerColor(t.owner)}>{OWNER_LABEL[t.owner]} </Text>
550        {t.effort && <Text dimColor>{`${EFFORT_TAG[t.effort]} `}</Text>}
551        <Text bold={t.status === 'active'} dimColor={t.status === 'done' || t.status === 'dropped'} strikethrough={t.status === 'dropped'}>{t.title}</Text>
552      </Text>
553    )
554  }
555
556  // the plan keeps what's moving in view: the last two done, then everything open
557  const plan = (() => {
558    if (!m) return null
559    const done = m.tasks.filter(t => t.status === 'done')
560    const hiddenDone = Math.max(0, done.length - 2)
561    const keep = new Set(done.slice(-2).map(t => t.id))
562    const rows = m.tasks.filter(t => t.status !== 'done' || keep.has(t.id))
563    return { hiddenDone, rows }
564  })()
565
566  const live = !!m && (m.status === 'running' || m.status === 'waiting')
567
568  return (
569    <Box flexDirection="column" rowGap={1}>
570      {header}
571      <Box flexDirection="column" rowGap={0}>
572        <Text bold>{`AGENTS  ${running.length} working · ${finished.length} done`}</Text>
573        {!agents.length && <Text dimColor>No agents launched yet.</Text>}
574        {running.map(runningCard)}
575        {shownFinished.map(finishedRow)}
576        {finished.length > shownFinished.length && <Text dimColor>{`+${finished.length - shownFinished.length} earlier`}</Text>}
577      </Box>
578      {m && plan && (
579        <Box flexDirection="column">
580          <Text bold>PLAN</Text>
581          {!m.tasks.length && <Text dimColor>Waiting for Opus to plan…</Text>}
582          {plan.hiddenDone > 0 && <Text color={COLOR.done} dimColor>{`✓ ${plan.hiddenDone} earlier tasks done`}</Text>}
583          {plan.rows.map(taskRow)}
584        </Box>
585      )}
586      <Box flexDirection="row" columnGap={2} flexWrap="wrap">
587        {live && (
588          <Button
589            key="conduct-stop"
590            label="Stop mission"
591            onPress={async () => {
592              const at = await $.clock.now()
593              await update($, missionAtom, x => (x ? { ...x, status: 'stopped' as const, endedAt: at } : x))
594            }}
595          />
596        )}
597        <Button
598          key="conduct-auto"
599          label={settings.auto ? 'Autopilot: on' : 'Autopilot: off'}
600          onPress={async () => {
601            const next = await update($, settingsAtom, s => ({ ...s, auto: !s.auto }))
602            await $.store.set('settings', next).catch(() => undefined)
603          }}
604        />
605        {!live && (m || agents.length > 0) && (
606          <Button
607            key="conduct-clear"
608            label="Clear board"
609            onPress={async () => {
610              await update($, missionAtom, () => null)
611              await update($, agentsAtom, list => (list ?? []).filter(a => a.status === 'running'))
612            }}
613          />
614        )}
615      </Box>
616    </Box>
617  )
618}
619
hooks/mission.ts 333 lines
1// Opus Conductor's mission: the plan Opus keeps through the mod's tool, the
2// progress the pane draws from it, and the words the mod hands the models.
3// Pure functions, no `$`: the tests drive them directly.
4
5import type { AgentRow, Effort, Mission, Owner, Size, Task } from '../types'
6
7export const SONNET = 'claude-sonnet-5-5'
8export const HAIKU = 'claude-haiku-5-5'
9export const WORKER = 'worker'
10export const SCOUT = 'scout'
11
12/** One registered agent type per effort: the Agent tool can't set effort per call. */
13export const WORKER_EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh']
14export const SCOUT_EFFORTS: readonly Effort[] = ['low', 'medium', 'high']
15const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh']
16
17export function effortOf(v: unknown): Effort | undefined {
18  const hit = /(?:^|[-\s:])(xhigh|high|medium|low)$/i.exec(String(v ?? '').trim())
19  const e = hit?.[1]?.toLowerCase() as Effort | undefined
20  return e && EFFORTS.includes(e) ? e : undefined
21}
22
23/** worker-high, scout-low, ...: the agent type for a kind of helper at one effort */
24export function agentName(kind: 'worker' | 'scout', effort: Effort): string {
25  return `${kind}-${effort}`
26}
27const SIZES: Record<Size, number> = { S: 1, M: 2, L: 3 }
28const STOP = /^(stop|cancel|off|end|abort)$/i
29
30export function isStopWord(text: string): boolean {
31  return STOP.test(text.trim())
32}
33
34export function clip(text: unknown, max: number): string {
35  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
36  return s.length > max ? s.slice(0, max - 1) + '…' : s
37}
38
39export function minutes(ms: number): string {
40  const s = Math.max(0, Math.round(ms / 1000))
41  if (s < 60) return `${s}s`
42  const m = Math.round(s / 60)
43  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
44}
45
46export function newMission(condition: string, now: number): Mission {
47  const first = condition.split(/\r?\n/).map(l => l.trim()).find(Boolean) ?? 'Mission'
48  return {
49    title: clip(first, 120),
50    condition: condition.slice(0, 4000),
51    startedAt: now,
52    endedAt: 0,
53    status: 'running',
54    tasks: [],
55    nextId: 1,
56    planAt: 0,
57    continues: 0,
58    note: '',
59    doing: 'reading the ask',
60  }
61}
62
63type RawTask = { title?: unknown; size?: unknown; owner?: unknown; effort?: unknown }
64
65function sizeOf(v: unknown): Size {
66  const s = String(v ?? '').trim().toUpperCase().charAt(0)
67  return s === 'S' || s === 'L' ? s : 'M'
68}
69
70function ownerOf(v: unknown): Owner {
71  const s = String(v ?? '')
72  return /opus/i.test(s) ? 'opus' : /haiku|scout/i.test(s) ? 'haiku' : 'sonnet'
73}
74
75/**
76 * The model a mission's spawn should run on, or undefined to leave the call as made.
77 * Labor defaults to Sonnet 5.5; read-only exploring defaults to Haiku 5.5; any
78 * Haiku request is lifted to Haiku 5.5; an explicit Sonnet/Opus/Fable choice stands.
79 */
80export function steerModel(model: string | undefined, subagentType: string, own: { worker: string; scout: string }): string | undefined {
81  if (model) return /haiku/i.test(model) && !/5[-.]5/.test(model) ? HAIKU : undefined
82  if (subagentType.startsWith(own.worker) || subagentType.startsWith(own.scout)) return undefined
83  return /^explore$/i.test(subagentType) ? HAIKU : SONNET
84}
85
86function makeTasks(m: Mission, raw: unknown): Task[] {
87  const list = Array.isArray(raw) ? (raw as RawTask[]) : []
88  return list
89    .filter(t => t && clip(t.title, 140))
90    .map(t => ({
91      id: m.nextId++,
92      title: clip(t.title, 140),
93      size: sizeOf(t.size),
94      owner: ownerOf(t.owner),
95      ...(effortOf(t.effort) ? { effort: effortOf(t.effort) } : {}),
96      status: 'pending' as const,
97      agentId: '',
98      startedAt: 0,
99      doneAt: 0,
100      note: '',
101    }))
102}
103
104export type MissionInput = {
105  action?: unknown
106  tasks?: unknown
107  ids?: unknown
108  note?: unknown
109}
110
111function idsOf(raw: unknown): number[] {
112  const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]
113  return list.map(Number).filter(n => Number.isInteger(n))
114}
115
116/** Applies one call of the mission tool; returns the mission after it and the reply. */
117export function applyAction(
118  before: Mission,
119  input: MissionInput,
120  now: number,
121): { mission: Mission; text: string } {
122  const m: Mission = { ...before, tasks: before.tasks.map(t => ({ ...t })) }
123  const action = String(input.action ?? 'show').toLowerCase()
124  const note = clip(input.note, 300)
125  const pick = (ids: number[]) => m.tasks.filter(t => ids.includes(t.id))
126
127  if (action === 'plan' || action === 'add') {
128    if (action === 'plan') m.tasks = m.tasks.filter(t => t.status !== 'pending')
129    const added = makeTasks(m, input.tasks)
130    if (!added.length) return { mission: before, text: 'No tasks given. Pass tasks: [{ title, size: S|M|L, owner: opus|sonnet }].' }
131    m.tasks.push(...added)
132    if (!m.planAt) m.planAt = now
133    m.doing = 'planned; dispatching'
134    return { mission: m, text: `${action === 'plan' ? 'Plan set' : 'Added'}: ${added.map(t => `#${t.id} ${t.title} (${t.size}, ${t.owner})`).join('; ')}.\n${summary(m)}` }
135  }
136
137  if (action === 'start' || action === 'done' || action === 'drop') {
138    const hit = pick(idsOf(input.ids))
139    if (!hit.length) return { mission: before, text: `No task with ids ${JSON.stringify(input.ids)}. ${summary(before)}` }
140    for (const t of hit) {
141      if (action === 'start') {
142        t.status = 'active'
143        t.startedAt = t.startedAt || now
144      } else if (action === 'done') {
145        t.status = 'done'
146        t.doneAt = now
147        t.startedAt = t.startedAt || now
148      } else {
149        t.status = 'dropped'
150      }
151      if (note) t.note = note
152    }
153    if (m.status === 'waiting') m.status = 'running'
154    return { mission: m, text: summary(m) }
155  }
156
157  if (action === 'wait') {
158    m.status = 'waiting'
159    m.note = note || 'waiting on the person'
160    return { mission: m, text: 'Mission paused for the person. Autopilot will not continue until they reply.' }
161  }
162
163  if (action === 'complete') {
164    m.status = 'done'
165    m.endedAt = now
166    m.note = note
167    // a verified mission finishes every task still on its plan
168    for (const t of m.tasks) {
169      if (t.status === 'active' || t.status === 'pending') {
170        t.status = 'done'
171        t.doneAt = now
172      }
173    }
174    return { mission: m, text: `Mission complete. ${summary(m)}` }
175  }
176
177  return { mission: before, text: summary(before) }
178}
179
180export function live(m: Mission): Task[] {
181  return m.tasks.filter(t => t.status !== 'dropped')
182}
183
184/** Finished weight over the plan's weight; an active task counts its worker's reported %. */
185export function progress(m: Mission, agents: readonly AgentRow[] = []) {
186  const tasks = live(m)
187  const total = tasks.reduce((a, t) => a + SIZES[t.size], 0)
188  let done = 0
189  for (const t of tasks) {
190    if (t.status === 'done') done += SIZES[t.size]
191    else if (t.status === 'active') {
192      const a = agents.find(x => x.taskId === t.id && x.status === 'running' && x.pct > 0)
193      if (a) done += SIZES[t.size] * Math.min(0.9, a.pct / 100)
194    }
195  }
196  const doneN = tasks.filter(t => t.status === 'done').length
197  const fraction = m.status === 'done' ? 1 : total ? done / total : 0
198  return { n: tasks.length, doneN, fraction, pct: Math.round(fraction * 100) }
199}
200
201/** Time left at the mission's own pace, once a quarter of it is done. */
202export function eta(m: Mission, agents: readonly AgentRow[], now: number): number | null {
203  const p = progress(m, agents)
204  if (m.status !== 'running' || !m.planAt || p.fraction < 0.25 || p.fraction >= 1) return null
205  const spent = now - m.planAt
206  return (spent / p.fraction) * (1 - p.fraction)
207}
208
209export function summary(m: Mission): string {
210  const p = progress(m)
211  const rows = m.tasks.map(t => `#${t.id} [${t.status}] ${t.size} ${t.owner}${t.effort ? `@${t.effort}` : ''}: ${t.title}${t.note ? ` (${t.note})` : ''}`)
212  return [`Mission "${m.title}": ${p.doneN}/${p.n} tasks, ${p.pct}%.`, ...rows].join('\n')
213}
214
215/** The task an Agent call is for: "#3" in its description or prompt. */
216export function taskIdIn(...texts: (string | undefined)[]): number {
217  for (const t of texts) {
218    const hit = /#(\d{1,4})\b/.exec(t ?? '')
219    if (hit) return Number(hit[1])
220  }
221  return 0
222}
223
224/** One short line of what a tool call does, for an agent's card. */
225export function describeCall(tool: string, input: Record<string, unknown>): string {
226  const file = (v: unknown) => String(v ?? '').split(/[\\/]/).pop() ?? ''
227  switch (tool) {
228    case 'Bash':
229    case 'PowerShell': {
230      // the step that matters, not the cd and variables in front of it
231      const parts = String(input.command ?? '').split(/&&|;|\n/).map(x => x.trim()).filter(Boolean)
232      const step = parts.filter(x => !/^(cd|set|export|\$?[A-Za-z_][A-Za-z0-9_]*=)/.test(x)).pop() ?? parts.pop() ?? ''
233      return `$ ${clip(step, 70)}`
234    }
235    case 'Read':
236      return `reading ${file(input.file_path)}`
237    case 'Edit':
238    case 'MultiEdit':
239      return `editing ${file(input.file_path)}`
240    case 'Write':
241      return `writing ${file(input.file_path)}`
242    case 'Grep':
243      return `searching "${clip(input.pattern, 40)}"`
244    case 'Glob':
245      return `finding ${clip(input.pattern, 40)}`
246    case 'WebFetch':
247    case 'WebSearch':
248      return `web: ${clip(input.query ?? input.url, 50)}`
249    case 'Agent':
250      return `delegating: ${clip(input.description, 50)}`
251    default:
252      return tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' · ') : tool
253  }
254}
255
256// ---------- words for the models ----------
257
258export function missionToolSpec(name: string) {
259  return {
260    name,
261    description:
262      'Opus Conductor mission board: the plan the person watches live. Actions: ' +
263      '"plan" (replace pending tasks) / "add" with tasks [{ title, size: S|M|L, owner: "opus"|"sonnet"|"haiku", effort: "low"|"medium"|"high"|"xhigh" }]; ' +
264      '"start" / "done" / "drop" with ids [n] and an optional note; "wait" with a note when you need the person; ' +
265      '"complete" with a note once the whole mission is verified; "show". Mark a task done only after you verified its result.',
266    inputSchema: {
267      type: 'object',
268      properties: {
269        action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'wait', 'complete', 'show'] },
270        tasks: {
271          type: 'array',
272          items: {
273            type: 'object',
274            properties: {
275              title: { type: 'string' },
276              size: { type: 'string', enum: ['S', 'M', 'L'] },
277              owner: { type: 'string', enum: ['opus', 'sonnet', 'haiku'] },
278              effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh'] },
279            },
280            required: ['title'],
281          },
282        },
283        ids: { type: 'array', items: { type: 'integer' } },
284        note: { type: 'string' },
285      },
286      required: ['action'],
287    },
288  }
289}
290
291export function reportToolSpec(name: string) {
292  return {
293    name,
294    description:
295      'For a delegated worker: report your progress to the conductor\'s live board. ' +
296      'pct is your own honest estimate 0-100 of your assignment; doing is a few words on what you are doing now. ' +
297      'Call it after each meaningful step (roughly every 3-5 tool calls).',
298    inputSchema: {
299      type: 'object',
300      properties: { pct: { type: 'number', minimum: 0, maximum: 100 }, doing: { type: 'string' } },
301      required: ['pct', 'doing'],
302    },
303  }
304}
305
306export function scoutPrompt(reportTool: string): string {
307  return `You are a Haiku 5.5 scout under an Opus 5.5 conductor. You do one narrow, well-defined job fast: look things up, read and sweep files, extract or summarize, triage, or run a named check and report what it says.
308
309Rules:
310- Do exactly the job in the brief. Do not design, refactor, or decide; if the job needs judgment the brief does not settle, stop and say so.
311- Prefer reading over writing. Edit files only when the brief names the exact change.
312- Quote evidence: file paths with line numbers, command output, URLs. Never guess a value you could not find; say it is missing.
313- Report progress with ${reportTool} (pct 0-100 and a few words) after each meaningful step, and pct 100 just before you finish.
314- Finish with a compact result in the shape the brief asks for, then any gaps.`
315}
316
317export function workerPrompt(reportTool: string): string {
318  return `You are a Sonnet 5.5 worker under an Opus 5.5 conductor. The conductor owns design decisions, integration and verification; you own exactly one delegated outcome.
319
320Rules:
321- Do the assignment exactly as briefed. Stay inside the stated scope; never touch files listed under "Do not touch".
322- Locked decisions are not yours to revisit. If the brief is wrong or blocked, stop and report the evidence instead of widening scope.
323- Report progress with ${reportTool} (pct 0-100 and a few words) after each meaningful step, and pct 100 just before you finish.
324- Run the verification the brief names. Do not claim a check passed that you did not run.
325- Finish with: summary, changed files, checks run with their result, remaining risks.`
326}
327
328export function continuation(m: Mission): string {
329  const open = live(m).filter(t => t.status !== 'done')
330  if (!m.planAt) return `Continue the Opus Conductor mission "${m.title}": make the plan with the mission tool first, then delegate.`
331  return `Continue the Opus Conductor mission "${m.title}". Open tasks: ${open.map(t => `#${t.id} ${t.title} [${t.status}, ${t.owner}]`).join('; ')}. Delegate, verify, integrate and update the board. If you need the person, call the mission tool with action "wait".`
332}
333
types/index.d.ts 66 lines
1export type Size = 'S' | 'M' | 'L'
2export type Owner = 'opus' | 'sonnet' | 'haiku'
3export type Effort = 'low' | 'medium' | 'high' | 'xhigh'
4export type TaskStatus = 'pending' | 'active' | 'done' | 'dropped'
5
6export type Task = {
7  id: number
8  title: string
9  size: Size
10  owner: Owner
11  /** the reasoning effort Opus chose for it; absent on tasks planned before 1.4 */
12  effort?: Effort
13  status: TaskStatus
14  agentId: string
15  startedAt: number
16  doneAt: number
17  note: string
18}
19
20export type MissionStatus = 'running' | 'waiting' | 'done' | 'stopped'
21
22export type Mission = {
23  title: string
24  condition: string
25  startedAt: number
26  endedAt: number
27  status: MissionStatus
28  tasks: Task[]
29  nextId: number
30  planAt: number
31  continues: number
32  note: string
33  doing: string
34}
35
36export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
37
38export type AgentRow = {
39  id: string
40  label: string
41  model: string
42  type: string
43  startedAt: number
44  endedAt: number
45  status: AgentStatus
46  tools: number
47  doing: string
48  pct: number
49  taskId: number
50  /** the effort its agent type runs at; absent for agents that aren't the conductor's */
51  effort?: Effort
52  summary: string
53}
54
55export type ConductorSettings = { auto: boolean; maxContinues: number }
56
57declare module 'claude-code' {
58  interface PluginState {
59    'opus-conductor': {
60      mission: Mission | null
61      agents: AgentRow[]
62      settings: ConductorSettings
63    }
64  }
65}
66