pi-taskflow 0.0.1 → 0.0.3
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/DESIGN.md +6 -6
- package/extensions/index.ts +59 -15
- package/extensions/render.ts +235 -35
- package/extensions/runner.ts +52 -3
- package/extensions/runs-view.ts +141 -0
- package/extensions/runtime.ts +116 -13
- package/extensions/store.ts +7 -1
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +22 -2
package/DESIGN.md
CHANGED
|
@@ -288,12 +288,12 @@ export async function runTaskflow(def, args, ctx): Promise<TaskflowResult>
|
|
|
288
288
|
|
|
289
289
|
## 5. 路线图
|
|
290
290
|
|
|
291
|
-
| 版本 | 范围 |
|
|
292
|
-
|
|
293
|
-
| **v0.1** | DSL + schema + runtime(agent/parallel/map/reduce)+ `taskflow` 工具 + `/tf run` + 内存隔离 + 流式进度 |
|
|
294
|
-
| **v0.2** | 保存/动态命令注册 + 跨 session 恢复 + `gate`
|
|
295
|
-
| **v0.3** | examples + SKILL.md(教 LLM 写定义)+ YAML 支持 + 发布 npm |
|
|
296
|
-
| **v0.4** | 真·后台执行(detached + 轮询)+ 成本预估/上限 + 内置 `deep-research` 工作流 |
|
|
291
|
+
| 版本 | 范围 | 状态 |
|
|
292
|
+
|------|------|------|
|
|
293
|
+
| **v0.1** | DSL + schema + runtime(agent/parallel/map/reduce)+ `taskflow` 工具 + `/tf run` + 内存隔离 + 流式进度 | ✅ 已发布 (npm 0.0.1) |
|
|
294
|
+
| **v0.2** | 保存/动态命令注册 + 跨 session 恢复 + `gate` 真门控 + run 历史交互 TUI | ✅ 已完成 (npm 0.0.3) |
|
|
295
|
+
| **v0.3** | examples + SKILL.md(教 LLM 写定义)+ YAML 支持 + 发布 npm | 🚧 examples/SKILL/npm 已做;YAML 待办 |
|
|
296
|
+
| **v0.4** | 真·后台执行(detached + 轮询)+ 成本预估/上限 + 内置 `deep-research` 工作流 | ⏳ 待办 |
|
|
297
297
|
|
|
298
298
|
---
|
|
299
299
|
|
package/extensions/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
19
19
|
import { renderRunResult, summarizeRun } from "./render.ts";
|
|
20
|
+
import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
|
|
20
21
|
import { executeTaskflow, type RuntimeResult } from "./runtime.ts";
|
|
21
22
|
import { finalPhase, type Taskflow, validateTaskflow } from "./schema.ts";
|
|
22
23
|
import {
|
|
@@ -105,14 +106,41 @@ async function runFlow(
|
|
|
105
106
|
});
|
|
106
107
|
};
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
// Throttled persistence: avoid disk writes on every sub-item event.
|
|
110
|
+
let lastPersist = 0;
|
|
111
|
+
const persistThrottled = (s: RunState) => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
if (now - lastPersist >= 1000) {
|
|
114
|
+
lastPersist = now;
|
|
115
|
+
saveRun(s);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ~8fps heartbeat drives all rendering: it naturally caps the frame rate
|
|
120
|
+
// (no event bursts) while keeping the spinner, elapsed timers, live tokens
|
|
121
|
+
// and the latest message current. Phase events only mutate `state`.
|
|
122
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
123
|
+
if (onUpdate) {
|
|
124
|
+
heartbeat = setInterval(() => {
|
|
125
|
+
if (state.status === "running") emit(state);
|
|
126
|
+
}, 120);
|
|
127
|
+
(heartbeat as { unref?: () => void }).unref?.();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = await executeTaskflow(state, {
|
|
132
|
+
cwd: ctx.cwd,
|
|
133
|
+
agents,
|
|
134
|
+
globalThinking: settings.globalThinking,
|
|
135
|
+
signal,
|
|
136
|
+
persist: persistThrottled,
|
|
137
|
+
});
|
|
138
|
+
return result;
|
|
139
|
+
} finally {
|
|
140
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
141
|
+
saveRun(state); // force-persist terminal state
|
|
142
|
+
emit(state); // final render reflecting terminal state
|
|
143
|
+
}
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
export default function (pi: ExtensionAPI) {
|
|
@@ -152,9 +180,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
152
180
|
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
153
181
|
].join(" "),
|
|
154
182
|
parameters: TaskflowParams,
|
|
155
|
-
promptSnippet: "
|
|
183
|
+
promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
|
|
156
184
|
promptGuidelines: [
|
|
157
|
-
"
|
|
185
|
+
"Prefer taskflow whenever a request spans a whole project/codebase or many items — e.g. 'explore / 探索 / 审计 / analyze the project', auditing endpoints, reviewing or migrating many files/modules, or cross-checked research. It fans out to many subagents across phases and aggregates the result, keeping intermediate work out of your context.",
|
|
186
|
+
"Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover → work → review → report), needs dynamic fan-out over a discovered list, or should be saved and rerun. Use the plain subagent tool only for a single delegated task.",
|
|
158
187
|
"For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
|
|
159
188
|
],
|
|
160
189
|
|
|
@@ -278,15 +307,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
307
|
}
|
|
279
308
|
|
|
280
309
|
if (sub === "runs") {
|
|
281
|
-
const runs = listRuns(ctx.cwd);
|
|
310
|
+
const runs = listRuns(ctx.cwd, 50);
|
|
282
311
|
if (runs.length === 0) {
|
|
283
312
|
ctx.ui.notify("No taskflow runs yet.", "info");
|
|
284
313
|
return;
|
|
285
314
|
}
|
|
286
|
-
ctx.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
315
|
+
if (!ctx.hasUI) {
|
|
316
|
+
ctx.ui.notify(
|
|
317
|
+
runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
|
|
318
|
+
"info",
|
|
319
|
+
);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const result = await ctx.ui.custom<RunHistoryResult | undefined>((_tui, theme, _kb, done) => {
|
|
323
|
+
return new RunHistoryComponent(runs, theme, (r) => done(r));
|
|
324
|
+
});
|
|
325
|
+
if (result?.action === "resume") {
|
|
326
|
+
if (ctx.isIdle()) {
|
|
327
|
+
pi.sendUserMessage(
|
|
328
|
+
`Resume the taskflow run "${result.runId}" using the taskflow tool with action="resume", runId="${result.runId}".`,
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
ctx.ui.notify("Agent is busy; try /tf resume when idle.", "warning");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
290
334
|
return;
|
|
291
335
|
}
|
|
292
336
|
|
package/extensions/render.ts
CHANGED
|
@@ -1,22 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TUI rendering for the taskflow tool and commands.
|
|
3
|
+
*
|
|
4
|
+
* Design goals: high information density, column alignment, and width-safe
|
|
5
|
+
* single-cell status glyphs (no double-width emoji that break alignment).
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
6
9
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
7
|
-
import {
|
|
10
|
+
import { formatTokens, type UsageStats } from "./runner.ts";
|
|
8
11
|
import type { PhaseState, RunState } from "./store.ts";
|
|
12
|
+
import type { Phase } from "./schema.ts";
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
failed:
|
|
15
|
-
skipped:
|
|
14
|
+
// Single-width glyphs (Geometric Shapes / check marks) — keep columns aligned.
|
|
15
|
+
const ICON: Record<PhaseState["status"], { ch: string; color: string }> = {
|
|
16
|
+
done: { ch: "✓", color: "success" },
|
|
17
|
+
running: { ch: "◐", color: "warning" },
|
|
18
|
+
failed: { ch: "✗", color: "error" },
|
|
19
|
+
skipped: { ch: "⊘", color: "muted" },
|
|
20
|
+
pending: { ch: "○", color: "dim" },
|
|
16
21
|
};
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
function icon(status: PhaseState["status"], theme: Theme): string {
|
|
24
|
+
if (status === "running") return theme.fg("warning", spinnerFrame());
|
|
25
|
+
const i = ICON[status] ?? ICON.pending;
|
|
26
|
+
return theme.fg(i.color as any, i.ch);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shortModel(model?: string): string {
|
|
30
|
+
if (!model) return "";
|
|
31
|
+
return model.split("/").pop() ?? model;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Braille dots spinner (ora classic) — smooth, clockwise, single-width.
|
|
35
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
36
|
+
function spinnerFrame(): string {
|
|
37
|
+
return SPINNER[Math.floor(Date.now() / 120) % SPINNER.length];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Elapsed as 5s / 3m30s / 1h05m
|
|
41
|
+
function elapsed(ms: number): string {
|
|
42
|
+
const s = Math.floor(ms / 1000);
|
|
43
|
+
if (s < 60) return `${s}s`;
|
|
44
|
+
if (s < 3600) {
|
|
45
|
+
const m = Math.floor(s / 60);
|
|
46
|
+
const ss = s % 60;
|
|
47
|
+
return `${m}m${ss.toString().padStart(2, "0")}s`;
|
|
48
|
+
}
|
|
49
|
+
const h = Math.floor(s / 3600);
|
|
50
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
51
|
+
return `${h}h${mm.toString().padStart(2, "0")}m`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function phaseElapsed(ps: PhaseState): number {
|
|
55
|
+
if (!ps.startedAt) return 0;
|
|
56
|
+
return (ps.endedAt ?? Date.now()) - ps.startedAt;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function miniBar(done: number, total: number, theme: Theme, width = 8): string {
|
|
60
|
+
if (total <= 0) return "";
|
|
61
|
+
const filled = Math.max(0, Math.min(width, Math.round((done / total) * width)));
|
|
62
|
+
return theme.fg("accent", "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
|
|
63
|
+
}
|
|
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(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
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(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function aggregateCost(state: RunState): number {
|
|
85
|
+
let c = 0;
|
|
86
|
+
for (const p of Object.values(state.phases)) c += p.usage?.cost ?? 0;
|
|
87
|
+
return c;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runElapsed(state: RunState): number {
|
|
91
|
+
const starts = Object.values(state.phases)
|
|
92
|
+
.map((p) => p.startedAt)
|
|
93
|
+
.filter((x): x is number => !!x);
|
|
94
|
+
if (starts.length === 0) return 0;
|
|
95
|
+
const min = Math.min(...starts);
|
|
96
|
+
const ends = Object.values(state.phases).map((p) => p.endedAt ?? Date.now());
|
|
97
|
+
const max = ends.length ? Math.max(...ends) : Date.now();
|
|
98
|
+
return max - min;
|
|
20
99
|
}
|
|
21
100
|
|
|
22
101
|
export function summarizeRun(state: RunState): string {
|
|
@@ -31,39 +110,160 @@ export function summarizeRun(state: RunState): string {
|
|
|
31
110
|
return bits.join(", ");
|
|
32
111
|
}
|
|
33
112
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
113
|
+
/** Build the detail column for a phase (the right-hand info). */
|
|
114
|
+
function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): string {
|
|
115
|
+
const type = phase.type ?? "agent";
|
|
116
|
+
if (!ps || ps.status === "pending") return theme.fg("dim", "—");
|
|
117
|
+
|
|
118
|
+
if (ps.status === "skipped") {
|
|
119
|
+
const reason = (ps.error ?? "upstream failed").replace(/\s+/g, " ");
|
|
120
|
+
const snip = reason.length > 52 ? `${reason.slice(0, 52)}…` : reason;
|
|
121
|
+
return theme.fg("muted", `skipped · ${snip}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isFanout = type === "map" || type === "parallel";
|
|
125
|
+
|
|
126
|
+
if (ps.status === "failed") {
|
|
127
|
+
const e = (ps.error ?? "failed").replace(/\s+/g, " ");
|
|
128
|
+
const snip = e.length > 56 ? `${e.slice(0, 56)}…` : e;
|
|
129
|
+
if (isFanout && ps.subProgress) {
|
|
130
|
+
const { done, total, failed } = ps.subProgress;
|
|
131
|
+
return (
|
|
132
|
+
theme.fg("toolOutput", `${done - failed}/${total}`) +
|
|
133
|
+
theme.fg("error", ` ${failed}✗`) +
|
|
134
|
+
(snip ? theme.fg("error", ` ${snip}`) : "")
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return theme.fg("error", snip);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const t = phaseElapsed(ps);
|
|
141
|
+
const time = t ? theme.fg("dim", elapsed(t)) : "";
|
|
142
|
+
|
|
143
|
+
if (ps.status === "running") {
|
|
144
|
+
const model = shortModel(ps.model);
|
|
145
|
+
const tokens = liveUsageStr(ps.usage, theme);
|
|
146
|
+
if (isFanout && ps.subProgress) {
|
|
147
|
+
const { done, total, running, failed } = ps.subProgress;
|
|
148
|
+
let s = `${miniBar(done, total, theme)} ${theme.fg("toolOutput", `${done}/${total}`)}`;
|
|
149
|
+
if (running) s += theme.fg("dim", ` · ${running} run`);
|
|
150
|
+
if (failed) s += theme.fg("error", ` · ${failed}✗`);
|
|
151
|
+
if (tokens) s += ` ${tokens}`;
|
|
152
|
+
if (time) s += ` ${time}`;
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
let s = model ? theme.fg("accent", model) : theme.fg("warning", "running…");
|
|
156
|
+
if (tokens) s += ` ${tokens}`;
|
|
157
|
+
if (time) s += ` ${time}`;
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// done
|
|
162
|
+
if (isFanout) {
|
|
163
|
+
const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
|
|
164
|
+
let s = theme.fg("success", `${total}✓`);
|
|
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}`;
|
|
168
|
+
if (time) s += ` ${time}`;
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
// single-agent done
|
|
172
|
+
const model = shortModel(ps.model);
|
|
173
|
+
const u = compactUsage(ps.usage, theme);
|
|
174
|
+
if (ps.gate) {
|
|
175
|
+
const badge =
|
|
176
|
+
ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
|
|
177
|
+
let g = badge;
|
|
178
|
+
if (ps.gate.reason) {
|
|
179
|
+
const r = ps.gate.reason.replace(/\s+/g, " ");
|
|
180
|
+
g += theme.fg("dim", ` ${r.length > 44 ? `${r.slice(0, 44)}…` : r}`);
|
|
181
|
+
}
|
|
182
|
+
if (model) g += ` ${theme.fg("dim", model)}`;
|
|
183
|
+
if (time) g += ` ${time}`;
|
|
184
|
+
return g;
|
|
185
|
+
}
|
|
186
|
+
let s = "";
|
|
187
|
+
if (model) s += theme.fg("accent", model);
|
|
188
|
+
if (u) s += (s ? " " : "") + u;
|
|
189
|
+
if (time) s += ` ${time}`;
|
|
190
|
+
return s || theme.fg("dim", "done");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Header line: status glyph + name + compact totals. */
|
|
194
|
+
function headerLine(state: RunState, theme: Theme): string {
|
|
195
|
+
const phases = Object.values(state.phases);
|
|
196
|
+
const done = phases.filter((p) => p.status === "done").length;
|
|
197
|
+
const failed = phases.filter((p) => p.status === "failed").length;
|
|
198
|
+
const running = phases.filter((p) => p.status === "running").length;
|
|
199
|
+
const total = state.def.phases.length;
|
|
200
|
+
|
|
201
|
+
const head =
|
|
202
|
+
state.status === "completed"
|
|
203
|
+
? theme.fg("success", "✓")
|
|
204
|
+
: state.status === "failed"
|
|
205
|
+
? theme.fg("error", "✗")
|
|
206
|
+
: state.status === "blocked"
|
|
207
|
+
? theme.fg("error", "⊗")
|
|
208
|
+
: state.status === "paused"
|
|
209
|
+
? theme.fg("warning", "‖")
|
|
210
|
+
: theme.fg("warning", spinnerFrame());
|
|
211
|
+
|
|
212
|
+
let line =
|
|
213
|
+
`${head} ${theme.fg("toolTitle", theme.bold("taskflow"))} ` +
|
|
38
214
|
theme.fg("accent", state.flowName) +
|
|
39
|
-
theme.fg("muted", ` ${
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
215
|
+
theme.fg("muted", ` ${done}/${total}`);
|
|
216
|
+
if (running) line += theme.fg("warning", ` · ${running}▸`);
|
|
217
|
+
if (failed) line += theme.fg("error", ` · ${failed}✗`);
|
|
218
|
+
if (state.status === "blocked") line += theme.fg("error", " · blocked");
|
|
219
|
+
const cost = aggregateCost(state);
|
|
220
|
+
if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
|
|
221
|
+
const el = runElapsed(state);
|
|
222
|
+
if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
|
|
223
|
+
return line;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The full dense progress block (header + aligned phase rows). */
|
|
227
|
+
export function renderProgress(state: RunState, theme: Theme): string {
|
|
228
|
+
const phases = state.def.phases;
|
|
229
|
+
const idW = Math.max(...phases.map((p) => p.id.length), 2);
|
|
230
|
+
const typeW = Math.max(...phases.map((p) => (p.type ?? "agent").length), 4);
|
|
231
|
+
|
|
232
|
+
let text = headerLine(state, theme);
|
|
233
|
+
for (const phase of phases) {
|
|
234
|
+
const ps = state.phases[phase.id];
|
|
235
|
+
const status = ps?.status ?? "pending";
|
|
236
|
+
const id = phase.id.padEnd(idW);
|
|
237
|
+
const type = (phase.type ?? "agent").padEnd(typeW);
|
|
238
|
+
const detail = phaseDetail(phase, ps, theme);
|
|
239
|
+
text +=
|
|
240
|
+
`\n ${icon(status, theme)} ` +
|
|
241
|
+
theme.fg(status === "pending" ? "dim" : "text", id) +
|
|
242
|
+
" " +
|
|
243
|
+
theme.fg("dim", type) +
|
|
244
|
+
" " +
|
|
245
|
+
detail;
|
|
246
|
+
|
|
247
|
+
// Live activity sub-line (only while running, only if we have a message).
|
|
248
|
+
if (status === "running" && ps?.liveText) {
|
|
249
|
+
const indent = " ".repeat(2 + 2 + idW + 2);
|
|
250
|
+
const msg = ps.liveText.replace(/\s+/g, " ").trim();
|
|
251
|
+
const snip = msg.length > 88 ? `${msg.slice(0, 88)}…` : msg;
|
|
252
|
+
text += `\n${indent}${theme.fg("dim", "› ")}${theme.fg("muted", snip)}`;
|
|
51
253
|
}
|
|
52
|
-
text += line;
|
|
53
254
|
}
|
|
54
255
|
return text;
|
|
55
256
|
}
|
|
56
257
|
|
|
57
|
-
export function renderRunResult(
|
|
258
|
+
export function renderRunResult(
|
|
259
|
+
state: RunState,
|
|
260
|
+
finalOutput: string,
|
|
261
|
+
theme: Theme,
|
|
262
|
+
expanded: boolean,
|
|
263
|
+
): Container | Text {
|
|
58
264
|
if (!expanded) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
? theme.fg("success", "✓")
|
|
62
|
-
: state.status === "failed"
|
|
63
|
-
? theme.fg("error", "✗")
|
|
64
|
-
: theme.fg("warning", "⏸");
|
|
65
|
-
let text = `${icon} ${renderProgress(state, theme)}`;
|
|
66
|
-
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
265
|
+
let text = renderProgress(state, theme);
|
|
266
|
+
text += `\n ${theme.fg("dim", "Ctrl+O to expand")}`;
|
|
67
267
|
return new Text(text, 0, 0);
|
|
68
268
|
}
|
|
69
269
|
|
package/extensions/runner.ts
CHANGED
|
@@ -38,13 +38,21 @@ export interface RunResult {
|
|
|
38
38
|
errorMessage?: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface LiveUpdate {
|
|
42
|
+
/** Latest assistant text or tool activity (single-line, truncated upstream). */
|
|
43
|
+
text: string;
|
|
44
|
+
usage: UsageStats;
|
|
45
|
+
model?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
export interface RunOptions {
|
|
42
49
|
model?: string;
|
|
43
50
|
thinking?: string;
|
|
44
51
|
tools?: string[];
|
|
45
52
|
cwd?: string;
|
|
46
53
|
signal?: AbortSignal;
|
|
47
|
-
|
|
54
|
+
/** Fires on each assistant turn with the latest activity + accumulated usage. */
|
|
55
|
+
onLive?: (live: LiveUpdate) => void;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export function isFailed(r: RunResult): boolean {
|
|
@@ -63,6 +71,44 @@ function getFinalOutput(messages: Message[]): string {
|
|
|
63
71
|
return "";
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
/** One-line description of the most recent assistant activity (text or tool call). */
|
|
75
|
+
function describeActivity(msg: Message): string {
|
|
76
|
+
if (msg.role !== "assistant") return "";
|
|
77
|
+
let lastText = "";
|
|
78
|
+
let lastTool = "";
|
|
79
|
+
for (const part of (msg as any).content ?? []) {
|
|
80
|
+
if (part.type === "text" && part.text?.trim()) lastText = part.text.trim();
|
|
81
|
+
else if (part.type === "toolCall") lastTool = summarizeToolCall(part.name, part.arguments ?? {});
|
|
82
|
+
}
|
|
83
|
+
const chosen = lastText || lastTool;
|
|
84
|
+
return chosen.replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function summarizeToolCall(name: string, args: Record<string, unknown>): string {
|
|
88
|
+
const short = (p: unknown) => {
|
|
89
|
+
const s = String(p ?? "");
|
|
90
|
+
return s.length > 48 ? `${s.slice(0, 48)}…` : s;
|
|
91
|
+
};
|
|
92
|
+
switch (name) {
|
|
93
|
+
case "bash":
|
|
94
|
+
return `$ ${short(args.command)}`;
|
|
95
|
+
case "read":
|
|
96
|
+
return `read ${short(args.path ?? args.file_path)}`;
|
|
97
|
+
case "write":
|
|
98
|
+
return `write ${short(args.path ?? args.file_path)}`;
|
|
99
|
+
case "edit":
|
|
100
|
+
return `edit ${short(args.path ?? args.file_path)}`;
|
|
101
|
+
case "grep":
|
|
102
|
+
return `grep ${short(args.pattern)}`;
|
|
103
|
+
case "find":
|
|
104
|
+
return `find ${short(args.pattern)}`;
|
|
105
|
+
case "ls":
|
|
106
|
+
return `ls ${short(args.path)}`;
|
|
107
|
+
default:
|
|
108
|
+
return `${name}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
66
112
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
67
113
|
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-taskflow-"));
|
|
68
114
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -132,6 +178,7 @@ export async function runAgentTask(
|
|
|
132
178
|
let tmpPromptPath: string | null = null;
|
|
133
179
|
|
|
134
180
|
const messages: Message[] = [];
|
|
181
|
+
let lastActivity = "";
|
|
135
182
|
const result: RunResult = {
|
|
136
183
|
agent: agentName,
|
|
137
184
|
task,
|
|
@@ -186,8 +233,10 @@ export async function runAgentTask(
|
|
|
186
233
|
if (!result.model && (msg as any).model) result.model = (msg as any).model;
|
|
187
234
|
if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason;
|
|
188
235
|
if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage;
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
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 });
|
|
191
240
|
}
|
|
192
241
|
}
|
|
193
242
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive run-history view for `/tf runs` (ctx.ui.custom).
|
|
3
|
+
* List view: navigate runs; Enter → detail; r → resume; Esc/q → close.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { renderProgress, summarizeRun } from "./render.ts";
|
|
9
|
+
import type { RunState } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface RunHistoryResult {
|
|
12
|
+
action: "resume";
|
|
13
|
+
runId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function statusBadge(status: RunState["status"], theme: Theme): string {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case "completed":
|
|
19
|
+
return theme.fg("success", "✓ done");
|
|
20
|
+
case "failed":
|
|
21
|
+
return theme.fg("error", "✗ failed");
|
|
22
|
+
case "blocked":
|
|
23
|
+
return theme.fg("error", "⊗ blocked");
|
|
24
|
+
case "paused":
|
|
25
|
+
return theme.fg("warning", "‖ paused");
|
|
26
|
+
default:
|
|
27
|
+
return theme.fg("warning", "◐ running");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timeAgo(ts: number): string {
|
|
32
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
33
|
+
if (s < 60) return `${s}s ago`;
|
|
34
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
35
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
36
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isResumable(r: RunState): boolean {
|
|
40
|
+
return r.status === "paused" || r.status === "failed" || r.status === "blocked";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RunHistoryComponent {
|
|
44
|
+
private runs: RunState[];
|
|
45
|
+
private theme: Theme;
|
|
46
|
+
private onDone: (result?: RunHistoryResult) => void;
|
|
47
|
+
private selected = 0;
|
|
48
|
+
private mode: "list" | "detail" = "list";
|
|
49
|
+
private cachedWidth?: number;
|
|
50
|
+
private cachedLines?: string[];
|
|
51
|
+
|
|
52
|
+
constructor(runs: RunState[], theme: Theme, onDone: (result?: RunHistoryResult) => void) {
|
|
53
|
+
this.runs = runs;
|
|
54
|
+
this.theme = theme;
|
|
55
|
+
this.onDone = onDone;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
handleInput(data: string): void {
|
|
59
|
+
this.invalidate();
|
|
60
|
+
if (this.mode === "detail") {
|
|
61
|
+
if (matchesKey(data, "escape")) {
|
|
62
|
+
this.mode = "list";
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (data === "r" && isResumable(this.runs[this.selected])) {
|
|
66
|
+
this.onDone({ action: "resume", runId: this.runs[this.selected].runId });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// list mode
|
|
71
|
+
if (matchesKey(data, "escape") || data === "q" || matchesKey(data, "ctrl+c")) {
|
|
72
|
+
this.onDone();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (matchesKey(data, "up")) {
|
|
76
|
+
this.selected = (this.selected - 1 + this.runs.length) % this.runs.length;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (matchesKey(data, "down")) {
|
|
80
|
+
this.selected = (this.selected + 1) % this.runs.length;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (matchesKey(data, "return")) {
|
|
84
|
+
this.mode = "detail";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (data === "r" && isResumable(this.runs[this.selected])) {
|
|
88
|
+
this.onDone({ action: "resume", runId: this.runs[this.selected].runId });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
94
|
+
const th = this.theme;
|
|
95
|
+
const lines: string[] = [""];
|
|
96
|
+
|
|
97
|
+
if (this.mode === "detail") {
|
|
98
|
+
const run = this.runs[this.selected];
|
|
99
|
+
lines.push(truncateToWidth(` ${th.fg("accent", "Run ")}${th.fg("muted", run.runId)}`, width));
|
|
100
|
+
lines.push("");
|
|
101
|
+
for (const l of renderProgress(run, th).split("\n")) lines.push(truncateToWidth(l, width));
|
|
102
|
+
lines.push("");
|
|
103
|
+
const hint = isResumable(run) ? "Esc back · r resume" : "Esc back";
|
|
104
|
+
lines.push(truncateToWidth(` ${th.fg("dim", hint)}`, width));
|
|
105
|
+
lines.push("");
|
|
106
|
+
this.cachedWidth = width;
|
|
107
|
+
this.cachedLines = lines;
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// list mode
|
|
112
|
+
const header =
|
|
113
|
+
th.fg("borderMuted", "─".repeat(3)) +
|
|
114
|
+
th.fg("accent", " Taskflow runs ") +
|
|
115
|
+
th.fg("borderMuted", "─".repeat(Math.max(0, width - 18)));
|
|
116
|
+
lines.push(truncateToWidth(header, width));
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
this.runs.forEach((run, i) => {
|
|
120
|
+
const sel = i === this.selected;
|
|
121
|
+
const marker = sel ? th.fg("accent", "❯ ") : " ";
|
|
122
|
+
const badge = statusBadge(run.status, th);
|
|
123
|
+
const name = sel ? th.fg("text", run.flowName) : th.fg("muted", run.flowName);
|
|
124
|
+
const meta = th.fg("dim", `${summarizeRun(run)} · ${timeAgo(run.updatedAt)}`);
|
|
125
|
+
lines.push(truncateToWidth(` ${marker}${badge} ${name} ${meta}`, width));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}`, width));
|
|
130
|
+
lines.push("");
|
|
131
|
+
|
|
132
|
+
this.cachedWidth = width;
|
|
133
|
+
this.cachedLines = lines;
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
invalidate(): void {
|
|
138
|
+
this.cachedWidth = undefined;
|
|
139
|
+
this.cachedLines = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/extensions/runtime.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { AgentConfig } from "./agents.ts";
|
|
14
14
|
import { coerceArray, interpolate, type InterpolationContext, safeParse } from "./interpolate.ts";
|
|
15
|
-
import { aggregateUsage, emptyUsage, isFailed, mapWithConcurrencyLimit, runAgentTask, type RunResult, type UsageStats } from "./runner.ts";
|
|
15
|
+
import { aggregateUsage, emptyUsage, isFailed, type LiveUpdate, mapWithConcurrencyLimit, runAgentTask, type RunResult, type UsageStats } from "./runner.ts";
|
|
16
16
|
import { dependenciesOf, finalPhase, type Phase, type Taskflow, topoLayers } from "./schema.ts";
|
|
17
17
|
import { hashInput, type PhaseState, type RunState } from "./store.ts";
|
|
18
18
|
|
|
@@ -79,12 +79,14 @@ function mergePhaseState(
|
|
|
79
79
|
.map((r, i) => `### [${i + 1}/${results.length}] ${r.agent}${isFailed(r) ? " (failed)" : ""}\n\n${r.output}`)
|
|
80
80
|
.join("\n\n---\n\n");
|
|
81
81
|
const jsonArray = parseJson ? results.map((r) => safeParse(r.output) ?? r.output) : undefined;
|
|
82
|
+
const failedCount = results.filter(isFailed).length;
|
|
82
83
|
return {
|
|
83
84
|
id,
|
|
84
85
|
status: anyFailed ? "failed" : "done",
|
|
85
86
|
output: combinedText,
|
|
86
87
|
json: jsonArray,
|
|
87
88
|
usage,
|
|
89
|
+
subProgress: { done: results.length, total: results.length, running: 0, failed: failedCount },
|
|
88
90
|
error: anyFailed ? results.filter(isFailed).map((r) => `${r.agent}: ${r.errorMessage ?? r.stderr}`).join("; ") : undefined,
|
|
89
91
|
inputHash,
|
|
90
92
|
endedAt: Date.now(),
|
|
@@ -96,13 +98,14 @@ async function executePhase(
|
|
|
96
98
|
state: RunState,
|
|
97
99
|
deps: RuntimeDeps,
|
|
98
100
|
prior: PhaseState | undefined,
|
|
101
|
+
emitProgress: () => void,
|
|
99
102
|
): Promise<PhaseState> {
|
|
100
103
|
const type = phase.type ?? "agent";
|
|
101
104
|
const concurrency = phase.concurrency ?? state.def.concurrency ?? 8;
|
|
102
105
|
const previousOutput = lastCompletedOutput(state, phase);
|
|
103
106
|
const run = deps.runTask ?? runAgentTask;
|
|
104
107
|
|
|
105
|
-
const runOne = (agentName: string, task: string,
|
|
108
|
+
const runOne = (agentName: string, task: string, onLive?: (l: LiveUpdate) => void) =>
|
|
106
109
|
run(
|
|
107
110
|
deps.cwd,
|
|
108
111
|
deps.agents,
|
|
@@ -114,12 +117,51 @@ async function executePhase(
|
|
|
114
117
|
tools: phase.tools,
|
|
115
118
|
cwd: phase.cwd,
|
|
116
119
|
signal: deps.signal,
|
|
120
|
+
onLive,
|
|
117
121
|
},
|
|
118
122
|
deps.globalThinking,
|
|
119
123
|
);
|
|
120
124
|
|
|
121
125
|
const parseJson = phase.output === "json";
|
|
122
126
|
|
|
127
|
+
// Runs a list of sub-tasks with live fan-out progress + aggregate live usage/activity.
|
|
128
|
+
const runFanout = async (items: Array<{ agent: string; task: string }>): Promise<RunResult[]> => {
|
|
129
|
+
let done = 0;
|
|
130
|
+
let running = 0;
|
|
131
|
+
let failed = 0;
|
|
132
|
+
const total = items.length;
|
|
133
|
+
const live = state.phases[phase.id];
|
|
134
|
+
const liveUsages: UsageStats[] = items.map(() => emptyUsage());
|
|
135
|
+
let latestText = "";
|
|
136
|
+
let latestModel: string | undefined;
|
|
137
|
+
const refresh = () => {
|
|
138
|
+
if (live) {
|
|
139
|
+
live.subProgress = { done, total, running, failed };
|
|
140
|
+
live.usage = aggregateUsage(liveUsages);
|
|
141
|
+
live.liveText = latestText;
|
|
142
|
+
live.model = latestModel;
|
|
143
|
+
}
|
|
144
|
+
emitProgress();
|
|
145
|
+
};
|
|
146
|
+
refresh();
|
|
147
|
+
return mapWithConcurrencyLimit(items, concurrency, async (it, idx) => {
|
|
148
|
+
running++;
|
|
149
|
+
refresh();
|
|
150
|
+
const r = await runOne(it.agent, it.task, (l) => {
|
|
151
|
+
liveUsages[idx] = l.usage;
|
|
152
|
+
if (l.text) latestText = l.text;
|
|
153
|
+
if (l.model) latestModel = l.model;
|
|
154
|
+
refresh();
|
|
155
|
+
});
|
|
156
|
+
running--;
|
|
157
|
+
done++;
|
|
158
|
+
if (isFailed(r)) failed++;
|
|
159
|
+
liveUsages[idx] = r.usage;
|
|
160
|
+
refresh();
|
|
161
|
+
return r;
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
123
165
|
if (type === "agent" || type === "gate") {
|
|
124
166
|
const ctx = buildInterpolationContext(state, previousOutput);
|
|
125
167
|
const { text } = interpolate(phase.task ?? "", ctx);
|
|
@@ -127,8 +169,18 @@ async function executePhase(
|
|
|
127
169
|
const cached = cachedPhase(prior, inputHash);
|
|
128
170
|
if (cached) return cached;
|
|
129
171
|
|
|
130
|
-
const
|
|
131
|
-
|
|
172
|
+
const live = state.phases[phase.id];
|
|
173
|
+
const r = await runOne(phase.agent ?? defaultAgent(deps), text, (l) => {
|
|
174
|
+
if (live) {
|
|
175
|
+
live.liveText = l.text;
|
|
176
|
+
live.usage = l.usage;
|
|
177
|
+
live.model = l.model;
|
|
178
|
+
}
|
|
179
|
+
emitProgress();
|
|
180
|
+
});
|
|
181
|
+
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
182
|
+
if (type === "gate" && ps.status === "done") ps.gate = parseGateVerdict(r.output);
|
|
183
|
+
return ps;
|
|
132
184
|
}
|
|
133
185
|
|
|
134
186
|
if (type === "parallel") {
|
|
@@ -141,7 +193,7 @@ async function executePhase(
|
|
|
141
193
|
const cached = cachedPhase(prior, inputHash);
|
|
142
194
|
if (cached) return cached;
|
|
143
195
|
|
|
144
|
-
const results = await
|
|
196
|
+
const results = await runFanout(branches);
|
|
145
197
|
return mergePhaseState(phase.id, results, inputHash, parseJson);
|
|
146
198
|
}
|
|
147
199
|
|
|
@@ -172,7 +224,7 @@ async function executePhase(
|
|
|
172
224
|
const cached = cachedPhase(prior, inputHash);
|
|
173
225
|
if (cached) return cached;
|
|
174
226
|
|
|
175
|
-
const results = await
|
|
227
|
+
const results = await runFanout(tasks);
|
|
176
228
|
return mergePhaseState(phase.id, results, inputHash, parseJson);
|
|
177
229
|
}
|
|
178
230
|
|
|
@@ -184,7 +236,15 @@ async function executePhase(
|
|
|
184
236
|
const cached = cachedPhase(prior, inputHash);
|
|
185
237
|
if (cached) return cached;
|
|
186
238
|
|
|
187
|
-
const
|
|
239
|
+
const live = state.phases[phase.id];
|
|
240
|
+
const r = await runOne(phase.agent ?? defaultAgent(deps), text, (l) => {
|
|
241
|
+
if (live) {
|
|
242
|
+
live.liveText = l.text;
|
|
243
|
+
live.usage = l.usage;
|
|
244
|
+
live.model = l.model;
|
|
245
|
+
}
|
|
246
|
+
emitProgress();
|
|
247
|
+
});
|
|
188
248
|
return resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
189
249
|
}
|
|
190
250
|
|
|
@@ -227,6 +287,36 @@ function defaultAgent(deps: RuntimeDeps): string {
|
|
|
227
287
|
return deps.agents[0]?.name ?? "default";
|
|
228
288
|
}
|
|
229
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Parse a gate phase's output into a verdict. Blocks the flow only on an
|
|
292
|
+
* explicit negative signal; ambiguous output passes (fail-open).
|
|
293
|
+
* Accepts JSON ({continue|pass: bool} or {verdict: "..."}) or a text marker
|
|
294
|
+
* `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins).
|
|
295
|
+
*/
|
|
296
|
+
export function parseGateVerdict(output: string): { verdict: "pass" | "block"; reason?: string } {
|
|
297
|
+
const json = safeParse(output);
|
|
298
|
+
if (json && typeof json === "object") {
|
|
299
|
+
const o = json as Record<string, unknown>;
|
|
300
|
+
if (typeof o.continue === "boolean") return { verdict: o.continue ? "pass" : "block", reason: asReason(o.reason) };
|
|
301
|
+
if (typeof o.pass === "boolean") return { verdict: o.pass ? "pass" : "block", reason: asReason(o.reason) };
|
|
302
|
+
if (typeof o.verdict === "string") {
|
|
303
|
+
const block = /block|fail|stop|reject|halt|\bno\b/i.test(o.verdict);
|
|
304
|
+
return { verdict: block ? "block" : "pass", reason: asReason(o.reason) };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)];
|
|
308
|
+
if (matches.length) {
|
|
309
|
+
const v = matches[matches.length - 1][1].toUpperCase();
|
|
310
|
+
const pass = v === "PASS" || v === "OK";
|
|
311
|
+
return { verdict: pass ? "pass" : "block" };
|
|
312
|
+
}
|
|
313
|
+
return { verdict: "pass" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function asReason(v: unknown): string | undefined {
|
|
317
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
230
320
|
/**
|
|
231
321
|
* Execute a full taskflow. Mutates and persists `state` as it progresses.
|
|
232
322
|
*/
|
|
@@ -239,6 +329,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
239
329
|
deps.onProgress?.(state);
|
|
240
330
|
|
|
241
331
|
let aborted = false;
|
|
332
|
+
let gateBlocked = false;
|
|
333
|
+
let gateReason = "";
|
|
334
|
+
let gateOutput = "";
|
|
242
335
|
|
|
243
336
|
for (const layer of layers) {
|
|
244
337
|
if (deps.signal?.aborted) {
|
|
@@ -250,13 +343,13 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
250
343
|
await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
|
|
251
344
|
// Snapshot prior state BEFORE marking running, so resume cache checks work.
|
|
252
345
|
const prior = state.phases[phase.id];
|
|
253
|
-
// Skip if a dependency failed
|
|
346
|
+
// Skip if a dependency failed, or an upstream gate blocked the flow.
|
|
254
347
|
const failedDep = dependenciesOf(phase).some((d) => state.phases[d]?.status === "failed");
|
|
255
|
-
if (failedDep) {
|
|
348
|
+
if (gateBlocked || failedDep) {
|
|
256
349
|
state.phases[phase.id] = {
|
|
257
350
|
id: phase.id,
|
|
258
351
|
status: "skipped",
|
|
259
|
-
error: "Upstream dependency failed",
|
|
352
|
+
error: gateBlocked ? `Gate blocked${gateReason ? `: ${gateReason}` : ""}` : "Upstream dependency failed",
|
|
260
353
|
endedAt: Date.now(),
|
|
261
354
|
usage: emptyUsage(),
|
|
262
355
|
};
|
|
@@ -273,8 +366,13 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
273
366
|
};
|
|
274
367
|
deps.onProgress?.(state);
|
|
275
368
|
|
|
276
|
-
const ps = await executePhase(phase, state, deps, prior);
|
|
369
|
+
const ps = await executePhase(phase, state, deps, prior, () => deps.onProgress?.(state));
|
|
277
370
|
state.phases[phase.id] = ps;
|
|
371
|
+
if ((phase.type ?? "agent") === "gate" && ps.gate?.verdict === "block") {
|
|
372
|
+
gateBlocked = true;
|
|
373
|
+
gateReason = ps.gate.reason ?? "";
|
|
374
|
+
gateOutput = ps.output ?? "";
|
|
375
|
+
}
|
|
278
376
|
deps.persist?.(state);
|
|
279
377
|
deps.onProgress?.(state);
|
|
280
378
|
});
|
|
@@ -284,14 +382,19 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
284
382
|
const finalState = state.phases[fp.id];
|
|
285
383
|
const anyFailed = Object.values(state.phases).some((p) => p.status === "failed");
|
|
286
384
|
|
|
287
|
-
state.status = aborted ? "paused" : anyFailed ? "failed" : "completed";
|
|
385
|
+
state.status = aborted ? "paused" : gateBlocked ? "blocked" : anyFailed ? "failed" : "completed";
|
|
288
386
|
deps.persist?.(state);
|
|
289
387
|
deps.onProgress?.(state);
|
|
290
388
|
|
|
389
|
+
let finalOutput = finalState?.output ?? "(no output)";
|
|
390
|
+
if (gateBlocked && (!finalState || finalState.status === "skipped")) {
|
|
391
|
+
finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${gateOutput ? `\n\n${gateOutput}` : ""}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
291
394
|
const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
|
|
292
395
|
return {
|
|
293
396
|
state,
|
|
294
|
-
finalOutput
|
|
397
|
+
finalOutput,
|
|
295
398
|
ok: state.status === "completed",
|
|
296
399
|
totalUsage,
|
|
297
400
|
};
|
package/extensions/store.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface PhaseState {
|
|
|
33
33
|
inputHash?: string;
|
|
34
34
|
startedAt?: number;
|
|
35
35
|
endedAt?: number;
|
|
36
|
+
/** Live fan-out progress for map/parallel phases. */
|
|
37
|
+
subProgress?: { done: number; total: number; running: number; failed: number };
|
|
38
|
+
/** Latest activity line from the running subagent(s). */
|
|
39
|
+
liveText?: string;
|
|
40
|
+
/** Gate verdict (gate phases only). */
|
|
41
|
+
gate?: { verdict: "pass" | "block"; reason?: string };
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface RunState {
|
|
@@ -40,7 +46,7 @@ export interface RunState {
|
|
|
40
46
|
flowName: string;
|
|
41
47
|
def: Taskflow;
|
|
42
48
|
args: Record<string, unknown>;
|
|
43
|
-
status: "running" | "completed" | "failed" | "paused";
|
|
49
|
+
status: "running" | "completed" | "failed" | "paused" | "blocked";
|
|
44
50
|
phases: Record<string, PhaseState>;
|
|
45
51
|
createdAt: number;
|
|
46
52
|
updatedAt: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: taskflow
|
|
3
|
-
description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use
|
|
3
|
+
description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel subagents when the work has multiple phases or dynamic fan-out over a discovered list. Not for a single delegated task — use the subagent tool for that.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Taskflow
|
|
@@ -55,9 +55,29 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
|
55
55
|
| `agent` | one subagent runs `task` |
|
|
56
56
|
| `parallel` | run `branches[]` concurrently |
|
|
57
57
|
| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
|
|
58
|
-
| `gate` | quality/review step
|
|
58
|
+
| `gate` | quality/review step that can **halt the flow** (see below) |
|
|
59
59
|
| `reduce` | aggregate `from[]` phases into one output |
|
|
60
60
|
|
|
61
|
+
### Gate phases (quality control)
|
|
62
|
+
|
|
63
|
+
A `gate` phase runs an agent to review upstream output and can **block the rest
|
|
64
|
+
of the workflow**. End the gate task's instructions by asking the agent to emit a
|
|
65
|
+
verdict the runtime can read:
|
|
66
|
+
|
|
67
|
+
- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or
|
|
68
|
+
- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}`
|
|
69
|
+
|
|
70
|
+
On **BLOCK**, downstream phases are skipped and the run ends as `blocked` with the
|
|
71
|
+
reason surfaced. Ambiguous output **fails open** (treated as PASS) so a gate never
|
|
72
|
+
halts the flow by accident. Example gate task:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Review the audit results below. If any endpoint is missing auth, end with
|
|
76
|
+
"VERDICT: BLOCK" and a one-line reason; otherwise end with "VERDICT: PASS".
|
|
77
|
+
|
|
78
|
+
{steps.audit.output}
|
|
79
|
+
```
|
|
80
|
+
|
|
61
81
|
### Interpolation
|
|
62
82
|
|
|
63
83
|
- `{args.X}` — invocation argument
|