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.
- package/CHANGELOG.md +135 -0
- package/README.md +16 -15
- package/README.zh-CN.md +635 -0
- package/extensions/agents.ts +79 -41
- package/extensions/cache.ts +5 -1
- package/extensions/index.ts +117 -34
- package/extensions/init.ts +80 -1
- package/extensions/interpolate.ts +32 -5
- package/extensions/render.ts +2 -2
- package/extensions/runner.ts +38 -2
- package/extensions/runs-view.ts +2 -2
- package/extensions/runtime.ts +56 -9
- package/extensions/schema.ts +1 -1
- package/extensions/store.ts +61 -13
- package/extensions/verify.ts +11 -0
- package/package.json +6 -3
- package/skills/taskflow/SKILL.md +1 -1
- package/skills/taskflow/configuration.md +10 -11
- package/DESIGN.md +0 -338
package/extensions/agents.ts
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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.
|
|
203
|
+
for (const [name, agent] of agentMap.entries()) {
|
|
170
204
|
const resolved = resolveModelRole(agent.model, modelRoles);
|
|
171
|
-
if (resolved !== agent.model)
|
|
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
|
-
|
|
290
|
+
writeFileAtomic(dst, content);
|
|
253
291
|
} catch {
|
|
254
292
|
// Best-effort: a locked file must not block the sync.
|
|
255
293
|
}
|
package/extensions/cache.ts
CHANGED
|
@@ -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
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
259
|
-
// (and
|
|
260
|
-
//
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
|
|
411
|
-
|
|
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(
|
|
415
|
-
const r = validateTaskflow(
|
|
416
|
-
if (r.ok) def =
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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;
|
package/extensions/init.ts
CHANGED
|
@@ -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>[] },
|