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.
@@ -105,14 +105,41 @@ async function runFlow(
105
105
  });
106
106
  };
107
107
 
108
- return executeTaskflow(state, {
109
- cwd: ctx.cwd,
110
- agents,
111
- globalThinking: settings.globalThinking,
112
- signal,
113
- persist: (s) => saveRun(s),
114
- onProgress: (s) => emit(s),
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: "Run a multi-phase subagent workflow (declarative DAG with map fan-out)",
182
+ promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
156
183
  promptGuidelines: [
157
- "Use taskflow when a task needs several coordinated subagent steps, fan-out over many items, or a repeatable orchestration not for a single delegated task (use subagent for that).",
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
 
@@ -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 { formatUsage } from "./runner.ts";
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
- const STATUS_ICON: Record<PhaseState["status"], (t: Theme) => string> = {
11
- pending: (t) => t.fg("dim", "○"),
12
- running: (t) => t.fg("warning", ""),
13
- done: (t) => t.fg("success", ""),
14
- failed: (t) => t.fg("error", ""),
15
- skipped: (t) => t.fg("muted", ""),
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
- export function phaseIcon(status: PhaseState["status"], theme: Theme): string {
19
- return (STATUS_ICON[status] ?? STATUS_ICON.pending)(theme);
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
- /** Compact one-line-per-phase progress block. */
35
- export function renderProgress(state: RunState, theme: Theme): string {
36
- let text =
37
- theme.fg("toolTitle", theme.bold("taskflow ")) +
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", ` ${summarizeRun(state)}`);
40
-
41
- for (const phase of state.def.phases) {
42
- const ps = state.phases[phase.id] ?? { id: phase.id, status: "pending" as const };
43
- const icon = phaseIcon(ps.status, theme);
44
- const type = theme.fg("dim", `[${phase.type ?? "agent"}]`);
45
- let line = `\n ${icon} ${theme.fg("accent", phase.id)} ${type}`;
46
- if (ps.status === "running") line += theme.fg("warning", " …");
47
- if (ps.usage?.cost) line += theme.fg("dim", ` ${formatUsage(ps.usage, ps.model)}`);
48
- if (ps.status === "failed" && ps.error) {
49
- const e = ps.error.length > 60 ? `${ps.error.slice(0, 60)}…` : ps.error;
50
- line += theme.fg("error", ` ${e}`);
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(state: RunState, finalOutput: string, theme: Theme, expanded: boolean): Container | Text {
239
+ export function renderRunResult(
240
+ state: RunState,
241
+ finalOutput: string,
242
+ theme: Theme,
243
+ expanded: boolean,
244
+ ): Container | Text {
58
245
  if (!expanded) {
59
- const icon =
60
- state.status === "completed"
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
 
@@ -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
- onText?: (text: string) => void;
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 text = getFinalOutput([msg]);
190
- if (text && opts.onText) opts.onText(text);
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
  };
@@ -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, _locals?: Record<string, unknown>) =>
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 r = await runOne(phase.agent ?? defaultAgent(deps), text);
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 mapWithConcurrencyLimit(branches, concurrency, (b) => runOne(b.agent, b.task));
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 mapWithConcurrencyLimit(tasks, concurrency, (t) => runOne(t.agent, t.task));
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 r = await runOne(phase.agent ?? defaultAgent(deps), text);
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);
@@ -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.1",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: taskflow
3
- description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use when a task needs several coordinated subagent steps, fan-out over many items (files, endpoints, modules), cross-checked/adversarial review, or a repeatable orchestration you want to save and rerun. Not for a single delegated task — use the subagent tool for that.
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