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
@@ -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.0.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",
@@ -64,6 +65,7 @@
64
65
  },
65
66
  "license": "MIT",
66
67
  "dependencies": {
67
- "acorn": "^8.17.0"
68
+ "acorn": "^8.17.0",
69
+ "minimatch": "^10.2.5"
68
70
  }
69
71
  }
@@ -4,30 +4,38 @@ description: Use when the task is complex enough to require multiple subagents o
4
4
  ---
5
5
 
6
6
  # pi-extensible-workflows
7
- 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
7
 
9
- ## Example
10
- ```js
11
- const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
8
+ ## Default path
9
+
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.
12
11
 
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.
13
13
 
14
+ ```js
14
15
  const reports = await parallel("research", {
15
- first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
16
- second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
16
+ first: () => agent("Research the first target."),
17
+ second: () => agent("Research the second target."),
17
18
  });
18
19
 
19
- return agent(
20
- prompt("Review these reports:\n\n{reports}", { reports }),
21
- { role: "reviewer", outputSchema: reportSchema },
20
+ return await agent(
21
+ prompt("Summarize these reports:\n\n{reports}", { reports }),
22
22
  );
23
23
  ```
24
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
+
25
30
  Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
31
+
26
32
  ```json
27
33
  { "workflow": "workflowName", "args": { "issue": 42 } }
28
34
  ```
29
- 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.
30
- Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
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.
38
+ Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
31
39
 
32
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`.
33
41
 
@@ -37,20 +45,28 @@ Example use of `shell`:
37
45
  // ... other code
38
46
  const testRes = await shell("yarn test", { env: { CI: "1" } });
39
47
  if (testRes.exitCode === 0) {
40
- // success path
41
- return {...}
48
+ // success path
49
+ return { ok: true };
42
50
  }
43
51
  ```
44
52
 
45
53
  Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
46
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
+
47
59
  ## `agent()` options
60
+
48
61
  ```typescript
49
62
  export interface AgentOptions {
50
- label?: string; model?: string; role?: string; tools?: string[];
51
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
52
- outputSchema?: JsonSchema;
53
- retries?: number;
63
+ label?: string;
64
+ model?: string;
65
+ role?: string;
66
+ tools?: string[];
67
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
68
+ outputSchema?: JsonSchema;
69
+ retries?: number;
54
70
  timeoutMs?: number | null;
55
71
  [key: string]: JsonValue;
56
72
  }
@@ -61,24 +77,34 @@ Extensions may add JSON-compatible agent options such as `advisor: true`; core k
61
77
  Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
62
78
 
63
79
  ## Passing agent results
80
+
64
81
  Use independent `agent(prompt, options)` calls and pass each completed result explicitly to the next prompt. This keeps workflow execution deterministic and makes replay state local to each call:
82
+
65
83
  ```js
66
84
  const findings = await agent("Inspect the implementation.");
67
- const fix = await agent(prompt("Propose the smallest fix from these findings:\n\n{findings}", { findings }));
85
+ const fix = await agent(
86
+ prompt("Propose the smallest fix from these findings:\n\n{findings}", {
87
+ findings,
88
+ }),
89
+ );
68
90
  return { findings, fix };
69
91
  ```
70
92
 
71
93
  ## Worktrees
94
+
72
95
  Use `withWorktree(name, callback)` for top-level agents that collaborate in one explicitly named worktree scope:
96
+
73
97
  ```js
74
98
  const result = await withWorktree("issue", async ({ path, branch }) => {
75
99
  const report = await agent("Implement the issue");
76
100
  return { path, branch, report };
77
101
  });
78
102
  ```
103
+
79
104
  Entering the scope materializes its worktree before the callback. The callback receives a frozen reference containing only the real string `path` and `branch`; callbacks may ignore the argument, and their bare return value is preserved. Concurrent agents share mutable files, so assign non-conflicting work or coordinate explicitly.
80
105
 
81
106
  Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
107
+
82
108
  ```js
83
109
  const results = await parallel("implementation", {
84
110
  api: () => withWorktree("api", () => agent("Implement the API")),
@@ -89,6 +115,7 @@ const results = await parallel("implementation", {
89
115
  Registered extension functions receive `withWorktree` in context and can compose other registered functions with `context.invoke("reviewRepository", { focus: "security" })`. Their public inputs and outputs remain JSON; callbacks cannot cross the extension boundary.
90
116
 
91
117
  ## Rules
118
+
92
119
  - Use `log(messageString)` for brief operator status.
93
120
  - A role owns execution policy: with `role`, do not set `model`, `thinking`, or `tools`; only task options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
94
121
  - Use `parallel()` for independent tasks with different flows and `pipeline()` when every keyed item follows the same ordered stages; do not duplicate identical chains in `parallel()`. Signatures are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; keys are stable task, item, and stage names.
@@ -2,13 +2,14 @@ import { realpathSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import { Value } from "typebox/value";
5
- import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
7
7
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
8
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
9
- import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
8
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetup, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput, WorkflowRunContext } from "./types.js";
9
+ import { jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
10
10
  import { WorkflowError } from "./types.js";
11
11
  import type { RunStore } from "./persistence.js";
12
+ export type { AgentSetup, AgentSetupContext, AgentSetupHook, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput } from "./types.js";
12
13
  export interface AgentBudgetHooks {
13
14
  beforeAttempt(): void;
14
15
  beforeTurn(): void;
@@ -66,29 +67,6 @@ export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: stri
66
67
  export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
67
68
  export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
68
69
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
69
- export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: SessionInput; createSession: SessionFactory }
70
- export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
71
- export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
72
- export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
73
- type NativeSessionStats = Pick<SessionStats, "tokens" | "cost">;
74
- export interface NativeSession {
75
- readonly sessionId: string;
76
- readonly sessionFile: string | undefined;
77
- readonly messages: readonly AgentMessage[];
78
- getSessionStats(): NativeSessionStats;
79
- readonly systemPrompt?: string;
80
- readonly model?: { provider: string; model?: string; id?: string };
81
- readonly agent?: { state: { tools: readonly { name: string }[] } };
82
- getLeafId?: () => string | null;
83
- getToolDefinitions?: () => unknown;
84
- subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
85
- prompt(text: string): Promise<void>;
86
- steer?(text: string): Promise<void>;
87
- abort?(): Promise<void>;
88
- dispose(): void;
89
- }
90
- export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue> }
91
- export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
92
70
 
93
71
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
94
72
  if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
@@ -129,7 +107,7 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
129
107
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
130
108
  }
131
109
 
132
- function accounting(stats: NativeSessionStats): AgentAccounting {
110
+ function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
133
111
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
134
112
  }
135
113
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
@@ -151,14 +129,17 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
151
129
  settingsManager.setProjectTrusted(policy.projectTrusted);
152
130
  const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
153
131
  const resolved = await packageManager.resolve();
154
- const disabledExtensions = new Set(policy.effective.extensions);
155
- const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)).filter((path) => !disabledExtensions.has(canonicalSourcePath(path))))];
132
+ const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)))];
133
+ const excludedExtensions = new Set(disabledResources(policy.effective.extensions, discoveredExtensions));
134
+ const extensionPaths = discoveredExtensions.filter((path) => !excludedExtensions.has(path));
135
+ Object.assign(policy, { excludedExtensions: [...excludedExtensions], unmatchedExtensions: unmatchedResourcePatterns(policy.effective.extensions, discoveredExtensions) });
156
136
  const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
157
- const updateSkillMatches = (skills: readonly { name: string }[]) => {
158
- const names = new Set(skills.map(({ name }) => name));
159
- Object.assign(policy, { unmatchedSkills: policy.effective.skills.filter((name) => !names.has(name)) });
137
+ const updateSkillMatches = (skills: readonly { name: string }[]): Set<string> => {
138
+ const names = [...new Set(skills.map(({ name }) => name))];
139
+ const excludedSkills = disabledResources(policy.effective.skills, names);
140
+ Object.assign(policy, { excludedSkills, unmatchedSkills: unmatchedResourcePatterns(policy.effective.skills, names) });
141
+ return new Set(excludedSkills);
160
142
  };
161
- const disabledSkills = new Set(policy.effective.skills);
162
143
  resourceLoader = new DefaultResourceLoader({
163
144
  cwd: input.cwd,
164
145
  agentDir,
@@ -169,14 +150,12 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
169
150
  additionalSkillPaths: skillPaths,
170
151
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
171
152
  skillsOverride: (base) => {
172
- updateSkillMatches(base.skills);
153
+ const disabledSkills = updateSkillMatches(base.skills);
173
154
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
174
155
  },
175
156
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
176
157
  });
177
158
  await resourceLoader.reload();
178
- const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
179
- Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
180
159
  } else if (input.systemPromptAppend || input.extensionFactories?.length) {
181
160
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
182
161
  await resourceLoader.reload();
@@ -218,7 +197,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
218
197
  return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
219
198
  }
220
199
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
221
- return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
200
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
222
201
  }
223
202
  async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
224
203
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -518,6 +497,13 @@ export class FairAgentScheduler {
518
497
  this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
519
498
  this.#runOrder.push(runId);
520
499
  }
500
+ updateRunLimit(runId: string, limit: number): void {
501
+ const run = this.#runs.get(runId);
502
+ if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
503
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
504
+ run.limit = limit;
505
+ this.#dispatch();
506
+ }
521
507
 
522
508
  spawn(runId: string, prompt: string, options: ScheduledAgentOptions, parentId?: string): { id: string; result: Promise<ScheduledAgentResult> } {
523
509
  const run = this.#runs.get(runId);
package/src/cli.ts CHANGED
@@ -7,7 +7,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
8
8
  import { Value } from "typebox/value";
9
9
  import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
10
- import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
10
+ import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport, type DoctorCleanupOptions } from "./doctor-cleanup.js";
11
+ import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
11
12
  import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
12
13
  import type { PersistedRun } from "./persistence.js";
13
14
  import type { WorkflowCatalogFunction } from "./index.js";
@@ -158,6 +159,26 @@ function launcherHelpLines(): string[] {
158
159
  }
159
160
  function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
160
161
  function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
162
+ export function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">> {
163
+ let olderThanDays = 90;
164
+ let yes = false;
165
+ let seenDays = false;
166
+ for (let index = 0; index < rawArgs.length; index += 1) {
167
+ const token = rawArgs[index] as string;
168
+ if (token === "--yes") { yes = true; continue; }
169
+ const inline = token.startsWith("--older-than-days=") ? token.slice("--older-than-days=".length) : undefined;
170
+ if (token === "--older-than-days" || inline !== undefined) {
171
+ if (seenDays) throw new Error("--older-than-days may only be provided once");
172
+ const raw = inline ?? rawArgs[++index];
173
+ if (raw === undefined || !/^[1-9]\d*$/.test(raw)) throw new Error("older-than-days must be a positive integer");
174
+ const parsed = Number(raw);
175
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error("older-than-days must be a positive integer");
176
+ olderThanDays = parsed; seenDays = true; continue;
177
+ }
178
+ throw new Error(`Unknown cleanup option: ${token}`);
179
+ }
180
+ return { olderThanDays, yes };
181
+ }
161
182
  function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustOverride?: boolean } {
162
183
  const args: string[] = [];
163
184
  let trustOverride: boolean | undefined;
@@ -233,7 +254,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
233
254
  workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
234
255
  const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
235
256
  if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
236
- return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
257
+ return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
237
258
  }
238
259
 
239
260
  function availableModelInfo(services: WorkflowRuntime["services"], available = false): { provider: string; id: string }[] {
@@ -415,6 +436,16 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
415
436
  write(formatDoctorReport(report));
416
437
  return doctorExitCode(report);
417
438
  }
439
+ if (args[0] === "doctor" && args[1] === "cleanup") {
440
+ if (args.slice(2).some((arg) => arg === "--help" || arg === "-h")) { write("Usage: pi-extensible-workflows doctor cleanup [--older-than-days <days>] [--yes]\n"); return 0; }
441
+ try {
442
+ const parsed = parseDoctorCleanupArgs(args.slice(2));
443
+ const cleanupOptions: DoctorCleanupOptions = { ...parsed, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) };
444
+ const report = await doctorCleanup(cleanupOptions);
445
+ write(formatDoctorCleanupReport(report));
446
+ return doctorCleanupExitCode(report);
447
+ } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
448
+ }
418
449
  if (args[0] === "inspect" && args.length <= 2) {
419
450
  try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
420
451
  catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }