pi-extensible-workflows 3.1.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.
@@ -5,6 +5,7 @@ export * from "./validation.js";
5
5
  export * from "./registry.js";
6
6
  export * from "./execution.js";
7
7
  export * from "./host.js";
8
+ export * from "./workflow-artifacts.js";
8
9
  export { default } from "./host.js";
9
10
  export { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
10
11
  export type { AwaitingCheckpoint, CompletedOperation, NativeSessionReference, PendingWorkflowDecision, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./validation.js";
5
5
  export * from "./registry.js";
6
6
  export * from "./execution.js";
7
7
  export * from "./host.js";
8
+ export * from "./workflow-artifacts.js";
8
9
  export { default } from "./host.js";
9
10
  export { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
10
11
  export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
@@ -257,6 +257,7 @@ export interface AgentRecord {
257
257
  state: AgentState;
258
258
  parentId?: string;
259
259
  structuralPath?: readonly string[];
260
+ resultPath?: string;
260
261
  parentBreadcrumb?: string;
261
262
  worktreeOwner?: string;
262
263
  role?: string;
@@ -401,6 +401,39 @@ function workflowCallsWithStructure(program) {
401
401
  visit(program, { execution: "sequential", structure: [] });
402
402
  return calls.sort((left, right) => left.call.start - right.call.start);
403
403
  }
404
+ function memberCall(node, objectName, propertyName) {
405
+ 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")
406
+ return false;
407
+ return node.callee.property.name === propertyName;
408
+ }
409
+ function mapCallback(node) {
410
+ if (!memberCall(node, "Promise", "all") && !memberCall(node, "Promise", "allSettled"))
411
+ return undefined;
412
+ if (node.type !== "CallExpression")
413
+ return undefined;
414
+ const source = node.arguments[0];
415
+ if (source?.type !== "CallExpression" || source.callee.type !== "MemberExpression" || source.callee.computed || source.callee.property.type !== "Identifier" || !["map", "flatMap"].includes(source.callee.property.name))
416
+ return undefined;
417
+ const callback = source.arguments[0];
418
+ return callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression" ? callback : undefined;
419
+ }
420
+ function hasUnscopedAgent(node, scoped = false) {
421
+ const operation = workflowCallKind(node);
422
+ if (operation === "agent")
423
+ return !scoped;
424
+ const nestedScope = scoped || operation === "parallel" || operation === "pipeline";
425
+ return astChildren(node).some((child) => hasUnscopedAgent(child, nestedScope));
426
+ }
427
+ function validateObviousConcurrentAgentCalls(program) {
428
+ const visit = (node) => {
429
+ const callback = mapCallback(node);
430
+ if (callback && hasUnscopedAgent(callback))
431
+ fail("INVALID_METADATA", "Promise.all/map agent fan-out cannot prove stable call-site identity; use parallel(...) or pipeline(...)");
432
+ for (const child of astChildren(node))
433
+ visit(child);
434
+ };
435
+ visit(program);
436
+ }
404
437
  function validateDirectPrimitiveReferences(program, name) {
405
438
  const visit = (node, parent) => {
406
439
  if (node.type === "Identifier" && node.name === name) {
@@ -731,6 +764,7 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
731
764
  for (const [index, schema] of schemas.entries())
732
765
  validateSchema(schema, `schema[${String(index)}]`);
733
766
  const calls = workflowCalls(program);
767
+ validateObviousConcurrentAgentCalls(program);
734
768
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
735
769
  for (const call of calls) {
736
770
  const operation = call.callee.name;
@@ -0,0 +1,13 @@
1
+ import type { JsonValue } from "./types.js";
2
+ export interface WorkflowArtifact {
3
+ extension: ".js" | ".json" | ".md";
4
+ content: string;
5
+ }
6
+ export type WorkflowTui = {
7
+ stop(): void;
8
+ start(): void;
9
+ requestRender(force?: boolean): void;
10
+ };
11
+ export declare function workflowScriptArtifact(script: string): WorkflowArtifact;
12
+ export declare function workflowResultArtifact(value: JsonValue): WorkflowArtifact;
13
+ export declare function openWorkflowArtifact(tui: WorkflowTui, command: string, artifact: WorkflowArtifact): Promise<number | null>;
@@ -0,0 +1,39 @@
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
+ export function workflowScriptArtifact(script) { return { extension: ".js", content: script }; }
6
+ export function workflowResultArtifact(value) { return typeof value === "string" ? { extension: ".md", content: value } : { extension: ".json", content: `${JSON.stringify(value, null, 2)}\n` }; }
7
+ async function spawnWorkflowEditor(command, path) {
8
+ const [editor, ...editorArgs] = command.split(" ");
9
+ if (!editor)
10
+ return null;
11
+ return new Promise((resolve) => {
12
+ try {
13
+ const child = spawn(editor, [...editorArgs, path], { stdio: "inherit", shell: process.platform === "win32" });
14
+ child.once("error", () => { resolve(null); });
15
+ child.once("close", (code) => { resolve(code); });
16
+ }
17
+ catch {
18
+ resolve(null);
19
+ }
20
+ });
21
+ }
22
+ export async function openWorkflowArtifact(tui, command, artifact) {
23
+ const directory = await mkdtemp(join(tmpdir(), "pi-workflow-editor-"));
24
+ const path = join(directory, `artifact${artifact.extension}`);
25
+ try {
26
+ await writeFile(path, artifact.content, { encoding: "utf8", mode: 0o600 });
27
+ tui.stop();
28
+ try {
29
+ return await spawnWorkflowEditor(command, path);
30
+ }
31
+ finally {
32
+ tui.start();
33
+ tui.requestRender(true);
34
+ }
35
+ }
36
+ finally {
37
+ await rm(directory, { recursive: true, force: true });
38
+ }
39
+ }
@@ -0,0 +1,37 @@
1
+ # Workflow extension template
2
+
3
+ This is a small, copyable extension rather than a generator. It shows the usual
4
+ registration shape with one function and one packaged role. Copy the directory
5
+ into a project, rename the metadata and function, then edit the role body.
6
+
7
+
8
+ ## Run it
9
+
10
+ From the repository root after installing dependencies and building the package:
11
+
12
+ ```sh
13
+ node --test examples/workflow-extension-template/extension.test.mjs
14
+ ```
15
+
16
+ For a published package, run the same test from this directory after installing
17
+ `pi-extensible-workflows` in the surrounding project. Copy the directory into a
18
+ trusted Pi extension location; Pi auto-discovers its `index.js` entry point.
19
+
20
+
21
+ ## Files
22
+
23
+ - `index.js` registers `greet` and resolves `roles/` from `import.meta.url`.
24
+ Relative role-directory strings are not accepted by the extension API.
25
+ - `roles/reviewer.md` is a portable packaged role with no provider or tool
26
+ assumptions.
27
+ - `extension.test.mjs` checks registration, function behavior, role packaging,
28
+ and the advanced examples.
29
+
30
+
31
+ ## Optional advanced pieces
32
+
33
+ The dynamic `template-model` alias and `templateAdvisor` setup hook are
34
+ optional examples. The hook only changes an agent after the call includes
35
+ `{ templateAdvisor: true }`; remove either section if the extension does not
36
+ need it. Both features are trusted host code and should be kept under explicit
37
+ project policy.
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { cp, mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
9
+ import { beginWorkflowExtensionLoading, loadingRegistry, registeredWorkflowFunctions, registeredWorkflowRoleDirectoryRegistrations, resetWorkflowRegistry, workflowCatalog } from "pi-extensible-workflows";
10
+
11
+ test("discovers the copied directory as a trusted Pi extension", async () => {
12
+ const root = await mkdtemp(join(tmpdir(), "workflow-extension-template-"));
13
+ try {
14
+ const destination = join(root, ".pi", "extensions", "workflow-extension-template");
15
+ await mkdir(join(root, "node_modules"), { recursive: true });
16
+ const packageEntry = fileURLToPath(import.meta.resolve("pi-extensible-workflows"));
17
+ const packageRoot = join(dirname(packageEntry), "..", "..");
18
+ await symlink(packageRoot, join(root, "node_modules", "pi-extensible-workflows"), "dir");
19
+ await cp(dirname(fileURLToPath(import.meta.url)), destination, { recursive: true });
20
+ const result = await discoverAndLoadExtensions([], root, join(root, ".pi", "agent"));
21
+ assert.equal(result.errors.length, 0);
22
+ assert.equal(result.extensions.length, 1);
23
+ } finally {
24
+ await rm(root, { recursive: true, force: true });
25
+ }
26
+
27
+ resetWorkflowRegistry();
28
+ const { default: extension } = await import("./index.js");
29
+ beginWorkflowExtensionLoading();
30
+ extension();
31
+
32
+ const catalog = workflowCatalog();
33
+ assert.deepEqual(catalog.functions.map(({ name }) => name), ["greet"]);
34
+ assert.deepEqual(catalog.modelAliasEntries?.filter(({ name }) => name === "template-model").map(({ name, kind }) => ({ name, kind })), [{ name: "template-model", kind: "dynamic" }]);
35
+ assert.equal(await registeredWorkflowFunctions().greet.run({ name: "Ada" }, {}), "Hello, Ada!");
36
+
37
+ const registration = registeredWorkflowRoleDirectoryRegistrations()[0];
38
+ assert.ok(registration);
39
+ assert.match(readFileSync(join(registration.path, "reviewer.md"), "utf8"), /Packaged reviewer role/);
40
+
41
+ const resolved = await loadingRegistry().resolveModelAliases({
42
+ cwd: process.cwd(),
43
+ projectTrusted: true,
44
+ rootModel: { provider: "example", model: "root" },
45
+ knownModels: new Set(["example/root", "example/available"]),
46
+ availableModels: new Set(["example/available"]),
47
+ signal: new AbortController().signal,
48
+ });
49
+ assert.deepEqual(resolved, { "template-model": "example/available" });
50
+
51
+ const hook = loadingRegistry().agentSetupHooks().find(({ name }) => name === "templateAdvisor");
52
+ assert.ok(hook);
53
+ const agent = { prompt: "Review this", options: {}, sessionInput: {} };
54
+ await hook.setup(agent, { signal: new AbortController().signal });
55
+ assert.equal(agent.sessionInput.systemPromptAppend, undefined);
56
+ agent.options.templateAdvisor = true;
57
+ await hook.setup(agent, { signal: new AbortController().signal });
58
+ assert.match(agent.sessionInput.systemPromptAppend, /one concrete risk/);
59
+ });
@@ -0,0 +1,51 @@
1
+ import { registerWorkflowExtension } from "pi-extensible-workflows";
2
+
3
+ const templateExtension = {
4
+ version: "1.0.0",
5
+ headline: "Workflow extension template",
6
+ description: "A copyable registered function with a packaged role.",
7
+ functions: {
8
+ greet: {
9
+ description: "Return a greeting for one person.",
10
+ input: {
11
+ type: "object",
12
+ properties: { name: { type: "string" } },
13
+ required: ["name"],
14
+ additionalProperties: false,
15
+ },
16
+ output: { type: "string" },
17
+ run(input) {
18
+ return `Hello, ${String(input.name)}!`;
19
+ },
20
+ },
21
+ },
22
+ // Packaged resources must be absolute paths or file URLs. This URL stays
23
+ // correct when the extension is copied or installed elsewhere.
24
+ roleDirectories: [new URL("./roles/", import.meta.url)],
25
+ // Optional advanced example: select an available model without naming a
26
+ // provider-specific model in the extension.
27
+ modelAliases: {
28
+ "template-model": {
29
+ resolve({ availableModels, rootModel }) {
30
+ const root = `${rootModel.provider}/${rootModel.model}`;
31
+ return availableModels.has(root) ? root : availableModels.values().next().value ?? root;
32
+ },
33
+ },
34
+ },
35
+ // Optional advanced example: mutate setup only for explicitly opted-in
36
+ // agents. Setup hooks are trusted code and should stay narrowly scoped.
37
+ agentSetupHooks: {
38
+ templateAdvisor: {
39
+ setup(agent, context) {
40
+ if (context.signal.aborted || agent.options.templateAdvisor !== true) return;
41
+ const suffix = "\n\nAdvisor: call out one concrete risk and one next check.";
42
+ const existing = agent.sessionInput.systemPromptAppend;
43
+ agent.sessionInput.systemPromptAppend = existing ? existing + suffix : suffix;
44
+ },
45
+ },
46
+ },
47
+ };
48
+
49
+ export default function extension() {
50
+ registerWorkflowExtension(templateExtension);
51
+ }
@@ -0,0 +1,4 @@
1
+ ---
2
+ description: Packaged reviewer role
3
+ ---
4
+ Review the requested change carefully. Return concrete findings with file names and a next check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  "src",
26
26
  "skills",
27
27
  "evals",
28
+ "examples",
28
29
  "test/fixtures"
29
30
  ],
30
31
  "scripts": {
@@ -32,7 +33,7 @@
32
33
  "inspect": "node dist/src/cli.js inspect",
33
34
  "lint": "eslint .",
34
35
  "test": "npm run build && TEST_FILES='dist/test/*.test.js' npm run test:run",
35
- "test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test $TEST_FILES",
36
+ "test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test --test-concurrency=1 $TEST_FILES",
36
37
  "acceptance": "npm run build && TEST_FILES='dist/test/runtime-acceptance.test.js' npm run test:run",
37
38
  "docs:check": "node scripts/check-docs.mjs",
38
39
  "check": "npm run lint && npm test && npm run docs:check",
@@ -5,47 +5,36 @@ description: Use when the task is complex enough to require multiple subagents o
5
5
 
6
6
  # pi-extensible-workflows
7
7
 
8
- Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
8
+ ## Default path
9
9
 
10
- ## Example
10
+ Use `workflow` only for genuinely multi-agent orchestration; a single agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
11
11
 
12
- ```js
13
- const reportSchema = {
14
- type: "object",
15
- properties: {
16
- summary: { type: "string" },
17
- findings: { type: "array", items: { type: "string" } },
18
- },
19
- required: ["summary", "findings"],
20
- additionalProperties: false,
21
- };
12
+ For most multi-agent tasks, start with a named inline workflow: provide a non-empty `name` and a `script` that fans out independent work with `parallel(...)`, awaits the keyed results, passes them into one summarizing `agent(...)`, and returns.
22
13
 
14
+ ```js
23
15
  const reports = await parallel("research", {
24
- first: () =>
25
- agent("Research the first target.", {
26
- role: "scout",
27
- outputSchema: reportSchema,
28
- }),
29
- second: () =>
30
- agent("Research the second target.", {
31
- role: "scout",
32
- outputSchema: reportSchema,
33
- }),
16
+ first: () => agent("Research the first target."),
17
+ second: () => agent("Research the second target."),
34
18
  });
35
19
 
36
- return agent(prompt("Review these reports:\n\n{reports}", { reports }), {
37
- role: "reviewer",
38
- outputSchema: reportSchema,
39
- });
20
+ return await agent(
21
+ prompt("Summarize these reports:\n\n{reports}", { reports }),
22
+ );
40
23
  ```
41
24
 
25
+ Await `parallel(...)` or `pipeline(...)` results before interpolation. Runs are backgrounded by default; set the tool-call `foreground: true` when the caller must wait for the final value.
26
+
27
+ ## Runtime and safety rules
28
+
29
+
42
30
  Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
43
31
 
44
32
  ```json
45
33
  { "workflow": "workflowName", "args": { "issue": 42 } }
46
34
  ```
47
-
48
- Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes but does not replay journal results. For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. Retry versus per-agent `retries` and `workflow_resume` is always explicit; external side effects before failure are not guaranteed exactly once.
35
+ Recovery map: `agent(..., { retries })` reruns one agent call in the same run for transient failures; `workflow_retry({ runId })` replays a failed run into a child; `workflow_resume({ runId, budget? })` continues a `budget_exhausted` run; `parentRunId` on a new launch only borrows named worktrees and never replays or resumes. Use each only for its stated case.
36
+ For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. External side effects before failure are not guaranteed exactly once.
37
+ Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. Retry versus per-agent `retries` and `workflow_resume` is always explicit.
49
38
  Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
50
39
 
51
40
  Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
@@ -63,6 +52,10 @@ if (testRes.exitCode === 0) {
63
52
 
64
53
  Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
65
54
 
55
+ ## Advanced capabilities
56
+
57
+ Registered functions, `outputSchema`, budgets, checkpoints, worktrees, retry/resume, CLI export, and `pipeline(...)` remain available for workflows that need them. Treat these as advanced controls rather than requirements for the default inline path.
58
+
66
59
  ## `agent()` options
67
60
 
68
61
  ```typescript
package/src/execution.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fork, spawn, type ChildProcess } from "node:child_process";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
@@ -78,6 +78,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
78
78
  const path = (...names) => names.map(encodeURIComponent).join("/");
79
79
  const inheritedAgentPath = new AsyncLocalStorage();
80
80
  const agentOccurrences = new Map();
81
+ const agentInflight = new Set();
81
82
  const shellOccurrences = new Map();
82
83
  const worktreeOwners = new AsyncLocalStorage();
83
84
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
@@ -98,14 +99,22 @@ const internalAgent = (...values) => {
98
99
  const callSite = values.pop();
99
100
  if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
100
101
  const inherited = inheritedAgentPath.getStore() || [];
101
- // ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
102
102
  const occurrenceKey = JSON.stringify([inherited, callSite]);
103
+ if (agentInflight.has(occurrenceKey)) throw workError("INVALID_METADATA", "Concurrent agent calls from the same source call site are unsupported; use parallel(...) or pipeline(...)");
104
+ agentInflight.add(occurrenceKey);
103
105
  const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
104
106
  agentOccurrences.set(occurrenceKey, occurrence);
105
107
  const options = values.length < 2 || values[1] === undefined ? {} : values[1];
106
108
  const worktreeOwner = worktreeOwners.getStore();
107
109
  const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
108
- const result = rpc("agent", [values[0], options, identity]).then(unwrap);
110
+ let result;
111
+ try {
112
+ result = rpc("agent", [values[0], options, identity]).then(unwrap);
113
+ } catch (error) {
114
+ agentInflight.delete(occurrenceKey);
115
+ throw error;
116
+ }
117
+ void result.then(() => agentInflight.delete(occurrenceKey), () => agentInflight.delete(occurrenceKey));
109
118
  Object.defineProperties(result, {
110
119
  toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
111
120
  toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
@@ -386,7 +395,7 @@ function workflowErrorFromWorker(error: WorkerErrorShape): WorkflowError {
386
395
  export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
387
396
  encoded(args);
388
397
  const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
389
- const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
398
+ const childDir = realpathSync(mkdtempSync(join(tmpdir(), "pi-wf-")));
390
399
  const childFile = join(childDir, "child.cjs");
391
400
  writeFileSync(childFile, childSource);
392
401
  const child: ChildProcess = fork(childFile, [String(RPC_LIMIT_BYTES), config], {