Visual workflow planner in Claude Code


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
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.
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 ──────────────────────────────────────────────────────────────╯
.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. ! on its card. You can run it anyway. It's your money.What Claude spends getting a workflow to run, before the run's own agents. Estimates, in tokens.
| Flow | Design | Hand-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 |
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:
| Block | Kinds |
|---|---|
| agent | task · scout · check · verify · judge · critic · merge · classify |
| loop | custom · count · pass · dry · converged · target · budget |
| parallel | arms · each · sweep · attempts |
| branch | if-else · guard · route |
Four templates to start from (Review, Research, Migrate, Judge panel), or a blank board.
/conductor | the board: the live run, else the last project, else the start screen |
/conductor <description…> | Claude drafts a new project from your words |
/conductor min · max | fold the designer away, bring it back |
| Key | Does |
|---|---|
w a s d | move the selection; with Shift, move the block |
Enter | edit the block's fields |
n x t | add a block, delete it, change its type or kind |
1 2 | step the model, the effort |
o b | open a container as the view, back up a level |
u y | undo, redo: fifty steps a project |
r | implement and run |
q | ask Claude to review the design |
p | projects 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.
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.Conductor makes no network requests and reads no settings or credentials.
| Area | What Conductor does | What it never does |
|---|---|---|
| Files | Writes 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. |
| Programs | Nothing. | Run any program. |
| Prompts | Submits 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. |
| Tools | Stands 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 tools | Refreshes 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. |
hooks/register.tsx 17 lines1import 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}
17src/run/tracker.ts 490 lines1import { 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}
490hooks/project.tsx 2764 lines1import { 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 lines1import 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}
153hooks/run.tsx 290 lines1import { 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}
290src/ui/rail.ts 325 lines1import 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}
325src/run/events.ts 51 lines1export 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}
51src/run/journal.ts 105 lines1import 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
105src/run/reducer.ts 174 lines1import { 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}
174src/run/replay.ts 95 lines1import { 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}
95src/project/chat.ts 624 lines1// 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}
624src/project/history.ts 36 lines1// 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