SLOPSHOPPER

workflow-conductor

Visual workflow planner in Claude Code

newpanebandrowsguardcommand
★ 10v0.3.7MITupdated 2026-10-15bruw/workflow-conductor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow-conductor
│ ┃ Conductor project ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Conductor project: no room here for the │ ┃ designer, it goes to the band above the ⏺ Read(src/auth.ts) │ ┃ prompt ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /conductor │ ⎿ workflow-conductor: Open a saved project in the pane, or start a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Conductor project
◆ Conductor project: no room here for the designer, it goes to the band above the prompt
Pane · conductor
◆ Conductor ╰ no workflow running yet
README

Conductor in Claude Code: open the board, build a workflow by keys and mouse, watch the run on the graph, minimize it

Claude Code Mod that adds a UI for planning, running and understanding agent Workflows.

Claude writes a long orchestration script and proposes to execute. You say yes. Most everybody says yes. Nobody reads the script. Then the agents go off somewhere you can't see, and you sit watching a counter, hoping that what's burning your tokens is the thing you meant.

Conductor adds an option to create a visual plan first. You can read it in 10 sec, verify the logic before handing off to Claude to write the script. It costs like a Plan, and can save millions of tokens over a workflow done badly.

/plugin install workflow-conductor --marketplace bruw/workflow-conductor

What you get

Design: draw it yourself, start from a template, or let Claude draft it from a description and refine it in plain words.

Check: common design mistakes are flagged on the graph before you run.

Run: hands the spec to Claude to implement and run

Monitor: progress, failures and token cost show up on the plan you made.

Learn: block kinds are named multi-agent patterns, and you can compare each design with the script it produced. That makes it a practical way to learn how good workflows are built.

How to use

Type /conductor review this PR thoroughly. Claude drafts this and puts it on the board:

╭───────────────────────────────────────────────────────────────────── n6 ─╮
│ START                                                                    │
│ Review the current diff along several dimensions and keep only findings… │
╰──────────────────────────────────────────────────────────────────────────╯
                                     ↓  [+]
╭───────────────────────────────────────────────────────────────────── n1 ─╮
│ AGENT scout / scout diff                                             low │
│ Cheap and mechanical. Its list of dimensions is the work-list for the p… │
╰──────────────────────────────────────────────────────────────────────────╯
                                     ↓  [+]
┌╴ PIPELINE / review · once per: ×4 dimensions ╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴ n2 ╴┐
╎ ╭───────────────────────────────────────────────────────────────── n3 ─╮ ╎
╎ │ AGENT / review                                                       │ ╎
╎ │ One reviewer per dimension.                                          │ ╎
╎ ╰──────────────────────────────────────────────────────────────────────╯ ╎
╎                                    ↓  [+]                                ╎
╎ ╭───────────────────────────────────────────────────────────────── n4 ─╮ ╎
╎ │ AGENT verify / verify findings                                       │ ╎
╎ │ 3 votes: each skeptic tries to refute a finding and defaults to ref… │ ╎
╎ ╰──────────────────────────────────────────────────────────────────────╯ ╎
└╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴ [+] ╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴┘
                                 all ⇓  [+]
╭───────────────────────────────────────────────────────────────────── n5 ─╮
│ AGENT merge                                                              │
│ Runs once over all surviving findings: merge duplicates, order by sever… │
╰──────────────────────────────────────────────────────────────────────────╯

You can read that in ten seconds.

Don't like it? Say so, in plain words: "add a verify with 3 votes after n3", "set this block to low effort".

Then press r. Claude confirms the design, asks what to run it on, writes a real Workflow script from the project, and runs it. The board becomes the run:

╭───────────────────────────────────────────────────────────────────── n1 ─╮
│ AGENT scout / scout diff                                             low │
│ Cheap and mechanical. Its list of dimensions is the work-list for the p… │
╰─ ✗ 3/4 done · 1 failed · 20.4k ──────────────────────────────────────────╯
                                     ↓
┌╴ PIPELINE / review · once per: ×4 dimensions ╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴ n2 ╴┐
╎ ╭───────────────────────────────────────────────────────────────── n3 ─╮ ╎
╎ │ AGENT / review                                                       │ ╎
╎ │ One reviewer per dimension.                                          │ ╎
╎ ╰─ ✓ 4/4 done · 30.6k ─────────────────────────────────────────────────╯ ╎
╎                                    ↓                                     ╎
╎ ╭───────────────────────────────────────────────────────────────── n4 ─╮ ╎
╎ │ AGENT verify / verify findings                                       │ ╎
╎ │ 3 votes: each skeptic tries to refute a finding and defaults to ref… │ ╎
╎ ╰─ ✓ 4/4 done · 40.8k ─────────────────────────────────────────────────╯ ╎
└╴ ✓ 8/8 done · 71.4k ╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴╴┘
                                 all ⇓
╭───────────────────────────────────────────────────────────────────── n5 ─╮
│ AGENT merge                                                              │
│ Runs once over all surviving findings: merge duplicates, order by sever… │
╰─ · not run ──────────────────────────────────────────────────────────────╯

Why a man might want it

  • You approve a design, not a script.
  • The project is a file in your repo. .claude/workflow-conductor/<name>.json, with the script it was last run as beside it. It diffs. It reviews. The next run changes only what the project changed.
  • It tells you what's wrong and stops nobody. A loop with no stop condition, a verify with nothing to check, a branch arm with no condition: each gets a ! on its card. You can run it anyway. It's your money.
  • It works on runs you never designed. When Claude starts a workflow on its own, Conductor asks once whether to draw it. Say yes and the run shows up on a graph of the script that's already running.

Cost

What Claude spends getting a workflow to run, before the run's own agents. Estimates, in tokens.

FlowDesignHand-off (r)
Draw it yourself, press r~0k: the board makes no Claude calls~10–20k, what Claude spends writing any Workflow script, but from a full spec
/conductor <description…>, press r~5–15k for the draft, like Plan mode~10–20k
Draft, q for a review, fix, press r~10–25k, like Plan mode plus a round of refinement~10–20k

The parts

Eight blocks to rule them all. Three do work: agent, code, subworkflow. Four hold other blocks: pipeline, parallel, loop, branch. And start, which carries the goal.

A pattern is a kind of a block, and picking one fills in what the pattern needs:

BlockKinds
agenttask · scout · check · verify · judge · critic · merge · classify
loopcustom · count · pass · dry · converged · target · budget
parallelarms · each · sweep · attempts
branchif-else · guard · route

Four templates to start from (Review, Research, Migrate, Judge panel), or a blank board.

Getting around

/conductorthe board: the live run, else the last project, else the start screen
/conductor <description…>Claude drafts a new project from your words
/conductor min · maxfold the designer away, bring it back
KeyDoes
w a s dmove the selection; with Shift, move the block
Enteredit the block's fields
n x tadd a block, delete it, change its type or kind
1 2step the model, the effort
o bopen a container as the view, back up a level
u yundo, redo: fifty steps a project
rimplement and run
qask Claude to review the design
pprojects and templates
?every other key, and the mouse

The mouse works too: click to select, drag the selected card to move it, wheel to scroll.

What you need

  • Claude Code 2.1.296 or later, with the Workflow tool.
  • A terminal 110 columns wide, in the fullscreen layout. Narrower than that, you get the compact strip and no designer; widen it and the designer opens by itself.
  • Fullscreen for the mouse. The keys work without it.

Hack it

MIT. Get busy designing.

claude --plugin-dir /path/to/conductor

In the desktop app, where no flag can be given, the env of your user settings takes it:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/conductor" } }
  • bun install, then bun run check: the plugin validation, tsc and the tests.
  • bun scripts/render-graph.ts --list names what it can draw, and draws it with no session.
  • scripts/dev-session.sh starts Claude Code with the mod in tmux; scripts/send.sh sends it keys, scripts/capture.sh prints the screen and the debug lines.
  • src/ is pure logic; hooks/ is what Claude Code loads.

What it touches

Conductor makes no network requests and reads no settings or credentials.

AreaWhat Conductor doesWhat it never does
FilesWrites only .claude/workflow-conductor/<name>.json under the session's root; <name> is always kebab-case. The <name>.workflow.js beside it is the project's Workflow script: Claude writes it with its own tools on a hand-off, and the Workflow tool runs it.Write a file another tool runs, settings, CLAUDE.md, build or start-up files. Delete or rename a file: to remove or rename a project, remove or rename its files.
ProgramsNothing.Run any program.
PromptsSubmits a prompt to your own session in two cases. r: Implement and run the "<name>" workflow project (Conductor hands it off: call mcp__workflow-conductor__before_workflow first). /conductor <description>: Draft a workflow project: <description>, your own words. q and the draft key only put a sentence in the prompt box for you to send.Put file content or conversation text in a prompt it submits: only the project name, or your own words. Read the prompt box beyond whether it is empty.
ToolsStands in for two tools, both its own, on every call: its tool.call hook returns the result, and no other tool runs in their place. It is the only way a mod serves a tool it adds. mcp__workflow-conductor__before_workflow (hooks/offer.tsx): Claude calls it before writing or running a Workflow script; Conductor asks you whether to design the workflow on the board first and answers with your choice, or with the one you told it to keep; subagents and -p runs get the standard answer without a question. mcp__workflow-conductor__project (hooks/project.tsx): Claude calls it to read and edit a project (read, apply, write, attach); the project's goal, blocks and file paths reach Claude this way and in notes on the prompts you send while the board is open.Answer another tool's call in that tool's place.
Claude's toolsRefreshes the board after a project file is written and tracks a started run. Refuses a write or edit of a project file while that project is running. Asks once whether to draw a Workflow run no project claims: a yes, or what you type instead, is added to the tool's result as a note to Claude.Change Claude's own Write, Edit and Workflow calls otherwise.
Hooks/conductor (command.run, on that command name alone): opens the board or the run view, folds or expands the designer, or starts a draft, the same whoever runs the command, you or other code. Subagent starts, model requests and turn ends (agent.spawn, turn.step, turn.complete): reads a workflow agent's id and label, a subagent's token usage and how its turn ended, to draw the run, and passes each on unchanged. The prompt box (prompt.edit): while the board is taking the keyboard back, one letter typed into the empty box goes to the board as a key instead.Hook any other command. Read a prompt, instruction or tool description in the run hooks.
Source 37 files
hooks/register.tsx 17 lines
1import type { Register } from 'claude-code'
2
3import { createTracker } from '../src/run/tracker'
4
5import { registerProject } from './project'
6import { registerOffer } from './offer'
7import { registerRun } from './run'
8
9// Composes only: run hooks live in run.tsx, project hooks in project.tsx, the question before a workflow in offer.tsx.
10// They share the one tracker, so a handoff can tell it which project the next run is.
11export const register: Register = on => {
12  const tracker = createTracker()
13  registerRun(on, tracker)
14  registerProject(on, tracker)
15  registerOffer(on, tracker)
16}
17
src/run/tracker.ts 490 lines
1import { endText, statusText } from '../ui/rail'
2
3import { emptyRunState } from './events'
4import type { RunEvent, RunState } from './events'
5import { journalPhases, journalResults } from './journal'
6import { reduce, remap } from './reducer'
7import type { Mapping } from './reducer'
8import { replayDue } from './replay'
9
10// Decides when things happen for a live run: which agents count, when the state is written, the journal read, the run ended.
11// Reaches everything outside through a TrackerHost, so it runs under a fake host in tests.
12
13export const TICK_MS = 96
14/**
15 * Every agent that ended since the last spawn was aborted, none runs, and no spawn came for this long: the run was stopped
16 * where the engine sends no notice of it (the workflow view). A skipped agent is aborted too, and its run spawns on at once.
17 */
18export const STOPPED_MS = 8000
19/** A handoff waits this long for a run under another name than its project's; a prompt from the person starts the wait again. */
20export const HANDOFF_MS = 180_000
21/** A slow replay spreads its events over this long. */
22export const REPLAY_MS = 10_000
23/** An agent neither heard from (a spawn, a usage count) nor ended for this long is held failed, so its run can end. */
24export const STALE_MS = 30 * 60_000
25
26// In ticks. A full state write may go out every tick (~10/s); the rest are slower.
27const USAGE_EVERY = 5
28const JOURNAL_EVERY = 5
29const PANE_EVERY = 10
30/** Journal reads an agent may miss before its phase is given up on. */
31const JOURNAL_TRIES = 8
32const STALE_TICKS = Math.ceil(STALE_MS / TICK_MS)
33
34export type Timer = { cancel: () => void }
35
36export type TrackerHost = {
37  write: (state: RunState) => unknown
38  /** Stores the tick clock (ms), which the pane's spinner follows. */
39  frame: (now: number) => unknown
40  readText: (path: string) => Promise<string>
41  every: (ms: number, fn: () => void) => Timer
42  after: (ms: number, fn: () => void) => Timer
43  paneShown: () => Promise<boolean>
44  status: (text: string | undefined) => unknown
45  toast: (text: string) => unknown
46  /** Null: the run was not started from a project. */
47  project?: (name: string | null) => unknown
48}
49
50/** A project's claim on a run: handed off, the next run to start is its run; adopted, the run already tracked is. */
51export type Handoff = { name: string; nodeIds: readonly string[]; titles?: Readonly<Record<string, string>>; match?: Readonly<Record<string, readonly string[]>> }
52
53type Claim = Mapping & { name: string; titles?: Readonly<Record<string, string>> }
54
55const claimOf = (from: Handoff): Claim => ({ name: from.name, ids: new Set(from.nodeIds), titles: from.titles, match: from.match })
56
57/** `project`: the saved project the run's workflow is named as; the run is that project's, handed off or not. */
58export type RunStarted = { runId: string; name?: string; transcriptDir?: string; taskId?: string; project?: Handoff }
59export type AgentSpawned = { runId: string; agentId: string; label: string; index?: number }
60export type AgentEnded = { ok: boolean; aborted?: boolean; durationMs?: number }
61
62export type Tracker = ReturnType<typeof createTracker>
63
64const hasRunning = (state: RunState): boolean => Object.values(state.agents).some(agent => agent.status === 'running')
65
66type Ending = 'done' | 'failed' | 'stopped'
67
68/** The words a task notification ends with: `completed`, `failed`, `killed`, or another its producer wrote. */
69const endingOf = (status: string): Ending => (status === 'completed' ? 'done' : status === 'killed' ? 'stopped' : 'failed')
70
71export function createTracker() {
72  let host: TrackerHost | undefined
73  let state = emptyRunState()
74  /** 0 clean, 1 token counts only, 2 anything else. */
75  let dirty: 0 | 1 | 2 = 0
76  let ticker: Timer | undefined
77  let stopWait: Timer | undefined
78  let replaying: Timer | undefined
79  let ticks = 0
80  let lastRead = -JOURNAL_EVERY
81  let reading = false
82  let shown = false
83  let lastStatus: string | undefined
84  /** The handoff waiting for its run, and the one the tracked run came from. */
85  let pending: Claim | undefined
86  let active: Claim | undefined
87  let expiry: Timer | undefined
88  /** The project whose handoff is being worked on: from the handoff until the person's next prompt after its claim is spent. A run of it meanwhile (the first, a retry) comes out of the handoff. */
89  let handed: string | undefined
90  let replayed = false
91  /** Agents ended since the last spawn, and whether every one of them was aborted. */
92  let ends = 0
93  let abortedOnly = true
94
95  /** Agents of the tracked run still running, with their latest request's token count: its context, which the built-in workflow row shows. */
96  const latest = new Map<string, number>()
97  /** Agents waiting for a phase, with the journal reads that missed them. */
98  const noPhase = new Map<string, number>()
99  /** The tick each agent was last heard from: its spawn, or a usage count. */
100  const heard = new Map<string, number>()
101  /** Runs a newer one replaced: their late agents are not this run's. */
102  const superseded = new Set<string>()
103
104  // A host call must never break tracking: its failure is the host's to report.
105  const call = (fn: (host: TrackerHost) => unknown): void => {
106    if (!host) return
107    try {
108      Promise.resolve(fn(host)).catch(() => {})
109    } catch {
110      // ignored
111    }
112  }
113
114  const isDirty = (): boolean => dirty !== 0
115
116  const syncStatus = (): void => {
117    const want = state.status === 'running' && !shown ? statusText(state, active?.titles) : undefined
118    if (want === lastStatus) return
119    lastStatus = want
120    call(h => h.status(want))
121  }
122
123  const flush = (): void => {
124    dirty = 0
125    const snapshot = state
126    call(h => h.write(snapshot))
127    syncStatus()
128  }
129
130  const pollPane = (): void => {
131    if (!host) return
132    host.paneShown().then(
133      isShown => {
134        if (isShown === shown) return
135        shown = isShown
136        syncStatus()
137      },
138      () => {},
139    )
140  }
141
142  const cancelStopWait = (): void => {
143    stopWait?.cancel()
144    stopWait = undefined
145  }
146
147  const looksStopped = (): boolean => ends > 0 && abortedOnly && !hasRunning(state)
148
149  const armStopWait = (): void => {
150    if (!host || stopWait) return
151    stopWait = host.after(STOPPED_MS, () => {
152      stopWait = undefined
153      if (state.status === 'running' && looksStopped()) conclude('stopped')
154    })
155  }
156
157  /** Ends the run as the engine says it ended. Agents still open (an end missed, as across a reload) take their journal result, else fail. */
158  function conclude(ending: Ending, durationMs?: number): void {
159    const runId = state.runId
160    if (runId === undefined) return
161    cancelStopWait()
162
163    const close = (results: Map<string, boolean>): void => {
164      if (state.runId !== runId) return
165      const open = Object.values(state.agents).filter(agent => agent.status === 'running')
166      for (const agent of open) latest.delete(agent.agentId)
167      noPhase.clear()
168      apply([
169        ...open.map((agent): RunEvent => ({ kind: 'agent-end', agentId: agent.agentId, ok: results.get(agent.agentId) ?? false })),
170        { kind: 'run-end', runId, ok: ending === 'done', stopped: ending === 'stopped', durationMs },
171      ])
172    }
173
174    const dir = state.transcriptDir
175    if (!host || dir === undefined || !hasRunning(state)) {
176      close(new Map())
177
178      return
179    }
180    host.readText(`${dir}/journal.jsonl`).then(
181      text => close(journalResults(text)),
182      () => close(new Map()),
183    )
184  }
185
186  const readJournal = (): void => {
187    lastRead = ticks
188    const runId = state.runId
189    const dir = state.transcriptDir
190    if (!host || reading) return
191
192    const settle = (phases: Map<string, string | null>): void => {
193      reading = false
194      if (state.runId !== runId) return
195
196      const events: RunEvent[] = []
197      for (const [agentId, misses] of [...noPhase]) {
198        const phase = phases.get(agentId)
199        const agent = state.agents[agentId]
200        if (phase === undefined && agent && misses + 1 < JOURNAL_TRIES) {
201          noPhase.set(agentId, misses + 1)
202          continue
203        }
204        noPhase.delete(agentId)
205        if (agent && typeof phase === 'string') {
206          events.push({ kind: 'agent-start', runId: runId ?? '', agentId, label: agent.label, phase })
207        }
208      }
209      apply(events)
210    }
211
212    if (dir === undefined) {
213      settle(new Map())
214
215      return
216    }
217    reading = true
218    host.readText(`${dir}/journal.jsonl`).then(
219      text => settle(journalPhases(text)),
220      () => settle(new Map()),
221    )
222  }
223
224  const stopTicker = (): void => {
225    ticker?.cancel()
226    ticker = undefined
227  }
228
229  /** An agent not heard from for STALE_MS is held failed: its end was missed, or never comes. */
230  const closeStale = (): void => {
231    const stale = Object.values(state.agents).filter(agent => agent.status === 'running' && ticks - (heard.get(agent.agentId) ?? 0) >= STALE_TICKS)
232    if (stale.length === 0) return
233    ends += stale.length
234    abortedOnly = false
235    for (const agent of stale) latest.delete(agent.agentId)
236    apply(stale.map(agent => ({ kind: 'agent-end', agentId: agent.agentId, ok: false })))
237  }
238
239  const tick = (): void => {
240    ticks++
241    if (ticks % PANE_EVERY === 0) {
242      pollPane()
243      closeStale()
244    }
245    const running = hasRunning(state)
246
247    let wrote = false
248    if (dirty === 2 || (dirty === 1 && ticks % USAGE_EVERY === 0)) {
249      flush()
250      wrote = true
251    }
252    if (noPhase.size > 0 && ticks - lastRead >= JOURNAL_EVERY) readJournal()
253    // One write a tick at most: the spinner holds its frame on a tick that wrote the state.
254    if (running && shown && !wrote) call(h => h.frame(ticks * TICK_MS))
255    if (!running && dirty === 0 && noPhase.size === 0 && !reading) stopTicker()
256  }
257
258  const startTicker = (): void => {
259    if (!host || ticker) return
260    pollPane()
261    ticker = host.every(TICK_MS, tick)
262  }
263
264  const finish = (): void => {
265    cancelStopWait()
266    flush()
267    const text = endText(state)
268    call(h => h.toast(text))
269  }
270
271  function apply(events: readonly RunEvent[]): void {
272    const before = state
273    let wasRunning = state.status === 'running'
274    for (const event of events) {
275      const next = reduce(state, event, active)
276      if (next === state) continue
277      state = next
278      wasRunning ||= state.status === 'running'
279      if (event.kind === 'agent-start' || event.kind === 'agent-usage') heard.set(event.agentId, ticks)
280      if (event.kind !== 'agent-usage') dirty = 2
281      else if (dirty === 0) dirty = 1
282    }
283    if (state === before) return
284
285    if (state.status === 'running') {
286      if (looksStopped()) armStopWait()
287      else cancelStopWait()
288    }
289    if (wasRunning && state.status !== 'running') finish()
290    else startTicker()
291  }
292
293  const dropPending = (): void => {
294    expiry?.cancel()
295    expiry = undefined
296    pending = undefined
297  }
298
299  const armExpiry = (): void => {
300    expiry?.cancel()
301    expiry = host?.after(HANDOFF_MS, dropPending)
302  }
303
304  /** Forgets the run held so far so that `runId` starts clean. `own` is the saved project the run is named as. */
305  const begin = (runId: string, own?: Handoff): void => {
306    replaying?.cancel()
307    replaying = undefined
308    cancelStopWait()
309    replayed = false
310    ends = 0
311    abortedOnly = true
312    latest.clear()
313    noPhase.clear()
314    heard.clear()
315    if (own !== undefined) {
316      // Named as a saved project: that project's run, whatever waits. A handoff of another project goes on waiting for its own.
317      active = claimOf(own)
318      if (pending?.name === own.name) dropPending()
319      call(h => h.project?.(own.name))
320    } else if (pending !== undefined || state.runId !== runId) {
321      // Another run, or the one a handoff waited for: it belongs to that project, or to none.
322      active = pending
323      dropPending()
324      // Said for every run, the same project's next one too: the host's copy is the session's and starts over on a /clear, this one does not.
325      const name = active?.name ?? null
326      call(h => h.project?.(name))
327    }
328    if (state.runId !== undefined && state.runId !== runId) superseded.add(state.runId)
329    superseded.delete(runId)
330  }
331
332  /** The run tracked now is `claim`'s project's: every agent seen so far is placed again by it. */
333  const take = (claim: Handoff): void => {
334    const before = active
335    active = claimOf(claim)
336    if (pending?.name === claim.name) dropPending()
337    if (active.name !== before?.name) call(h => h.project?.(claim.name))
338    state = remap(state, active)
339    flush()
340  }
341
342  /** Whether a real run holds the tracker: one running now, or the one a handoff waits for. A replay would take its place. */
343  const busy = (): boolean => pending !== undefined || (state.status === 'running' && !replayed)
344
345  return {
346    /** The state as of every event so far (the stored copy may lag by a tick). */
347    state: (): RunState => state,
348
349    /** `stored` is the state a previous load of the module left: its run is picked up, with `claim` as its project (node ids are not stored). */
350    attach(next: TrackerHost, stored?: RunState, claim?: Handoff): void {
351      host = next
352      if (stored && state.status === 'idle' && Object.keys(state.agents).length === 0) {
353        state = stored
354        if (claim && state.status !== 'idle') active = claimOf(claim)
355        for (const agent of Object.values(state.agents)) {
356          if (agent.status !== 'running') continue
357          latest.set(agent.agentId, agent.tokens)
358          heard.set(agent.agentId, ticks)
359        }
360      }
361      if (state.status === 'running') startTicker()
362    },
363
364    /** The first run to start within HANDOFF_MS is the project's; then the claim drops, so a later unrelated run is not taken. */
365    handoff(next: Handoff): void {
366      dropPending()
367      pending = claimOf(next)
368      handed = next.name
369      armExpiry()
370    },
371
372    /**
373     * While a handoff waits, a prompt is the person answering Claude about the project: the wait starts again.
374     * With none waiting the handoff's work is over: a later run of the project is one asked for anew.
375     */
376    prompted(): void {
377      if (pending === undefined) handed = undefined
378      else armExpiry()
379    },
380
381    /** Returns the labels of the agents no block claims, one per agent; null with no run to give the project to. */
382    adopt(claim: Handoff): string[] | null {
383      if (state.status === 'idle') return null
384      take(claim)
385      return state.unmapped.map(id => state.agents[id]?.label ?? '')
386    },
387
388    /** A project is handed off and its run has not started: Claude is writing the script for it. */
389    awaitsRun: (): boolean => pending !== undefined,
390
391    busy,
392
393    project: (): string | undefined => active?.name,
394
395    /** Whether the tracked run comes out of a handoff of its project (the run it waited for, or a retry of it), not only out of its name. */
396    handedOff: (): boolean => active !== undefined && active.name === handed,
397
398    paneOpened(): void {
399      shown = true
400      syncStatus()
401    },
402
403    runStarted(run: RunStarted): void {
404      const isNew = state.runId !== run.runId || state.status !== 'running'
405      if (isNew) begin(run.runId, run.project)
406      apply([{ kind: 'run-start', runId: run.runId, name: run.name, transcriptDir: run.transcriptDir || undefined, taskId: run.taskId || undefined }])
407      // Its agents were seen before the run was announced, so it began with no name: they are placed again by its project.
408      if (!isNew && run.project !== undefined && active?.name !== run.project.name) take(run.project)
409    },
410
411    agentSpawned(agent: AgentSpawned): void {
412      if (superseded.has(agent.runId)) return
413      if (state.runId !== agent.runId) {
414        begin(agent.runId)
415        apply([{ kind: 'run-start', runId: agent.runId }])
416      }
417      cancelStopWait()
418      ends = 0
419      abortedOnly = true
420      latest.set(agent.agentId, 0)
421      noPhase.set(agent.agentId, 0)
422      apply([{ kind: 'agent-start', runId: agent.runId, agentId: agent.agentId, label: agent.label, index: agent.index }])
423      if (ticks - lastRead >= JOURNAL_EVERY) readJournal()
424    },
425
426    /** `tokens`: one request's four usage counts. Each request re-reads the whole prompt, so a sum would count it once per request. */
427    agentUsage(agentId: string, tokens: number): void {
428      if (!latest.has(agentId) || !Number.isFinite(tokens) || tokens <= 0) return
429      latest.set(agentId, tokens)
430      apply([{ kind: 'agent-usage', agentId, tokens }])
431    },
432
433    agentEnded(agentId: string, end: AgentEnded): void {
434      const tokens = latest.get(agentId)
435      if (tokens === undefined) return
436      latest.delete(agentId)
437      ends++
438      abortedOnly &&= end.aborted === true
439      apply([{ kind: 'agent-end', agentId, ok: end.ok, durationMs: end.durationMs, tokens }])
440    },
441
442    /**
443     * The engine's notice that task `taskId` ended, with its status word. Said again for every drawing of the notice: only the
444     * first counts. A run held stopped for want of a notice takes the status a later one gives.
445     */
446    taskEnded(taskId: string, status: string, durationMs?: number): void {
447      if (state.taskId === undefined || state.taskId !== taskId) return
448      const ending = endingOf(status)
449      if (state.status === ending || (state.status !== 'running' && state.status !== 'stopped')) return
450      conclude(ending, durationMs)
451    },
452
453    /** Refused (false) while `busy`: the real run's agents would be dropped as a superseded run's. */
454    replay(events: readonly RunEvent[], mode: 'fast' | 'slow', own?: Handoff): boolean {
455      if (busy()) return false
456      const first = events.find(event => event.kind === 'run-start')
457      begin(first?.kind === 'run-start' ? first.runId : '', own)
458      replayed = true
459      state = emptyRunState()
460      dirty = 2
461
462      if (mode === 'fast' || !host) {
463        apply(events)
464        if (isDirty()) flush()
465
466        return true
467      }
468
469      const steps = Math.ceil(REPLAY_MS / TICK_MS)
470      let step = 0
471      let applied = 0
472      const timer = host.every(TICK_MS, () => {
473        step++
474        const due = replayDue(events.length, step, steps)
475        const slice = events.slice(applied, due)
476        applied = due
477        if (step >= steps) {
478          timer.cancel()
479          if (replaying === timer) replaying = undefined
480        }
481        apply(slice)
482      })
483      replaying = timer
484      startTicker()
485
486      return true
487    },
488  }
489}
490
hooks/project.tsx 2764 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren } from 'claude-code'
3
4import { FLASH_CLEAR_MS, TOOL_FULL_NAME, TOOL_NAME, TOOL_SCHEMA, applyOps, attachNote, changeWords, changedNote, clearFlash, projectOfPath, diffProjects, draftBrief, editReport, knownAfterEdit, readReport, replaceProject, showEdit, staleNote, toolDescription, unplacedNote } from '../src/project/chat'
5import { UNDO_HINT, remember, travel } from '../src/project/history'
6import type { History } from '../src/project/history'
7import { lint, preflightLine, problemFields } from '../src/project/lints'
8import * as partial29 from '../fixtures/partial-29'
9import * as review41 from '../fixtures/review-41'
10import { LAST_SCRIPTS_STORE, handoffPrompt, handoffTarget, noteScript, reviewPrompt } from '../src/project/handoff'
11import { ASK_STORES, resetText } from '../src/project/offer'
12import { replayEvents } from '../src/run/replay'
13import type { ReplayFixture } from '../src/run/replay'
14import { emptyProject, findNode, flatten, goalOf, isBlank, isProjectName, kindsOf, parseProject, serializeProject, typeName } from '../src/project/model'
15import type { Project, ProjectNode, NodeType } from '../src/project/model'
16import { nextIdNumber, quickKnob } from '../src/project/ops'
17import type { NodePatch } from '../src/project/ops'
18import {
19  AGENT_ROWS_MAX,
20  DEFAULT_PROJECT_NAME,
21  DRAFT_LOCK_NOTICE,
22  DRAFT_WAIT_NOTE,
23  PROJECT_DIR,
24  PROJECT_PANE_ID,
25  PROJECT_PANE_TITLE,
26  HANDED_NOTICE,
27  HELP_PAGES,
28  KEYS_CUE,
29  LOCK_NOTICE,
30  REVEAL_STEPS,
31  SCRATCH_PREFIX,
32  TEXT_KNOBS,
33  addHeading,
34  agentsOf,
35  applyMessage,
36  applyOp,
37  bareTarget,
38  firstBlocks,
39  isProjectEntry,
40  blocksWord,
41  carryUi,
42  projectFile,
43  projectName,
44  detailWidth,
45  dockColumns,
46  elapsedOf,
47  fitHelp,
48  freeName,
49  graphProps,
50  handoffOf,
51  helpColumn,
52  helpPage,
53  initialTimes,
54  initialUi,
55  isDefaultName,
56  isSaved,
57  listWindow,
58  movePick,
59  moveStartMark,
60  nothingToRestore,
61  noticeInforms,
62  openProject,
63  openRetype,
64  overlaysProject,
65  patchNode,
66  pickBlock,
67  pickerMatches,
68  previewProps,
69  scriptFile,
70  restoreProject,
71  runOverlay,
72  runSummary,
73  savedEntry,
74  startProject,
75  startEntries,
76  filterText,
77  fitsFull,
78  typePicker,
79  viewRootOf,
80  withoutKeys,
81} from '../src/project/session'
82import type { ProjectUi, GraphBox, PickRow, RunContext, SavedProject, Step, TextKnob } from '../src/project/session'
83import { FIELD_LABEL, PALETTE_GROUPS, TEACH, fieldWord as fieldLabel, groupOf, hasLogic, hasName, knobsOf, pickName, placeholderOf, teachOf, useWhen } from '../src/project/teach'
84import type { BlockRef } from '../src/project/teach'
85import { TEMPLATES } from '../src/project/templates'
86import { emptyRunState } from '../src/run/events'
87import { replayProjectEvents } from '../src/run/replay'
88import type { Tracker } from '../src/run/tracker'
89import { NOT_SET, UNMAPPED_ID, canOpen } from '../src/ui/graph-layout'
90import { parseGraphMessage } from '../src/ui/graph-model'
91import { BORDER_CELLS, BORDER_ROWS, COMPACT_BAR_W, EXPAND_W, EXPAND_WORD, LIST_GAP, LIST_LABEL, WHY_TEXT, compactView, initialShell, whyCompact, wouldDock } from '../src/ui/compact'
92import type { CompactWhy, Minimized } from '../src/ui/compact'
93import { ENTER, FOCUS_HINT, KEY_OPS, MINIMIZE_KEYS, PASTE_NOTICE, burstKeys, legendItems, legendRows, unboundNotice, LEGEND_GAP } from '../src/ui/keys'
94import type { KeyOp, LegendAction, LegendItem } from '../src/ui/keys'
95import { FAILED, bar, fmtCount } from '../src/ui/rail'
96import { BEAT_MS, SCENE_COLUMNS, SCENE_ROWS, SOLO_ROWS, SPLASH_MS, splashScene, waitScene } from '../src/ui/persona'
97import type { PersonaSpan } from '../src/ui/persona'
98import { MARK_ROWS, MARK_W, markFrame, markPalette } from '../src/ui/mark'
99import { cellWidth, clip, cutCells, firstLines, oneLine, padCells, sanitize, wrapText } from '../src/ui/text'
100import { ACCENT, FADED, GLYPH, PANE_ID, PANE_TITLE, RULE, TEXT } from '../src/ui/theme'
101import { wordmark } from '../src/ui/wordmark'
102
103import { offerDrafted, offerHanded, offerPrompted, setOfferBoard, takeOffered } from './offer'
104
105// The designer: owns the Project and what is chosen on it, applies ops, saves the file and draws the screen.
106// Keys are typed into the key catcher, an Input that is not drawn; the graph (./canvas.tsx) is a Client that posts here.
107
108const COMMAND = 'conductor'
109const CANVAS_ELEMENT = 'canvas'
110const SPLASH_ELEMENT = 'splash'
111const SPLASH_NOTE = 'designed for full screen'
112const NAME_FIELD = 'project-name'
113const LOGIC_FIELD = 'project-logic'
114/** Drawn under a new `n` (`project-add:<n>`) to empty it of the keys it took. */
115const PICKER_ELEMENT = 'project-add'
116const TYPE_ELEMENT = 'project-type'
117const REST_PREFIX = 'project-rest:'
118const START_PREFIX = 'start:'
119const DRAFT_LABEL = '[Draft using claude]'
120const NAME_BUTTON = 'projects-button'
121const ACTION_BUTTON_PREFIX = 'act:'
122const MINIMIZE_BUTTON = 'project-minimize'
123const ARROWS_CHOOSE = 'the arrows choose a row'
124/** Saved projects the start screen lists at once where it is short of rows. */
125const SAVED_ROWS = 8
126const SAVED_MAX = 60
127const KNOB_PREFIX = 'project-knob:'
128/** The engine keeps what was typed in an Input under its address, so a field's address names its block. */
129const fieldKey = (base: string, id: string): string => `${base}@${id}#${editGen}`
130const baseOf = (element: string): string => element.split('@')[0] ?? element
131/** Counts the times the fields closed: fields opened again have new addresses and show the block as it is. */
132let editGen = 0
133let fieldsDrawn = false
134const isField = (element: string | undefined): boolean => {
135  const base = baseOf(element ?? '')
136  return base === NAME_FIELD || base === LOGIC_FIELD || base.startsWith(KNOB_PREFIX)
137}
138const fieldWord = (base: string): string => fieldLabel(base === NAME_FIELD ? 'name' : base === LOGIC_FIELD ? 'logic' : base.slice(KNOB_PREFIX.length))
139const firstField = (node: { type: ProjectNode['type'] }): string => (hasName(node) ? NAME_FIELD : `${KNOB_PREFIX}goal`)
140/**
141 * Where the ring rests while no field is open: an Input (a Button's hotkey cannot see Shift), first among the stops, since the engine keeps the ring by its place.
142 * Not drawn (`display: none`). Emptied by drawing it under a new key, once the typing has paused: a key typed in the frame it is drawn anew is dropped.
143 */
144const CATCH_ELEMENT = 'keys'
145/** This load, in the catcher's key: a module loaded again counts from 0, and the engine would bring back an old key's text. */
146const LOAD = Date.now().toString(36)
147let catchGen = 0
148const catcherKey = (): string => `${CATCH_ELEMENT}:${LOAD}.${catchGen}`
149const isCatcher = (element: string | undefined): boolean => (element ?? '').startsWith(`${CATCH_ELEMENT}:`)
150/** '' where the last drawing drew none. */
151let drawnCatcher = ''
152let pickGen = 0
153const pickerKey = (): string => `${PICKER_ELEMENT}:${LOAD}.${pickGen}`
154const isPickerField = (element: string | undefined): boolean => (element ?? '').startsWith(`${PICKER_ELEMENT}:`)
155const restBase = (element: string | undefined): string | null => (element !== undefined && element.startsWith(REST_PREFIX) ? baseOf(element.slice(REST_PREFIX.length)) : null)
156/** The door: an Input right after the catcher while a block's fields are shown. Down or Tab from the catcher lands on it, which opens the fields; a step back onto it returns to the graph. */
157const DOOR_ELEMENT = 'door'
158let doorGen = 0
159const doorKey = (): string => `${DOOR_ELEMENT}:${LOAD}.${doorGen}`
160const isDoor = (element: string | undefined): boolean => (element ?? '').startsWith(`${DOOR_ELEMENT}:`)
161const isDetailStop = (element: string | undefined): boolean => element !== undefined && (isField(element) || isPickerField(element) || restBase(element) !== null)
162/** How long after the command's answer the handoff prompt is submitted. */
163export const SUBMIT_DELAY_MS = 64
164/** How many times the ring is sent to an element before the field is given up. */
165const LAND_TRIES = 5
166/** A redraw's worth of wait before the ring moves to an element just drawn. */
167export const FOCUS_DELAY_MS = 48
168export const BOX_POLL_MS = 600
169/** The mark's two rows. */
170const HEAD_ROWS = 2
171const EDIT_HINT = 'click a field of the block, or press the Down arrow to reach the name'
172const MAIN_LEAST_ROWS = 5
173/** The detail pane's last rows name its keys: one row where the pane is short. */
174const HINT_ROWS = 2
175const ACTION_GAP = 4
176/** Cells of rule kept at each side of what is said over the footer. */
177const SAID_RULE_W = 3
178/** Rows asked for when the pane sits inline above the prompt. */
179export const PANE_ROWS = 28
180/** A command's `e.presentation.columns` is the only place a hook is told the terminal's width: undefined until one has run. */
181let terminalColumns: number | undefined
182/** The person's theme setting, for the mark's raw colours; read again once it is this old. */
183const THEME_MS = 5000
184let themeRead: { at: number; theme: string | undefined } = { at: -THEME_MS, theme: undefined }
185let splashing = false
186/** The splash stays on its last beat this long before the designer is drawn. */
187const SPLASH_REST_MS = 150
188/** Without `focus` the pane is shown and the person goes on typing in the prompt (an edit from chat). */
189export const paneArgs = (focus: boolean): { id: string; title: string; rows: number; columns?: number; focus?: true } => {
190  const columns = dockColumns(terminalColumns)
191
192  return { id: PROJECT_PANE_ID, title: PROJECT_PANE_TITLE, rows: PANE_ROWS, ...(columns === undefined ? {} : { columns }), ...(focus ? { focus: true as const } : {}) }
193}
194const runNotice = (line: string, isFilled: boolean): string => `run: ${isFilled ? 'the request is in the prompt · Enter sends it' : `type in the prompt: ${line.trim()}`}`
195const askNotice = (line: string, isFilled: boolean): string => (isFilled ? 'ask: the question is in the prompt · change it as you like, then Enter' : `ask: type in the prompt: ${line.trim()}`)
196const draftLine = (): string => `/${COMMAND} `
197const draftNotice = (line: string, isFilled: boolean): string => (isFilled ? 'draft: say in the prompt what the workflow should do, then Enter' : `draft: type in the prompt: ${line.trim()} <what you want>`)
198
199/** Undo and redo, per project. Module variables, not state: fifty projects are too much to store, and a drawing may read them. */
200const histories: Record<string, History> = {}
201/** Changes per project in this load, and the count Claude last saw: they differ when the board changed by hand since. */
202const revisions: Record<string, number> = {}
203const seenByClaude: Record<string, number> = {}
204/** The project as Claude last saw it whole; none where it never read it, or the conversation was compacted or cleared. */
205const seenProjects: Record<string, Project> = {}
206const sawProject = (name: string, project: Project): void => {
207  seenByClaude[name] = revisions[name] ?? 0
208  seenProjects[name] = project
209}
210const forgetSeenProjects = (): void => {
211  for (const name of Object.keys(seenProjects)) delete seenProjects[name]
212}
213/** The model is told a project is stale once per change, not with every prompt. */
214const warnedAt: Record<string, number> = {}
215const forgetConversation = (): void => {
216  forgetSeenProjects()
217  for (const book of [seenByClaude, warnedAt]) for (const name of Object.keys(book)) delete book[name]
218}
219const record = (name: string, before: Project | null, what: string | undefined): void => {
220  revisions[name] = (revisions[name] ?? 0) + 1
221  if (before) histories[name] = remember(histories[name], before, what ?? 'an edit')
222}
223/** Ids are never reused: the counter never goes back. */
224const timeTravel = (project: Project | null, ui: ProjectUi, way: 'undo' | 'redo', context: RunContext): Step => {
225  if (!project || ui.status !== 'ready') return { project, ui, changed: false }
226  if (context.locked || ui.handed) return applyOp(project, ui, way, context)
227  const moved = travel(histories[ui.name], project, way)
228  if (!moved) return { project, ui: nothingToRestore(ui, way), changed: false }
229  histories[ui.name] = moved.history
230  revisions[ui.name] = (revisions[ui.name] ?? 0) + 1
231  const target: Project = { ...moved.entry.project, name: project.name, nextId: Math.max(nextIdNumber(moved.entry.project), nextIdNumber(project)) }
232  return restoreProject(project, ui, target, way, moved.entry.what, context)
233}
234const keyStep = (project: Project | null, ui: ProjectUi, op: KeyOp, context: RunContext): Step =>
235  op === 'undo' || op === 'redo' ? timeTravel(project, ui, op, context) : applyOp(project, ui, op, context)
236/** Typed and not saved yet. Not state: a keystroke must not redraw the pane. `flushDrafts` saves it on every way out of a field. */
237type Drafts = { name: string; id: string | null; values: Record<string, string> }
238const noDrafts = (): Drafts => ({ name: '', id: null, values: {} })
239let drafts: Drafts = noDrafts()
240const hasDrafts = (): boolean => drafts.id !== null && Object.keys(drafts.values).length > 0
241const fieldPatch = (node: { name: string }, base: string, value: string): NodePatch =>
242  base === NAME_FIELD ? { name: value.trim() || node.name } : base === LOGIC_FIELD ? { logic: value.trim() } : ({ knobs: { [base.slice(KNOB_PREFIX.length)]: value.trim() } } as NodePatch)
243let paneShape = ''
244/** When the focus stops change under a ring at rest, the ring is put back on the catcher. */
245let ringStops = ''
246/** Undefined when unknown. A field typed in while the ring is known to be elsewhere got it by its place among the stops: what is typed there is not its text. */
247let ringOn: string | undefined
248/** The element the ring is moving to ('' with none): what is typed there meanwhile is its text. */
249let ringTo = ''
250/**
251 * A field's drawn `value`, the letters that came by the catcher or the door (`head`) and those typed at its end (`tail`). An Input keeps its text while
252 * `value` holds; a changed `value` replaces the text but not the cursor, and a letter typed in the frame of a new address is dropped: so a field keeps the text it opened with until the typing pauses.
253 */
254let pins: Record<string, { value: string; head: string; tail: string }> = {}
255const pinned = (element: string, value: string): string => (pins[element] ??= { value, head: '', tail: '' }).value
256/** `loose` when the letters kept beside the field cannot be placed: the field is then drawn anew at once. */
257const fieldText = (element: string, typed: string): { text: string; loose: boolean } => {
258  const pin = pins[element]
259  if (!pin) return { text: typed, loose: false }
260  if (typed.startsWith(pin.value)) {
261    pin.tail = typed.slice(pin.value.length)
262
263    return { text: `${pin.value}${pin.head}${pin.tail}`, loose: false }
264  }
265  if (pin.head === '') return { text: typed, loose: false }
266  // A cut at its end is made at the end of the whole; anything else keeps the letters after it.
267  const whole = `${pin.value}${pin.head}`
268  return { text: pin.value.startsWith(typed) ? whole.slice(0, Math.max(0, whole.length - (pin.value.length - typed.length))) : `${typed}${pin.head}`, loose: true }
269}
270/** New addresses for the open fields: the ring, kept by its place, stays on the same field. */
271const redrawFields = (): void => {
272  editGen++
273  for (const pin of Object.values(pins)) pin.head = ''
274  const swap = (key: string): string => key.replace(/#\d+$/, `#${editGen}`)
275  if (ringOn !== undefined && isField(ringOn)) ringOn = swap(ringOn)
276  if (isField(ringTo)) ringTo = swap(ringTo)
277}
278/** Counts every key typed in the pane, for what waits for the typing to pause. */
279let typingSeq = 0
280const TIDY_MS = 600
281/** The catcher is emptied once it holds more keys than this. */
282const CATCH_FULL = 40
283let doored = { element: '', taken: 0 }
284/** For the start screen's preview: null for a file that is no project. */
285const previews: Record<string, Project | null> = {}
286let savedPast = 0
287/** The project the list of projects was opened from: one no file holds yet comes back from here. */
288let behind: { project: Project; ui: ProjectUi } | null = null
289/** `sent` is the text Enter was last pressed on, until the next key: Enter empties the catcher, so what it holds next is counted from the start. */
290let caught: { element: string; taken: number; sent: string | null } = { element: '', taken: 0, sent: null }
291/** The same for the add list's filter: how many of its characters were keys, not text. */
292let picked = { element: '', taken: 0 }
293/** What is typed in the filter once the list has closed is for whatever opened next. */
294let pickedField = { element: '', value: '', taken: 0 }
295/** What is typed in a field after Enter, before the ring has left, is keys. */
296let savedField = { element: '', value: '', taken: 0 }
297/** Where the characters not yet taken start: after the text Enter sent, where the field still holds it (Enter empties some fields). */
298const afterEnter = (sent: { value: string; taken: number }, value: string): number => (value.startsWith(sent.value) ? sent.value.length : 0) + sent.taken
299/** The keys walk the layout the Client made for the same room. */
300let graphBox: GraphBox | undefined
301/** Where the graph stands in the pane's body, in cells, for the wheel; null while none is drawn. */
302let graphArea: { top: number; left: number; columns: number; rows: number } | null = null
303/** Summed over this load: the Client moves its pan by what was added since it last drew. */
304let wheelRows = 0
305/** The keys typed, applied one at a time in the order they came. */
306let keyChain: Promise<void> = Promise.resolve()
307let flushAsked = false
308let boxWatched = false
309let runTracker: Tracker | undefined
310let draftTicker: { cancel: () => void } | null = null
311
312// Both hooks modules declare these (an atom must be spelled in the file that reads it): run.tsx writes them through the tracker.
313const runAtom = atom({ plugin: 'workflow-conductor', key: 'run' } as const, emptyRunState())
314const clockAtom = atom({ plugin: 'workflow-conductor', key: 'runClock' } as const, 0)
315/** Null for a run that no handoff started. */
316const runProjectAtom = atom({ plugin: 'workflow-conductor', key: 'runProject' } as const, null)
317const projectAtom = atom({ plugin: 'workflow-conductor', key: 'project' } as const, null)
318const uiAtom = atom({ plugin: 'workflow-conductor', key: 'projectUi' } as const, initialUi())
319const splashedAtom = atom({ plugin: 'workflow-conductor', key: 'splashed' } as const, false)
320const draftBeatAtom = atom({ plugin: 'workflow-conductor', key: 'draftBeat' } as const, 0)
321/** Grows by one on each key that moves the selection: the mark steps on at once. */
322const nudgeAtom = atom({ plugin: 'workflow-conductor', key: 'markNudge' } as const, 0)
323const timesAtom = atom({ plugin: 'workflow-conductor', key: 'runTimes' } as const, initialTimes())
324const shellAtom = atom({ plugin: 'workflow-conductor', key: 'shell' } as const, initialShell())
325const runScriptAtom = atom({ plugin: 'workflow-conductor', key: 'runScript' } as const, null)
326
327// Spelled whole, not built by `projectFile`, so that a reader of the call sees where every save lands.
328const saveProject = async ($: EngineInterface, name: string, project: Project): Promise<void> => {
329  // The name is spelled into the path: anything but kebab-case could reach outside the projects' directory.
330  if (!isProjectName(name)) throw new Error(`not a project name: ${name}`)
331  const root = await $.session.root()
332  await $.fs.write(`${root.replace(/\/+$/, '')}/.claude/workflow-conductor/${name}.json`, serializeProject(project))
333}
334
335const reason = (error: unknown): string => cutCells(sanitize(error instanceof Error ? error.message : String(error)), 160)
336
337/** Both atoms are set before `adopt`: the tracker's write reads the run's project and the board. Returns the labels no block took; null with no run. */
338const showRunOn = async ($: EngineInterface, tracker: Tracker, name: string, project: Project): Promise<string[] | null> => {
339  await update($, uiAtom, held => ({ ...held, follow: true, runCleared: false }))
340  await update($, runProjectAtom, () => name)
341
342  return tracker.adopt(handoffOf(name, project))
343}
344
345/** Null when no saved project has the workflow's name. The caller tells the tracker next: its write reads what is set here. */
346const projectOfRun = async ($: EngineInterface, workflow: string | undefined): Promise<{ name: string; project: Project } | null> => {
347  if (workflow === undefined) return null
348  const name = projectName(workflow)
349  if (name === null) return null
350  const path = projectFile(await $.session.root(), name)
351  if (!isSaved(name) || !(await $.fs.exists(path).catch(() => false))) return null
352  await flushDrafts($)
353  const shown = await read($, uiAtom)
354  let project = shown.name === name && shown.status === 'ready' ? await read($, projectAtom) : null
355  if (project === null) {
356    const opened = openProject(name, await $.fs.read(path).catch(() => ''), shown)
357    if (opened.project === null) return null
358    project = opened.project
359    await update($, projectAtom, () => opened.project)
360    await update($, uiAtom, () => opened.ui)
361  }
362  await update($, uiAtom, held => ({ ...held, follow: true, runCleared: false }))
363  await update($, runProjectAtom, () => name)
364
365  return { name, project }
366}
367
368/** `$` does not cross files (`claude plugin validate` refuses a `$` passed to an imported function), so the session's start hook gives run.tsx these. */
369export type RunBoard = { projectOfRun: (workflow: string | undefined) => Promise<{ name: string; project: Project } | null> }
370let runBoard: RunBoard | undefined
371export const runBoardOf = (): RunBoard | undefined => runBoard
372
373const lockedNow = async ($: EngineInterface): Promise<boolean> => {
374  const run = await read($, runAtom)
375  const ui = await read($, uiAtom)
376  return ui.drafting === 'show' || (run.status === 'running' && overlaysProject(run, await read($, runProjectAtom), ui))
377}
378
379let flushing: Promise<string | null> = Promise.resolve(null)
380/** Kept for a moment: a drawing may have asked for the save just before the action that would have. */
381let justSaid: string | null = null
382const SAID_MS = 400
383/** Saves what was typed and not sent with Enter, as one edit, before anything else acts on the project. One at a time, in the order asked. */
384const flushDrafts = ($: EngineInterface): Promise<string | null> => {
385  const run = flushing.then(async (): Promise<string | null> => {
386    if (!hasDrafts()) return null
387    const pending = drafts
388    drafts = noDrafts()
389    const ui = await read($, uiAtom)
390    let project: Project | null = await read($, projectAtom)
391    // The project typed for is not the one shown, or a run locks it: nothing can be saved, and it is said.
392    const lost = `unsaved text in ${Object.keys(pending.values).map(fieldWord).join(', ')} discarded`
393    if (!project || ui.status !== 'ready' || ui.name !== pending.name) {
394      $.ui.toast(`conductor: ${lost} (project ${pending.name} is no longer on the board)`)
395
396      return null
397    }
398    if (await lockedNow($)) {
399      await update($, uiAtom, held => ({ ...held, notice: `${lost} · ${held.drafting === 'show' ? DRAFT_LOCK_NOTICE : LOCK_NOTICE}` }))
400
401      return null
402    }
403    const said: string[] = []
404    let titled = false
405    const node = pending.id === null ? undefined : findNode(project, pending.id)
406    if (node) {
407      let patch: NodePatch = {}
408      for (const [base, text] of Object.entries(pending.values)) {
409        const part = fieldPatch(node, base, text)
410        patch = { ...patch, ...part, ...(patch.knobs || part.knobs ? { knobs: { ...patch.knobs, ...part.knobs } } : {}) }
411      }
412      const step = patchNode(project, ui, node.id, patch)
413      if (step.changed && step.project) {
414        record(ui.name, project, step.what)
415        project = step.project
416        said.push(step.what ?? `${node.id} changed`)
417        titled = patch.name !== undefined
418      }
419    }
420    if (said.length === 0) return null
421    let notice = `${said.join('; ')} · ${UNDO_HINT}`
422    try {
423      if (isSaved(ui.name)) await saveProject($, ui.name, project)
424    } catch (error) {
425      notice = `not saved: ${reason(error)}`
426    }
427    const saved = project
428    await update($, projectAtom, () => saved)
429    await update($, uiAtom, held => ({ ...held, notice, runCleared: true, unsaved: false, freshId: titled ? null : held.freshId }))
430    justSaid = notice
431    $.clock.after(SAID_MS, () => {
432      if (justSaid === notice) justSaid = null
433    })
434
435    return notice
436  })
437  flushing = run.catch(() => null)
438
439  return run
440}
441/** An action that left nothing said keeps what the flush said. */
442const keepSaid = async ($: EngineInterface, flushed: string | null): Promise<void> => {
443  const said = flushed ?? justSaid
444  justSaid = null
445  if (said !== null) await update($, uiAtom, held => (held.notice === null ? { ...held, notice: said } : held))
446}
447
448/** The catcher is the first stop, so the ring is there by its place but for the ways out of the detail pane. */
449const settleRing = ($: EngineInterface, tries = 1): void => {
450  $.clock.after(FOCUS_DELAY_MS, () => {
451    void (async (): Promise<void> => {
452      const ui = await read($, uiAtom)
453      if ((ui.status !== 'ready' && ui.status !== 'pick' && ui.status !== 'error') || ui.editing) return
454      const key = ui.status === 'ready' && ui.picker !== null && !(await lockedNow($)) ? pickerKey() : drawnCatcher
455      const moved = await $.ui.focus({ requestId: PROJECT_PANE_ID, key }).catch(() => ({ deny: 'no' }))
456      if (moved.deny === undefined) ringOn = key
457      else if (tries > 0) settleRing($, tries - 1)
458    })()
459  })
460}
461
462/** The ring goes to the catcher before the fields close: kept by its place, it must not be left on what takes their place. */
463const home = async ($: EngineInterface): Promise<void> => {
464  const key = drawnCatcher
465  ringTo = ''
466  const moved = await $.ui.focus({ requestId: PROJECT_PANE_ID, key }).catch(() => ({ deny: 'no' }))
467  if (moved.deny === undefined) ringOn = key
468  // Not drawn at this moment (the add list stands in its place): once it is.
469  else settleRing($)
470  await update($, uiAtom, held => (held.editing || held.freshId !== null ? { ...held, editing: false, freshId: null } : held))
471  $.ui.invalidate('ui.render')
472}
473
474/** What to land on: its key is made anew on each try, as letters typed meanwhile draw it under another. */
475type Spot = { base: string; id: string } | 'picker'
476const spotKey = (spot: Spot): string => (spot === 'picker' ? pickerKey() : fieldKey(spot.base, spot.id))
477
478/** Where the element cannot be reached at all the field is given up and `fallback` is said. */
479const landOn = async ($: EngineInterface, spot: Spot, fallback: string, tries = LAND_TRIES, last = ''): Promise<void> => {
480  const key = spotKey(spot)
481  // Said before the move is asked: a letter that reaches the element before the answer does is its text.
482  ringTo = key
483  const moved = await $.ui.focus({ requestId: PROJECT_PANE_ID, key }).catch((error: unknown) => ({ deny: reason(error) }))
484  if (moved.deny === undefined && key === spotKey(spot)) {
485    ringOn = key
486    if (ringTo === key) ringTo = ''
487    $.ui.invalidate('ui.render')
488
489    return
490  }
491  // Refused: not drawn yet, or drawn anew under another address meanwhile (letters typed on the way do that): that one counts from the start.
492  const left = key === last ? tries - 1 : LAND_TRIES
493  if (left > 0) {
494    $.clock.after(left > LAND_TRIES - 2 ? FOCUS_DELAY_MS : FOCUS_DELAY_MS * 2, () => void landOn($, spot, fallback, left, key))
495
496    return
497  }
498  if (ringTo === key) ringTo = ''
499  await update($, uiAtom, held => ({ ...held, editing: false, freshId: null, notice: fallback }))
500}
501
502/** What `e` does, for the ways that are not a key: the Down arrow from the catcher, a click on a field at rest. */
503const openFields = async ($: EngineInterface, base?: string): Promise<void> => {
504  const ui = await read($, uiAtom)
505  const project = await read($, projectAtom)
506  const node = project === null || ui.selectedId === null ? undefined : findNode(project, ui.selectedId)
507  if (ui.status !== 'ready' || !node || ui.handed || (await lockedNow($))) return
508  if (!ui.editing || ui.help > 0 || ui.picker !== null) await update($, uiAtom, held => ({ ...held, editing: true, help: 0, picker: null, detailHidden: false, confirmId: null }))
509  void landOn($, { base: base ?? firstField(node), id: node.id }, `edit: ${EDIT_HINT}`)
510}
511
512/** After a click on the graph, until the ring is on the catcher: the engine gives a letter typed on a Button to the prompt box, and the keyboard with it (`prompt.edit` makes it a key). */
513let landing = false
514/** `landing` is given up after this long, whatever became of the ring. */
515const LANDING_MS = 400
516/** Nothing tells a hook when the graph loses the keyboard a click gave it, so the pane takes the keys back at once; refused (the prompt box holds text), no key is answered (`handed`). */
517const retake = async ($: EngineInterface, picks: boolean): Promise<void> => {
518  landing = true
519  $.clock.after(LANDING_MS, () => {
520    landing = false
521  })
522  await $.ui.open(paneArgs(true)).catch(() => undefined)
523  // Asked before the fields close, or the ring would rest on what takes a field's place; a move refused while the pane's take lands is asked again below.
524  const early = picks || drawnCatcher === '' ? { deny: 'no' } : await $.ui.focus({ requestId: PROJECT_PANE_ID, key: drawnCatcher }).catch((error: unknown) => ({ deny: reason(error) }))
525  if (early.deny === undefined) ringOn = drawnCatcher
526  await update($, uiAtom, held => (held.editing || held.freshId !== null ? { ...held, editing: false, freshId: null } : held))
527  // Moving the ring is how the pane learns whether it holds the keyboard (`$.ui.panes()` asked right after the open still says no). One more try: the pane may not be drawn yet.
528  const land = async (tries: number): Promise<void> => {
529    const key = picks ? pickerKey() : drawnCatcher
530    const moved = await $.ui.focus({ requestId: PROJECT_PANE_ID, key }).catch((error: unknown) => ({ deny: reason(error) }))
531    const holds = moved.deny === undefined
532    if (!holds && tries > 0) {
533      $.clock.after(FOCUS_DELAY_MS * 2, () => void land(tries - 1))
534
535      return
536    }
537    landing = false
538    if (holds) ringOn = key
539    const now = await read($, uiAtom)
540    if (holds === now.handed) await update($, uiAtom, held => ({ ...held, handed: !holds, notice: !holds ? HANDED_NOTICE : held.notice === HANDED_NOTICE ? null : held.notice }))
541    else $.ui.invalidate('ui.render')
542  }
543  // The filter exists once the list is drawn: the ring moves there a frame later.
544  if (picks) $.clock.after(FOCUS_DELAY_MS, () => void land(1))
545  else await land(1)
546}
547
548/** Tidies the Inputs once no key has been typed for TIDY_MS: a letter typed in the frame an Input is drawn anew would be dropped. */
549const tidySoon = ($: EngineInterface): void => {
550  const mine = ++typingSeq
551  $.clock.after(TIDY_MS, () => {
552    if (mine !== typingSeq) return
553    let tidied = false
554    if (caught.taken > CATCH_FULL && caught.element === drawnCatcher) {
555      catchGen++
556      tidied = true
557    }
558    if (doored.taken > 0) {
559      doorGen++
560      doored = { element: '', taken: 0 }
561      tidied = true
562    }
563    // Drawn anew, a field keeps the cursor of the one before it, short of the end by the letters added. The ring is
564    // stepped off it and on again: landing puts the cursor at the end.
565    const at = ringOn
566    const again = (spot: Spot): void => {
567      const off = $.ui.focus({ requestId: PROJECT_PANE_ID, key: drawnCatcher })
568      void off.then(() => landOn($, spot, `edit: ${EDIT_HINT}`)).catch(() => undefined)
569    }
570    if (Object.entries(pins).some(([element, pin]) => pin.head !== '' && isField(element))) {
571      redrawFields()
572      tidied = true
573      const [base = '', id = ''] = (at ?? '').replace(/#\d+$/, '').split('@')
574      if (at !== undefined && isField(at)) again({ base, id })
575    }
576    if (tidied) $.ui.invalidate('ui.render')
577  })
578}
579const runContext = async ($: EngineInterface): Promise<RunContext> => {
580  const now = await read($, runAtom)
581  const ui = await read($, uiAtom)
582  const held = overlaysProject(now, await read($, runProjectAtom), ui)
583  // A draft being laid out locks the project as a run does.
584  const laying = ui.drafting === 'show'
585
586  return { ...(held ? { run: now, locked: now.status === 'running' || laying } : laying ? { locked: true } : {}), ...(laying ? { drafting: true } : {}), ...(graphBox ? { graph: graphBox } : {}) }
587}
588
589const store = async ($: EngineInterface, step: Step, before: Project | null): Promise<ProjectUi> => {
590  if (!step.changed || !step.project) return step.ui
591  if (step.what !== undefined) record(step.ui.name, before, step.what)
592  try {
593    if (isSaved(step.ui.name)) await saveProject($, step.ui.name, step.project)
594  } catch (error) {
595    return { ...step.ui, notice: `not saved: ${reason(error)}` }
596  }
597
598  return step.ui
599}
600
601const beat = ($: EngineInterface): void => {
602  void update($, nudgeAtom, nudge => nudge + 1)
603}
604
605const themeOf = async ($: EngineInterface): Promise<string | undefined> => {
606  const now = Date.now()
607  if (now - themeRead.at < THEME_MS) return themeRead.theme
608  const row = (await $.config.list().catch(() => [])).find(one => one.key === 'theme')
609  themeRead = { at: now, theme: typeof row?.value === 'string' ? row.value : undefined }
610
611  return themeRead.theme
612}
613
614/** Started by the drawing that shows the splash; a reload in the middle of it starts it again. */
615const startSplash = ($: EngineInterface): void => {
616  if (splashing) return
617  splashing = true
618  $.clock.after(SPLASH_MS + SPLASH_REST_MS, () => {
619    // Given back: a /clear starts the session's state over (the splash is unseen again) while this module lives on.
620    splashing = false
621    void update($, splashedAtom, () => true).then(() => settleRing($))
622  })
623}
624
625const commit = async ($: EngineInterface, make: (project: Project | null, held: ProjectUi, context: RunContext) => Step): Promise<Step> => {
626  const before = await read($, projectAtom)
627  const held = await read($, uiAtom)
628  const step = make(before, held, await runContext($))
629  const nextUi = await store($, step, before)
630  if (step.project !== before) await update($, projectAtom, () => step.project)
631  if (nextUi !== held) await update($, uiAtom, () => nextUi)
632
633  return { ...step, ui: nextUi }
634}
635
636/** Polls the prompt box while a request waits in it or the keys are off (`handed`): once it is empty the line goes and the keys are keys again. */
637const watchBox = ($: EngineInterface): void => {
638  if (boxWatched) return
639  boxWatched = true
640  const look = (): void => {
641    $.clock.after(BOX_POLL_MS, () => {
642      void (async (): Promise<void> => {
643        const ui = await read($, uiAtom)
644        const waits = ui.handed || (ui.notice !== null && /^(run|ask|draft): /.test(ui.notice))
645        const box = waits ? await $.prompt.read().catch(() => ({ text: '', cursor: 0 })) : null
646        if (box !== null && box.text.trim() !== '') return look()
647        boxWatched = false
648        if (waits) await update($, uiAtom, held => ({ ...held, handed: false, notice: held.notice !== null && (/^(run|ask|draft): /.test(held.notice) || held.notice === HANDED_NOTICE) ? null : held.notice }))
649      })()
650    })
651  }
652  look()
653}
654
655const MINIMIZE_WORDS: readonly string[] = ['min', 'minimize']
656const EXPAND_WORDS: readonly string[] = ['max', 'expand']
657/** A Button's hotkey has no case, so `M` presses it too. */
658const EXPAND_ELEMENT = 'conductor-expand'
659const EXPAND_HOTKEY = 'm'
660const BAND_PROJECT_PREFIX = 'conductor-project:'
661/** The band lists the saved projects again when its listing is older than this. */
662const LISTING_MS = 4000
663let bandListing: { at: number; names: string[] } = { at: 0, names: [] }
664/** The terminal's size at which the band last opened the pane by itself: not tried again until the size changes. */
665let autoTried = ''
666
667/** Until the pane is gone its drawings ask for no other minimize, and do not take the pane back. */
668let minimizing = false
669/** The pane is given this long to be drawn with the room it has before a pane short of room is minimized. */
670const MINIMIZE_DELAY_MS = 32
671/** Opened by the person from the band: short of room it stays and says why, since a dock can be widened only while it is on screen. */
672let heldOpen = false
673
674/** From a clock callback: a drawing asks for it too, and a drawing closes nothing. */
675const minimize = ($: EngineInterface, by: Exclude<Minimized, 'no'>, why: CompactWhy | null = null): void => {
676  if (minimizing) return
677  minimizing = true
678  heldOpen = false
679  $.clock.after(by === 'auto' ? MINIMIZE_DELAY_MS : 0, () => {
680    void (async (): Promise<void> => {
681      await flushDrafts($)
682      await update($, uiAtom, held => (held.editing || held.picker !== null || held.help > 0 ? { ...held, editing: false, freshId: null, picker: null, help: 0 } : held))
683      await update($, shellAtom, () => ({ minimized: by, why }))
684      await $.ui.close({ id: PROJECT_PANE_ID }).catch(() => undefined)
685    })()
686      .catch(() => undefined)
687      .then(() => {
688        minimizing = false
689        $.ui.invalidate('ui.render')
690      })
691  })
692}
693
694/** `asked` opens the pane with the keyboard (a key or a click), else without (the terminal was widened); a pane still short of room minimizes itself anew. */
695const expand = async ($: EngineInterface, asked: boolean, columns?: number): Promise<void> => {
696  terminalColumns = columns ?? terminalColumns
697  heldOpen = asked
698  const opened = await $.ui.open(paneArgs(asked)).catch(() => undefined)
699  if (opened?.isPlaced === false) return
700  // A press in the band holds the keyboard: the pane is shown without it and takes the keys once drawn, as after a click on the graph.
701  if (asked) $.clock.after(FOCUS_DELAY_MS * 2, () => void retake($, false).catch(() => undefined))
702}
703
704const showSaved = async ($: EngineInterface, name: string): Promise<void> => {
705  await flushDrafts($)
706  const held = await read($, uiAtom)
707  if (held.name === name && held.status === 'ready') return
708  const text = await $.fs.read(projectFile(await $.session.root(), name)).catch(() => undefined)
709  const step = openProject(name, typeof text === 'string' ? text : undefined, held)
710  if (step.ui.status !== 'ready') return
711  await update($, projectAtom, () => step.project)
712  await update($, uiAtom, () => step.ui)
713}
714
715/** A pane opened with nobody asking, in a terminal too narrow for it to be placed: the designer is in the band instead. */
716const bandIfWaiting = async ($: EngineInterface, opened: { isPlaced: boolean } | undefined): Promise<void> => {
717  if (opened?.isPlaced === false) await update($, shellAtom, held => (held.minimized === 'no' ? { minimized: 'auto' as const, why: 'narrow' as const } : held))
718}
719
720const say = async ($: EngineInterface, notice: string): Promise<void> => {
721  await update($, uiAtom, held => ({ ...held, notice }))
722}
723
724/** The hand-off is left with the offer tool, which the sentence tells Claude to call first. False where there is nothing to hand off. */
725const handOff = async ($: EngineInterface, name: string): Promise<boolean> => {
726  const shown = await read($, uiAtom)
727  const path = projectFile(await $.session.root(), name)
728  // A template nobody edited yet is on the board only: handing it off is what saves it.
729  const onBoard = shown.name === name && shown.status === 'ready' ? await read($, projectAtom) : null
730  if (onBoard !== null && !(await $.fs.exists(path).catch(() => true))) await saveProject($, name, onBoard).catch(() => undefined)
731  if (!(await $.fs.exists(path).catch(() => false))) return false
732  if (onBoard !== null && shown.unsaved) await update($, uiAtom, held => ({ ...held, unsaved: false }))
733  const parsed = parseProject(await $.fs.read(path).catch(() => ''))
734  if (!parsed.ok || isBlank(parsed.project)) return false
735  runTracker?.handoff(handoffOf(name, parsed.project))
736  offerHanded(name)
737  await update($, uiAtom, held => ({ ...held, follow: true, runCleared: false, notice: held.notice?.startsWith('run: ') ? null : held.notice }))
738
739  return true
740}
741
742/** No call hands the keyboard from a pane to the prompt, so the pane is closed and shown again without it; where the keys stayed, none is acted on until the person takes them (`handed`). */
743const request = async ($: EngineInterface, kind: 'run' | 'ask' | 'draft'): Promise<void> => {
744  const ui = await read($, uiAtom)
745  const now = await read($, projectAtom)
746  if (kind === 'run' && !isSaved(ui.name)) return say($, 'run: a replay is not a saved project, there is nothing to hand off')
747  if (kind === 'run' && (now === null || isBlank(now))) return say($, 'run: the project is empty, add a block first')
748  const line = kind === 'run' ? handoffPrompt(ui.name) : kind === 'ask' ? reviewPrompt(ui.name) : draftLine()
749  const fill = async (): Promise<boolean> => {
750    const filled = await $.prompt.fill({ text: line }).catch(() => ({ isFilled: false }))
751    await say($, kind === 'run' ? runNotice(line, filled.isFilled) : kind === 'ask' ? askNotice(line, filled.isFilled) : draftNotice(line, filled.isFilled))
752    if (filled.isFilled) watchBox($)
753
754    return filled.isFilled
755  }
756  if (kind === 'run') {
757    // The mod's own prompt does not come by its `prompt.submit` hook: the hand-off is made here.
758    if (!(await handOff($, ui.name))) return say($, `run: ${ui.name} could not be handed off`)
759    const problems = now === null ? null : preflightLine(now)
760    if (now !== null) $.ui.toast(`conductor: ${ui.name} handed to Claude${problems === null ? '' : ` · ${problems}`}`)
761    // From a clock callback, never from inside the chain of keys, which it would hold for the turn.
762    // Refused (the prompt box holds text): the request is left there for the person to send.
763    $.clock.after(SUBMIT_DELAY_MS, () => {
764      const submitted = $.prompt.submit({ text: line, asUser: true })
765      void submitted.then(entered => (entered.drop === undefined ? undefined : fill())).catch(() => fill())
766    })
767    await $.ui.close({ id: PROJECT_PANE_ID }).catch(() => undefined)
768    await $.ui.open(paneArgs(false)).catch(() => undefined)
769
770    return
771  }
772  if (!(await fill())) return
773  await $.ui.close({ id: PROJECT_PANE_ID }).catch(() => undefined)
774  await $.ui.open(paneArgs(false)).catch(() => undefined)
775  // The ring can be moved only in a site that holds the keyboard: if it still can, the keys did not go.
776  $.clock.after(FOCUS_DELAY_MS * 2, () => {
777    const focused = $.ui.focus({ requestId: PROJECT_PANE_ID, key: drawnCatcher })
778    void focused
779      .catch(() => ({ deny: 'no' }))
780      .then(moved => (moved.deny === undefined ? update($, uiAtom, held => ({ ...held, handed: true, notice: HANDED_NOTICE })) : undefined))
781      .then(() => watchBox($))
782  })
783}
784
785const stopDraftTicker = (): void => {
786  draftTicker?.cancel()
787  draftTicker = null
788}
789
790const draftBeat = async ($: EngineInterface): Promise<void> => {
791  const ui = await read($, uiAtom)
792  if ((ui.drafting ?? null) === null) return stopDraftTicker()
793  await update($, draftBeatAtom, beats => beats + 1)
794  const project = await read($, projectAtom)
795  const shownSoFar = ui.reveal ?? null
796  if (ui.drafting !== 'show' || shownSoFar === null || project === null) return
797  const rows = flatten(project)
798  const upTo = shownSoFar + Math.max(1, Math.ceil(rows.length / REVEAL_STEPS))
799  const fresh = rows.slice(shownSoFar, upTo).map(row => row.node.id)
800  const done = upTo >= rows.length
801  let seq = 0
802  await update($, uiAtom, held => {
803    if (held.drafting !== 'show' || held.reveal !== shownSoFar) return held
804    seq = (held.flash?.seq ?? 0) + 1
805    return { ...held, reveal: done ? null : upTo, selectedId: done ? (project.nodes[0]?.id ?? null) : (fresh.at(-1) ?? held.selectedId), flash: { ids: fresh, seq } }
806  })
807  if (done && seq > 0) {
808    $.clock.after(FLASH_CLEAR_MS, () => {
809      void update($, uiAtom, held => clearFlash(held, seq))
810    })
811  }
812}
813
814const startDraftTicker = ($: EngineInterface): void => {
815  if (draftTicker !== null) return
816  draftTicker = $.clock.every(BEAT_MS, () => {
817    void draftBeat($).catch(() => undefined)
818  })
819}
820
821/** The keyboard stays in the prompt, where Claude's questions are answered. */
822const beginDraft = async ($: EngineInterface): Promise<void> => {
823  await flushDrafts($)
824  await update($, draftBeatAtom, () => 0)
825  await update($, uiAtom, held => ({ ...held, drafting: 'wait' as const, reveal: null, editing: false, freshId: null, picker: null, confirmId: null, handed: false, notice: null }))
826  await $.ui.open(paneArgs(false)).catch(() => undefined)
827  startDraftTicker($)
828}
829
830/** With `keys` a landed project's board takes the keyboard, where nothing is being typed in the prompt. */
831const endDraft = async ($: EngineInterface, keys: boolean): Promise<void> => {
832  const ui = await read($, uiAtom)
833  if ((ui.drafting ?? null) === null) return
834  stopDraftTicker()
835  const landed = ui.drafting === 'show'
836  await update($, uiAtom, held => ({ ...held, drafting: null, reveal: null, flash: held.flash ? { ids: [], seq: held.flash.seq } : null }))
837  $.ui.invalidate('ui.render')
838  if (!landed || !keys) return
839  const box = await $.prompt.read().catch(() => ({ text: '?', cursor: 0 }))
840  if (box.text.trim() !== '') return
841  await $.ui.open(paneArgs(true)).catch(() => undefined)
842  settleRing($)
843}
844
845/** The ring is claimed for the catcher first: a letter typed on Enter's heels is then no text of the field. */
846const saveField = async ($: EngineInterface, id: string, base: string, value: string): Promise<void> => {
847  ringOn = drawnCatcher
848  savedField = { element: fieldKey(base, id), value, taken: 0 }
849  const ui = await read($, uiAtom)
850  const mine = drafts.id === id && drafts.name === ui.name
851  drafts = { name: ui.name, id, values: { ...(mine ? drafts.values : {}), [base]: value } }
852  await home($)
853  await flushDrafts($)
854}
855
856const pick = async ($: EngineInterface, choice: Partial<PickRow>): Promise<void> => {
857  await flushDrafts($)
858  const step = await commit($, (before, held, context) => pickBlock(before, held, choice, context))
859  // A block opened on its kinds: the filter is drawn anew, empty, for the kinds' list.
860  if (step.ui.picker?.block !== undefined && step.ui.picker.query === '') pickGen++
861  const fresh = step.changed && step.ui.editing ? step.ui.freshId : null
862  if (fresh !== null) void landOn($, { base: NAME_FIELD, id: fresh }, `edit: ${EDIT_HINT}`)
863  else if (!step.ui.picker) await home($)
864}
865
866const startFrom = async ($: EngineInterface, templateId: string): Promise<void> => {
867  const held = await read($, uiAtom)
868  const root = await $.session.root()
869  // The name was chosen when the list opened: a project saved under it since (a rename) is never written over.
870  const taken = isSaved(held.name) && (await $.fs.exists(projectFile(root, held.name)).catch(() => false))
871  const step = startProject(taken ? { ...held, name: freeName(await savedNames($, root)) } : held, templateId)
872  if (!step.project) return
873  // Not written yet: the first edit (or handing it off) saves it. A project looked at and left leaves no file.
874  delete histories[step.ui.name]
875  await update($, projectAtom, () => step.project)
876  await update($, uiAtom, () => ({ ...step.ui, help: 0, notice: isDefaultName(step.ui.name) ? `named ${step.ui.name} for now · once it is saved, p lists it and e renames it there` : null }))
877  settleRing($)
878}
879
880const savedNames = async ($: EngineInterface, root: string): Promise<string[]> => {
881  const entries = await $.fs.list(`${root.replace(/\/+$/, '')}/${PROJECT_DIR}`).catch(() => [])
882
883  return entries
884    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
885    .map(entry => entry.name.slice(0, -'.json'.length))
886    .filter(isSaved)
887    .sort()
888}
889
890const readSaved = async ($: EngineInterface, root: string): Promise<SavedProject[]> => {
891  const dir = `${root.replace(/\/+$/, '')}/${PROJECT_DIR}`
892  const names = await savedNames($, root)
893  savedPast = Math.max(0, names.length - SAVED_MAX)
894  const saved: SavedProject[] = []
895  for (const one of names.slice(0, SAVED_MAX)) {
896    const text = await $.fs.read(`${dir}/${one}.json`).catch(() => '')
897    const parsed = parseProject(text)
898    previews[one] = parsed.ok ? parsed.project : null
899    saved.push(savedEntry(one, parsed))
900  }
901
902  return saved
903}
904
905const openProjectList = async ($: EngineInterface): Promise<void> => {
906  await flushDrafts($)
907  const held = await read($, uiAtom)
908  if (held.status !== 'ready' && held.status !== 'error') return
909  const project = await read($, projectAtom)
910  const root = await $.session.root()
911  const saved = await readSaved($, root)
912  behind = project !== null && held.status === 'ready' ? { project, ui: { ...held, editing: false, freshId: null, picker: null, confirmId: null, help: 0, notice: null } } : null
913  // A template picked there starts a new project, under a name no saved one has.
914  const started = openProject(freeName(await savedNames($, root)), undefined, held)
915  await update($, projectAtom, () => null)
916  await update($, uiAtom, () => ({ ...started.ui, saved, startMark: Math.max(0, saved.findIndex(one => one.name === held.name)), back: held.name }))
917  settleRing($)
918}
919
920const closeProjectList = async ($: EngineInterface): Promise<void> => {
921  const held = await read($, uiAtom)
922  const name = held.back ?? null
923  if (held.status !== 'pick' || name === null) return
924  const kept = behind !== null && behind.ui.name === name ? behind : null
925  const text = isSaved(name) ? await $.fs.read(projectFile(await $.session.root(), name)).catch(() => undefined) : undefined
926  const step = typeof text === 'string' ? openProject(name, text, held) : null
927  behind = null
928  if (step !== null && step.project !== null) {
929    const project = step.project
930    const viewRootId = kept !== null && kept.ui.viewRootId !== null && findNode(project, kept.ui.viewRootId) ? kept.ui.viewRootId : null
931    await update($, projectAtom, () => project)
932    await update($, uiAtom, () => ({ ...step.ui, viewRootId, carousels: kept?.ui.carousels ?? {}, runCleared: kept?.ui.runCleared ?? false, follow: kept?.ui.follow ?? true }))
933  } else if (step !== null) {
934    // Its file is no project: said as it is when opened by name.
935    await update($, projectAtom, () => null)
936    await update($, uiAtom, () => step.ui)
937  } else if (kept !== null) {
938    await update($, projectAtom, () => kept.project)
939    await update($, uiAtom, ui => ({ ...kept.ui, detailHidden: ui.detailHidden === true, picks: ui.picks ?? {} }))
940  } else {
941    await update($, uiAtom, ui => ({ ...ui, back: null, notice: `${name} is gone: it could not be read` }))
942
943    return
944  }
945  settleRing($)
946}
947
948const openSaved = async ($: EngineInterface, name: string): Promise<void> => {
949  const held = await read($, uiAtom)
950  const text = await $.fs.read(projectFile(await $.session.root(), name)).catch(() => undefined)
951  const step = openProject(name, typeof text === 'string' ? text : undefined, held)
952  if (step.ui.status === 'pick') {
953    await update($, uiAtom, ui => ({ ...ui, notice: `${name} is gone: it could not be read` }))
954
955    return
956  }
957  await update($, projectAtom, () => step.project)
958  await update($, uiAtom, () => step.ui)
959  settleRing($)
960}
961
962const FIXTURES: Record<string, ReplayFixture> = { 'review-41': review41, 'partial-29': partial29 }
963
964/** A replay would take the place of a real run, whose agents would then be counted nowhere. Null when one may start. */
965const replayRefusal = (): string | null => {
966  if (runTracker === undefined || !runTracker.busy()) return null
967
968  return runTracker.awaitsRun() ? 'a project handed to Claude waits for its run: a replay can start once that run ends' : 'a workflow is running: a replay can start once the run ends'
969}
970
971/** The project is named `replay-<template>` and lives in the session only: no file is written, and none is listed. */
972const replayOver = async ($: EngineInterface, templateId: string): Promise<void> => {
973  const template = TEMPLATES.find(one => one.id === templateId)
974  if (!template || runTracker === undefined) return
975  const refusal = replayRefusal()
976  if (refusal !== null) return say($, refusal)
977  const name = `${SCRATCH_PREFIX}${template.id}`
978  const made: Project = { ...template.make(), name }
979  // Another project under the same name: what could be undone in the one before is not its history.
980  delete histories[name]
981  behind = null
982  await update($, projectAtom, () => made)
983  await update($, uiAtom, held => ({ ...carryUi(held, initialUi()), name, status: 'ready' as const, selectedId: made.nodes[0]?.id ?? null, notice: 'replaying a made-up run: token counts are made up, nothing is saved' }))
984  settleRing($)
985  // A replay reuses its run id: the clock of the one before must not time this one.
986  await update($, timesAtom, () => initialTimes())
987  if (runTracker.replay(replayProjectEvents(made, name), 'fast', handoffOf(name, made))) return
988  // A run began while the board was put up: the tracker kept it.
989  const late = replayRefusal()
990  if (late !== null) await say($, late)
991}
992
993const replayRecording = async ($: EngineInterface, id: string): Promise<void> => {
994  const fixture = FIXTURES[id]
995  if (!fixture || runTracker === undefined) return
996  const refusal = replayRefusal()
997  if (refusal !== null) return say($, refusal)
998  runTracker.replay(replayEvents(id, fixture), 'fast')
999  const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE }).catch(() => undefined)
1000  if (opened?.isPlaced) runTracker.paneOpened()
1001  await say($, `replaying ${id} in the run view: token counts are made up`)
1002}
1003
1004const forgetAnswers = async ($: EngineInterface): Promise<void> => {
1005  let cleared = 0
1006  for (const slot of ASK_STORES) {
1007    if ((await $.store.get(slot).catch(() => undefined)) === undefined) continue
1008    await $.store.delete(slot).catch(() => undefined)
1009    cleared++
1010  }
1011  await say($, resetText(cleared))
1012}
1013
1014const openEntry = async ($: EngineInterface, at?: number): Promise<void> => {
1015  const ui = await read($, uiAtom)
1016  if (ui.status !== 'pick') return
1017  const entry = startEntries(ui.saved ?? [])[at ?? ui.startMark ?? 0]
1018  if (!entry) return
1019  if (entry.kind === 'replay') return replayOver($, entry.id)
1020  if (entry.kind === 'recording') return replayRecording($, entry.id)
1021  if (entry.kind === 'reset') return forgetAnswers($)
1022  const back = ui.back ?? null
1023  if (entry.kind === 'saved' && entry.id === back) return closeProjectList($)
1024  // A project started from a template and never edited has no file: left for another, nothing of it is kept.
1025  const dropped = back !== null && behind !== null && behind.ui.name === back && behind.ui.unsaved ? back : null
1026  if (entry.kind === 'saved') await openSaved($, entry.id)
1027  else await startFrom($, entry.id)
1028  const now = await read($, uiAtom)
1029  if (now.status !== 'ready') return
1030  behind = null
1031  if (dropped !== null && now.notice === null) await say($, `${dropped} was not saved: nothing of it was kept`)
1032}
1033
1034const startKey = async ($: EngineInterface, key: string): Promise<boolean> => {
1035  const ui = await read($, uiAtom)
1036  if (key === ENTER) {
1037    await openEntry($)
1038
1039    return false
1040  }
1041  const op = KEY_OPS[key]
1042  if (op === 'minimize') {
1043    minimize($, 'person')
1044
1045    return false
1046  }
1047  const helping = (ui.help ?? 0) > 0
1048  if (op === 'up' || op === 'down') beat($)
1049  if (op === 'up' || op === 'down') await update($, uiAtom, held => ({ ...moveStartMark(held, op === 'up' ? -1 : 1), help: 0 }))
1050  else if (op === 'help') await update($, uiAtom, held => ({ ...held, notice: null, help: ((held.help ?? 0) % HELP_PAGES) + 1 }))
1051  else if (op === 'remove' && helping) await update($, uiAtom, held => ({ ...held, help: 0 }))
1052  else if ((op === 'projects' || op === 'back') && (ui.back ?? null) !== null) {
1053    await closeProjectList($)
1054
1055    return false
1056  } else await say($, unboundNotice(key))
1057
1058  return true
1059}
1060
1061/** Resolves to false when the keys typed with it are not to be applied (the keyboard went to the prompt, nothing is on the board). */
1062const runKey = async ($: EngineInterface, key: string): Promise<boolean> => {
1063  const ui = await read($, uiAtom)
1064  const project = await read($, projectAtom)
1065  if (ui.drafting === 'wait') return false
1066  if (ui.status === 'pick') return startKey($, key)
1067  // A file that is no project: the one key is the way to the others.
1068  if (ui.status === 'error' && KEY_OPS[key] === 'projects') await openProjectList($)
1069  if (ui.status !== 'ready' || project === null) return false
1070  const { locked } = await runContext($)
1071  const node = ui.selectedId === null ? undefined : findNode(project, ui.selectedId)
1072  // A field was asked for (`e`, a new block, `n`) and the ring is on its way there: it moves a redraw later. What
1073  // is typed into the catcher meanwhile is that field's text, never a key: `e` then `x` types an x, it deletes nothing.
1074  if (!ui.handed && !locked) {
1075    if (ui.editing && node) {
1076      const mine = drafts.id === node.id && drafts.name === ui.name
1077      const first = firstField(node)
1078      const text = (mine ? drafts.values[first] : undefined) ?? (first !== NAME_FIELD ? (node.knobs?.goal ?? '') : ui.freshId === node.id ? '' : node.name)
1079      if (key === ENTER) await saveField($, node.id, first, text)
1080      else {
1081        // The field may be drawn already, without this letter: it is kept beside it until the typing pauses.
1082        const pin = pins[fieldKey(first, node.id)]
1083        if (pin) pin.head += key
1084        drafts = { name: ui.name, id: node.id, values: { ...(mine ? drafts.values : {}), [first]: pin ? `${pin.value}${pin.head}${pin.tail}` : `${text}${key}` } }
1085      }
1086
1087      return true
1088    }
1089    if (ui.picker !== null) {
1090      if (key === ENTER) await pick($, {})
1091      else {
1092        await update($, uiAtom, held => typePicker(project, held, `${held.picker?.query ?? ''}${key}`).ui)
1093        // `x` closed the list before the ring got to its filter.
1094        if ((await read($, uiAtom)).picker === null) await home($)
1095      }
1096
1097      return true
1098    }
1099  }
1100  const op = KEY_OPS[key]
1101  if (op === undefined) {
1102    await say($, unboundNotice(key))
1103
1104    return true
1105  }
1106  if (op === 'minimize') {
1107    minimize($, 'person')
1108
1109    return false
1110  }
1111  if (op === 'up' || op === 'down' || op === 'left' || op === 'right') beat($)
1112  const flushed = await flushDrafts($)
1113  const step = await commit($, (before, held, context) => keyStep(before, held, op, context))
1114  await keepSaid($, flushed)
1115  // A request waits in the prompt box: the key was not acted on, and the pane says how to get to the prompt.
1116  if (ui.handed) return false
1117  if (op === 'projects') {
1118    await openProjectList($)
1119
1120    return false
1121  }
1122  if ((op === 'run' || op === 'review') && !locked) {
1123    await request($, op === 'run' ? 'run' : 'ask')
1124
1125    return false
1126  }
1127  const edited = op === 'edit' && step.ui.editing && step.project && step.ui.selectedId !== null ? findNode(step.project, step.ui.selectedId) : undefined
1128  if (edited) void landOn($, { base: firstField(edited), id: edited.id }, `edit: ${EDIT_HINT}`)
1129  if ((op === 'add' || op === 'retype') && step.ui.picker) {
1130    pickGen++
1131    void landOn($, 'picker', op === 'add' ? 'new block: click the filter to type in it' : 'block: click the filter to type in it')
1132  }
1133
1134  return true
1135}
1136
1137/** A change that is no key joins the line of keys: no key is applied between its read and its write. What it throws is its caller's. */
1138function inLine<T>(work: () => Promise<T>): Promise<T> {
1139  const done = keyChain.then(work)
1140  keyChain = done.then(
1141    () => undefined,
1142    () => undefined,
1143  )
1144
1145  return done
1146}
1147
1148const press = ($: EngineInterface, work: () => Promise<unknown>): void => {
1149  keyChain = keyChain
1150    .then(async (): Promise<void> => {
1151      await work()
1152    })
1153    .catch((error: unknown) => {
1154      $.ui.toast(`conductor: ${reason(error)}`)
1155    })
1156    .then(() => $.ui.invalidate('ui.render'))
1157}
1158
1159/** Each key is applied on the state the one before it left. */
1160const queueKeys = ($: EngineInterface, keys: readonly string[] | null): void => {
1161  keyChain = keyChain
1162    .then(async (): Promise<void> => {
1163      if (keys === null) return say($, PASTE_NOTICE)
1164      for (const key of keys) if (!(await runKey($, key))) return
1165    })
1166    .catch((error: unknown) => {
1167      $.ui.toast(`conductor: ${reason(error)}`)
1168    })
1169    // The catcher is drawn again under its new key, empty, whatever the keys changed.
1170    .then(() => $.ui.invalidate('ui.render'))
1171}
1172
1173/** The catcher holds its whole text so far, so the new characters are the ones after those already taken; more than a burst at once is a paste. */
1174const catchKeys = ($: EngineInterface, element: string, kind: 'change' | 'submit', value: string): void => {
1175  const { sent } = caught
1176  // After Enter the text is new from its first character; where it still starts with all that Enter sent, from there.
1177  const from = caught.element !== element ? 0 : sent === null ? Math.min(value.length, caught.taken) : value.length > sent.length && value.startsWith(sent) ? sent.length : 0
1178  const fresh = value.slice(from)
1179  caught = { element, taken: value.length, sent: kind === 'submit' ? value : null }
1180  tidySoon($)
1181  const burst = burstKeys(fresh)
1182  queueKeys($, 'paste' in burst ? null : kind === 'submit' ? [...burst.keys, ENTER] : burst.keys)
1183}
1184
1185/** What is typed into the door is the title's text, as what is typed into the catcher after `e` is. */
1186const doorKeys = ($: EngineInterface, element: string, kind: 'change' | 'submit', value: string): void => {
1187  const fresh = doored.element === element ? value.slice(Math.min(value.length, doored.taken)) : value
1188  doored = { element, taken: value.length }
1189  tidySoon($)
1190  keyChain = keyChain
1191    .then(async (): Promise<void> => {
1192      if (!(await read($, uiAtom)).editing) await openFields($)
1193      if (!(await read($, uiAtom)).editing) return
1194      for (const key of [...fresh, ...(kind === 'submit' && value !== '' ? [ENTER] : [])]) if (!(await runKey($, key))) return
1195    })
1196    .catch((error: unknown) => {
1197      $.ui.toast(`conductor: ${reason(error)}`)
1198    })
1199    .then(() => $.ui.invalidate('ui.render'))
1200}
hooks/offer.tsx 153 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { offerBrief } from '../src/project/chat'
4import { LAST_SCRIPTS_STORE, buildHandoff, lastScripts } from '../src/project/handoff'
5import type { HandoffScript } from '../src/project/handoff'
6import { isBlank, parseProject } from '../src/project/model'
7import {
8  DISMISSED_RESULT,
9  OFFER_HEADER,
10  OFFER_STORE,
11  OFFER_OPTIONS,
12  OFFER_QUESTION,
13  OFFER_TOOL_DESCRIPTION,
14  OFFER_TOOL_FULL_NAME,
15  OFFER_TOOL_NAME,
16  handedResult,
17  keptToast,
18  offerChoice,
19  standardResult,
20  storedChoice,
21  typedResult,
22  waitingResult,
23} from '../src/project/offer'
24import { projectFile, scriptFile } from '../src/project/session'
25import type { Tracker } from '../src/run/tracker'
26
27/** A script a run left elsewhere is newer than the one beside the project: a run of that one forgets it. */
28const scriptOf = async ($: EngineInterface, root: string, name: string): Promise<HandoffScript> => {
29  const path = scriptFile(root, name)
30  const last = lastScripts(await $.store.get(LAST_SCRIPTS_STORE).catch(() => undefined))[name]
31  if (last !== undefined && (await $.fs.exists(last).catch(() => false))) return { path, from: last }
32
33  return { path, from: (await $.fs.exists(path).catch(() => false)) ? path : null }
34}
35
36/** "Conductor" was answered and the draft it asks Claude for has not landed yet. A module variable: it lasts a turn. */
37let offered = false
38
39/** Whether the project being written is the one the person just asked for in the question; asking spends it. */
40export const takeOffered = (): boolean => {
41  const was = offered
42  offered = false
43
44  return was
45}
46
47/**
48 * The project the last "Conductor" answer led to, until it is handed off or run. While it waits the question is not
49 * asked again: Claude would draft a second project, or write a script the board knows nothing of.
50 */
51let waiting: string | null = null
52/** The tool already answered in this turn that a project waits: a second call is for another workflow, and asks. */
53let answered = false
54
55/** `$` does not cross files, so project.tsx's session.start hook gives this file its board, as it gives run.tsx its `RunBoard`. */
56export type OfferBoard = { drafting: () => Promise<void> }
57let offerBoard: OfferBoard | undefined
58export const setOfferBoard = (board: OfferBoard): void => {
59  offerBoard = board
60}
61
62export const offerDrafted = (name: string): void => {
63  waiting = name
64}
65
66/** The project handed off on the board (`r`) whose hand-off Claude has not been given yet: the tool's next answer is it. */
67let handing: string | null = null
68
69export const offerHanded = (name: string): void => {
70  handing = name
71  if (waiting === name) waiting = null
72}
73
74/** Handed off or run: project `name` waits for no approval. */
75export const offerSettled = (name: string): void => {
76  if (waiting === name) waiting = null
77}
78
79/** A prompt was sent: a "Conductor" answer whose draft never came is over with its turn, and so is what the tool said in it. */
80export const offerPrompted = (): void => {
81  offered = false
82  answered = false
83  handing = null
84}
85
86export const registerOffer = (on: On, tracker: Tracker): void => {
87  // Not deferred: Claude calls a tool before a workflow only when its description is in front of it.
88  // The matcher is always true: run.tsx holds the unmatched session.start, project.tsx the one on `cwd`.
89  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
90    await $.tool.register({ name: OFFER_TOOL_NAME, description: OFFER_TOOL_DESCRIPTION, isDeferred: false })
91
92    return next(e)
93  })
94
95  // Claude always gets a result to act on, never a throw and never a deny: it does not take instructions from a refusal.
96  on('tool.call', { tool: OFFER_TOOL_FULL_NAME }, async ($, e) => {
97    const conductor = async (asked: boolean): Promise<{ result: string }> => {
98      offered = true
99      // project.tsx shows the splash until the project lands.
100      await offerBoard?.drafting().catch(() => undefined)
101
102      return { result: offerBrief(asked) }
103    }
104    // A workflow's own agents, and any subagent: the person is asked by the conversation, once.
105    if (e.agentId !== undefined) return { result: standardResult('subagent') }
106    // The project the person just handed off on the board: its hand-off, once.
107    if (handing !== null) {
108      const name = handing
109      handing = null
110      const root = await $.session.root()
111      const path = projectFile(root, name)
112      const parsed = parseProject(await $.fs.read(path).catch(() => ''))
113      if (parsed.ok && !isBlank(parsed.project)) {
114        return { result: handedResult(buildHandoff({ ...parsed.project, name }, path, await scriptOf($, root, name)).context.join('\n')) }
115      }
116    }
117    // The script being written is the project the person approved on the board: that was their answer.
118    if (tracker.awaitsRun()) return { result: standardResult('handoff') }
119    // A `-p` run draws nowhere: nobody reads a question, and nobody would review a project.
120    const surfaces = await $.session.surfaces().catch(() => undefined)
121    if (surfaces !== undefined && surfaces.length === 0) return { result: standardResult('headless') }
122    // The project drafted after the last "Conductor" answer still waits for the person: said once a turn, with what an
123    // approval given in chat is carried out by. Gone, emptied or said already: the question is asked as for any workflow.
124    if (waiting !== null && !answered) {
125      const root = await $.session.root()
126      const path = projectFile(root, waiting)
127      const parsed = parseProject(await $.fs.read(path).catch(() => ''))
128      if (parsed.ok && !isBlank(parsed.project)) {
129        answered = true
130        return { result: waitingResult(waiting, buildHandoff({ ...parsed.project, name: waiting }, path, await scriptOf($, root, waiting)).context.join('\n')) }
131      }
132    }
133    waiting = null
134    const kept = storedChoice(await $.store.get(OFFER_STORE).catch(() => undefined))
135    if (kept !== null) return kept === 'conductor' ? conductor(false) : { result: standardResult('always') }
136
137    let answer: string
138    try {
139      answer = await $.ui.ask(OFFER_QUESTION, { options: OFFER_OPTIONS, header: OFFER_HEADER })
140    } catch {
141      return { result: DISMISSED_RESULT }
142    }
143    const picked = offerChoice(answer)
144    if (picked === null) return { result: answer.trim() === '' ? DISMISSED_RESULT : typedResult(answer) }
145    if (picked.always) {
146      await $.store.set(OFFER_STORE, picked.choice).catch(() => undefined)
147      $.ui.toast(keptToast(picked.choice))
148    }
149
150    return picked.choice === 'conductor' ? conductor(true) : { result: standardResult('chosen') }
151  })
152}
153
hooks/run.tsx 290 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { visualizeBrief } from '../src/project/chat'
5import { LAST_SCRIPTS_STORE, noteScript } from '../src/project/handoff'
6import { parseProject } from '../src/project/model'
7import { VISUALIZE_HEADER, VISUALIZE_STORE, VISUALIZE_OPTIONS, VISUALIZE_QUESTION, reusedToast, storedVisualize, visualizeChoice, visualizeToast, visualizeTyped } from '../src/project/offer'
8import { PROJECT_PANE_ID, bareTarget, projectFile, projectName, endRun, followRun, handoffOf, initialTimes, initialUi, isSaved, scriptFile, stampTimes } from '../src/project/session'
9import { emptyRunState } from '../src/run/events'
10import type { RunState } from '../src/run/events'
11import { USAGE_KEYS } from '../src/run/journal'
12import type { Handoff, Tracker } from '../src/run/tracker'
13import { buildRunRows, togglePhase } from '../src/ui/rail'
14import type { Part, Row } from '../src/ui/rail'
15import { initialShell } from '../src/ui/compact'
16import { FADED, GLYPH, PANE_ID, PANE_TITLE, TEXT } from '../src/ui/theme'
17
18import { paneArgs, runBoardOf } from './project'
19import { offerSettled } from './offer'
20
21const PLACEHOLDER = 'no workflow running yet'
22const MAX_WIDTH = 96
23const MAX_ROWS = 400
24// Both hooks modules declare the run's atoms (an atom must be spelled in the file that reads it):
25// run.tsx writes them through the tracker, project.tsx reads them.
26const runAtom = atom({ plugin: 'workflow-conductor', key: 'run' } as const, emptyRunState())
27const clockAtom = atom({ plugin: 'workflow-conductor', key: 'runClock' } as const, 0)
28/** The project the tracked run belongs to (handed off, or named as it); null for any other run. */
29const runProjectAtom = atom({ plugin: 'workflow-conductor', key: 'runProject' } as const, null)
30const uiAtom = atom({ plugin: 'workflow-conductor', key: 'runUi' } as const, { expanded: [], collapsed: [] })
31/** When the run began and ended: stamped as its state is written. */
32const timesAtom = atom({ plugin: 'workflow-conductor', key: 'runTimes' } as const, initialTimes())
33/** The tracked run's script file, and the project drawn of that run after it started (project.tsx `attach` sets it). */
34const runScriptAtom = atom({ plugin: 'workflow-conductor', key: 'runScript' } as const, null)
35// The designer's state (project.tsx owns it): read and written here only so the selection can follow the run.
36const projectAtom = atom({ plugin: 'workflow-conductor', key: 'project' } as const, null)
37const projectUiAtom = atom({ plugin: 'workflow-conductor', key: 'projectUi' } as const, initialUi())
38/** Where the designer is (project.tsx owns it): written here only so a run whose pane could not open is shown in the band. */
39const shellAtom = atom({ plugin: 'workflow-conductor', key: 'shell' } as const, initialShell())
40
41// The reducer leaves optional fields present but undefined; the stored copy is plain data.
42const plain = (state: RunState): RunState => JSON.parse(JSON.stringify(state)) as RunState
43
44const text = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined)
45
46const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0)
47
48/** One request's figure: all four usage counts, as the built-in workflow row counts an agent's latest request. */
49const usageTotal = (usage: unknown): number | undefined => {
50  if (usage === null || typeof usage !== 'object') return undefined
51  const counts = usage as Record<string, unknown>
52
53  return USAGE_KEYS.reduce((sum, key) => sum + count(counts[key]), 0)
54}
55
56export const registerRun = (on: On, tracker: Tracker): void => {
57  on('session.start', async ($, e, next) => {
58    await $.command.register({
59      name: 'conductor',
60      description: 'Workflow designer and run view: no argument opens the board (a run in progress, the project last opened, or the start screen to pick one); with a description, Claude drafts a new project for it; min and max move the designer between its pane and the band above the prompt',
61      argumentHint: '[what the workflow should do | min | max]',
62    })
63
64    // A run a previous load of the module was following: its project's node ids are not stored, so the project file is read again.
65    const stored = await read($, runAtom)
66    const attached = stored.status === 'idle' ? null : await read($, runProjectAtom)
67    let claim: Handoff | undefined
68    if (attached !== null) {
69      const body = await $.fs.read(projectFile(await $.session.root(), attached)).catch(() => '')
70      const parsed = parseProject(typeof body === 'string' ? body : '')
71      if (parsed.ok) claim = handoffOf(attached, parsed.project)
72    }
73
74    // The tracker's only way out: every `$` call it causes is spelled here. Its timers
75    // outlive this hook, as a `$.clock` callback does.
76    tracker.attach(
77      {
78        write: async state => {
79          await update($, runAtom, () => plain(state))
80          const now = await $.clock.now()
81          const times = await read($, timesAtom)
82          if (stampTimes(times, state, now) !== times) await update($, timesAtom, held => stampTimes(held, state, now))
83          // The designer's selection follows the block that is running, until the person moves it.
84          const name = await read($, runProjectAtom)
85          if (name === null || state.status === 'idle') return
86          const project = await read($, projectAtom)
87          const shown = await read($, projectUiAtom)
88          // Over: the selection goes to the first block with a failure, once.
89          const settle = state.status === 'running' ? followRun : endRun
90          if (settle(shown, project, state, name) !== shown) await update($, projectUiAtom, held => settle(held, project, state, name))
91        },
92        frame: async tick => {
93          await update($, clockAtom, () => tick)
94          // The elapsed time in the designer's header: stamped here, about once a second, so no drawing reads a clock.
95          const now = await $.clock.now()
96          const times = await read($, timesAtom)
97          if (stampTimes(times, tracker.state(), now) !== times) await update($, timesAtom, held => stampTimes(held, tracker.state(), now))
98        },
99        readText: async path => {
100          const body = await $.fs.read(path)
101
102          return typeof body === 'string' ? body : ''
103        },
104        every: (ms, fn) => $.clock.every(ms, fn),
105        after: (ms, fn) => $.clock.after(ms, fn),
106        // Either pane shows the run: the run view, or the designer with the run laid over it.
107        paneShown: async () => (await $.ui.panes()).some(pane => (pane.id === PANE_ID || pane.id === PROJECT_PANE_ID) && pane.isShown && pane.isPlaced),
108        project: name => update($, runProjectAtom, () => name),
109        status: line => $.ui.status(line),
110        toast: line => $.ui.toast(line),
111      },
112      stored,
113      claim,
114    )
115
116    return next(e)
117  })
118
119  on('command.run', { command: 'conductor' }, async ($, e, next) => {
120    // With words it is a draft, and project.tsx's, whichever of the hooks the engine asks first.
121    if (e.args.trim() !== '') return next(e)
122    // A run that came from a project is shown on that project, and with no run there is nothing to show here: both are project.tsx's.
123    if (bareTarget((await read($, runAtom)).status, await read($, runProjectAtom), await read($, projectUiAtom)).view === 'project') return next(e)
124    const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
125    if (opened.isPlaced) tracker.paneOpened()
126
127    return { text: 'Conductor pane opened.' }
128  })
129
130  // A run named as a saved project is that project's, handed off or not; one a handoff waited for is that project's under any name.
131  // A run no project claimed is asked about, the turn held meanwhile. Whatever follows the start, the tool's result goes back: never a throw.
132  on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
133    const answer = await next(e)
134    const result = (answer as { result?: unknown }).result
135    if (result === null || typeof result !== 'object') return answer
136    const { runId, taskId, workflowName, transcriptDir, scriptPath } = result as Record<string, unknown>
137    const id = text(runId)
138    if (id === undefined) return answer
139    const workflow = text(workflowName)
140    // The board is on the run's project before the tracker places an agent on it. Never a throw: a run is tracked all the same.
141    const board = runBoardOf()
142    const own = (await board?.projectOfRun(workflow).catch(() => null)) ?? null
143    tracker.runStarted({ runId: id, name: workflow, transcriptDir: text(transcriptDir), taskId: text(taskId), project: own === null ? undefined : handoffOf(own.name, own.project) })
144
145    try {
146      // The script is the file the result names, in every form of the call. A run started again keeps its drawing.
147      const script = text(scriptPath)
148      await update($, runScriptAtom, held => (script === undefined ? null : { runId: id, path: script, drawn: held !== null && held.runId === id ? held.drawn : null }))
149      const claimed = tracker.project()
150      if (claimed !== undefined) {
151        offerSettled(claimed)
152        // Out of a handoff the script is the project's as of now, for the next handoff to start from.
153        // A run that has only the project's name never stands in for the script the project has: that one may be newer.
154        if (script !== undefined && isSaved(claimed)) {
155          const beside = scriptFile(await $.session.root(), claimed)
156          if (tracker.handedOff() || !(await $.fs.exists(beside).catch(() => true))) await $.store.set(LAST_SCRIPTS_STORE, noteScript(await $.store.get(LAST_SCRIPTS_STORE).catch(() => undefined), claimed, script, beside)).catch(() => undefined)
157        }
158        // Where nobody handed the project off, a toast says where the run went.
159        if (own !== null) {
160          const pane = await $.ui.open(paneArgs(false)).catch(() => undefined)
161          // Not placed (nobody asked, a narrow terminal): the run is shown in the band above the prompt.
162          if (pane?.isPlaced === false) await update($, shellAtom, held => (held.minimized === 'no' ? { minimized: 'auto' as const, why: 'narrow' as const } : held))
163          if (!tracker.handedOff()) $.ui.toast(reusedToast(own.name, pane?.isPlaced !== false))
164        }
165
166        return answer
167      }
168      // A subagent's workflow is not the person's to be asked about; nor is one where nobody reads a question.
169      if (e.agentId !== undefined || answer.deny !== undefined || answer.isError === true) return answer
170      const surfaces = await $.session.surfaces().catch(() => undefined)
171      if (surfaces !== undefined && surfaces.length === 0) return answer
172      // With no script file there is nothing to draw a project of.
173      if (script === undefined) return answer
174      const kept = storedVisualize(await $.store.get(VISUALIZE_STORE).catch(() => undefined))
175      let choice = kept
176      if (choice === null) {
177        let said: string
178        try {
179          said = await $.ui.ask(VISUALIZE_QUESTION, { options: VISUALIZE_OPTIONS, header: VISUALIZE_HEADER })
180        } catch {
181          return answer
182        }
183        const picked = visualizeChoice(said)
184        if (picked === null) return said.trim() === '' ? answer : { ...answer, context: [...(answer.context ?? []), visualizeTyped(said)] }
185        if (picked.always) {
186          await $.store.set(VISUALIZE_STORE, picked.choice).catch(() => undefined)
187          $.ui.toast(visualizeToast(picked.choice))
188        }
189        choice = picked.choice
190      }
191
192      return choice === 'skip' ? answer : { ...answer, context: [...(answer.context ?? []), visualizeBrief(script, kept === null, workflow === undefined ? undefined : (projectName(workflow) ?? undefined))] }
193    } catch {
194      return answer
195    }
196  })
197
198  // Only a workflow's agents carry `e.workflow`; the id is on what `next` resolves to.
199  on('agent.spawn', async (_$, e, next) => {
200    const answer = await next(e)
201    const agentId = e.workflow ? text((answer as { agentId?: unknown }).agentId) : undefined
202    if (e.workflow && agentId !== undefined) {
203      tracker.agentSpawned({ runId: e.workflow.runId, agentId, label: e.description, index: e.workflow.agentIndex })
204    }
205
206    return answer
207  })
208
209  // Runs on every loop: each chunk goes through untouched, the last one is read.
210  on('turn.step', async function* (_$, e, next) {
211    for await (const chunk of next(e)) {
212      if (chunk.kind === 'stop' && e.agentId !== undefined) tracker.agentUsage(e.agentId, usageTotal(chunk.usage) ?? 0)
213      yield chunk
214    }
215  })
216
217  on('turn.complete', async (_$, e, next) => {
218    if (e.agentId !== undefined) {
219      tracker.agentEnded(e.agentId, {
220        ok: !e.isAborted && e.reason !== 'error' && e.reason !== 'refusal',
221        aborted: e.isAborted,
222        durationMs: e.durationMs,
223      })
224    }
225
226    return next(e)
227  })
228
229  // A stopped run gets no notice; Claude's stop of its task is the one sign of it there is, besides its agents' aborted ends.
230  on('tool.call', { tool: 'TaskStop' }, async (_$, e, next) => {
231    const answer = await next(e)
232    const stopped = text((answer as { result?: { task_id?: unknown } }).result?.task_id)
233    if (stopped !== undefined && answer.deny === undefined && answer.isError !== true) tracker.taskEnded(stopped, 'killed')
234
235    return answer
236  })
237
238  // The run's end: the engine's notice of its task, as the transcript draws it, passed on untouched and drawn again on every redraw.
239  // Told from a timer: no state is written while a render hook draws.
240  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
241    const task = e.props.task
242    if (e.props.origin.kind === 'task-notification' && task?.id !== undefined && task.status !== undefined) {
243      const { id, status, durationMs } = task
244      $.clock.after(0, () => tracker.taskEnded(id, status, durationMs))
245    }
246
247    return next(e)
248  })
249
250  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
251    const { Box, Text, Button } = $.ui.resolve(e)
252    const state = await read($, runAtom)
253    const ui = await read($, uiAtom)
254    const now = await read($, clockAtom)
255    const rows = buildRunRows(state, {
256      expanded: new Set(ui.expanded),
257      collapsed: new Set(ui.collapsed),
258      now,
259      width: Math.min(MAX_WIDTH, e.props.bodyColumns),
260    })
261
262    const span = (part: Part) => (
263      <Text color={part.color ?? TEXT} bold={part.bold}>
264        {part.text}
265      </Text>
266    )
267    const draw = (row: Row) => {
268      const phase = row.phase
269      if (row.kind !== 'phase' || phase === undefined) return <Text wrap="truncate-end">{row.parts.map(span)}</Text>
270      const isOpen = row.open === true
271
272      // Keyed by the phase, not its text: the counts in the label change, the address must not.
273      return (
274        <Button key={row.key} plain onPress={() => update($, uiAtom, held => togglePhase(held, phase, isOpen))}>
275          {row.parts.map(span)}
276        </Button>
277      )
278    }
279
280    return (
281      <Box flexDirection="column">
282        <Text bold color={TEXT}>{`${GLYPH.diamond} ${PANE_TITLE}`}</Text>
283        {rows.length === 0 && <Text color={FADED}>{`${GLYPH.railEnd} ${PLACEHOLDER}`}</Text>}
284        {rows.slice(0, MAX_ROWS).map(draw)}
285        {rows.length > MAX_ROWS && <Text color={FADED}>{`… ${rows.length - MAX_ROWS} more rows`}</Text>}
286      </Box>
287    )
288  })
289}
290
src/ui/rail.ts 325 lines
1import type { AgentRun, RunState } from '../run/events'
2
3import { cellWidth, clip, oneLine } from './text'
4import { FADED, GLYPH, RULE } from './theme'
5
6// The run pane as plain rows: no `$`, no elements. hooks/run.tsx maps rows to elements.
7
8export type ColorKey = 'text' | 'claude' | 'success' | 'error' | 'subtle' | 'inactive'
9
10export type Part = { text: string; color?: ColorKey; bold?: boolean }
11
12export type RowKind = 'header' | 'phase' | 'agent' | 'footer'
13
14export type Counts = { total: number; running: number; done: number; failed: number }
15
16export type Row = {
17  kind: RowKind
18  /** Stable address: `header`, `phase:<title>`, `agent:<id>`, `footer`. */
19  key: string
20  glyph: string
21  /** Color of the glyph, which is the row's state. */
22  color: ColorKey
23  /** The whole line left to right, glyph included, padded to the width. */
24  parts: Part[]
25  text: string
26  /** Phase title: on a phase row its own, on an agent row its group's. */
27  phase?: string
28  /** Phase rows: whether its agents follow. */
29  open?: boolean
30  counts?: Counts
31  tokens?: string
32  bar?: string
33}
34
35export type RailUi = {
36  expanded: ReadonlySet<string>
37  /** Phases the person closed; wins over the automatic expansion on failure. */
38  collapsed?: ReadonlySet<string>
39  /** Milliseconds on the run's tick clock; picks the spinner frame. */
40  now?: number
41  /** Cells across; rows are padded so the right-hand columns line up. */
42  width?: number
43}
44
45export type RailUiState = { expanded: string[]; collapsed: string[] }
46
47export const NO_PHASE = '(no phase)'
48export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
49export const SPIN_MS = 96
50export const FAILED = '✗'
51export const DEFAULT_WIDTH = 60
52export const BAR_WIDTH = 8
53
54const MIN_WIDTH = 36
55const EIGHTHS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'] as const
56const COUNT_COLUMN = 8
57const TOKEN_COLUMN = 8
58
59/** `842`, `18.2k`, `120k`, `1.2M`. */
60export function fmtCount(tokens: number): string {
61  if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) return '0'
62  const n = Math.round(tokens)
63  if (n < 1000) return String(n)
64  if (n < 99_950) return `${(n / 1000).toFixed(1)}k`
65  if (n < 999_500) return `${Math.round(n / 1000)}k`
66
67  return `${(n / 1_000_000).toFixed(1)}M`
68}
69
70/** `850ms`, `1.1s`, `42s`, `3m05s`, `1h02m`. */
71export function fmtDuration(ms: number): string {
72  if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return ''
73  if (ms < 1000) return `${Math.round(ms)}ms`
74  if (ms < 9950) return `${(ms / 1000).toFixed(1)}s`
75
76  const seconds = Math.round(ms / 1000)
77  if (seconds < 60) return `${seconds}s`
78
79  const minutes = Math.floor(seconds / 60)
80  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
81
82  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
83}
84
85export function bar(done: number, total: number, width: number = BAR_WIDTH): string {
86  const cells = Math.max(0, Math.floor(width))
87  const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0
88  const eighths = Math.round(ratio * cells * 8)
89  const full = Math.floor(eighths / 8)
90  const rest = eighths % 8
91
92  return ('█'.repeat(full) + (rest > 0 ? EIGHTHS[rest - 1] : '')).padEnd(cells, ' ')
93}
94
95export function spinnerFrame(now: number = 0): string {
96  const frame = Math.floor(Math.max(0, now) / SPIN_MS) % SPINNER.length
97
98  return SPINNER[frame] ?? SPINNER[0]
99}
100
101/** Left parts, a gap, right parts: `width` cells in all, the left side clipped to fit. */
102function line(left: Part[], right: Part[], width: number): Part[] {
103  const rightCells = right.reduce((sum, part) => sum + cellWidth(part.text), 0)
104  let room = Math.max(0, width - rightCells - (rightCells > 0 ? 1 : 0))
105  const fitted: Part[] = []
106
107  for (const part of left) {
108    const text = clip(part.text, room)
109    if (text.length > 0) fitted.push({ ...part, text })
110    room -= cellWidth(text)
111  }
112
113  const used = fitted.reduce((sum, part) => sum + cellWidth(part.text), 0)
114  const gap = Math.max(0, width - used - rightCells)
115
116  return [...fitted, ...(gap > 0 && rightCells > 0 ? [{ text: ' '.repeat(gap) }] : []), ...right]
117}
118
119function countOf(agents: readonly AgentRun[]): Counts & { tokens: number } {
120  const sum = { total: 0, running: 0, done: 0, failed: 0, tokens: 0 }
121  for (const agent of agents) {
122    sum.total++
123    sum[agent.status]++
124    sum.tokens += agent.tokens
125  }
126
127  return sum
128}
129
130function stateColor(counts: Counts): ColorKey {
131  if (counts.running > 0) return 'claude'
132  if (counts.failed > 0) return 'error'
133
134  return counts.total > 0 ? 'success' : 'inactive'
135}
136
137function tally(counts: Counts & { tokens: number }): Part[] {
138  const settled = `${counts.done + counts.failed}/${counts.total}`
139  const failed = counts.failed > 0 ? `${counts.failed}${FAILED} ` : ''
140  const pad = ' '.repeat(Math.max(1, COUNT_COLUMN - cellWidth(settled) - cellWidth(failed)))
141
142  return [
143    { text: pad },
144    ...(failed ? [{ text: failed, color: 'error' as const }] : []),
145    { text: settled },
146    { text: fmtCount(counts.tokens).padStart(TOKEN_COLUMN, ' '), color: FADED },
147  ]
148}
149
150const row = (fields: Omit<Row, 'text'>): Row => ({ ...fields, text: fields.parts.map(part => part.text).join('') })
151
152/** Agents grouped by phase in first-seen order; agents without one come last. */
153export function groupByPhase(state: RunState): { phase: string; agents: AgentRun[] }[] {
154  const groups = new Map<string, AgentRun[]>()
155  const loose: AgentRun[] = []
156
157  for (const agent of Object.values(state.agents)) {
158    if (agent.phase === undefined || agent.phase === '') {
159      loose.push(agent)
160      continue
161    }
162    const group = groups.get(agent.phase)
163    if (group) group.push(agent)
164    else groups.set(agent.phase, [agent])
165  }
166
167  const ordered = [...groups].map(([phase, agents]) => ({ phase, agents }))
168  if (loose.length > 0) ordered.push({ phase: NO_PHASE, agents: loose })
169
170  return ordered
171}
172
173export function buildRunRows(state: RunState, ui: RailUi): Row[] {
174  const agents = Object.values(state.agents)
175  if (state.status === 'idle' && agents.length === 0) return []
176
177  const width = Math.max(MIN_WIDTH, Math.floor(ui.width ?? DEFAULT_WIDTH))
178  const spin = spinnerFrame(ui.now)
179  const all = countOf(agents)
180  const statusColor: ColorKey = state.status === 'running' ? 'claude' : state.status === 'failed' ? 'error' : state.status === 'stopped' ? 'subtle' : 'success'
181  const rows: Row[] = []
182  // A run's name, a phase's title and an agent's label are not this mod's words: each is shown cleaned, on one line.
183  const name = oneLine(state.name ?? state.runId ?? '') || 'workflow'
184
185  rows.push(
186    row({
187      kind: 'header',
188      key: 'header',
189      glyph: GLYPH.dot,
190      color: statusColor,
191      parts: line(
192        [
193          { text: `${GLYPH.dot} `, color: statusColor },
194          { text: 'wf: ', color: FADED },
195          { text: name, bold: true },
196        ],
197        [{ text: state.status, color: statusColor }, ...tally(all)],
198        width,
199      ),
200      counts: { total: all.total, running: all.running, done: all.done, failed: all.failed },
201      tokens: fmtCount(all.tokens),
202    }),
203  )
204
205  for (const { phase, agents: members } of groupByPhase(state)) {
206    const counts = countOf(members)
207    const color = stateColor(counts)
208    const open = ui.collapsed?.has(phase) ? false : ui.expanded.has(phase) || counts.failed > 0
209    const glyph = counts.running > 0 ? spin : GLYPH.diamond
210    const progress = bar(counts.done + counts.failed, counts.total)
211
212    rows.push(
213      row({
214        kind: 'phase',
215        key: `phase:${phase}`,
216        glyph,
217        color,
218        phase,
219        open,
220        parts: line(
221          [
222            { text: `${glyph}  `, color },
223            { text: oneLine(phase), color: phase === NO_PHASE ? FADED : undefined },
224            { text: `  ×${counts.total}`, color: FADED },
225          ],
226          [{ text: `▕${progress}▏`, color: counts.running > 0 ? 'claude' : RULE }, ...tally(counts)],
227          width,
228        ),
229        counts: { total: counts.total, running: counts.running, done: counts.done, failed: counts.failed },
230        tokens: fmtCount(counts.tokens),
231        bar: progress,
232      }),
233    )
234
235    if (!open) continue
236
237    members.forEach((agent, at) => {
238      const agentColor: ColorKey = agent.status === 'running' ? 'claude' : agent.status === 'failed' ? 'error' : 'success'
239      const agentGlyph = agent.status === 'running' ? spin : agent.status === 'failed' ? FAILED : GLYPH.done
240      const branch = at === members.length - 1 ? GLYPH.railEnd : GLYPH.railBranch
241      const took = agent.durationMs === undefined ? '' : fmtDuration(agent.durationMs)
242      const tokens = agent.tokens > 0 ? fmtCount(agent.tokens) : ''
243
244      rows.push(
245        row({
246          kind: 'agent',
247          key: `agent:${agent.agentId}`,
248          glyph: agentGlyph,
249          color: agentColor,
250          phase,
251          parts: line(
252            [
253              { text: `${GLYPH.rail}  ${branch} `, color: RULE },
254              { text: `${agentGlyph} `, color: agentColor },
255              { text: oneLine(agent.label) || oneLine(agent.agentId) },
256            ],
257            [
258              { text: took, color: FADED },
259              { text: tokens.padStart(TOKEN_COLUMN, ' '), color: FADED },
260            ],
261            width,
262          ),
263          tokens,
264        }),
265      )
266    })
267  }
268
269  const summary = [
270    all.running > 0 ? `${all.running} running` : '',
271    `${all.done} done`,
272    all.failed > 0 ? `${all.failed} failed` : '',
273  ].filter(Boolean)
274
275  rows.push(
276    row({
277      kind: 'footer',
278      key: 'footer',
279      glyph: GLYPH.railEnd,
280      color: FADED,
281      parts: line([{ text: `${GLYPH.railEnd}─ `, color: RULE }, { text: summary.join(' · '), color: FADED }], [], width),
282      counts: { total: all.total, running: all.running, done: all.done, failed: all.failed },
283      tokens: fmtCount(all.tokens),
284    }),
285  )
286
287  return rows
288}
289
290export function currentPhase(state: RunState): string | undefined {
291  const groups = groupByPhase(state)
292  const busy = groups.filter(group => group.agents.some(agent => agent.status === 'running'))
293
294  return (busy.at(-1) ?? groups.at(-1))?.phase
295}
296
297/** Status line while the pane is closed: `wf 7/12 ▸ verify`. With `titles` (a project's block titles by id, in project order) it names the last block running: `wf 3/4 ▸ n3 review`. */
298export function statusText(state: RunState, titles?: Readonly<Record<string, string>>): string {
299  const all = countOf(Object.values(state.agents))
300  const head = `wf ${all.done + all.failed}/${all.total}`
301  if (titles !== undefined) {
302    const busy = Object.keys(titles).filter(id => (state.byNode[id]?.running ?? 0) > 0).at(-1)
303    return busy === undefined ? head : `${head} ▸ ${busy} ${oneLine(titles[busy] ?? '')}`.trimEnd()
304  }
305  const phase = currentPhase(state)
306
307  return `${head}${phase === undefined || phase === NO_PHASE ? '' : ` ▸ ${oneLine(phase)}`}`
308}
309
310/** The one toast at run end. */
311export function endText(state: RunState): string {
312  const all = countOf(Object.values(state.agents))
313  const failed = all.failed > 0 ? ` · ${all.failed} failed` : ''
314
315  return `wf ${oneLine(state.name ?? state.runId ?? '') || 'workflow'} ${state.status}: ${all.done}/${all.total} agents${failed} · ${fmtCount(all.tokens)} tokens`
316}
317
318/** `open` is what the person sees now; the phase goes to its opposite. */
319export function togglePhase(ui: RailUiState, phase: string, open: boolean): RailUiState {
320  const expanded = (ui.expanded ?? []).filter(one => one !== phase)
321  const collapsed = (ui.collapsed ?? []).filter(one => one !== phase)
322
323  return open ? { expanded, collapsed: [...collapsed, phase] } : { expanded: [...expanded, phase], collapsed }
324}
325
src/run/events.ts 51 lines
1export type RunEvent =
2  | { kind: 'run-start'; runId: string; name?: string; transcriptDir?: string; taskId?: string; at?: number }
3  | { kind: 'agent-start'; runId: string; agentId: string; label: string; phase?: string; index?: number; at?: number }
4  | { kind: 'agent-usage'; agentId: string; tokens: number }
5  | { kind: 'agent-end'; agentId: string; ok: boolean; durationMs?: number; tokens?: number }
6  | { kind: 'run-end'; runId: string; ok: boolean; stopped?: boolean; durationMs?: number }
7
8export type RunStatus = 'idle' | 'running' | 'done' | 'failed' | 'stopped'
9
10export type AgentStatus = 'running' | 'done' | 'failed'
11
12export type AgentRun = {
13  agentId: string
14  label: string
15  nodeId?: string
16  phase?: string
17  status: AgentStatus
18  tokens: number
19  durationMs?: number
20}
21
22export type NodeRun = { total: number; running: number; done: number; failed: number; tokens: number }
23
24export type RunState = {
25  runId?: string
26  name?: string
27  /** Kept in the state so that a load of the module midway through a run still reads its phases. */
28  transcriptDir?: string
29  /** The background task the engine runs it as: its end notice names it. Kept for the same reason. */
30  taskId?: string
31  status: RunStatus
32  /** How long the engine says the run took; set at its end. */
33  durationMs?: number
34  /** By agentId. */
35  agents: Record<string, AgentRun>
36  /** By project node id. */
37  byNode: Record<string, NodeRun>
38  /** By phase title, for a run with no project. */
39  byPhase: Record<string, NodeRun>
40  /** AgentIds whose label carries no known node id. */
41  unmapped: string[]
42}
43
44export function emptyNodeRun(): NodeRun {
45  return { total: 0, running: 0, done: 0, failed: 0, tokens: 0 }
46}
47
48export function emptyRunState(): RunState {
49  return { status: 'idle', agents: {}, byNode: {}, byPhase: {}, unmapped: [] }
50}
51
src/run/journal.ts 105 lines
1import type { RunEvent } from './events'
2
3export type AgentMeta = { description?: string; workflowPhase?: string }
4
5export type JournalOptions = {
6  /** The journal carries no run id of its own. */
7  runId?: string
8}
9
10function parseLine(line: string): Record<string, unknown> | undefined {
11  if (!line.trim()) return undefined
12  try {
13    const value: unknown = JSON.parse(line)
14    return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
15  } catch {
16    return undefined
17  }
18}
19
20function text(value: unknown): string | undefined {
21  return typeof value === 'string' && value.length > 0 ? value : undefined
22}
23
24/** `metas` (by agentId, from `agent-<id>.meta.json`) gives the label or phase a `started` line lacks. */
25export function journalToEvents(journalText: string, metas: Record<string, AgentMeta> = {}, options: JournalOptions = {}): RunEvent[] {
26  const runId = options.runId ?? ''
27  const events: RunEvent[] = []
28  let index = 0
29
30  if (typeof journalText !== 'string') return events
31
32  for (const line of journalText.split('\n')) {
33    const entry = parseLine(line)
34    if (!entry) continue
35
36    if (entry.type === 'launched') {
37      events.push({ kind: 'run-start', runId })
38      continue
39    }
40
41    const agentId = text(entry.agentId)
42    if (agentId === undefined) continue
43
44    if (entry.type === 'started') {
45      const meta = metas?.[agentId]
46      const start: RunEvent = {
47        kind: 'agent-start',
48        runId,
49        agentId,
50        label: text(entry.label) ?? text(meta?.description) ?? '',
51        index: index++,
52      }
53      const phase = text(entry.phase) ?? text(meta?.workflowPhase)
54      if (phase !== undefined) start.phase = phase
55      events.push(start)
56    } else if (entry.type === 'result') {
57      events.push({ kind: 'agent-end', agentId, ok: entry.result !== null && entry.result !== undefined })
58    }
59  }
60
61  return events
62}
63
64/** Null: the `started` line names no phase. Absent: the agent is not journalled yet. */
65export function journalPhases(journalText: string): Map<string, string | null> {
66  const phases = new Map<string, string | null>()
67  if (typeof journalText !== 'string') return phases
68
69  for (const line of journalText.split('\n')) {
70    // Read with every burst of spawns, so a line without the word (a result, with all it carries) is not parsed.
71    if (!line.includes('"started"')) continue
72    const entry = parseLine(line)
73    if (entry?.type !== 'started') continue
74
75    const agentId = text(entry.agentId)
76    if (agentId !== undefined) phases.set(agentId, text(entry.phase) ?? null)
77  }
78
79  return phases
80}
81
82/** By agentId, whether the agent's `result` line holds a value: the engine writes one per agent that completed, null where it gave nothing back. */
83export function journalResults(journalText: string): Map<string, boolean> {
84  const results = new Map<string, boolean>()
85  if (typeof journalText !== 'string') return results
86
87  for (const line of journalText.split('\n')) {
88    if (!line.includes('"result"')) continue
89    const entry = parseLine(line)
90    if (entry?.type !== 'result') continue
91
92    const agentId = text(entry.agentId)
93    if (agentId !== undefined) results.set(agentId, entry.result !== null && entry.result !== undefined)
94  }
95
96  return results
97}
98
99function count(value: unknown): number {
100  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
101}
102
103/** The usage counts that make up the token figure shown: prompt tokens mostly arrive as cache writes and reads. */
104export const USAGE_KEYS = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens'] as const
105
src/run/reducer.ts 174 lines
1import { emptyNodeRun, emptyRunState } from './events'
2import type { AgentRun, NodeRun, RunEvent, RunState } from './events'
3
4const NODE_ID = /^(n\d+)\b/
5
6export function parseNodeId(label: string): string | undefined {
7  if (typeof label !== 'string') return undefined
8  return NODE_ID.exec(label)?.[1]
9}
10
11/** `match` holds a block's agent labels and phase titles from the script, in project order; one ending in `*` is a prefix. */
12export type Mapping = { ids: ReadonlySet<string>; match?: Readonly<Record<string, readonly string[]>> }
13
14/** An exact string wins over any prefix, then the longest prefix, then the first block in project order. */
15export function matchNode(text: string, match: Mapping['match']): string | undefined {
16  let found: string | undefined
17  let length = -1
18  for (const [id, patterns] of Object.entries(match ?? {})) {
19    for (const pattern of patterns) {
20      if (pattern === text) return id
21      if (pattern.endsWith('*') && pattern.length - 1 > length && text.startsWith(pattern.slice(0, -1))) {
22        found = id
23        length = pattern.length - 1
24      }
25    }
26  }
27  return found
28}
29
30/** The id the label opens with (with a project, only one it has), else a match on the label, else on the phase title. */
31export function nodeOf(label: string, phase: string | undefined, mapping?: Mapping): string | undefined {
32  const parsed = parseNodeId(label)
33  if (parsed !== undefined && (!mapping || mapping.ids.has(parsed))) return parsed
34  if (!mapping?.match) return undefined
35  return (label === '' ? undefined : matchNode(label, mapping.match)) ?? (phase === undefined || phase === '' ? undefined : matchNode(phase, mapping.match))
36}
37
38type Buckets = Record<string, NodeRun>
39
40function patchBucket(buckets: Buckets, key: string | undefined, patch: (one: NodeRun) => NodeRun): Buckets {
41  if (key === undefined) return buckets
42  return { ...buckets, [key]: patch(buckets[key] ?? emptyNodeRun()) }
43}
44
45function patchAgentBuckets(state: RunState, agent: AgentRun, patch: (one: NodeRun) => NodeRun): Pick<RunState, 'byNode' | 'byPhase'> {
46  return {
47    byNode: patchBucket(state.byNode, agent.nodeId, patch),
48    byPhase: patchBucket(state.byPhase, agent.phase, patch),
49  }
50}
51
52function usageCount(value: unknown): number | undefined {
53  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
54}
55
56/** Returns the same state when the event changes nothing; the input is never mutated. */
57export function reduce(state: RunState, event: RunEvent, mapping?: Mapping): RunState {
58  switch (event.kind) {
59    case 'run-start': {
60      // A repeated start for the run already in progress must not drop its agents.
61      const dir = event.transcriptDir === undefined ? {} : { transcriptDir: event.transcriptDir }
62      const task = event.taskId === undefined ? {} : { taskId: event.taskId }
63      if (state.status === 'running' && state.runId === event.runId) {
64        const same = (event.name === undefined || event.name === state.name) && (event.transcriptDir === undefined || event.transcriptDir === state.transcriptDir) && (event.taskId === undefined || event.taskId === state.taskId)
65        return same ? state : { ...state, ...(event.name === undefined ? {} : { name: event.name }), ...dir, ...task }
66      }
67      return { ...emptyRunState(), runId: event.runId, name: event.name, ...dir, ...task, status: 'running' }
68    }
69
70    case 'agent-start': {
71      if (typeof event.agentId !== 'string') return state
72
73      const known = state.agents[event.agentId]
74      if (known) {
75        // A start seen again never counts twice, but it may carry the phase the first lacked.
76        const phase = typeof event.phase === 'string' && event.phase.length > 0 ? event.phase : undefined
77        if (known.phase !== undefined || phase === undefined) return state
78
79        // An agent no label placed may be placed by its phase title.
80        const nodeId = known.nodeId ?? nodeOf(known.label, phase, mapping)
81        const counted = (one: NodeRun): NodeRun => ({ ...one, total: one.total + 1, [known.status]: one[known.status] + 1, tokens: one.tokens + known.tokens })
82
83        return {
84          ...state,
85          agents: { ...state.agents, [known.agentId]: { ...known, nodeId, phase } },
86          byNode: nodeId === known.nodeId ? state.byNode : patchBucket(state.byNode, nodeId, counted),
87          byPhase: patchBucket(state.byPhase, phase, counted),
88          unmapped: nodeId === known.nodeId ? state.unmapped : state.unmapped.filter(id => id !== known.agentId),
89        }
90      }
91
92      const label = typeof event.label === 'string' ? event.label : ''
93      const nodeId = nodeOf(label, event.phase, mapping)
94      const agent: AgentRun = { agentId: event.agentId, label, nodeId, phase: event.phase, status: 'running', tokens: 0 }
95      // A spawn for a run held stopped (no notice says so, the stop is inferred) reopens it.
96      const sameRun = state.runId === undefined || state.runId === event.runId
97
98      return {
99        ...state,
100        // Agents can be seen before the run itself is announced.
101        runId: state.runId ?? event.runId,
102        status: state.status === 'idle' || sameRun ? 'running' : state.status,
103        agents: { ...state.agents, [agent.agentId]: agent },
104        ...patchAgentBuckets(state, agent, one => ({ ...one, total: one.total + 1, running: one.running + 1 })),
105        unmapped: nodeId === undefined ? [...state.unmapped, agent.agentId] : state.unmapped,
106      }
107    }
108
109    case 'agent-usage': {
110      const agent = state.agents[event.agentId]
111      const tokens = usageCount(event.tokens)
112      if (!agent || tokens === undefined || tokens === agent.tokens) return state
113
114      const delta = tokens - agent.tokens
115      return {
116        ...state,
117        agents: { ...state.agents, [agent.agentId]: { ...agent, tokens } },
118        ...patchAgentBuckets(state, agent, one => ({ ...one, tokens: one.tokens + delta })),
119      }
120    }
121
122    case 'agent-end': {
123      const agent = state.agents[event.agentId]
124      if (!agent || agent.status !== 'running') return state
125
126      const status = event.ok ? 'done' : 'failed'
127      const tokens = usageCount(event.tokens) ?? agent.tokens
128      const delta = tokens - agent.tokens
129      const ended: AgentRun = { ...agent, status, tokens }
130      if (typeof event.durationMs === 'number') ended.durationMs = event.durationMs
131
132      return {
133        ...state,
134        agents: { ...state.agents, [agent.agentId]: ended },
135        ...patchAgentBuckets(state, agent, one => ({
136          ...one,
137          running: one.running - 1,
138          [status]: one[status] + 1,
139          tokens: one.tokens + delta,
140        })),
141      }
142    }
143
144    case 'run-end': {
145      if (state.runId !== undefined && event.runId !== state.runId) return state
146      const status = event.stopped === true ? 'stopped' : event.ok ? 'done' : 'failed'
147      const durationMs = typeof event.durationMs === 'number' && Number.isFinite(event.durationMs) && event.durationMs >= 0 ? event.durationMs : state.durationMs
148      if (status === state.status && durationMs === state.durationMs) return state
149      return { ...state, status, ...(durationMs === undefined ? {} : { durationMs }) }
150    }
151
152    default:
153      return state
154  }
155}
156
157/** Places every agent again under `mapping`, for a project given to a run after it started; the phases stay. */
158export function remap(state: RunState, mapping?: Mapping): RunState {
159  const agents: RunState['agents'] = {}
160  let byNode: Buckets = {}
161  const unmapped: string[] = []
162  for (const agent of Object.values(state.agents)) {
163    const nodeId = nodeOf(agent.label, agent.phase, mapping)
164    agents[agent.agentId] = { ...agent, nodeId }
165    byNode = patchBucket(byNode, nodeId, one => ({ ...one, total: one.total + 1, [agent.status]: one[agent.status] + 1, tokens: one.tokens + agent.tokens }))
166    if (nodeId === undefined) unmapped.push(agent.agentId)
167  }
168  return { ...state, agents, byNode, unmapped }
169}
170
171export function reduceAll(events: readonly RunEvent[], mapping?: Mapping, from: RunState = emptyRunState()): RunState {
172  return events.reduce((state, event) => reduce(state, event, mapping), from)
173}
174
src/run/replay.ts 95 lines
1import { flatten, idNumber } from '../project/model'
2import type { Project } from '../project/model'
3
4import type { RunEvent } from './events'
5import { journalToEvents } from './journal'
6import type { AgentMeta } from './journal'
7
8// Dev aid: turns a recorded journal into a whole run's events, so the pane can be driven without a real workflow.
9
10export type ReplayFixture = { journal: string; metas: Record<string, AgentMeta> }
11
12/** A journal records no token counts, so a replay makes one up per agent, stable across runs. */
13export function replayUsage(agentId: string): number {
14  let hash = 0
15  for (const char of agentId) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 9973
16
17  return 1200 + hash
18}
19
20/** A recording that stops with agents open is an interrupted run: those agents end failed, and so does the run. */
21export function replayEvents(name: string, fixture: ReplayFixture): RunEvent[] {
22  const runId = `replay:${name}`
23  const events: RunEvent[] = [{ kind: 'run-start', runId, name }]
24  const open = new Set<string>()
25
26  for (const event of journalToEvents(fixture.journal, fixture.metas, { runId })) {
27    if (event.kind === 'run-start') continue
28    if (event.kind === 'agent-start') open.add(event.agentId)
29    if (event.kind === 'agent-end') {
30      open.delete(event.agentId)
31      events.push({ ...event, tokens: replayUsage(event.agentId) })
32      continue
33    }
34    events.push(event)
35  }
36
37  for (const agentId of open) events.push({ kind: 'agent-end', agentId, ok: false })
38  events.push({ kind: 'run-end', runId, ok: open.size === 0 })
39
40  return events
41}
42
43export function replayDue(total: number, step: number, steps: number): number {
44  if (steps <= 0 || step >= steps) return total
45
46  return Math.min(total, Math.max(0, Math.ceil((total * step) / steps)))
47}
48
49/** Labels of the agents a project replay adds that no node of the project claims. */
50export const REPLAY_STRAYS = ['fix lint', 'stray check'] as const
51
52function replayAgents(id: string): number {
53  return 2 + (idNumber(id) % 3)
54}
55
56/** Labels are `<nodeId>: <title> <k>`, as a handed-off script writes them; one agent fails and two carry labels no node claims. */
57export function replayProjectEvents(project: Project, name: string = project.name): RunEvent[] {
58  const runId = `replay:project:${name}`
59  const events: RunEvent[] = [{ kind: 'run-start', runId, name }]
60  const rows = flatten(project)
61  const leaves = rows.filter(row => (row.node.children?.length ?? 0) === 0 && row.node.type !== 'code' && row.node.type !== 'start')
62  const phaseOf = new Map<string, string>()
63  let phase = ''
64  for (const row of rows) {
65    if (row.depth === 0) phase = `${row.node.id} ${row.node.name}`
66    phaseOf.set(row.node.id, phase)
67  }
68  const strayId = `n${rows.reduce((max, row) => Math.max(max, idNumber(row.node.id)), 0) + 90}`
69  const strays = [REPLAY_STRAYS[0], `${strayId}: ${REPLAY_STRAYS[1]}`]
70  const failing = leaves[Math.min(1, leaves.length - 1)]?.node.id
71
72  const run = (agentId: string, label: string, phase: string | undefined, ok: boolean): RunEvent[][] => {
73    const tokens = replayUsage(agentId)
74
75    return [
76      [{ kind: 'agent-start', runId, agentId, label, phase }],
77      [{ kind: 'agent-usage', agentId, tokens: Math.round(tokens / 2) }],
78      [{ kind: 'agent-end', agentId, ok, tokens, durationMs: 1000 + (tokens % 4000) }],
79    ]
80  }
81
82  for (const [at, { node }] of leaves.entries()) {
83    const count = replayAgents(node.id)
84    const agents = Array.from({ length: count }, (_, k) => run(`replay-${node.id}-${k + 1}`, `${node.id}: ${node.name} ${k + 1}`, phaseOf.get(node.id), !(node.id === failing && k === 1)))
85    const stray = at === 0 ? strays[0] : at === leaves.length - 1 ? strays[1] : undefined
86    if (stray !== undefined) agents.push(run(`replay-stray-${at === 0 ? 1 : 2}`, stray, undefined, true))
87    // Starts together, then usage, then ends: a node's agents overlap as a fan-out does.
88    for (const stage of [0, 1, 2]) for (const agent of agents) events.push(...(agent[stage] ?? []))
89  }
90  if (leaves.length === 1) events.push(...run('replay-stray-2', strays[1] ?? '', undefined, true).flat())
91  events.push({ kind: 'run-end', runId, ok: true })
92
93  return events
94}
95
src/project/chat.ts 624 lines
1// The project as Claude edits it from chat: the tool's description and schema, generated from the teaching content
2// and the model; the edits; the tool's answers. Every edit goes through ops.ts and is checked by parseProject.
3
4import { OUTLINE_MAX, outline } from './handoff'
5import { OFFER_TOOL_FULL_NAME } from './offer'
6import { lint } from './lints'
7import { CONTAINER_TYPES, KINDS, NODE_TYPES, startOf, typeName, canHoldChildren, findNode, flatten, kindsOf, parseProject, readType, serializeProject } from './model'
8import type { Project, ProjectNode, Kind, Row } from './model'
9import { addNode, moveNode, nextIdNumber, removeNode, retypeNode, updateNode } from './ops'
10import type { NodePatch, Place } from './ops'
11import { FLASH_MS } from '../ui/graph-model'
12import { UNDO_HINT } from './history'
13import { PROJECT_DIR, carryUi, initialUi } from './session'
14import type { ProjectUi } from './session'
15import { KIND_TEACH, KNOB_HELP, TEACH, defaultName, fillOf, logicOf } from './teach'
16
17export const TOOL_NAME = 'project'
18export const TOOL_FULL_NAME = 'mcp__workflow-conductor__project'
19export const PROJECT_DIR_HINT = `${PROJECT_DIR}/<name>.json`
20
21export const ACTIONS = ['read', 'apply', 'write', 'attach'] as const
22export const OP_KINDS = ['add', 'update', 'move', 'remove'] as const
23
24const KNOB_NAMES = Object.keys(KNOB_HELP)
25
26function typeLines(): string[] {
27  return NODE_TYPES.map(type => `- ${type}${canHoldChildren(type) ? ' (container)' : ''}: ${TEACH[type].hint}. Use when ${TEACH[type].when}.`)
28}
29
30/** All but the plain kind, which a block does not name. */
31function namedKinds(type: keyof typeof KINDS): readonly Kind[] {
32  return kindsOf(type).slice(1)
33}
34
35function kindNames(): string {
36  return (Object.keys(KINDS) as (keyof typeof KINDS)[]).map(type => `${type}: ${namedKinds(type).join(', ')}`).join('. ')
37}
38
39function kindLine(): string {
40  return (Object.keys(KINDS) as (keyof typeof KINDS)[]).map(type => `${type}: ${namedKinds(type).map(kind => `${kind} = ${KIND_TEACH[kind].hint}`).join('; ')}`).join('. ')
41}
42
43function knobLine(): string {
44  return Object.entries(KNOB_HELP)
45    .map(([key, help]) => `${key} (${help})`)
46    .join('; ')
47}
48
49/** The tool is deferred: only its name is in every turn, and the model reads this once it loads the tool. */
50export function toolDescription(): string {
51  return [
52    `Reads and edits the user's Conductor workflow project: the board of blocks they see, saved at ${PROJECT_DIR_HINT}. Use it whenever the user asks to draft, add, change, move or remove workflow blocks or steps ("add a verify after n3", "this block"); do not edit that file by hand. A project is a spec the user reviews, not a script: never run it from here.`,
53    'Actions: read = the project JSON, the block selected on the board ("this block") and the lints. Read before you describe, review or edit a project, unless a Conductor note with the prompt has told you what changed: the user changes the board by hand between your turns, so what you read or wrote earlier may be gone (every answer names the revision it shows). apply = `ops` in order, all or nothing. write = replace the whole tree with `nodes` (drafting, large restructures). attach = a write, with `match`, of a project OF the workflow run tracked now: the run shows on it. An edit shows on the board at once and returns the lints: fix them or tell the user.',
54    "Block: { id, type, kind?, name, logic?, knobs?, match?, children? }. Ids (n1, n2…) are assigned for you and never change. name is a short label; logic is free text saying what the block should do. Order is execution order. Only containers hold children. Blocks directly inside a branch are its arms (one runs, picked by its `condition`), and directly inside a parallel they run side by side: to run two blocks one after the other there, put both in a pipeline. Every project starts with its start block: it is not added, moved or removed, and it alone has the knobs `goal` (the goal of the whole workflow, in a sentence: set it when drafting, keep it true) and `args` (the workflow's input, or unset for none). Update it by id; in a write, put it first, or leave it out to keep it as it is.",
55    'Types:',
56    ...typeLines(),
57    `Kinds: \`kind\` names the pattern a block is used for (each explained on \`kind\` in the schema); leave it out for the plain block. ${kindNames()}.`,
58    // A description is cut at 4096 characters, and the types take them: what belongs to one field (what each knob and kind means, how a write keeps ids) is said on that field in the schema.
59    `Knobs, all optional, explained on \`knobs\` in the schema: ${KNOB_NAMES.join(', ')}.`,
60  ].join('\n')
61}
62
63const BLOCK_FIELDS = {
64  type: { enum: [...NODE_TYPES] },
65  kind: { type: 'string', description: `Optional: the pattern, by type. ${kindLine()}. A new block of a kind gets the knobs its pattern needs where you set none. In an update, "" makes the block plain again.` },
66  name: { type: 'string' },
67  logic: { type: 'string' },
68  knobs: { type: 'object', description: `All optional: ${knobLine()}. The board and the lints say a key in words: oncePer is "once per", stopCondition "stop condition". In an update, "" clears a knob.` },
69  match: { type: 'array', items: { type: 'string' }, description: "Only on a project of a script that exists: the script's agent labels or phase titles this block stands for, each exact, or a prefix when it ends in *. In an update, [] clears it." },
70  children: { type: 'array', items: { type: 'object' }, description: 'Blocks inside a container, same shape.' },
71}
72
73export const TOOL_SCHEMA: Record<string, unknown> = {
74  type: 'object',
75  required: ['action'],
76  properties: {
77    action: { enum: [...ACTIONS] },
78    name: { type: 'string', description: 'Project name. Default: the one open on the board (attach: name it).' },
79    ops: {
80      type: 'array',
81      description:
82        'apply. add: a new block (type, kind?, name, logic?, knobs?, children?) placed by `after` or `before` (a sibling id) or `parent` (a container id: appended); none of them = end of the top level. update: `id` plus the fields to change (type and kind too). move: `id` plus after | before | parent. remove: `id`, with everything it holds.',
83      items: {
84        type: 'object',
85        required: ['op'],
86        properties: { op: { enum: [...OP_KINDS] }, id: { type: 'string' }, after: { type: 'string' }, before: { type: 'string' }, parent: { type: 'string' }, ...BLOCK_FIELDS },
87      },
88    },
89    nodes: {
90      type: 'array',
91      description: 'write/attach. The whole tree, top level first: keep the id of every block that stays, leave id off new ones.',
92      // The knobs are explained once, on the ops.
93      items: { type: 'object', required: ['type', 'name'], properties: { id: { type: 'string', description: 'Only for a block that already exists.' }, ...BLOCK_FIELDS, kind: { type: 'string', description: 'The same kinds as in `ops`.' }, knobs: { type: 'object', description: 'The same knobs as in `ops`.' }, match: { type: 'array', items: { type: 'string' }, description: 'As in `ops`.' } } },
94    },
95  },
96}
97
98const DRAFT_RULES = [
99  'The project is a spec the user reviews on the board. Do not write a workflow script and do not run anything.',
100  'Pick the structure the task needs and no more: a few blocks with clear logic beat many. Give a block its kind where one fits, and use knobs where they say something (oncePer, stopCondition, output).',
101]
102
103const DRAFT_AFTER = 'Afterwards stop: the user takes it from there. Say in two or three lines what the blocks do, name any lint you chose to leave, and tell the user to review and edit the project on the board and to approve it there with r (Implement and Run).'
104
105/** For `/conductor <description>`. It names the tool in full: the tool is deferred, and the name is what Claude loads it by. */
106export function draftBrief(description: string): string[] {
107  return [
108    [
109      `Conductor draft: the user wants a new workflow project for: ${description}`,
110      '',
111      'Where what they want is too unclear to pick a structure for, ask them first, briefly, and wait for the answer. Otherwise do not ask.',
112      `Make it with the ${TOOL_FULL_NAME} tool (it is deferred: load it by that name): one call with action "write", a short kebab-case name of your choosing that no project has, and the nodes, the start block first with the goal in a sentence in its \`goal\` knob. It is always a new project, unless the user's own words name an existing one to change.`,
113      ...DRAFT_RULES,
114      DRAFT_AFTER,
115    ].join('\n'),
116  ]
117}
118
119/** `asked` is false when an "Always Conductor" kept earlier answered. */
120export function offerBrief(asked: boolean): string {
121  return [
122    `${asked ? 'The user chose Conductor' : 'The user chose Always Conductor earlier, so they were not asked'}: they want this workflow as a project on the Conductor board, to review and edit before anything is written or run.`,
123    '',
124    `Make it with the ${TOOL_FULL_NAME} tool (it is deferred: load it by that name): one call with action "write", a short kebab-case name of your choosing and the nodes, the start block first with the goal of the workflow in its \`goal\` knob.`,
125    ...DRAFT_RULES,
126    DRAFT_AFTER,
127    `If they then tell you in chat to run it, call ${OFFER_TOOL_FULL_NAME} again before anything else: its answer says how this project is run.`,
128  ].join('\n')
129}
130
131/** Added to the Workflow tool's result. Under `name` the next run of the same script finds the project, and is shown on it with no question. */
132export function visualizeBrief(scriptPath: string, asked: boolean, name: string | undefined): string {
133  return [
134    `Conductor: ${asked ? 'the user chose to see' : 'the user chose Always visualize earlier, so they see'} this workflow run as a project on the Conductor board. The run goes on meanwhile: do not change, re-run or stop the script.`,
135    '',
136    `Draw the project OF the script that is running with the ${TOOL_FULL_NAME} tool (it is deferred: load it by that name): one call with action "attach", ${name === undefined ? 'a short kebab-case name' : `name "${name}" (the workflow's own name as a project's: the next run of this script is then shown on this project without a question)`} and the nodes, the start block first with the workflow's goal in its \`goal\` knob.`,
137    "- The nodes mirror the script's real structure: its pipelines, parallels, loops, branches and agents, no more. A phase is no block: its blocks stand at the top level in its place.",
138    '- `match` ties the run to the blocks. It goes on the INNERMOST block an agent belongs to, and a label on one block only (never also on the container around it): the exact `label` of each agent() the block stands for, or the fixed start of the label and a * ("review *") where the script builds it at runtime.',
139    "- Where the script groups its agents by `phase`, the first top-level block of each phase gets that phase's exact title in `match`.",
140    `The script, if you do not hold its text: ${scriptPath}`,
141    'The answer lists the agent labels no block took: fix `match` and call attach again under the same name. Afterwards tell the user in one or two lines what the board shows.',
142  ].join('\n')
143}
144
145/** A label two blocks both list in `match`: the first in project order takes its agents, and the other shows none. */
146function sharedMatch(project: Project): string[] {
147  const first = new Map<string, string>()
148  const notes: string[] = []
149  for (const { node } of flatten(project)) {
150    for (const one of node.match ?? []) {
151      const held = first.get(one)
152      if (held === undefined) first.set(one, node.id)
153      else if (held !== node.id) notes.push(`Note: ${JSON.stringify(one)} is in the \`match\` of ${held} and ${node.id}: ${held} comes first and takes those agents. Keep it on the innermost block only.`)
154    }
155  }
156  return notes
157}
158
159/** The agents of the run no block took, each label once. Later phases may still place some. */
160export function attachNote(project: Project, unplaced: readonly string[]): string {
161  const labels = [...new Set(unplaced)]
162  const placed =
163    labels.length === 0
164      ? 'The run is shown on it: every agent so far is on a block.'
165      : `The run is shown on it. Agent labels not placed yet (an agent whose phase has not been read yet may still land): ${labels.map(label => JSON.stringify(label)).join(', ')}. For any that no \`match\` covers, fix \`match\` and call attach again.`
166  return [placed, ...sharedMatch(project)].join('\n')
167}
168
169/** Added to an edit's answer when the board could not be put on screen (an open nobody typed waits under 144 columns). */
170export function unplacedNote(name: string): string {
171  return `The board is not on screen (the terminal is too narrow for it to open by itself; the band above the prompt names the project): tell the user that /conductor opens it (project ${name}).`
172}
173
174// Edits
175
176/** `notes` are things the edit did that the caller may not have meant, said in the answer. */
177export type Change = { added: string[]; changed: string[]; removed: string[]; notes?: string[] }
178
179export type Edit = ({ ok: true; project: Project } & Change) | { ok: false; error: string }
180
181function isRecord(value: unknown): value is Record<string, unknown> {
182  return typeof value === 'object' && value !== null && !Array.isArray(value)
183}
184
185const TYPES_HINT = `types: ${NODE_TYPES.join(', ')}`
186const CONTAINERS_HINT = `containers: ${CONTAINER_TYPES.join(', ')}`
187const KNOBS_HINT = `knobs: ${KNOB_NAMES.join(', ')}`
188
189// A refusal Claude can act on: the reason, plus the list it should have picked from.
190function explain(error: string): string {
191  if (error.includes('unknown type')) return `${error}; ${TYPES_HINT}`
192  if (error.includes('cannot hold children')) return `${error}; ${CONTAINERS_HINT}`
193  if (error.includes('unknown knob')) return `${error}; ${KNOBS_HINT}`
194  return error
195}
196
197function knobsError(raw: unknown, where: string): string | undefined {
198  if (raw === undefined || raw === null) return undefined
199  if (!isRecord(raw)) return `${where}: knobs must be an object`
200  const stray = Object.keys(raw).find(key => !KNOB_NAMES.includes(key))
201  return stray === undefined ? undefined : `${where}: unknown knob ${stray}`
202}
203
204function signature(project: Project): Map<string, string> {
205  const out = new Map<string, string>()
206  for (const row of flatten(project)) {
207    const { id, type, kind, name, logic, knobs, match } = row.node
208    out.set(id, JSON.stringify([row.parentId ?? null, type, kind ?? '', name, logic ?? '', Object.entries(knobs ?? {}).sort(([a], [b]) => a.localeCompare(b)), match ?? []]))
209  }
210  return out
211}
212
213/** Order among siblings is not compared. */
214export function diffProjects(before: Project | null, after: Project): Change {
215  const old = before ? signature(before) : new Map<string, string>()
216  const now = signature(after)
217  const change: Change = { added: [], changed: [], removed: [] }
218  for (const [id, mark] of now) {
219    if (!old.has(id)) change.added.push(id)
220    else if (old.get(id) !== mark) change.changed.push(id)
221  }
222  for (const id of old.keys()) if (!now.has(id)) change.removed.push(id)
223  return change
224}
225
226/** A `condition` on a block that is no arm of a branch picks nothing. */
227export function strayConditions(project: Project, ids: readonly string[]): string[] {
228  return flatten(project)
229    .filter(row => ids.includes(row.node.id) && (row.node.knobs?.condition ?? '').trim() !== '' && (row.parentId === undefined ? undefined : findNode(project, row.parentId)?.type) !== 'branch')
230    .map(row => `${row.node.id} has a \`condition\` but does not stand directly in a branch, so nothing reads it: move the block into the branch as an arm, or clear the knob (condition: "").`)
231}
232
233// The last gate: whatever was assembled is read back by the model's own parser.
234function checked(before: Project, candidate: Project, touched: Iterable<string> = [], notes: string[] = []): Edit {
235  const parsed = parseProject(serializeProject(candidate))
236  if (!parsed.ok) return { ok: false, error: explain(parsed.error) }
237  const change = diffProjects(before, parsed.project)
238  for (const id of touched) {
239    if (!change.added.includes(id) && !change.changed.includes(id) && findNode(parsed.project, id)) change.changed.push(id)
240  }
241  const said = [...notes, ...strayConditions(parsed.project, [...change.added, ...change.changed])]
242  return { ok: true, project: parsed.project, ...change, ...(said.length > 0 ? { notes: said } : {}) }
243}
244
245/** An `add` beside a sibling in a branch or a parallel makes another arm, not a step after it. Null elsewhere. */
246export function armNote(project: Project, id: string, beside: string): string | null {
247  const row = flatten(project).find(one => one.node.id === id)
248  const parent = row?.parentId === undefined ? undefined : findNode(project, row.parentId)
249  if (!parent) return null
250  if (parent.type === 'branch') return `${id} was added as a new arm of branch ${parent.id} (a branch runs one of its arms; every arm but the last needs a \`condition\`), not as a step after ${beside}. To run it after ${beside} in the same arm, wrap the two in a pipeline: add a pipeline in ${parent.id}, then move ${beside} and ${id} into it.`
251  if (parent.type === 'parallel') return `${id} was added beside ${beside} in ${parent.type} ${parent.id}: the two run at the same time, not one after the other. To run it after ${beside}, wrap the two in a pipeline inside ${parent.id}.`
252  return null
253}
254
255/** A tree written in another order: `diffProjects` does not compare order. */
256function reordered(before: Project, after: Project): string[] {
257  const order = (project: Project): Map<string, string[]> => {
258    const out = new Map<string, string[]>()
259    for (const row of flatten(project)) out.set(row.parentId ?? '', [...(out.get(row.parentId ?? '') ?? []), row.node.id])
260    return out
261  }
262  const old = order(before)
263  const moved: string[] = []
264  for (const [parent, ids] of order(after)) {
265    const was = (old.get(parent) ?? []).filter(id => ids.includes(id))
266    ids.filter(id => was.includes(id)).forEach((id, index) => {
267      if (was[index] !== id) moved.push(id)
268    })
269  }
270  return moved
271}
272
273/** A block whose id is in the project as it stood keeps it; every other gets a fresh id, whatever it came with, so ids never collide. */
274export function replaceProject(before: Project, rawNodes: unknown): Edit {
275  if (!Array.isArray(rawNodes)) return { ok: false, error: 'write needs `nodes`: the array of top-level blocks' }
276  const known = new Set(flatten(before).map(row => row.node.id))
277  const kept = new Set<string>()
278  let next = nextIdNumber(before)
279  let problem: string | undefined
280
281  const build = (raw: unknown, where: string): Record<string, unknown> => {
282    if (!isRecord(raw)) {
283      problem ??= `${where} must be an object`
284      return {}
285    }
286    let id: string
287    let stays = false
288    if (typeof raw.id === 'string' && known.has(raw.id)) {
289      if (kept.has(raw.id)) problem ??= `duplicate id ${raw.id}: a block that stays appears once; leave id off new blocks`
290      kept.add(raw.id)
291      id = raw.id
292      stays = true
293    } else id = `n${next++}`
294    const read = readType(raw.type, raw.kind)
295    if ('error' in read) problem ??= `${id}: ${read.error}`
296    const block = 'error' in read ? undefined : read
297    problem ??= knobsError(raw.knobs, id)
298    const node: Record<string, unknown> = { id, type: block?.type ?? raw.type, name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name : block ? defaultName(block) : '' }
299    if (block?.kind !== undefined) node.kind = block.kind
300    // A new block of a kind gets what the kind fills in, under the logic and the knobs it came with; a block that stays is taken as written.
301    const filled = stays || !block ? undefined : logicOf(block)
302    if (typeof raw.logic === 'string' && raw.logic.trim() !== '') node.logic = raw.logic
303    else if (raw.logic !== undefined && raw.logic !== null && typeof raw.logic !== 'string') problem ??= `${id}: logic must be a string`
304    else if (filled !== undefined) node.logic = filled
305    const given = isRecord(raw.knobs) ? Object.fromEntries(Object.entries(raw.knobs).filter(([, value]) => value !== '' && value !== null)) : {}
306    const knobs = stays || !block ? given : { ...fillOf(block), ...given }
307    if (isRecord(raw.knobs) || Object.keys(knobs).length > 0) node.knobs = knobs
308    // Read back by parseProject below, which refuses a list that is not one of strings.
309    if (raw.match !== undefined && raw.match !== null) node.match = raw.match
310    const children = raw.children
311    if (children !== undefined && children !== null && !Array.isArray(children)) problem ??= `${id}: children must be an array`
312    const list = Array.isArray(children) ? children : []
313    if (list.length > 0 || (block !== undefined && canHoldChildren(block.type))) node.children = list.map((child, index) => build(child, `${id}.children[${index}]`))
314    return node
315  }
316
317  const nodes = rawNodes.map((raw, index) => build(raw, `nodes[${index}]`))
318  if (problem !== undefined) return { ok: false, error: explain(problem) }
319  // A tree written without the start block keeps the one the project has, as it is.
320  const start = startOf(before)
321  if (start && !nodes.some(node => node.type === 'start')) nodes.unshift({ ...start })
322  const candidate = { version: 1, name: before.name, nextId: next, nodes } as unknown as Project
323  // Not serializeProject: it would trust shapes that have not been checked yet.
324  const parsed = parseProject(JSON.stringify(candidate))
325  if (!parsed.ok) return { ok: false, error: explain(parsed.error) }
326  return checked(before, parsed.project, reordered(before, parsed.project))
327}
328
329function placeOf(project: Project, op: Record<string, unknown>): Place | string {
330  // A null is a place not said, as one left out is.
331  const beside = op.after ?? op.before
332  if (beside !== undefined && beside !== null) {
333    if (typeof beside !== 'string') return 'after/before must be a block id'
334    const row = flatten(project).find(one => one.node.id === beside)
335    if (!row) return `no block ${beside}`
336    const index = op.after !== undefined && op.after !== null ? row.index + 1 : row.index
337    return row.parentId === undefined ? { index } : { parentId: row.parentId, index }
338  }
339  if (op.parent !== undefined && op.parent !== null) {
340    if (typeof op.parent !== 'string') return 'parent must be a block id'
341    const parent = findNode(project, op.parent)
342    if (!parent) return `no block ${op.parent}`
343    return { parentId: parent.id, index: parent.children?.length ?? 0 }
344  }
345  return { index: project.nodes.length }
346}
347
348function patchOf(raw: Record<string, unknown>): NodePatch {
349  const patch: Record<string, unknown> = {}
350  if (raw.name !== undefined && raw.name !== null) patch.name = raw.name
351  if (raw.logic !== undefined && raw.logic !== null) patch.logic = raw.logic
352  if (raw.knobs !== undefined && raw.knobs !== null) patch.knobs = raw.knobs
353  if (raw.match !== undefined && raw.match !== null) patch.match = raw.match
354  return patch as NodePatch
355}
356
357type Grown = { project: Project; ids: string[] } | { error: string }
358
359function grow(project: Project, raw: unknown, at: Place): Grown {
360  if (!isRecord(raw)) return { error: 'a block must be an object' }
361  const block = readType(raw.type, raw.kind)
362  if ('error' in block) return { error: block.error }
363  // The blocks a kind starts with are left out: an edit from chat brings its own.
364  const added = addNode(project, { ...block, name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name : undefined, bare: true }, at)
365  if (added.error || added.id === undefined) return { error: added.error ?? 'could not add' }
366  const id = added.id
367  const knobs = knobsError(raw.knobs, id)
368  if (knobs) return { error: knobs }
369  const patched = updateNode(added.project, id, patchOf({ logic: raw.logic, knobs: raw.knobs, match: raw.match }))
370  if (patched.error) return { error: `${id}: ${patched.error}` }
371  let current = patched.project
372  const ids = [id]
373  const children = raw.children
374  if (children !== undefined && children !== null && !Array.isArray(children)) return { error: `${id}: children must be an array` }
375  for (const [index, child] of (Array.isArray(children) ? children : []).entries()) {
376    const inner = grow(current, child, { parentId: id, index })
377    if ('error' in inner) return inner
378    current = inner.project
379    ids.push(...inner.ids)
380  }
381  return { project: current, ids }
382}
383
384/** One refused op refuses them all. */
385export function applyOps(before: Project, ops: unknown): Edit {
386  if (!Array.isArray(ops)) return { ok: false, error: 'apply needs `ops`: an array of { op: add | update | move | remove, … }' }
387  let project: Project = before
388  const touched: string[] = []
389  const notes: string[] = []
390
391  for (const [index, op] of ops.entries()) {
392    const kind = isRecord(op) ? op.op : undefined
393    const refuse = (error: string): Edit => ({ ok: false, error: `ops[${index}]${typeof kind === 'string' ? ` (${kind})` : ''}: ${explain(error)}. Nothing was changed.` })
394    if (!isRecord(op)) return refuse('an op must be an object')
395    if (kind === 'add') {
396      const at = placeOf(project, op)
397      if (typeof at === 'string') return refuse(at)
398      const grown = grow(project, op, at)
399      if ('error' in grown) return refuse(grown.error)
400      project = grown.project
401      const beside = op.after ?? op.before
402      const note = typeof beside === 'string' && grown.ids[0] !== undefined ? armNote(project, grown.ids[0], beside) : null
403      if (note !== null) notes.push(note)
404      continue
405    }
406    const id = op.id
407    if (kind !== 'update' && kind !== 'move' && kind !== 'remove') return refuse(`op must be one of ${OP_KINDS.join(', ')}`)
408    if (typeof id !== 'string') return refuse('needs the `id` of a block')
409    if (!findNode(project, id)) return refuse(`no block ${id}`)
410    if (kind === 'remove') {
411      const removed = removeNode(project, id)
412      if (removed.error) return refuse(removed.error)
413      project = removed.project
414      continue
415    }
416    if (kind === 'move') {
417      // The place is read with the block lifted out, as moveNode counts it.
418      if ((op.after ?? op.before ?? op.parent ?? null) === null) return refuse('needs after, before or parent')
419      const at = placeOf(removeNode(project, id).project, op)
420      if (typeof at === 'string') return refuse(at.startsWith('no block') ? `${at} outside ${id}` : at)
421      const moved = moveNode(project, id, at)
422      if (moved.error) return refuse(moved.error)
423      project = moved.project
424      touched.push(id)
425      continue
426    }
427    if (op.type !== undefined || op.kind !== undefined) {
428      const now = findNode(project, id)
429      const asked = readType(op.type ?? now?.type, op.kind)
430      if ('error' in asked) return refuse(asked.error)
431      // A type said again with no kind leaves the kind the block has; `kind: ""` makes it plain.
432      const kept = op.kind === undefined && asked.kind === undefined && asked.type === now?.type ? now.kind : asked.kind
433      const retyped = retypeNode(project, id, asked.type, kept)
434      if (retyped.error) return refuse(retyped.error)
435      project = retyped.project
436    }
437    const knobs = knobsError(op.knobs, id)
438    if (knobs) return refuse(knobs)
439    const updated = updateNode(project, id, patchOf(op))
440    if (updated.error) return refuse(updated.error)
441    project = updated.project
442  }
443  return checked(before, project, touched, notes)
444}
445
446// What the tool answers with
447
448function lintLines(project: Project): string[] {
449  const lints = lint(project)
450  if (lints.length === 0) return ['Lints: none.']
451  return ['Lints (advisory: fix them, or tell the user why they stand):', ...lints.map(one => `- ${one.nodeId} [${one.rule}]: ${one.message}`)]
452}
453
454/** `revision` counts every change to the project this session, by hand or from chat: an answer that names an older one is stale. */
455export type ReadState = { open: boolean; selectedId: string | null; locked: boolean; revision?: number }
456
457function revisionWords(revision: number | undefined): string {
458  return revision === undefined ? '' : ` Revision ${revision}: the user edits the board by hand, so in a later turn read again before relying on this, unless a Conductor note with that prompt says what changed.`
459}
460
461export function readReport(project: Project, state: ReadState): string {
462  const selected = state.selectedId === null ? undefined : findNode(project, state.selectedId)
463  const where = state.open ? `open on the board; selected block ("this block"): ${selected ? `${selected.id} (${typeName(selected)} "${selected.name}")` : 'none'}` : 'not open on the board'
464  return [
465    `Project "${project.name}" as it is on the board now (${where})${state.locked ? '. A run of it is in progress: editing is locked until it ends' : ''}.${revisionWords(state.revision)}`,
466    serializeProject(project).trimEnd(),
467    ...lintLines(project),
468  ].join('\n')
469}
470
471export function editReport(project: Project, change: Change, revision?: number): string {
472  const parts = [
473    change.added.length > 0 ? `added ${change.added.join(', ')}` : '',
474    change.changed.length > 0 ? `changed ${change.changed.join(', ')}` : '',
475    change.removed.length > 0 ? `removed ${change.removed.join(', ')}` : '',
476  ].filter(Boolean)
477  return [
478    `Project "${project.name}" saved and shown on the board: ${parts.length > 0 ? parts.join('; ') : 'nothing differs from before'}.${revisionWords(revision)}`,
479    ...(change.notes ?? []).map(note => `Note: ${note}`),
480    'Now:',
481    ...outline(project),
482    ...lintLines(project),
483  ].join('\n')
484}
485
486/**
487 * Whether Claude holds `stored` whole after the edit it made with `input`: every text the answer's outline does not say is one
488 * Claude sent or one the block had in the project it last saw (`seen`). False for a text from elsewhere, such as what a kind fills in.
489 */
490export function knownAfterEdit(stored: Project, seen: Project | undefined, input: unknown): boolean {
491  const rows = flatten(stored)
492  if (rows.length > OUTLINE_MAX) return false
493  const sent = new Set<string>()
494  const collect = (value: unknown): void => {
495    if (typeof value === 'string') sent.add(value)
496    else if (Array.isArray(value)) value.forEach(collect)
497    else if (isRecord(value)) Object.values(value).forEach(collect)
498  }
499  collect(input)
500  const texts = (node: ProjectNode | undefined): string[] => (node ? [`logic=${node.logic ?? ''}`, ...Object.entries(node.knobs ?? {}).map(([key, value]) => `${key}=${String(value)}`), ...(node.match ?? []).map(one => `match=${one}`)] : [])
501  const before = new Map((seen ? flatten(seen) : []).map(row => [row.node.id, new Set(texts(row.node))]))
502  const known = (id: string, text: string): boolean => text === 'logic=' || sent.has(text.slice(text.indexOf('=') + 1)) || before.get(id)?.has(text) === true
503  return rows.every(({ node }) => texts(node).every(text => known(node.id, text)))
504}
505
506export function projectOfPath(root: string, cwd: string, path: string): string | null {
507  if (path === '') return null
508  const absolute = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
509  const parts: string[] = []
510  for (const part of absolute.split('/')) {
511    if (part === '' || part === '.') continue
512    if (part === '..') parts.pop()
513    else parts.push(part)
514  }
515  const prefix = `${root.replace(/\/+$/, '')}/${PROJECT_DIR}/`
516  const clean = `/${parts.join('/')}`
517  if (!clean.startsWith(prefix)) return null
518  const file = clean.slice(prefix.length)
519  return /^[a-z0-9-]+\.json$/.test(file) ? file.slice(0, -'.json'.length) : null
520}
521
522/** For the message row and the undo history: `added n12; changed n3, n4`. */
523export function changeWords(change: Change): string {
524  const list = (ids: string[]): string => (ids.length > 4 ? `${ids.slice(0, 4).join(', ')} and ${ids.length - 4} more` : ids.join(', '))
525  return [change.added.length > 0 ? `added ${list(change.added)}` : '', change.changed.length > 0 ? `changed ${list(change.changed)}` : '', change.removed.length > 0 ? `removed ${list(change.removed)}` : ''].filter(Boolean).join('; ')
526}
527
528/** Sent, unseen, with a prompt when the board changed by hand and the change itself cannot be said (`changedNote`). */
529export function staleNote(name: string, revision: number): string {
530  return `Conductor: the user changed the "${name}" workflow project on the board by hand since you last read or edited it (it is now revision ${revision}). This note cannot say what changed, so what you know of it is out of date: call ${TOOL_FULL_NAME} with action "read" before you describe, review or edit it.`
531}
532
533function blockOf(node: ProjectNode, deep: boolean): Record<string, unknown> {
534  const { id, type, kind, name, logic, knobs, match, children } = node
535  const own = { id, type, kind, name, logic, knobs: knobs && Object.keys(knobs).length > 0 ? knobs : undefined, match }
536  return deep && children ? { ...own, children: children.map(child => blockOf(child, true)) } : own
537}
538
539const TOP = ''
540
541function childIds(rows: readonly Row[]): Map<string, string[]> {
542  const out = new Map<string, string[]>()
543  for (const row of rows) out.set(row.parentId ?? TOP, [...(out.get(row.parentId ?? TOP) ?? []), row.node.id])
544  return out
545}
546
547const whereWords = (parentId: string): string => (parentId === TOP ? 'at the top level' : `inside ${parentId}`)
548
549/** Everything that differs between the project as Claude last saw it and the board, so that the one and the lines give the other exactly. */
550export function boardChanges(seen: Project, now: Project): string[] {
551  const old = new Map(flatten(seen).map(row => [row.node.id, row]))
552  const rows = flatten(now)
553  const kids = childIds(rows)
554  // A block is placed by the one before it: with the blocks that stayed keeping their order, that says the whole order.
555  const place = (row: Row): string => `${whereWords(row.parentId ?? TOP)}, ${row.index === 0 ? 'first' : `after ${kids.get(row.parentId ?? TOP)?.[row.index - 1]}`}`
556  const added: string[] = []
557  const changed: string[] = []
558  const moved: string[] = []
559  // A new block is said whole, so nothing it holds is said again.
560  const within = new Set<string>()
561  for (const row of rows) {
562    const { id } = row.node
563    const was = old.get(id)
564    if (row.parentId !== undefined && within.has(row.parentId)) within.add(id)
565    else if (!was) {
566      within.add(id)
567      added.push(`- ${place(row)}: ${JSON.stringify(blockOf(row.node, true))}`)
568    } else {
569      if (JSON.stringify(blockOf(was.node, false)) !== JSON.stringify(blockOf(row.node, false))) changed.push(`- ${JSON.stringify(blockOf(row.node, false))}`)
570      if (was.parentId !== row.parentId) moved.push(`- ${id}: ${place(row)}`)
571    }
572  }
573  // Blocks that stayed in their container but not in their order: the container's whole order is said.
574  const before = childIds([...old.values()])
575  const order: string[] = []
576  for (const [parentId, ids] of kids) {
577    if (within.has(parentId)) continue
578    const stayed = ids.filter(id => (old.get(id)?.parentId ?? TOP) === parentId && old.has(id))
579    const asWas = (before.get(parentId) ?? []).filter(id => stayed.includes(id))
580    if (stayed.join() !== asWas.join()) order.push(`Order ${whereWords(parentId)} now: ${ids.join(', ')}.`)
581  }
582  const removed = [...old.keys()].filter(id => !findNode(now, id))
583  return [
584    ...(added.length > 0 ? ['Added, each whole (a block you knew that is written inside one now stands there):', ...added] : []),
585    ...(changed.length > 0 ? ['Changed, each as it is now (what it holds is as before, but for what these lines say):', ...changed] : []),
586    ...(moved.length > 0 ? ['Moved, each with everything it holds:', ...moved] : []),
587    ...order,
588    ...(removed.length > 0 ? [`Removed: ${removed.join(', ')}.`] : []),
589  ]
590}
591
592/** In place of `staleNote` when the project as Claude last saw it is at hand. Null when saying the change takes more than the project does: a read is cheaper. */
593export function changedNote(name: string, revision: number, seen: Project, now: Project, selectedId: string | null): string | null {
594  const lines = boardChanges(seen, now)
595  if (lines.join('\n').length > serializeProject(now).length) return null
596  const selected = selectedId === null ? undefined : findNode(now, selectedId)
597  return [
598    `Conductor: the user changed the "${name}" workflow project on the board by hand since you last read or edited it (it is now revision ${revision}). ${lines.length > 0 ? 'What follows is everything that changed' : 'Nothing in it differs from what you last saw'}: with it you know the board as it is, so this is the note that stands in for a read: none is needed. An edit with ${TOOL_FULL_NAME} from here is accepted.`,
599    ...lines,
600    `Selected block ("this block"): ${selected ? `${selected.id} (${typeName(selected)} "${selected.name}")` : 'none'}.`,
601  ].join('\n')
602}
603
604/** When the hooks module takes the mark's ids back: once the graph has shown it. */
605export const FLASH_CLEAR_MS = FLASH_MS + 400
606
607/** The selection goes to the first new block so the graph scrolls to it. `seq` only ever grows, so the graph can tell one edit from the next. */
608export function showEdit(ui: ProjectUi, name: string, project: Project, change: Change): ProjectUi {
609  const same = ui.name === name && ui.status === 'ready'
610  // A draft being laid out stays so under a later edit of Claude's turn (a lint fixed): the turn's end is what ends it.
611  // An edit never lands under the results of a run that is over: they are put away.
612  const base: ProjectUi = same ? { ...ui, picker: null, confirmId: null, editing: false, freshId: null, drafting: ui.drafting === 'show' ? 'show' : null, notice: null, runCleared: true, unsaved: false } : { ...carryUi(ui, initialUi()), name, status: 'ready' }
613  const still = same && ui.selectedId !== null && findNode(project, ui.selectedId) ? ui.selectedId : null
614  const selectedId = change.added[0] ?? still ?? change.changed[0] ?? project.nodes[0]?.id ?? null
615  // An edit from chat is undone like any other.
616  const said = changeWords(change)
617  return { ...base, selectedId, notice: said !== '' ? `Claude ${said} · ${UNDO_HINT}` : base.notice, flash: { ids: [...change.added, ...change.changed], seq: (ui.flash?.seq ?? 0) + 1 } }
618}
619
620/** The mark is spent: the ids go, the count stays. */
621export function clearFlash(ui: ProjectUi, seq: number): ProjectUi {
622  return ui.flash && ui.flash.seq === seq && ui.flash.ids.length > 0 ? { ...ui, flash: { ids: [], seq } } : ui
623}
624
src/project/history.ts 36 lines
1// Undo and redo: the projects each change left behind, newest last, with what the change was in words.
2// One history per project, held for the session by the hooks module.
3
4import type { Project } from './model'
5
6/** A project as it stood, and the change that led away from it (`deleted n7 "verify"`). */
7export type HistoryEntry = { project: Project; what: string }
8
9export type History = { undo: HistoryEntry[]; redo: HistoryEntry[] }
10
11export const HISTORY_MAX = 50
12
13/** What every message announcing a change ends with. */
14export const UNDO_HINT = '[u] Undo'
15export const REDO_HINT = '[y] Redo'
16
17export function emptyHistory(): History {
18  return { undo: [], redo: [] }
19}
20
21export function remember(history: History | undefined, before: Project, what: string): History {
22  const undo = [...(history?.undo ?? []), { project: before, what }]
23  return { undo: undo.slice(Math.max(0, undo.length - HISTORY_MAX)), redo: [] }
24}
25
26export function travel(history: History | undefined, current: Project, way: 'undo' | 'redo'): { history: History; entry: HistoryEntry } | null {
27  const from = way === 'undo' ? (history?.undo ?? []) : (history?.redo ?? [])
28  const entry = from.at(-1)
29  if (!history || !entry) return null
30  const left = from.slice(0, -1)
31  const kept: HistoryEntry = { project: current, what: entry.what }
32  return way === 'undo'
33    ? { history: { undo: left, redo: [...history.redo, kept].slice(-HISTORY_MAX) }, entry }
34    : { history: { undo: [...history.undo, kept].slice(-HISTORY_MAX), redo: left }, entry }
35}
36