pi-taskflow 0.0.5 → 0.0.7

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.
@@ -20,7 +20,7 @@ export interface InterpolationContext {
20
20
  locals?: Record<string, unknown>;
21
21
  }
22
22
 
23
- const PLACEHOLDER = /\{([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)\}/g;
23
+ const PLACEHOLDER = /\{([a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*)\}/g;
24
24
 
25
25
  export interface InterpolationResult {
26
26
  text: string;
@@ -149,3 +149,234 @@ export function coerceArray(value: unknown): unknown[] | null {
149
149
  }
150
150
  return null;
151
151
  }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Conditional expressions (phase.when)
155
+ // ---------------------------------------------------------------------------
156
+ //
157
+ // A tiny, safe boolean expression language — NO eval / Function. Operands are
158
+ // either interpolation placeholders `{...}` (resolved to their raw value) or
159
+ // literals (quoted string, number, true/false/null, or a bare word treated as
160
+ // a string). Operators, by precedence (low → high):
161
+ //
162
+ // || logical or
163
+ // && logical and
164
+ // == != == >= <= > < comparison
165
+ // ! logical not / unary
166
+ // ( ) grouping
167
+ //
168
+ // A bare operand is evaluated for truthiness. Parse errors fail OPEN (return
169
+ // true) so a malformed guard never silently drops a phase.
170
+
171
+ type Tok =
172
+ | { t: "ref"; v: string }
173
+ | { t: "str"; v: string }
174
+ | { t: "num"; v: number }
175
+ | { t: "bool"; v: boolean }
176
+ | { t: "null" }
177
+ | { t: "op"; v: string };
178
+
179
+ const OPS = ["&&", "||", "==", "!=", ">=", "<=", ">", "<", "!", "(", ")"];
180
+
181
+ function tokenize(input: string): Tok[] {
182
+ const toks: Tok[] = [];
183
+ let i = 0;
184
+ const n = input.length;
185
+ while (i < n) {
186
+ const c = input[i];
187
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
188
+ i++;
189
+ continue;
190
+ }
191
+ // placeholder {path.to.value}
192
+ if (c === "{") {
193
+ const end = input.indexOf("}", i);
194
+ if (end === -1) throw new Error("unterminated placeholder");
195
+ toks.push({ t: "ref", v: input.slice(i + 1, end).trim() });
196
+ i = end + 1;
197
+ continue;
198
+ }
199
+ // quoted string
200
+ if (c === '"' || c === "'") {
201
+ const end = input.indexOf(c, i + 1);
202
+ if (end === -1) throw new Error("unterminated string");
203
+ toks.push({ t: "str", v: input.slice(i + 1, end) });
204
+ i = end + 1;
205
+ continue;
206
+ }
207
+ // multi/single char operators
208
+ const op = OPS.find((o) => input.startsWith(o, i));
209
+ if (op) {
210
+ toks.push({ t: "op", v: op });
211
+ i += op.length;
212
+ continue;
213
+ }
214
+ // number
215
+ const numMatch = /^-?\d+(?:\.\d+)?/.exec(input.slice(i));
216
+ if (numMatch) {
217
+ toks.push({ t: "num", v: Number(numMatch[0]) });
218
+ i += numMatch[0].length;
219
+ continue;
220
+ }
221
+ // bareword → literal (true/false/null keywords, else string)
222
+ const word = /^[^\s&|!=<>()"'{}]+/.exec(input.slice(i));
223
+ if (word) {
224
+ const w = word[0];
225
+ if (w === "true") toks.push({ t: "bool", v: true });
226
+ else if (w === "false") toks.push({ t: "bool", v: false });
227
+ else if (w === "null") toks.push({ t: "null" });
228
+ else toks.push({ t: "str", v: w });
229
+ i += w.length;
230
+ continue;
231
+ }
232
+ throw new Error(`unexpected char '${c}'`);
233
+ }
234
+ return toks;
235
+ }
236
+
237
+ function isNumeric(v: unknown): boolean {
238
+ if (typeof v === "number") return Number.isFinite(v);
239
+ if (typeof v === "string" && v.trim() !== "") return Number.isFinite(Number(v));
240
+ return false;
241
+ }
242
+
243
+ function truthy(v: unknown): boolean {
244
+ if (v === undefined || v === null) return false;
245
+ if (typeof v === "boolean") return v;
246
+ if (typeof v === "number") return v !== 0;
247
+ if (typeof v === "string") {
248
+ const s = v.trim().toLowerCase();
249
+ return !(s === "" || s === "false" || s === "0" || s === "no" || s === "off" || s === "null");
250
+ }
251
+ if (Array.isArray(v)) return v.length > 0;
252
+ if (typeof v === "object") return Object.keys(v as object).length > 0;
253
+ return Boolean(v);
254
+ }
255
+
256
+ function compare(a: unknown, op: string, b: unknown): boolean {
257
+ if (isNumeric(a) && isNumeric(b)) {
258
+ const x = Number(a);
259
+ const y = Number(b);
260
+ switch (op) {
261
+ case "==": return x === y;
262
+ case "!=": return x !== y;
263
+ case ">": return x > y;
264
+ case "<": return x < y;
265
+ case ">=": return x >= y;
266
+ case "<=": return x <= y;
267
+ }
268
+ }
269
+ const sa = a === undefined || a === null ? "" : String(a);
270
+ const sb = b === undefined || b === null ? "" : String(b);
271
+ switch (op) {
272
+ case "==": return sa === sb;
273
+ case "!=": return sa !== sb;
274
+ case ">": return sa > sb;
275
+ case "<": return sa < sb;
276
+ case ">=": return sa >= sb;
277
+ case "<=": return sa <= sb;
278
+ }
279
+ return false;
280
+ }
281
+
282
+ /** Recursive-descent parser/evaluator over the token stream. */
283
+ class CondParser {
284
+ private pos = 0;
285
+ private readonly toks: Tok[];
286
+ private readonly ctx: InterpolationContext;
287
+ constructor(toks: Tok[], ctx: InterpolationContext) {
288
+ this.toks = toks;
289
+ this.ctx = ctx;
290
+ }
291
+
292
+ parse(): unknown {
293
+ const v = this.parseOr();
294
+ if (this.pos < this.toks.length) throw new Error("trailing tokens");
295
+ return v;
296
+ }
297
+ private peek(): Tok | undefined {
298
+ return this.toks[this.pos];
299
+ }
300
+ private eat(op: string): boolean {
301
+ const t = this.peek();
302
+ if (t && t.t === "op" && t.v === op) {
303
+ this.pos++;
304
+ return true;
305
+ }
306
+ return false;
307
+ }
308
+ private parseOr(): unknown {
309
+ let left = this.parseAnd();
310
+ while (this.eat("||")) {
311
+ const right = this.parseAnd();
312
+ left = truthy(left) || truthy(right);
313
+ }
314
+ return left;
315
+ }
316
+ private parseAnd(): unknown {
317
+ let left = this.parseNot();
318
+ while (this.eat("&&")) {
319
+ const right = this.parseNot();
320
+ left = truthy(left) && truthy(right);
321
+ }
322
+ return left;
323
+ }
324
+ private parseNot(): unknown {
325
+ if (this.eat("!")) return !truthy(this.parseNot());
326
+ return this.parseComparison();
327
+ }
328
+ private parseComparison(): unknown {
329
+ const left = this.parsePrimary();
330
+ const t = this.peek();
331
+ if (t && t.t === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(t.v)) {
332
+ this.pos++;
333
+ const right = this.parsePrimary();
334
+ return compare(left, t.v, right);
335
+ }
336
+ return left;
337
+ }
338
+ private parsePrimary(): unknown {
339
+ if (this.eat("(")) {
340
+ const v = this.parseOr();
341
+ if (!this.eat(")")) throw new Error("missing )");
342
+ return v;
343
+ }
344
+ const t = this.peek();
345
+ if (!t) throw new Error("unexpected end");
346
+ this.pos++;
347
+ switch (t.t) {
348
+ case "ref": return resolvePath(t.v, this.ctx);
349
+ case "str": return t.v;
350
+ case "num": return t.v;
351
+ case "bool": return t.v;
352
+ case "null": return null;
353
+ default: throw new Error(`unexpected operator '${(t as { v: string }).v}'`);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Evaluate a `when` expression to a boolean. Returns `{ value, error }`.
360
+ * Parse errors set `error` and fail OPEN (`value: true`) so a broken guard
361
+ * never silently drops a phase.
362
+ */
363
+ export function tryEvaluateCondition(
364
+ expr: string,
365
+ ctx: InterpolationContext,
366
+ ): { value: boolean; error?: string } {
367
+ const trimmed = (expr ?? "").trim();
368
+ if (!trimmed) return { value: true };
369
+ try {
370
+ const toks = tokenize(trimmed);
371
+ if (toks.length === 0) return { value: true };
372
+ const result = new CondParser(toks, ctx).parse();
373
+ return { value: truthy(result) };
374
+ } catch (e) {
375
+ return { value: true, error: e instanceof Error ? e.message : String(e) };
376
+ }
377
+ }
378
+
379
+ /** Boolean convenience wrapper over {@link tryEvaluateCondition}. */
380
+ export function evaluateCondition(expr: string, ctx: InterpolationContext): boolean {
381
+ return tryEvaluateCondition(expr, ctx).value;
382
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
10
- import { formatTokens, type UsageStats } from "./runner.ts";
10
+ import { type UsageStats } from "./usage.ts";
11
11
  import type { PhaseState, RunState } from "./store.ts";
12
12
  import { dependenciesOf, type Phase, topoLayers } from "./schema.ts";
13
13
 
@@ -62,23 +62,16 @@ function miniBar(done: number, total: number, theme: Theme, width = 8): string {
62
62
  return theme.fg("accent", "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
63
63
  }
64
64
 
65
- function compactUsage(usage: UsageStats | undefined, theme: Theme): string {
66
- if (!usage) return "";
67
- const parts: string[] = [];
68
- if (usage.turns) parts.push(theme.fg("dim", `${usage.turns}t`));
69
- if (usage.input) parts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
70
- if (usage.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
71
- if (usage.cost) parts.push(theme.fg("muted", `$${usage.cost.toFixed(3)}`));
72
- return parts.join(" ");
65
+ function agentRole(phase: Phase, ps: PhaseState | undefined, theme: Theme): string {
66
+ const role = phase.agent ?? phase.type ?? "agent";
67
+ const model = ps?.model ? shortModel(ps.model) : "";
68
+ if (!model) return theme.fg("accent", role);
69
+ return theme.fg("accent", role) + theme.fg("dim", `(${model})`);
73
70
  }
74
71
 
75
- function liveUsageStr(usage: UsageStats | undefined, theme: Theme): string {
76
- if (!usage) return "";
77
- const parts: string[] = [];
78
- if (usage.input) parts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
79
- if (usage.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
80
- if (usage.cost) parts.push(theme.fg("muted", `$${usage.cost.toFixed(3)}`));
81
- return parts.join(" ");
72
+ function costStr(usage: UsageStats | undefined, theme: Theme): string {
73
+ if (!usage?.cost) return "";
74
+ return theme.fg("muted", `$${usage.cost.toFixed(3)}`);
82
75
  }
83
76
 
84
77
  function aggregateCost(state: RunState): number {
@@ -118,10 +111,10 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
118
111
  if (ps.status === "skipped") {
119
112
  const reason = (ps.error ?? "upstream failed").replace(/\s+/g, " ");
120
113
  const snip = reason.length > 52 ? `${reason.slice(0, 52)}…` : reason;
121
- return theme.fg("muted", `skipped · ${snip}`);
114
+ return theme.fg("muted", `skipped · ${snip}`) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
122
115
  }
123
116
 
124
- const isFanout = type === "map" || type === "parallel";
117
+ const isFanout = type === "map" || type === "parallel" || type === "flow";
125
118
 
126
119
  if (ps.status === "failed") {
127
120
  const e = (ps.error ?? "failed").replace(/\s+/g, " ");
@@ -131,30 +124,34 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
131
124
  return (
132
125
  theme.fg("toolOutput", `${done - failed}/${total}`) +
133
126
  theme.fg("error", ` ${failed}✗`) +
134
- (snip ? theme.fg("error", ` ${snip}`) : "")
127
+ (snip ? theme.fg("error", ` ${snip}`) : "") +
128
+ (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "")
135
129
  );
136
130
  }
137
- return theme.fg("error", snip);
131
+ return theme.fg("error", snip) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
138
132
  }
139
133
 
140
134
  const t = phaseElapsed(ps);
141
135
  const time = t ? theme.fg("dim", elapsed(t)) : "";
142
136
 
143
137
  if (ps.status === "running") {
144
- const model = shortModel(ps.model);
145
- const tokens = liveUsageStr(ps.usage, theme);
138
+ const roleLabel = agentRole(phase, ps, theme);
139
+ const cost = costStr(ps.usage, theme);
146
140
  if (isFanout && ps.subProgress) {
147
141
  const { done, total, running, failed } = ps.subProgress;
148
142
  let s = `${miniBar(done, total, theme)} ${theme.fg("toolOutput", `${done}/${total}`)}`;
149
143
  if (running) s += theme.fg("dim", ` · ${running} run`);
150
144
  if (failed) s += theme.fg("error", ` · ${failed}✗`);
151
- if (tokens) s += ` ${tokens}`;
145
+ s += ` ${roleLabel}`;
146
+ if (cost) s += ` ${cost}`;
152
147
  if (time) s += ` ${time}`;
148
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
153
149
  return s;
154
150
  }
155
- let s = model ? theme.fg("accent", model) : theme.fg("warning", "running…");
156
- if (tokens) s += ` ${tokens}`;
151
+ let s = roleLabel;
152
+ if (cost) s += ` ${cost}`;
157
153
  if (time) s += ` ${time}`;
154
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
158
155
  return s;
159
156
  }
160
157
 
@@ -163,14 +160,24 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
163
160
  const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
164
161
  let s = theme.fg("success", `${total}✓`);
165
162
  if (failed) s = theme.fg("toolOutput", `${done - failed}/${total}`) + theme.fg("error", ` ${failed}✗`);
166
- const u = compactUsage(ps.usage, theme);
167
- if (u) s += ` ${u}`;
163
+ const cost = costStr(ps.usage, theme);
164
+ if (cost) s += ` ${cost}`;
168
165
  if (time) s += ` ${time}`;
166
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
169
167
  return s;
170
168
  }
171
169
  // single-agent done
172
- const model = shortModel(ps.model);
173
- const u = compactUsage(ps.usage, theme);
170
+ const roleLabel = agentRole(phase, ps, theme);
171
+ const cost = costStr(ps.usage, theme);
172
+ if (ps.approval) {
173
+ const d = ps.approval.decision;
174
+ const color = d === "reject" ? "error" : d === "edit" ? "warning" : "success";
175
+ let a = theme.fg("warning", "⚠") + " " + theme.fg(color as Parameters<typeof theme.fg>[0], theme.bold(d.toUpperCase()));
176
+ if (ps.approval.auto) a += theme.fg("dim", " auto");
177
+ if (time) a += ` ${time}`;
178
+ if (ps.warnings?.length) a += theme.fg("warning", ` ⚠${ps.warnings.length}`);
179
+ return a;
180
+ }
174
181
  if (ps.gate) {
175
182
  const badge =
176
183
  ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
@@ -179,15 +186,18 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
179
186
  const r = ps.gate.reason.replace(/\s+/g, " ");
180
187
  g += theme.fg("dim", ` ${r.length > 44 ? `${r.slice(0, 44)}…` : r}`);
181
188
  }
182
- if (model) g += ` ${theme.fg("dim", model)}`;
189
+ const cost = costStr(ps.usage, theme);
190
+ if (cost) g += ` ${cost}`;
183
191
  if (time) g += ` ${time}`;
192
+ if (ps.warnings?.length) g += theme.fg("warning", ` ⚠${ps.warnings.length}`);
184
193
  return g;
185
194
  }
186
- let s = "";
187
- if (model) s += theme.fg("accent", model);
188
- if (u) s += (s ? " " : "") + u;
195
+ let s = roleLabel;
196
+ if (cost) s += ` ${cost}`;
197
+ if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
189
198
  if (time) s += ` ${time}`;
190
- return s || theme.fg("dim", "done");
199
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
200
+ return s;
191
201
  }
192
202
 
193
203
  /** Header line: status glyph + name + compact totals. */
@@ -217,7 +227,9 @@ function headerLine(state: RunState, theme: Theme): string {
217
227
  if (failed) line += theme.fg("error", ` · ${failed}✗`);
218
228
  if (state.status === "blocked") line += theme.fg("error", " · blocked");
219
229
  const cost = aggregateCost(state);
220
- if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
230
+ const budget = state.def.budget;
231
+ if (budget?.maxUSD !== undefined) line += theme.fg("muted", ` · $${cost.toFixed(3)}/$${budget.maxUSD}`);
232
+ else if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
221
233
  const el = runElapsed(state);
222
234
  if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
223
235
  return line;
@@ -11,20 +11,7 @@ import * as path from "node:path";
11
11
  import type { Message } from "@earendil-works/pi-ai";
12
12
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
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
- }
14
+ import { emptyUsage, type UsageStats } from "./usage.ts";
28
15
 
29
16
  export interface RunResult {
30
17
  agent: string;
@@ -36,6 +23,8 @@ export interface RunResult {
36
23
  model?: string;
37
24
  stopReason?: string;
38
25
  errorMessage?: string;
26
+ /** Total subagent attempts incl. retries (set by the runtime's retry wrapper). */
27
+ attempts?: number;
39
28
  }
40
29
 
41
30
  export interface LiveUpdate {
@@ -59,18 +48,123 @@ export function isFailed(r: RunResult): boolean {
59
48
  return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
60
49
  }
61
50
 
51
+ /** Placeholder written to a failed phase's `output` so downstream interpolation
52
+ * can detect "upstream failed" without being polluted by raw HTML/JSON. */
53
+ export const TRANSPORT_ERROR_PLACEHOLDER = "(upstream error: subagent failed; see error)";
54
+
55
+ /** Hard cap on the errorMessage field stored in PhaseState (≈ 4 KB). */
56
+ export const ERROR_MESSAGE_MAX_LEN = 4096;
57
+
58
+ /** Cheap HTML/JSON detector so we can summarize upstream garbage. */
59
+ export function looksLikeHtmlOrJson(s: string): boolean {
60
+ const t = s.trimStart();
61
+ if (!t) return false;
62
+ if (t.startsWith("<")) {
63
+ // HTML/XML/Cloudflare challenge pages
64
+ return /^<(?:!doctype\s+html|html|head|body|script|svg|div|iframe|span|p)\b/i.test(t);
65
+ }
66
+ if (t.startsWith("{")) {
67
+ // Truncated JSON. A genuine JSON envelope is fine to keep; an unwrapped
68
+ // {error: "..."} from an SDK is short. We only treat it as "garbage" if
69
+ // it parses and is huge — but that's caught by the size cap below.
70
+ return false;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Truncate and (when obviously HTML) summarize an errorMessage before it is
77
+ * persisted. Returns the cleaned string. Empty input returns empty.
78
+ */
79
+ export function sanitizeErrorMessage(raw: string | undefined): string {
80
+ if (!raw) return "";
81
+ const cleaned = raw.replace(/\s+/g, " ").trim();
82
+ if (!cleaned) return "";
83
+ // Decide the sanitization branch on the RAW length, not the whitespace-
84
+ // collapsed length — otherwise an HTML page padded with spaces would slip
85
+ // through the "looks like HTML" branch and be persisted as-is.
86
+ const rawLen = raw.length;
87
+ if (rawLen > ERROR_MESSAGE_MAX_LEN) {
88
+ const head = cleaned.slice(0, 200);
89
+ const tail = cleaned.slice(-200);
90
+ return `${head} ... [truncated ${rawLen - 400} chars] ... ${tail}`;
91
+ }
92
+ if (looksLikeHtmlOrJson(cleaned)) {
93
+ // Any document-like HTML (Cloudflare challenge pages, proxy error pages,
94
+ // gateway error pages) is a strong signal the upstream returned a page
95
+ // instead of JSON. Summarize it instead of letting HTML pollute the
96
+ // phase's error and downstream interpolation contexts.
97
+ const title = cleaned.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim();
98
+ const stripped = cleaned.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
99
+ const m = stripped.match(/(?:Unable to load site|Ray ID[: ]+([A-Za-z0-9]+)|[A-Z][a-z]+Error[: ]+(.{0,200}))/i);
100
+ const hint = title || (m ? (m[1] || m[0]).trim() : stripped.slice(0, 200));
101
+ return `Upstream returned non-JSON response (${rawLen} chars). Hint: ${hint}`;
102
+ }
103
+ return cleaned;
104
+ }
105
+
62
106
  function getFinalOutput(messages: Message[]): string {
63
107
  for (let i = messages.length - 1; i >= 0; i--) {
64
108
  const msg = messages[i];
65
109
  if (msg.role === "assistant") {
66
110
  for (const part of msg.content) {
67
- if (part.type === "text") return part.text;
111
+ if (part.type === "text" && part.text.trim()) return part.text;
68
112
  }
69
113
  }
70
114
  }
71
115
  return "";
72
116
  }
73
117
 
118
+ /** Accumulated state folded from a subagent's NDJSON event stream. */
119
+ export interface EventAccumulator {
120
+ messages: Message[];
121
+ usage: UsageStats;
122
+ model?: string;
123
+ stopReason?: string;
124
+ errorMessage?: string;
125
+ lastActivity: string;
126
+ }
127
+
128
+ export function newAccumulator(model?: string): EventAccumulator {
129
+ return { messages: [], usage: emptyUsage(), model, lastActivity: "" };
130
+ }
131
+
132
+ /**
133
+ * Fold one NDJSON line into the accumulator. Returns a LiveUpdate when an
134
+ * assistant message ended (for streaming), else null. Empty, malformed, and
135
+ * non-`message_end` lines are ignored — making the parser robust to partial
136
+ * buffers/noise and unit-testable without spawning a process.
137
+ */
138
+ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate | null {
139
+ if (!line.trim()) return null;
140
+ let event: any;
141
+ try {
142
+ event = JSON.parse(line);
143
+ } catch {
144
+ return null;
145
+ }
146
+ if (event.type !== "message_end" || !event.message) return null;
147
+ const msg = event.message as Message;
148
+ acc.messages.push(msg);
149
+ if (msg.role !== "assistant") return null;
150
+ acc.usage.turns++;
151
+ const u = (msg as any).usage;
152
+ if (u) {
153
+ acc.usage.input += u.input || 0;
154
+ acc.usage.output += u.output || 0;
155
+ acc.usage.cacheRead += u.cacheRead || 0;
156
+ acc.usage.cacheWrite += u.cacheWrite || 0;
157
+ acc.usage.cost += u.cost?.total || 0;
158
+ acc.usage.contextTokens = u.totalTokens || 0;
159
+ }
160
+ if (!acc.model && (msg as any).model) acc.model = (msg as any).model;
161
+ if ((msg as any).stopReason) acc.stopReason = (msg as any).stopReason;
162
+ if ((msg as any).errorMessage) acc.errorMessage = (msg as any).errorMessage;
163
+ const activity = describeActivity(msg);
164
+ if (activity) acc.lastActivity = activity;
165
+ return { text: acc.lastActivity, usage: { ...acc.usage }, model: acc.model };
166
+ }
167
+
74
168
  /** One-line description of the most recent assistant activity (text or tool call). */
75
169
  function describeActivity(msg: Message): string {
76
170
  if (msg.role !== "assistant") return "";
@@ -177,8 +271,7 @@ export async function runAgentTask(
177
271
  let tmpPromptDir: string | null = null;
178
272
  let tmpPromptPath: string | null = null;
179
273
 
180
- const messages: Message[] = [];
181
- let lastActivity = "";
274
+ const acc = newAccumulator(model);
182
275
  const result: RunResult = {
183
276
  agent: agentName,
184
277
  task,
@@ -209,36 +302,8 @@ export async function runAgentTask(
209
302
  let buffer = "";
210
303
 
211
304
  const processLine = (line: string) => {
212
- if (!line.trim()) return;
213
- let event: any;
214
- try {
215
- event = JSON.parse(line);
216
- } catch {
217
- return;
218
- }
219
- if (event.type === "message_end" && event.message) {
220
- const msg = event.message as Message;
221
- messages.push(msg);
222
- if (msg.role === "assistant") {
223
- result.usage.turns++;
224
- const u = (msg as any).usage;
225
- if (u) {
226
- result.usage.input += u.input || 0;
227
- result.usage.output += u.output || 0;
228
- result.usage.cacheRead += u.cacheRead || 0;
229
- result.usage.cacheWrite += u.cacheWrite || 0;
230
- result.usage.cost += u.cost?.total || 0;
231
- result.usage.contextTokens = u.totalTokens || 0;
232
- }
233
- if (!result.model && (msg as any).model) result.model = (msg as any).model;
234
- if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason;
235
- if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage;
236
- const activity = describeActivity(msg);
237
- if (activity) lastActivity = activity;
238
- if (opts.onLive)
239
- opts.onLive({ text: lastActivity, usage: { ...result.usage }, model: result.model });
240
- }
241
- }
305
+ const live = foldEventLine(acc, line);
306
+ if (live && opts.onLive) opts.onLive(live);
242
307
  };
243
308
 
244
309
  proc.stdout.on("data", (data) => {
@@ -270,13 +335,26 @@ export async function runAgentTask(
270
335
  });
271
336
 
272
337
  result.exitCode = exitCode;
273
- result.output = getFinalOutput(messages);
338
+ result.usage = acc.usage;
339
+ result.model = acc.model;
340
+ result.stopReason = acc.stopReason;
341
+ result.errorMessage = acc.errorMessage;
342
+ result.output = getFinalOutput(acc.messages);
274
343
  if (wasAborted) {
275
344
  result.stopReason = "aborted";
276
345
  result.errorMessage = "Subagent was aborted";
277
346
  }
347
+ // On failure, build a short, structured errorMessage + a placeholder
348
+ // output. We deliberately do NOT copy the raw errorMessage into
349
+ // `output`: upstream providers (e.g. a Cloudflare challenge page) can
350
+ // surface huge HTML/JSON in errorMessage, and that garbage would
351
+ // otherwise flow into downstream phase interpolations.
278
352
  if (isFailed(result) && !result.output) {
279
- result.output = result.errorMessage || result.stderr || "(no output)";
353
+ result.output = TRANSPORT_ERROR_PLACEHOLDER;
354
+ if (!result.errorMessage) {
355
+ result.errorMessage = result.stderr || `Subagent exited with code ${result.exitCode} (stopReason: ${result.stopReason ?? "unknown"})`;
356
+ }
357
+ result.errorMessage = sanitizeErrorMessage(result.errorMessage);
280
358
  }
281
359
  return result;
282
360
  } finally {
@@ -317,34 +395,3 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
317
395
  await Promise.all(workers);
318
396
  return results;
319
397
  }
320
-
321
- export function formatTokens(count: number): string {
322
- if (count < 1000) return count.toString();
323
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
324
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
325
- return `${(count / 1000000).toFixed(1)}M`;
326
- }
327
-
328
- export function formatUsage(usage: UsageStats, model?: string): string {
329
- const parts: string[] = [];
330
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
331
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
332
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
333
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
334
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
335
- if (model) parts.push(model);
336
- return parts.join(" ");
337
- }
338
-
339
- export function aggregateUsage(usages: UsageStats[]): UsageStats {
340
- const total = emptyUsage();
341
- for (const u of usages) {
342
- total.input += u.input;
343
- total.output += u.output;
344
- total.cacheRead += u.cacheRead;
345
- total.cacheWrite += u.cacheWrite;
346
- total.cost += u.cost;
347
- total.turns += u.turns;
348
- }
349
- return total;
350
- }