@quintinshaw/pi-dynamic-workflows 1.2.0 → 1.4.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,8 @@ 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, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
137
+ - **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
124
138
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
125
139
  - **Live progress + token/cost display**, `Esc` to abort
126
140
  - **Log persistence** to `.pi/workflows/runs/`
@@ -129,8 +143,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
129
143
 
130
144
  Tracked toward closer parity with Claude Code dynamic workflows:
131
145
 
132
- - **Command surface** — `/workflows` (list / status / stop) and reachable background runs
133
- - **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
136
148
 
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";
@@ -33,6 +33,12 @@ export interface PersistedRunState {
33
33
  output: number;
34
34
  total: number;
35
35
  };
36
+ /** Cached agent results for resume, keyed by deterministic call index. */
37
+ journal?: Array<{
38
+ index: number;
39
+ hash: string;
40
+ result: unknown;
41
+ }>;
36
42
  }
37
43
  export interface RunPersistence {
38
44
  /** Save current run state. */
@@ -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
+ }
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
5
5
  import type { WorkflowSnapshot } from "./display.js";
6
6
  import { WorkflowError } from "./errors.js";
7
7
  import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
8
- import { type WorkflowRunResult } from "./workflow.js";
8
+ import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
9
9
  export interface ManagedRun {
10
10
  runId: string;
11
11
  status: RunStatus;
@@ -14,6 +14,11 @@ export interface ManagedRun {
14
14
  error?: WorkflowError;
15
15
  controller: AbortController;
16
16
  startedAt: Date;
17
+ /** The real script, kept so the run can be resumed. */
18
+ script: string;
19
+ args?: unknown;
20
+ /** Accumulated agent results for resume (deterministic call index -> result). */
21
+ journal: JournalEntry[];
17
22
  }
18
23
  export interface WorkflowManagerOptions {
19
24
  cwd?: string;
@@ -44,7 +49,8 @@ export declare class WorkflowManager extends EventEmitter {
44
49
  */
45
50
  pause(runId: string): boolean;
46
51
  /**
47
- * Resume a paused workflow.
52
+ * Resume an interrupted run: replay journaled results for the unchanged prefix
53
+ * and run the rest live. Returns false if there is nothing resumable.
48
54
  */
49
55
  resume(runId: string): Promise<boolean>;
50
56
  /**
@@ -40,6 +40,9 @@ export class WorkflowManager extends EventEmitter {
40
40
  },
41
41
  controller,
42
42
  startedAt: new Date(),
43
+ script,
44
+ args,
45
+ journal: [],
43
46
  };
44
47
  this.runs.set(runId, managed);
45
48
  // Persist initial state
@@ -82,17 +85,28 @@ export class WorkflowManager extends EventEmitter {
82
85
  },
83
86
  controller,
84
87
  startedAt: new Date(),
88
+ script,
89
+ args,
90
+ journal: [],
85
91
  };
86
92
  this.runs.set(runId, managed);
87
93
  return this.executeRun(managed, script, args);
88
94
  }
89
- async executeRun(managed, script, args) {
95
+ async executeRun(managed, script, args, resumeJournal) {
90
96
  try {
91
97
  const result = await runWorkflow(script, {
92
98
  cwd: this.cwd,
93
99
  args,
94
100
  signal: managed.controller.signal,
95
101
  concurrency: this.concurrency,
102
+ resumeJournal,
103
+ resumeFromRunId: resumeJournal ? managed.runId : undefined,
104
+ onAgentJournal: (entry) => {
105
+ // Append (crash-safe-ish): keep the latest entry per index, then persist.
106
+ managed.journal = managed.journal.filter((e) => e.index !== entry.index);
107
+ managed.journal.push(entry);
108
+ this.persistRun(managed);
109
+ },
96
110
  onLog: (message) => {
97
111
  managed.snapshot.logs.push(message);
98
112
  this.emit("log", { runId: managed.runId, message });
@@ -152,7 +166,11 @@ export class WorkflowManager extends EventEmitter {
152
166
  this.persistence.save({
153
167
  runId: managed.runId,
154
168
  workflowName: managed.snapshot.name,
155
- script: "", // Don't persist script for security
169
+ // Persist the real script + journal so the run can be resumed. Runs live
170
+ // under .pi/workflows/runs/ — protect via directory permissions, not blanking.
171
+ script: managed.script,
172
+ args: managed.args,
173
+ journal: managed.journal,
156
174
  status: managed.status,
157
175
  phases: managed.snapshot.phases,
158
176
  currentPhase: managed.snapshot.currentPhase,
@@ -183,15 +201,41 @@ export class WorkflowManager extends EventEmitter {
183
201
  return true;
184
202
  }
185
203
  /**
186
- * Resume a paused workflow.
204
+ * Resume an interrupted run: replay journaled results for the unchanged prefix
205
+ * and run the rest live. Returns false if there is nothing resumable.
187
206
  */
188
207
  async resume(runId) {
208
+ const active = this.runs.get(runId);
209
+ if (active?.status === "running")
210
+ return false; // already running
189
211
  const persisted = this.persistence.load(runId);
190
- if (persisted?.status !== "paused")
212
+ if (!persisted?.script || persisted.status === "completed")
191
213
  return false;
192
- // For now, resume creates a fresh run with completed agents' results cached
193
- // Full resume would require re-executing the script with cached results
214
+ const controller = new AbortController();
215
+ const managed = {
216
+ runId,
217
+ status: "running",
218
+ snapshot: {
219
+ name: persisted.workflowName,
220
+ phases: persisted.phases ?? [],
221
+ logs: persisted.logs ?? [],
222
+ agents: [],
223
+ agentCount: 0,
224
+ runningCount: 0,
225
+ doneCount: 0,
226
+ errorCount: 0,
227
+ },
228
+ controller,
229
+ startedAt: new Date(),
230
+ script: persisted.script,
231
+ args: persisted.args,
232
+ journal: persisted.journal ?? [],
233
+ };
234
+ this.runs.set(runId, managed);
235
+ const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
194
236
  this.emit("resumed", { runId });
237
+ // Run in the background; executeRun records status/errors on the managed run.
238
+ void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
195
239
  return true;
196
240
  }
197
241
  /**
@@ -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
  ],
@@ -11,6 +11,13 @@ export interface WorkflowMeta {
11
11
  whenToUse?: string;
12
12
  phases?: WorkflowMetaPhase[];
13
13
  }
14
+ /** One cached agent() result, keyed by its deterministic call index. */
15
+ export interface JournalEntry {
16
+ index: number;
17
+ /** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
18
+ hash: string;
19
+ result: unknown;
20
+ }
14
21
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
15
22
  args?: unknown;
16
23
  agent?: Pick<WorkflowAgent, "run">;
@@ -25,6 +32,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
25
32
  persistLogs?: boolean;
26
33
  /** Run ID for persistence. Auto-generated if not provided. */
27
34
  runId?: string;
35
+ /** Resume: cached agent results keyed by deterministic call index. */
36
+ resumeJournal?: Map<number, JournalEntry>;
37
+ /** Resume: the run being resumed (informational; enables resume mode). */
38
+ resumeFromRunId?: string;
39
+ /** Called after each live agent completes so the caller can persist the journal. */
40
+ onAgentJournal?: (entry: JournalEntry) => void;
28
41
  onLog?: (message: string) => void;
29
42
  onPhase?: (title: string) => void;
30
43
  onAgentStart?: (event: {
package/dist/workflow.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import vm from "node:vm";
2
3
  import { parse } from "acorn";
3
4
  import { WorkflowAgent } from "./agent.js";
@@ -25,6 +26,7 @@ export async function runWorkflow(script, options = {}) {
25
26
  logs: [],
26
27
  phases: [],
27
28
  agentCount: 0,
29
+ callSeq: 0,
28
30
  spent: 0,
29
31
  tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
30
32
  };
@@ -67,6 +69,20 @@ export async function runWorkflow(script, options = {}) {
67
69
  const requestedLabel = agentOptions.label?.trim();
68
70
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
69
71
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
72
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
73
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
74
+ const callIndex = state.callSeq++;
75
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
76
+ // Resume: replay a cached result for an unchanged call (matching hash), without
77
+ // consuming a concurrency slot, tokens, or a real subagent run.
78
+ const cached = options.resumeJournal?.get(callIndex);
79
+ if (cached && cached.hash === callHash) {
80
+ state.agentCount++;
81
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
82
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
83
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
84
+ return cached.result;
85
+ }
70
86
  return limiter(async () => {
71
87
  state.agentCount++;
72
88
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
@@ -101,6 +117,7 @@ export async function runWorkflow(script, options = {}) {
101
117
  }), timeout, `Agent "${label}" timed out after ${timeout}ms`);
102
118
  throwIfAborted();
103
119
  const tokens = recordTokens(result);
120
+ options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
104
121
  options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
105
122
  return result;
106
123
  }
@@ -348,6 +365,17 @@ function createLimiter(limit) {
348
365
  function defaultAgentLabel(phase, index) {
349
366
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
350
367
  }
368
+ /** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
369
+ function hashAgentCall(prompt, model, phase, options) {
370
+ const identity = JSON.stringify({
371
+ prompt,
372
+ model: model ?? null,
373
+ phase: phase ?? null,
374
+ agentType: options.agentType ?? null,
375
+ schema: options.schema ?? null,
376
+ });
377
+ return createHash("sha256").update(identity).digest("hex");
378
+ }
351
379
  function buildAgentInstructions(phase, options) {
352
380
  const lines = [];
353
381
  if (phase)
@@ -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.4.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";
@@ -40,6 +40,8 @@ export interface PersistedRunState {
40
40
  output: number;
41
41
  total: number;
42
42
  };
43
+ /** Cached agent results for resume, keyed by deterministic call index. */
44
+ journal?: Array<{ index: number; hash: string; result: unknown }>;
43
45
  }
44
46
 
45
47
  export interface RunPersistence {
@@ -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
  type RunPersistence,
13
13
  type RunStatus,
14
14
  } from "./run-persistence.js";
15
- import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
15
+ import { type JournalEntry, parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
16
16
 
17
17
  export interface ManagedRun {
18
18
  runId: string;
@@ -22,6 +22,11 @@ export interface ManagedRun {
22
22
  error?: WorkflowError;
23
23
  controller: AbortController;
24
24
  startedAt: Date;
25
+ /** The real script, kept so the run can be resumed. */
26
+ script: string;
27
+ args?: unknown;
28
+ /** Accumulated agent results for resume (deterministic call index -> result). */
29
+ journal: JournalEntry[];
25
30
  }
26
31
 
27
32
  export interface WorkflowManagerOptions {
@@ -67,6 +72,9 @@ export class WorkflowManager extends EventEmitter {
67
72
  },
68
73
  controller,
69
74
  startedAt: new Date(),
75
+ script,
76
+ args,
77
+ journal: [],
70
78
  };
71
79
 
72
80
  this.runs.set(runId, managed);
@@ -115,19 +123,35 @@ export class WorkflowManager extends EventEmitter {
115
123
  },
116
124
  controller,
117
125
  startedAt: new Date(),
126
+ script,
127
+ args,
128
+ journal: [],
118
129
  };
119
130
 
120
131
  this.runs.set(runId, managed);
121
132
  return this.executeRun(managed, script, args);
122
133
  }
123
134
 
124
- private async executeRun(managed: ManagedRun, script: string, args?: unknown): Promise<WorkflowRunResult> {
135
+ private async executeRun(
136
+ managed: ManagedRun,
137
+ script: string,
138
+ args?: unknown,
139
+ resumeJournal?: Map<number, JournalEntry>,
140
+ ): Promise<WorkflowRunResult> {
125
141
  try {
126
142
  const result = await runWorkflow(script, {
127
143
  cwd: this.cwd,
128
144
  args,
129
145
  signal: managed.controller.signal,
130
146
  concurrency: this.concurrency,
147
+ resumeJournal,
148
+ resumeFromRunId: resumeJournal ? managed.runId : undefined,
149
+ onAgentJournal: (entry) => {
150
+ // Append (crash-safe-ish): keep the latest entry per index, then persist.
151
+ managed.journal = managed.journal.filter((e) => e.index !== entry.index);
152
+ managed.journal.push(entry);
153
+ this.persistRun(managed);
154
+ },
131
155
  onLog: (message) => {
132
156
  managed.snapshot.logs.push(message);
133
157
  this.emit("log", { runId: managed.runId, message });
@@ -197,7 +221,11 @@ export class WorkflowManager extends EventEmitter {
197
221
  this.persistence.save({
198
222
  runId: managed.runId,
199
223
  workflowName: managed.snapshot.name,
200
- script: "", // Don't persist script for security
224
+ // Persist the real script + journal so the run can be resumed. Runs live
225
+ // under .pi/workflows/runs/ — protect via directory permissions, not blanking.
226
+ script: managed.script,
227
+ args: managed.args,
228
+ journal: managed.journal,
201
229
  status: managed.status,
202
230
  phases: managed.snapshot.phases,
203
231
  currentPhase: managed.snapshot.currentPhase,
@@ -230,15 +258,42 @@ export class WorkflowManager extends EventEmitter {
230
258
  }
231
259
 
232
260
  /**
233
- * Resume a paused workflow.
261
+ * Resume an interrupted run: replay journaled results for the unchanged prefix
262
+ * and run the rest live. Returns false if there is nothing resumable.
234
263
  */
235
264
  async resume(runId: string): Promise<boolean> {
265
+ const active = this.runs.get(runId);
266
+ if (active?.status === "running") return false; // already running
267
+
236
268
  const persisted = this.persistence.load(runId);
237
- if (persisted?.status !== "paused") return false;
269
+ if (!persisted?.script || persisted.status === "completed") return false;
270
+
271
+ const controller = new AbortController();
272
+ const managed: ManagedRun = {
273
+ runId,
274
+ status: "running",
275
+ snapshot: {
276
+ name: persisted.workflowName,
277
+ phases: persisted.phases ?? [],
278
+ logs: persisted.logs ?? [],
279
+ agents: [],
280
+ agentCount: 0,
281
+ runningCount: 0,
282
+ doneCount: 0,
283
+ errorCount: 0,
284
+ },
285
+ controller,
286
+ startedAt: new Date(),
287
+ script: persisted.script,
288
+ args: persisted.args,
289
+ journal: persisted.journal ?? [],
290
+ };
291
+ this.runs.set(runId, managed);
238
292
 
239
- // For now, resume creates a fresh run with completed agents' results cached
240
- // Full resume would require re-executing the script with cached results
293
+ const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
241
294
  this.emit("resumed", { runId });
295
+ // Run in the background; executeRun records status/errors on the managed run.
296
+ void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
242
297
  return true;
243
298
  }
244
299
 
@@ -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
  ],
package/src/workflow.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import vm from "node:vm";
2
3
  import type { Node } from "acorn";
3
4
  import { parse } from "acorn";
@@ -22,6 +23,14 @@ export interface WorkflowMeta {
22
23
  phases?: WorkflowMetaPhase[];
23
24
  }
24
25
 
26
+ /** One cached agent() result, keyed by its deterministic call index. */
27
+ export interface JournalEntry {
28
+ index: number;
29
+ /** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
30
+ hash: string;
31
+ result: unknown;
32
+ }
33
+
25
34
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
26
35
  args?: unknown;
27
36
  agent?: Pick<WorkflowAgent, "run">;
@@ -36,6 +45,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
36
45
  persistLogs?: boolean;
37
46
  /** Run ID for persistence. Auto-generated if not provided. */
38
47
  runId?: string;
48
+ /** Resume: cached agent results keyed by deterministic call index. */
49
+ resumeJournal?: Map<number, JournalEntry>;
50
+ /** Resume: the run being resumed (informational; enables resume mode). */
51
+ resumeFromRunId?: string;
52
+ /** Called after each live agent completes so the caller can persist the journal. */
53
+ onAgentJournal?: (entry: JournalEntry) => void;
39
54
  onLog?: (message: string) => void;
40
55
  onPhase?: (title: string) => void;
41
56
  onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
@@ -75,6 +90,8 @@ interface RuntimeState {
75
90
  logs: string[];
76
91
  phases: string[];
77
92
  agentCount: number;
93
+ /** Monotonic, assigned at lexical agent() call time — the stable resume key. */
94
+ callSeq: number;
78
95
  spent: number;
79
96
  tokenUsage: {
80
97
  input: number;
@@ -112,6 +129,7 @@ export async function runWorkflow<T = unknown>(
112
129
  logs: [],
113
130
  phases: [],
114
131
  agentCount: 0,
132
+ callSeq: 0,
115
133
  spent: 0,
116
134
  tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
117
135
  };
@@ -170,6 +188,22 @@ export async function runWorkflow<T = unknown>(
170
188
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
171
189
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
172
190
 
191
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
192
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
193
+ const callIndex = state.callSeq++;
194
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
195
+
196
+ // Resume: replay a cached result for an unchanged call (matching hash), without
197
+ // consuming a concurrency slot, tokens, or a real subagent run.
198
+ const cached = options.resumeJournal?.get(callIndex);
199
+ if (cached && cached.hash === callHash) {
200
+ state.agentCount++;
201
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
202
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
203
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
204
+ return cached.result;
205
+ }
206
+
173
207
  return limiter(async () => {
174
208
  state.agentCount++;
175
209
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
@@ -214,6 +248,7 @@ export async function runWorkflow<T = unknown>(
214
248
  throwIfAborted();
215
249
 
216
250
  const tokens = recordTokens(result);
251
+ options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
217
252
  options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
218
253
  return result;
219
254
  } catch (error) {
@@ -481,6 +516,23 @@ function defaultAgentLabel(phase: string | undefined, index: number): string {
481
516
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
482
517
  }
483
518
 
519
+ /** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
520
+ function hashAgentCall(
521
+ prompt: string,
522
+ model: string | undefined,
523
+ phase: string | undefined,
524
+ options: AgentOptions,
525
+ ): string {
526
+ const identity = JSON.stringify({
527
+ prompt,
528
+ model: model ?? null,
529
+ phase: phase ?? null,
530
+ agentType: options.agentType ?? null,
531
+ schema: options.schema ?? null,
532
+ });
533
+ return createHash("sha256").update(identity).digest("hex");
534
+ }
535
+
484
536
  function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
485
537
  const lines = [];
486
538
  if (phase) lines.push(`Workflow phase: ${phase}`);