pi-taskflow 0.0.1 → 0.0.2
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/extensions/index.ts +38 -10
- package/extensions/render.ts +216 -35
- package/extensions/runner.ts +52 -3
- package/extensions/runtime.ts +65 -7
- package/extensions/store.ts +4 -0
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +1 -1
package/extensions/index.ts
CHANGED
|
@@ -105,14 +105,41 @@ async function runFlow(
|
|
|
105
105
|
});
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
// Throttled persistence: avoid disk writes on every sub-item event.
|
|
109
|
+
let lastPersist = 0;
|
|
110
|
+
const persistThrottled = (s: RunState) => {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
if (now - lastPersist >= 1000) {
|
|
113
|
+
lastPersist = now;
|
|
114
|
+
saveRun(s);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// ~8fps heartbeat drives all rendering: it naturally caps the frame rate
|
|
119
|
+
// (no event bursts) while keeping the spinner, elapsed timers, live tokens
|
|
120
|
+
// and the latest message current. Phase events only mutate `state`.
|
|
121
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
122
|
+
if (onUpdate) {
|
|
123
|
+
heartbeat = setInterval(() => {
|
|
124
|
+
if (state.status === "running") emit(state);
|
|
125
|
+
}, 120);
|
|
126
|
+
(heartbeat as { unref?: () => void }).unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = await executeTaskflow(state, {
|
|
131
|
+
cwd: ctx.cwd,
|
|
132
|
+
agents,
|
|
133
|
+
globalThinking: settings.globalThinking,
|
|
134
|
+
signal,
|
|
135
|
+
persist: persistThrottled,
|
|
136
|
+
});
|
|
137
|
+
return result;
|
|
138
|
+
} finally {
|
|
139
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
140
|
+
saveRun(state); // force-persist terminal state
|
|
141
|
+
emit(state); // final render reflecting terminal state
|
|
142
|
+
}
|
|
116
143
|
}
|
|
117
144
|
|
|
118
145
|
export default function (pi: ExtensionAPI) {
|
|
@@ -152,9 +179,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
152
179
|
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
153
180
|
].join(" "),
|
|
154
181
|
parameters: TaskflowParams,
|
|
155
|
-
promptSnippet: "
|
|
182
|
+
promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
|
|
156
183
|
promptGuidelines: [
|
|
157
|
-
"
|
|
184
|
+
"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.",
|
|
185
|
+
"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
186
|
"For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
|
|
159
187
|
],
|
|
160
188
|
|
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,141 @@ 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") return theme.fg("muted", "skipped · upstream failed");
|
|
119
|
+
|
|
120
|
+
const isFanout = type === "map" || type === "parallel";
|
|
121
|
+
|
|
122
|
+
if (ps.status === "failed") {
|
|
123
|
+
const e = (ps.error ?? "failed").replace(/\s+/g, " ");
|
|
124
|
+
const snip = e.length > 56 ? `${e.slice(0, 56)}…` : e;
|
|
125
|
+
if (isFanout && ps.subProgress) {
|
|
126
|
+
const { done, total, failed } = ps.subProgress;
|
|
127
|
+
return (
|
|
128
|
+
theme.fg("toolOutput", `${done - failed}/${total}`) +
|
|
129
|
+
theme.fg("error", ` ${failed}✗`) +
|
|
130
|
+
(snip ? theme.fg("error", ` ${snip}`) : "")
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return theme.fg("error", snip);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const t = phaseElapsed(ps);
|
|
137
|
+
const time = t ? theme.fg("dim", elapsed(t)) : "";
|
|
138
|
+
|
|
139
|
+
if (ps.status === "running") {
|
|
140
|
+
const model = shortModel(ps.model);
|
|
141
|
+
const tokens = liveUsageStr(ps.usage, theme);
|
|
142
|
+
if (isFanout && ps.subProgress) {
|
|
143
|
+
const { done, total, running, failed } = ps.subProgress;
|
|
144
|
+
let s = `${miniBar(done, total, theme)} ${theme.fg("toolOutput", `${done}/${total}`)}`;
|
|
145
|
+
if (running) s += theme.fg("dim", ` · ${running} run`);
|
|
146
|
+
if (failed) s += theme.fg("error", ` · ${failed}✗`);
|
|
147
|
+
if (tokens) s += ` ${tokens}`;
|
|
148
|
+
if (time) s += ` ${time}`;
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
let s = model ? theme.fg("accent", model) : theme.fg("warning", "running…");
|
|
152
|
+
if (tokens) s += ` ${tokens}`;
|
|
153
|
+
if (time) s += ` ${time}`;
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// done
|
|
158
|
+
if (isFanout) {
|
|
159
|
+
const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
|
|
160
|
+
let s = theme.fg("success", `${total}✓`);
|
|
161
|
+
if (failed) s = theme.fg("toolOutput", `${done - failed}/${total}`) + theme.fg("error", ` ${failed}✗`);
|
|
162
|
+
const u = compactUsage(ps.usage, theme);
|
|
163
|
+
if (u) s += ` ${u}`;
|
|
164
|
+
if (time) s += ` ${time}`;
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
// single-agent done
|
|
168
|
+
const model = shortModel(ps.model);
|
|
169
|
+
const u = compactUsage(ps.usage, theme);
|
|
170
|
+
let s = "";
|
|
171
|
+
if (model) s += theme.fg("accent", model);
|
|
172
|
+
if (u) s += (s ? " " : "") + u;
|
|
173
|
+
if (time) s += ` ${time}`;
|
|
174
|
+
return s || theme.fg("dim", "done");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Header line: status glyph + name + compact totals. */
|
|
178
|
+
function headerLine(state: RunState, theme: Theme): string {
|
|
179
|
+
const phases = Object.values(state.phases);
|
|
180
|
+
const done = phases.filter((p) => p.status === "done").length;
|
|
181
|
+
const failed = phases.filter((p) => p.status === "failed").length;
|
|
182
|
+
const running = phases.filter((p) => p.status === "running").length;
|
|
183
|
+
const total = state.def.phases.length;
|
|
184
|
+
|
|
185
|
+
const head =
|
|
186
|
+
state.status === "completed"
|
|
187
|
+
? theme.fg("success", "✓")
|
|
188
|
+
: state.status === "failed"
|
|
189
|
+
? theme.fg("error", "✗")
|
|
190
|
+
: state.status === "paused"
|
|
191
|
+
? theme.fg("warning", "‖")
|
|
192
|
+
: theme.fg("warning", spinnerFrame());
|
|
193
|
+
|
|
194
|
+
let line =
|
|
195
|
+
`${head} ${theme.fg("toolTitle", theme.bold("taskflow"))} ` +
|
|
38
196
|
theme.fg("accent", state.flowName) +
|
|
39
|
-
theme.fg("muted", ` ${
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
197
|
+
theme.fg("muted", ` ${done}/${total}`);
|
|
198
|
+
if (running) line += theme.fg("warning", ` · ${running}▸`);
|
|
199
|
+
if (failed) line += theme.fg("error", ` · ${failed}✗`);
|
|
200
|
+
const cost = aggregateCost(state);
|
|
201
|
+
if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
|
|
202
|
+
const el = runElapsed(state);
|
|
203
|
+
if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
|
|
204
|
+
return line;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The full dense progress block (header + aligned phase rows). */
|
|
208
|
+
export function renderProgress(state: RunState, theme: Theme): string {
|
|
209
|
+
const phases = state.def.phases;
|
|
210
|
+
const idW = Math.max(...phases.map((p) => p.id.length), 2);
|
|
211
|
+
const typeW = Math.max(...phases.map((p) => (p.type ?? "agent").length), 4);
|
|
212
|
+
|
|
213
|
+
let text = headerLine(state, theme);
|
|
214
|
+
for (const phase of phases) {
|
|
215
|
+
const ps = state.phases[phase.id];
|
|
216
|
+
const status = ps?.status ?? "pending";
|
|
217
|
+
const id = phase.id.padEnd(idW);
|
|
218
|
+
const type = (phase.type ?? "agent").padEnd(typeW);
|
|
219
|
+
const detail = phaseDetail(phase, ps, theme);
|
|
220
|
+
text +=
|
|
221
|
+
`\n ${icon(status, theme)} ` +
|
|
222
|
+
theme.fg(status === "pending" ? "dim" : "text", id) +
|
|
223
|
+
" " +
|
|
224
|
+
theme.fg("dim", type) +
|
|
225
|
+
" " +
|
|
226
|
+
detail;
|
|
227
|
+
|
|
228
|
+
// Live activity sub-line (only while running, only if we have a message).
|
|
229
|
+
if (status === "running" && ps?.liveText) {
|
|
230
|
+
const indent = " ".repeat(2 + 2 + idW + 2);
|
|
231
|
+
const msg = ps.liveText.replace(/\s+/g, " ").trim();
|
|
232
|
+
const snip = msg.length > 88 ? `${msg.slice(0, 88)}…` : msg;
|
|
233
|
+
text += `\n${indent}${theme.fg("dim", "› ")}${theme.fg("muted", snip)}`;
|
|
51
234
|
}
|
|
52
|
-
text += line;
|
|
53
235
|
}
|
|
54
236
|
return text;
|
|
55
237
|
}
|
|
56
238
|
|
|
57
|
-
export function renderRunResult(
|
|
239
|
+
export function renderRunResult(
|
|
240
|
+
state: RunState,
|
|
241
|
+
finalOutput: string,
|
|
242
|
+
theme: Theme,
|
|
243
|
+
expanded: boolean,
|
|
244
|
+
): Container | Text {
|
|
58
245
|
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)")}`;
|
|
246
|
+
let text = renderProgress(state, theme);
|
|
247
|
+
text += `\n ${theme.fg("dim", "Ctrl+O to expand")}`;
|
|
67
248
|
return new Text(text, 0, 0);
|
|
68
249
|
}
|
|
69
250
|
|
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
|
};
|
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,7 +169,15 @@ async function executePhase(
|
|
|
127
169
|
const cached = cachedPhase(prior, inputHash);
|
|
128
170
|
if (cached) return cached;
|
|
129
171
|
|
|
130
|
-
const
|
|
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
|
+
});
|
|
131
181
|
return resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
132
182
|
}
|
|
133
183
|
|
|
@@ -141,7 +191,7 @@ async function executePhase(
|
|
|
141
191
|
const cached = cachedPhase(prior, inputHash);
|
|
142
192
|
if (cached) return cached;
|
|
143
193
|
|
|
144
|
-
const results = await
|
|
194
|
+
const results = await runFanout(branches);
|
|
145
195
|
return mergePhaseState(phase.id, results, inputHash, parseJson);
|
|
146
196
|
}
|
|
147
197
|
|
|
@@ -172,7 +222,7 @@ async function executePhase(
|
|
|
172
222
|
const cached = cachedPhase(prior, inputHash);
|
|
173
223
|
if (cached) return cached;
|
|
174
224
|
|
|
175
|
-
const results = await
|
|
225
|
+
const results = await runFanout(tasks);
|
|
176
226
|
return mergePhaseState(phase.id, results, inputHash, parseJson);
|
|
177
227
|
}
|
|
178
228
|
|
|
@@ -184,7 +234,15 @@ async function executePhase(
|
|
|
184
234
|
const cached = cachedPhase(prior, inputHash);
|
|
185
235
|
if (cached) return cached;
|
|
186
236
|
|
|
187
|
-
const
|
|
237
|
+
const live = state.phases[phase.id];
|
|
238
|
+
const r = await runOne(phase.agent ?? defaultAgent(deps), text, (l) => {
|
|
239
|
+
if (live) {
|
|
240
|
+
live.liveText = l.text;
|
|
241
|
+
live.usage = l.usage;
|
|
242
|
+
live.model = l.model;
|
|
243
|
+
}
|
|
244
|
+
emitProgress();
|
|
245
|
+
});
|
|
188
246
|
return resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
189
247
|
}
|
|
190
248
|
|
|
@@ -273,7 +331,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
273
331
|
};
|
|
274
332
|
deps.onProgress?.(state);
|
|
275
333
|
|
|
276
|
-
const ps = await executePhase(phase, state, deps, prior);
|
|
334
|
+
const ps = await executePhase(phase, state, deps, prior, () => deps.onProgress?.(state));
|
|
277
335
|
state.phases[phase.id] = ps;
|
|
278
336
|
deps.persist?.(state);
|
|
279
337
|
deps.onProgress?.(state);
|
package/extensions/store.ts
CHANGED
|
@@ -33,6 +33,10 @@ 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;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface RunState {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
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
|