pi-taskflow 0.0.1
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 +324 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/examples/summarize-files.json +37 -0
- package/extensions/agents.ts +152 -0
- package/extensions/index.ts +383 -0
- package/extensions/interpolate.ts +151 -0
- package/extensions/render.ts +81 -0
- package/extensions/runner.ts +301 -0
- package/extensions/runtime.ts +298 -0
- package/extensions/schema.ts +261 -0
- package/extensions/store.ts +170 -0
- package/package.json +65 -0
- package/skills/taskflow/SKILL.md +87 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-taskflow — lightweight workflow orchestration for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - tool `taskflow` : run inline / saved flows, save, resume (LLM-callable)
|
|
6
|
+
* - command `/tf` : list | run | show | save | resume | runs (user)
|
|
7
|
+
* - command `/tf:<name>` : per-saved-flow shortcut (registered on session_start)
|
|
8
|
+
*
|
|
9
|
+
* Intermediate phase outputs are held in the runtime and never pushed into the
|
|
10
|
+
* host conversation context — only the final phase output is returned.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
19
|
+
import { renderRunResult, summarizeRun } from "./render.ts";
|
|
20
|
+
import { executeTaskflow, type RuntimeResult } from "./runtime.ts";
|
|
21
|
+
import { finalPhase, type Taskflow, validateTaskflow } from "./schema.ts";
|
|
22
|
+
import {
|
|
23
|
+
getFlow,
|
|
24
|
+
listFlows,
|
|
25
|
+
listRuns,
|
|
26
|
+
loadRun,
|
|
27
|
+
newRunId,
|
|
28
|
+
type RunState,
|
|
29
|
+
saveFlow,
|
|
30
|
+
saveRun,
|
|
31
|
+
} from "./store.ts";
|
|
32
|
+
|
|
33
|
+
interface TaskflowDetails {
|
|
34
|
+
state?: RunState;
|
|
35
|
+
finalOutput?: string;
|
|
36
|
+
action: string;
|
|
37
|
+
message?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** pi reads `isError` at runtime to mark tool failures; it is not in the public type. */
|
|
41
|
+
type ToolResult = AgentToolResult<TaskflowDetails> & { isError?: boolean };
|
|
42
|
+
|
|
43
|
+
const TaskflowParams = Type.Object({
|
|
44
|
+
action: StringEnum(["run", "save", "resume", "list"] as const, {
|
|
45
|
+
description: "What to do: run a flow, save a definition, resume a paused run, or list saved flows",
|
|
46
|
+
default: "run",
|
|
47
|
+
}),
|
|
48
|
+
name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
|
|
49
|
+
define: Type.Optional(
|
|
50
|
+
Type.Unknown({
|
|
51
|
+
description:
|
|
52
|
+
"Inline taskflow definition (JSON object matching the taskflow DSL). Use to run or save a new flow.",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Invocation arguments for the flow" })),
|
|
56
|
+
runId: Type.Optional(Type.String({ description: "Run id to resume (for action=resume)" })),
|
|
57
|
+
scope: Type.Optional(
|
|
58
|
+
StringEnum(["user", "project"] as const, { description: "Where to save (action=save)", default: "project" }),
|
|
59
|
+
),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function resolveArgs(def: Taskflow, provided: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
63
|
+
const args: Record<string, unknown> = {};
|
|
64
|
+
for (const [key, spec] of Object.entries(def.args ?? {})) {
|
|
65
|
+
if (provided && key in provided) args[key] = provided[key];
|
|
66
|
+
else if (spec.default !== undefined) args[key] = spec.default;
|
|
67
|
+
}
|
|
68
|
+
// also pass through any extra provided args
|
|
69
|
+
if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
|
|
74
|
+
return {
|
|
75
|
+
runId: newRunId(def.name),
|
|
76
|
+
flowName: def.name,
|
|
77
|
+
def,
|
|
78
|
+
args,
|
|
79
|
+
status: "running",
|
|
80
|
+
phases: {},
|
|
81
|
+
createdAt: Date.now(),
|
|
82
|
+
updatedAt: Date.now(),
|
|
83
|
+
cwd,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runFlow(
|
|
88
|
+
def: Taskflow,
|
|
89
|
+
args: Record<string, unknown>,
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
signal: AbortSignal | undefined,
|
|
92
|
+
onUpdate: ((p: AgentToolResult<TaskflowDetails>) => void) | undefined,
|
|
93
|
+
existing?: RunState,
|
|
94
|
+
): Promise<RuntimeResult> {
|
|
95
|
+
const settings = readSubagentSettings();
|
|
96
|
+
const scope: AgentScope = def.agentScope ?? "user";
|
|
97
|
+
const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
|
|
98
|
+
|
|
99
|
+
const state = existing ?? makeRunState(def, args, ctx.cwd);
|
|
100
|
+
|
|
101
|
+
const emit = (s: RunState, finalOutput?: string) => {
|
|
102
|
+
onUpdate?.({
|
|
103
|
+
content: [{ type: "text", text: finalOutput ?? summarizeRun(s) }],
|
|
104
|
+
details: { action: "run", state: s, finalOutput },
|
|
105
|
+
});
|
|
106
|
+
};
|
|
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
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default function (pi: ExtensionAPI) {
|
|
119
|
+
// ---- Register per-saved-flow shortcut commands on session start ----
|
|
120
|
+
const registerSavedFlowCommands = (ctx: ExtensionContext) => {
|
|
121
|
+
const flows = listFlows(ctx.cwd);
|
|
122
|
+
for (const flow of flows) {
|
|
123
|
+
const cmdName = `tf:${flow.name}`;
|
|
124
|
+
pi.registerCommand(cmdName, {
|
|
125
|
+
description: flow.def.description || `Run taskflow '${flow.name}'`,
|
|
126
|
+
handler: async (args, cmdCtx) => {
|
|
127
|
+
if (!cmdCtx.isIdle()) {
|
|
128
|
+
cmdCtx.ui.notify("Agent is busy; try again when idle.", "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const parsed = parseArgsString(args, flow.def);
|
|
132
|
+
pi.sendUserMessage(
|
|
133
|
+
`Run the saved taskflow "${flow.name}" using the taskflow tool with action="run", name="${flow.name}", args=${JSON.stringify(parsed)}.`,
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
pi.on("session_start", async (_e, ctx) => registerSavedFlowCommands(ctx));
|
|
141
|
+
|
|
142
|
+
// ---- The LLM-callable tool ----
|
|
143
|
+
pi.registerTool({
|
|
144
|
+
name: "taskflow",
|
|
145
|
+
label: "Taskflow",
|
|
146
|
+
description: [
|
|
147
|
+
"Orchestrate a multi-phase workflow of subagents from a declarative definition.",
|
|
148
|
+
"Phases (agent, parallel, map, gate, reduce) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
|
|
149
|
+
"Use action=run with an inline `define` (you write the DSL) or a saved `name`.",
|
|
150
|
+
"Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows.",
|
|
151
|
+
"DSL: {name, args?, concurrency?, phases:[{id, type, agent, task, dependsOn?, over?(map), as?(map), branches?(parallel), from?(reduce), output?:'json', final?}]}.",
|
|
152
|
+
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
153
|
+
].join(" "),
|
|
154
|
+
parameters: TaskflowParams,
|
|
155
|
+
promptSnippet: "Run a multi-phase subagent workflow (declarative DAG with map fan-out)",
|
|
156
|
+
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).",
|
|
158
|
+
"For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
|
|
159
|
+
],
|
|
160
|
+
|
|
161
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
162
|
+
const action = params.action ?? "run";
|
|
163
|
+
|
|
164
|
+
// list
|
|
165
|
+
if (action === "list") {
|
|
166
|
+
const flows = listFlows(ctx.cwd);
|
|
167
|
+
const text = flows.length
|
|
168
|
+
? flows.map((f) => `- ${f.name} (${f.scope}): ${f.def.description ?? ""}`).join("\n")
|
|
169
|
+
: "No saved taskflows.";
|
|
170
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// resume
|
|
174
|
+
if (action === "resume") {
|
|
175
|
+
if (!params.runId)
|
|
176
|
+
return errorResult(action, "action=resume requires 'runId'");
|
|
177
|
+
const prev = loadRun(ctx.cwd, params.runId);
|
|
178
|
+
if (!prev) return errorResult(action, `Run not found: ${params.runId}`);
|
|
179
|
+
const result = await runFlow(prev.def, prev.args, ctx, signal, onUpdate as any, prev);
|
|
180
|
+
return finalResult(action, result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// resolve the definition (inline define wins, else saved name)
|
|
184
|
+
let def: Taskflow | undefined;
|
|
185
|
+
if (params.define) {
|
|
186
|
+
const v = validateTaskflow(params.define);
|
|
187
|
+
if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
|
|
188
|
+
def = params.define as Taskflow;
|
|
189
|
+
} else if (params.name) {
|
|
190
|
+
const saved = getFlow(ctx.cwd, params.name);
|
|
191
|
+
if (!saved) return errorResult(action, `Saved flow not found: ${params.name}`);
|
|
192
|
+
def = saved.def;
|
|
193
|
+
}
|
|
194
|
+
if (!def) return errorResult(action, "Provide 'define' (inline) or 'name' (saved).");
|
|
195
|
+
|
|
196
|
+
// save
|
|
197
|
+
if (action === "save") {
|
|
198
|
+
const v = validateTaskflow(def);
|
|
199
|
+
if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
|
|
200
|
+
const { filePath } = saveFlow(ctx.cwd, def, params.scope ?? "project");
|
|
201
|
+
// Make the shortcut available immediately this session.
|
|
202
|
+
pi.registerCommand(`tf:${def.name}`, {
|
|
203
|
+
description: def.description || `Run taskflow '${def.name}'`,
|
|
204
|
+
handler: async (args, cmdCtx) => {
|
|
205
|
+
const parsed = parseArgsString(args, def!);
|
|
206
|
+
if (cmdCtx.isIdle())
|
|
207
|
+
pi.sendUserMessage(
|
|
208
|
+
`Run the saved taskflow "${def!.name}" using the taskflow tool with action="run", name="${def!.name}", args=${JSON.stringify(parsed)}.`,
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{ type: "text", text: `Saved taskflow '${def.name}' → ${filePath}\nRun it with /tf:${def.name} or action=run.` },
|
|
215
|
+
],
|
|
216
|
+
details: { action, message: filePath } satisfies TaskflowDetails,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// run
|
|
221
|
+
const v = validateTaskflow(def);
|
|
222
|
+
if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
|
|
223
|
+
const args = resolveArgs(def, params.args);
|
|
224
|
+
const result = await runFlow(def, args, ctx, signal, onUpdate as any);
|
|
225
|
+
return finalResult(action, result);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
renderCall(args, theme) {
|
|
229
|
+
const action = args.action ?? "run";
|
|
230
|
+
const name = args.name || (args.define as any)?.name || "(inline)";
|
|
231
|
+
let text = theme.fg("toolTitle", theme.bold("taskflow ")) + theme.fg("accent", `${action} `) + theme.fg("muted", name);
|
|
232
|
+
const phases = (args.define as Taskflow | undefined)?.phases;
|
|
233
|
+
if (phases) text += theme.fg("dim", ` (${phases.length} phases)`);
|
|
234
|
+
return new Text(text, 0, 0);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
renderResult(result, { expanded }, theme) {
|
|
238
|
+
const details = result.details as TaskflowDetails | undefined;
|
|
239
|
+
if (!details?.state) {
|
|
240
|
+
const t = result.content[0];
|
|
241
|
+
return new Text(t?.type === "text" ? t.text : "(no output)", 0, 0);
|
|
242
|
+
}
|
|
243
|
+
return renderRunResult(details.state, details.finalOutput ?? "", theme, expanded);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---- The /tf user command ----
|
|
248
|
+
pi.registerCommand("tf", {
|
|
249
|
+
description: "Taskflow: list | run <name> | show <name> | runs",
|
|
250
|
+
getArgumentCompletions: (prefix) => {
|
|
251
|
+
const subs = ["list", "run", "show", "runs", "resume"];
|
|
252
|
+
const items = subs.map((s) => ({ value: s, label: s }));
|
|
253
|
+
const filtered = items.filter((i) => i.value.startsWith(prefix));
|
|
254
|
+
return filtered.length > 0 ? filtered : null;
|
|
255
|
+
},
|
|
256
|
+
handler: async (argStr, ctx) => {
|
|
257
|
+
const [sub, ...rest] = argStr.trim().split(/\s+/);
|
|
258
|
+
const arg = rest.join(" ");
|
|
259
|
+
|
|
260
|
+
if (!sub || sub === "list") {
|
|
261
|
+
const flows = listFlows(ctx.cwd);
|
|
262
|
+
if (flows.length === 0) {
|
|
263
|
+
ctx.ui.notify("No saved taskflows. Ask the agent to create one.", "info");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
ctx.ui.notify(flows.map((f) => `${f.name} (${f.scope}) — ${f.def.description ?? ""}`).join("\n"), "info");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (sub === "show") {
|
|
271
|
+
const flow = getFlow(ctx.cwd, arg);
|
|
272
|
+
if (!flow) {
|
|
273
|
+
ctx.ui.notify(`Flow not found: ${arg}`, "error");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
ctx.ui.notify(JSON.stringify(flow.def, null, 2), "info");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (sub === "runs") {
|
|
281
|
+
const runs = listRuns(ctx.cwd);
|
|
282
|
+
if (runs.length === 0) {
|
|
283
|
+
ctx.ui.notify("No taskflow runs yet.", "info");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
ctx.ui.notify(
|
|
287
|
+
runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
|
|
288
|
+
"info",
|
|
289
|
+
);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (sub === "run") {
|
|
294
|
+
if (!arg) {
|
|
295
|
+
ctx.ui.notify("Usage: /tf run <name> [args-json]", "warning");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const [name, ...maybeArgs] = arg.split(/\s+/);
|
|
299
|
+
const flow = getFlow(ctx.cwd, name);
|
|
300
|
+
if (!flow) {
|
|
301
|
+
ctx.ui.notify(`Flow not found: ${name}`, "error");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!ctx.isIdle()) {
|
|
305
|
+
ctx.ui.notify("Agent is busy; try again when idle.", "warning");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const parsed = parseArgsString(maybeArgs.join(" "), flow.def);
|
|
309
|
+
pi.sendUserMessage(
|
|
310
|
+
`Run the saved taskflow "${name}" using the taskflow tool with action="run", name="${name}", args=${JSON.stringify(parsed)}.`,
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (sub === "resume") {
|
|
316
|
+
if (!arg) {
|
|
317
|
+
ctx.ui.notify("Usage: /tf resume <runId>", "warning");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!ctx.isIdle()) {
|
|
321
|
+
ctx.ui.notify("Agent is busy; try again when idle.", "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
pi.sendUserMessage(`Resume the taskflow run "${arg}" using the taskflow tool with action="resume", runId="${arg}".`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ctx.ui.notify(`Unknown subcommand: ${sub}`, "warning");
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- helpers ---
|
|
334
|
+
|
|
335
|
+
function errorResult(action: string, message: string): ToolResult {
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: message }],
|
|
338
|
+
details: { action, message },
|
|
339
|
+
isError: true,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function finalResult(action: string, result: RuntimeResult): ToolResult {
|
|
344
|
+
const fp = finalPhase(result.state.def.phases);
|
|
345
|
+
const header = result.ok
|
|
346
|
+
? `Taskflow '${result.state.flowName}' completed (${summarizeRun(result.state)}). Run id: ${result.state.runId}`
|
|
347
|
+
: `Taskflow '${result.state.flowName}' ${result.state.status} (${summarizeRun(result.state)}). Run id: ${result.state.runId} — resume with action=resume.`;
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text", text: `${header}\n\n--- ${fp.id} ---\n${result.finalOutput}` }],
|
|
350
|
+
details: { action, state: result.state, finalOutput: result.finalOutput },
|
|
351
|
+
isError: !result.ok,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Parse a CLI-ish arg string into an args object. Accepts JSON or key=value pairs. */
|
|
356
|
+
function parseArgsString(input: string, def: Taskflow): Record<string, unknown> {
|
|
357
|
+
const trimmed = (input ?? "").trim();
|
|
358
|
+
if (!trimmed) return {};
|
|
359
|
+
if (trimmed.startsWith("{")) {
|
|
360
|
+
try {
|
|
361
|
+
return JSON.parse(trimmed);
|
|
362
|
+
} catch {
|
|
363
|
+
/* fall through */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// key=value pairs
|
|
367
|
+
const out: Record<string, unknown> = {};
|
|
368
|
+
const pairs = trimmed.match(/(\w+)=("[^"]*"|\S+)/g);
|
|
369
|
+
if (pairs) {
|
|
370
|
+
for (const p of pairs) {
|
|
371
|
+
const idx = p.indexOf("=");
|
|
372
|
+
const k = p.slice(0, idx);
|
|
373
|
+
let v: string = p.slice(idx + 1);
|
|
374
|
+
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
375
|
+
out[k] = v;
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
// single positional → first declared arg
|
|
380
|
+
const firstArg = Object.keys(def.args ?? {})[0];
|
|
381
|
+
if (firstArg) return { [firstArg]: trimmed };
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template interpolation for taskflow tasks.
|
|
3
|
+
*
|
|
4
|
+
* Supported placeholders:
|
|
5
|
+
* {args.X} invocation argument
|
|
6
|
+
* {steps.ID.output} prior phase final output (string)
|
|
7
|
+
* {steps.ID.json} prior phase output parsed as JSON (stringified back if object)
|
|
8
|
+
* {previous.output} alias for the immediately-preceding completed phase output
|
|
9
|
+
* {item} / {item.f} map loop variable (or custom name via phase.as)
|
|
10
|
+
*
|
|
11
|
+
* Unknown placeholders are left intact (with a recorded warning) rather than
|
|
12
|
+
* throwing, so a partially-specified task still runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface InterpolationContext {
|
|
16
|
+
args: Record<string, unknown>;
|
|
17
|
+
steps: Record<string, { output: string; json?: unknown }>;
|
|
18
|
+
previousOutput?: string;
|
|
19
|
+
/** loop variable bindings, e.g. { item: {...} } */
|
|
20
|
+
locals?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PLACEHOLDER = /\{([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)\}/g;
|
|
24
|
+
|
|
25
|
+
export interface InterpolationResult {
|
|
26
|
+
text: string;
|
|
27
|
+
missing: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function interpolate(template: string, ctx: InterpolationContext): InterpolationResult {
|
|
31
|
+
const missing: string[] = [];
|
|
32
|
+
|
|
33
|
+
const text = template.replace(PLACEHOLDER, (whole, path: string) => {
|
|
34
|
+
const value = resolvePath(path, ctx);
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
missing.push(path);
|
|
37
|
+
return whole;
|
|
38
|
+
}
|
|
39
|
+
return stringify(value);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return { text, missing };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolvePath(path: string, ctx: InterpolationContext): unknown {
|
|
46
|
+
const parts = path.split(".");
|
|
47
|
+
const head = parts[0];
|
|
48
|
+
|
|
49
|
+
// previous.output
|
|
50
|
+
if (head === "previous") {
|
|
51
|
+
if (parts[1] === "output") return ctx.previousOutput ?? undefined;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// args.*
|
|
56
|
+
if (head === "args") {
|
|
57
|
+
return dig(ctx.args, parts.slice(1));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// steps.<id>.output | steps.<id>.json | steps.<id>.json.<field>
|
|
61
|
+
if (head === "steps") {
|
|
62
|
+
const stepId = parts[1];
|
|
63
|
+
const step = stepId ? ctx.steps[stepId] : undefined;
|
|
64
|
+
if (!step) return undefined;
|
|
65
|
+
const field = parts[2];
|
|
66
|
+
if (field === "output") return step.output;
|
|
67
|
+
if (field === "json") {
|
|
68
|
+
const json = step.json ?? safeParse(step.output);
|
|
69
|
+
return dig(json, parts.slice(3));
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// locals (map loop variable), e.g. item / item.field
|
|
75
|
+
if (ctx.locals && head in ctx.locals) {
|
|
76
|
+
return dig(ctx.locals[head], parts.slice(1));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function dig(obj: unknown, parts: string[]): unknown {
|
|
83
|
+
let cur: unknown = obj;
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (cur === null || cur === undefined) return undefined;
|
|
86
|
+
if (typeof cur !== "object") return undefined;
|
|
87
|
+
cur = (cur as Record<string, unknown>)[part];
|
|
88
|
+
}
|
|
89
|
+
return cur;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stringify(value: unknown): string {
|
|
93
|
+
if (typeof value === "string") return value;
|
|
94
|
+
if (value === null || value === undefined) return "";
|
|
95
|
+
try {
|
|
96
|
+
return JSON.stringify(value, null, 2);
|
|
97
|
+
} catch {
|
|
98
|
+
return String(value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function safeParse(text: string): unknown {
|
|
103
|
+
const trimmed = text.trim();
|
|
104
|
+
if (!trimmed) return undefined;
|
|
105
|
+
// Direct parse
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(trimmed);
|
|
108
|
+
} catch {
|
|
109
|
+
// noop
|
|
110
|
+
}
|
|
111
|
+
// Extract from a ```json fenced block
|
|
112
|
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
113
|
+
if (fence) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fence[1].trim());
|
|
116
|
+
} catch {
|
|
117
|
+
// noop
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Extract the first balanced [...] or {...}
|
|
121
|
+
const arrStart = trimmed.indexOf("[");
|
|
122
|
+
const objStart = trimmed.indexOf("{");
|
|
123
|
+
const start =
|
|
124
|
+
arrStart === -1 ? objStart : objStart === -1 ? arrStart : Math.min(arrStart, objStart);
|
|
125
|
+
if (start !== -1) {
|
|
126
|
+
const open = trimmed[start];
|
|
127
|
+
const close = open === "[" ? "]" : "}";
|
|
128
|
+
const end = trimmed.lastIndexOf(close);
|
|
129
|
+
if (end > start) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
132
|
+
} catch {
|
|
133
|
+
// noop
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Coerce a parsed value into an array for map fan-out. */
|
|
141
|
+
export function coerceArray(value: unknown): unknown[] | null {
|
|
142
|
+
if (Array.isArray(value)) return value;
|
|
143
|
+
if (value && typeof value === "object") {
|
|
144
|
+
// {items: [...]} or {results: [...]} convenience
|
|
145
|
+
for (const key of ["items", "results", "list", "data"]) {
|
|
146
|
+
const v = (value as Record<string, unknown>)[key];
|
|
147
|
+
if (Array.isArray(v)) return v;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for the taskflow tool and commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { formatUsage } from "./runner.ts";
|
|
8
|
+
import type { PhaseState, RunState } from "./store.ts";
|
|
9
|
+
|
|
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", "⊘"),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function phaseIcon(status: PhaseState["status"], theme: Theme): string {
|
|
19
|
+
return (STATUS_ICON[status] ?? STATUS_ICON.pending)(theme);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summarizeRun(state: RunState): string {
|
|
23
|
+
const phases = Object.values(state.phases);
|
|
24
|
+
const done = phases.filter((p) => p.status === "done").length;
|
|
25
|
+
const failed = phases.filter((p) => p.status === "failed").length;
|
|
26
|
+
const running = phases.filter((p) => p.status === "running").length;
|
|
27
|
+
const total = state.def.phases.length;
|
|
28
|
+
const bits = [`${done}/${total} done`];
|
|
29
|
+
if (running) bits.push(`${running} running`);
|
|
30
|
+
if (failed) bits.push(`${failed} failed`);
|
|
31
|
+
return bits.join(", ");
|
|
32
|
+
}
|
|
33
|
+
|
|
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 ")) +
|
|
38
|
+
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}`);
|
|
51
|
+
}
|
|
52
|
+
text += line;
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderRunResult(state: RunState, finalOutput: string, theme: Theme, expanded: boolean): Container | Text {
|
|
58
|
+
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)")}`;
|
|
67
|
+
return new Text(text, 0, 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const mdTheme = getMarkdownTheme();
|
|
71
|
+
const container = new Container();
|
|
72
|
+
container.addChild(new Text(renderProgress(state, theme), 0, 0));
|
|
73
|
+
container.addChild(new Spacer(1));
|
|
74
|
+
container.addChild(new Text(theme.fg("muted", "─── Result ───"), 0, 0));
|
|
75
|
+
if (finalOutput.trim()) {
|
|
76
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
77
|
+
} else {
|
|
78
|
+
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
79
|
+
}
|
|
80
|
+
return container;
|
|
81
|
+
}
|