@quintinshaw/pi-dynamic-workflows 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -68,6 +68,8 @@ Ask for a background workflow (the model passes `background: true`) and it runs
68
68
 
69
69
  `/deep-research` fans out web searches across several angles, fetches the top sources with real `web_search` / `web_fetch` tools, keeps only claims supported by multiple sources, and writes a cited report.
70
70
 
71
+ Save any run as a reusable command: `/workflows save <name>` writes the most recent run's script to `.pi/workflows/saved/<name>.json`, and it immediately becomes `/<name>` (arguments parsed as `key=value` + positionals into `args`).
72
+
71
73
  ## Workflow script shape
72
74
 
73
75
  A workflow is plain JavaScript. The first statement must export literal metadata:
@@ -146,6 +148,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
146
148
  - **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
147
149
  - **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
148
150
  - **Bundled `/deep-research` & `/adversarial-review`** — `/deep-research` runs real web searches (via built-in `web_search` / `web_fetch` tools), extracts claims, cross-checks them across sources, and reports only what survived; `/adversarial-review` investigates a task then has independent skeptics try to refute each finding, keeping only those that clear an agreement threshold
151
+ - **Saved workflows as `/<name>`** — save a run's script with `/workflows save <name>` and it becomes a reusable slash command; arguments are parsed (`key=value` and positionals) and passed through as `args`
149
152
  - **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
150
153
  - **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
151
154
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
@@ -156,7 +159,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
156
159
 
157
160
  Tracked toward closer parity with Claude Code dynamic workflows:
158
161
 
159
- - **Saved workflows** as `/<name>` slash commands
160
162
  - **Nested `workflow()`** to compose saved workflows inline
161
163
 
162
164
  ## How it works
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export type { ModelRoute, ModelRoutingConfig } from "./model-routing.js";
17
17
  export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
18
18
  export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
19
19
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
20
+ export { parseCommandArgs, registerAllSavedWorkflows, registerSavedWorkflow, } from "./saved-commands.js";
20
21
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
21
22
  export { createStructuredOutputTool } from "./structured-output.js";
22
23
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { isAbortError, isTimeoutError, isWorkflowError, WorkflowError, WorkflowE
9
9
  export { createWorkflowLogger } from "./logger.js";
10
10
  export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
11
11
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
12
+ export { parseCommandArgs, registerAllSavedWorkflows, registerSavedWorkflow, } from "./saved-commands.js";
12
13
  export { createStructuredOutputTool } from "./structured-output.js";
13
14
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
14
15
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
3
+ * command that runs its script, passing parsed arguments through as `args`.
4
+ */
5
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
7
+ /**
8
+ * Parse a command argument string into an `args` object for the script.
9
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
10
+ * Declared parameter defaults fill in missing keys.
11
+ */
12
+ export declare function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown>;
13
+ /** Register one saved workflow as a `/<name>` command (idempotent). */
14
+ export declare function registerSavedWorkflow(pi: ExtensionAPI, cwd: string, wf: SavedWorkflow): void;
15
+ /** Register every saved workflow found in storage. */
16
+ export declare function registerAllSavedWorkflows(pi: ExtensionAPI, cwd: string, storage: WorkflowStorage): void;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
3
+ * command that runs its script, passing parsed arguments through as `args`.
4
+ */
5
+ import { createCodingTools } from "@earendil-works/pi-coding-agent";
6
+ import { runWorkflow } from "./workflow.js";
7
+ function isRegistered(pi, name) {
8
+ try {
9
+ return (pi.getCommands?.() ?? []).some((c) => c.name === name);
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ function reportText(result) {
16
+ const r = result.result;
17
+ if (r && typeof r.report === "string" && r.report.trim())
18
+ return r.report;
19
+ return JSON.stringify(result.result, null, 2);
20
+ }
21
+ /**
22
+ * Parse a command argument string into an `args` object for the script.
23
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
24
+ * Declared parameter defaults fill in missing keys.
25
+ */
26
+ export function parseCommandArgs(raw, parameters) {
27
+ const out = {};
28
+ const positional = [];
29
+ for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
30
+ const eq = tok.indexOf("=");
31
+ if (eq > 0)
32
+ out[tok.slice(0, eq)] = tok.slice(eq + 1);
33
+ else
34
+ positional.push(tok);
35
+ }
36
+ out._ = positional.join(" ");
37
+ out._raw = raw.trim();
38
+ for (const [key, spec] of Object.entries(parameters ?? {})) {
39
+ if (out[key] === undefined && spec.default !== undefined)
40
+ out[key] = spec.default;
41
+ }
42
+ return out;
43
+ }
44
+ /** Register one saved workflow as a `/<name>` command (idempotent). */
45
+ export function registerSavedWorkflow(pi, cwd, wf) {
46
+ if (isRegistered(pi, wf.name))
47
+ return;
48
+ pi.registerCommand(wf.name, {
49
+ description: wf.description || `Saved workflow: ${wf.name}`,
50
+ async handler(args, ctx) {
51
+ try {
52
+ const result = await runWorkflow(wf.script, {
53
+ cwd,
54
+ args: parseCommandArgs(args, wf.parameters),
55
+ tools: createCodingTools(cwd),
56
+ onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
57
+ });
58
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
59
+ await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
60
+ }
61
+ catch (error) {
62
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
63
+ ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
64
+ }
65
+ },
66
+ });
67
+ }
68
+ /** Register every saved workflow found in storage. */
69
+ export function registerAllSavedWorkflows(pi, cwd, storage) {
70
+ for (const wf of storage.list())
71
+ registerSavedWorkflow(pi, cwd, wf);
72
+ }
@@ -4,5 +4,12 @@
4
4
  */
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import type { WorkflowManager } from "./workflow-manager.js";
7
+ import type { WorkflowStorage } from "./workflow-saved.js";
8
+ export interface WorkflowCommandOptions {
9
+ /** Saved-workflow storage, enabling `/workflows save`. */
10
+ storage?: WorkflowStorage;
11
+ /** Working directory for saved workflows registered via `save`. */
12
+ cwd?: string;
13
+ }
7
14
  /** Register the `/workflows` command against the shared manager. Idempotent. */
8
- export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void;
15
+ export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager, opts?: WorkflowCommandOptions): void;
@@ -3,6 +3,7 @@
3
3
  * Shares the extension's single WorkflowManager so background runs are reachable.
4
4
  */
5
5
  import { renderWorkflowText } from "./display.js";
6
+ import { registerSavedWorkflow } from "./saved-commands.js";
6
7
  const STATUS_ICON = {
7
8
  pending: "·",
8
9
  running: "◆",
@@ -11,7 +12,7 @@ const STATUS_ICON = {
11
12
  failed: "✗",
12
13
  aborted: "⊘",
13
14
  };
14
- const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
15
+ const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id> | save <name> [runId]";
15
16
  function summarizeRun(run) {
16
17
  const icon = STATUS_ICON[run.status] ?? "?";
17
18
  const done = run.agents.filter((a) => a.status === "done").length;
@@ -34,7 +35,7 @@ function renderPersistedStatus(run) {
34
35
  return lines.join("\n");
35
36
  }
36
37
  /** Register the `/workflows` command against the shared manager. Idempotent. */
37
- export function registerWorkflowCommands(pi, manager) {
38
+ export function registerWorkflowCommands(pi, manager, opts = {}) {
38
39
  try {
39
40
  const taken = (pi.getCommands?.() ?? []).some((c) => c.name === "workflows");
40
41
  if (taken)
@@ -103,6 +104,30 @@ export function registerWorkflowCommands(pi, manager) {
103
104
  ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
104
105
  return;
105
106
  }
107
+ case "save": {
108
+ const name = id;
109
+ if (!name)
110
+ return ctx.ui.notify("Usage: /workflows save <name> [runId]", "warning");
111
+ if (!opts.storage)
112
+ return ctx.ui.notify("Saving is not available (no storage configured)", "error");
113
+ const runs = manager.listRuns();
114
+ const runIdArg = parts[2];
115
+ // Pick the named run, else the most recent run that still has its script.
116
+ const run = runIdArg ? runs.find((r) => r.runId === runIdArg) : runs.find((r) => r.script);
117
+ if (!run?.script) {
118
+ ctx.ui.notify(runIdArg ? `No run ${runIdArg} with a script` : "No saved run to save", "error");
119
+ return;
120
+ }
121
+ const saved = opts.storage.save({
122
+ name,
123
+ description: run.workflowName,
124
+ script: run.script,
125
+ location: "project",
126
+ });
127
+ registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
128
+ ctx.ui.notify(`Saved /${name} (from ${run.runId})`, "info");
129
+ return;
130
+ }
106
131
  default:
107
132
  ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
108
133
  }
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  createWorkflowStorage,
4
4
  createWorkflowTool,
5
+ registerAllSavedWorkflows,
5
6
  registerBuiltinWorkflows,
6
7
  registerWorkflowCommands,
7
8
  WorkflowManager,
@@ -16,8 +17,9 @@ export default function extension(pi: ExtensionAPI) {
16
17
 
17
18
  const workflowTool = createWorkflowTool({ cwd, manager, storage });
18
19
  pi.registerTool(workflowTool);
19
- registerWorkflowCommands(pi, manager);
20
+ registerWorkflowCommands(pi, manager, { storage, cwd });
20
21
  registerBuiltinWorkflows(pi, { cwd });
22
+ registerAllSavedWorkflows(pi, cwd, storage);
21
23
 
22
24
  pi.on("session_start", () => {
23
25
  const active = pi.getActiveTools();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -38,6 +38,11 @@ export type { ModelRoute, ModelRoutingConfig } from "./model-routing.js";
38
38
  export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
39
39
  export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persistence.js";
40
40
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
41
+ export {
42
+ parseCommandArgs,
43
+ registerAllSavedWorkflows,
44
+ registerSavedWorkflow,
45
+ } from "./saved-commands.js";
41
46
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
42
47
  export { createStructuredOutputTool } from "./structured-output.js";
43
48
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Saved workflows as `/<name>` slash commands. Each saved workflow becomes a
3
+ * command that runs its script, passing parsed arguments through as `args`.
4
+ */
5
+
6
+ import { createCodingTools, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { runWorkflow, type WorkflowRunResult } from "./workflow.js";
8
+ import type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
9
+
10
+ function isRegistered(pi: ExtensionAPI, name: string): boolean {
11
+ try {
12
+ return (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === name);
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function reportText(result: WorkflowRunResult): string {
19
+ const r = result.result as { report?: unknown } | undefined;
20
+ if (r && typeof r.report === "string" && r.report.trim()) return r.report;
21
+ return JSON.stringify(result.result, null, 2);
22
+ }
23
+
24
+ /**
25
+ * Parse a command argument string into an `args` object for the script.
26
+ * Supports `key=value` tokens; everything else collects into `_` (and `_raw`).
27
+ * Declared parameter defaults fill in missing keys.
28
+ */
29
+ export function parseCommandArgs(raw: string, parameters?: SavedWorkflow["parameters"]): Record<string, unknown> {
30
+ const out: Record<string, unknown> = {};
31
+ const positional: string[] = [];
32
+ for (const tok of raw.trim().split(/\s+/).filter(Boolean)) {
33
+ const eq = tok.indexOf("=");
34
+ if (eq > 0) out[tok.slice(0, eq)] = tok.slice(eq + 1);
35
+ else positional.push(tok);
36
+ }
37
+ out._ = positional.join(" ");
38
+ out._raw = raw.trim();
39
+ for (const [key, spec] of Object.entries(parameters ?? {})) {
40
+ if (out[key] === undefined && spec.default !== undefined) out[key] = spec.default;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Register one saved workflow as a `/<name>` command (idempotent). */
46
+ export function registerSavedWorkflow(pi: ExtensionAPI, cwd: string, wf: SavedWorkflow): void {
47
+ if (isRegistered(pi, wf.name)) return;
48
+ pi.registerCommand(wf.name, {
49
+ description: wf.description || `Saved workflow: ${wf.name}`,
50
+ async handler(args: string, ctx: ExtensionCommandContext) {
51
+ try {
52
+ const result = await runWorkflow(wf.script, {
53
+ cwd,
54
+ args: parseCommandArgs(args, wf.parameters),
55
+ tools: createCodingTools(cwd),
56
+ onPhase: (title) => ctx.ui.setStatus(`wf:${wf.name}`, `${wf.name}: ${title}`),
57
+ });
58
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
59
+ await pi.sendMessage({ customType: `workflow:${wf.name}`, content: reportText(result), display: true });
60
+ } catch (error) {
61
+ ctx.ui.setStatus(`wf:${wf.name}`, undefined);
62
+ ctx.ui.notify(`/${wf.name} failed: ${error instanceof Error ? error.message : error}`, "error");
63
+ }
64
+ },
65
+ });
66
+ }
67
+
68
+ /** Register every saved workflow found in storage. */
69
+ export function registerAllSavedWorkflows(pi: ExtensionAPI, cwd: string, storage: WorkflowStorage): void {
70
+ for (const wf of storage.list()) registerSavedWorkflow(pi, cwd, wf);
71
+ }
@@ -6,7 +6,9 @@
6
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
7
  import { renderWorkflowText } from "./display.js";
8
8
  import type { PersistedRunState } from "./run-persistence.js";
9
+ import { registerSavedWorkflow } from "./saved-commands.js";
9
10
  import type { WorkflowManager } from "./workflow-manager.js";
11
+ import type { WorkflowStorage } from "./workflow-saved.js";
10
12
 
11
13
  const STATUS_ICON: Record<string, string> = {
12
14
  pending: "·",
@@ -17,7 +19,8 @@ const STATUS_ICON: Record<string, string> = {
17
19
  aborted: "⊘",
18
20
  };
19
21
 
20
- const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
22
+ const USAGE =
23
+ "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id> | save <name> [runId]";
21
24
 
22
25
  function summarizeRun(run: PersistedRunState): string {
23
26
  const icon = STATUS_ICON[run.status] ?? "?";
@@ -40,8 +43,19 @@ function renderPersistedStatus(run: PersistedRunState): string {
40
43
  return lines.join("\n");
41
44
  }
42
45
 
46
+ export interface WorkflowCommandOptions {
47
+ /** Saved-workflow storage, enabling `/workflows save`. */
48
+ storage?: WorkflowStorage;
49
+ /** Working directory for saved workflows registered via `save`. */
50
+ cwd?: string;
51
+ }
52
+
43
53
  /** Register the `/workflows` command against the shared manager. Idempotent. */
44
- export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void {
54
+ export function registerWorkflowCommands(
55
+ pi: ExtensionAPI,
56
+ manager: WorkflowManager,
57
+ opts: WorkflowCommandOptions = {},
58
+ ): void {
45
59
  try {
46
60
  const taken = (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === "workflows");
47
61
  if (taken) return;
@@ -109,6 +123,28 @@ export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowMana
109
123
  ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
110
124
  return;
111
125
  }
126
+ case "save": {
127
+ const name = id;
128
+ if (!name) return ctx.ui.notify("Usage: /workflows save <name> [runId]", "warning");
129
+ if (!opts.storage) return ctx.ui.notify("Saving is not available (no storage configured)", "error");
130
+ const runs = manager.listRuns();
131
+ const runIdArg = parts[2];
132
+ // Pick the named run, else the most recent run that still has its script.
133
+ const run = runIdArg ? runs.find((r) => r.runId === runIdArg) : runs.find((r) => r.script);
134
+ if (!run?.script) {
135
+ ctx.ui.notify(runIdArg ? `No run ${runIdArg} with a script` : "No saved run to save", "error");
136
+ return;
137
+ }
138
+ const saved = opts.storage.save({
139
+ name,
140
+ description: run.workflowName,
141
+ script: run.script,
142
+ location: "project",
143
+ });
144
+ registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
145
+ ctx.ui.notify(`Saved /${name} (from ${run.runId})`, "info");
146
+ return;
147
+ }
112
148
  default:
113
149
  ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
114
150
  }