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…

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.
| Type | Does |
|---|---|
/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:conduct | Opens the live board. |
/opus-conductor:conduct stop · auto on · auto off · clear | Stops the mission, toggles autopilot, or clears the board. |
In the desktop app, type /conduct and pick conduct (opus-conductor) from the menu.
c opens the board) and a status line.| Model | Role | Gets |
|---|---|---|
| Opus 5.5 | conductor | Architecture, design, diagnosis, security, integration, review. |
| Sonnet 5.5 | worker | Implementation, refactors, tests, multi-step debugging, agentic coding. |
| Haiku 5.5 | scout | Lookups, 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.
| Effort | opus-conductor:worker-* (Sonnet 5.5) | opus-conductor:scout-* (Haiku 5.5) |
|---|---|---|
low | mechanical edits from an exact spec | single lookups, greps, one value, one named check |
medium | clear-brief implementation (the default for agentic coding) | summaries, triage, extraction across several files |
high | nontrivial implementation, cross-module refactors, tricky tests, bounded debugging | careful cross-checks, browser flows, costly-if-wrong sweeps |
xhigh | hard multi-step debugging, long multi-file builds where a miss is costly | not 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.
claude plugin validate . and claude plugin test . in this folder./reload-plugins in a session to pick up the changes.claude --plugin-dir <copy> so running sessions are untouched.hooks/register.tsx 619 lines1// 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}
619hooks/mission.ts 333 lines1// 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}
333types/index.d.ts 66 lines1export 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