SLOPSHOPPER

orchestra

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…

newpanebandspinnerrowsguard
v0.1.2MITupdated 2026-10-08krzemienski/orchestra-cc/plugin
A shopper browsing a rack in a slop shop
Preview could not run: the mod kept running for 20s in the preview session (a hold loop or a long wait); no drawing was captured
README

Orchestra

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.

MusicianSubagent typeRole
Violinorchestra:violinScout: maps code, writes notes, never edits source
Trumpetorchestra:trumpetImplementer: changes source code
Fluteorchestra:fluteScribe: writes documentation
Timpaniorchestra:timpaniTester: runs tests and commands, saves logs
Celloorchestra:celloReviewer: reports problems, never edits

Install

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

Use

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.

CommandWhat it does
/orchestraOpens the Orchestra pane (tabs: Ensemble, Score, Artifacts, Coda). Esc closes it. Keys 1–4 switch tabs.
/orchestra codaPrints the coda for this session from the ledger
/orchestra ledgerPrints the ledger's path, event count and status
/orchestra closeCloses the pane

What you see

  • The band above the prompt lists every musician with a state glyph and the measures played so far:
  • ♪ playing, ‖ waiting on you, ✓ done, ✕ failed.
  • One measure is one completed tool call.
  • The tool a musician is running right now is shown beside it.
  • Open conflicts and stale reads are flagged.
  • The pane (beside the transcript in a wide terminal, above the prompt in a narrow one):
  • Ensemble: every musician's role, state, current activity and read and write counts. Select one to see its part, the version of every file it has seen, and its answer.
  • Score: one staff per musician, newest events on the right. ○ read, ← read another's work, ● write, ‼ conflict, ⚠ stale read, ✓ done, ✕ failed.
  • Artifacts: every file touched, with its versions. Select one to see each version's author, base version, line delta and SHA-256, who read which version, and its conflicts.
  • Coda: who contributed what, artifacts changed, handoffs, conflicts, failures and stale reads, all computed from the ledger.
  • The status line under the prompt, for example Orchestra · 2 playing · 1 done · 3 artifacts changed · 1 conflict.
  • Toasts for handoffs, stale reads and failures as they happen, for each conflict once it is decided, and when the coda is ready.
  • The spinner gains · 2 musicians playing.
  • Agent tool rows are labelled with the instrument, its role, its state and its measures. When several parts start in one message, Claude Code draws a single summary row that a mod cannot redraw, so those parts are not labelled there.

Data model

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:

EventRecorded when
part.assignedThe conductor's Agent call started a subagent. Carries the subagent's id, type, description and prompt.
tool.call, tool.resultA musician called a tool, and how it ended
artifact.readA musician read a file. Carries the file's SHA-256 at that moment.
write.attemptA write is about to run. Carries the SHA-256 of the file it is about to change.
artifact.writeThe write landed. Carries the new SHA-256 and lines added and removed.
conflict.resolvedYour decision on a conflict
part.done, part.failedThe subagent's turn ended
codaThe coda file was written

Everything else is derived from versions, never declared by the agents:

  • Conflict: a musician tries to write a file whose current version is not the one it last saw. Example: Trumpet read v0, Trumpet 2 wrote v1, and Trumpet now edits from v0. Orchestra holds the write and asks in Claude Code's own question dialog: Let it write, Send it back to re-read, or Show both versions, which draws the lines the other musician added and the held change directly above the question before asking again. Sending it back denies the edit and tells the musician to re-read and reapply its change. In a claude -p run nobody can answer, so the write is sent back.
  • Stale read: a musician produced other work after reading a version that someone has since replaced. It clears when that musician reads the current version.
  • Handoff: a musician read a version that another musician wrote.
  • Coda: written to .orchestra/performances/<session-id>-coda.md at the end of each conductor turn, from the ledger alone.

Interactive prototype

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.

Research

  • 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.

Limitations

  • Mod drawing appears only in the Claude Code terminal and the Desktop app's Code tab. In the VS Code extension, claude -p and cloud sessions, the ledger, conflict guard and coda still work, but nothing is drawn.
  • A change a musician makes with Bash (for example 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.
  • A command that hides its own exit code (for example 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.
  • If one of Orchestra's own hooks fails, Claude Code skips it and the tool runs anyway (fail open), so Orchestra can never block your work. claude plugin validate reports this as gating hook without .catch: tool.call.
  • Progress is counted in measures (completed tool calls), not as a fraction of a known total, because Claude Code exposes no step plan for a subagent.
  • Hot reload of the mod waits until the current turn ends. After a reload, or after claude --continue, the session's ledger is replayed, so the picture is rebuilt rather than lost.
  • Colours follow your Claude Code theme (dark, light or ANSI) within 2 seconds of a change. On a 256-colour terminal the hex colours are approximated.
  • The mods API is new: it was added in 2.1.287, and the events and methods may change between releases. This version was tested against 2.1.289.
  • Agent teams (split-pane teammates in separate processes) were not tested.

License

MIT

Source 4 files
hooks/register.js 569 lines
1// 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}
569
hooks/ledger.js 433 lines
1// 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}
433
hooks/views.js 227 lines
1// 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}
227
hooks/palette.js 38 lines
1// 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