SLOPSHOPPER

orchestra

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-09octavi42/orchestra
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orchestra
│ ┃ Orchestra ✕ › fix the failing auth test and add an audit log call │ ┃ Orchestra ctrl+x tab to focus │ ┃ ● orchestra: orchestra: read-only agent unavailable: TypeError: undef │ ┃ Accounts refresh ⏺ Read(src/auth.ts) │ ┃ ◌ Claude Fable, Opus checking… ⎿ Read 6 lines │ ┃ ◌ ChatGPT Astra checking… ⏺ Update(src/auth.ts) │ ┃ ◌ Cursor Composer checking… ⎿ Added 2 lines, removed 1 line │ ┃ ● Gemini Gemini Google account sw ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Tasks model it runs on │ ┃ Full pipeline: plan,… Fable → GPT-6 → Op ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▸ Plan a change (with … Fable 5.1 · read-o │ ┃ Red-team a plan or d… GPT-6 Astra · read ✻ Worked for 42s · done 4:20 PM │ ┃ Implement with a don… Opus 5.5 · edits f │ ┃ Mechanical bulk edit Composer 2.5 · edi › /orchestrate │ ┃ Review uncommitted c… GPT-6 Astra · read ⎿ orchestra: Orchestra opened. │ ┃ Review a PR GPT-6 Astra · read │ ┃ Merge-risk gate GPT-6 Astra · read │ ┃ Verify a flow in the… GPT-6 Astra · edit │ ┃ Investigate (logs, b… Gemini · read-only │ ┃ Explain code simply Opus 5.5 · read-on │ ┃ + : Add a task (e.g. Write tests) ⏎ add Opu │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Plan a change (with reproduction test) … │ ┃ │ Reads the code first; returns approach, ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orchestra
Orchestra ctrl+x tab to focus Accounts refresh ◌ Claude Fable, Opus checking… ◌ ChatGPT Astra checking… ◌ Cursor Composer checking… ● Gemini Gemini Google account switch Tasks model it runs on Full pipeline: plan,… Fable → GPT-6 → Opus → GPT-6 ▸ Plan a change (with … Fable 5.1 · read-only ▾ Red-team a plan or d… GPT-6 Astra · read-only ▾ Implement with a don… Opus 5.5 · edits files ▾ Mechanical bulk edit Composer 2.5 · edits files ▾ Review uncommitted c… GPT-6 Astra · read-only ▾ Review a PR GPT-6 Astra · read-only ▾ Merge-risk gate GPT-6 Astra · read-only ▾ Verify a flow in the… GPT-6 Astra · edits files ▾ Investigate (logs, b… Gemini · read-only ▾ Explain code simply Opus 5.5 · read-only ▾ + : Add a task (e.g. Write tests) ⏎ add Opus 5.5 · edits fi ╭──────────────────────────────────────────────────────────╮ │ Plan a change (with reproduction test) → Fable 5.1 · … │ │ Reads the code first; returns approach, files, risks and │ │ the failing repro test. │ │ › : What do you want done? Enter sends it ⏎ send │ ╰──────────────────────────────────────────────────────────╯ Jobs Nothing yet. Pick a task, type what you want, press Enter. settings
README

Orchestra

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

Install

/plugin marketplace add octavi42/orchestra
/plugin install orchestra@orchestra

Then run /orchestrate.

What you get

  • Tasks and models. Every task has a model picker. Add your own tasks with the + row and the model beside it. Your table is saved across sessions.
  • Run from the pane. Pick a task, type what you want, press Enter. Jobs stream their output; open one to rerun it, ask a follow-up, hand the result to another task, or send it into your Claude conversation.
  • A pipeline. "Full pipeline" runs plan, red-team, implement and review in order, each step reading the last one's output, and pauses for your approval between steps.
  • Sign-ins. See which of your subscriptions are signed in and sign in from the pane: Claude, ChatGPT (Codex CLI), Cursor and Gemini.
  • Claude follows it. Your table is added to the conversation so Claude routes its own delegation the same way. Turn this off in the plugin settings.

Requirements

  • A Claude Code version with mods (plugin hooks modules); tested on 2.1.286.
  • Any of these CLIs for the non-Claude models, signed in: Codex (codex), Cursor CLI (cursor-agent), Gemini CLI (gemini). Models whose CLI is not installed are hidden from the pickers.

Settings

Change these in the /config menu under Orchestra:

SettingDefaultWhat it does
Planning model (Claude)claude-fable-5-1Model id behind the "Fable" choice
Implementation model (Claude)claude-opus-5-5Model id behind the "Opus" choice
Codex modelgpt-6-astraPassed to codex exec -m
Cursor modelcomposer-2.5Passed to cursor-agent --model
Model guardoffRuns Haiku, Sonnet and older Opus subagents on the implementation model instead
Share routing with ClaudeonAdds your table to the conversation

Safety notes

Every task row is either read-only or edits files, and that setting is enforced per tool:

ModelRead-onlyEdits files
Claude (Fable, Opus)a read-only agent with Edit/Write removed and its own permission modea general-purpose agent
Codexcodex exec -s read-onlycodex exec -s workspace-write
Cursorcursor-agent --mode askcursor-agent --force
Geminigemini --approval-mode plangemini --approval-mode yolo
  • "Edits files" runs in your current directory without asking. Use it for tasks you would hand off unattended.
  • Task text never passes through a shell and can never be read as a flag: Codex gets it on stdin, Cursor after --, Gemini as one --prompt= value.
  • Jobs are kept per repository, so a paused pipeline never continues in a different project.
  • Nothing is posted anywhere: the review templates tell the model not to comment on PRs.

Develop

claude --plugin-dir ./
claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 1257 lines
1import { 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 lines
1export 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