pi-taskflow 0.0.15 → 0.0.17

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.
@@ -10,10 +10,57 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  export type AgentScope = "user" | "project" | "both";
12
12
 
13
- export interface AgentOverride {
14
- model?: string;
15
- thinking?: string;
16
- tools?: string[];
13
+ export interface TaskflowSettings {
14
+ /** Whether taskflow's package-local built-in agents are available to flows. */
15
+ builtInAgents: boolean;
16
+ /** Whether package-local built-ins are copied into the current project's .pi/agents/. */
17
+ syncBuiltinAgentsToProject: boolean;
18
+ /** Maximum completed/failed runs to keep. 0 disables cleanup. */
19
+ maxKeptRuns: number;
20
+ /** Maximum age (days) for completed/failed runs. 0 disables age cleanup. */
21
+ maxRunAgeDays: number;
22
+ }
23
+
24
+ import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, writeFileAtomic } from "./store.ts";
25
+
26
+ export const DEFAULT_TASKFLOW_SETTINGS: TaskflowSettings = {
27
+ builtInAgents: true,
28
+ syncBuiltinAgentsToProject: false,
29
+ maxKeptRuns: DEFAULT_KEPT_RUNS,
30
+ maxRunAgeDays: DEFAULT_RUN_AGE_DAYS,
31
+ };
32
+
33
+ export function normalizeTaskflowSettings(raw: unknown): TaskflowSettings {
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
35
+ return { ...DEFAULT_TASKFLOW_SETTINGS };
36
+ }
37
+ const rec = raw as Record<string, unknown>;
38
+ return {
39
+ builtInAgents:
40
+ typeof rec.builtInAgents === "boolean"
41
+ ? rec.builtInAgents
42
+ : DEFAULT_TASKFLOW_SETTINGS.builtInAgents,
43
+ syncBuiltinAgentsToProject:
44
+ typeof rec.syncBuiltinAgentsToProject === "boolean"
45
+ ? rec.syncBuiltinAgentsToProject
46
+ : DEFAULT_TASKFLOW_SETTINGS.syncBuiltinAgentsToProject,
47
+ maxKeptRuns:
48
+ typeof rec.maxKeptRuns === "number" && rec.maxKeptRuns >= 0 && Number.isInteger(rec.maxKeptRuns)
49
+ ? rec.maxKeptRuns
50
+ : DEFAULT_TASKFLOW_SETTINGS.maxKeptRuns,
51
+ maxRunAgeDays:
52
+ typeof rec.maxRunAgeDays === "number" && rec.maxRunAgeDays >= 0 && Number.isInteger(rec.maxRunAgeDays)
53
+ ? rec.maxRunAgeDays
54
+ : DEFAULT_TASKFLOW_SETTINGS.maxRunAgeDays,
55
+ };
56
+ }
57
+
58
+ export function shouldLoadBuiltinAgents(settings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS): boolean {
59
+ return settings.builtInAgents;
60
+ }
61
+
62
+ export function shouldSyncBuiltinAgentsToProject(settings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS): boolean {
63
+ return settings.builtInAgents && settings.syncBuiltinAgentsToProject;
17
64
  }
18
65
 
19
66
  export interface AgentConfig {
@@ -67,16 +114,18 @@ function loadAgentsFromDir(dir: string, source: "user" | "project" | "built-in")
67
114
  if (!frontmatter.name || !frontmatter.description) continue;
68
115
 
69
116
  // frontmatter is YAML-parsed: tools may be a comma-separated string ("a, b")
70
- // OR a YAML sequence ([a, b]). Handle both forms.
117
+ // OR a YAML sequence ([a, b]). Handle both forms; reject other types to
118
+ // prevent garbage output from malformed YAML (e.g. boolean, number).
71
119
  const rawTools = frontmatter.tools;
72
- const tools: string[] | undefined = Array.isArray(rawTools)
73
- ? rawTools.map((t) => String(t).trim()).filter(Boolean)
74
- : rawTools !== undefined && rawTools !== null
75
- ? String(rawTools)
76
- .split(",")
77
- .map((t) => t.trim())
78
- .filter(Boolean)
79
- : undefined;
120
+ let tools: string[] | undefined;
121
+ if (Array.isArray(rawTools)) {
122
+ tools = rawTools.map((t) => String(t).trim()).filter(Boolean);
123
+ } else if (typeof rawTools === "string") {
124
+ tools = rawTools.split(",").map((t) => t.trim()).filter(Boolean);
125
+ } else if (rawTools !== undefined && rawTools !== null) {
126
+ console.warn(`[taskflow] Agent '${String(frontmatter.name)}': 'tools' must be a string or array, got ${typeof rawTools}. Ignoring.`);
127
+ tools = undefined;
128
+ }
80
129
 
81
130
  agents.push({
82
131
  name: String(frontmatter.name),
@@ -120,14 +169,15 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
120
169
  export function discoverAgents(
121
170
  cwd: string,
122
171
  scope: AgentScope,
123
- overrides?: Record<string, AgentOverride>,
124
172
  modelRoles?: Record<string, string>,
173
+ taskflowSettings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS,
125
174
  ): 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)
175
+ // Built-in agents ship with the package (extensions/agents/*.md).
176
+ // PI_TASKFLOW_BUILTIN_AGENTS_DIR is kept as a test hook only; user-facing
177
+ // enable/disable lives in settings.json under `taskflow.builtInAgents`.
128
178
  const builtInDirEnv = process.env.PI_TASKFLOW_BUILTIN_AGENTS_DIR;
129
179
  const builtInDir = builtInDirEnv ? builtInDirEnv : builtInDirEnv === undefined ? path.resolve(import.meta.dirname, "agents") : "";
130
- const builtInAgents = builtInDir ? loadAgentsFromDir(builtInDir, "built-in") : [];
180
+ const builtInAgents = shouldLoadBuiltinAgents(taskflowSettings) && builtInDir ? loadAgentsFromDir(builtInDir, "built-in") : [];
131
181
 
132
182
  const userDir = path.join(getAgentDir(), "agents");
133
183
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
@@ -147,28 +197,16 @@ export function discoverAgents(
147
197
  for (const a of projectAgents) agentMap.set(a.name, a);
148
198
  }
149
199
 
150
- if (overrides) {
151
- for (const [name, override] of Object.entries(overrides)) {
152
- const agent = agentMap.get(name);
153
- if (agent) {
154
- // Clone before mutating: agentMap owns the original AgentConfig
155
- // (loaded from disk in loadAgentsFromDir). Mutating it in place
156
- // would cause cross-contamination for any caller that retains a
157
- // reference and invokes discoverAgents again with different overrides.
158
- const mutated: AgentConfig = { ...agent };
159
- if (override.model !== undefined) mutated.model = override.model;
160
- if (override.thinking !== undefined) mutated.thinking = override.thinking;
161
- if (override.tools !== undefined) mutated.tools = override.tools;
162
- agentMap.set(name, mutated);
163
- }
164
- }
165
- }
166
-
167
200
  // Resolve {{role}} model references (e.g. {{fast}} → openrouter/deepseek/v4-flash)
201
+ // Clone before mutating, consistent with the overrides block above.
168
202
  if (modelRoles) {
169
- for (const agent of agentMap.values()) {
203
+ for (const [name, agent] of agentMap.entries()) {
170
204
  const resolved = resolveModelRole(agent.model, modelRoles);
171
- if (resolved !== agent.model) agent.model = resolved;
205
+ if (resolved !== agent.model) {
206
+ const mutated: AgentConfig = { ...agent };
207
+ mutated.model = resolved;
208
+ agentMap.set(name, mutated);
209
+ }
172
210
  }
173
211
  }
174
212
 
@@ -176,9 +214,9 @@ export function discoverAgents(
176
214
  }
177
215
 
178
216
  export interface SubagentSettings {
179
- agentOverrides?: Record<string, AgentOverride>;
180
217
  globalThinking?: string;
181
218
  modelRoles?: Record<string, string>;
219
+ taskflow: TaskflowSettings;
182
220
  }
183
221
 
184
222
  /**
@@ -197,15 +235,15 @@ export function resolveModelRole(model: string | undefined, roles?: Record<strin
197
235
  export function readSubagentSettings(): SubagentSettings {
198
236
  try {
199
237
  const settingsPath = path.join(getAgentDir(), "settings.json");
200
- if (!fs.existsSync(settingsPath)) return {};
238
+ if (!fs.existsSync(settingsPath)) return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
201
239
  const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
202
240
  return {
203
- agentOverrides: raw.subagents?.agentOverrides,
204
241
  globalThinking: raw.subagents?.globalThinking ?? raw.defaultThinkingLevel,
205
242
  modelRoles: raw.modelRoles,
243
+ taskflow: normalizeTaskflowSettings(raw.taskflow),
206
244
  };
207
245
  } catch {
208
- return {};
246
+ return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
209
247
  }
210
248
  }
211
249
 
@@ -249,7 +287,7 @@ export function syncBuiltinAgentsToProject(cwd: string): void {
249
287
 
250
288
  try {
251
289
  const content = fs.readFileSync(src, "utf-8");
252
- fs.writeFileSync(dst, content, "utf-8");
290
+ writeFileAtomic(dst, content);
253
291
  } catch {
254
292
  // Best-effort: a locked file must not block the sync.
255
293
  }
@@ -47,9 +47,13 @@ function resolveOne(entry: string, cwd: string): string {
47
47
  cwd,
48
48
  encoding: "utf-8",
49
49
  stdio: ["ignore", "pipe", "ignore"],
50
+ timeout: 30_000,
50
51
  }).trim();
51
52
  return `git:${ref}=${sha}`;
52
- } catch {
53
+ } catch (e: unknown) {
54
+ if ((e as NodeJS.ErrnoException).code === "ETIMEDOUT") {
55
+ return `git:${ref}=<timeout>`;
56
+ }
53
57
  return `git:${ref}=<no-git>`;
54
58
  }
55
59
  }
@@ -24,7 +24,7 @@ import {
24
24
  runInteractiveInit,
25
25
  } from "./init.ts";
26
26
  import { Type } from "typebox";
27
- import { type AgentScope, discoverAgents, readSubagentSettings, syncBuiltinAgentsToProject } from "./agents.ts";
27
+ import { type AgentScope, discoverAgents, readSubagentSettings, shouldSyncBuiltinAgentsToProject, syncBuiltinAgentsToProject } from "./agents.ts";
28
28
  import { renderRunResult, summarizeRun } from "./render.ts";
29
29
  import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
30
30
  import { executeTaskflow, type ApprovalDecision, type ApprovalRequest, type RuntimeResult } from "./runtime.ts";
@@ -38,8 +38,11 @@ import {
38
38
  type RunState,
39
39
  saveFlow,
40
40
  saveRun,
41
+ DEFAULT_KEPT_RUNS,
42
+ DEFAULT_RUN_AGE_DAYS,
41
43
  } from "./store.ts";
42
44
  import { CacheStore } from "./cache.ts";
45
+ import { safeParse } from "./interpolate.ts";
43
46
 
44
47
  interface TaskflowDetails {
45
48
  state?: RunState;
@@ -142,11 +145,12 @@ async function runFlow(
142
145
 
143
146
  // Throttled persistence: avoid disk writes on every sub-item event.
144
147
  let lastPersist = 0;
148
+ const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS };
145
149
  const persistThrottled = (s: RunState) => {
146
150
  const now = Date.now();
147
151
  if (now - lastPersist >= 1000) {
148
152
  lastPersist = now;
149
- saveRun(s);
153
+ saveRun(s, cleanupConfig);
150
154
  }
151
155
  };
152
156
 
@@ -189,8 +193,10 @@ async function runFlow(
189
193
  // discoverAgents or readSubagentSettings (F-001) is caught and
190
194
  // the heartbeat timer is cleared by the finally block below.
191
195
  const settings = readSubagentSettings();
196
+ cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns;
197
+ cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays;
192
198
  const scope: AgentScope = def.agentScope ?? "user";
193
- const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles);
199
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow);
194
200
 
195
201
  // Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
196
202
  const unresolvedRoles = agents
@@ -225,7 +231,7 @@ async function runFlow(
225
231
  return result;
226
232
  } finally {
227
233
  if (heartbeat) clearInterval(heartbeat);
228
- saveRun(state); // force-persist terminal state
234
+ saveRun(state, cleanupConfig); // force-persist terminal state
229
235
  emit(state); // final render reflecting terminal state
230
236
  }
231
237
  }
@@ -255,16 +261,44 @@ export default function (pi: ExtensionAPI) {
255
261
  pi.on("session_start", async (_e, ctx) => {
256
262
  registerSavedFlowCommands(ctx);
257
263
 
258
- // Sync built-in agents into .pi/agents/ so Pi's native subagent tool
259
- // (and any other extension) can discover them taskflow's
260
- // extensions/agents/ directory is invisible to the rest of Pi.
264
+ // Optional: copy built-in agents into .pi/agents/ so Pi's native
265
+ // subagent tool (and other extensions) can discover them. This is
266
+ // disabled by default to avoid surprising project file creation.
261
267
  try {
262
- syncBuiltinAgentsToProject(ctx.cwd);
268
+ const settings = readSubagentSettings();
269
+ if (shouldSyncBuiltinAgentsToProject(settings.taskflow)) {
270
+ syncBuiltinAgentsToProject(ctx.cwd);
271
+ }
263
272
  } catch {
264
273
  // Best-effort: a locked or readonly .pi/ directory must not block
265
274
  // session startup.
266
275
  }
267
276
 
277
+ // Upgrade hint: if the project already has .pi/agents/ with agent
278
+ // files but no explicit taskflow settings, the user is upgrading
279
+ // from the old default (sync=true) and may be surprised that sync
280
+ // is now disabled by default.
281
+ try {
282
+ const raw = readSettings();
283
+ if (!("taskflow" in raw)) {
284
+ const fs = await import("node:fs");
285
+ const path = await import("node:path");
286
+ const projectAgentsDir = path.join(ctx.cwd, ".pi", "agents");
287
+ try {
288
+ const entries = fs.readdirSync(projectAgentsDir).filter((e: string) => e.endsWith(".md"));
289
+ if (entries.length > 0) {
290
+ console.warn(
291
+ `[taskflow] Note: built-in agents are no longer synced to .pi/agents/ by default. ` +
292
+ `If you rely on this, run /tf init → 'Configure taskflow preferences' to re-enable. ` +
293
+ `(This is a one-time upgrade hint.)`,
294
+ );
295
+ }
296
+ } catch { /* .pi/agents/ doesn't exist — no hint needed */ }
297
+ }
298
+ } catch {
299
+ // Best-effort: settings.json missing or unreadable is not an error.
300
+ }
301
+
268
302
  // Hint: prompt to configure model roles if not set
269
303
  try {
270
304
  const settings = readSubagentSettings();
@@ -282,7 +316,7 @@ export default function (pi: ExtensionAPI) {
282
316
  name: "taskflow",
283
317
  label: "Taskflow",
284
318
  description: [
285
- "Orchestrate subagents the ONLY delegation tool. Fully replaces the built-in subagent tool.",
319
+ "IMPORTANT: Before using this tool for the first time in a session, invoke skill_load('taskflow') to read the full documentation (DSL syntax, examples, best practices). This tool description is a reference, not a tutorial.",
286
320
  "Shorthand (same API as subagent): pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` for parallel, or `chain:[{task,agent?}]` for sequential (use {previous.output}).",
287
321
  "DSL: use action=run with an inline `define` (you write the DAG) or a saved `name`. Phases (agent, parallel, map, gate, reduce, approval, flow, loop, tournament) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
288
322
  "Every delegation is tracked (runId), resumable across sessions, and saveable as /tf:<name> via action=save.",
@@ -293,7 +327,7 @@ export default function (pi: ExtensionAPI) {
293
327
  parameters: TaskflowParams,
294
328
  promptSnippet: "Orchestrate subagents — single, parallel, chain, or DAG — with tracking, resume, and context isolation. Replaces the subagent tool.",
295
329
  promptGuidelines: [
296
- "Use taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:<name>. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.",
330
+ "BEFORE FIRST USE: invoke skill_load('taskflow') to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:<name>. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.",
297
331
  "For complex multi-phase work (explore / 审计 / analyze the project, auditing endpoints, reviewing or migrating many files/modules, cross-checked research), use the full DSL with phases. For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
298
332
  "For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
299
333
  ],
@@ -370,6 +404,7 @@ export default function (pi: ExtensionAPI) {
370
404
  modelRegistry: ctx.modelRegistry,
371
405
  modelList,
372
406
  currentRoles: current,
407
+ currentTaskflowSettings: readSubagentSettings().taskflow,
373
408
  });
374
409
  const text = formatFlowResult(result);
375
410
  return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
@@ -382,7 +417,7 @@ export default function (pi: ExtensionAPI) {
382
417
  if (action === "agents") {
383
418
  const scope = params.scope ?? "both";
384
419
  const settings2 = readSubagentSettings();
385
- const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles);
420
+ const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, settings2.modelRoles, settings2.taskflow);
386
421
  const text = agents.length
387
422
  ? agents
388
423
  .map(
@@ -407,13 +442,18 @@ export default function (pi: ExtensionAPI) {
407
442
  const { verifyTaskflow } = await import("./verify.ts");
408
443
  // Load definition: inline define takes priority, then saved name
409
444
  let def: Taskflow | undefined;
410
- if (params.define) {
411
- const d = params.define as Record<string, unknown>;
445
+ let resolvedDefine: unknown = params.define;
446
+ if (typeof resolvedDefine === "string") {
447
+ const parsed = safeParse(resolvedDefine);
448
+ if (parsed && typeof parsed === "object") resolvedDefine = parsed;
449
+ }
450
+ if (resolvedDefine) {
451
+ const d = resolvedDefine as Record<string, unknown>;
412
452
  if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
413
453
  def = d as unknown as Taskflow;
414
- } else if (isShorthand(params.define)) {
415
- const r = validateTaskflow(params.define);
416
- if (r.ok) def = params.define as unknown as Taskflow;
454
+ } else if (isShorthand(resolvedDefine)) {
455
+ const r = validateTaskflow(resolvedDefine);
456
+ if (r.ok) def = resolvedDefine as unknown as Taskflow;
417
457
  }
418
458
  } else if (params.name) {
419
459
  const saved = getFlow(ctx.cwd, params.name);
@@ -471,9 +511,25 @@ export default function (pi: ExtensionAPI) {
471
511
  // resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
472
512
  let def: Taskflow | undefined;
473
513
 
514
+ // Auto-parse string `define` — LLMs sometimes pass a JSON string
515
+ // instead of a parsed object. safeParse handles markdown fences too.
516
+ let resolvedDefine: unknown = params.define;
517
+ if (typeof resolvedDefine === "string") {
518
+ const parsed = safeParse(resolvedDefine);
519
+ if (parsed && typeof parsed === "object") {
520
+ resolvedDefine = parsed;
521
+ } else {
522
+ return errorResult(
523
+ action,
524
+ `'define' was passed as a string, not a JSON object. Pass it as a proper object, e.g.:\n` +
525
+ `define: {"name":"my-flow","phases":[{"id":"step1","task":"do something"}]}`,
526
+ );
527
+ }
528
+ }
529
+
474
530
  // A shorthand spec can come from `define` (no phases) or top-level params.
475
531
  const shorthandSpec: unknown =
476
- params.define ??
532
+ resolvedDefine ??
477
533
  (params.chain
478
534
  ? { chain: params.chain, name: params.name }
479
535
  : params.tasks
@@ -496,11 +552,25 @@ export default function (pi: ExtensionAPI) {
496
552
  def = candidate as Taskflow;
497
553
  } else if (params.name) {
498
554
  const saved = getFlow(ctx.cwd, params.name);
499
- if (!saved) return errorResult(action, `Saved flow not found: ${params.name}`);
555
+ if (!saved) {
556
+ const available = listFlows(ctx.cwd);
557
+ const hint = available.length
558
+ ? ` Available flows: ${available.map((f) => f.name).join(", ")}.`
559
+ : " No saved flows found. Use action=save to create one, or pass 'define' for an inline flow.";
560
+ return errorResult(action, `Saved flow '${params.name}' not found.${hint}`);
561
+ }
500
562
  def = saved.def;
501
563
  }
502
564
  if (!def)
503
- return errorResult(action, "Provide 'define' (DSL), shorthand 'task'/'tasks'/'chain', or 'name' (saved).");
565
+ return errorResult(
566
+ action,
567
+ `No taskflow definition provided. Use one of:\n` +
568
+ `- define: {"name":"...","phases":[...]} (inline DSL object)\n` +
569
+ `- task: "..." (shorthand single agent)\n` +
570
+ `- tasks: [{"task":"..."},...] (shorthand parallel)\n` +
571
+ `- chain: [{"task":"..."},...] (shorthand sequential)\n` +
572
+ `- name: "saved-flow-name" (run a previously saved flow)`,
573
+ );
504
574
 
505
575
  // save
506
576
  if (action === "save") {
@@ -528,7 +598,17 @@ export default function (pi: ExtensionAPI) {
528
598
  }
529
599
 
530
600
  // run
531
- const args = resolveArgs(def, params.args);
601
+ // Auto-parse string args LLMs sometimes pass a JSON string.
602
+ let resolvedArgs: Record<string, unknown> | undefined;
603
+ if (typeof params.args === "string") {
604
+ const parsed = safeParse(params.args);
605
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
606
+ resolvedArgs = parsed as Record<string, unknown>;
607
+ }
608
+ } else if (params.args && typeof params.args === "object") {
609
+ resolvedArgs = params.args as Record<string, unknown>;
610
+ }
611
+ const args = resolveArgs(def, resolvedArgs);
532
612
  const v = validateTaskflow(def, { args, cwd: ctx.cwd });
533
613
  if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
534
614
  for (const w of v.warnings) {
@@ -545,7 +625,14 @@ export default function (pi: ExtensionAPI) {
545
625
 
546
626
  renderCall(args, theme) {
547
627
  const action = args.action ?? "run";
548
- let label = args.name || (args.define as { name?: string } | undefined)?.name;
628
+ let label = args.name;
629
+ if (!label) {
630
+ let define = args.define;
631
+ if (typeof define === "string") {
632
+ try { define = JSON.parse(define); } catch { /* not JSON */ }
633
+ }
634
+ label = (define as { name?: string } | undefined)?.name;
635
+ }
549
636
  let suffix = "";
550
637
  const phases = (args.define as Taskflow | undefined)?.phases;
551
638
  if (phases) suffix = ` (${phases.length} phases)`;
@@ -579,7 +666,7 @@ export default function (pi: ExtensionAPI) {
579
666
  pi.registerCommand("tf", {
580
667
  description: "Taskflow: list | run <name> | show <name> | runs | init",
581
668
  getArgumentCompletions: (prefix) => {
582
- const subs = ["list", "run", "show", "runs", "resume", "init"];
669
+ const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify"];
583
670
  const items = subs.map((s) => ({ value: s, label: s }));
584
671
  const filtered = items.filter((i) => i.value.startsWith(prefix));
585
672
  return filtered.length > 0 ? filtered : null;
@@ -713,17 +800,13 @@ export default function (pi: ExtensionAPI) {
713
800
  modelRegistry: ctx.modelRegistry,
714
801
  modelList,
715
802
  currentRoles,
803
+ currentTaskflowSettings: readSubagentSettings().taskflow,
716
804
  });
717
- ctx.ui.notify(
718
- result.kind === "saved"
719
- ? `Saved model roles to ${result.savedPath}:\n${Object.entries(result.chosen)
720
- .map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
721
- .join("\n")}`
722
- : result.kind === "no-change"
723
- ? "No changes made."
724
- : "Init cancelled.",
725
- result.kind === "saved" ? "info" : "info",
726
- );
805
+ if (result.kind === "cancelled") {
806
+ ctx.ui.notify("Init cancelled.", "info");
807
+ } else {
808
+ ctx.ui.notify(formatFlowResult(result), "info");
809
+ }
727
810
  return;
728
811
  }
729
812
 
@@ -767,13 +850,13 @@ function parseArgsString(input: string, def: Taskflow): Record<string, unknown>
767
850
  }
768
851
  // key=value pairs
769
852
  const out: Record<string, unknown> = {};
770
- const pairs = trimmed.match(/(\w+)=("[^"]*"|\S+)/g);
853
+ const pairs = trimmed.match(/(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g);
771
854
  if (pairs) {
772
855
  for (const p of pairs) {
773
856
  const idx = p.indexOf("=");
774
857
  const k = p.slice(0, idx);
775
858
  let v: string = p.slice(idx + 1);
776
- if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
859
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"');
777
860
  out[k] = v;
778
861
  }
779
862
  return out;
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import type { Api, Model } from "@earendil-works/pi-ai";
18
18
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
19
  import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
+ import { DEFAULT_TASKFLOW_SETTINGS, normalizeTaskflowSettings, type TaskflowSettings } from "./agents.ts";
20
21
  import { writeFileAtomic } from "./store.ts";
21
22
 
22
23
  // ---------------------------------------------------------------------------
@@ -330,8 +331,23 @@ export function formatDiffReport(
330
331
  return lines.join("\n");
331
332
  }
332
333
 
334
+ export function formatTaskflowSettingsReport(settings: TaskflowSettings): string {
335
+ return [
336
+ "Taskflow preferences:",
337
+ ` Built-in agents: ${settings.builtInAgents ? "enabled" : "disabled"}`,
338
+ ` Sync built-ins to project .pi/agents: ${settings.syncBuiltinAgentsToProject ? "enabled" : "disabled"}`,
339
+ ].join("\n");
340
+ }
341
+
333
342
  export function formatFlowResult(result: InitFlowResult): string {
334
343
  if (result.kind === "cancelled") return "Init cancelled.";
344
+ if (result.kind === "preferences-no-change") {
345
+ return "No changes.\n" + formatTaskflowSettingsReport(result.settings);
346
+ }
347
+ if (result.kind === "preferences-saved") {
348
+ const savedPath = formatSettingsPath(result.savedPath);
349
+ return `Saved taskflow preferences to ${savedPath}:\n` + formatTaskflowSettingsReport(result.settings);
350
+ }
335
351
  if (result.kind === "no-change") {
336
352
  return (
337
353
  "No changes.\n" +
@@ -357,6 +373,8 @@ export function formatFlowResult(result: InitFlowResult): string {
357
373
  export type InitFlowResult =
358
374
  | { kind: "saved"; chosen: Record<string, string>; savedPath: string }
359
375
  | { kind: "no-change"; chosen: Record<string, string> }
376
+ | { kind: "preferences-saved"; settings: TaskflowSettings; savedPath: string }
377
+ | { kind: "preferences-no-change"; settings: TaskflowSettings }
360
378
  | { kind: "cancelled" };
361
379
 
362
380
  export async function runInteractiveInit(ctx: {
@@ -366,6 +384,7 @@ export async function runInteractiveInit(ctx: {
366
384
  modelRegistry: ExtensionContext["modelRegistry"];
367
385
  modelList: Model<Api>[];
368
386
  currentRoles: Record<string, string>;
387
+ currentTaskflowSettings?: TaskflowSettings;
369
388
  }): Promise<InitFlowResult> {
370
389
  if (!ctx.hasUI) {
371
390
  throw new Error("runInteractiveInit requires an interactive session (hasUI=true).");
@@ -373,6 +392,7 @@ export async function runInteractiveInit(ctx: {
373
392
 
374
393
  const recommended = RECOMMENDED_DEFAULTS;
375
394
  const current = ctx.currentRoles;
395
+ const currentTaskflowSettings = ctx.currentTaskflowSettings ?? normalizeTaskflowSettings(readSettings().taskflow);
376
396
  const hasCurrent = Object.keys(current).length > 0;
377
397
 
378
398
  // ---- Action menu ----
@@ -381,10 +401,11 @@ export async function runInteractiveInit(ctx: {
381
401
  "Use recommended defaults",
382
402
  "Configure each role",
383
403
  "Edit one role",
404
+ "Configure taskflow preferences",
384
405
  "Show current roles",
385
406
  "Cancel",
386
407
  ]
387
- : ["Use recommended defaults", "Configure each role"];
408
+ : ["Use recommended defaults", "Configure each role", "Configure taskflow preferences"];
388
409
 
389
410
  const action = await ctx.ui.select(
390
411
  "What do you want to do with model roles?",
@@ -416,6 +437,11 @@ export async function runInteractiveInit(ctx: {
416
437
  // ---- Cancel ----
417
438
  if (action === "Cancel") return { kind: "cancelled" };
418
439
 
440
+ // ---- Configure taskflow preferences ----
441
+ if (action === "Configure taskflow preferences") {
442
+ return configureTaskflowPreferences(ctx, currentTaskflowSettings);
443
+ }
444
+
419
445
  // ---- Configure each role ----
420
446
  if (action === "Configure each role") {
421
447
  const chosen = await collectRolePicks(ctx, current, recommended, undefined);
@@ -437,6 +463,59 @@ export async function runInteractiveInit(ctx: {
437
463
  // Internal helpers
438
464
  // ---------------------------------------------------------------------------
439
465
 
466
+ function taskflowSettingsIdentical(a: TaskflowSettings, b: TaskflowSettings): boolean {
467
+ return a.builtInAgents === b.builtInAgents && a.syncBuiltinAgentsToProject === b.syncBuiltinAgentsToProject;
468
+ }
469
+
470
+ async function configureTaskflowPreferences(
471
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext },
472
+ current: TaskflowSettings,
473
+ ): Promise<InitFlowResult> {
474
+ const builtInPick = await ctx.ui.select(
475
+ "Taskflow built-in agents",
476
+ [
477
+ `Enable built-in agents${current.builtInAgents ? " (current)" : ""}`,
478
+ `Disable built-in agents${!current.builtInAgents ? " (current)" : ""}`,
479
+ "Back to action menu",
480
+ ],
481
+ { signal: ctx.signal },
482
+ );
483
+ if (builtInPick === undefined || builtInPick === "Back to action menu") return { kind: "cancelled" };
484
+
485
+ const chosen: TaskflowSettings = {
486
+ ...DEFAULT_TASKFLOW_SETTINGS,
487
+ builtInAgents: builtInPick.startsWith("Enable"),
488
+ };
489
+
490
+ if (chosen.builtInAgents) {
491
+ const syncPick = await ctx.ui.select(
492
+ "Expose built-in agents to native Pi/project discovery?",
493
+ [
494
+ `Do not copy to project .pi/agents${!current.syncBuiltinAgentsToProject ? " (current)" : ""}`,
495
+ `Copy to project .pi/agents on session start${current.syncBuiltinAgentsToProject ? " (current)" : ""}`,
496
+ "Back to action menu",
497
+ ],
498
+ { signal: ctx.signal },
499
+ );
500
+ if (syncPick === undefined || syncPick === "Back to action menu") return { kind: "cancelled" };
501
+ chosen.syncBuiltinAgentsToProject = syncPick.startsWith("Copy");
502
+ }
503
+
504
+ if (taskflowSettingsIdentical(current, chosen)) {
505
+ return { kind: "preferences-no-change", settings: chosen };
506
+ }
507
+
508
+ const preview = await ctx.ui.select(
509
+ `Review taskflow preferences:\n\n${formatTaskflowSettingsReport(chosen)}`,
510
+ ["Save these preferences", "Cancel"],
511
+ { signal: ctx.signal },
512
+ );
513
+ if (preview !== "Save these preferences") return { kind: "cancelled" };
514
+
515
+ const savedPath = writeSettings({ ...readSettings(), taskflow: chosen });
516
+ return { kind: "preferences-saved", settings: chosen, savedPath };
517
+ }
518
+
440
519
  /** Collect picks for all roles. Returns undefined if user escapes to action menu. */
441
520
  async function collectRolePicks(
442
521
  ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },