pi-taskflow 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Subagent runner — spawns an isolated `pi --mode json -p` process for a single
3
+ * task and collects its structured output and usage. Adapted from the pi
4
+ * subagent extension's runSingleAgent.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { Message } from "@earendil-works/pi-ai";
12
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
+ import type { AgentConfig } from "./agents.ts";
14
+
15
+ export interface UsageStats {
16
+ input: number;
17
+ output: number;
18
+ cacheRead: number;
19
+ cacheWrite: number;
20
+ cost: number;
21
+ contextTokens: number;
22
+ turns: number;
23
+ }
24
+
25
+ export function emptyUsage(): UsageStats {
26
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
27
+ }
28
+
29
+ export interface RunResult {
30
+ agent: string;
31
+ task: string;
32
+ exitCode: number;
33
+ output: string;
34
+ stderr: string;
35
+ usage: UsageStats;
36
+ model?: string;
37
+ stopReason?: string;
38
+ errorMessage?: string;
39
+ }
40
+
41
+ export interface RunOptions {
42
+ model?: string;
43
+ thinking?: string;
44
+ tools?: string[];
45
+ cwd?: string;
46
+ signal?: AbortSignal;
47
+ onText?: (text: string) => void;
48
+ }
49
+
50
+ export function isFailed(r: RunResult): boolean {
51
+ return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
52
+ }
53
+
54
+ function getFinalOutput(messages: Message[]): string {
55
+ for (let i = messages.length - 1; i >= 0; i--) {
56
+ const msg = messages[i];
57
+ if (msg.role === "assistant") {
58
+ for (const part of msg.content) {
59
+ if (part.type === "text") return part.text;
60
+ }
61
+ }
62
+ }
63
+ return "";
64
+ }
65
+
66
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
67
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-taskflow-"));
68
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
69
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
70
+ await withFileMutationQueue(filePath, async () => {
71
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
72
+ });
73
+ return { dir: tmpDir, filePath };
74
+ }
75
+
76
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
77
+ // Explicit override (used by tests and unusual launch setups).
78
+ const override = process.env.PI_TASKFLOW_PI_BIN;
79
+ if (override) return { command: override, args };
80
+
81
+ const currentScript = process.argv[1];
82
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
83
+ // Only re-exec the current script if it actually looks like the pi CLI entry.
84
+ const looksLikePi = currentScript ? /(?:^|[\\/])(?:cli|pi)\.(?:js|mjs|cjs)$/.test(currentScript) : false;
85
+ if (currentScript && !isBunVirtualScript && looksLikePi && fs.existsSync(currentScript)) {
86
+ return { command: process.execPath, args: [currentScript, ...args] };
87
+ }
88
+
89
+ const execName = path.basename(process.execPath).toLowerCase();
90
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
91
+ if (!isGenericRuntime) return { command: process.execPath, args };
92
+ return { command: "pi", args };
93
+ }
94
+
95
+ /**
96
+ * Run a single subagent task. Resolves the agent from `agents` by name and
97
+ * spawns an isolated pi process, returning structured output + usage.
98
+ */
99
+ export async function runAgentTask(
100
+ defaultCwd: string,
101
+ agents: AgentConfig[],
102
+ agentName: string,
103
+ task: string,
104
+ opts: RunOptions,
105
+ globalThinking?: string,
106
+ ): Promise<RunResult> {
107
+ const agent = agents.find((a) => a.name === agentName);
108
+ if (!agent) {
109
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
110
+ return {
111
+ agent: agentName,
112
+ task,
113
+ exitCode: 1,
114
+ output: "",
115
+ stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
116
+ usage: emptyUsage(),
117
+ errorMessage: `Unknown agent: ${agentName}`,
118
+ stopReason: "error",
119
+ };
120
+ }
121
+
122
+ const model = opts.model ?? agent.model;
123
+ const thinking = opts.thinking ?? agent.thinking ?? globalThinking;
124
+ const tools = opts.tools ?? agent.tools;
125
+
126
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
127
+ if (model) args.push("--model", model);
128
+ if (thinking) args.push("--thinking", thinking);
129
+ if (tools && tools.length > 0) args.push("--tools", tools.join(","));
130
+
131
+ let tmpPromptDir: string | null = null;
132
+ let tmpPromptPath: string | null = null;
133
+
134
+ const messages: Message[] = [];
135
+ const result: RunResult = {
136
+ agent: agentName,
137
+ task,
138
+ exitCode: 0,
139
+ output: "",
140
+ stderr: "",
141
+ usage: emptyUsage(),
142
+ model,
143
+ };
144
+
145
+ try {
146
+ if (agent.systemPrompt.trim()) {
147
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
148
+ tmpPromptDir = tmp.dir;
149
+ tmpPromptPath = tmp.filePath;
150
+ args.push("--append-system-prompt", tmpPromptPath);
151
+ }
152
+ args.push(`Task: ${task}`);
153
+
154
+ let wasAborted = false;
155
+ const exitCode = await new Promise<number>((resolve) => {
156
+ const invocation = getPiInvocation(args);
157
+ const proc = spawn(invocation.command, invocation.args, {
158
+ cwd: opts.cwd ?? defaultCwd,
159
+ shell: false,
160
+ stdio: ["ignore", "pipe", "pipe"],
161
+ });
162
+ let buffer = "";
163
+
164
+ const processLine = (line: string) => {
165
+ if (!line.trim()) return;
166
+ let event: any;
167
+ try {
168
+ event = JSON.parse(line);
169
+ } catch {
170
+ return;
171
+ }
172
+ if (event.type === "message_end" && event.message) {
173
+ const msg = event.message as Message;
174
+ messages.push(msg);
175
+ if (msg.role === "assistant") {
176
+ result.usage.turns++;
177
+ const u = (msg as any).usage;
178
+ if (u) {
179
+ result.usage.input += u.input || 0;
180
+ result.usage.output += u.output || 0;
181
+ result.usage.cacheRead += u.cacheRead || 0;
182
+ result.usage.cacheWrite += u.cacheWrite || 0;
183
+ result.usage.cost += u.cost?.total || 0;
184
+ result.usage.contextTokens = u.totalTokens || 0;
185
+ }
186
+ if (!result.model && (msg as any).model) result.model = (msg as any).model;
187
+ if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason;
188
+ if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage;
189
+ const text = getFinalOutput([msg]);
190
+ if (text && opts.onText) opts.onText(text);
191
+ }
192
+ }
193
+ };
194
+
195
+ proc.stdout.on("data", (data) => {
196
+ buffer += data.toString();
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() || "";
199
+ for (const line of lines) processLine(line);
200
+ });
201
+ proc.stderr.on("data", (data) => {
202
+ result.stderr += data.toString();
203
+ });
204
+ proc.on("close", (code) => {
205
+ if (buffer.trim()) processLine(buffer);
206
+ resolve(code ?? 0);
207
+ });
208
+ proc.on("error", () => resolve(1));
209
+
210
+ if (opts.signal) {
211
+ const kill = () => {
212
+ wasAborted = true;
213
+ proc.kill("SIGTERM");
214
+ setTimeout(() => {
215
+ if (!proc.killed) proc.kill("SIGKILL");
216
+ }, 5000);
217
+ };
218
+ if (opts.signal.aborted) kill();
219
+ else opts.signal.addEventListener("abort", kill, { once: true });
220
+ }
221
+ });
222
+
223
+ result.exitCode = exitCode;
224
+ result.output = getFinalOutput(messages);
225
+ if (wasAborted) {
226
+ result.stopReason = "aborted";
227
+ result.errorMessage = "Subagent was aborted";
228
+ }
229
+ if (isFailed(result) && !result.output) {
230
+ result.output = result.errorMessage || result.stderr || "(no output)";
231
+ }
232
+ return result;
233
+ } finally {
234
+ if (tmpPromptPath) {
235
+ try {
236
+ fs.unlinkSync(tmpPromptPath);
237
+ } catch {
238
+ /* ignore */
239
+ }
240
+ }
241
+ if (tmpPromptDir) {
242
+ try {
243
+ fs.rmdirSync(tmpPromptDir);
244
+ } catch {
245
+ /* ignore */
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ /** Run an array of items through `fn` with a bounded concurrency pool. */
252
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
253
+ items: TIn[],
254
+ concurrency: number,
255
+ fn: (item: TIn, index: number) => Promise<TOut>,
256
+ ): Promise<TOut[]> {
257
+ if (items.length === 0) return [];
258
+ const limit = Math.max(1, Math.min(concurrency, items.length));
259
+ const results: TOut[] = new Array(items.length);
260
+ let nextIndex = 0;
261
+ const workers = new Array(limit).fill(null).map(async () => {
262
+ while (true) {
263
+ const current = nextIndex++;
264
+ if (current >= items.length) return;
265
+ results[current] = await fn(items[current], current);
266
+ }
267
+ });
268
+ await Promise.all(workers);
269
+ return results;
270
+ }
271
+
272
+ export function formatTokens(count: number): string {
273
+ if (count < 1000) return count.toString();
274
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
275
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
276
+ return `${(count / 1000000).toFixed(1)}M`;
277
+ }
278
+
279
+ export function formatUsage(usage: UsageStats, model?: string): string {
280
+ const parts: string[] = [];
281
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
282
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
283
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
284
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
285
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
286
+ if (model) parts.push(model);
287
+ return parts.join(" ");
288
+ }
289
+
290
+ export function aggregateUsage(usages: UsageStats[]): UsageStats {
291
+ const total = emptyUsage();
292
+ for (const u of usages) {
293
+ total.input += u.input;
294
+ total.output += u.output;
295
+ total.cacheRead += u.cacheRead;
296
+ total.cacheWrite += u.cacheWrite;
297
+ total.cost += u.cost;
298
+ total.turns += u.turns;
299
+ }
300
+ return total;
301
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Taskflow runtime — the orchestration engine.
3
+ *
4
+ * Resolves the phase DAG into topological layers and executes each phase by
5
+ * delegating to isolated subagents. Intermediate phase outputs live here (in
6
+ * RunState) and never enter the host conversation's context window — only the
7
+ * final phase output is returned to the caller.
8
+ *
9
+ * Supports resume: phases whose resolved input hash matches a cached completed
10
+ * result are skipped.
11
+ */
12
+
13
+ import type { AgentConfig } from "./agents.ts";
14
+ import { coerceArray, interpolate, type InterpolationContext, safeParse } from "./interpolate.ts";
15
+ import { aggregateUsage, emptyUsage, isFailed, mapWithConcurrencyLimit, runAgentTask, type RunResult, type UsageStats } from "./runner.ts";
16
+ import { dependenciesOf, finalPhase, type Phase, type Taskflow, topoLayers } from "./schema.ts";
17
+ import { hashInput, type PhaseState, type RunState } from "./store.ts";
18
+
19
+ export interface RuntimeDeps {
20
+ cwd: string;
21
+ agents: AgentConfig[];
22
+ globalThinking?: string;
23
+ signal?: AbortSignal;
24
+ /** Persist run state after each phase (for resume). */
25
+ persist?: (state: RunState) => void;
26
+ /** Live progress callback for TUI streaming. */
27
+ onProgress?: (state: RunState) => void;
28
+ /** Injectable task runner (defaults to spawning a real subagent). Enables testing. */
29
+ runTask?: typeof runAgentTask;
30
+ }
31
+
32
+ export interface RuntimeResult {
33
+ state: RunState;
34
+ finalOutput: string;
35
+ ok: boolean;
36
+ totalUsage: UsageStats;
37
+ }
38
+
39
+ function buildInterpolationContext(
40
+ state: RunState,
41
+ previousOutput: string | undefined,
42
+ locals?: Record<string, unknown>,
43
+ ): InterpolationContext {
44
+ const steps: Record<string, { output: string; json?: unknown }> = {};
45
+ for (const [id, ps] of Object.entries(state.phases)) {
46
+ if (ps.status === "done" && ps.output !== undefined) {
47
+ steps[id] = { output: ps.output, json: ps.json };
48
+ }
49
+ }
50
+ return { args: state.args, steps, previousOutput, locals };
51
+ }
52
+
53
+ function resultToPhaseState(id: string, r: RunResult, inputHash: string, parseJson: boolean): PhaseState {
54
+ const failed = isFailed(r);
55
+ return {
56
+ id,
57
+ status: failed ? "failed" : "done",
58
+ output: r.output,
59
+ json: parseJson && !failed ? safeParse(r.output) : undefined,
60
+ usage: r.usage,
61
+ model: r.model,
62
+ error: failed ? r.errorMessage || r.stderr || r.output : undefined,
63
+ inputHash,
64
+ endedAt: Date.now(),
65
+ };
66
+ }
67
+
68
+ /** Merge several sub-results into a single PhaseState (for map/parallel). */
69
+ function mergePhaseState(
70
+ id: string,
71
+ results: RunResult[],
72
+ inputHash: string,
73
+ parseJson: boolean,
74
+ ): PhaseState {
75
+ const anyFailed = results.some(isFailed);
76
+ const usage = aggregateUsage(results.map((r) => r.usage));
77
+ // Combine outputs as a labelled list; also expose a JSON array of outputs.
78
+ const combinedText = results
79
+ .map((r, i) => `### [${i + 1}/${results.length}] ${r.agent}${isFailed(r) ? " (failed)" : ""}\n\n${r.output}`)
80
+ .join("\n\n---\n\n");
81
+ const jsonArray = parseJson ? results.map((r) => safeParse(r.output) ?? r.output) : undefined;
82
+ return {
83
+ id,
84
+ status: anyFailed ? "failed" : "done",
85
+ output: combinedText,
86
+ json: jsonArray,
87
+ usage,
88
+ error: anyFailed ? results.filter(isFailed).map((r) => `${r.agent}: ${r.errorMessage ?? r.stderr}`).join("; ") : undefined,
89
+ inputHash,
90
+ endedAt: Date.now(),
91
+ };
92
+ }
93
+
94
+ async function executePhase(
95
+ phase: Phase,
96
+ state: RunState,
97
+ deps: RuntimeDeps,
98
+ prior: PhaseState | undefined,
99
+ ): Promise<PhaseState> {
100
+ const type = phase.type ?? "agent";
101
+ const concurrency = phase.concurrency ?? state.def.concurrency ?? 8;
102
+ const previousOutput = lastCompletedOutput(state, phase);
103
+ const run = deps.runTask ?? runAgentTask;
104
+
105
+ const runOne = (agentName: string, task: string, _locals?: Record<string, unknown>) =>
106
+ run(
107
+ deps.cwd,
108
+ deps.agents,
109
+ agentName,
110
+ task,
111
+ {
112
+ model: phase.model,
113
+ thinking: phase.thinking,
114
+ tools: phase.tools,
115
+ cwd: phase.cwd,
116
+ signal: deps.signal,
117
+ },
118
+ deps.globalThinking,
119
+ );
120
+
121
+ const parseJson = phase.output === "json";
122
+
123
+ if (type === "agent" || type === "gate") {
124
+ const ctx = buildInterpolationContext(state, previousOutput);
125
+ const { text } = interpolate(phase.task ?? "", ctx);
126
+ const inputHash = hashInput(phase.id, phase.agent ?? "", text);
127
+ const cached = cachedPhase(prior, inputHash);
128
+ if (cached) return cached;
129
+
130
+ const r = await runOne(phase.agent ?? defaultAgent(deps), text);
131
+ return resultToPhaseState(phase.id, r, inputHash, parseJson);
132
+ }
133
+
134
+ if (type === "parallel") {
135
+ const ctx = buildInterpolationContext(state, previousOutput);
136
+ const branches = (phase.branches ?? []).map((b) => ({
137
+ agent: b.agent ?? phase.agent ?? defaultAgent(deps),
138
+ task: interpolate(b.task, ctx).text,
139
+ }));
140
+ const inputHash = hashInput(phase.id, JSON.stringify(branches));
141
+ const cached = cachedPhase(prior, inputHash);
142
+ if (cached) return cached;
143
+
144
+ const results = await mapWithConcurrencyLimit(branches, concurrency, (b) => runOne(b.agent, b.task));
145
+ return mergePhaseState(phase.id, results, inputHash, parseJson);
146
+ }
147
+
148
+ if (type === "map") {
149
+ const ctx = buildInterpolationContext(state, previousOutput);
150
+ const overResolved = interpolate(phase.over ?? "", ctx).text;
151
+ // `over` may itself be a placeholder that resolved to a JSON string.
152
+ const arr = coerceArray(safeParse(overResolved)) ?? coerceArray(directRef(phase.over ?? "", state));
153
+ if (!arr) {
154
+ return {
155
+ id: phase.id,
156
+ status: "failed",
157
+ error: `map phase '${phase.id}': 'over' (${phase.over}) did not resolve to an array`,
158
+ inputHash: hashInput(phase.id, "no-array"),
159
+ endedAt: Date.now(),
160
+ usage: emptyUsage(),
161
+ };
162
+ }
163
+ const loopVar = phase.as ?? "item";
164
+ const tasks = arr.map((item) => {
165
+ const localCtx = buildInterpolationContext(state, previousOutput, { [loopVar]: item });
166
+ return {
167
+ agent: phase.agent ?? defaultAgent(deps),
168
+ task: interpolate(phase.task ?? "", localCtx).text,
169
+ };
170
+ });
171
+ const inputHash = hashInput(phase.id, JSON.stringify(tasks));
172
+ const cached = cachedPhase(prior, inputHash);
173
+ if (cached) return cached;
174
+
175
+ const results = await mapWithConcurrencyLimit(tasks, concurrency, (t) => runOne(t.agent, t.task));
176
+ return mergePhaseState(phase.id, results, inputHash, parseJson);
177
+ }
178
+
179
+ if (type === "reduce") {
180
+ const ctx = buildInterpolationContext(state, previousOutput);
181
+ // Inputs for reduce come from `from` phases; interpolation already exposes them.
182
+ const { text } = interpolate(phase.task ?? "", ctx);
183
+ const inputHash = hashInput(phase.id, text);
184
+ const cached = cachedPhase(prior, inputHash);
185
+ if (cached) return cached;
186
+
187
+ const r = await runOne(phase.agent ?? defaultAgent(deps), text);
188
+ return resultToPhaseState(phase.id, r, inputHash, parseJson);
189
+ }
190
+
191
+ return {
192
+ id: phase.id,
193
+ status: "failed",
194
+ error: `Unknown phase type: ${type}`,
195
+ endedAt: Date.now(),
196
+ usage: emptyUsage(),
197
+ };
198
+ }
199
+
200
+ /** Resolve a `{steps.x.json}`-style ref directly to its parsed value (bypassing stringify). */
201
+ function directRef(over: string, state: RunState): unknown {
202
+ const m = over.match(/^\{steps\.([a-zA-Z0-9_]+)\.(output|json)\}$/);
203
+ if (!m) return undefined;
204
+ const step = state.phases[m[1]];
205
+ if (!step || step.status !== "done") return undefined;
206
+ if (m[2] === "json") return step.json ?? safeParse(step.output ?? "");
207
+ return safeParse(step.output ?? "");
208
+ }
209
+
210
+ function lastCompletedOutput(state: RunState, phase: Phase): string | undefined {
211
+ const deps = dependenciesOf(phase);
212
+ for (let i = deps.length - 1; i >= 0; i--) {
213
+ const ps = state.phases[deps[i]];
214
+ if (ps?.status === "done") return ps.output;
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ function cachedPhase(prior: PhaseState | undefined, inputHash: string): PhaseState | null {
220
+ if (prior && prior.status === "done" && prior.inputHash === inputHash) {
221
+ return { ...prior, status: "done" };
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function defaultAgent(deps: RuntimeDeps): string {
227
+ return deps.agents[0]?.name ?? "default";
228
+ }
229
+
230
+ /**
231
+ * Execute a full taskflow. Mutates and persists `state` as it progresses.
232
+ */
233
+ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise<RuntimeResult> {
234
+ const def: Taskflow = state.def;
235
+ const layers = topoLayers(def.phases);
236
+
237
+ state.status = "running";
238
+ deps.persist?.(state);
239
+ deps.onProgress?.(state);
240
+
241
+ let aborted = false;
242
+
243
+ for (const layer of layers) {
244
+ if (deps.signal?.aborted) {
245
+ aborted = true;
246
+ break;
247
+ }
248
+ // Phases within a layer have no inter-dependencies → run concurrently.
249
+ const layerConcurrency = Math.max(1, def.concurrency ?? 8);
250
+ await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
251
+ // Snapshot prior state BEFORE marking running, so resume cache checks work.
252
+ const prior = state.phases[phase.id];
253
+ // Skip if a dependency failed (unless this phase is optional).
254
+ const failedDep = dependenciesOf(phase).some((d) => state.phases[d]?.status === "failed");
255
+ if (failedDep) {
256
+ state.phases[phase.id] = {
257
+ id: phase.id,
258
+ status: "skipped",
259
+ error: "Upstream dependency failed",
260
+ endedAt: Date.now(),
261
+ usage: emptyUsage(),
262
+ };
263
+ deps.persist?.(state);
264
+ deps.onProgress?.(state);
265
+ return;
266
+ }
267
+
268
+ state.phases[phase.id] = {
269
+ ...(state.phases[phase.id] ?? { id: phase.id }),
270
+ id: phase.id,
271
+ status: "running",
272
+ startedAt: Date.now(),
273
+ };
274
+ deps.onProgress?.(state);
275
+
276
+ const ps = await executePhase(phase, state, deps, prior);
277
+ state.phases[phase.id] = ps;
278
+ deps.persist?.(state);
279
+ deps.onProgress?.(state);
280
+ });
281
+ }
282
+
283
+ const fp = finalPhase(def.phases);
284
+ const finalState = state.phases[fp.id];
285
+ const anyFailed = Object.values(state.phases).some((p) => p.status === "failed");
286
+
287
+ state.status = aborted ? "paused" : anyFailed ? "failed" : "completed";
288
+ deps.persist?.(state);
289
+ deps.onProgress?.(state);
290
+
291
+ const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
292
+ return {
293
+ state,
294
+ finalOutput: finalState?.output ?? "(no output)",
295
+ ok: state.status === "completed",
296
+ totalUsage,
297
+ };
298
+ }