Pick the model each kind of task runs on (Claude, Codex, Cursor, Gemini), dispatch it from a pane, and manage your sign-ins

Pick which model runs each kind of task, from inside Claude Code.
Orchestra is a Claude Code mod (a plugin with a live pane). It gives you one table: your tasks on the left, the model each one runs on to the right. Send a task from the pane and it goes to that model. Claude also reads the table, so when it delegates on its own it follows your picks.
Orchestra
Accounts refresh
● Claude Fable, Opus you@example.com
● ChatGPT Astra using ChatGPT sign out
○ Cursor Composer not signed in sign in
● Gemini Gemini you@gmail.com switch
Tasks model it runs on
▸ Plan a change (with reproduction test) Fable 5.1
Red-team a plan or design GPT-6 Astra · read-only
Implement with a done-criterion Opus 5.5
Review a PR GPT-6 Astra · read-only
Write tests Composer 2.5 · edits files ×
+ Add a task Opus 5.5
╭ Plan a change → Fable 5.1 ──────────────────────╮
│ › What do you want done? Enter sends it │
╰───────────────────────────────────────────────────╯
Jobs
1: ● running Fable 5.1 0m42s Plan a change: add rate limiting
/plugin marketplace add octavi42/orchestra
/plugin install orchestra@orchestra
Then run /orchestrate.
+ row and the model beside it. Your table is saved across sessions.codex), Cursor CLI (cursor-agent), Gemini CLI (gemini). Models whose CLI is not installed are hidden from the pickers.Change these in the /config menu under Orchestra:
| Setting | Default | What it does |
|---|---|---|
| Planning model (Claude) | claude-fable-5-1 | Model id behind the "Fable" choice |
| Implementation model (Claude) | claude-opus-5-5 | Model id behind the "Opus" choice |
| Codex model | gpt-6-astra | Passed to codex exec -m |
| Cursor model | composer-2.5 | Passed to cursor-agent --model |
| Model guard | off | Runs Haiku, Sonnet and older Opus subagents on the implementation model instead |
| Share routing with Claude | on | Adds your table to the conversation |
Every task row is either read-only or edits files, and that setting is enforced per tool:
| Model | Read-only | Edits files |
|---|---|---|
| Claude (Fable, Opus) | a read-only agent with Edit/Write removed and its own permission mode | a general-purpose agent |
| Codex | codex exec -s read-only | codex exec -s workspace-write |
| Cursor | cursor-agent --mode ask | cursor-agent --force |
| Gemini | gemini --approval-mode plan | gemini --approval-mode yolo |
--, Gemini as one --prompt= value.claude --plugin-dir ./
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 1257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Access, Account, Draft, Job, ModelId, Pipeline, Preset, Provider, Stage, Ui } from '../types'
5
6const PANE = 'orchestra'
7const KEYS = { presets: 'presets', jobs: 'jobs', guard: 'guard', autoAdvance: 'autoAdvance' }
8
9const STAGES: { value: Stage; label: string }[] = [
10 { value: 'plan', label: 'Plan' },
11 { value: 'critique', label: 'Plan critique' },
12 { value: 'implement', label: 'Implement' },
13 { value: 'mechanical', label: 'Mechanical bulk' },
14 { value: 'review', label: 'Review' },
15 { value: 'verify', label: 'Verify' },
16 { value: 'research', label: 'Bulk read / research' },
17]
18
19const MODELS: { value: ModelId; label: string; via: string }[] = [
20 { value: 'fable', label: 'Fable 5.1', via: 'Claude subagent' },
21 { value: 'opus', label: 'Opus 5.5', via: 'Claude subagent' },
22 { value: 'astra', label: 'GPT-6 Astra', via: 'codex exec' },
23 { value: 'composer', label: 'Composer 2.5', via: 'cursor-agent' },
24 { value: 'gemini', label: 'Gemini', via: 'gemini -y' },
25]
26
27// Settings from the plugin's userConfig; filled in by register().
28const CFG = {
29 fableModel: 'claude-fable-5-1',
30 opusModel: 'claude-opus-5-5',
31 codexModel: 'gpt-6-astra',
32 cursorModel: 'composer-2.5',
33 guard: false,
34 shareRouting: true,
35}
36
37const CLAUDE_IDS: Partial<Record<ModelId, string>> = {
38 fable: CFG.fableModel,
39 opus: CFG.opusModel,
40}
41
42// Mirrors the routing table in ~/.claude/CLAUDE.md.
43const DEFAULT_ROUTING: Record<Stage, ModelId> = {
44 plan: 'fable',
45 critique: 'astra',
46 implement: 'opus',
47 mechanical: 'composer',
48 review: 'astra',
49 verify: 'astra',
50 research: 'gemini',
51}
52
53const BRIEF_OUT =
54 'When a step does not need my input, keep going. Stop and ask only if something fails for a reason you cannot explain, or before anything destructive.'
55
56const BUILTIN_PRESETS: Preset[] = [
57 {
58 id: 'pipeline',
59 title: 'Full pipeline: plan, critique, implement, review',
60 stage: 'plan',
61 access: 'read',
62 template: '{input}',
63 then: ['critique', 'implement', 'review'],
64 },
65 {
66 id: 'plan',
67 title: 'Plan a change (with reproduction test)',
68 stage: 'plan',
69 access: 'read',
70 template:
71 'Plan this change before any code is written. Read the relevant code first. Return: the approach, the files to touch, risks, and the failing reproduction test that defines done (as runnable code plus the command to run it).\n\nChange: {input}',
72 },
73 {
74 id: 'critique',
75 title: 'Red-team a plan or design',
76 stage: 'critique',
77 access: 'read',
78 template:
79 'Adversarially critique this plan. List what will break, what is missing, wrong assumptions, and simpler alternatives, ranked by severity. Mark anything you could not confirm from the code.\n\n{input}',
80 },
81 {
82 id: 'implement',
83 title: 'Implement with a done-criterion',
84 stage: 'implement',
85 access: 'write',
86 template: `Implement this: {input}\n\nDone means the reproduction test (or the test command named above) passes and nothing else regresses. Run it before you report. ${BRIEF_OUT}\n\nUse sub-agents where they help.`,
87 },
88 {
89 id: 'mechanical',
90 title: 'Mechanical bulk edit',
91 stage: 'mechanical',
92 access: 'write',
93 template: `Make this mechanical change across the codebase: {input}\n\nKeep the diff minimal and consistent with surrounding code. Run the typecheck and tests when done. ${BRIEF_OUT}`,
94 },
95 {
96 id: 'review-diff',
97 title: 'Review uncommitted changes',
98 stage: 'review',
99 access: 'read',
100 template:
101 'Review the uncommitted changes in this repo (git diff and git diff --staged). Report correctness bugs ranked most severe first, each with file:line and a concrete failure scenario. Mark what you could not confirm. Extra context: {input}',
102 },
103 {
104 id: 'review-pr',
105 title: 'Review a PR',
106 stage: 'review',
107 access: 'read',
108 template:
109 'Review PR {input} (use gh pr view and gh pr diff). Report correctness bugs ranked most severe first with file:line and a failure scenario. Mark what you could not confirm. Do not post anything to GitHub.',
110 },
111 {
112 id: 'merge-gate',
113 title: 'Merge-risk gate',
114 stage: 'review',
115 access: 'read',
116 template:
117 'For {input}: what are the risks of merging this today, and what is the worst thing that happens if we merge it right now? Be concrete and mark what you could not confirm.',
118 },
119 {
120 id: 'verify',
121 title: 'Verify a flow in the browser',
122 stage: 'verify',
123 access: 'write',
124 template:
125 'Verify this works in the running app using your browser tools: {input}\n\nReport what you did, what you saw, and pass/fail per step with evidence. Do not change code.',
126 },
127 {
128 id: 'investigate',
129 title: 'Investigate (logs, big files, many dirs)',
130 stage: 'research',
131 access: 'read',
132 template: 'Investigate and summarize, citing files and lines: {input}',
133 },
134 {
135 id: 'explain',
136 title: 'Explain code simply',
137 stage: 'research',
138 model: 'opus',
139 access: 'read',
140 template: 'Explain simply and briefly how this works, with the key files: {input}',
141 },
142].map(p => ({ model: DEFAULT_ROUTING[p.stage as Stage], ...p, isBuiltin: true }) as Preset)
143
144// How a pipeline hands one stage's output to the next.
145const HANDOFF: Record<Stage, (goal: string, prev: string) => string> = {
146 plan: goal => `Plan this change. Return the approach, files, risks, and the failing reproduction test that defines done.\n\nChange: ${goal}`,
147 critique: (goal, prev) =>
148 `Adversarially critique this plan for: ${goal}\n\nList what breaks, what is missing, and simpler alternatives, ranked by severity. Then give a corrected final plan. Mark what you could not confirm.\n\nPLAN:\n${prev}`,
149 implement: (goal, prev) =>
150 `Implement: ${goal}\n\nFollow this plan (with its critique folded in). Done means its reproduction test passes. ${BRIEF_OUT}\n\nPLAN:\n${prev}`,
151 mechanical: (goal, prev) => `Apply this mechanically: ${goal}\n\nCONTEXT:\n${prev}`,
152 review: (goal, prev) =>
153 `Review the uncommitted changes in this repo (git diff) made for: ${goal}\n\nReport bugs ranked by severity with file:line. Mark what you could not confirm.\n\nIMPLEMENTER REPORT:\n${prev}`,
154 verify: (goal, prev) => `Verify in the running app that this works: ${goal}\n\nCONTEXT:\n${prev}`,
155 research: (goal, prev) => `Investigate: ${goal}\n\nCONTEXT:\n${prev}`,
156}
157
158
159// Models the routing table says never to use for real work.
160const BANNED = /haiku|sonnet|opus-4|claude-opus-5$/i
161
162const HINTS: Record<string, string> = {
163 pipeline: 'Each step feeds the next, using the models picked for Plan, Red-team, Implement and Review.',
164 plan: 'Reads the code first; returns approach, files, risks and the failing repro test.',
165 critique: 'Ranks what breaks, what is missing and simpler options.',
166 implement: 'Runs until the named test passes. Say what done means.',
167 mechanical: 'Minimal consistent diff across the codebase, then typecheck and tests.',
168 'review-diff': 'Reviews git diff and staged changes; bugs first, with file:line.',
169 'review-pr': 'gh pr view + diff; bugs ranked, nothing posted.',
170 'merge-gate': 'What goes wrong if this merges today, and how badly.',
171 verify: 'Drives the running app in a browser; pass/fail per step with evidence.',
172 investigate: 'Reads big files, logs and many dirs; cites files and lines.',
173 explain: 'Short plain explanation with the key files.',
174}
175
176const PLACEHOLDERS: Record<string, string> = {
177 pipeline: 'Describe the goal',
178 'review-pr': 'PR number or URL',
179 critique: 'Paste the plan, or say where it is',
180 'review-diff': 'Extra context (optional)',
181}
182
183// Tasks whose input may be left empty.
184const OPTIONAL_INPUT = new Set(['review-diff'])
185
186// Which task row decides the model for each pipeline / hand-off stage.
187const STAGE_TASK: Record<Stage, string> = {
188 plan: 'plan',
189 critique: 'critique',
190 implement: 'implement',
191 mechanical: 'mechanical',
192 review: 'review-diff',
193 verify: 'verify',
194 research: 'investigate',
195}
196
197const PROVIDERS: { id: Provider; name: string; models: string }[] = [
198 { id: 'claude', name: 'Claude', models: 'Fable, Opus' },
199 { id: 'codex', name: 'ChatGPT', models: 'Astra' },
200 { id: 'cursor', name: 'Cursor', models: 'Composer' },
201 { id: 'gemini', name: 'Gemini', models: 'Gemini' },
202]
203const PROVIDER_OF: Record<ModelId, Provider> = { fable: 'claude', opus: 'claude', astra: 'codex', composer: 'cursor', gemini: 'gemini' }
204
205const draft = atom({ plugin: 'orchestra', key: 'draft' } as const, {
206 presetId: 'plan',
207 stage: 'plan',
208 model: 'fable',
209 access: 'read',
210 input: '',
211} as Draft)
212const jobs = atom({ plugin: 'orchestra', key: 'jobs' } as const, [] as Job[])
213const presets = atom({ plugin: 'orchestra', key: 'presets' } as const, BUILTIN_PRESETS)
214const ui = atom({ plugin: 'orchestra', key: 'ui' } as const, { view: 'home', showSettings: false, showPrompt: false } as Ui)
215const guard = atom({ plugin: 'orchestra', key: 'guard' } as const, true)
216const autoAdvance = atom({ plugin: 'orchestra', key: 'autoAdvance' } as const, false)
217const accounts = atom({ plugin: 'orchestra', key: 'accounts' } as const, {
218 claude: { status: 'checking', detail: '' },
219 codex: { status: 'checking', detail: '' },
220 cursor: { status: 'checking', detail: '' },
221 gemini: { status: 'checking', detail: '' },
222} as Record<Provider, Account>)
223const newTask = atom({ plugin: 'orchestra', key: 'newTask' } as const, { title: '', who: 'opus:write' })
224
225// The registered read-only agent type, once session.start has defined it.
226let readerAgent: string | undefined
227// The session's directory: jobs belong to the repository they ran in.
228let sessionCwd = ''
229const jobsKey = () => `${KEYS.jobs}:${sessionCwd}`
230// Jobs whose next pipeline step has been claimed, so two Continue presses launch it once.
231const advancing = new Set<string>()
232
233// Live child processes, so Stop can end them. A reload kills them anyway.
234const children = new Map<string, () => void>()
235
236const modelLabel = (model: ModelId) => MODELS.find(m => m.value === model)?.label ?? model
237const stageLabel = (stage: Stage) => STAGES.find(s => s.value === stage)?.label ?? stage
238const isClaude = (model: ModelId) => model in CLAUDE_IDS
239const accessFor = (stage: Stage): Access => (stage === 'implement' || stage === 'mechanical' ? 'write' : 'read')
240const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
241const short = (text: string, n: number) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
242const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
243const modelOf = (p: Preset) => p.model ?? DEFAULT_ROUTING[p.stage]
244
245// The task table is the routing: a stage runs on the model its task row picks.
246const routesOf = (ps: readonly Preset[]): Record<Stage, ModelId> => {
247 const out = { ...DEFAULT_ROUTING }
248 for (const s of STAGES) {
249 const p = ps.find(x => x.id === STAGE_TASK[s.value])
250 if (p) out[s.value] = modelOf(p)
251 }
252 return out
253}
254
255// A stage's model and access, both as its task row sets them.
256const routeFor = (ps: readonly Preset[], stage: Stage): { model: ModelId; access: Access } => {
257 const p = ps.find(x => x.id === STAGE_TASK[stage])
258 return p ? { model: modelOf(p), access: p.access } : { model: DEFAULT_ROUTING[stage], access: accessFor(stage) }
259}
260
261// "Who" folds access into the model: `astra:write`, `fable`.
262const WHO = MODELS.flatMap(m =>
263 (['read', 'write'] as const).map(access => ({ value: `${m.value}:${access}`, model: m.value, access })),
264)
265
266const whoValue = (model: ModelId, access: Access) => `${model}:${access}`
267const whoLabel = (model: ModelId, access: Access) => `${modelLabel(model)} · ${access === 'write' ? 'edits files' : 'read-only'}`
268
269// The prompt never lands where a CLI could read it as a flag: stdin for Codex, after `--`
270// for Cursor, and fused into `--prompt=` for Gemini.
271function cliCommand(model: ModelId, access: Access, prompt: string): { argv: string[]; input?: string } {
272 if (model === 'astra') {
273 return { argv: ['codex', 'exec', '--skip-git-repo-check', '-s', access === 'write' ? 'workspace-write' : 'read-only', '-m', CFG.codexModel, '-'], input: prompt }
274 }
275 if (model === 'composer') {
276 const mode = access === 'write' ? ['--force'] : ['--mode', 'ask']
277 return { argv: ['cursor-agent', '-p', '--trust', ...mode, '--model', CFG.cursorModel, '--output-format', 'text', '--', prompt] }
278 }
279
280 return { argv: ['gemini', '--skip-trust', '--approval-mode', access === 'write' ? 'yolo' : 'plan', `--prompt=${prompt}`] }
281}
282
283// CLIs run with the PATH of the user's login shell, read once, so they resolve as in a terminal.
284// The argv itself never passes through a shell.
285let loginPath: Promise<string | undefined> | undefined
286function readLoginPath($: EngineInterface) {
287 loginPath ??= (async () => {
288 const shell = (await $.process.run(['/bin/sh', '-c', 'printf %s "$SHELL"']).catch(() => ({ stdout: '' }))).stdout.trim()
289 const name = shell.split('/').pop() ?? ''
290 const script = name === 'fish' ? 'string join : $PATH' : ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(name) ? 'printf %s "$PATH"' : undefined
291 if (!script) return undefined
292 const { exitCode, stdout } = await $.process.run([shell, '-lc', script], { timeoutMs: 10000 }).catch(() => ({ exitCode: 1, stdout: '' }))
293 return exitCode === 0 && stdout.trim() ? stdout.trim() : undefined
294 })()
295 return loginPath
296}
297const withPath = async ($: EngineInterface, argv: readonly string[]) => {
298 const path = await readLoginPath($)
299 return path ? ['/usr/bin/env', `PATH=${path}`, ...argv] : [...argv]
300}
301
302// Store writes are best effort: a failed save must never break a dispatch.
303const persist = ($: EngineInterface, key: string, value: unknown) =>
304 $.store.set(key, value as never).catch(err => $.ui.log(`orchestra: could not save ${key}: ${String(err)}`, { to: 'debug' }))
305
306const saveJobs = async ($: EngineInterface) => {
307 const list = await read($, jobs)
308 await persist($, jobsKey(), list.slice(0, 40).map(j => ({ ...j, output: j.output.slice(-8000) })))
309}
310
311const savePresets = async ($: EngineInterface, fn: (l: Preset[]) => Preset[]) => {
312 await update($, presets, fn)
313 const after = await read($, presets)
314 await persist($, KEYS.presets, after)
315 if (routingNote(after) !== lastNote) await noteRouting($, after)
316}
317
318// The table reaches Claude as a note in the conversation: the system context is closed to user mods.
319let lastNote = ''
320async function noteRouting($: EngineInterface, ps: readonly Preset[]) {
321 if (!CFG.shareRouting) return
322 const text = routingNote(ps)
323 lastNote = text
324 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }).catch(err =>
325 $.ui.log(`orchestra: could not share the routing table: ${String(err)}`, { to: 'debug' }),
326 )
327}
328
329const patchJob = ($: EngineInterface, id: string, fn: (job: Job) => Job) =>
330 update($, jobs, list => list.map(job => (job.id === id ? fn(job) : job)))
331
332const appendOutput = (output: string, text: string) => (output + text).slice(-60000)
333
334const nextStageOf = (job: Job) => job.pipeline?.stages[(job.step ?? 0) + 1]
335const isPaused = (job: Job) => job.status === 'done' && !!nextStageOf(job) && !job.isAdvanced
336
337// ---------- Accounts ----------
338
339const setAccount = ($: EngineInterface, id: Provider, account: Account) => update($, accounts, a => ({ ...a, [id]: account }))
340
341const CLI_OF: Record<Provider, string> = { claude: 'claude', codex: 'codex', cursor: 'cursor-agent', gemini: 'gemini' }
342
343async function checkAccount($: EngineInterface, id: Provider): Promise<Account> {
344 const found = await $.process.run(await withPath($, ['which', CLI_OF[id]]), { timeoutMs: 10000 }).catch(() => ({ exitCode: 1 }))
345 if (found.exitCode !== 0) return { status: 'missing', detail: `${CLI_OF[id]} is not installed` }
346 try {
347 if (id === 'claude') {
348 const { stdout } = await $.process.run(await withPath($, ['claude', 'auth', 'status', '--json']), { timeoutMs: 20000 })
349 const s = JSON.parse(stdout) as { loggedIn?: boolean; email?: string; orgName?: string }
350 return s.loggedIn ? { status: 'in', detail: [s.email, s.orgName].filter(Boolean).join(' · ') } : { status: 'out', detail: 'not signed in' }
351 }
352 if (id === 'codex') {
353 const { stdout, stderr } = await $.process.run(await withPath($, ['codex', 'login', 'status']), { timeoutMs: 20000 })
354 const text = oneLine(`${stdout} ${stderr}`)
355 return /logged in/i.test(text) && !/not logged in/i.test(text) ? { status: 'in', detail: text.replace(/^Logged in /i, '') } : { status: 'out', detail: 'not signed in' }
356 }
357 if (id === 'cursor') {
358 const { stdout, stderr } = await $.process.run(await withPath($, ['cursor-agent', 'status']), { timeoutMs: 20000 })
359 const text = oneLine(`${stdout} ${stderr}`).replace(/\x1b\[[0-9;]*m/g, '')
360 return /logged in/i.test(text) && !/not logged in/i.test(text) ? { status: 'in', detail: short(text.replace(/^✓?\s*Logged in as\s*/i, ''), 60) } : { status: 'out', detail: 'not signed in' }
361 }
362 // Google sign-in records the account; an API key or Vertex setup counts as signed in too.
363 const { stdout: env } = await $.process.run(['/bin/sh', '-c', 'printf %s "${GEMINI_API_KEY:+key}${GOOGLE_API_KEY:+key}"'])
364 if (env) return { status: 'in', detail: 'API key' }
365 const settings = await $.process.run(['/bin/sh', '-c', 'cat "$HOME/.gemini/settings.json" 2>/dev/null'], { timeoutMs: 5000 })
366 const selected = (JSON.parse(settings.stdout || '{}') as { security?: { auth?: { selectedType?: string } } }).security?.auth?.selectedType
367 if (selected && selected !== 'oauth-personal') return { status: 'in', detail: selected }
368 // A Google sign-in counts only while its token file exists; the account list outlives it.
369 const creds = await $.process.run(['/bin/sh', '-c', 'test -s "$HOME/.gemini/oauth_creds.json"'], { timeoutMs: 5000 })
370 if (creds.exitCode !== 0) return { status: 'out', detail: 'not signed in (or the sign-in expired)' }
371 const { stdout } = await $.process.run(['/bin/sh', '-c', 'cat "$HOME/.gemini/google_accounts.json" 2>/dev/null'], { timeoutMs: 5000 })
372 const active = (JSON.parse(stdout || '{}') as { active?: string | null }).active
373 return { status: 'in', detail: active || 'Google account' }
374 } catch {
375 return { status: 'out', detail: 'not signed in, or the CLI is missing' }
376 }
377}
378
379async function refreshAccounts($: EngineInterface) {
380 await Promise.all(PROVIDERS.map(async p => setAccount($, p.id, await checkAccount($, p.id))))
381}
382
383async function signIn($: EngineInterface, id: Provider) {
384 if (id === 'gemini') {
385 // Gemini signs in from its own interactive screen, so it needs a real terminal.
386 const { stdout } = await $.process.run(['uname']).catch(() => ({ stdout: '' }))
387 if (stdout.trim() === 'Darwin') {
388 await setAccount($, id, { status: 'busy', detail: 'opened a Terminal window: pick "Login with Google", then come back' })
389 await $.process
390 .run(['osascript', '-e', 'tell application "Terminal" to activate', '-e', 'tell application "Terminal" to do script "gemini"'])
391 .catch(err => $.ui.toast(`Could not open Terminal: ${String(err)}`))
392 } else {
393 await setAccount($, id, { status: 'busy', detail: 'run `gemini` in a terminal, pick "Login with Google", then press refresh' })
394 }
395 return
396 }
397 const argv = id === 'claude' ? ['claude', 'auth', 'login'] : id === 'codex' ? ['codex', 'login'] : ['cursor-agent', 'login']
398 await setAccount($, id, { status: 'busy', detail: 'opening your browser…' })
399 void (async () => {
400 const stream = $.process.spawn({ argv: await withPath($, argv) })
401 try {
402 for await (const { text } of stream) {
403 // Surface the sign-in link in case the browser did not open.
404 const url = text.match(/https?:\/\/\S+/)?.[0]
405 if (url) await setAccount($, id, { status: 'busy', detail: `finish in the browser · ${short(url, 70)}` })
406 }
407 } catch (err) {
408 $.ui.toast(`Sign-in failed: ${String(err)}`)
409 }
410 const account = await checkAccount($, id)
411 await setAccount($, id, account)
412 $.ui.toast(account.status === 'in' ? `${PROVIDERS.find(p => p.id === id)?.name} signed in` : 'Not signed in yet')
413 })()
414}
415
416async function signOut($: EngineInterface, id: Provider) {
417 const argv = id === 'codex' ? ['codex', 'logout'] : id === 'cursor' ? ['cursor-agent', 'logout'] : undefined
418 if (!argv) return
419 await setAccount($, id, { status: 'busy', detail: 'signing out…' })
420 await $.process.run(await withPath($, argv), { timeoutMs: 20000 }).catch(() => undefined)
421 await setAccount($, id, await checkAccount($, id))
422}
423
424// ---------- Jobs ----------
425
426type Dispatch = {
427 title: string
428 stage: Stage
429 model: ModelId
430 access: Access
431 task: string
432 prompt: string
433 parentId?: string
434 pipeline?: Pipeline
435 step?: number
436}
437
438async function dispatch($: EngineInterface, input: Dispatch): Promise<string> {
439 // Only the dispatch fields: a retry built from an old job must not inherit its run state.
440 const { title, stage, model, access, task, prompt, parentId, pipeline, step } = input
441 const d: Dispatch = { title, stage, model, access, task, prompt, parentId, pipeline, step }
442 const job: Job = { ...d, id: newId(), status: 'running', startedAt: Date.now(), output: '', activity: [] }
443 await update($, jobs, list => [job, ...list].slice(0, 60))
444 await saveJobs($)
445 $.ui.toast(`${short(d.title, 40)} → ${modelLabel(d.model)}`)
446 const account = (await read($, accounts))[PROVIDER_OF[d.model]]
447 if (account.status === 'missing') $.ui.toast(`${CLI_OF[PROVIDER_OF[d.model]]} is not installed, so this will fail`)
448 else if (account.status === 'out') $.ui.toast(`${PROVIDERS.find(p => p.id === PROVIDER_OF[d.model])?.name} looks signed out; sign in at the top if this fails`)
449
450 if (isClaude(d.model)) {
451 // Read-only Claude jobs run as an agent with the editing tools removed and its own
452 // permission mode, so they cannot inherit an edit-accepting parent session.
453 if (d.access === 'read' && !readerAgent) {
454 await patchJob($, job.id, j => ({ ...j, status: 'failed', endedAt: Date.now(), output: 'The read-only agent is not available in this session, so this read-only job did not start.' }))
455 await finish($, job.id)
456 return job.id
457 }
458 const started = await $.agent.spawn({
459 prompt: d.prompt,
460 description: `[${modelLabel(d.model).split(' ')[0]!.toUpperCase()}] ${short(d.title, 40)}`,
461 subagentType: d.access === 'read' && readerAgent ? readerAgent : 'general-purpose',
462 model: CLAUDE_IDS[d.model],
463 })
464 if (started.deny !== undefined || !started.agentId) {
465 await patchJob($, job.id, j => ({ ...j, status: 'failed', endedAt: Date.now(), output: `Spawn refused: ${String(started.deny)}` }))
466 await finish($, job.id)
467 } else {
468 const agentId = started.agentId
469 await patchJob($, job.id, j => ({ ...j, agentId }))
470 }
471
472 return job.id
473 }
474
475 const { argv, input: stdin } = cliCommand(d.model, d.access, d.prompt)
476 void (async () => {
477 const stream = $.process.spawn({ argv: await withPath($, argv), input: stdin })
478 children.set(job.id, () => void stream.return(undefined as never))
479 let isFirst = true
480 try {
481 while (true) {
482 const step = await stream.next()
483 if (step.done) {
484 const { code, signal } = step.value
485 await patchJob($, job.id, j => ({
486 ...j,
487 status: j.status === 'stopped' ? 'stopped' : code === 0 ? 'done' : 'failed',
488 endedAt: Date.now(),
489 output: code === 0 ? j.output : appendOutput(j.output, `\n[exit ${code ?? signal}]`),
490 }))
491 break
492 }
493 await patchJob($, job.id, j => ({ ...j, output: appendOutput(j.output, step.value.text) }))
494 if (isFirst) {
495 isFirst = false
496 // Follow a growing output when its job is the one on screen.
497 const u = await read($, ui)
498 if (u.view === 'job' && u.jobId === job.id) void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
499 }
500 }
501 } catch (err) {
502 await patchJob($, job.id, j => ({
503 ...j,
504 status: j.status === 'stopped' ? 'stopped' : 'failed',
505 endedAt: j.endedAt ?? Date.now(),
506 output: appendOutput(j.output, `\n${String(err)}`),
507 }))
508 } finally {
509 children.delete(job.id)
510 }
511 await finish($, job.id)
512 })()
513
514 return job.id
515}
516
517// Runs once per job when it ends: toast, persist, and advance a pipeline.
518async function finish($: EngineInterface, id: string) {
519 const job = (await read($, jobs)).find(j => j.id === id)
520 if (!job) return
521 await saveJobs($)
522 const next = nextStageOf(job)
523 if (job.status === 'done' && next && (await read($, autoAdvance))) {
524 await advance($, job)
525 return
526 }
527 if (job.status === 'done' && next && job.pipeline) {
528 $.ui.toast(`${stageLabel(job.stage)} done · step ${(job.step ?? 0) + 2} of ${job.pipeline.stages.length} waits for you`)
529 } else if (job.status === 'done') {
530 $.ui.toast(`${modelLabel(job.model)} finished: ${short(job.title, 40)}`)
531 } else if (job.status === 'failed') {
532 $.ui.toast(`${modelLabel(job.model)} failed: ${short(job.title, 40)}`)
533 }
534}
535
536async function advance($: EngineInterface, job: Job) {
537 const pipeline = job.pipeline
538 const step = (job.step ?? 0) + 1
539 const stage = pipeline?.stages[step]
540 if (!pipeline || !stage || advancing.has(job.id)) return
541 // Claim against the current state, not the caller's snapshot.
542 const current = (await read($, jobs)).find(j => j.id === job.id)
543 if (!current || current.isAdvanced) return
544 advancing.add(job.id)
545 await patchJob($, job.id, j => ({ ...j, isAdvanced: true }))
546 const route = routeFor(await read($, presets), stage)
547
548 return dispatch($, {
549 title: `${stageLabel(stage)}: ${short(pipeline.goal, 50)}`,
550 stage,
551 model: route.model,
552 access: route.access,
553 task: pipeline.goal,
554 prompt: HANDOFF[stage](pipeline.goal, job.output.trim()),
555 parentId: job.id,
556 pipeline,
557 step,
558 })
559}
560
561async function launch($: EngineInterface, text: string) {
562 const d = await read($, draft)
563 const preset = (await read($, presets)).find(p => p.id === d.presetId)
564 const input = text.trim()
565 if (!preset) {
566 $.ui.toast('Pick a task first')
567 return
568 }
569 if (!input && !OPTIONAL_INPUT.has(preset.id)) {
570 $.ui.toast('Type what you want first')
571 return
572 }
573 const prompt = preset.template.includes('{input}') ? preset.template.replaceAll('{input}', () => input) : `${preset.template}\n\n${input}`.trim()
574 const title = input ? short(`${preset.title.split(' (')[0]}: ${input}`, 80) : preset.title
575 const pipeline = preset.then ? { id: newId(), stages: [preset.stage, ...preset.then], goal: input } : undefined
576 const routes = routesOf(await read($, presets))
577 await update($, draft, x => ({ ...x, input: '' }))
578 await dispatch($, {
579 title,
580 stage: preset.stage,
581 model: pipeline ? routes[preset.stage] : modelOf(preset),
582 access: pipeline ? routeFor(await read($, presets), preset.stage).access : preset.access,
583 task: input,
584 prompt: pipeline ? HANDOFF[preset.stage](input, '') : prompt,
585 pipeline,
586 step: pipeline ? 0 : undefined,
587 })
588}
589
590async function pickTask($: EngineInterface, id: string) {
591 const preset = (await read($, presets)).find(p => p.id === id)
592 if (!preset) return
593 await update($, draft, d => ({ ...d, presetId: id, stage: preset.stage, model: modelOf(preset), access: preset.access }))
594 await $.ui.focus({ requestId: PANE, key: 'input' }).catch(() => undefined)
595}
596
597async function addTask($: EngineInterface) {
598 const t = await read($, newTask)
599 const title = oneLine(t.title)
600 if (!title) {
601 $.ui.toast('Name the task first')
602 return
603 }
604 const w = WHO.find(x => x.value === t.who) ?? { model: "opus" as ModelId, access: "write" as Access }
605 const p: Preset = { id: newId(), title, stage: 'research', model: w.model, access: w.access, template: `${title}: {input}` }
606 await savePresets($, l => [...l, p])
607 await update($, newTask, x => ({ ...x, title: '' }))
608 await update($, draft, d => ({ ...d, presetId: p.id, stage: p.stage, model: w.model, access: w.access }))
609 $.ui.toast(`Added "${title}" on ${modelLabel(w.model)}`)
610}
611
612// Next + Reply: one Select and one Input replace rerun, rerun-on, hand off and follow up.
613async function reply($: EngineInterface, job: Job, next: string, text: string) {
614 const routes = routesOf(await read($, presets))
615 const extra = text.trim()
616 const output = job.output.trim()
617 const followUp = (model: ModelId) =>
618 dispatch($, {
619 title: short(`↳ ${extra}`, 80),
620 stage: job.stage,
621 model,
622 access: job.access,
623 task: extra,
624 prompt: `Earlier task:\n${job.prompt}\n\nThe answer:\n${output}\n\nFollow-up:\n${extra}`,
625 parentId: job.id,
626 })
627
628 if (next.startsWith('stage:')) {
629 const stage = next.slice(6) as Stage
630 const base = HANDOFF[stage](job.task || job.title, output)
631 return dispatch($, {
632 title: short(`${stageLabel(stage)}: ${job.task || job.title}`, 80),
633 stage,
634 model: routeFor(await read($, presets), stage).model,
635 access: routeFor(await read($, presets), stage).access,
636 task: job.task,
637 prompt: extra ? `${base}\n\nAlso: ${extra}` : base,
638 parentId: job.id,
639 })
640 }
641 if (next.startsWith('model:')) {
642 const model = next.slice(6) as ModelId
643 if (extra) return followUp(model)
644 return dispatch($, { ...job, model, title: short(`${job.title} (on ${modelLabel(model)})`, 80), parentId: job.id, pipeline: undefined, step: undefined })
645 }
646 if (extra) return followUp(job.model)
647
648 return dispatch($, { ...job, parentId: job.parentId, pipeline: job.pipeline, step: job.step })
649}
650
651async function sendToClaude($: EngineInterface, job: Job) {
652 const text = [
653 `Result from the orchestra pane: ${job.title} by ${modelLabel(job.model)} (${job.status}).`,
654 '',
655 job.output.trim() || '(no output)',
656 ].join('\n')
657 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
658 await patchJob($, job.id, j => ({ ...j, isSent: true }))
659 $.ui.toast('Added to the conversation')
660}
661
662const elapsed = (job: Job) => {
663 const s = Math.max(0, Math.round(((job.endedAt ?? Date.now()) - job.startedAt) / 1000))
664 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`
665}
666
667type Shown = Job['status'] | 'paused'
668const shownStatus = (job: Job): Shown => (isPaused(job) ? 'paused' : job.status)
669const MARK: Record<Shown, string> = { paused: '‖', running: '●', done: '✓', failed: '✗', stopped: '■', lost: '?' }
670const COLOR: Record<Shown, string> = { paused: 'magenta', running: 'yellow', done: 'green', failed: 'red', stopped: 'gray', lost: 'gray' }
671const RANK: Record<Shown, number> = { paused: 0, running: 1, done: 2, failed: 2, stopped: 2, lost: 2 }
672
673const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Orchestra', focus: true, holdToasts: true, rows: 60, columns: 110 })
674
675const openJob = async ($: EngineInterface, id: string) => {
676 await update($, ui, x => ({ ...x, view: 'job' as const, jobId: id, next: 'again', showPrompt: false }))
677 const job = (await read($, jobs)).find(j => j.id === id)
678 if (job && isPaused(job)) await $.ui.focus({ requestId: PANE, key: 'continue' }).catch(() => undefined)
679}
680
681function routingNote(ps: readonly Preset[]) {
682 return [
683 'Orchestra routing (set by the user in the /orchestrate pane; it overrides the routing table in CLAUDE.md, follow it when you delegate):',
684 ...ps.filter(p => !p.then).map(p => `- ${p.title}: ${whoLabel(modelOf(p), p.access)}`),
685 `How to reach each: ${modelLabel('fable')} = Agent tool with model ${CFG.fableModel}; ${modelLabel('opus')} = Agent tool with model ${CFG.opusModel}; ${modelLabel('astra')} = codex exec -m ${CFG.codexModel}; ${modelLabel('composer')} = cursor-agent -p --model ${CFG.cursorModel}; ${modelLabel('gemini')} = gemini -y.`,
686 ].join('\n')
687}
688
689export const register: Register = (on, options: PluginOptions) => {
690 for (const key of Object.keys(CFG) as (keyof typeof CFG)[]) {
691 const value = options[key]
692 if (typeof value === typeof CFG[key]) (CFG as Record<string, unknown>)[key] = value
693 }
694 CLAUDE_IDS.fable = CFG.fableModel
695 CLAUDE_IDS.opus = CFG.opusModel
696
697 on('session.start', async ($, e, next) => {
698 sessionCwd = e.cwd
699 const [savedPresets, savedJobs, savedGuard, savedAuto] = await Promise.all(
700 [KEYS.presets, jobsKey(), KEYS.guard, KEYS.autoAdvance].map(key => $.store.get(key).catch(() => undefined)),
701 )
702 if (Array.isArray(savedPresets) && savedPresets.length) await update($, presets, () => savedPresets as Preset[])
703 if (Array.isArray(savedJobs)) {
704 // Anything still running belonged to a process that is gone now.
705 const restored = (savedJobs as Job[]).map(j => (j.status === 'running' ? { ...j, status: 'lost' as const, endedAt: j.endedAt ?? Date.now() } : j))
706 await update($, jobs, () => restored)
707 }
708 await update($, guard, () => (typeof savedGuard === 'boolean' ? savedGuard : CFG.guard))
709 if (typeof savedAuto === 'boolean') await update($, autoAdvance, () => savedAuto)
710 const plan = (await read($, presets)).find(p => p.id === 'plan')
711 if (plan) await update($, draft, d => ({ ...d, model: modelOf(plan), access: plan.access }))
712
713 await $.command.register({
714 name: 'orchestrate',
715 description: 'Your tasks, the model each one runs on, and your sign-ins. "/orchestrate <stage>: <task>" sends one straight away',
716 })
717
718 void refreshAccounts($)
719 const started = await next(e)
720 readerAgent = await $.agent
721 .register({
722 name: 'reader',
723 description: 'Read-only worker the Orchestra pane uses for read-only tasks',
724 prompt: 'You are a read-only worker. Investigate, review or plan, and report what you find. Never create, edit or delete files, and never run commands that change the repository or the system.',
725 disallowedTools: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'],
726 permissionMode: 'default',
727 })
728 .then(r => r.agent)
729 .catch(err => {
730 $.ui.log(`orchestra: read-only agent unavailable: ${String(err)}`, { to: 'debug' })
731 return undefined
732 })
733 await noteRouting($, await read($, presets))
734
735 // Keep elapsed times and the status line current while something runs.
736 $.clock.every(1000, () => {
737 void read($, jobs).then(list => {
738 const running = list.filter(j => j.status === 'running').length
739 const paused = list.filter(isPaused).length
740 const parts = [running && `${running} running`, paused && `${paused} paused`].filter(Boolean)
741 $.ui.status(parts.length ? `orchestra: ${parts.join(' · ')}` : undefined)
742 if (running) $.ui.invalidate('ui.render')
743 })
744 })
745
746 return started
747 })
748
749 on('command.run', { command: 'orchestrate' }, async ($, e) => {
750 const match = e.args.trim().match(/^(\w+):\s*([\s\S]+)$/)
751 const stage = STAGES.find(s => s.value === match?.[1]?.toLowerCase())?.value
752 const task = match?.[2]
753 if (stage && task) {
754 const { model, access } = routeFor(await read($, presets), stage)
755 const id = await dispatch($, { title: short(`${stageLabel(stage)}: ${task}`, 80), stage, model, access, task, prompt: task })
756 await update($, ui, x => ({ ...x, view: 'job' as const, jobId: id, next: 'again' }))
757 await openPane($)
758
759 return { text: `Sent ${stageLabel(stage)} to ${modelLabel(model)}.` }
760 }
761 await update($, ui, x => ({ ...x, view: 'home' as const }))
762 void refreshAccounts($)
763 await openPane($)
764
765 return { text: 'Orchestra opened.' }
766 })
767
768 // The read-only agent is the pane's own; Claude's Agent tool does not offer it.
769 on('agent.offer', async ($, e, next) => (readerAgent && e.agent === readerAgent ? { isOffered: false } : next(e)))
770
771 // Enforce the routing table on subagents spawned outside the pane.
772 on('agent.spawn', async ($, e, next) => {
773 if (e.model && BANNED.test(e.model) && (await read($, guard))) {
774 $.ui.toast(`${e.model} is off the routing table, running as Opus 5.5`)
775
776 return next({ ...e, model: CFG.opusModel })
777 }
778
779 return next(e)
780 })
781
782 // Live activity for Claude jobs: each tool their subagent calls.
783 on('tool.call', async ($, e, next) => {
784 if (e.agentId) {
785 const job = (await read($, jobs)).find(j => j.agentId === e.agentId && j.status === 'running')
786 if (job) await patchJob($, job.id, j => ({ ...j, activity: [...j.activity, e.tool].slice(-30) }))
787 }
788
789 return next(e)
790 })
791
792 // A Claude job's answer is its subagent's turn.complete.
793 on('turn.complete', async ($, e, next) => {
794 const result = await next(e)
795 if (e.agentId) {
796 const job = (await read($, jobs)).find(j => j.agentId === e.agentId && j.status === 'running')
797 if (job) {
798 await patchJob($, job.id, j => ({
799 ...j,
800 status: e.isAborted || e.reason === 'error' ? 'failed' : 'done',
801 endedAt: Date.now(),
802 output: result.text || e.answer,
803 }))
804 await finish($, job.id)
805 }
806 }
807
808 return result
809 })
810
811 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
812 if (e.surface === 'mobile') {
813 const { Text } = $.ui.resolve(e)
814 return <Text>Orchestra needs the terminal or desktop app.</Text>
815 }
816 const { Box, Text, Button, Input, Select, Markdown } = $.ui.resolve(e)
817 const [d, list, ps, u, g, auto, acc, nt] = await Promise.all([
818 read($, draft),
819 read($, jobs),
820 read($, presets),
821 read($, ui),
822 read($, guard),
823 read($, autoAdvance),
824 read($, accounts),
825 read($, newTask),
826 ])
827 const routes = routesOf(ps)
828 const width = e.props.bodyColumns
829 const running = list.filter(j => j.status === 'running').length
830 const paused = list.filter(isPaused).length
831 const heading = (text: string) => <Text bold color="magenta">{text}</Text>
832
833 // ---------- Job ----------
834 if (u.view === 'job') {
835 const back = <Button key="back" hotkey="b" plain label="back" onPress={() => void update($, ui, x => ({ ...x, view: 'home' as const }))} />
836 const job = list.find(j => j.id === u.jobId)
837 if (!job) {
838 return (
839 <Box flexDirection="column" gap={1}>
840 {back}
841 <Text dimColor>That job was dismissed.</Text>
842 </Box>
843 )
844 }
845 const shown = shownStatus(job)
846 const isRunning = job.status === 'running'
847 const nextStage = nextStageOf(job)
848 const parent = job.parentId ? list.find(j => j.id === job.parentId) : undefined
849 const kids = list.filter(j => j.parentId === job.id)
850 const output = job.output.trim()
851 const next = u.next ?? 'again'
852 const target = next.startsWith('model:') ? (next.slice(6) as ModelId) : undefined
853 const handTo = next.startsWith('stage:') ? (next.slice(6) as Stage) : undefined
854 const replyHint = handTo
855 ? `Enter hands the result to ${modelLabel(routes[handTo])}, or add instructions`
856 : target
857 ? `Enter reruns on ${modelLabel(target)}, or type a follow-up`
858 : job.status === 'failed'
859 ? 'Enter retries, or type what to change'
860 : 'Enter reruns the same prompt, or type a follow-up'
861 const meta = [
862 modelLabel(job.model),
863 job.access === 'write' ? 'edits files' : 'read-only',
864 elapsed(job),
865 job.pipeline ? `step ${(job.step ?? 0) + 1} of ${job.pipeline.stages.length}` : '',
866 ]
867 .filter(Boolean)
868 .join(' · ')
869
870 return (
871 <Box flexDirection="column" gap={1}>
872 {back}
873 <Box flexDirection="column">
874 <Text wrap="truncate-end">
875 <Text color={COLOR[shown]} bold>{`${MARK[shown]} ${shown} `}</Text>
876 <Text dimColor>{meta}</Text>
877 </Text>
878 <Text bold wrap="wrap">{job.title}</Text>
879 {job.status === 'lost' && <Text dimColor>The session restarted while this ran.</Text>}
880 {isRunning && job.activity.length > 0 && <Text dimColor wrap="truncate-start">{job.activity.slice(-10).join(' → ')}</Text>}
881 {parent && (
882 <Button
883 key="parent"
884 plain
885 dimColor
886 label={`↑ ${short(parent.title, 50)} · ${modelLabel(parent.model)} · ${shownStatus(parent)}`}
887 onPress={() => void openJob($, parent.id)}
888 />
889 )}
890 {kids.map(k => (
891 <Button
892 key={`child:${k.id}`}
893 plain
894 dimColor
895 label={`↓ ${MARK[shownStatus(k)]} ${short(k.title, 50)} · ${modelLabel(k.model)}`}
896 onPress={() => void openJob($, k.id)}
897 />
898 ))}
899 </Box>
900
901 <Box gap={3} flexWrap="wrap">
902 {isRunning && children.has(job.id) && (
903 <Button
904 key="stop"
905 hotkey="x"
906 plain
907 label="stop"
908 onPress={() => {
909 void patchJob($, job.id, j => ({ ...j, status: 'stopped', endedAt: Date.now() }))
910 children.get(job.id)?.()
911 $.ui.toast('Stopped')
912 }}
913 />
914 )}
915 {isPaused(job) && nextStage && (
916 <Button
917 key="continue"
918 hotkey="c"
919 variant="primary"
920 label={`Continue → ${stageLabel(nextStage)} (${modelLabel(routes[nextStage])})`}
921 onPress={() => void advance($, job).then(id => (id ? openJob($, id) : undefined))}
922 />
923 )}
924 {!isRunning && !job.isSent && <Button key="send" hotkey="s" plain label="send to chat" onPress={() => void sendToClaude($, job)} />}
925 {!isRunning && job.isSent && <Text dimColor>sent to chat</Text>}
926 {output && (
927 <Button
928 key="copy"
929 plain
930 label="copy"
931 onPress={press => void $.ui.copy({ text: output, surface: press.surface }).then(() => $.ui.toast('Copied'))}
932 />
933 )}
934 {!isRunning && (
935 <Button
936 key="dismiss"
937 plain
938 dimColor
939 label="dismiss"
940 onPress={() => {
941 void update($, jobs, l => l.filter(j => j.id !== job.id)).then(() => saveJobs($))
942 void update($, ui, x => ({ ...x, view: 'home' as const }))
943 }}
944 />
945 )}
946 </Box>
947
948 {isRunning ? (
949 job.agentId ? (
950 <Input
951 key="steer"
952 placeholder="Message the running agent"
953 submitLabel="send"
954 onSubmit={value => {
955 if (!value.trim() || !job.agentId) return
956 void $.session
957 .append({ agentId: job.agentId, message: { type: 'user', content: [{ type: 'text', text: value.trim() }] } })
958 .then(() => $.ui.toast('Sent to the agent'))
959 .catch(err => $.ui.toast(`Could not send: ${String(err)}`))
960 }}
961 />
962 ) : null
963 ) : (
964 <Box flexDirection="column">
965 <Select
966 key="next"
967 label="Next "
968 value={next}
969 options={[
970 { value: 'again', label: `Ask ${modelLabel(job.model)} again` },
971 ...MODELS.filter(m => m.value !== job.model).map(m => ({ value: `model:${m.value}`, label: `Ask ${m.label} instead` })),
972 ...STAGES.map(s => ({ value: `stage:${s.value}`, label: `Hand to ${s.label} (${modelLabel(routes[s.value])})` })),
973 ]}
974 onSelect={value => void update($, ui, x => ({ ...x, next: value }))}
975 />
976 <Input
977 key="reply"
978 placeholder={replyHint}
979 submitLabel={handTo ? 'hand off' : 'send'}
980 onSubmit={value => void reply($, job, next, value).then(id => openJob($, id))}
981 />
982 </Box>
983 )}
984
985 <Box flexDirection="column">
986 <Text dimColor>{`── output ${'─'.repeat(Math.max(0, width - 10))}`}</Text>
987 {!output && <Text dimColor>{isRunning ? 'Waiting for the first output…' : 'No output.'}</Text>}
988 {output && (!isRunning && isClaude(job.model) ? <Markdown text={output} /> : <Text wrap="wrap">{output}</Text>)}
989 </Box>
990 <Button
991 key="show-prompt"
992 plain
993 dimColor
994 label={u.showPrompt ? 'hide prompt' : 'show prompt'}
995 onPress={() => void update($, ui, x => ({ ...x, showPrompt: !x.showPrompt }))}
996 />
997 {u.showPrompt && <Text dimColor wrap="wrap">{job.prompt}</Text>}
998 </Box>
999 )
1000 }
1001
1002 // ---------- Home ----------
1003 const selected = ps.find(p => p.id === d.presetId)
1004 // Models whose CLI is not installed drop out of the pickers (the current pick stays visible).
1005 const whoOptions = (current: string) =>
1006 WHO.filter(w => acc[PROVIDER_OF[w.model]].status !== 'missing' || w.value === current).map(w => ({ value: w.value, label: whoLabel(w.model, w.access) }))
1007 const nameWidth = Math.min(40, Math.max(24, Math.floor(width * 0.42)))
1008 const sorted = [...list].sort((a, b) => RANK[shownStatus(a)] - RANK[shownStatus(b)] || b.startedAt - a.startedAt)
1009 const selectedModel = selected?.then
1010 ? [selected.stage, ...selected.then].map(s => modelLabel(routes[s]).split(' ')[0]).join(' → ')
1011 : selected
1012 ? whoLabel(modelOf(selected), selected.access)
1013 : ''
1014 const jobsHeader = [running && `${running} running`, paused && `${paused} paused`].filter(Boolean).join(' · ')
1015
1016 return (
1017 <Box flexDirection="column" gap={1}>
1018 <Box justifyContent="space-between">
1019 <Text bold>Orchestra</Text>
1020 <Text dimColor>{e.props.isFocused ? 'Tab moves · Enter picks' : 'ctrl+x tab to focus'}</Text>
1021 </Box>
1022
1023 {/* Accounts */}
1024 <Box flexDirection="column">
1025 <Box justifyContent="space-between">
1026 {heading('Accounts')}
1027 <Button key="refresh-accounts" plain dimColor label="refresh" onPress={() => void refreshAccounts($)} />
1028 </Box>
1029 {PROVIDERS.map(p => {
1030 const a = acc[p.id]
1031 const mark = a.status === 'in' ? '●' : a.status === 'out' ? '○' : a.status === 'missing' ? '–' : '◌'
1032 const color = a.status === 'in' ? 'green' : a.status === 'out' ? 'red' : a.status === 'missing' ? 'gray' : 'yellow'
1033 return (
1034 <Box key={`acct:${p.id}`} gap={2}>
1035 <Box width={nameWidth}>
1036 <Text wrap="truncate-end">
1037 <Text color={color}>{`${mark} `}</Text>
1038 <Text bold>{p.name.padEnd(9)}</Text>
1039 <Text dimColor>{p.models}</Text>
1040 </Text>
1041 </Box>
1042 <Box flexGrow={1}>
1043 <Text dimColor={a.status !== 'in'} wrap="truncate-end">{a.status === 'checking' ? 'checking…' : a.detail}</Text>
1044 </Box>
1045 {a.status === 'out' && <Button key={`signin:${p.id}`} plain label="sign in" onPress={() => void signIn($, p.id)} />}
1046 {a.status === 'in' && p.id !== 'claude' && (
1047 <Button
1048 key={`switch:${p.id}`}
1049 plain
1050 dimColor
1051 label={p.id === 'gemini' ? 'switch' : 'sign out'}
1052 onPress={() => void (p.id === 'gemini' ? signIn($, p.id) : signOut($, p.id))}
1053 />
1054 )}
1055 </Box>
1056 )
1057 })}
1058 </Box>
1059
1060 {/* Tasks: the routing table */}
1061 <Box flexDirection="column">
1062 <Box justifyContent="space-between">
1063 {heading('Tasks')}
1064 <Text dimColor>model it runs on</Text>
1065 </Box>
1066 {ps.map(p => {
1067 const isSel = p.id === d.presetId
1068 return (
1069 <Box key={`row:${p.id}`} gap={2}>
1070 <Box width={nameWidth}>
1071 <Button
1072 key={`task:${p.id}`}
1073 plain
1074 variant={isSel ? 'primary' : undefined}
1075 label={`${isSel ? '▸' : ' '} ${short(p.title, nameWidth - 3)}`}
1076 onPress={() => void pickTask($, p.id)}
1077 />
1078 </Box>
1079 <Box flexGrow={1}>
1080 {p.then ? (
1081 <Text dimColor wrap="truncate-end">
1082 {[p.stage, ...p.then].map(s => modelLabel(routes[s]).split(' ')[0]).join(' → ')}
1083 </Text>
1084 ) : (
1085 <Select
1086 key={`who:${p.id}`}
1087 value={whoValue(modelOf(p), p.access)}
1088 options={whoOptions(whoValue(modelOf(p), p.access))}
1089 onSelect={value => {
1090 const w = WHO.find(x => x.value === value)
1091 if (!w) return
1092 void savePresets($, l => l.map(x => (x.id === p.id ? { ...x, model: w.model, access: w.access } : x)))
1093 if (isSel) void update($, draft, x => ({ ...x, model: w.model, access: w.access }))
1094 }}
1095 />
1096 )}
1097 </Box>
1098 {!p.isBuiltin && (
1099 <Button
1100 key={`del:${p.id}`}
1101 plain
1102 dimColor
1103 label="×"
1104 onPress={() => {
1105 void savePresets($, l => l.filter(x => x.id !== p.id))
1106 if (isSel) void update($, draft, x => ({ ...x, presetId: 'plan' }))
1107 }}
1108 />
1109 )}
1110 </Box>
1111 )
1112 })}
1113 <Box gap={2}>
1114 <Box width={nameWidth}>
1115 <Input
1116 key="new-task"
1117 label="+ "
1118 placeholder="Add a task (e.g. Write tests)"
1119 value={nt.title}
1120 submitLabel="add"
1121 onInput={value => void update($, newTask, x => ({ ...x, title: value }))}
1122 onSubmit={value => void update($, newTask, x => ({ ...x, title: value })).then(() => addTask($))}
1123 />
1124 </Box>
1125 <Box flexGrow={1}>
1126 <Select
1127 key="new-task-model"
1128 value={nt.who}
1129 options={whoOptions(nt.who)}
1130 onSelect={value => void update($, newTask, x => ({ ...x, who: value }))}
1131 />
1132 </Box>
1133 </Box>
1134 </Box>
1135
1136 {/* Composer */}
1137 <Box flexDirection="column" borderStyle="round" paddingX={1}>
1138 <Text wrap="truncate-end">
1139 <Text bold>{selected ? selected.title : 'Pick a task above'}</Text>
1140 {selected ? <Text dimColor>{` → ${selectedModel}`}</Text> : null}
1141 </Text>
1142 {selected && <Text dimColor wrap="wrap">{HINTS[selected.id] ?? oneLine(selected.template.replaceAll('{input}', '…'))}</Text>}
1143 <Input
1144 key="input"
1145 label="› "
1146 placeholder={(selected && PLACEHOLDERS[selected.id]) ?? 'What do you want done? Enter sends it'}
1147 submitLabel="send"
1148 value={d.input}
1149 autoFocus
1150 onInput={value => void update($, draft, x => ({ ...x, input: value }))}
1151 onSubmit={value => void launch($, value)}
1152 />
1153 </Box>
1154
1155 {/* Jobs */}
1156 <Box flexDirection="column">
1157 <Box justifyContent="space-between">
1158 {heading('Jobs')}
1159 <Text dimColor>{jobsHeader}</Text>
1160 </Box>
1161 {sorted.length === 0 && <Text dimColor>Nothing yet. Pick a task, type what you want, press Enter.</Text>}
1162 {sorted.map((job, i) => {
1163 const s = shownStatus(job)
1164 const step = job.pipeline ? `${(job.step ?? 0) + 1}/${job.pipeline.stages.length} ` : ''
1165 const label = `${MARK[s]} ${s.padEnd(8)} ${modelLabel(job.model).padEnd(13)} ${elapsed(job).padStart(6)} ${step}${job.title}`
1166 return (
1167 <Button
1168 key={`job:${job.id}`}
1169 plain
1170 hotkey={i < 9 ? String(i + 1) : undefined}
1171 label={short(label, Math.max(30, width - 4))}
1172 onPress={() => void openJob($, job.id)}
1173 />
1174 )
1175 })}
1176 </Box>
1177
1178 <Box gap={3}>
1179 {list.some(j => j.status !== 'running' && !isPaused(j)) && (
1180 <Button
1181 key="clear-finished"
1182 plain
1183 dimColor
1184 label="clear finished"
1185 onPress={() => void update($, jobs, l => l.filter(j => j.status === 'running' || isPaused(j))).then(() => saveJobs($))}
1186 />
1187 )}
1188 <Button
1189 key="settings"
1190 plain
1191 dimColor
1192 label={u.showSettings ? 'hide settings' : 'settings'}
1193 onPress={() => void update($, ui, x => ({ ...x, showSettings: !x.showSettings }))}
1194 />
1195 </Box>
1196
1197 {u.showSettings && (
1198 <Box flexDirection="column">
1199 <Select
1200 key="pipelines"types/index.d.ts 71 lines1export type Stage = 'plan' | 'critique' | 'implement' | 'mechanical' | 'review' | 'verify' | 'research'
2export type ModelId = 'fable' | 'opus' | 'astra' | 'composer' | 'gemini'
3export type Access = 'read' | 'write'
4export type JobStatus = 'running' | 'done' | 'failed' | 'stopped' | 'lost'
5export type View = 'home' | 'job'
6export type Provider = 'claude' | 'codex' | 'cursor' | 'gemini'
7export type Account = { status: 'checking' | 'in' | 'out' | 'busy' | 'missing'; detail: string }
8
9export type Preset = {
10 id: string
11 title: string
12 stage: Stage
13 // Pinned model; absent follows the stage's routing default.
14 model?: ModelId
15 access: Access
16 // `{input}` is replaced by what the person types.
17 template: string
18 // Present on a pipeline preset: the stages after the first, run in order.
19 then?: Stage[]
20 isBuiltin?: boolean
21}
22
23export type Pipeline = { id: string; stages: Stage[]; goal: string }
24
25export type Job = {
26 id: string
27 title: string
28 stage: Stage
29 model: ModelId
30 access: Access
31 task: string
32 prompt: string
33 status: JobStatus
34 startedAt: number
35 endedAt?: number
36 agentId?: string
37 output: string
38 activity: string[]
39 isSent?: boolean
40 parentId?: string
41 pipeline?: Pipeline
42 step?: number
43 isAdvanced?: boolean
44}
45
46export type Draft = {
47 // A preset id, or `free:<stage>` for a template-less task.
48 presetId: string
49 stage: Stage
50 model: ModelId
51 access: Access
52 input: string
53}
54
55export type Ui = { view: View; jobId?: string; showSettings: boolean; showPrompt: boolean; next?: string }
56
57declare module 'claude-code' {
58 interface PluginState {
59 orchestra: {
60 accounts: Record<Provider, Account>
61 newTask: { title: string; who: string }
62 draft: Draft
63 jobs: Job[]
64 presets: Preset[]
65 ui: Ui
66 guard: boolean
67 autoAdvance: boolean
68 }
69 }
70}
71