pi-taskflow 0.0.4 → 0.0.6

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.
@@ -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 { formatTokens, 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
 
@@ -121,7 +121,7 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
121
121
  return theme.fg("muted", `skipped · ${snip}`);
122
122
  }
123
123
 
124
- const isFanout = type === "map" || type === "parallel";
124
+ const isFanout = type === "map" || type === "parallel" || type === "flow";
125
125
 
126
126
  if (ps.status === "failed") {
127
127
  const e = (ps.error ?? "failed").replace(/\s+/g, " ");
@@ -171,6 +171,14 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
171
171
  // single-agent done
172
172
  const model = shortModel(ps.model);
173
173
  const u = compactUsage(ps.usage, theme);
174
+ if (ps.approval) {
175
+ const d = ps.approval.decision;
176
+ const color = d === "reject" ? "error" : d === "edit" ? "warning" : "success";
177
+ let a = theme.fg(color as Parameters<typeof theme.fg>[0], theme.bold(d.toUpperCase()));
178
+ if (ps.approval.auto) a += theme.fg("dim", " auto");
179
+ if (time) a += ` ${time}`;
180
+ return a;
181
+ }
174
182
  if (ps.gate) {
175
183
  const badge =
176
184
  ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
@@ -186,6 +194,7 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
186
194
  let s = "";
187
195
  if (model) s += theme.fg("accent", model);
188
196
  if (u) s += (s ? " " : "") + u;
197
+ if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
189
198
  if (time) s += ` ${time}`;
190
199
  return s || theme.fg("dim", "done");
191
200
  }
@@ -217,7 +226,9 @@ function headerLine(state: RunState, theme: Theme): string {
217
226
  if (failed) line += theme.fg("error", ` · ${failed}✗`);
218
227
  if (state.status === "blocked") line += theme.fg("error", " · blocked");
219
228
  const cost = aggregateCost(state);
220
- if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
229
+ const budget = state.def.budget;
230
+ if (budget?.maxUSD !== undefined) line += theme.fg("muted", ` · $${cost.toFixed(3)}/$${budget.maxUSD}`);
231
+ else if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
221
232
  const el = runElapsed(state);
222
233
  if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
223
234
  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 {
@@ -71,6 +60,56 @@ function getFinalOutput(messages: Message[]): string {
71
60
  return "";
72
61
  }
73
62
 
63
+ /** Accumulated state folded from a subagent's NDJSON event stream. */
64
+ export interface EventAccumulator {
65
+ messages: Message[];
66
+ usage: UsageStats;
67
+ model?: string;
68
+ stopReason?: string;
69
+ errorMessage?: string;
70
+ lastActivity: string;
71
+ }
72
+
73
+ export function newAccumulator(model?: string): EventAccumulator {
74
+ return { messages: [], usage: emptyUsage(), model, lastActivity: "" };
75
+ }
76
+
77
+ /**
78
+ * Fold one NDJSON line into the accumulator. Returns a LiveUpdate when an
79
+ * assistant message ended (for streaming), else null. Empty, malformed, and
80
+ * non-`message_end` lines are ignored — making the parser robust to partial
81
+ * buffers/noise and unit-testable without spawning a process.
82
+ */
83
+ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate | null {
84
+ if (!line.trim()) return null;
85
+ let event: any;
86
+ try {
87
+ event = JSON.parse(line);
88
+ } catch {
89
+ return null;
90
+ }
91
+ if (event.type !== "message_end" || !event.message) return null;
92
+ const msg = event.message as Message;
93
+ acc.messages.push(msg);
94
+ if (msg.role !== "assistant") return null;
95
+ acc.usage.turns++;
96
+ const u = (msg as any).usage;
97
+ if (u) {
98
+ acc.usage.input += u.input || 0;
99
+ acc.usage.output += u.output || 0;
100
+ acc.usage.cacheRead += u.cacheRead || 0;
101
+ acc.usage.cacheWrite += u.cacheWrite || 0;
102
+ acc.usage.cost += u.cost?.total || 0;
103
+ acc.usage.contextTokens = u.totalTokens || 0;
104
+ }
105
+ if (!acc.model && (msg as any).model) acc.model = (msg as any).model;
106
+ if ((msg as any).stopReason) acc.stopReason = (msg as any).stopReason;
107
+ if ((msg as any).errorMessage) acc.errorMessage = (msg as any).errorMessage;
108
+ const activity = describeActivity(msg);
109
+ if (activity) acc.lastActivity = activity;
110
+ return { text: acc.lastActivity, usage: { ...acc.usage }, model: acc.model };
111
+ }
112
+
74
113
  /** One-line description of the most recent assistant activity (text or tool call). */
75
114
  function describeActivity(msg: Message): string {
76
115
  if (msg.role !== "assistant") return "";
@@ -177,8 +216,7 @@ export async function runAgentTask(
177
216
  let tmpPromptDir: string | null = null;
178
217
  let tmpPromptPath: string | null = null;
179
218
 
180
- const messages: Message[] = [];
181
- let lastActivity = "";
219
+ const acc = newAccumulator(model);
182
220
  const result: RunResult = {
183
221
  agent: agentName,
184
222
  task,
@@ -209,36 +247,8 @@ export async function runAgentTask(
209
247
  let buffer = "";
210
248
 
211
249
  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
- }
250
+ const live = foldEventLine(acc, line);
251
+ if (live && opts.onLive) opts.onLive(live);
242
252
  };
243
253
 
244
254
  proc.stdout.on("data", (data) => {
@@ -270,7 +280,11 @@ export async function runAgentTask(
270
280
  });
271
281
 
272
282
  result.exitCode = exitCode;
273
- result.output = getFinalOutput(messages);
283
+ result.usage = acc.usage;
284
+ result.model = acc.model;
285
+ result.stopReason = acc.stopReason;
286
+ result.errorMessage = acc.errorMessage;
287
+ result.output = getFinalOutput(acc.messages);
274
288
  if (wasAborted) {
275
289
  result.stopReason = "aborted";
276
290
  result.errorMessage = "Subagent was aborted";
@@ -317,34 +331,3 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
317
331
  await Promise.all(workers);
318
332
  return results;
319
333
  }
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
- }