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 +77 -13
- package/examples/conditional-research.json +1 -1
- package/examples/guarded-refactor.json +2 -2
- package/extensions/agents.ts +54 -34
- package/extensions/index.ts +19 -7
- package/extensions/interpolate.ts +25 -4
- package/extensions/render.ts +41 -36
- package/extensions/runner.ts +97 -15
- package/extensions/runs-view.ts +3 -0
- package/extensions/runtime.ts +216 -28
- package/extensions/schema.ts +151 -5
- package/extensions/store.ts +77 -7
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +112 -1
- package/skills/taskflow/configuration.md +0 -2
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,
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
`
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/extensions/agents.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
48
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
49
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
57
50
|
|
|
58
|
-
|
|
51
|
+
const filePath = path.join(dir, entry.name);
|
|
52
|
+
let content: string;
|
|
59
53
|
try {
|
|
60
|
-
|
|
54
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
61
55
|
} catch {
|
|
62
|
-
|
|
63
|
-
return { frontmatter: {} as Record<string, string>, body: "" };
|
|
56
|
+
continue;
|
|
64
57
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
name
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/extensions/render.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
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
|
|
76
|
-
if (!usage) return "";
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
145
|
-
const
|
|
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
|
-
|
|
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 =
|
|
156
|
-
if (
|
|
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
|
|
167
|
-
if (
|
|
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
|
|
173
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
231
|
-
else if (cost) line += theme.fg("muted", ` · $${cost.toFixed(
|
|
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;
|