pi-extensible-workflows 3.0.0 → 3.2.0

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.
Files changed (46) hide show
  1. package/README.md +15 -17
  2. package/dist/src/agent-execution.d.ts +4 -80
  3. package/dist/src/agent-execution.js +20 -10
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +48 -2
  6. package/dist/src/doctor-cleanup.d.ts +41 -0
  7. package/dist/src/doctor-cleanup.js +597 -0
  8. package/dist/src/doctor.d.ts +7 -2
  9. package/dist/src/doctor.js +127 -22
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +83 -4
  12. package/dist/src/host.js +1246 -410
  13. package/dist/src/index.d.ts +5 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +3 -0
  16. package/dist/src/persistence.js +49 -1
  17. package/dist/src/registry.d.ts +21 -9
  18. package/dist/src/registry.js +131 -21
  19. package/dist/src/session-inspector.js +4 -2
  20. package/dist/src/types.d.ts +135 -7
  21. package/dist/src/utils.d.ts +6 -0
  22. package/dist/src/utils.js +45 -5
  23. package/dist/src/validation.d.ts +6 -2
  24. package/dist/src/validation.js +157 -31
  25. package/dist/src/workflow-artifacts.d.ts +13 -0
  26. package/dist/src/workflow-artifacts.js +39 -0
  27. package/examples/workflow-extension-template/README.md +37 -0
  28. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  29. package/examples/workflow-extension-template/index.js +51 -0
  30. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  31. package/package.json +5 -3
  32. package/skills/pi-extensible-workflows/SKILL.md +45 -18
  33. package/src/agent-execution.ts +23 -37
  34. package/src/cli.ts +33 -2
  35. package/src/doctor-cleanup.ts +337 -0
  36. package/src/doctor.ts +117 -24
  37. package/src/execution.ts +13 -4
  38. package/src/host.ts +1039 -366
  39. package/src/index.ts +5 -2
  40. package/src/persistence.ts +35 -1
  41. package/src/registry.ts +108 -25
  42. package/src/session-inspector.ts +4 -2
  43. package/src/types.ts +53 -8
  44. package/src/utils.ts +39 -5
  45. package/src/validation.ts +130 -31
  46. package/src/workflow-artifacts.ts +34 -0
package/src/validation.ts CHANGED
@@ -8,9 +8,10 @@ import { Script } from "node:vm";
8
8
  import { Value } from "typebox/value";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  import { WorkflowError } from "./types.js";
11
- import type { AgentDefinition, AgentResourceExclusions, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, StaticWorkflowExecution, StaticWorkflowScope, ValidatedWorkflowLaunch, WorkflowCallKind, WorkflowErrorCode, WorkflowMetadata, WorkflowSettings, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
11
+ import type { AgentDefinition, AgentResourceExclusions, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, StaticWorkflowExecution, StaticWorkflowScope, ValidatedWorkflowLaunch, WorkflowCallKind, WorkflowErrorCode, WorkflowExtensionMetadata, WorkflowMetadata, WorkflowRoleDirectoryRegistration, WorkflowSettings, WorkflowSettingsOverrides, WorkflowSettingsResolution, WorkflowSettingsSources, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
12
12
  import type { WorkflowRegistryApi } from "./registry.js";
13
- import { deepFreeze, errorText, fail, jsonValue, mergeAgentResourceExclusions, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases } from "./utils.js";
13
+ import { registeredWorkflowRoleDirectoryRegistrations } from "./registry.js";
14
+ import { annotateModelAliasError, deepFreeze, errorText, fail, jsonValue, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases, validateResourcePattern } from "./utils.js";
14
15
  import { WORKFLOW_CALL_KINDS } from "./types.js";
15
16
 
16
17
  export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
@@ -25,10 +26,20 @@ export function validateCheckpoint(value: unknown): CheckpointInput {
25
26
  export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
26
27
  export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
27
28
  const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
29
+ function resourcePatternHasMagic(value: string): boolean { return /[*?\x5b\x5d{}()]/.test(value); }
28
30
  function normalizedResourcePath(value: string, settingsPath: string): string {
29
31
  let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
30
32
  if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
31
33
  const resolved = resolve(dirname(settingsPath), expanded);
34
+ if (expanded === "**" || expanded.startsWith("**/") || expanded.startsWith("**\\")) return expanded;
35
+ if (resourcePatternHasMagic(expanded)) {
36
+ const magicIndex = resolved.search(/[*?\x5b\x5d{}()]/);
37
+ const separatorIndex = Math.max(resolved.lastIndexOf("/", magicIndex), resolved.lastIndexOf("\\", magicIndex));
38
+ const rootBoundary = separatorIndex === 0 || (separatorIndex === 2 && /^[A-Za-z]:[\\/]/.test(resolved));
39
+ const prefix = rootBoundary ? resolved.slice(0, separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(0, separatorIndex) : resolved;
40
+ const suffix = rootBoundary ? resolved.slice(separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(separatorIndex) : "";
41
+ try { return `${realpathSync(prefix)}${suffix}`; } catch { return resolved; }
42
+ }
32
43
  try { return realpathSync(resolved); } catch { return resolved; }
33
44
  }
34
45
  function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
@@ -41,41 +52,74 @@ function validateAgentResourceExclusions(value: unknown, settingsPath: string, e
41
52
  const entries = value[kind];
42
53
  if (entries === undefined) continue;
43
54
  if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
44
- const seen = new Set<string>();
45
55
  for (const [index, entry] of entries.entries()) {
46
56
  if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
47
57
  let selector = entry.trim();
48
58
  if (kind === "extensions") {
49
- try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
59
+ const negated = selector.startsWith("!");
60
+ const body = negated ? selector.slice(1) : selector;
61
+ if (!body) fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: Empty minimatch pattern ${JSON.stringify(selector)}`);
62
+ try { selector = `${negated ? "!" : ""}${normalizedResourcePath(body, settingsPath)}`; } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
50
63
  }
51
- if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
64
+ try { validateResourcePattern(selector); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: ${errorText(error)}`); }
65
+ normalized[kind].push(selector);
52
66
  }
53
67
  }
54
68
  return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
55
69
  }
56
- export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
70
+ function parseSettings(path: string, partial: false): Readonly<WorkflowSettings>;
71
+ function parseSettings(path: string, partial: true): Readonly<WorkflowSettingsOverrides>;
72
+ function parseSettings(path: string, partial: boolean): Readonly<WorkflowSettings | WorkflowSettingsOverrides> {
57
73
  let parsed: unknown;
58
74
  try { parsed = JSON.parse(readFileSync(path, "utf8")); }
59
75
  catch (error) {
60
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
76
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return partial ? Object.freeze({}) : DEFAULT_SETTINGS;
61
77
  fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
62
78
  }
63
79
  if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
64
80
  const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
65
81
  const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
66
82
  if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
67
- const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
68
- if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
83
+ const concurrency = parsed.concurrency === undefined ? (partial ? undefined : DEFAULT_SETTINGS.concurrency) : parsed.concurrency;
84
+ if (concurrency !== undefined && (!positiveInteger(concurrency) || concurrency > 16)) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
69
85
  const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
70
86
  const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
71
- return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
87
+ return Object.freeze({ ...(concurrency === undefined ? {} : { concurrency }), ...(modelAliases === undefined ? {} : { modelAliases }), ...(disabledAgentResources === undefined ? {} : { disabledAgentResources }) });
72
88
  }
73
- export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
89
+ export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> { return parseSettings(path, false); }
90
+ export function loadSettingsOverrides(path: string): Readonly<WorkflowSettingsOverrides> { return parseSettings(path, true); }
91
+ export function resolveWorkflowSettings(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): WorkflowSettingsResolution {
74
92
  const projectSettingsPath = workflowProjectSettingsPath(cwd);
75
- const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
76
- const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
77
- const effective = mergeAgentResourceExclusions(global, project);
78
- return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
93
+ const global = loadSettings(globalSettingsPath);
94
+ const project: Readonly<WorkflowSettingsOverrides> = projectTrusted ? loadSettingsOverrides(projectSettingsPath) : Object.freeze({});
95
+ const projectHas = (key: keyof WorkflowSettingsOverrides): boolean => Object.prototype.hasOwnProperty.call(project, key);
96
+ const sources: WorkflowSettingsSources = {
97
+ concurrency: projectHas("concurrency") ? projectSettingsPath : globalSettingsPath,
98
+ modelAliases: projectHas("modelAliases") ? projectSettingsPath : globalSettingsPath,
99
+ disabledAgentResources: projectHas("disabledAgentResources") ? projectSettingsPath : globalSettingsPath,
100
+ };
101
+ const effective = Object.freeze({
102
+ concurrency: project.concurrency ?? global.concurrency,
103
+ ...(projectHas("modelAliases") ? { modelAliases: project.modelAliases } : global.modelAliases === undefined ? {} : { modelAliases: global.modelAliases }),
104
+ ...(projectHas("disabledAgentResources") ? { disabledAgentResources: project.disabledAgentResources } : global.disabledAgentResources === undefined ? {} : { disabledAgentResources: global.disabledAgentResources }),
105
+ });
106
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, sources };
107
+ }
108
+ export function validateModelAliasAvailability(aliases: Readonly<Record<string, string>>, names: readonly string[], availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath?: string): void {
109
+ for (const name of names) {
110
+ try {
111
+ const target = modelCapability(name, aliases, knownModels, settingsPath);
112
+ if (!availableModels.has(target)) unknownModel(name, target, settingsPath);
113
+ } catch (error) { throw annotateModelAliasError(error, name); }
114
+ }
115
+ }
116
+ export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
117
+ const resolved = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
118
+ const empty = EMPTY_AGENT_RESOURCE_EXCLUSIONS;
119
+ const global = resolved.global.disabledAgentResources ?? empty;
120
+ const project = resolved.project.disabledAgentResources ?? empty;
121
+ const effective = resolved.effective.disabledAgentResources ?? empty;
122
+ return { globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
79
123
  }
80
124
  export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
81
125
  const normalized = validateModelAliases(aliases, path);
@@ -135,23 +179,51 @@ function projectRoleDirectories(root: string): readonly string[] {
135
179
  return [join(root, ROLE_DIRECTORY, "roles")];
136
180
  }
137
181
 
138
- function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
139
- try {
140
- return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
141
- .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
142
- .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
143
- } catch (error) {
144
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
145
- throw error;
146
- }
182
+ type RoleDirectorySource = { path: string; extension?: WorkflowExtensionMetadata };
183
+ export type WorkflowRoleDirectoryInput = string | WorkflowRoleDirectoryRegistration;
184
+ type RoleFile = { name: string; path: string; source: RoleDirectorySource };
185
+ function roleDirectorySources(dirs: readonly WorkflowRoleDirectoryInput[]): RoleDirectorySource[] {
186
+ const seen = new Set<string>();
187
+ return dirs.flatMap((value) => {
188
+ const source = typeof value === "string" ? { path: value } : { path: value.path, extension: value.extension };
189
+ if (seen.has(source.path)) return [];
190
+ seen.add(source.path);
191
+ return [source];
192
+ });
147
193
  }
148
-
149
- function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefinition> {
150
- return Object.fromEntries(dirs.flatMap((dir) => Object.entries(readAgentDefinitions(dir))));
194
+ function roleDirectoryLabel(source: RoleDirectorySource, extension = true): string {
195
+ return source.extension ? `Extension "${source.extension.headline}" (${source.extension.version})` : extension ? "Registered workflow extension" : "Standard workflow";
196
+ }
197
+ function scanRoleFiles(dirs: readonly WorkflowRoleDirectoryInput[], extension: boolean): RoleFile[] {
198
+ const files: RoleFile[] = [];
199
+ for (const source of roleDirectorySources(dirs)) {
200
+ let entries: import("node:fs").Dirent[];
201
+ try { entries = readdirSync(source.path, { withFileTypes: true }); }
202
+ catch (error) {
203
+ if (!extension && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
204
+ fail("INVALID_METADATA", `${roleDirectoryLabel(source, extension)} role directory "${source.path}" could not be scanned: ${errorText(error)}`);
205
+ }
206
+ for (const entry of entries) if (entry.isFile() && extname(entry.name) === ".md") files.push({ name: basename(entry.name, ".md"), path: join(source.path, entry.name), source });
207
+ }
208
+ return files.sort((left, right) => left.name.localeCompare(right.name) || left.path.localeCompare(right.path));
209
+ }
210
+ function readRoleDefinitions(dirs: readonly WorkflowRoleDirectoryInput[], extension = false): Record<string, AgentDefinition> {
211
+ const files = scanRoleFiles(dirs, extension);
212
+ if (extension) {
213
+ const byName = new Map<string, RoleFile[]>();
214
+ for (const file of files) byName.set(file.name, [...(byName.get(file.name) ?? []), file]);
215
+ for (const [name, matches] of byName) if (matches.length > 1) fail("INVALID_METADATA", `Duplicate extension role "${name}": ${matches.map(({ path, source }) => `${roleDirectoryLabel(source)} role directory "${source.path}" (${path})`).join("; ")}`);
216
+ }
217
+ return Object.fromEntries(files.map(({ name, path, source }) => {
218
+ try { return [name, parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }
219
+ catch (error) {
220
+ if (extension) fail("INVALID_METADATA", `${roleDirectoryLabel(source)} role directory "${source.path}" contains invalid role "${name}" at "${path}": ${errorText(error)}`);
221
+ throw error;
222
+ }
223
+ }));
151
224
  }
152
-
153
- export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
154
- return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
225
+ export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true, extensionRoleDirectories: readonly WorkflowRoleDirectoryInput[] = registeredWorkflowRoleDirectoryRegistrations()): Readonly<Record<string, AgentDefinition>> {
226
+ return deepFreeze({ ...readRoleDefinitions(extensionRoleDirectories, true), ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
155
227
  }
156
228
  function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>, aliases: Readonly<Record<string, string>> = {}, knownModels = availableModels, settingsPath?: string): void {
157
229
  for (const role of roles) {
@@ -252,6 +324,32 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
252
324
  visit(program, { execution: "sequential", structure: [] });
253
325
  return calls.sort((left, right) => left.call.start - right.call.start);
254
326
  }
327
+ function memberCall(node: acorn.AnyNode | undefined, objectName: string, propertyName: string): boolean {
328
+ if (node?.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.object.type !== "Identifier" || node.callee.object.name !== objectName || node.callee.property.type !== "Identifier") return false;
329
+ return node.callee.property.name === propertyName;
330
+ }
331
+ function mapCallback(node: acorn.AnyNode): acorn.AnyNode | undefined {
332
+ if (!memberCall(node, "Promise", "all") && !memberCall(node, "Promise", "allSettled")) return undefined;
333
+ if (node.type !== "CallExpression") return undefined;
334
+ const source = node.arguments[0];
335
+ if (source?.type !== "CallExpression" || source.callee.type !== "MemberExpression" || source.callee.computed || source.callee.property.type !== "Identifier" || !["map", "flatMap"].includes(source.callee.property.name)) return undefined;
336
+ const callback = source.arguments[0];
337
+ return callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression" ? callback : undefined;
338
+ }
339
+ function hasUnscopedAgent(node: acorn.AnyNode, scoped = false): boolean {
340
+ const operation = workflowCallKind(node);
341
+ if (operation === "agent") return !scoped;
342
+ const nestedScope = scoped || operation === "parallel" || operation === "pipeline";
343
+ return astChildren(node).some((child) => hasUnscopedAgent(child, nestedScope));
344
+ }
345
+ function validateObviousConcurrentAgentCalls(program: acorn.Program): void {
346
+ const visit = (node: acorn.AnyNode): void => {
347
+ const callback = mapCallback(node);
348
+ if (callback && hasUnscopedAgent(callback)) fail("INVALID_METADATA", "Promise.all/map agent fan-out cannot prove stable call-site identity; use parallel(...) or pipeline(...)");
349
+ for (const child of astChildren(node)) visit(child);
350
+ };
351
+ visit(program);
352
+ }
255
353
  function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
256
354
  const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
257
355
  if (node.type === "Identifier" && node.name === name) {
@@ -534,6 +632,7 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
534
632
  validateDirectPrimitiveReferences(program, "shell");
535
633
  for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
536
634
  const calls = workflowCalls(program);
635
+ validateObviousConcurrentAgentCalls(program);
537
636
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
538
637
  for (const call of calls) {
539
638
  const operation = call.callee.name;
@@ -590,7 +689,7 @@ export function validateWorkflowLaunchWithRegistry(params: WorkflowValidationPar
590
689
  const script = functionName !== undefined && fn ? functionLaunchScript(functionName) : typeof params.script === "string" && params.script.trim() ? params.script : "";
591
690
  if (!script) fail("INVALID_SYNTAX", "Provide script or registered function");
592
691
  const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : fn?.description ? { description: fn.description } : {}) });
593
- const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false);
692
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false, registry && typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : registry && typeof registry.roleDirectories === "function" ? registry.roleDirectories() : undefined);
594
693
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
595
694
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
596
695
  const aliases = context.modelAliases ?? {};
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { JsonValue } from "./types.js";
6
+
7
+ export interface WorkflowArtifact { extension: ".js" | ".json" | ".md"; content: string }
8
+ export type WorkflowTui = { stop(): void; start(): void; requestRender(force?: boolean): void };
9
+
10
+ export function workflowScriptArtifact(script: string): WorkflowArtifact { return { extension: ".js", content: script }; }
11
+ export function workflowResultArtifact(value: JsonValue): WorkflowArtifact { return typeof value === "string" ? { extension: ".md", content: value } : { extension: ".json", content: `${JSON.stringify(value, null, 2)}\n` }; }
12
+
13
+ async function spawnWorkflowEditor(command: string, path: string): Promise<number | null> {
14
+ const [editor, ...editorArgs] = command.split(" ");
15
+ if (!editor) return null;
16
+ return new Promise((resolve) => {
17
+ try {
18
+ const child = spawn(editor, [...editorArgs, path], { stdio: "inherit", shell: process.platform === "win32" });
19
+ child.once("error", () => { resolve(null); });
20
+ child.once("close", (code) => { resolve(code); });
21
+ } catch { resolve(null); }
22
+ });
23
+ }
24
+
25
+ export async function openWorkflowArtifact(tui: WorkflowTui, command: string, artifact: WorkflowArtifact): Promise<number | null> {
26
+ const directory = await mkdtemp(join(tmpdir(), "pi-workflow-editor-"));
27
+ const path = join(directory, `artifact${artifact.extension}`);
28
+ try {
29
+ await writeFile(path, artifact.content, { encoding: "utf8", mode: 0o600 });
30
+ tui.stop();
31
+ try { return await spawnWorkflowEditor(command, path); }
32
+ finally { tui.start(); tui.requestRender(true); }
33
+ } finally { await rm(directory, { recursive: true, force: true }); }
34
+ }