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 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` 阶段 + run 历史 TUI |
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
 
@@ -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
- 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
- });
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: "Run a multi-phase subagent workflow (declarative DAG with map fan-out)",
183
+ promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
156
184
  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).",
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.ui.notify(
287
- runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
288
- "info",
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
 
@@ -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,160 @@ 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") {
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", ` ${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}`);
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(state: RunState, finalOutput: string, theme: Theme, expanded: boolean): Container | Text {
258
+ export function renderRunResult(
259
+ state: RunState,
260
+ finalOutput: string,
261
+ theme: Theme,
262
+ expanded: boolean,
263
+ ): Container | Text {
58
264
  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)")}`;
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
 
@@ -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
  };
@@ -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
+ }
@@ -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,8 +169,18 @@ 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);
131
- return resultToPhaseState(phase.id, r, inputHash, parseJson);
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 mapWithConcurrencyLimit(branches, concurrency, (b) => runOne(b.agent, b.task));
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 mapWithConcurrencyLimit(tasks, concurrency, (t) => runOne(t.agent, t.task));
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 r = await runOne(phase.agent ?? defaultAgent(deps), text);
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 (unless this phase is optional).
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: finalState?.output ?? "(no output)",
397
+ finalOutput,
295
398
  ok: state.status === "completed",
296
399
  totalUsage,
297
400
  };
@@ -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.1",
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",
@@ -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
@@ -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 (a focused agent pass) |
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