pi-taskflow 0.0.10 → 0.0.12
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/README.md +284 -212
- package/extensions/agents/analyst.md +30 -0
- package/extensions/agents/critic.md +31 -0
- package/extensions/agents/doc-writer.md +43 -0
- package/extensions/agents/executor-code.md +36 -0
- package/extensions/agents/executor-fast.md +26 -0
- package/extensions/agents/executor-ui.md +35 -0
- package/extensions/agents/executor.md +29 -0
- package/extensions/agents/final-arbiter.md +29 -0
- package/extensions/agents/plan-arbiter.md +35 -0
- package/extensions/agents/planner.md +30 -0
- package/extensions/agents/recover.md +28 -0
- package/extensions/agents/reviewer.md +37 -0
- package/extensions/agents/risk-reviewer.md +37 -0
- package/extensions/agents/scout.md +51 -0
- package/extensions/agents/security-reviewer.md +39 -0
- package/extensions/agents/test-engineer.md +31 -0
- package/extensions/agents/verifier.md +29 -0
- package/extensions/agents/visual-explorer.md +32 -0
- package/extensions/agents.ts +33 -2
- package/extensions/index.ts +178 -8
- package/extensions/render.ts +7 -2
- package/extensions/runner.ts +54 -1
- package/extensions/runtime.ts +13 -5
- package/extensions/schema.ts +3 -3
- package/package.json +2 -2
package/extensions/agents.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface AgentConfig {
|
|
|
23
23
|
model?: string;
|
|
24
24
|
thinking?: string;
|
|
25
25
|
systemPrompt: string;
|
|
26
|
-
source: "user" | "project";
|
|
26
|
+
source: "user" | "project" | "built-in";
|
|
27
27
|
filePath: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -32,7 +32,7 @@ export interface AgentDiscoveryResult {
|
|
|
32
32
|
projectAgentsDir: string | null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
35
|
+
function loadAgentsFromDir(dir: string, source: "user" | "project" | "built-in"): AgentConfig[] {
|
|
36
36
|
const agents: AgentConfig[] = [];
|
|
37
37
|
if (!fs.existsSync(dir)) return agents;
|
|
38
38
|
|
|
@@ -121,14 +121,23 @@ export function discoverAgents(
|
|
|
121
121
|
cwd: string,
|
|
122
122
|
scope: AgentScope,
|
|
123
123
|
overrides?: Record<string, AgentOverride>,
|
|
124
|
+
modelRoles?: Record<string, string>,
|
|
124
125
|
): AgentDiscoveryResult {
|
|
126
|
+
// Built-in agents ship with the package (extensions/agents/*.md)
|
|
127
|
+
// PI_TASKFLOW_BUILTIN_AGENTS_DIR allows tests to override or disable (empty = skip)
|
|
128
|
+
const builtInDirEnv = process.env.PI_TASKFLOW_BUILTIN_AGENTS_DIR;
|
|
129
|
+
const builtInDir = builtInDirEnv ? builtInDirEnv : builtInDirEnv === undefined ? path.resolve(import.meta.dirname, "agents") : "";
|
|
130
|
+
const builtInAgents = builtInDir ? loadAgentsFromDir(builtInDir, "built-in") : [];
|
|
131
|
+
|
|
125
132
|
const userDir = path.join(getAgentDir(), "agents");
|
|
126
133
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
127
134
|
|
|
128
135
|
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
129
136
|
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
130
137
|
|
|
138
|
+
// Layer order: built-in → user → project (later layers override earlier)
|
|
131
139
|
const agentMap = new Map<string, AgentConfig>();
|
|
140
|
+
for (const a of builtInAgents) agentMap.set(a.name, a);
|
|
132
141
|
if (scope === "both") {
|
|
133
142
|
for (const a of userAgents) agentMap.set(a.name, a);
|
|
134
143
|
for (const a of projectAgents) agentMap.set(a.name, a);
|
|
@@ -155,12 +164,33 @@ export function discoverAgents(
|
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
// Resolve {{role}} model references (e.g. {{fast}} → openrouter/deepseek/v4-flash)
|
|
168
|
+
if (modelRoles) {
|
|
169
|
+
for (const agent of agentMap.values()) {
|
|
170
|
+
const resolved = resolveModelRole(agent.model, modelRoles);
|
|
171
|
+
if (resolved !== agent.model) agent.model = resolved;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
158
175
|
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
export interface SubagentSettings {
|
|
162
179
|
agentOverrides?: Record<string, AgentOverride>;
|
|
163
180
|
globalThinking?: string;
|
|
181
|
+
modelRoles?: Record<string, string>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve `{{roleName}}` model references against a role→model mapping.
|
|
186
|
+
* E.g. `{{fast}}` → `openrouter/deepseek/deepseek-v4-flash` if modelRoles.fast is set.
|
|
187
|
+
* Returns undefined if the value is not a role reference or the role is unmapped.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveModelRole(model: string | undefined, roles?: Record<string, string>): string | undefined {
|
|
190
|
+
if (!model || !roles) return model;
|
|
191
|
+
const match = model.match(/^\{\{(\w+)\}\}$/);
|
|
192
|
+
if (!match) return model;
|
|
193
|
+
return roles[match[1]] ?? undefined;
|
|
164
194
|
}
|
|
165
195
|
|
|
166
196
|
/** Read subagent overrides from ~/.pi/agent/settings.json (shared with the subagent extension). */
|
|
@@ -172,6 +202,7 @@ export function readSubagentSettings(): SubagentSettings {
|
|
|
172
202
|
return {
|
|
173
203
|
agentOverrides: raw.subagents?.agentOverrides,
|
|
174
204
|
globalThinking: raw.subagents?.globalThinking ?? raw.defaultThinkingLevel,
|
|
205
|
+
modelRoles: raw.modelRoles,
|
|
175
206
|
};
|
|
176
207
|
} catch {
|
|
177
208
|
return {};
|
package/extensions/index.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* host conversation context — only the final phase output is returned.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
13
15
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
14
16
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
15
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
19
|
import { Text } from "@earendil-works/pi-tui";
|
|
17
20
|
import { Type } from "typebox";
|
|
18
21
|
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
@@ -50,8 +53,8 @@ const ShorthandStep = Type.Object(
|
|
|
50
53
|
);
|
|
51
54
|
|
|
52
55
|
const TaskflowParams = Type.Object({
|
|
53
|
-
action: StringEnum(["run", "save", "resume", "list", "agents"] as const, {
|
|
54
|
-
description: "What to do: run a flow, save a definition, resume a paused run, list saved flows,
|
|
56
|
+
action: StringEnum(["run", "save", "resume", "list", "agents", "init"] as const, {
|
|
57
|
+
description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, or init model role configuration",
|
|
55
58
|
default: "run",
|
|
56
59
|
}),
|
|
57
60
|
name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
|
|
@@ -167,7 +170,28 @@ async function runFlow(
|
|
|
167
170
|
// the heartbeat timer is cleared by the finally block below.
|
|
168
171
|
const settings = readSubagentSettings();
|
|
169
172
|
const scope: AgentScope = def.agentScope ?? "user";
|
|
170
|
-
const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
|
|
173
|
+
const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles);
|
|
174
|
+
|
|
175
|
+
// Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
|
|
176
|
+
const unresolvedRoles = agents
|
|
177
|
+
.filter(a => a.model && /^\{\{\w+\}\}$/.test(a.model))
|
|
178
|
+
.map(a => a.model!.match(/^\{\{(\w+)\}\}$/)![1]);
|
|
179
|
+
if (unresolvedRoles.length > 0) {
|
|
180
|
+
const unique = [...new Set(unresolvedRoles)];
|
|
181
|
+
console.warn(
|
|
182
|
+
`[taskflow] Hint: ${unique.length} model role(s) not configured: ${unique.join(", ")}. ` +
|
|
183
|
+
`Agents will use the default model (slower / less optimal). ` +
|
|
184
|
+
`Run /tf init to auto-generate modelRoles config.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Pre-flight: warn if any phase references an agent not in the registry
|
|
189
|
+
const agentNames = new Set(agents.map(a => a.name));
|
|
190
|
+
for (const p of def.phases ?? []) {
|
|
191
|
+
if (p.agent && !agentNames.has(p.agent)) {
|
|
192
|
+
console.warn(`[taskflow] Warning: phase '${p.id}' references agent '${p.agent}' which was not found. Available: ${[...agentNames].join(", ")}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
171
195
|
|
|
172
196
|
const result = await executeTaskflow(state, {
|
|
173
197
|
cwd: ctx.cwd,
|
|
@@ -208,7 +232,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
208
232
|
}
|
|
209
233
|
};
|
|
210
234
|
|
|
211
|
-
pi.on("session_start", async (_e, ctx) =>
|
|
235
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
236
|
+
registerSavedFlowCommands(ctx);
|
|
237
|
+
|
|
238
|
+
// Hint: prompt to configure model roles if not set
|
|
239
|
+
try {
|
|
240
|
+
const settings = readSubagentSettings();
|
|
241
|
+
if (!settings.modelRoles) {
|
|
242
|
+
console.warn(
|
|
243
|
+
`[taskflow] Model roles not configured — agents will use the default model. ` +
|
|
244
|
+
`Run /tf init to generate a recommended modelRoles config.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} catch {}
|
|
248
|
+
});
|
|
212
249
|
|
|
213
250
|
// ---- The LLM-callable tool ----
|
|
214
251
|
pi.registerTool({
|
|
@@ -235,10 +272,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
235
272
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
236
273
|
const action = params.action ?? "run";
|
|
237
274
|
|
|
238
|
-
//
|
|
275
|
+
// init — configure model roles
|
|
276
|
+
if (action === "init") {
|
|
277
|
+
const settingsPath = path.join(getAgentDir(), "settings.json");
|
|
278
|
+
let existing: Record<string, unknown> = {};
|
|
279
|
+
try { existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch {}
|
|
280
|
+
|
|
281
|
+
const roleDescs: Record<string, string> = {
|
|
282
|
+
fast: "cheap & quick (executor, scout, recover, verifier, doc-writer, test-engineer)",
|
|
283
|
+
strong: "balanced (planner, reviewer, executor-code)",
|
|
284
|
+
thinker: "deep analysis (analyst, critic)",
|
|
285
|
+
arbiter: "final judgment (plan-arbiter, final-arbiter)",
|
|
286
|
+
vision: "multimodal (executor-ui, visual-explorer)",
|
|
287
|
+
reasoner: "cautious reasoning (risk-reviewer, security-reviewer)",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
if (existing.modelRoles) {
|
|
291
|
+
const roles = existing.modelRoles as Record<string, string>;
|
|
292
|
+
const text = [
|
|
293
|
+
`Model roles already configured in ${settingsPath}:`,
|
|
294
|
+
...Object.entries(roles).map(([k, v]) => ` ${k.padEnd(10)} → ${v} (${roleDescs[k] ?? ""})`),
|
|
295
|
+
``,
|
|
296
|
+
`To reconfigure, run /tf init interactively or edit settings.json directly.`,
|
|
297
|
+
].join("\n");
|
|
298
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const defaults: Record<string, string> = {
|
|
302
|
+
fast: "openrouter/deepseek/deepseek-v4-flash",
|
|
303
|
+
strong: "openrouter/xiaomi/mimo-v2.5-pro",
|
|
304
|
+
thinker: "openrouter/deepseek/deepseek-v4-pro",
|
|
305
|
+
arbiter: "openrouter/qwen/qwen3.7-max",
|
|
306
|
+
vision: "minimax/MiniMax-M3",
|
|
307
|
+
reasoner: "z-ai/glm-5.1",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const newSettings = { ...existing, modelRoles: defaults };
|
|
311
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
312
|
+
fs.writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2) + "\n", "utf-8");
|
|
313
|
+
|
|
314
|
+
const text = [
|
|
315
|
+
`Wrote default model roles to ${settingsPath}:`,
|
|
316
|
+
...Object.entries(defaults).map(([k, v]) => ` ${k.padEnd(10)} → ${v} (${roleDescs[k]})`),
|
|
317
|
+
``,
|
|
318
|
+
`These models require provider-specific API keys. Edit settings.json or run /tf init interactively.`,
|
|
319
|
+
].join("\n");
|
|
320
|
+
return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// agents — list available agents the LLM can use in phase definitions
|
|
239
324
|
if (action === "agents") {
|
|
240
325
|
const scope = params.scope ?? "both";
|
|
241
|
-
const
|
|
326
|
+
const settings2 = readSubagentSettings();
|
|
327
|
+
const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles);
|
|
242
328
|
const text = agents.length
|
|
243
329
|
? agents
|
|
244
330
|
.map(
|
|
@@ -378,9 +464,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
464
|
|
|
379
465
|
// ---- The /tf user command ----
|
|
380
466
|
pi.registerCommand("tf", {
|
|
381
|
-
description: "Taskflow: list | run <name> | show <name> | runs",
|
|
467
|
+
description: "Taskflow: list | run <name> | show <name> | runs | init",
|
|
382
468
|
getArgumentCompletions: (prefix) => {
|
|
383
|
-
const subs = ["list", "run", "show", "runs", "resume"];
|
|
469
|
+
const subs = ["list", "run", "show", "runs", "resume", "init"];
|
|
384
470
|
const items = subs.map((s) => ({ value: s, label: s }));
|
|
385
471
|
const filtered = items.filter((i) => i.value.startsWith(prefix));
|
|
386
472
|
return filtered.length > 0 ? filtered : null;
|
|
@@ -472,6 +558,90 @@ export default function (pi: ExtensionAPI) {
|
|
|
472
558
|
return;
|
|
473
559
|
}
|
|
474
560
|
|
|
561
|
+
if (sub === "init") {
|
|
562
|
+
const settingsPath = path.join(getAgentDir(), "settings.json");
|
|
563
|
+
let existing: Record<string, unknown> = {};
|
|
564
|
+
try { existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch {}
|
|
565
|
+
const currentRoles = (existing.modelRoles ?? {}) as Record<string, string>;
|
|
566
|
+
|
|
567
|
+
// Role definitions: name → { description, recommended models }
|
|
568
|
+
// Role definitions: name → description (no per-role filtering)
|
|
569
|
+
const roleDefs: Array<{ role: string; desc: string }> = [
|
|
570
|
+
{ role: "fast", desc: "Cheap & quick — high-volume, low-stakes tasks (executor, scout, recover, verifier, doc-writer, test-engineer)" },
|
|
571
|
+
{ role: "strong", desc: "Balanced — planning, review, moderate complexity (planner, reviewer, executor-code)" },
|
|
572
|
+
{ role: "thinker", desc: "Deep analysis — requirements, ambiguity detection, critique (analyst, critic)" },
|
|
573
|
+
{ role: "arbiter", desc: "Final judgment — tiebreak, plan quality gates (plan-arbiter, final-arbiter)" },
|
|
574
|
+
{ role: "vision", desc: "Multimodal — UI work, design reading, Figma analysis (executor-ui, visual-explorer)" },
|
|
575
|
+
{ role: "reasoner", desc: "Cautious reasoning — security, risk review, sensitive changes (risk-reviewer, security-reviewer)" },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
if (!ctx.hasUI) {
|
|
579
|
+
if (Object.keys(currentRoles).length > 0) {
|
|
580
|
+
ctx.ui.notify(
|
|
581
|
+
`Current model roles:\n` +
|
|
582
|
+
Object.entries(currentRoles).map(([k, v]) => ` ${k.padEnd(10)} → ${v}`).join("\n"),
|
|
583
|
+
"info"
|
|
584
|
+
);
|
|
585
|
+
} else {
|
|
586
|
+
ctx.ui.notify(
|
|
587
|
+
`No modelRoles configured. Run /tf init in an interactive session to select models.`,
|
|
588
|
+
"warning"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Use the user's scoped/enabled models (same list as /model command).
|
|
595
|
+
// Fall back to all auth-configured models if none are scoped.
|
|
596
|
+
const enabledModels = (existing.enabledModels as string[] | undefined) ?? [];
|
|
597
|
+
const modelList = enabledModels.length > 0
|
|
598
|
+
? enabledModels
|
|
599
|
+
: ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
|
|
600
|
+
|
|
601
|
+
// Interactive: walk through each role using the same model list
|
|
602
|
+
const chosen: Record<string, string> = {};
|
|
603
|
+
for (const rd of roleDefs) {
|
|
604
|
+
const current = currentRoles[rd.role];
|
|
605
|
+
|
|
606
|
+
const seen = new Set<string>();
|
|
607
|
+
const options: string[] = [];
|
|
608
|
+
for (const m of modelList) {
|
|
609
|
+
if (seen.has(m)) continue;
|
|
610
|
+
seen.add(m);
|
|
611
|
+
options.push(m === current ? `${m} (current)` : m);
|
|
612
|
+
}
|
|
613
|
+
options.push("───────────────");
|
|
614
|
+
options.push("Custom (type your own)");
|
|
615
|
+
|
|
616
|
+
const title = `Model for '${rd.role}' — ${rd.desc}` + (current ? `\nCurrent: ${current}` : "");
|
|
617
|
+
const pick = await ctx.ui.select(title, options, { signal: ctx.signal });
|
|
618
|
+
|
|
619
|
+
if (!pick || pick.startsWith("───")) {
|
|
620
|
+
chosen[rd.role] = current ?? modelList[0] ?? "";
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (pick === "Custom (type your own)") {
|
|
625
|
+
const custom = await ctx.ui.input(`Enter model identifier for '${rd.role}'`, "provider/model-id", { signal: ctx.signal });
|
|
626
|
+
chosen[rd.role] = custom?.trim() || current || "";
|
|
627
|
+
} else {
|
|
628
|
+
chosen[rd.role] = pick.replace(" (current)", "");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Save
|
|
633
|
+
const newSettings = { ...existing, modelRoles: chosen };
|
|
634
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
635
|
+
fs.writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2) + "\n", "utf-8");
|
|
636
|
+
|
|
637
|
+
ctx.ui.notify(
|
|
638
|
+
`Saved model roles to ${settingsPath}:\n` +
|
|
639
|
+
Object.entries(chosen).map(([k, v]) => ` ${k.padEnd(10)} → ${v}`).join("\n"),
|
|
640
|
+
"info"
|
|
641
|
+
);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
475
645
|
ctx.ui.notify(`Unknown subcommand: ${sub}`, "warning");
|
|
476
646
|
},
|
|
477
647
|
});
|
package/extensions/render.ts
CHANGED
|
@@ -53,7 +53,12 @@ function elapsed(ms: number): string {
|
|
|
53
53
|
|
|
54
54
|
function phaseElapsed(ps: PhaseState): number {
|
|
55
55
|
if (!ps.startedAt) return 0;
|
|
56
|
-
|
|
56
|
+
// Guard against a stale/clock-skewed endedAt that precedes startedAt (e.g. a
|
|
57
|
+
// resumed phase that still carries a previous attempt's endedAt): treat such
|
|
58
|
+
// an end time as absent and fall back to now. Finally clamp to >= 0 so the
|
|
59
|
+
// TUI never shows a negative (and frozen) elapsed time.
|
|
60
|
+
const end = ps.endedAt && ps.endedAt >= ps.startedAt ? ps.endedAt : Date.now();
|
|
61
|
+
return Math.max(0, end - ps.startedAt);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function miniBar(done: number, total: number, theme: Theme, width = 8): string {
|
|
@@ -91,7 +96,7 @@ function runElapsed(state: RunState): number {
|
|
|
91
96
|
const min = Math.min(...starts);
|
|
92
97
|
const ends = Object.values(state.phases).map((p) => p.endedAt ?? Date.now());
|
|
93
98
|
const max = ends.length ? Math.max(...ends) : Date.now();
|
|
94
|
-
return max - min;
|
|
99
|
+
return Math.max(0, max - min);
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
export function summarizeRun(state: RunState): string {
|
package/extensions/runner.ts
CHANGED
|
@@ -42,8 +42,24 @@ export interface RunOptions {
|
|
|
42
42
|
signal?: AbortSignal;
|
|
43
43
|
/** Fires on each assistant turn with the latest activity + accumulated usage. */
|
|
44
44
|
onLive?: (live: LiveUpdate) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Idle watchdog: if the subagent produces no stdout for this many ms, it is
|
|
47
|
+
* considered stalled (hung stream / provider stall / tool deadlock) and is
|
|
48
|
+
* killed (SIGTERM → SIGKILL). Resets on every stdout chunk. 0/undefined keeps
|
|
49
|
+
* the prior behaviour (no idle timeout). Defaults to DEFAULT_IDLE_TIMEOUT_MS.
|
|
50
|
+
*/
|
|
51
|
+
idleTimeoutMs?: number;
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Default idle-watchdog window. A subagent that emits nothing on stdout for this
|
|
56
|
+
* long is treated as wedged and killed so a single stalled child cannot hang the
|
|
57
|
+
* entire taskflow forever (the only previous escape was a manual user abort).
|
|
58
|
+
* 5 minutes is generous enough for slow reasoning/long tool calls while still
|
|
59
|
+
* bounding a true hang.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
62
|
+
|
|
47
63
|
export function isFailed(r: RunResult): boolean {
|
|
48
64
|
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
49
65
|
}
|
|
@@ -306,6 +322,7 @@ export async function runAgentTask(
|
|
|
306
322
|
args.push(`Task: ${task}`);
|
|
307
323
|
|
|
308
324
|
let wasAborted = false;
|
|
325
|
+
let idleTimedOut = false;
|
|
309
326
|
const exitCode = await new Promise<number>((resolve) => {
|
|
310
327
|
const invocation = getPiInvocation(args);
|
|
311
328
|
const proc = spawn(invocation.command, invocation.args, {
|
|
@@ -315,12 +332,40 @@ export async function runAgentTask(
|
|
|
315
332
|
});
|
|
316
333
|
let buffer = "";
|
|
317
334
|
|
|
335
|
+
// Idle watchdog: a subagent that goes silent on stdout for too long is
|
|
336
|
+
// treated as wedged and killed, so one stalled child cannot hang the
|
|
337
|
+
// whole taskflow forever. The timer is reset on every stdout chunk and
|
|
338
|
+
// torn down on close/error.
|
|
339
|
+
const idleMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
340
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
341
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
342
|
+
const clearTimers = () => {
|
|
343
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
344
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
345
|
+
};
|
|
346
|
+
const hardKill = () => {
|
|
347
|
+
proc.kill("SIGTERM");
|
|
348
|
+
forceKillTimer = setTimeout(() => proc.kill("SIGKILL"), 5000);
|
|
349
|
+
forceKillTimer.unref();
|
|
350
|
+
};
|
|
351
|
+
const armIdle = () => {
|
|
352
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
353
|
+
if (idleMs <= 0) return; // disabled
|
|
354
|
+
idleTimer = setTimeout(() => {
|
|
355
|
+
idleTimedOut = true;
|
|
356
|
+
hardKill();
|
|
357
|
+
}, idleMs);
|
|
358
|
+
idleTimer.unref();
|
|
359
|
+
};
|
|
360
|
+
armIdle();
|
|
361
|
+
|
|
318
362
|
const processLine = (line: string) => {
|
|
319
363
|
const live = foldEventLine(acc, line);
|
|
320
364
|
if (live && opts.onLive) opts.onLive(live);
|
|
321
365
|
};
|
|
322
366
|
|
|
323
367
|
proc.stdout.on("data", (data) => {
|
|
368
|
+
armIdle(); // progress observed — reset the idle watchdog
|
|
324
369
|
buffer += data.toString();
|
|
325
370
|
const lines = buffer.split("\n");
|
|
326
371
|
buffer = lines.pop() || "";
|
|
@@ -330,10 +375,12 @@ export async function runAgentTask(
|
|
|
330
375
|
result.stderr += data.toString();
|
|
331
376
|
});
|
|
332
377
|
proc.on("close", (code) => {
|
|
378
|
+
clearTimers();
|
|
333
379
|
if (buffer.trim()) processLine(buffer);
|
|
334
380
|
resolve(code ?? 0);
|
|
335
381
|
});
|
|
336
382
|
proc.on("error", (err) => {
|
|
383
|
+
clearTimers();
|
|
337
384
|
if (!result.stderr) result.stderr = err.message;
|
|
338
385
|
if (!result.errorMessage) result.errorMessage = err.message;
|
|
339
386
|
resolve(1);
|
|
@@ -364,7 +411,13 @@ export async function runAgentTask(
|
|
|
364
411
|
result.stopReason = acc.stopReason;
|
|
365
412
|
result.errorMessage = acc.errorMessage;
|
|
366
413
|
result.output = getFinalOutput(acc.messages);
|
|
367
|
-
if (
|
|
414
|
+
if (idleTimedOut) {
|
|
415
|
+
// Distinct, actionable signal: the child was killed for being idle, not
|
|
416
|
+
// a user abort. stopReason "error" keeps it in the failed bucket so the
|
|
417
|
+
// runtime's retry/fail handling treats it as a real failure.
|
|
418
|
+
result.stopReason = "error";
|
|
419
|
+
result.errorMessage = `Subagent stalled: no output for ${Math.round((opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS) / 1000)}s (idle timeout) — killed`;
|
|
420
|
+
} else if (wasAborted) {
|
|
368
421
|
result.stopReason = "aborted";
|
|
369
422
|
result.errorMessage = "Subagent was aborted";
|
|
370
423
|
}
|
package/extensions/runtime.ts
CHANGED
|
@@ -437,7 +437,7 @@ async function executePhase(
|
|
|
437
437
|
const { text } = interpolate(phase.task ?? "", ctx);
|
|
438
438
|
const fullTask = preRead + text;
|
|
439
439
|
const agentName = resolveAgent(phase.agent, deps, state);
|
|
440
|
-
const inputHash = hashInput(phase.id, agentName, fullTask);
|
|
440
|
+
const inputHash = hashInput(phase.id, agentName, phase.model ?? "", fullTask);
|
|
441
441
|
const cached = cachedPhase(prior, inputHash);
|
|
442
442
|
if (cached) return cached;
|
|
443
443
|
|
|
@@ -455,7 +455,7 @@ async function executePhase(
|
|
|
455
455
|
task: preRead + r.text,
|
|
456
456
|
};
|
|
457
457
|
});
|
|
458
|
-
const inputHash = hashInput(phase.id, JSON.stringify(branches));
|
|
458
|
+
const inputHash = hashInput(phase.id, phase.model ?? "", JSON.stringify(branches));
|
|
459
459
|
const cached = cachedPhase(prior, inputHash);
|
|
460
460
|
if (cached) return cached;
|
|
461
461
|
|
|
@@ -485,7 +485,7 @@ async function executePhase(
|
|
|
485
485
|
task: preRead + interpolate(phase.task ?? "", localCtx).text,
|
|
486
486
|
};
|
|
487
487
|
});
|
|
488
|
-
const inputHash = hashInput(phase.id, JSON.stringify(tasks));
|
|
488
|
+
const inputHash = hashInput(phase.id, phase.model ?? "", JSON.stringify(tasks));
|
|
489
489
|
const cached = cachedPhase(prior, inputHash);
|
|
490
490
|
if (cached) return cached;
|
|
491
491
|
|
|
@@ -496,7 +496,7 @@ async function executePhase(
|
|
|
496
496
|
if (type === "approval") {
|
|
497
497
|
const ctx = buildInterpolationContext(state, previousOutput);
|
|
498
498
|
const message = interpolate(phase.task ?? "Approve to continue?", ctx).text;
|
|
499
|
-
const inputHash = hashInput(phase.id, "approval", message);
|
|
499
|
+
const inputHash = hashInput(phase.id, phase.model ?? "", "approval", message);
|
|
500
500
|
const cached = cachedPhase(prior, inputHash);
|
|
501
501
|
if (cached) return cached;
|
|
502
502
|
|
|
@@ -853,11 +853,19 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise<Ru
|
|
|
853
853
|
}
|
|
854
854
|
|
|
855
855
|
const startedAt = Date.now();
|
|
856
|
+
// Re-running a phase (resume after a previous failed/done attempt) must
|
|
857
|
+
// start from a clean "running" state. Spreading the prior PhaseState
|
|
858
|
+
// would carry over its terminal `endedAt` (and `error`/`gate`/`output`),
|
|
859
|
+
// leaving a running phase with an old endedAt < new startedAt — which
|
|
860
|
+
// renders as a frozen NEGATIVE elapsed time in the TUI. Keep only the
|
|
861
|
+
// fields that are still meaningful across attempts (model, attempts).
|
|
862
|
+
const priorPs = state.phases[phase.id];
|
|
856
863
|
state.phases[phase.id] = {
|
|
857
|
-
...(state.phases[phase.id] ?? { id: phase.id }),
|
|
858
864
|
id: phase.id,
|
|
859
865
|
status: "running",
|
|
860
866
|
startedAt,
|
|
867
|
+
...(priorPs?.model ? { model: priorPs.model } : {}),
|
|
868
|
+
...(priorPs?.attempts ? { attempts: priorPs.attempts } : {}),
|
|
861
869
|
};
|
|
862
870
|
safeProgress(deps, state);
|
|
863
871
|
|
package/extensions/schema.ts
CHANGED
|
@@ -342,9 +342,9 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
|
|
|
342
342
|
errors.push(`Phase '${p.id}': agent name '${p.agent}' uses underscores — use hyphens (e.g. 'executor-code' not 'executor_code')`);
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// Phase id convention: hyphens only (consistent with
|
|
345
|
+
// Phase id convention: hyphens only (consistent with interpolation placeholders like {steps.audit-each.output})
|
|
346
346
|
if (p.id && p.id.includes("_")) {
|
|
347
|
-
errors.push(`Phase '${p.id}': id uses underscores — use hyphens for consistency with
|
|
347
|
+
errors.push(`Phase '${p.id}': id uses underscores — use hyphens for consistency with interpolation placeholders (e.g. {steps.audit-each.output})`);
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
|
|
@@ -363,7 +363,7 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
|
|
|
363
363
|
const VALID_AGENT_RE = /^[a-z][a-z0-9-]*$/;
|
|
364
364
|
for (const p of flow.phases) {
|
|
365
365
|
if (!p?.id) continue;
|
|
366
|
-
if (p.agent && !VALID_AGENT_RE.test(p.agent)) {
|
|
366
|
+
if (p.agent && !p.agent.includes("_") && !VALID_AGENT_RE.test(p.agent)) {
|
|
367
367
|
errors.push(`Phase '${p.id}': agent '${p.agent}' has invalid name format (expected lowercase alphanumeric with hyphens)`);
|
|
368
368
|
}
|
|
369
369
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
39
|
+
"test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
40
40
|
"test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|