A conductor agent and musician subagents for Claude Code, with a live ledger of who read and changed what, conflict and stale-read detection, and a coda built…

A conductor agent and its musicians, inside Claude Code.
Orchestra is a Claude Code plugin. A conductor agent plans the work as a score, assigns parts to five musician subagents, runs independent parts in parallel, and presents a coda at the end. A Claude Code mod that ships in the same plugin watches the whole performance. It records every file each musician reads and writes into an append-only ledger with SHA-256 versions. It holds a write that would overwrite another musician's newer work and asks you what to do. It draws the ensemble beside your transcript.
| Musician | Subagent type | Role |
|---|---|---|
| Violin | orchestra:violin | Scout: maps code, writes notes, never edits source |
| Trumpet | orchestra:trumpet | Implementer: changes source code |
| Flute | orchestra:flute | Scribe: writes documentation |
| Timpani | orchestra:timpani | Tester: runs tests and commands, saves logs |
| Cello | orchestra:cello | Reviewer: reports problems, never edits |
Requires Claude Code 2.1.287 or later, the version that added mods. Tested with 2.1.289. Mods must be allowed on your machine: they are on by default, but managed settings such as allowManagedModsOnly, or --bare and --safe-mode, turn them off.
claude plugin marketplace add krzemienski/orchestra-cc
claude plugin install orchestra@orchestra-cc
To try it without installing, clone this repository and load the plugin directory for one session:
git clone https://github.com/krzemienski/orchestra-cc
claude --plugin-dir ./orchestra-cc/plugin --agent orchestra:conductor
Start a session with the conductor as the main agent, then describe the work:
claude --agent orchestra:conductor
Installing the plugin does not change your other sessions' main agent. The mod records any session where subagents run, but the conductor only conducts when you start it.
| Command | What it does |
|---|---|
/orchestra | Opens the Orchestra pane (tabs: Ensemble, Score, Artifacts, Coda). Esc closes it. Keys 1–4 switch tabs. |
/orchestra coda | Prints the coda for this session from the ledger |
/orchestra ledger | Prints the ledger's path, event count and status |
/orchestra close | Closes the pane |
♪ playing, ‖ waiting on you, ✓ done, ✕ failed.○ read, ← read another's work, ● write, ‼ conflict, ⚠ stale read, ✓ done, ✕ failed.Orchestra · 2 playing · 1 done · 3 artifacts changed · 1 conflict.· 2 musicians playing.Everything Orchestra shows is folded from one append-only ledger per session in .orchestra/performances/ in your project, written in segments of 500 events (<session-id>.0001.jsonl, …) so a crash can lose at most part of the newest segment. Orchestra also writes .orchestra/.gitignore so none of it is committed by accident.
Only observations are recorded:
| Event | Recorded when |
|---|---|
part.assigned | The conductor's Agent call started a subagent. Carries the subagent's id, type, description and prompt. |
tool.call, tool.result | A musician called a tool, and how it ended |
artifact.read | A musician read a file. Carries the file's SHA-256 at that moment. |
write.attempt | A write is about to run. Carries the SHA-256 of the file it is about to change. |
artifact.write | The write landed. Carries the new SHA-256 and lines added and removed. |
conflict.resolved | Your decision on a conflict |
part.done, part.failed | The subagent's turn ended |
coda | The coda file was written |
Everything else is derived from versions, never declared by the agents:
claude -p run nobody can answer, so the write is sent back..orchestra/performances/<session-id>-coda.md at the end of each conductor turn, from the ledger alone.prototype/ is the approved design prototype: a scripted performance played on HTML stand-ins for the surfaces a mod can draw. Run it with:
cd prototype && python3 -m http.server 8742 --bind 127.0.0.1
# open http://127.0.0.1:8742/index.html
Its data is illustrative. The plugin in plugin/ is the real integration.
docs/orchestra-research-report.md covers Claude Code plugins, mods and modes; prior art; OpenCode, Pi and terminal UX with visual evidence; data-operation patterns; and open questions. Every claim is labelled and linked.docs/orchestra-prototype-design.md gives the design rationale for the prototype.docs/orchestra-prd.md is the product requirements document: every screen with wireframes, colours, data placement and keyboard access.docs/orchestra-spec.md is the engineering specification: event flow, derivation rules and the verification plan.docs/verification.md records how the plugin was verified in a real environment, screen by screen.claude -p and cloud sessions, the ledger, conflict guard and coda still work, but nothing is drawn.sed -i) to a file Orchestra already tracks is credited to that musician, marked "via Bash", and marked uncertain if another musician had a tool running at the same time. Files created by Bash are not recorded, so the musicians are told to create files with Write and Edit.npm test; echo $?) is recorded as a success. The tester and conductor are instructed never to append anything to a command, but a model can still ignore that.claude plugin validate reports this as gating hook without .catch: tool.call.claude --continue, the session's ledger is replayed, so the picture is rebuilt rather than lost.MIT
hooks/register.js 569 lines1// Orchestra's hooks module. It records what each agent loop reads and writes into a
2// per-session ledger, guards writes that would overwrite another musician's newer work,
3// and draws the ensemble on the surfaces a mod owns: a pane, the band above the prompt,
4// the status line, toasts, the spinner and the Agent tool rows.
5
6import { CONDUCTOR, instrumentDef, apply, codaMarkdown, coda, emptyState, fold, instrumentOf, INSTRUMENTS, isPerformance, latest, lineDelta, musicians, nameOf, statusLine } from './ledger.js'
7import { GLYPH, TABS, band, heldWrite, pane } from './views.js'
8import { colorMode, modeFor, paint, setColorMode } from './palette.js'
9
10const PANE = 'orchestra'
11const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
12const LET_IT_WRITE = 'Let it write'
13const SEND_IT_BACK = 'Send it back to re-read'
14const SHOW_BOTH = 'Show both versions'
15const SEGMENT_EVENTS = 500
16// 3 header rows + 3 lines per side = 9 rows, under the dialog's 12-row limit.
17const DIALOG_LINES_PER_SIDE = 3
18
19/** @type {import('./ledger.js').State} */
20let state = emptyState()
21let cwd = ''
22let ledgerBase = ''
23let ledgerDir = ''
24let sessionId = ''
25let codaFile = ''
26let colorterm = ''
27const THEME_RECHECK_MS = 2000
28let seq = 0
29// The highest sequence number already written to a segment file.
30let persisted = 0
31let persisting = Promise.resolve()
32// Bumped whenever the session's paths and state are replaced, so a save queued by the previous
33// session can finish writing its own files without moving this session's `persisted`.
34let epoch = 0
35// Tool calls running now, per agent loop: a Bash change is attributed with certainty only when
36// no other loop had a call in flight.
37const inFlight = new Map()
38// Bash calls still running, one mark per call, so a loop's parallel Bash calls each keep their
39// own. A call is marked overlapped when any other loop has a call in flight while it runs,
40// including one that starts and ends entirely in the middle. The mark is read after the
41// command's own snapshots, so it stays until those finish.
42const bashRunning = new Set()
43// One write to a file at a time. The guard checks the file's hash and the write lands later,
44// several steps apart, so without this a second musician's write can land in between and be
45// credited to the first. The lock is held until that write's result is recorded.
46const fileLocks = new Map()
47const lockFile = (path) => {
48 const prev = fileLocks.get(path) || Promise.resolve()
49 let release
50 const gate = new Promise((resolve) => { release = resolve })
51 fileLocks.set(path, prev.then(() => gate))
52 return prev.then(() => release)
53}
54// The text each loop last read or wrote, in memory only (never in the ledger), so a held write
55// can show what changed since the writer's version.
56const lastText = new Map()
57const textKey = (agent, path) => `${agent}\u0000${path}`
58/**
59 * @typedef {{ tab: string, selected: { type: string, id: string }|null, codaPath: string|null,
60 * held: { path: string, writer: string, against: string, tool: string, current: number, base: number|null,
61 * changed: string[]|null, currentText: string|null, proposed: string }|null,
62 * setTab: (tab: string) => void, select: (type: string, id: string) => void }} View
63 */
64/** @type {View} */
65const view = { tab: 'ensemble', selected: null, codaPath: null, held: null, setTab: () => {}, select: () => {} }
66
67const segmentOf = (n) => Math.ceil(n / SEGMENT_EVENTS)
68const segmentFile = (n) => `${ledgerBase}.${String(n).padStart(4, '0')}.jsonl`
69
70const short = (text, n) => {
71 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
72 return s.length > n ? `${s.slice(0, n - 1)}…` : s
73}
74
75const relative = (path) => {
76 const p = String(path || '')
77 return cwd && p.startsWith(`${cwd}/`) ? p.slice(cwd.length + 1) : p
78}
79
80const loopOf = (e) => e.agentId || CONDUCTOR
81
82async function sha256(text) {
83 const bytes = new TextEncoder().encode(text)
84 const digest = await crypto.subtle.digest('SHA-256', bytes)
85 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
86}
87
88function targetOf(e) {
89 if (e.file_path) return relative(e.file_path)
90 if (e.notebook_path) return relative(e.notebook_path)
91 if (e.tool === 'Bash') return short(e.command, 80)
92 if (e.pattern) return short(e.pattern, 60)
93 if (e.tool === 'Agent') return short(e.description || e.subagent_type, 60)
94 return ''
95}
96
97async function snapshot($, path) {
98 try {
99 const text = await $.fs.read(path)
100 return { text, hash: await sha256(text) }
101 } catch {
102 return { text: null, hash: null }
103 }
104}
105
106// The ledger is written in segments of SEGMENT_EVENTS events. Each save rewrites only the
107// segments holding events not yet saved: normally just the newest, and both sides of a segment
108// boundary when one is crossed between saves. A torn write can lose at most one segment.
109// Everything a save writes is captured when it is queued, so it always writes one session's own
110// events to that session's files, even if it runs after the next session has started.
111async function persist($) {
112 if (!isPerformance(state) || !ledgerBase) return
113 const mine = epoch
114 const pending = state.events.filter((x) => x.seq > persisted)
115 if (!pending.length) return persisting
116 const upTo = Math.max(...pending.map((x) => x.seq))
117 const gitignore = `${cwd}/.orchestra/.gitignore`
118 const writes = [...new Set(pending.map((x) => segmentOf(x.seq)))].map((segment) => ({
119 file: segmentFile(segment),
120 body: `${state.events.filter((x) => segmentOf(x.seq) === segment).map((x) => JSON.stringify(x)).join('\n')}\n`,
121 }))
122 persisting = persisting.then(async () => {
123 if (!(await $.fs.exists(gitignore))) await $.fs.write(gitignore, '*\n')
124 for (const w of writes) await $.fs.write(w.file, w.body)
125 if (epoch === mine) persisted = Math.max(persisted, upTo)
126 }).catch((err) => debug($, `Orchestra could not write its ledger: ${err instanceof Error ? err.message : String(err)}`))
127 return persisting
128}
129
130// A debug-log line that never throws, for use inside error paths.
131function debug($, text) {
132 try {
133 $.ui.log(text, { to: 'debug' })
134 } catch {}
135}
136
137async function loadLedger($) {
138 if (!(await $.fs.exists(ledgerDir))) return []
139 const names = (await $.fs.list(ledgerDir)).map((x) => x.name)
140 .filter((n) => n.startsWith(`${sessionId}.`) && /\.\d{4}\.jsonl$/.test(n)).sort()
141 const events = []
142 for (const name of names) {
143 for (const text of (await $.fs.read(`${ledgerDir}/${name}`)).split('\n').filter(Boolean)) {
144 try {
145 events.push(JSON.parse(text))
146 } catch {
147 $.ui.log(`Orchestra skipped an unreadable ledger line in ${name}`, { to: 'debug' })
148 }
149 }
150 }
151 return events
152}
153
154const ledgerPath = () => relative(segmentFile(segmentOf(seq)))
155
156// Every observation goes through here: append, fold, persist, then tell the user what changed.
157// The sequence number is assigned only after the clock returns. Assigning it before the await
158// let two hooks hold consecutive numbers while only the later one was applied, and the save
159// then marked the earlier one as already written. Events are now applied and saved in order.
160async function record($, fields) {
161 const ts = await $.clock.now()
162 const before = { stale: state.stale.length, handoffs: state.handoffs.length, conflicts: state.conflicts.length, failures: state.toolFailures.length }
163 const event = { seq: ++seq, ts, ...fields }
164 apply(state, event)
165 // Once the event is applied it stands. Telling the user about it must not undo a decision
166 // the caller is about to act on, so a failed toast or redraw is only logged.
167 try {
168 for (const s of state.stale.slice(before.stale)) {
169 $.ui.toast(`Orchestra · stale read: ${nameOf(state, s.agent)} worked from v${s.readV} of ${s.path}; ${nameOf(state, s.writer)} wrote v${s.currentV}`, { timeoutMs: 6000 })
170 }
171 for (const h of state.handoffs.slice(before.handoffs)) {
172 const how = h.via === 'brief' ? 'was handed' : 'read'
173 $.ui.toast(`Orchestra · handoff: ${nameOf(state, h.to)} ${how} ${h.path} v${h.v} from ${nameOf(state, h.from)}`)
174 }
175 for (const f of state.toolFailures.slice(before.failures).filter((x) => x.agent !== CONDUCTOR)) {
176 $.ui.toast(`Orchestra · ${nameOf(state, f.agent)}: ${f.tool} failed`, { timeoutMs: 6000 })
177 }
178 await persist($)
179 $.ui.status(isPerformance(state) ? statusLine(state) : undefined)
180 $.ui.invalidate('ui.render')
181 } catch (err) {
182 debug($, `Orchestra could not show event ${event.seq}: ${err instanceof Error ? err.message : String(err)}`)
183 }
184 return event
185}
186
187async function writeCoda($) {
188 if (!isPerformance(state) || !codaFile) return
189 const settled = musicians(state).every((m) => m.state === 'done' || m.state === 'failed')
190 await $.fs.write(codaFile, codaMarkdown(state, ledgerPath()))
191 await record($, { type: 'coda', path: relative(codaFile), settled })
192 view.codaPath = relative(codaFile)
193 if (settled) {
194 $.ui.log(`Orchestra coda: ${statusLine(state)}. Written to ${relative(codaFile)}`)
195 $.ui.toast('Orchestra · coda ready: /orchestra and open the Coda tab', { timeoutMs: 6000 })
196 }
197}
198
199// Lines in `now` that are not in `then`, counted as a multiset.
200function addedLines(then, now) {
201 const left = new Map()
202 for (const l of String(then ?? '').split('\n')) left.set(l, (left.get(l) || 0) + 1)
203 return String(now ?? '').split('\n').filter((l) => {
204 const n = left.get(l) || 0
205 if (n > 0) {
206 left.set(l, n - 1)
207 return false
208 }
209 return l.trim() !== ''
210 })
211}
212
213function proposedText(e) {
214 if (e.tool === 'Write') return e.content
215 if (e.tool === 'Edit') return `${e.old_string}\n ↓\n${e.new_string}`
216 if (e.tool === 'MultiEdit') return (e.edits || []).map((x) => x.new_string).join('\n…\n')
217 if (e.tool === 'NotebookEdit') return e.new_source
218 return ''
219}
220
221// A write that would land on a version the writer never saw is held here and put to the user.
222// With nobody to ask (a -p run, or the dialog dismissed) it is sent back, the safe choice.
223// A write the musician has never read is also sent back without a question. Claude Code refuses
224// any Write or Edit of a file that loop has not read ("File has not been read yet"), and a mod
225// cannot record the read for it, so "Let it write" would fail every time. Sending it back makes
226// the musician read the file and reapply the change, which is the only path that succeeds.
227// Returns `{ deny }` to send the write back, or `{ base }`: the snapshot the write will replace.
228// `approved` is an earlier conflict on this write that the user let through before the file
229// moved on; it is settled here, once it is known whether the new version needs a question.
230/** @param {import('./ledger.js').Conflict|null} [approved] */
231async function guardWrite($, e, key, path, current, approved = null) {
232 const attempt = await record($, { type: 'write.attempt', agent: key, path, hash: current.hash })
233 const conflict = state.conflicts.find((c) => c.seq === attempt.seq)
234 if (approved) await record($, { type: 'conflict.resolved', id: approved.id, choice: conflict ? 'reask' : 'overwrite' })
235 if (!conflict) {
236 if (approved) notify($, `Orchestra · conflict on ${path}: ${nameOf(state, key)} vs ${nameOf(state, approved.against)}, write allowed`)
237 return { base: current }
238 }
239 const writer = nameOf(state, key)
240 const other = nameOf(state, conflict.against)
241 const neverRead = conflict.base === null
242 const seenText = neverRead ? `${writer} never read it` : `${writer} saw v${conflict.base}`
243 if (neverRead) {
244 await record($, { type: 'conflict.resolved', id: conflict.id, choice: 'reread' })
245 notify($, `Orchestra · conflict on ${path}: ${writer} vs ${other}, sent back to re-read`)
246 return { deny: `Orchestra: ${other} wrote ${path} v${conflict.current} and you never read it. Read the file, then reapply your change on top of its current content.` }
247 }
248 const question = `Orchestra: ${other} wrote ${path} v${conflict.current} and ${seenText}. Let ${writer}'s ${e.tool} go ahead?`
249 let choice = 'reread'
250 try {
251 let label = await $.ui.ask(question, { header: 'Conflict', options: [LET_IT_WRITE, SEND_IT_BACK, SHOW_BOTH] })
252 if (label === SHOW_BOTH) {
253 const seenText = lastText.get(textKey(key, path))
254 view.held = {
255 path, writer, against: other, tool: e.tool, current: conflict.current, base: conflict.base,
256 changed: seenText === undefined ? null : addedLines(seenText, current.text), currentText: current.text, proposed: proposedText(e),
257 }
258 view.tab = 'artifacts'
259 view.selected = { type: 'artifact', id: path }
260 await record($, { type: 'conflict.shown', id: conflict.id })
261 label = await $.ui.ask(`${question} Both versions are shown above.`, { header: 'Conflict', options: [LET_IT_WRITE, SEND_IT_BACK] })
262 }
263 choice = label === LET_IT_WRITE ? 'overwrite' : 'reread'
264 } catch {
265 choice = 'reread'
266 }
267 view.held = null
268 // Writes through Write and Edit wait on the file lock, but a Bash command or an edit outside
269 // Claude Code can change the file while the dialog is open. Then the approval was for an older
270 // version, so the write is attempted again against the version there now.
271 if (choice === 'overwrite') {
272 const again = await snapshot($, path)
273 if (again.hash !== current.hash) return guardWrite($, e, key, path, again, conflict)
274 }
275 await record($, { type: 'conflict.resolved', id: conflict.id, choice })
276 notify($, `Orchestra · conflict on ${path}: ${writer} vs ${other}, ${choice === 'overwrite' ? 'write allowed' : 'sent back to re-read'}`)
277 if (choice === 'overwrite') return { base: current }
278 return { deny: `Orchestra: ${other} wrote ${path} v${conflict.current} and you ${conflict.base === null ? 'never read it' : `last saw v${conflict.base}`}. Read the file again and reapply your change on top of its current content.` }
279}
280
281// A conflict's outcome toast. Shown after the decision is recorded, and never allowed to throw.
282function notify($, text) {
283 try {
284 $.ui.toast(text, { timeoutMs: 8000 })
285 } catch (err) {
286 debug($, `Orchestra could not show a toast: ${err instanceof Error ? err.message : String(err)}`)
287 }
288}
289
290// Files the ledger already knows, read before and after a Bash call; any that changed is a
291// version written by whoever ran the command.
292async function snapshotKnown($) {
293 // Only while a performance is running, and only for files inside the project. Every Bash
294 // call reads and hashes these twice, so tracking the whole machine would stall the session.
295 if (!isPerformance(state)) return {}
296 const paths = Object.keys(state.artifacts).filter((path) =>
297 !path.startsWith('.orchestra/performances/') && !path.startsWith('/') && !path.split('/').includes('..'))
298 const snaps = await Promise.all(paths.map((path) => snapshot($, path)))
299 return Object.fromEntries(paths.map((path, i) => [path, snaps[i]]))
300}
301
302async function attributeBash($, key, before, mark) {
303 // `mark.overlapped` is read here, after the snapshots, not before them. Another loop's call
304 // can start while the snapshots run, and that call can be what changed the file.
305 const pairs = await Promise.all(Object.entries(before).map(async ([path, prev]) => ({ path, prev, now: await snapshot($, path) })))
306 for (const { path, prev, now } of pairs) {
307 if (!now.hash || now.hash === prev.hash) continue
308 // A hash the ledger holds as its latest version was written by someone else and already
309 // recorded. An older hash means the command reverted the file, which is a new version.
310 const last = state.artifacts[path] && latest(state.artifacts[path])
311 if (last && last.hash === now.hash) continue
312 await record($, { type: 'artifact.write', agent: key, path, hash: now.hash, via: 'bash', uncertain: mark.overlapped, ...lineDelta(prev.text, now.text) })
313 }
314}
315
316// /theme does not raise config.set, so a timer re-reads the theme every THEME_RECHECK_MS.
317async function refreshTheme($) {
318 const theme = (await $.config.list()).find((row) => row.key === 'theme')?.value
319 const next = modeFor(theme, colorterm)
320 if (next === colorMode()) return
321 setColorMode(next)
322 $.ui.invalidate('ui.render')
323}
324
325// Paths a brief names that the ledger already knows, so a handoff by brief can be derived.
326function mentionsIn(prompt) {
327 const text = String(prompt || '')
328 return Object.keys(state.artifacts).filter((p) => text.includes(p))
329}
330
331export function register(on) {
332 on('session.start', async ($, e, next) => {
333 await $.command.register({ name: 'orchestra', description: 'Open the Orchestra pane, or /orchestra coda | ledger | close', immediate: true })
334 const dir = await $.session.cwd()
335 const id = await $.session.id()
336 // The paths, the state and the counters change together with no await in between, so a hook
337 // still running for the previous session cannot save its events into this session's files.
338 epoch += 1
339 cwd = dir
340 sessionId = id
341 ledgerDir = `${cwd}/.orchestra/performances`
342 ledgerBase = `${ledgerDir}/${sessionId}`
343 codaFile = `${ledgerBase}-coda.md`
344 state = emptyState()
345 seq = 0
346 persisted = 0
347 lastText.clear()
348 Object.assign(view, { codaPath: null, held: null, selected: null })
349 colorterm = (await $.env.get('COLORTERM')) || ''
350 const theme = (await $.config.list()).find((row) => row.key === 'theme')?.value
351 setColorMode(modeFor(theme, colorterm))
352 $.clock.every(THEME_RECHECK_MS, () => {
353 refreshTheme($).catch(() => {})
354 })
355 const events = await loadLedger($)
356 if (events.length) {
357 state = fold(events)
358 seq = events[events.length - 1].seq
359 persisted = seq
360 } else {
361 apply(state, { seq: ++seq, ts: await $.clock.now(), type: 'session.start', session: sessionId, cwd })
362 }
363 if (state.codaSeq) view.codaPath = relative(codaFile)
364 $.ui.status(isPerformance(state) ? statusLine(state) : undefined)
365 $.ui.invalidate('ui.render')
366 return next(e)
367 })
368
369 on('config.set', { key: 'theme' }, async ($, e, next) => {
370 const result = await next(e)
371 setColorMode(modeFor(e.value, colorterm))
372 $.ui.invalidate('ui.render')
373 return result
374 })
375
376 // Only a person's own prompt sets the task; background-task notifications and peer
377 // messages also arrive as prompts and must not overwrite it.
378 on('prompt.submit', async ($, e, next) => {
379 const fromPerson = ['composer', 'bridge', 'sdk'].includes(e.origin?.kind)
380 if (fromPerson && typeof e.text === 'string' && !e.text.startsWith('/')) await record($, { type: 'prompt', text: short(e.text, 500) })
381 return next(e)
382 })
383
384 on('turn.start', async ($, e, next) => {
385 if (!e.agentId) await record($, { type: 'turn.start' })
386 return next(e)
387 })
388
389 on('agent.spawn', async ($, e, next) => {
390 const result = await next(e)
391 if (result && result.agentId) {
392 const first = !isPerformance(state)
393 await record($, {
394 type: 'part.assigned', agent: result.agentId, subagentType: e.subagentType, description: e.description,
395 prompt: short(e.prompt, 600), parent: e.parentAgentId || CONDUCTOR, toolUseId: e.tool_use_id, mentions: mentionsIn(e.prompt),
396 })
397 if (first) await $.ui.open({ id: PANE, title: 'Orchestra' })
398 }
399 return result
400 })
401
402 on('tool.call', async ($, e, next) => {
403 const key = loopOf(e)
404 const target = targetOf(e)
405 const isWrite = WRITE_TOOLS.has(e.tool) && (e.file_path || e.notebook_path)
406 let before = null
407 let known = null
408 let release = null
409 let refusal = null
410 let guardFrom = null
411 inFlight.set(key, (inFlight.get(key) || 0) + 1)
412 // Any Bash call another loop is running overlapped with this one, and this call with it.
413 for (const other of bashRunning) if (other.loop !== key) other.overlapped = true
414 const mark = { loop: key, overlapped: [...inFlight.entries()].some(([agent, n]) => agent !== key && n > 0) }
415 if (e.tool === 'Bash') bashRunning.add(mark)
416 // Orchestra's own bookkeeping fails open: an error in it is logged and the tool still runs,
417 // and the tool's real result is returned. Only the tool's own error reaches the musician.
418 try {
419 try {
420 if (e.tool === 'Bash') known = await snapshotKnown($)
421 await record($, { type: 'tool.call', agent: key, tool: e.tool, target })
422 if (isWrite) {
423 release = await lockFile(target)
424 before = await snapshot($, e.file_path || e.notebook_path)
425 guardFrom = seq
426 const guard = await guardWrite($, e, key, target, before)
427 if (guard.deny) refusal = { deny: guard.deny }
428 else before = guard.base
429 }
430 } catch (err) {
431 // If the guard had found a conflict on this write and failed before the user's "Let it
432 // write" was recorded, the write is sent back rather than landing unasked or against
433 // the answer. The musician re-reads and tries again.
434 const held = guardFrom === null ? undefined : state.conflicts.filter((c) => c.agent === key && c.path === target && c.seq > guardFrom).at(-1)
435 if (held && held.choice !== 'overwrite') refusal = { deny: `Orchestra could not settle a conflict on ${target}. Read the file again and reapply your change on top of its current content.` }
436 debug($, `Orchestra skipped recording ${e.tool}: ${err instanceof Error ? err.message : String(err)}`)
437 }
438 if (refusal) {
439 await record($, { type: 'tool.result', agent: key, tool: e.tool, target, ok: false, sentBack: true, error: 'sent back by Orchestra: stale base' })
440 .catch((err) => debug($, `Orchestra could not record a send-back: ${err instanceof Error ? err.message : String(err)}`))
441 return refusal
442 }
443 const result = await next(e)
444 try {
445 const ok = !(result && (result.isError || result.deny))
446 if (known) await attributeBash($, key, known, mark)
447 if (ok && isWrite) {
448 const after = await snapshot($, e.file_path || e.notebook_path)
449 if (after.hash) await record($, { type: 'artifact.write', agent: key, path: target, hash: after.hash, ...lineDelta(before?.text ?? null, after.text) })
450 if (after.text !== null) lastText.set(textKey(key, target), after.text)
451 }
452 if (ok && e.tool === 'Read' && e.file_path) {
453 const seen = await snapshot($, e.file_path)
454 if (seen.hash) await record($, { type: 'artifact.read', agent: key, path: target, hash: seen.hash })
455 if (seen.text !== null) lastText.set(textKey(key, target), seen.text)
456 }
457 await record($, { type: 'tool.result', agent: key, tool: e.tool, target, ok, error: ok ? undefined : short(result?.text || result?.deny, 200) })
458 } catch (err) {
459 debug($, `Orchestra skipped recording the result of ${e.tool}: ${err instanceof Error ? err.message : String(err)}`)
460 }
461 return result
462 } finally {
463 inFlight.set(key, Math.max(0, (inFlight.get(key) || 1) - 1))
464 bashRunning.delete(mark)
465 if (release) release()
466 }
467 })
468
469 on('turn.complete', async ($, e, next) => {
470 try {
471 if (e.agentId && state.musicians[e.agentId]) {
472 if (e.reason === 'answer') await record($, { type: 'part.done', agent: e.agentId, answer: short(e.answer, 1200) })
473 else await record($, { type: 'part.failed', agent: e.agentId, reason: e.reason, answer: short(e.answer, 600) })
474 } else if (!e.agentId) {
475 await record($, { type: 'turn.end' })
476 await writeCoda($)
477 }
478 } catch (err) {
479 $.ui.log(`Orchestra could not record the end of a turn: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
480 }
481 return next(e)
482 })
483
484 on('command.run', { command: 'orchestra' }, async ($, e) => {
485 const arg = String(e.args || '').trim()
486 if (arg === 'close') {
487 await $.ui.close(PANE)
488 return {}
489 }
490 if (arg === 'ledger') {
491 const colours = `colours: ${colorMode()}`
492 return { text: isPerformance(state) ? `${ledgerPath()} · ${state.events.length} events · ${statusLine(state)} · ${colours}` : `No performance in this session yet: no musician has been assigned a part. ${colours}` }
493 }
494 if (arg === 'coda') {
495 if (!isPerformance(state)) return { text: 'No performance in this session yet.' }
496 const c = coda(state)
497 return { text: [
498 statusLine(state),
499 ...c.contributions.map((x) => `${x.name}: ${x.reads} reads, ${x.writes} writes, +${x.added} −${x.removed}`),
500 ...c.conflicts.map((x) => `Conflict: ${x.text}`),
501 ...c.failures.map((x) => `Failure: ${x}`),
502 ...c.stale.map((x) => `Stale: ${x}`),
503 `Handoffs: ${c.handoffs.length}`,
504 ].join('\n') }
505 }
506 await $.ui.open({ id: PANE, title: 'Orchestra', focus: true, closeOnEscape: true })
507 return {}
508 })
509
510 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
511 if (e.requestId !== PANE) return next(e)
512 const el = $.ui.resolve(e)
513 const width = Math.max(30, e.props.bodyColumns || 60)
514 if (!isPerformance(state)) {
515 return el.Box({ flexDirection: 'column', children: [
516 el.Text({ bold: true, children: ['Orchestra'] }),
517 el.Text({ dimColor: true, children: ['No performance yet. Start one with: claude --agent orchestra:conductor'] }),
518 ] })
519 }
520 view.setTab = (tab) => {
521 view.tab = tab
522 $.ui.invalidate('ui.render')
523 }
524 view.select = (type, id) => {
525 view.selected = view.selected?.type === type && view.selected?.id === id ? null : { type, id }
526 $.ui.invalidate('ui.render')
527 }
528 if (!TABS.some(([id]) => id === view.tab)) view.tab = 'ensemble'
529 return pane(el, state, view, width)
530 })
531
532 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
533 if (!isPerformance(state)) return next(e)
534 const el = $.ui.resolve(e)
535 const theirs = await next(e)
536 const mine = band(el, state, Math.max(30, e.props.bodyColumns || 80))
537 return el.Box({ flexDirection: 'column', children: [mine, theirs].filter(Boolean) })
538 })
539
540 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
541 const playing = musicians(state).filter((m) => m.state === 'playing').length
542 if (!playing) return next(e)
543 return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix || ''} · ${playing} ${playing === 1 ? 'musician' : 'musicians'} playing` } })
544 })
545
546 // While a write is held, the dialog shows the file as it is now beside the held change.
547 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
548 if (!view.held) return next(e)
549 const el = $.ui.resolve(e)
550 const ref = await next(e)
551 const width = Math.max(40, e.viewport?.columns ?? 100)
552 return el.Box({ flexDirection: 'column', children: [...heldWrite(el, view.held, width, DIALOG_LINES_PER_SIDE), ref] })
553 })
554
555 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
556 if (e.props.tool !== 'Agent') return next(e)
557 const agentId = e.props.tool_use_id ? state.rows[e.props.tool_use_id] : undefined
558 const m = agentId ? state.musicians[agentId] : null
559 const type = e.props.input?.subagent_type
560 if (!m && !String(type || '').startsWith('orchestra:')) return next(e)
561 const el = $.ui.resolve(e)
562 const instrument = m ? m.instrument : instrumentOf(type)
563 const def = instrumentDef(instrument)
564 const label = m ? `${GLYPH[m.state]} ${m.name} · ${m.role} · ${m.measures} measures` : `◌ ${def.name} · ${def.role}`
565 const row = await next(e)
566 return el.Box({ flexDirection: 'column', children: [el.Text({ color: paint(instrument), bold: true, children: [label] }), row].filter(Boolean) })
567 })
568}
569hooks/ledger.js 433 lines1// Orchestra's ledger: an append-only list of observations, and the state folded from it.
2// Only observations are recorded (a spawn, a tool call, the hash of a file read or written,
3// a user's decision). Conflicts, stale reads and handoffs are derived here from versions,
4// so replaying the same log always gives the same picture.
5
6/**
7 * @typedef {{ v: number, hash: string, agent: string, base: number|null, added: number, removed: number, seq: number, via?: string, uncertain?: boolean }} Version
8 * @typedef {{ path: string, versions: Version[], reads: { agent: string, v: number, seq: number }[] }} Artifact
9 * @typedef {{ key: string, instrument: string, name: string, type: string, role: string, color: string, state: string,
10 * assigned: boolean, part: string|null, prompt?: string, parent?: string, measures: number,
11 * tool: { tool: string, target: string }|null, answer: string|null, failure?: string,
12 * seen: Record<string, number>, readSeq: Record<string, number>, reads: number, writes: number, added: number, removed: number }} Musician
13 * @typedef {{ id: string, path: string, agent: string, against: string, base: number|null, current: number, seq: number,
14 * resolved: boolean, choice: string|null, resolvedSeq?: number, shown?: number }} Conflict
15 * @typedef {{ agent: string, path: string, readV: number, currentV: number, writer: string, products: string[], seq: number,
16 * cleared: boolean, clearedSeq?: number, clearedBy?: string }} Stale
17 * @typedef {{ from: string, to: string, path: string, v: number, seq: number, via: string }} Handoff
18 * @typedef {{ agent: string, tool: string, target: string, error?: string, seq: number, recoveredSeq: number|null, recoveredBy?: string }} ToolFailure
19 * @typedef {Record<string, any> & { seq: number, ts: number, type: string }} LedgerEvent
20 * @typedef {{ session: string|null, cwd: string|null, task: string|null, events: LedgerEvent[],
21 * musicians: Record<string, Musician>, order: string[], artifacts: Record<string, Artifact>,
22 * conflicts: Conflict[], stale: Stale[], handoffs: Handoff[], toolFailures: ToolFailure[],
23 * rows: Record<string, string>, codaSeq: number|null }} State
24 */
25
26/** @type {Record<string, { name: string, role: string }>} */
27export const INSTRUMENTS = {
28 conductor: { name: 'Conductor', role: 'Plans the score, assigns parts, presents the coda' },
29 violin: { name: 'Violin', role: 'Scout' },
30 trumpet: { name: 'Trumpet', role: 'Implementer' },
31 flute: { name: 'Flute', role: 'Scribe' },
32 timpani: { name: 'Timpani', role: 'Tester' },
33 cello: { name: 'Cello', role: 'Reviewer' },
34 guest: { name: 'Guest', role: 'Subagent outside the orchestra' },
35}
36
37export const CONDUCTOR = 'conductor'
38const GUEST = { name: 'Guest', role: 'Subagent outside the orchestra' }
39export const instrumentDef = (name) => INSTRUMENTS[name] ?? GUEST
40const NOBODY = new Set(['repo', 'outside'])
41
42/** @returns {State} */
43export function emptyState() {
44 return {
45 session: null,
46 cwd: null,
47 task: null,
48 events: [],
49 musicians: { [CONDUCTOR]: newMusician(CONDUCTOR, 'conductor', instrumentDef('conductor').name, 'main loop') },
50 order: [CONDUCTOR],
51 artifacts: {},
52 conflicts: [],
53 stale: [],
54 handoffs: [],
55 toolFailures: [],
56 rows: {},
57 codaSeq: null,
58 }
59}
60
61// `color` is a palette token (see palette.js), the instrument's own key.
62/** @returns {Musician} */
63function newMusician(key, instrument, name, type) {
64 return {
65 key, instrument, name, type,
66 role: instrumentDef(instrument).role, color: instrument,
67 state: key === CONDUCTOR ? 'resting' : 'playing',
68 assigned: key === CONDUCTOR,
69 part: null, measures: 0, tool: null, answer: null,
70 seen: {}, readSeq: {},
71 reads: 0, writes: 0, added: 0, removed: 0,
72 }
73}
74
75export function instrumentOf(type) {
76 const name = String(type || '').replace(/^orchestra:/, '')
77 return INSTRUMENTS[name] && name !== 'conductor' && name !== 'guest' ? name : 'guest'
78}
79
80function displayName(state, key, instrument, type) {
81 const same = state.order.filter((k) => k !== key && state.musicians[k].instrument === instrument).length
82 const base = instrument === 'guest' ? `${GUEST.name} (${type})` : instrumentDef(instrument).name
83 return same ? `${base} ${same + 1}` : base
84}
85
86function ensureMusician(state, key, type = 'unknown') {
87 if (state.musicians[key]) return state.musicians[key]
88 const instrument = instrumentOf(type)
89 const m = newMusician(key, instrument, displayName(state, key, instrument, type), type)
90 state.musicians[key] = m
91 state.order.push(key)
92 return m
93}
94
95// A loop first seen through a tool call is identified once its spawn is recorded.
96function identify(state, m, type) {
97 if (!type || m.type === type) return
98 const instrument = instrumentOf(type)
99 Object.assign(m, { type, instrument, role: instrumentDef(instrument).role, color: instrument, name: displayName(state, m.key, instrument, type) })
100}
101
102function ensureArtifact(state, path) {
103 if (!state.artifacts[path]) state.artifacts[path] = { path, versions: [], reads: [] }
104 return state.artifacts[path]
105}
106
107export const latest = (a) => a.versions[a.versions.length - 1]
108
109// Two version numbers of one file can hold the same bytes, for example after a revert.
110const sameContent = (a, v1, v2) => v1 === v2 || (a.versions[v1] !== undefined && a.versions[v1]?.hash === a.versions[v2]?.hash)
111
112// The version a hash belongs to. A hash never seen before is a version nobody in the
113// ensemble wrote: the file as it was at first sight, or a change made outside.
114function versionOf(state, path, hash, seq) {
115 const a = ensureArtifact(state, path)
116 const known = [...a.versions].reverse().find((x) => x.hash === hash)
117 if (known) return known
118 const v = { v: a.versions.length, hash, agent: a.versions.length ? 'outside' : 'repo', base: null, added: 0, removed: 0, seq }
119 a.versions.push(v)
120 return v
121}
122
123function addHandoff(state, from, to, path, v, seq, via) {
124 if (NOBODY.has(from) || from === to) return
125 if (state.handoffs.some((h) => h.from === from && h.to === to && h.path === path && h.v === v)) return
126 state.handoffs.push({ from, to, path, v, seq, via })
127}
128
129export function artifactStatus(state, path) {
130 if (state.conflicts.some((c) => c.path === path && !c.resolved)) return 'conflict'
131 if (state.stale.some((s) => s.path === path && !s.cleared)) return 'stale'
132 return 'ok'
133}
134
135/** @type {Record<string, (state: State, e: LedgerEvent) => void>} */
136const handlers = {
137 'session.start'(state, e) {
138 state.session = e.session
139 state.cwd = e.cwd
140 },
141 'prompt'(state, e) {
142 state.task = e.text
143 },
144 'turn.start'(state) {
145 ensureMusician(state, CONDUCTOR).state = 'playing'
146 },
147 'turn.end'(state) {
148 ensureMusician(state, CONDUCTOR).state = 'resting'
149 },
150 'part.assigned'(state, e) {
151 const m = ensureMusician(state, e.agent, e.subagentType)
152 identify(state, m, e.subagentType)
153 m.assigned = true
154 m.part = e.description || null
155 m.prompt = e.prompt
156 m.parent = e.parent || CONDUCTOR
157 m.state = 'playing'
158 if (e.toolUseId) state.rows[e.toolUseId] = e.agent
159 // A brief that names a file another musician wrote hands that version over.
160 for (const path of e.mentions || []) {
161 const a = state.artifacts[path]
162 if (a && a.versions.length) addHandoff(state, latest(a).agent, e.agent, path, latest(a).v, e.seq, 'brief')
163 }
164 },
165 'tool.call'(state, e) {
166 const m = ensureMusician(state, e.agent)
167 m.tool = { tool: e.tool, target: e.target }
168 if (m.state !== 'waiting') m.state = 'playing'
169 },
170 'tool.result'(state, e) {
171 const m = ensureMusician(state, e.agent)
172 m.tool = null
173 m.measures += 1
174 if (!e.ok && e.sentBack) return
175 if (!e.ok) {
176 state.toolFailures.push({ agent: e.agent, tool: e.tool, target: e.target, error: e.error, seq: e.seq, recoveredSeq: null })
177 return
178 }
179 // A failed call is recovered when any musician later runs the same call successfully.
180 for (const f of state.toolFailures) {
181 if (f.tool === e.tool && f.target === e.target && f.recoveredSeq === null) {
182 f.recoveredSeq = e.seq
183 f.recoveredBy = e.agent
184 }
185 }
186 },
187 'artifact.read'(state, e) {
188 const m = ensureMusician(state, e.agent)
189 const ver = versionOf(state, e.path, e.hash, e.seq)
190 ensureArtifact(state, e.path).reads.push({ agent: e.agent, v: ver.v, seq: e.seq })
191 m.reads += 1
192 m.seen[e.path] = ver.v
193 m.readSeq[e.path] = e.seq
194 for (const s of state.stale) {
195 if (s.agent === e.agent && s.path === e.path && !s.cleared && ver.v >= s.currentV) {
196 s.cleared = true
197 s.clearedSeq = e.seq
198 s.clearedBy = e.agent
199 }
200 }
201 addHandoff(state, ver.agent, e.agent, e.path, ver.v, e.seq, 'read')
202 },
203 // Seen just before a write runs: the hash of the file as the writer is about to change it.
204 'write.attempt'(state, e) {
205 const m = ensureMusician(state, e.agent)
206 if (e.hash === null) return
207 const cur = versionOf(state, e.path, e.hash, e.seq)
208 const seen = m.seen[e.path]
209 const overSomeoneElse = !NOBODY.has(cur.agent) && cur.agent !== e.agent
210 const changedSinceSeen = seen !== undefined && !sameContent(state.artifacts[e.path], seen, cur.v)
211 const blind = seen === undefined && overSomeoneElse
212 if (!changedSinceSeen && !blind) return
213 state.conflicts.push({
214 id: `c${state.conflicts.length + 1}`, path: e.path, agent: e.agent, against: cur.agent,
215 base: seen ?? null, current: cur.v, seq: e.seq, resolved: false, choice: null,
216 })
217 m.state = 'waiting'
218 },
219 'conflict.shown'(state, e) {
220 const c = state.conflicts.find((x) => x.id === e.id)
221 if (c) c.shown = (c.shown || 0) + 1
222 },
223 'conflict.resolved'(state, e) {
224 const c = state.conflicts.find((x) => x.id === e.id)
225 if (!c) return
226 c.resolved = true
227 c.choice = e.choice
228 c.resolvedSeq = e.seq
229 const m = state.musicians[c.agent]
230 const stillHeld = state.conflicts.some((x) => x.agent === c.agent && !x.resolved)
231 if (m && m.state === 'waiting' && !stillHeld) m.state = 'playing'
232 },
233 'artifact.write'(state, e) {
234 const m = ensureMusician(state, e.agent)
235 const a = ensureArtifact(state, e.path)
236 const prev = latest(a)
237 m.writes += 1
238 m.added += e.added
239 m.removed += e.removed
240 for (const f of state.toolFailures) {
241 if (f.agent === e.agent && f.target === e.path && f.recoveredSeq === null) f.recoveredSeq = e.seq
242 }
243 // Stale work is redone when its product is rewritten by someone who has seen the current input.
244 for (const s of state.stale) {
245 if (s.cleared || !s.products.includes(e.path)) continue
246 const art = ensureArtifact(state, s.path)
247 const input = latest(art)
248 const seenV = m.seen[s.path]
249 if (input && seenV !== undefined && (seenV >= input.v || sameContent(art, seenV, input.v))) {
250 s.cleared = true
251 s.clearedSeq = e.seq
252 s.clearedBy = e.agent
253 }
254 }
255 if (prev && prev.hash === e.hash) {
256 m.seen[e.path] = prev.v
257 return
258 }
259 const ver = { v: a.versions.length, hash: e.hash, agent: e.agent, base: m.seen[e.path] ?? null, added: e.added, removed: e.removed, seq: e.seq, via: e.via, uncertain: e.uncertain }
260 a.versions.push(ver)
261 m.seen[e.path] = ver.v
262 m.readSeq[e.path] = e.seq
263 // Anyone who used an older version of this file to produce something else is now stale.
264 for (const key of state.order) {
265 const other = state.musicians[key]
266 if (!other) continue
267 const readV = other.seen[e.path]
268 if (key === e.agent || key === CONDUCTOR || readV === undefined || readV >= ver.v || sameContent(a, readV, ver.v)) continue
269 const products = productsSince(state, key, other.readSeq[e.path]).filter((p) => p !== e.path)
270 if (!products.length) continue
271 if (state.stale.some((s) => s.agent === key && s.path === e.path && !s.cleared)) continue
272 state.stale.push({ agent: key, path: e.path, readV, currentV: ver.v, writer: e.agent, products, seq: e.seq, cleared: false })
273 }
274 // The other order: this writer read an input, someone else changed it, and only now does
275 // the writer produce from the version they first saw. The check above never sees that,
276 // because it runs when the input changes, before the product exists.
277 // The conductor is left out of both checks: it reads to plan, and its only product is the
278 // score, so an input changing under it is the plan working, not stale work.
279 if (e.agent === CONDUCTOR) return
280 for (const [input, readV] of Object.entries(m.seen)) {
281 if (input === e.path) continue
282 const cur = latest(state.artifacts[input])
283 if (!cur || readV >= cur.v || sameContent(state.artifacts[input], readV, cur.v)) continue
284 if (state.stale.some((s) => s.agent === e.agent && s.path === input && !s.cleared)) continue
285 state.stale.push({ agent: e.agent, path: input, readV, currentV: cur.v, writer: cur.agent, products: [e.path], seq: e.seq, cleared: false })
286 }
287 },
288 'part.done'(state, e) {
289 const m = ensureMusician(state, e.agent)
290 m.state = 'done'
291 m.tool = null
292 m.answer = e.answer
293 },
294 'part.failed'(state, e) {
295 const m = ensureMusician(state, e.agent)
296 m.state = 'failed'
297 m.tool = null
298 m.answer = e.answer
299 m.failure = e.reason
300 },
301 'coda'(state, e) {
302 state.codaSeq = e.seq
303 },
304}
305
306// Files a musician wrote after a given moment: the work that depends on what it had read then.
307function productsSince(state, key, sinceSeq) {
308 return Object.values(state.artifacts)
309 .filter((a) => a.versions.some((v) => v.agent === key && v.seq > (sinceSeq ?? 0)))
310 .map((a) => a.path)
311}
312
313export function apply(state, e) {
314 const handle = handlers[e.type]
315 if (!handle) return state
316 state.events.push(e)
317 handle(state, e)
318 return state
319}
320
321export function fold(events) {
322 return events.reduce(apply, emptyState())
323}
324
325// The roster: the conductor plus every loop it assigned a part to. Other loops (another
326// plugin's hook agents, the engine's own forks) stay in the ledger but are not musicians.
327export const roster = (state) => state.order.filter((k) => state.musicians[k].assigned)
328export const musicians = (state) => roster(state).filter((k) => k !== CONDUCTOR).map((k) => state.musicians[k])
329export const isPerformance = (state) => musicians(state).length > 0
330export const nameOf = (state, key) => (state.musicians[key] ? state.musicians[key].name : key === 'outside' ? 'someone outside the ensemble' : key)
331
332export function counts(state) {
333 const ms = musicians(state)
334 const by = (s) => ms.filter((m) => m.state === s).length
335 return {
336 playing: by('playing'), waiting: by('waiting'), done: by('done'), failed: by('failed'),
337 changed: Object.values(state.artifacts).filter((a) => a.versions.some((v) => !NOBODY.has(v.agent))).length,
338 openConflicts: state.conflicts.filter((c) => !c.resolved).length,
339 openStale: state.stale.filter((s) => !s.cleared).length,
340 }
341}
342
343export function statusLine(state) {
344 const c = counts(state)
345 return [
346 'Orchestra',
347 `${c.playing} playing`,
348 c.waiting ? `${c.waiting} waiting` : null,
349 `${c.done} done`,
350 c.failed ? `${c.failed} failed` : null,
351 `${c.changed} artifacts changed`,
352 c.openConflicts ? `${c.openConflicts} conflict` : null,
353 c.openStale ? `${c.openStale} stale` : null,
354 ].filter(Boolean).join(' · ')
355}
356
357const rosterFailures = (state) => state.toolFailures.filter((f) => f.agent !== CONDUCTOR && state.musicians[f.agent]?.assigned)
358
359// Every sentence of the coda comes from ledger state, never from a musician's own prose.
360export function coda(state) {
361 const contributions = roster(state).map((k) => state.musicians[k]).filter((m) => m.writes > 0 || m.reads > 0).map((m) => ({
362 key: m.key, name: m.name, role: m.role, reads: m.reads, writes: m.writes, added: m.added, removed: m.removed,
363 artifacts: Object.values(state.artifacts).filter((a) => a.versions.some((v) => v.agent === m.key)).map((a) => a.path),
364 }))
365 const changed = Object.values(state.artifacts)
366 .filter((a) => a.versions.some((v) => !NOBODY.has(v.agent)))
367 .map((a) => ({ path: a.path, versions: a.versions.filter((v) => !NOBODY.has(v.agent)).length, authors: [...new Set(a.versions.filter((v) => !NOBODY.has(v.agent)).map((v) => nameOf(state, v.agent)))], hash: latest(a).hash }))
368 const conflicts = state.conflicts.map((c) => ({
369 ...c,
370 text: `${nameOf(state, c.agent)} tried to write ${c.path} over ${nameOf(state, c.against)}'s v${c.current} (${c.base === null ? 'never read it' : `had seen v${c.base}`}). ${
371 !c.resolved ? 'Still open.'
372 : c.choice === 'overwrite' ? 'You let the write go ahead.'
373 : c.choice === 'reask' ? 'You let the write go ahead, but the file changed before it landed, so you were asked again.'
374 : 'The write was sent back to re-read first.'}`,
375 }))
376 const failures = [
377 ...musicians(state).filter((m) => m.state === 'failed').map((m) => `${m.name}'s part ended with ${m.failure}.`),
378 ...rosterFailures(state).map((f) => `${nameOf(state, f.agent)}: ${f.tool} ${f.target} failed${f.recoveredSeq ? `; ${nameOf(state, f.recoveredBy || f.agent)} later ran it successfully` : '; not yet recovered'}.`),
379 ]
380 const stale = state.stale.map((s) => `${nameOf(state, s.agent)} produced ${s.products.join(', ')} from v${s.readV} of ${s.path}; ${nameOf(state, s.writer)} then wrote v${s.currentV}. ${
381 s.cleared ? `Redone from the current version by ${nameOf(state, s.clearedBy)}.` : 'Not redone.'}`)
382 const handoffs = state.handoffs.map((h) => `${nameOf(state, h.from)} → ${nameOf(state, h.to)}: ${h.path} v${h.v}${h.via === 'brief' ? ' (named in the brief)' : ''}`)
383 return { task: state.task, counts: counts(state), contributions, changed, conflicts, failures, stale, handoffs }
384}
385
386export function codaMarkdown(state, sessionFile) {
387 const c = coda(state)
388 const list = (items) => (items.length ? items.map((x) => `- ${x}`) : ['- none'])
389 return [
390 '# Orchestra coda',
391 '',
392 `Task: ${c.task || '(not recorded)'}`,
393 `Ledger: ${sessionFile}`,
394 `Status: ${statusLine(state)}`,
395 '',
396 '## Who contributed what',
397 ...list(c.contributions.map((x) => `${x.name} (${x.role}): ${x.reads} reads, ${x.writes} writes, +${x.added} −${x.removed}${x.artifacts.length ? `; wrote ${x.artifacts.join(', ')}` : ''}`)),
398 '',
399 '## Artifacts changed',
400 ...list(c.changed.map((a) => `${a.path}: ${a.versions} new version(s) by ${a.authors.join(', ')}; now sha256:${a.hash}`)),
401 '',
402 '## Handoffs',
403 ...list(c.handoffs),
404 '',
405 '## Conflicts',
406 ...list(c.conflicts.map((x) => x.text)),
407 '',
408 '## Failures',
409 ...list(c.failures),
410 '',
411 '## Stale reads',
412 ...list(c.stale),
413 '',
414 ].join('\n')
415}
416
417// Lines added and removed between two texts, counted as a multiset of lines.
418export function lineDelta(before, after) {
419 const count = (text) => {
420 const m = new Map()
421 for (const line of String(text ?? '').split('\n')) m.set(line, (m.get(line) || 0) + 1)
422 return m
423 }
424 const a = count(before)
425 const b = count(after)
426 let added = 0
427 let removed = 0
428 for (const [line, n] of b) added += Math.max(0, n - (a.get(line) || 0))
429 for (const [line, n] of a) removed += Math.max(0, n - (b.get(line) || 0))
430 if (before === null || before === undefined) removed = 0
431 return { added, removed }
432}
433hooks/views.js 227 lines1// What Orchestra draws. Every function takes the element constructors the mod resolved
2// for the surface, the folded ledger state, and the pane's own view state.
3
4import { CONDUCTOR, artifactStatus, coda, counts, musicians, nameOf, roster } from './ledger.js'
5import { paint } from './palette.js'
6
7export const GLYPH = { resting: '·', playing: '♪', waiting: '‖', done: '✓', failed: '✕' }
8export const TABS = [['ensemble', 'Ensemble'], ['score', 'Score'], ['artifacts', 'Artifacts'], ['coda', 'Coda']]
9const STATE_TOKEN = { playing: 'ok', waiting: 'warn', done: null, failed: 'danger', resting: null }
10const SCORE_NAME_COLUMNS = 11
11const READS_SHOWN = 8
12const HELD_LINES = 14
13
14export const short = (text, n) => {
15 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
16 return s.length > n ? `${s.slice(0, n - 1)}…` : s
17}
18
19const line = (el, parts) => el.Text({ wrap: 'truncate-end', children: parts })
20const dim = (el, text) => el.Text({ dimColor: true, children: [text] })
21const tone = (el, token, text, bold = false) => el.Text({ color: paint(token), bold, children: [text] })
22const who = (el, m) => tone(el, m.color, m.name, true)
23const glyphOf = (el, m) => (STATE_TOKEN[m.state] ? tone(el, STATE_TOKEN[m.state], GLYPH[m.state]) : el.Text({ children: [GLYPH[m.state]] }))
24
25export function band(el, state, width) {
26 const chunks = musicians(state).map((m) => el.Text({
27 key: m.key,
28 children: [who(el, m), ' ', glyphOf(el, m), ` ${m.measures}${m.tool ? ` ${m.tool.tool}` : ''}`],
29 }))
30 const c = counts(state)
31 const alerts = [
32 c.openConflicts ? tone(el, 'danger', `‼ ${c.openConflicts} conflict`, true) : null,
33 c.openStale ? tone(el, 'warn', `⚠ ${c.openStale} stale`) : null,
34 ].filter(Boolean)
35 return el.Box({
36 flexDirection: 'row', flexWrap: 'wrap', columnGap: 3, width,
37 children: [dim(el, '𝄐 Orchestra'), ...chunks, ...alerts],
38 })
39}
40
41export function pane(el, state, view, width) {
42 const tabs = el.Box({
43 flexDirection: 'row', columnGap: 3,
44 children: TABS.map(([id, label], i) => el.Button({
45 key: `tab-${id}`, label, hotkey: String(i + 1), plain: true, dimColor: view.tab !== id,
46 onPress: () => view.setTab(id),
47 })),
48 })
49 const render = { ensemble, score, artifacts, coda: codaTab }[view.tab]
50 return el.Box({ flexDirection: 'column', width, children: [tabs, el.Text({ children: [' '] }), ...render(el, state, view, width)] })
51}
52
53const detailsButton = (el, view, type, id) => el.Button({
54 key: `pick-${type}-${id}`, plain: true,
55 label: view.selected?.type === type && view.selected?.id === id ? '▾ details' : '▸ details',
56 onPress: () => view.select(type, id),
57})
58
59function ensemble(el, state, view, width) {
60 const c = counts(state)
61 const rows = roster(state).map((key) => {
62 const m = state.musicians[key]
63 const activity = m.tool ? `${m.tool.tool} ${m.tool.target}` : m.part || (key === CONDUCTOR ? 'Coordinating the ensemble' : m.role)
64 const stale = state.stale.some((s) => s.agent === key && !s.cleared)
65 const open = view.selected?.type === 'musician' && view.selected.id === key
66 return el.Box({
67 key: `seat-${key}`, flexDirection: 'column', marginBottom: 1,
68 children: [
69 el.Box({ flexDirection: 'row', columnGap: 1, children: [
70 who(el, m), dim(el, `${m.role} ·`), glyphOf(el, m), dim(el, m.state), detailsButton(el, view, 'musician', key),
71 ] }),
72 line(el, [dim(el, ` ${short(activity, width - 3)}`)]),
73 key === CONDUCTOR ? null : line(el, [` ${m.measures} measures · ${m.reads} reads · ${m.writes} writes`, stale ? tone(el, 'warn', ' ⚠ stale read') : '']),
74 ...(open ? musicianDetail(el, state, key, width) : []),
75 ].filter(Boolean),
76 })
77 })
78 return [line(el, [`${c.playing} playing in parallel · ${c.done} done`]), el.Text({ children: [' '] }), ...rows]
79}
80
81function musicianDetail(el, state, key, width) {
82 const m = state.musicians[key]
83 const seen = Object.entries(m.seen).map(([p, v]) => {
84 const now = state.artifacts[p].versions.length - 1
85 return line(el, [` ${short(p, width - 18)} v${v}`, v < now ? tone(el, 'warn', ` (now v${now})`) : ''])
86 })
87 return [
88 dim(el, ` ${m.name} · ${m.type}`),
89 m.prompt ? el.Text({ wrap: 'wrap', dimColor: true, children: [` Part: ${short(m.prompt, 300)}`] }) : null,
90 el.Text({ children: [' Versions it has seen:'] }),
91 ...(seen.length ? seen : [dim(el, ' none yet')]),
92 m.answer ? el.Text({ wrap: 'wrap', dimColor: true, children: [` Answer: ${short(m.answer, 400)}`] }) : null,
93 ].filter(Boolean)
94}
95
96// One staff per musician, the newest events on the right; glyphs coloured by meaning.
97function score(el, state, view, width) {
98 const cols = Math.max(10, width - SCORE_NAME_COLUMNS - 1)
99 const events = state.events.slice(-cols)
100 const conflictSeqs = new Set(state.conflicts.map((c) => c.seq))
101 const staleSeqs = new Set(state.stale.map((s) => `${s.seq}:${s.agent}`))
102 const handoffIn = new Set(state.handoffs.map((h) => `${h.seq}:${h.to}`))
103 const glyph = (e, key, color) => {
104 if (staleSeqs.has(`${e.seq}:${key}`)) return ['⚠', 'warn']
105 if (e.agent === key) {
106 if (e.type === 'write.attempt' && conflictSeqs.has(e.seq)) return ['‼', 'danger']
107 if (e.type === 'artifact.read') return [handoffIn.has(`${e.seq}:${key}`) ? '←' : '○', color]
108 if (e.type === 'part.assigned' && handoffIn.has(`${e.seq}:${key}`)) return ['←', color]
109 const g = { 'tool.call': ['·', color], 'artifact.write': ['●', color], 'part.assigned': ['◆', color], 'part.done': ['✓', 'ok'], 'part.failed': ['✕', 'danger'] }[e.type]
110 if (g) return g
111 if (e.type === 'tool.result' && !e.ok && !e.sentBack) return ['✕', 'danger']
112 return [' ', null]
113 }
114 if (key === CONDUCTOR) {
115 if (e.type === 'part.assigned') return ['◆', color]
116 if (e.type === 'conflict.resolved') return ['?', 'warn']
117 if (e.type === 'coda') return ['𝄐', color]
118 }
119 return [' ', null]
120 }
121 const staff = (key, color) => {
122 const runs = []
123 for (const e of events) {
124 const [g, token] = glyph(e, key, color)
125 const last = runs[runs.length - 1]
126 if (last && last.token === token) last.text += g
127 else runs.push({ token, text: g })
128 }
129 return runs.map((r, i) => (r.token ? el.Text({ key: `r${i}`, color: paint(r.token), children: [r.text] }) : el.Text({ key: `r${i}`, children: [r.text] })))
130 }
131 const lanes = roster(state).map((key) => {
132 const m = state.musicians[key]
133 return el.Box({ key: `lane-${key}`, flexDirection: 'row', children: [
134 el.Box({ width: SCORE_NAME_COLUMNS, children: [el.Text({ color: paint(m.color), wrap: 'truncate-end', children: [m.name] })] }),
135 el.Text({ wrap: 'truncate-end', children: staff(key, m.color) }),
136 ] })
137 })
138 const first = events.length ? events[0].seq : 0
139 const lastSeq = events.length ? events[events.length - 1].seq : 0
140 return [
141 dim(el, `Events ${first}–${lastSeq} of the ledger, newest on the right`),
142 ...lanes,
143 el.Text({ children: [' '] }),
144 el.Text({ dimColor: true, wrap: 'wrap', children: ['○ read ← used another\'s work ● write · tool ◆ assigned ✓ done ✕ failed ‼ conflict ⚠ stale ? you decided'] }),
145 ]
146}
147
148const lastSeq = (a) => Math.max(0, ...a.versions.map((v) => v.seq), ...a.reads.map((r) => r.seq))
149
150function artifacts(el, state, view, width) {
151 const list = Object.values(state.artifacts).sort((a, b) => lastSeq(b) - lastSeq(a))
152 if (!list.length) return [dim(el, 'No artifacts touched yet.')]
153 return list.map((a) => {
154 const status = artifactStatus(state, a.path)
155 const authors = a.versions.filter((v) => v.agent !== 'repo').map((v) => nameOf(state, v.agent))
156 const open = view.selected?.type === 'artifact' && view.selected.id === a.path
157 return el.Box({ key: `art-${a.path}`, flexDirection: 'column', marginBottom: 1, children: [
158 el.Box({ flexDirection: 'row', columnGap: 1, children: [
159 el.Text({ wrap: 'truncate-end', children: [short(a.path, width - 26)] }),
160 status === 'conflict' ? tone(el, 'danger', 'conflict', true) : status === 'stale' ? tone(el, 'warn', 'stale') : dim(el, `v${a.versions.length - 1}`),
161 detailsButton(el, view, 'artifact', a.path),
162 ] }),
163 line(el, [dim(el, authors.length ? ` written by ${authors.join(' → ')} · ${a.reads.length} reads` : ` read only · ${a.reads.length} reads`)]),
164 ...(open ? artifactDetail(el, state, view, a.path, width) : []),
165 ] })
166 })
167}
168
169function artifactDetail(el, state, view, path, width) {
170 const a = state.artifacts[path]
171 const versions = a.versions.map((v) => line(el, [
172 ` v${v.v} ${v.agent === 'repo' ? 'as first seen' : `by ${nameOf(state, v.agent)}`}`,
173 v.base !== null && v.base !== undefined ? ` from v${v.base}` : '',
174 v.agent === 'repo' || v.agent === 'outside' ? '' : ` +${v.added} −${v.removed}`,
175 v.via === 'bash' ? dim(el, v.uncertain ? ' via Bash, attribution uncertain' : ' via Bash') : '',
176 dim(el, ` sha256:${v.hash.slice(0, 12)}`),
177 v.base !== null && v.base !== undefined && v.base < v.v - 1 ? tone(el, 'danger', ` skipped v${v.v - 1}`) : '',
178 ]))
179 const reads = a.reads.slice(-READS_SHOWN).map((r) => dim(el, ` ${nameOf(state, r.agent)} read v${r.v} at event ${r.seq}`))
180 const conflicts = state.conflicts.filter((c) => c.path === path).map((c) => line(el, [
181 c.resolved ? tone(el, 'ok', ' ✓ ') : tone(el, 'danger', ' ‼ '),
182 `${nameOf(state, c.agent)} wrote from v${c.base ?? '-'} over ${nameOf(state, c.against)}'s v${c.current}${c.resolved ? ` · ${c.choice}` : ' · open'}`,
183 ]))
184 const held = view.held && view.held.path === path ? [el.Text({ children: [' '] }), ...heldWrite(el, view.held, width)] : []
185 return [
186 el.Text({ children: [' Versions:'] }), ...versions,
187 el.Text({ children: [' Reads:'] }), ...(reads.length ? reads : [dim(el, ' none')]),
188 ...(conflicts.length ? [el.Text({ children: [' Conflicts:'] }), ...conflicts] : []),
189 ...held,
190 ]
191}
192
193// Shown while a write is held: the file as it is now, beside what the writer is about to do.
194// `lines` caps each side; above the question dialog Claude Code allows at most 12 rows.
195export function heldWrite(el, held, width, lines = HELD_LINES) {
196 const clip = (items) => items.filter((l) => l.trim()).slice(0, lines).map((l) => short(l, width - 8))
197 const sinceSeen = held.changed !== null
198 const theirs = sinceSeen ? held.changed : String(held.currentText ?? '').split('\n')
199 const seen = held.base === null ? `${held.writer} never read it` : `${held.writer} saw v${held.base}`
200 return [
201 tone(el, 'warn', `Held write by ${held.writer} (${held.tool}) on ${short(held.path, width - 30)}`, true),
202 dim(el, sinceSeen ? `Added by ${held.against} since ${seen} (now v${held.current}):` : `Now v${held.current} by ${held.against}:`),
203 ...clip(theirs.length ? theirs : ['(no lines added; lines were only removed)']).map((l, i) => el.Text({ key: `cur${i}`, children: [` ${sinceSeen ? '+ ' : ''}${l}`] })),
204 dim(el, `${held.writer} wants to ${held.tool === 'Write' ? 'write' : 'replace'}:`),
205 ...clip(String(held.proposed ?? '').split('\n')).map((l, i) => el.Text({ key: `new${i}`, color: paint('warn'), children: [` ${l}`] })),
206 ]
207}
208
209function codaTab(el, state, view, width) {
210 const c = coda(state)
211 const section = (title, items) => [
212 el.Text({ bold: true, children: [title] }),
213 ...(items.length ? items.map((t, i) => el.Text({ key: `${title}${i}`, wrap: 'wrap', children: [` ${t}`] })) : [dim(el, ' none')]),
214 ]
215 return [
216 el.Text({ wrap: 'wrap', children: [`Task: ${short(c.task || 'not recorded', width * 3)}`] }),
217 dim(el, view.codaPath ? `Written to ${view.codaPath}` : 'The coda file is written when the conductor\'s turn ends.'),
218 el.Text({ children: [' '] }),
219 ...section('Who contributed what', c.contributions.map((x) => `${x.name}: ${x.reads} reads, ${x.writes} writes, +${x.added} −${x.removed}`)),
220 ...section('Artifacts changed', c.changed.map((a) => `${a.path} · ${a.versions} new · ${a.authors.join(', ')}`)),
221 ...section('Handoffs', c.handoffs),
222 ...section('Conflicts', c.conflicts.map((x) => x.text)),
223 ...section('Failures', c.failures),
224 ...section('Stale reads', c.stale),
225 ]
226}
227hooks/palette.js 38 lines1// Colour tokens (PRD §6.4). Each has a dark-theme value, a light-theme value and an
2// ANSI name used when the terminal does not advertise truecolor. Every token is at least
3// 4.5:1 against #0c1018 (dark) and #ffffff (light).
4
5export const TOKENS = {
6 conductor: { dark: '#c3a6ff', light: '#6b3fd4', ansi: 'cyan' },
7 violin: { dark: '#7eaaff', light: '#2457d6', ansi: 'blue' },
8 trumpet: { dark: '#f2b950', light: '#8a5a00', ansi: 'yellow' },
9 flute: { dark: '#5fd3a8', light: '#1d7a4f', ansi: 'green' },
10 timpani: { dark: '#ff8c6e', light: '#b4441e', ansi: 'red' },
11 cello: { dark: '#d78cff', light: '#8a2fb4', ansi: 'magenta' },
12 guest: { dark: '#a8b3c7', light: '#4f5b70', ansi: 'white' },
13 ok: { dark: '#6bdc9a', light: '#1d7a4f', ansi: 'green' },
14 warn: { dark: '#ffc65c', light: '#8a5a00', ansi: 'yellow' },
15 danger: { dark: '#ff6b6b', light: '#b42318', ansi: 'red' },
16}
17
18let mode = 'dark'
19
20// 'dark', 'light' or 'ansi'.
21export function setColorMode(next) {
22 mode = next
23}
24
25export const colorMode = () => mode
26
27export function paint(token) {
28 const t = TOKENS[token] || TOKENS.guest
29 return t[mode] || t.dark
30}
31
32// Claude Code's theme names include "dark", "light", "light-daltonized", "dark-ansi" and so on.
33export function modeFor(theme, colorterm) {
34 const name = String(theme || '')
35 if (name.includes('ansi') || !/truecolor|24bit/i.test(String(colorterm || ''))) return 'ansi'
36 return name.startsWith('light') ? 'light' : 'dark'
37}
38