@quintinshaw/pi-dynamic-workflows 1.2.0 → 1.3.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
@@ -46,6 +46,18 @@ The model writes a workflow script and calls the `workflow` tool. Live progress
46
46
 
47
47
  Press `Esc` to cancel a running run; active subagents are aborted and surfaced as skipped.
48
48
 
49
+ ### Background runs & `/workflows`
50
+
51
+ Ask for a background workflow (the model passes `background: true`) and it runs without blocking your session. Manage it with the `/workflows` command:
52
+
53
+ ```text
54
+ /workflows # list runs (default)
55
+ /workflows status <id> # show a run's progress
56
+ /workflows stop <id> # abort a running run
57
+ /workflows pause <id> # pause a running run
58
+ /workflows rm <id> # remove a run from the list
59
+ ```
60
+
49
61
  ## Workflow script shape
50
62
 
51
63
  A workflow is plain JavaScript. The first statement must export literal metadata:
@@ -121,6 +133,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
121
133
  - **Structured output** — JSON-Schema-validated subagent results
122
134
  - **Real token & cost accounting** — read from each subagent's SDK session (input / output / total / cost), with a character estimate only as fallback when a provider reports no usage; `budget` gates on the real total
123
135
  - **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
136
+ - **`/workflows` command** — list, inspect, stop, pause, and remove background runs; runs started with `background: true` are reachable from the command
124
137
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
125
138
  - **Live progress + token/cost display**, `Esc` to abort
126
139
  - **Log persistence** to `.pi/workflows/runs/`
@@ -129,7 +142,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
129
142
 
130
143
  Tracked toward closer parity with Claude Code dynamic workflows:
131
144
 
132
- - **Command surface** — `/workflows` (list / status / stop) and reachable background runs
133
145
  - **Resume** — journaled results, replay the unchanged prefix, run the rest live
134
146
  - **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
135
147
  - **Saved workflows** as `/<name>` slash commands
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
20
20
  export { createStructuredOutputTool } from "./structured-output.js";
21
21
  export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
22
22
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
23
+ export { registerWorkflowCommands } from "./workflow-commands.js";
23
24
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
24
25
  export { WorkflowManager } from "./workflow-manager.js";
25
26
  export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { buildModelRoutingInstructions, parseModelRoutingFromMeta, resolveModelF
10
10
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
11
11
  export { createStructuredOutputTool } from "./structured-output.js";
12
12
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
13
+ export { registerWorkflowCommands } from "./workflow-commands.js";
13
14
  export { WorkflowManager } from "./workflow-manager.js";
14
15
  export { createWorkflowStorage } from "./workflow-saved.js";
15
16
  export { createWorkflowTool } from "./workflow-tool.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `/workflows` slash command: list, inspect, and control background workflow runs.
3
+ * Shares the extension's single WorkflowManager so background runs are reachable.
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { WorkflowManager } from "./workflow-manager.js";
7
+ /** Register the `/workflows` command against the shared manager. Idempotent. */
8
+ export declare function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `/workflows` slash command: list, inspect, and control background workflow runs.
3
+ * Shares the extension's single WorkflowManager so background runs are reachable.
4
+ */
5
+ import { renderWorkflowText } from "./display.js";
6
+ const STATUS_ICON = {
7
+ pending: "·",
8
+ running: "◆",
9
+ paused: "⏸",
10
+ completed: "✓",
11
+ failed: "✗",
12
+ aborted: "⊘",
13
+ };
14
+ const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
15
+ function summarizeRun(run) {
16
+ const icon = STATUS_ICON[run.status] ?? "?";
17
+ const done = run.agents.filter((a) => a.status === "done").length;
18
+ const total = run.agents.length;
19
+ const tokens = run.tokenUsage ? ` · ${run.tokenUsage.total.toLocaleString()} tok` : "";
20
+ return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
21
+ }
22
+ function renderPersistedStatus(run) {
23
+ const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
24
+ if (run.currentPhase)
25
+ lines.push(` phase: ${run.currentPhase}`);
26
+ for (const agent of run.agents) {
27
+ const icon = agent.status === "done" ? "✓" : agent.status === "error" ? "✗" : agent.status === "running" ? "◆" : "·";
28
+ lines.push(` ${icon} ${agent.label}`);
29
+ }
30
+ if (run.tokenUsage)
31
+ lines.push(` tokens: ${run.tokenUsage.total.toLocaleString()}`);
32
+ if (run.durationMs)
33
+ lines.push(` duration: ${(run.durationMs / 1000).toFixed(1)}s`);
34
+ return lines.join("\n");
35
+ }
36
+ /** Register the `/workflows` command against the shared manager. Idempotent. */
37
+ export function registerWorkflowCommands(pi, manager) {
38
+ try {
39
+ const taken = (pi.getCommands?.() ?? []).some((c) => c.name === "workflows");
40
+ if (taken)
41
+ return;
42
+ }
43
+ catch {
44
+ // getCommands may be unavailable in some hosts; fall through and try to register.
45
+ }
46
+ pi.registerCommand("workflows", {
47
+ description: "List and control background workflow runs",
48
+ async handler(args, ctx) {
49
+ const parts = args.trim().split(/\s+/).filter(Boolean);
50
+ const sub = (parts[0] ?? "list").toLowerCase();
51
+ const id = parts[1];
52
+ const print = (text) => pi.sendMessage({ customType: "workflows", content: text, display: true });
53
+ switch (sub) {
54
+ case "list": {
55
+ const runs = manager.listRuns();
56
+ if (!runs.length) {
57
+ await print("No workflow runs yet. Start one with a background workflow (background: true).");
58
+ return;
59
+ }
60
+ await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
61
+ return;
62
+ }
63
+ case "status": {
64
+ if (!id) {
65
+ ctx.ui.notify(USAGE, "warning");
66
+ return;
67
+ }
68
+ const live = manager.getSnapshot(id);
69
+ if (live) {
70
+ await print(renderWorkflowText(live, false));
71
+ return;
72
+ }
73
+ const run = manager.listRuns().find((r) => r.runId === id);
74
+ if (!run) {
75
+ ctx.ui.notify(`No workflow run "${id}"`, "error");
76
+ return;
77
+ }
78
+ await print(renderPersistedStatus(run));
79
+ return;
80
+ }
81
+ case "stop": {
82
+ if (!id)
83
+ return ctx.ui.notify(USAGE, "warning");
84
+ ctx.ui.notify(manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id} (not running)`, manager.getRun(id) ? "info" : "warning");
85
+ return;
86
+ }
87
+ case "pause": {
88
+ if (!id)
89
+ return ctx.ui.notify(USAGE, "warning");
90
+ ctx.ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id} (not running)`, "info");
91
+ return;
92
+ }
93
+ case "resume": {
94
+ if (!id)
95
+ return ctx.ui.notify(USAGE, "warning");
96
+ const ok = await manager.resume(id);
97
+ ctx.ui.notify(ok ? `Resumed ${id}` : `Resume not available for ${id} yet`, ok ? "info" : "warning");
98
+ return;
99
+ }
100
+ case "rm": {
101
+ if (!id)
102
+ return ctx.ui.notify(USAGE, "warning");
103
+ ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
104
+ return;
105
+ }
106
+ default:
107
+ ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
108
+ }
109
+ },
110
+ });
111
+ }
@@ -1,5 +1,7 @@
1
1
  import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { WorkflowManager } from "./workflow-manager.js";
4
+ import { type WorkflowStorage } from "./workflow-saved.js";
3
5
  declare const workflowToolSchema: Type.TObject<{
4
6
  script: Type.TString;
5
7
  args: Type.TOptional<Type.TAny>;
@@ -17,6 +19,10 @@ export type WorkflowToolInput = {
17
19
  export interface WorkflowToolOptions {
18
20
  cwd?: string;
19
21
  concurrency?: number;
22
+ /** Shared manager so background runs are reachable from the `/workflows` command. */
23
+ manager?: WorkflowManager;
24
+ /** Shared saved-workflow storage. */
25
+ storage?: WorkflowStorage;
20
26
  }
21
27
  export declare function createWorkflowTool(options?: WorkflowToolOptions): ToolDefinition<typeof workflowToolSchema, any>;
22
28
  export {};
@@ -27,8 +27,8 @@ const workflowToolSchema = Type.Object({
27
27
  })),
28
28
  });
29
29
  export function createWorkflowTool(options = {}) {
30
- const manager = new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
31
- const _storage = createWorkflowStorage(options.cwd ?? process.cwd());
30
+ const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
31
+ const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
32
32
  return defineTool({
33
33
  name: "workflow",
34
34
  label: "Workflow",
@@ -70,8 +70,8 @@ export function createWorkflowTool(options = {}) {
70
70
  text: [
71
71
  `Workflow "${parsed.meta.name}" started in background.`,
72
72
  `Run ID: ${runId}`,
73
- `Use /workflow status ${runId} to check progress.`,
74
- `Use /workflow stop ${runId} to cancel.`,
73
+ `Use /workflows status ${runId} to check progress.`,
74
+ `Use /workflows stop ${runId} to cancel.`,
75
75
  ].join("\n"),
76
76
  },
77
77
  ],
@@ -1,9 +1,16 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createWorkflowTool } from "../src/index.js";
2
+ import { createWorkflowStorage, createWorkflowTool, registerWorkflowCommands, WorkflowManager } from "../src/index.js";
3
3
 
4
4
  export default function extension(pi: ExtensionAPI) {
5
- const workflowTool = createWorkflowTool();
5
+ // Single manager/storage shared by the workflow tool and the /workflows command,
6
+ // so background runs started by the tool are reachable from the command.
7
+ const cwd = process.cwd();
8
+ const manager = new WorkflowManager({ cwd });
9
+ const storage = createWorkflowStorage(cwd);
10
+
11
+ const workflowTool = createWorkflowTool({ cwd, manager, storage });
6
12
  pi.registerTool(workflowTool);
13
+ registerWorkflowCommands(pi, manager);
7
14
 
8
15
  pi.on("session_start", () => {
9
16
  const active = pi.getActiveTools();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
@@ -47,6 +47,7 @@ export type {
47
47
  WorkflowRunResult,
48
48
  } from "./workflow.js";
49
49
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
50
+ export { registerWorkflowCommands } from "./workflow-commands.js";
50
51
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
51
52
  export { WorkflowManager } from "./workflow-manager.js";
52
53
  export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `/workflows` slash command: list, inspect, and control background workflow runs.
3
+ * Shares the extension's single WorkflowManager so background runs are reachable.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { renderWorkflowText } from "./display.js";
8
+ import type { PersistedRunState } from "./run-persistence.js";
9
+ import type { WorkflowManager } from "./workflow-manager.js";
10
+
11
+ const STATUS_ICON: Record<string, string> = {
12
+ pending: "·",
13
+ running: "◆",
14
+ paused: "⏸",
15
+ completed: "✓",
16
+ failed: "✗",
17
+ aborted: "⊘",
18
+ };
19
+
20
+ const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
21
+
22
+ function summarizeRun(run: PersistedRunState): string {
23
+ const icon = STATUS_ICON[run.status] ?? "?";
24
+ const done = run.agents.filter((a) => a.status === "done").length;
25
+ const total = run.agents.length;
26
+ const tokens = run.tokenUsage ? ` · ${run.tokenUsage.total.toLocaleString()} tok` : "";
27
+ return `${icon} ${run.runId} ${run.workflowName} [${run.status}] ${done}/${total} agents${tokens}`;
28
+ }
29
+
30
+ function renderPersistedStatus(run: PersistedRunState): string {
31
+ const lines = [`${STATUS_ICON[run.status] ?? "?"} ${run.workflowName} (${run.runId}) — ${run.status}`];
32
+ if (run.currentPhase) lines.push(` phase: ${run.currentPhase}`);
33
+ for (const agent of run.agents) {
34
+ const icon =
35
+ agent.status === "done" ? "✓" : agent.status === "error" ? "✗" : agent.status === "running" ? "◆" : "·";
36
+ lines.push(` ${icon} ${agent.label}`);
37
+ }
38
+ if (run.tokenUsage) lines.push(` tokens: ${run.tokenUsage.total.toLocaleString()}`);
39
+ if (run.durationMs) lines.push(` duration: ${(run.durationMs / 1000).toFixed(1)}s`);
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /** Register the `/workflows` command against the shared manager. Idempotent. */
44
+ export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void {
45
+ try {
46
+ const taken = (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === "workflows");
47
+ if (taken) return;
48
+ } catch {
49
+ // getCommands may be unavailable in some hosts; fall through and try to register.
50
+ }
51
+
52
+ pi.registerCommand("workflows", {
53
+ description: "List and control background workflow runs",
54
+ async handler(args: string, ctx: ExtensionCommandContext) {
55
+ const parts = args.trim().split(/\s+/).filter(Boolean);
56
+ const sub = (parts[0] ?? "list").toLowerCase();
57
+ const id = parts[1];
58
+ const print = (text: string) => pi.sendMessage({ customType: "workflows", content: text, display: true });
59
+
60
+ switch (sub) {
61
+ case "list": {
62
+ const runs = manager.listRuns();
63
+ if (!runs.length) {
64
+ await print("No workflow runs yet. Start one with a background workflow (background: true).");
65
+ return;
66
+ }
67
+ await print(["Workflow runs:", ...runs.map(summarizeRun), "", USAGE].join("\n"));
68
+ return;
69
+ }
70
+ case "status": {
71
+ if (!id) {
72
+ ctx.ui.notify(USAGE, "warning");
73
+ return;
74
+ }
75
+ const live = manager.getSnapshot(id);
76
+ if (live) {
77
+ await print(renderWorkflowText(live, false));
78
+ return;
79
+ }
80
+ const run = manager.listRuns().find((r) => r.runId === id);
81
+ if (!run) {
82
+ ctx.ui.notify(`No workflow run "${id}"`, "error");
83
+ return;
84
+ }
85
+ await print(renderPersistedStatus(run));
86
+ return;
87
+ }
88
+ case "stop": {
89
+ if (!id) return ctx.ui.notify(USAGE, "warning");
90
+ ctx.ui.notify(
91
+ manager.stop(id) ? `Stopped ${id}` : `Cannot stop ${id} (not running)`,
92
+ manager.getRun(id) ? "info" : "warning",
93
+ );
94
+ return;
95
+ }
96
+ case "pause": {
97
+ if (!id) return ctx.ui.notify(USAGE, "warning");
98
+ ctx.ui.notify(manager.pause(id) ? `Paused ${id}` : `Cannot pause ${id} (not running)`, "info");
99
+ return;
100
+ }
101
+ case "resume": {
102
+ if (!id) return ctx.ui.notify(USAGE, "warning");
103
+ const ok = await manager.resume(id);
104
+ ctx.ui.notify(ok ? `Resumed ${id}` : `Resume not available for ${id} yet`, ok ? "info" : "warning");
105
+ return;
106
+ }
107
+ case "rm": {
108
+ if (!id) return ctx.ui.notify(USAGE, "warning");
109
+ ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
110
+ return;
111
+ }
112
+ default:
113
+ ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
114
+ }
115
+ },
116
+ });
117
+ }
@@ -12,7 +12,7 @@ import {
12
12
  import { WorkflowError, WorkflowErrorCode } from "./errors.js";
13
13
  import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
14
14
  import { WorkflowManager } from "./workflow-manager.js";
15
- import { createWorkflowStorage } from "./workflow-saved.js";
15
+ import { createWorkflowStorage, type WorkflowStorage } from "./workflow-saved.js";
16
16
 
17
17
  const workflowToolSchema = Type.Object({
18
18
  script: Type.String({
@@ -54,11 +54,15 @@ export type WorkflowToolInput = {
54
54
  export interface WorkflowToolOptions {
55
55
  cwd?: string;
56
56
  concurrency?: number;
57
+ /** Shared manager so background runs are reachable from the `/workflows` command. */
58
+ manager?: WorkflowManager;
59
+ /** Shared saved-workflow storage. */
60
+ storage?: WorkflowStorage;
57
61
  }
58
62
 
59
63
  export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
60
- const manager = new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
61
- const _storage = createWorkflowStorage(options.cwd ?? process.cwd());
64
+ const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
65
+ const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
62
66
 
63
67
  return defineTool({
64
68
  name: "workflow",
@@ -103,8 +107,8 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
103
107
  text: [
104
108
  `Workflow "${parsed.meta.name}" started in background.`,
105
109
  `Run ID: ${runId}`,
106
- `Use /workflow status ${runId} to check progress.`,
107
- `Use /workflow stop ${runId} to cancel.`,
110
+ `Use /workflows status ${runId} to check progress.`,
111
+ `Use /workflows stop ${runId} to cancel.`,
108
112
  ].join("\n"),
109
113
  },
110
114
  ],