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.
@@ -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 {};
@@ -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, or list available agents you can use in phases",
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) => registerSavedFlowCommands(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
- // agentslist available agents the LLM can use in phase definitions
275
+ // initconfigure 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 { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined);
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
  });
@@ -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
- return (ps.endedAt ?? Date.now()) - ps.startedAt;
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 {
@@ -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 (wasAborted) {
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
  }
@@ -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
 
@@ -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 agent naming)
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 agent naming convention`);
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.10",
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": {