pi-taskflow 0.0.6 → 0.0.8

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.
package/README.md CHANGED
@@ -22,9 +22,10 @@ saveable as a one-word `/tf:<name>` command.
22
22
  pi install npm:pi-taskflow
23
23
  ```
24
24
 
25
- Fan out one subagent per item, gate the results with an adversarial review, and
26
- get back only the final report none of the intermediate transcripts ever touch
27
- your conversation.
25
+ Fan out one subagent per item, route on results, retry the flaky ones, pause for
26
+ human approval, cap the spend, and gate the output with an adversarial review
27
+ all from one declarative definition. Only the final report reaches your
28
+ conversation; every intermediate transcript stays in the runtime.
28
29
 
29
30
  ## Why
30
31
 
@@ -45,6 +46,11 @@ only the final phase's output.
45
46
  | Scale | a few tasks | dynamic `map` fan-out |
46
47
  | Resumable | no | yes (cross-session, cached phases skip) |
47
48
  | Quality gates | no | `gate` phases with `VERDICT: BLOCK / PASS` |
49
+ | Conditional routing | no | `when` guards + `join: any` OR-joins |
50
+ | Fault tolerance | no | per-phase `retry` with backoff |
51
+ | Human-in-the-loop | no | `approval` phases (approve / reject / edit) |
52
+ | Cost control | no | run-wide `budget` (USD / token caps) |
53
+ | Composition | no | `flow` phases run saved sub-flows |
48
54
  | Progress visibility | opaque while running | live DAG render with timing + cost |
49
55
  | Ergonomics | inline JSON each time | shorthand (`task`/`tasks`/`chain`) or DSL |
50
56
 
@@ -137,6 +143,36 @@ only the final report back.
137
143
 
138
144
  Save it once → `/tf:summarize-files` forever.
139
145
 
146
+ ### Route, gate, and guard
147
+
148
+ Phases also **branch, retry, pause for a human, and respect a budget** — still
149
+ declaratively, no scripting:
150
+
151
+ ```jsonc
152
+ {
153
+ "name": "triage-and-fix",
154
+ "budget": { "maxUSD": 1.5 },
155
+ "phases": [
156
+ { "id": "triage", "type": "agent", "agent": "analyst", "output": "json",
157
+ "task": "Classify the bug. Output ONLY {\"severity\":\"high\"} or {\"severity\":\"low\"}." },
158
+ { "id": "deep", "when": "{steps.triage.json.severity} == high", "dependsOn": ["triage"],
159
+ "agent": "executor_code", "task": "Root-cause and patch it.",
160
+ "retry": { "max": 2, "backoffMs": 500 } },
161
+ { "id": "quick", "when": "{steps.triage.json.severity} == low", "dependsOn": ["triage"],
162
+ "agent": "executor_fast", "task": "Apply the quick fix." },
163
+ { "id": "approve", "type": "approval", "join": "any", "dependsOn": ["deep", "quick"],
164
+ "task": "Review the fix before it ships." },
165
+ { "id": "ship", "type": "agent", "dependsOn": ["approve"],
166
+ "task": "Open a PR with the change.", "final": true }
167
+ ]
168
+ }
169
+ ```
170
+
171
+ - **`when`** routes to `deep` *or* `quick` from the triage JSON; the other branch is skipped.
172
+ - **`join: "any"`** lets `approve` run as soon as whichever branch fired completes.
173
+ - **`retry`** re-runs a flaky patch with backoff; **`budget`** halts the whole run if it gets too expensive.
174
+ - **`approval`** pauses for a human (approve / reject / edit) before the final `ship`.
175
+
140
176
  ## Watch it run
141
177
 
142
178
  This is the live progress render for a real run — the `self-improve` flow that
@@ -181,11 +217,28 @@ writes and verifies its own test suites, caught here mid-block by a quality gate
181
217
  | `approval` | **human-in-the-loop** pause — approve / reject / edit before continuing | — |
182
218
  | `flow` | run a **saved sub-flow** as one phase (composition/reuse) | `use` |
183
219
 
184
- Every phase needs `id`. Optional fields: `agent`, `dependsOn`, `output`,
185
- `model`, `thinking`, `tools`, `cwd`, `concurrency`, `final`, `optional`,
186
- `when` (conditional guard), `join` (`all`\|`any` dependency join), `retry`
187
- (`{max, backoffMs, factor}`), and `with` (args for a `flow` phase).
188
- Run-wide: `budget: {maxUSD, maxTokens}` halts the flow when exceeded.
220
+ ### Common phase fields
221
+
222
+ Every phase needs a unique `id` and a `type` (defaults to `agent`). On top of the
223
+ per-type fields above:
224
+
225
+ | Field | Meaning |
226
+ |---|---|
227
+ | `agent` | Agent to run (defaults to the first discovered agent) |
228
+ | `dependsOn` | Phase ids this phase waits for — builds the DAG |
229
+ | `join` | `"all"` (default) waits for every dep; `"any"` is an OR-join |
230
+ | `when` | Conditional guard — skip unless the expression is truthy |
231
+ | `retry` | `{ max, backoffMs?, factor? }` — retry a failing subagent |
232
+ | `output` | `"text"` (default) or `"json"` (exposes `{steps.ID.json}`) |
233
+ | `model` / `thinking` / `tools` | Per-phase overrides for the subagent |
234
+ | `cwd` | Working directory for the subagent |
235
+ | `concurrency` | Fan-out cap for `map` / `parallel` (overrides the flow default) |
236
+ | `final` | Marks the result-bearing phase (else the last phase wins) |
237
+ | `optional` | A failure here does **not** abort the run |
238
+ | `use` / `with` | (`flow`) saved sub-flow name + its args |
239
+
240
+ Flow-level keys: `name`, `description`, `args`, `concurrency` (default 8),
241
+ `agentScope`, and `budget: { maxUSD?, maxTokens? }`.
189
242
 
190
243
  ### Control flow & reliability
191
244
 
@@ -294,6 +347,20 @@ file). Phase-level overrides for `model`, `thinking`, and `tools` are passed as
294
347
  Settings from `~/.pi/agent/settings.json` (the `subagents.agentOverrides` map)
295
348
  are honored, letting you tweak model, thinking, or tools per agent across all flows.
296
349
 
350
+ ## Examples
351
+
352
+ Ready-to-read definitions live in [`examples/`](./examples):
353
+
354
+ | File | Demonstrates |
355
+ |---|---|
356
+ | [`summarize-files.json`](./examples/summarize-files.json) | discover → `map` fan-out → `reduce` |
357
+ | [`conditional-research.json`](./examples/conditional-research.json) | `when` routing + `join: any` + `gate` + `budget` |
358
+ | [`guarded-refactor.json`](./examples/guarded-refactor.json) | `approval` (human-in-the-loop) + `retry` + `gate` |
359
+
360
+ To use one, copy it into `.pi/taskflows/<name>.json` (or
361
+ `~/.pi/agent/taskflows/`) and it registers as `/tf:<name>` — or just point the
362
+ model at the definition.
363
+
297
364
  ## Status & limits
298
365
 
299
366
  - **v0.0.6** — control flow & reliability: conditional `when` guards, `join: any`
@@ -327,13 +394,10 @@ are honored, letting you tweak model, thinking, or tools per agent across all fl
327
394
  ```bash
328
395
  npm install
329
396
  npm run typecheck
330
- node --experimental-strip-types --test test/interpolate.test.ts \
331
- test/condition.test.ts test/schema.test.ts test/usage.test.ts \
332
- test/runtime.test.ts test/features.test.ts test/runner.test.ts \
333
- test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts
397
+ npm test # unit tests — no network, no process spawning
334
398
 
335
399
  # real end-to-end (spawns live subagents; needs model access)
336
- PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts
400
+ npm run test:e2e
337
401
  ```
338
402
 
339
403
  ## Contributing
@@ -47,7 +47,7 @@
47
47
  "id": "report",
48
48
  "type": "reduce",
49
49
  "from": ["review"],
50
- "dependsOn": ["review"],
50
+ "dependsOn": ["review", "deep", "quick"],
51
51
  "agent": "doc-writer",
52
52
  "task": "Write a clean markdown brief on \"{args.topic}\" from the validated research:\n\n{steps.deep.output}{steps.quick.output}",
53
53
  "final": true
@@ -26,7 +26,7 @@
26
26
  "id": "implement",
27
27
  "type": "agent",
28
28
  "agent": "executor_code",
29
- "dependsOn": ["approve"],
29
+ "dependsOn": ["approve", "plan"],
30
30
  "task": "Implement the approved plan for {args.target}.\nPlan:\n{steps.plan.output}\nExtra human guidance (if any):\n{steps.approve.output}",
31
31
  "retry": { "max": 1, "backoffMs": 1000 }
32
32
  },
@@ -41,7 +41,7 @@
41
41
  "id": "summary",
42
42
  "type": "reduce",
43
43
  "from": ["review"],
44
- "dependsOn": ["review"],
44
+ "dependsOn": ["review", "implement"],
45
45
  "agent": "doc-writer",
46
46
  "task": "Write a short changelog entry summarizing what was done:\n\n{steps.implement.output}",
47
47
  "final": true
@@ -44,42 +44,56 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
44
44
  }
45
45
 
46
46
  for (const entry of entries) {
47
- if (!entry.name.endsWith(".md")) continue;
48
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
49
-
50
- const filePath = path.join(dir, entry.name);
51
- let content: string;
52
47
  try {
53
- content = fs.readFileSync(filePath, "utf-8");
54
- } catch {
55
- continue;
56
- }
48
+ if (!entry.name.endsWith(".md")) continue;
49
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
57
50
 
58
- const { frontmatter, body } = (() => {
51
+ const filePath = path.join(dir, entry.name);
52
+ let content: string;
59
53
  try {
60
- return parseFrontmatter<Record<string, string>>(content);
54
+ content = fs.readFileSync(filePath, "utf-8");
61
55
  } catch {
62
- // A single malformed agent file must not break discovery for every flow.
63
- return { frontmatter: {} as Record<string, string>, body: "" };
56
+ continue;
64
57
  }
65
- })();
66
- if (!frontmatter.name || !frontmatter.description) continue;
67
-
68
- const tools = frontmatter.tools
69
- ?.split(",")
70
- .map((t) => t.trim())
71
- .filter(Boolean);
72
-
73
- agents.push({
74
- name: frontmatter.name,
75
- description: frontmatter.description,
76
- tools: tools && tools.length > 0 ? tools : undefined,
77
- model: frontmatter.model,
78
- thinking: frontmatter.thinking,
79
- systemPrompt: body,
80
- source,
81
- filePath,
82
- });
58
+
59
+ const { frontmatter, body } = (() => {
60
+ try {
61
+ return parseFrontmatter<Record<string, unknown>>(content);
62
+ } catch {
63
+ // A single malformed agent file must not break discovery for every flow.
64
+ return { frontmatter: {} as Record<string, unknown>, body: "" };
65
+ }
66
+ })();
67
+ if (!frontmatter.name || !frontmatter.description) continue;
68
+
69
+ // frontmatter is YAML-parsed: tools may be a comma-separated string ("a, b")
70
+ // OR a YAML sequence ([a, b]). Handle both forms.
71
+ const rawTools = frontmatter.tools;
72
+ const tools: string[] | undefined = Array.isArray(rawTools)
73
+ ? rawTools.map((t) => String(t).trim()).filter(Boolean)
74
+ : rawTools !== undefined && rawTools !== null
75
+ ? String(rawTools)
76
+ .split(",")
77
+ .map((t) => t.trim())
78
+ .filter(Boolean)
79
+ : undefined;
80
+
81
+ agents.push({
82
+ name: String(frontmatter.name),
83
+ description: String(frontmatter.description),
84
+ tools: tools && tools.length > 0 ? tools : undefined,
85
+ model: frontmatter.model === undefined ? undefined : String(frontmatter.model),
86
+ thinking: frontmatter.thinking === undefined ? undefined : String(frontmatter.thinking),
87
+ systemPrompt: body,
88
+ source,
89
+ filePath,
90
+ });
91
+ } catch {
92
+ // Defense-in-depth: a single bad agent file must not break discovery
93
+ // for the entire flow (e.g. exotic YAML shapes, runtime errors in
94
+ // field access, symlink races, etc.).
95
+ continue;
96
+ }
83
97
  }
84
98
  return agents;
85
99
  }
@@ -128,9 +142,15 @@ export function discoverAgents(
128
142
  for (const [name, override] of Object.entries(overrides)) {
129
143
  const agent = agentMap.get(name);
130
144
  if (agent) {
131
- if (override.model !== undefined) agent.model = override.model;
132
- if (override.thinking !== undefined) agent.thinking = override.thinking;
133
- if (override.tools !== undefined) agent.tools = override.tools;
145
+ // Clone before mutating: agentMap owns the original AgentConfig
146
+ // (loaded from disk in loadAgentsFromDir). Mutating it in place
147
+ // would cause cross-contamination for any caller that retains a
148
+ // reference and invokes discoverAgents again with different overrides.
149
+ const mutated: AgentConfig = { ...agent };
150
+ if (override.model !== undefined) mutated.model = override.model;
151
+ if (override.thinking !== undefined) mutated.thinking = override.thinking;
152
+ if (override.tools !== undefined) mutated.tools = override.tools;
153
+ agentMap.set(name, mutated);
134
154
  }
135
155
  }
136
156
  }
@@ -108,10 +108,6 @@ async function runFlow(
108
108
  onUpdate: ((p: AgentToolResult<TaskflowDetails>) => void) | undefined,
109
109
  existing?: RunState,
110
110
  ): Promise<RuntimeResult> {
111
- const settings = readSubagentSettings();
112
- const scope: AgentScope = def.agentScope ?? "user";
113
- const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
114
-
115
111
  const state = existing ?? makeRunState(def, args, ctx.cwd);
116
112
 
117
113
  const emit = (s: RunState, finalOutput?: string) => {
@@ -166,6 +162,13 @@ async function runFlow(
166
162
  : undefined;
167
163
 
168
164
  try {
165
+ // Discover settings/agents inside try so a YAML/IO crash in
166
+ // discoverAgents or readSubagentSettings (F-001) is caught and
167
+ // the heartbeat timer is cleared by the finally block below.
168
+ const settings = readSubagentSettings();
169
+ const scope: AgentScope = def.agentScope ?? "user";
170
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
171
+
169
172
  const result = await executeTaskflow(state, {
170
173
  cwd: ctx.cwd,
171
174
  agents,
@@ -301,19 +304,28 @@ export default function (pi: ExtensionAPI) {
301
304
  );
302
305
  },
303
306
  });
307
+ const warningText = v.warnings.length ? `\n\nWarnings:\n- ${v.warnings.join("\n- ")}` : "";
304
308
  return {
305
309
  content: [
306
- { type: "text", text: `Saved taskflow '${def.name}' → ${filePath}\nRun it with /tf:${def.name} or action=run.` },
310
+ { type: "text", text: `Saved taskflow '${def.name}' → ${filePath}\nRun it with /tf:${def.name} or action=run.${warningText}` },
307
311
  ],
308
312
  details: { action, message: filePath } satisfies TaskflowDetails,
309
313
  };
310
314
  }
311
315
 
312
316
  // run
313
- const v = validateTaskflow(def);
314
- if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
315
317
  const args = resolveArgs(def, params.args);
318
+ const v = validateTaskflow(def, { args, cwd: ctx.cwd });
319
+ if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
320
+ for (const w of v.warnings) {
321
+ console.warn(`[taskflow:${def.name}] ${w}`);
322
+ }
316
323
  const result = await runFlow(def, args, ctx, signal, onUpdate as any);
324
+ // Surface the validation warnings in the tool result so the model
325
+ // can acknowledge or fix them, and the user sees them in the chat.
326
+ if (v.warnings.length) {
327
+ result.finalOutput = `${result.finalOutput}\n\nWarnings:\n- ${v.warnings.join("\n- ")}`;
328
+ }
317
329
  return finalResult(action, result);
318
330
  },
319
331
 
@@ -20,17 +20,20 @@ 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;
27
27
  missing: string[];
28
28
  }
29
29
 
30
- export function interpolate(template: string, ctx: InterpolationContext): InterpolationResult {
30
+ export function interpolate(
31
+ template: string | null | undefined,
32
+ ctx: InterpolationContext,
33
+ ): InterpolationResult {
31
34
  const missing: string[] = [];
32
35
 
33
- const text = template.replace(PLACEHOLDER, (whole, path: string) => {
36
+ const text = String(template ?? "").replace(PLACEHOLDER, (whole, path: string) => {
34
37
  const value = resolvePath(path, ctx);
35
38
  if (value === undefined) {
36
39
  missing.push(path);
@@ -134,6 +137,24 @@ export function safeParse(text: string): unknown {
134
137
  }
135
138
  }
136
139
  }
140
+ // Anti-pattern detection (v0.0.8.1): array followed by a stray top-level
141
+ // "key": value. A common LLM mistake — the model appends
142
+ // `"deferred": [...]` after a JSON array, producing a non-JSON hybrid that
143
+ // none of the above strategies can recover. We surface a diagnostic hint
144
+ // so flow authors can spot the bug fast.
145
+ //
146
+ // We check the original (trimmed) input rather than the slice tail,
147
+ // because `lastIndexOf(close)` lands on the *last* bracket — for the
148
+ // anti-pattern the stray key is between the array's `]` and the trailing
149
+ // `]`, not after the last one.
150
+ if (/]\s*[\},]?\s*"[^"\n]+"\s*:/.test(trimmed)) {
151
+ console.warn(
152
+ "[pi-taskflow safeParse] input looks like a JSON array followed by a stray top-level key " +
153
+ `(pattern: [{...}], "key": ...). This is not valid JSON. ` +
154
+ `Hint: put extra data as array members (e.g. {"id":"D-001","status":"deferred",...}) ` +
155
+ `or split into a separate phase.`,
156
+ );
157
+ }
137
158
  return undefined;
138
159
  }
139
160
 
@@ -142,7 +163,7 @@ export function coerceArray(value: unknown): unknown[] | null {
142
163
  if (Array.isArray(value)) return value;
143
164
  if (value && typeof value === "object") {
144
165
  // {items: [...]} or {results: [...]} convenience
145
- for (const key of ["items", "results", "list", "data"]) {
166
+ for (const key of ["items", "results", "list", "data", "findings"]) {
146
167
  const v = (value as Record<string, unknown>)[key];
147
168
  if (Array.isArray(v)) return v;
148
169
  }
@@ -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 "./usage.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,19 @@ 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
+ const c = usage.cost;
75
+ return c >= 0.01
76
+ ? theme.fg("muted", `$${c.toFixed(2)}`)
77
+ : theme.fg("muted", `$${c.toFixed(4)}`);
82
78
  }
83
79
 
84
80
  function aggregateCost(state: RunState): number {
@@ -118,7 +114,7 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
118
114
  if (ps.status === "skipped") {
119
115
  const reason = (ps.error ?? "upstream failed").replace(/\s+/g, " ");
120
116
  const snip = reason.length > 52 ? `${reason.slice(0, 52)}…` : reason;
121
- return theme.fg("muted", `skipped · ${snip}`);
117
+ return theme.fg("muted", `skipped · ${snip}`) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
122
118
  }
123
119
 
124
120
  const isFanout = type === "map" || type === "parallel" || type === "flow";
@@ -131,30 +127,34 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
131
127
  return (
132
128
  theme.fg("toolOutput", `${done - failed}/${total}`) +
133
129
  theme.fg("error", ` ${failed}✗`) +
134
- (snip ? theme.fg("error", ` ${snip}`) : "")
130
+ (snip ? theme.fg("error", ` ${snip}`) : "") +
131
+ (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "")
135
132
  );
136
133
  }
137
- return theme.fg("error", snip);
134
+ return theme.fg("error", snip) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
138
135
  }
139
136
 
140
137
  const t = phaseElapsed(ps);
141
138
  const time = t ? theme.fg("dim", elapsed(t)) : "";
142
139
 
143
140
  if (ps.status === "running") {
144
- const model = shortModel(ps.model);
145
- const tokens = liveUsageStr(ps.usage, theme);
141
+ const roleLabel = agentRole(phase, ps, theme);
142
+ const cost = costStr(ps.usage, theme);
146
143
  if (isFanout && ps.subProgress) {
147
144
  const { done, total, running, failed } = ps.subProgress;
148
145
  let s = `${miniBar(done, total, theme)} ${theme.fg("toolOutput", `${done}/${total}`)}`;
149
146
  if (running) s += theme.fg("dim", ` · ${running} run`);
150
147
  if (failed) s += theme.fg("error", ` · ${failed}✗`);
151
- if (tokens) s += ` ${tokens}`;
148
+ s += ` ${roleLabel}`;
149
+ if (cost) s += ` ${cost}`;
152
150
  if (time) s += ` ${time}`;
151
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
153
152
  return s;
154
153
  }
155
- let s = model ? theme.fg("accent", model) : theme.fg("warning", "running…");
156
- if (tokens) s += ` ${tokens}`;
154
+ let s = roleLabel;
155
+ if (cost) s += ` ${cost}`;
157
156
  if (time) s += ` ${time}`;
157
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
158
158
  return s;
159
159
  }
160
160
 
@@ -163,20 +163,23 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
163
163
  const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
164
164
  let s = theme.fg("success", `${total}✓`);
165
165
  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}`;
166
+ const cost = costStr(ps.usage, theme);
167
+ if (cost) s += ` ${cost}`;
168
168
  if (time) s += ` ${time}`;
169
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
169
170
  return s;
170
171
  }
171
172
  // single-agent done
172
- const model = shortModel(ps.model);
173
- const u = compactUsage(ps.usage, theme);
173
+ const roleLabel = agentRole(phase, ps, theme);
174
+ const cost = costStr(ps.usage, theme);
174
175
  if (ps.approval) {
175
176
  const d = ps.approval.decision;
176
177
  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
+ let a = theme.fg("warning", "⚠") + " " + theme.fg(color as Parameters<typeof theme.fg>[0], theme.bold(d.toUpperCase()));
178
179
  if (ps.approval.auto) a += theme.fg("dim", " auto");
180
+ if (cost) a += ` ${cost}`;
179
181
  if (time) a += ` ${time}`;
182
+ if (ps.warnings?.length) a += theme.fg("warning", ` ⚠${ps.warnings.length}`);
180
183
  return a;
181
184
  }
182
185
  if (ps.gate) {
@@ -187,16 +190,18 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
187
190
  const r = ps.gate.reason.replace(/\s+/g, " ");
188
191
  g += theme.fg("dim", ` ${r.length > 44 ? `${r.slice(0, 44)}…` : r}`);
189
192
  }
190
- if (model) g += ` ${theme.fg("dim", model)}`;
193
+ const cost = costStr(ps.usage, theme);
194
+ if (cost) g += ` ${cost}`;
191
195
  if (time) g += ` ${time}`;
196
+ if (ps.warnings?.length) g += theme.fg("warning", ` ⚠${ps.warnings.length}`);
192
197
  return g;
193
198
  }
194
- let s = "";
195
- if (model) s += theme.fg("accent", model);
196
- if (u) s += (s ? " " : "") + u;
199
+ let s = roleLabel;
200
+ if (cost) s += ` ${cost}`;
197
201
  if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
198
202
  if (time) s += ` ${time}`;
199
- return s || theme.fg("dim", "done");
203
+ if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
204
+ return s;
200
205
  }
201
206
 
202
207
  /** Header line: status glyph + name + compact totals. */
@@ -227,8 +232,8 @@ function headerLine(state: RunState, theme: Theme): string {
227
232
  if (state.status === "blocked") line += theme.fg("error", " · blocked");
228
233
  const cost = aggregateCost(state);
229
234
  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)}`);
235
+ if (budget?.maxUSD !== undefined) line += theme.fg("muted", ` · $${cost >= 0.01 ? cost.toFixed(2) : cost.toFixed(4)}/$${budget.maxUSD}`);
236
+ else if (cost) line += theme.fg("muted", ` · $${cost >= 0.01 ? cost.toFixed(2) : cost.toFixed(4)}`);
232
237
  const el = runElapsed(state);
233
238
  if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
234
239
  return line;