@quintinshaw/pi-dynamic-workflows 1.3.0 → 1.5.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
@@ -99,6 +99,7 @@ return { inventory, summary }
99
99
  | `phase` | string | Override the current phase for this agent |
100
100
  | `schema` | object | JSON Schema for structured output |
101
101
  | `model` | string | Run this agent on a specific model — `provider/modelId` or a bare `modelId` |
102
+ | `isolation` | `"worktree"` | Run this agent in its own throwaway git worktree (parallel edits without conflict) |
102
103
  | `timeoutMs` | number | Override the default 5-minute agent timeout |
103
104
 
104
105
  Models can also be set per phase via `meta.phases[].model`. Precedence is `opts.model` > phase model > session default; an unknown model logs a warning and falls back to the default.
@@ -133,7 +134,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
133
134
  - **Structured output** — JSON-Schema-validated subagent results
134
135
  - **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
135
136
  - **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
137
+ - **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
138
+ - **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
139
+ - **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
137
140
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
138
141
  - **Live progress + token/cost display**, `Esc` to abort
139
142
  - **Log persistence** to `.pi/workflows/runs/`
@@ -142,9 +145,9 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
142
145
 
143
146
  Tracked toward closer parity with Claude Code dynamic workflows:
144
147
 
145
- - **Resume** journaled results, replay the unchanged prefix, run the rest live
146
- - **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
148
+ - **Bundled `/deep-research`** and `/adversarial-review` workflows
147
149
  - **Saved workflows** as `/<name>` slash commands
150
+ - **Nested `workflow()`** to compose saved workflows inline
148
151
 
149
152
  ## How it works
150
153
 
package/dist/agent.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
38
38
  model?: string;
39
39
  /** Called with the resolved model id once known (for display/telemetry). */
40
40
  onModelResolved?: (modelId: string) => void;
41
+ /** Run this agent in a different working directory (e.g. an isolated worktree). */
42
+ cwd?: string;
41
43
  }
42
44
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
43
45
  export declare class WorkflowAgent {
package/dist/agent.js CHANGED
@@ -39,7 +39,11 @@ export class WorkflowAgent {
39
39
  }
40
40
  async run(prompt, options = {}) {
41
41
  const capture = { called: false, value: undefined };
42
- const customTools = [...this.baseTools, ...(options.tools ?? [])];
42
+ // Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
43
+ // since tools capture their cwd at construction and can't be relocated.
44
+ const runCwd = options.cwd ?? this.cwd;
45
+ const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
46
+ const customTools = [...baseTools, ...(options.tools ?? [])];
43
47
  if (options.schema) {
44
48
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
45
49
  }
@@ -57,7 +61,7 @@ export class WorkflowAgent {
57
61
  }
58
62
  const agentDir = getAgentDir();
59
63
  const { session } = await createAgentSession({
60
- cwd: this.cwd,
64
+ cwd: runCwd,
61
65
  agentDir,
62
66
  sessionManager: SessionManager.inMemory(),
63
67
  // Use real SettingsManager to inherit user's default provider/model settings.
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export type { PersistedRunState, RunPersistence, RunStatus } from "./run-persist
18
18
  export { createRunPersistence, generateRunId } from "./run-persistence.js";
19
19
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
20
20
  export { createStructuredOutputTool } from "./structured-output.js";
21
- export type { AgentOptions, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
21
+ export type { AgentOptions, JournalEntry, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
22
22
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
23
23
  export { registerWorkflowCommands } from "./workflow-commands.js";
24
24
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
@@ -27,3 +27,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
27
27
  export { createWorkflowStorage } from "./workflow-saved.js";
28
28
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
29
29
  export { createWorkflowTool } from "./workflow-tool.js";
30
+ export type { Worktree } from "./worktree.js";
31
+ export { createWorktree, removeWorktree } from "./worktree.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export { registerWorkflowCommands } from "./workflow-commands.js";
14
14
  export { WorkflowManager } from "./workflow-manager.js";
15
15
  export { createWorkflowStorage } from "./workflow-saved.js";
16
16
  export { createWorkflowTool } from "./workflow-tool.js";
17
+ export { createWorktree, removeWorktree } from "./worktree.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. */
@@ -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
  /**
@@ -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: {
@@ -38,6 +51,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
38
51
  phase?: string;
39
52
  result: unknown;
40
53
  tokens?: number;
54
+ worktree?: string;
41
55
  }) => void;
42
56
  onTokenUsage?: (usage: {
43
57
  input: number;
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";
@@ -5,6 +6,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
5
6
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
6
7
  import { createWorkflowLogger } from "./logger.js";
7
8
  import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
9
+ import { createWorktree, removeWorktree } from "./worktree.js";
8
10
  const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
9
11
  export async function runWorkflow(script, options = {}) {
10
12
  const started = Date.now();
@@ -14,6 +16,7 @@ export async function runWorkflow(script, options = {}) {
14
16
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
15
17
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
16
18
  const runId = options.runId ?? `run-${started.toString(36)}`;
19
+ const baseCwd = options.cwd ?? process.cwd();
17
20
  // Initialize logger
18
21
  const logger = createWorkflowLogger({
19
22
  runId,
@@ -25,6 +28,7 @@ export async function runWorkflow(script, options = {}) {
25
28
  logs: [],
26
29
  phases: [],
27
30
  agentCount: 0,
31
+ callSeq: 0,
28
32
  spent: 0,
29
33
  tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
30
34
  };
@@ -67,11 +71,33 @@ export async function runWorkflow(script, options = {}) {
67
71
  const requestedLabel = agentOptions.label?.trim();
68
72
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
69
73
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
74
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
75
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
76
+ const callIndex = state.callSeq++;
77
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
78
+ // Resume: replay a cached result for an unchanged call (matching hash), without
79
+ // consuming a concurrency slot, tokens, or a real subagent run.
80
+ const cached = options.resumeJournal?.get(callIndex);
81
+ if (cached && cached.hash === callHash) {
82
+ state.agentCount++;
83
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
84
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
85
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
86
+ return cached.result;
87
+ }
70
88
  return limiter(async () => {
71
89
  state.agentCount++;
72
90
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
73
91
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
74
92
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
93
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
94
+ let worktree;
95
+ if (agentOptions.isolation === "worktree") {
96
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
97
+ if (!worktree.isolated)
98
+ log(`isolation ignored for "${label}" (${worktree.reason})`);
99
+ }
100
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
75
101
  // Captured from the subagent's real session usage; falls back to an
76
102
  // estimate when the provider reports no usage (total === 0).
77
103
  let usage;
@@ -95,13 +121,15 @@ export async function runWorkflow(script, options = {}) {
95
121
  signal: options.signal,
96
122
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
97
123
  model: modelSpec,
124
+ cwd: runCwd,
98
125
  onUsage: (u) => {
99
126
  usage = u;
100
127
  },
101
128
  }), timeout, `Agent "${label}" timed out after ${timeout}ms`);
102
129
  throwIfAborted();
103
130
  const tokens = recordTokens(result);
104
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
131
+ options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
132
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
105
133
  return result;
106
134
  }
107
135
  catch (error) {
@@ -110,13 +138,18 @@ export async function runWorkflow(script, options = {}) {
110
138
  const workflowError = wrapError(error, { agentLabel: label });
111
139
  logger.error(`agent ${label} failed: ${workflowError.message}`);
112
140
  const tokens = recordTokens(null);
113
- options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
141
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
114
142
  // Return null for recoverable errors
115
143
  if (workflowError.recoverable) {
116
144
  return null;
117
145
  }
118
146
  throw workflowError;
119
147
  }
148
+ finally {
149
+ // Always tear down the worktree, even on timeout/abort.
150
+ if (worktree?.isolated)
151
+ await removeWorktree(worktree);
152
+ }
120
153
  });
121
154
  };
122
155
  const parallel = async (thunks) => {
@@ -348,6 +381,17 @@ function createLimiter(limit) {
348
381
  function defaultAgentLabel(phase, index) {
349
382
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
350
383
  }
384
+ /** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
385
+ function hashAgentCall(prompt, model, phase, options) {
386
+ const identity = JSON.stringify({
387
+ prompt,
388
+ model: model ?? null,
389
+ phase: phase ?? null,
390
+ agentType: options.agentType ?? null,
391
+ schema: options.schema ?? null,
392
+ });
393
+ return createHash("sha256").update(identity).digest("hex");
394
+ }
351
395
  function buildAgentInstructions(phase, options) {
352
396
  const lines = [];
353
397
  if (phase)
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+ export interface Worktree {
8
+ /** True when a real worktree was created; false means "ran in the shared tree". */
9
+ isolated: boolean;
10
+ /** cwd the agent should run in (worktree path when isolated, else the base cwd). */
11
+ cwd: string;
12
+ branch?: string;
13
+ /** Repo root the worktree was added to (for teardown). */
14
+ repoRoot?: string;
15
+ /** Why isolation was skipped, when isolated === false. */
16
+ reason?: string;
17
+ }
18
+ /**
19
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
20
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
21
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
22
+ */
23
+ export declare function createWorktree(baseCwd: string, name: string): Promise<Worktree>;
24
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
25
+ export declare function removeWorktree(wt: Worktree): Promise<void>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { join } from "node:path";
9
+ import { promisify } from "node:util";
10
+ const exec = promisify(execFile);
11
+ function slug(name) {
12
+ return (name
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .slice(0, 32) || "agent");
17
+ }
18
+ /**
19
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
20
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
21
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
22
+ */
23
+ export async function createWorktree(baseCwd, name) {
24
+ const id = slug(name);
25
+ let repoRoot;
26
+ try {
27
+ const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
28
+ repoRoot = stdout.trim();
29
+ }
30
+ catch {
31
+ return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
32
+ }
33
+ const path = join(repoRoot, ".pi", "worktrees", id);
34
+ const branch = `pi/wf/${id}`;
35
+ try {
36
+ await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
37
+ return { isolated: true, cwd: path, branch, repoRoot };
38
+ }
39
+ catch (error) {
40
+ return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
41
+ }
42
+ }
43
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
44
+ export async function removeWorktree(wt) {
45
+ if (!wt.isolated || !wt.repoRoot)
46
+ return;
47
+ try {
48
+ await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
49
+ }
50
+ catch {
51
+ // already gone / locked — fall through
52
+ }
53
+ if (wt.branch) {
54
+ try {
55
+ await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
56
+ }
57
+ catch {
58
+ // branch already deleted
59
+ }
60
+ }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/agent.ts CHANGED
@@ -54,6 +54,8 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
54
54
  model?: string;
55
55
  /** Called with the resolved model id once known (for display/telemetry). */
56
56
  onModelResolved?: (modelId: string) => void;
57
+ /** Run this agent in a different working directory (e.g. an isolated worktree). */
58
+ cwd?: string;
57
59
  }
58
60
 
59
61
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
@@ -105,7 +107,11 @@ export class WorkflowAgent {
105
107
  options: AgentRunOptions<TSchemaDef> = {},
106
108
  ): Promise<AgentRunResult<TSchemaDef>> {
107
109
  const capture: StructuredOutputCapture<any> = { called: false, value: undefined };
108
- const customTools: ToolDefinition[] = [...this.baseTools, ...(options.tools ?? [])];
110
+ // Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
111
+ // since tools capture their cwd at construction and can't be relocated.
112
+ const runCwd = options.cwd ?? this.cwd;
113
+ const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
114
+ const customTools: ToolDefinition[] = [...baseTools, ...(options.tools ?? [])];
109
115
 
110
116
  if (options.schema) {
111
117
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
@@ -125,7 +131,7 @@ export class WorkflowAgent {
125
131
 
126
132
  const agentDir = getAgentDir();
127
133
  const { session } = await createAgentSession({
128
- cwd: this.cwd,
134
+ cwd: runCwd,
129
135
  agentDir,
130
136
  sessionManager: SessionManager.inMemory(),
131
137
  // Use real SettingsManager to inherit user's default provider/model settings.
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./str
41
41
  export { createStructuredOutputTool } from "./structured-output.js";
42
42
  export type {
43
43
  AgentOptions,
44
+ JournalEntry,
44
45
  WorkflowMeta,
45
46
  WorkflowMetaPhase,
46
47
  WorkflowRunOptions,
@@ -54,3 +55,5 @@ export type { SavedWorkflow, WorkflowStorage } from "./workflow-saved.js";
54
55
  export { createWorkflowStorage } from "./workflow-saved.js";
55
56
  export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.js";
56
57
  export { createWorkflowTool } from "./workflow-tool.js";
58
+ export type { Worktree } from "./worktree.js";
59
+ export { createWorktree, removeWorktree } from "./worktree.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 {
@@ -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
 
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";
@@ -8,6 +9,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
8
9
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
9
10
  import { createWorkflowLogger } from "./logger.js";
10
11
  import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
12
+ import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
11
13
 
12
14
  export interface WorkflowMetaPhase {
13
15
  title: string;
@@ -22,6 +24,14 @@ export interface WorkflowMeta {
22
24
  phases?: WorkflowMetaPhase[];
23
25
  }
24
26
 
27
+ /** One cached agent() result, keyed by its deterministic call index. */
28
+ export interface JournalEntry {
29
+ index: number;
30
+ /** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
31
+ hash: string;
32
+ result: unknown;
33
+ }
34
+
25
35
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
26
36
  args?: unknown;
27
37
  agent?: Pick<WorkflowAgent, "run">;
@@ -36,10 +46,16 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
36
46
  persistLogs?: boolean;
37
47
  /** Run ID for persistence. Auto-generated if not provided. */
38
48
  runId?: string;
49
+ /** Resume: cached agent results keyed by deterministic call index. */
50
+ resumeJournal?: Map<number, JournalEntry>;
51
+ /** Resume: the run being resumed (informational; enables resume mode). */
52
+ resumeFromRunId?: string;
53
+ /** Called after each live agent completes so the caller can persist the journal. */
54
+ onAgentJournal?: (entry: JournalEntry) => void;
39
55
  onLog?: (message: string) => void;
40
56
  onPhase?: (title: string) => void;
41
57
  onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
42
- onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
58
+ onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
43
59
  onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
44
60
  }
45
61
 
@@ -75,6 +91,8 @@ interface RuntimeState {
75
91
  logs: string[];
76
92
  phases: string[];
77
93
  agentCount: number;
94
+ /** Monotonic, assigned at lexical agent() call time — the stable resume key. */
95
+ callSeq: number;
78
96
  spent: number;
79
97
  tokenUsage: {
80
98
  input: number;
@@ -99,6 +117,7 @@ export async function runWorkflow<T = unknown>(
99
117
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
100
118
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
101
119
  const runId = options.runId ?? `run-${started.toString(36)}`;
120
+ const baseCwd = options.cwd ?? process.cwd();
102
121
 
103
122
  // Initialize logger
104
123
  const logger = createWorkflowLogger({
@@ -112,6 +131,7 @@ export async function runWorkflow<T = unknown>(
112
131
  logs: [],
113
132
  phases: [],
114
133
  agentCount: 0,
134
+ callSeq: 0,
115
135
  spent: 0,
116
136
  tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
117
137
  };
@@ -170,6 +190,22 @@ export async function runWorkflow<T = unknown>(
170
190
  // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
171
191
  const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
172
192
 
193
+ // Deterministic resume key: assigned at lexical call time, before the limiter,
194
+ // so parallel()/pipeline() fan-out is reproducible for a fixed script.
195
+ const callIndex = state.callSeq++;
196
+ const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
197
+
198
+ // Resume: replay a cached result for an unchanged call (matching hash), without
199
+ // consuming a concurrency slot, tokens, or a real subagent run.
200
+ const cached = options.resumeJournal?.get(callIndex);
201
+ if (cached && cached.hash === callHash) {
202
+ state.agentCount++;
203
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
204
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
205
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
206
+ return cached.result;
207
+ }
208
+
173
209
  return limiter(async () => {
174
210
  state.agentCount++;
175
211
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
@@ -177,6 +213,14 @@ export async function runWorkflow<T = unknown>(
177
213
 
178
214
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
179
215
 
216
+ // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
217
+ let worktree: Worktree | undefined;
218
+ if (agentOptions.isolation === "worktree") {
219
+ worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
220
+ if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
221
+ }
222
+ const runCwd = worktree?.isolated ? worktree.cwd : undefined;
223
+
180
224
  // Captured from the subagent's real session usage; falls back to an
181
225
  // estimate when the provider reports no usage (total === 0).
182
226
  let usage: AgentUsage | undefined;
@@ -203,6 +247,7 @@ export async function runWorkflow<T = unknown>(
203
247
  signal: options.signal,
204
248
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
205
249
  model: modelSpec,
250
+ cwd: runCwd,
206
251
  onUsage: (u: AgentUsage) => {
207
252
  usage = u;
208
253
  },
@@ -214,7 +259,8 @@ export async function runWorkflow<T = unknown>(
214
259
  throwIfAborted();
215
260
 
216
261
  const tokens = recordTokens(result);
217
- options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
262
+ options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
263
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
218
264
  return result;
219
265
  } catch (error) {
220
266
  if (options.signal?.aborted) throw error;
@@ -222,13 +268,16 @@ export async function runWorkflow<T = unknown>(
222
268
  const workflowError = wrapError(error, { agentLabel: label });
223
269
  logger.error(`agent ${label} failed: ${workflowError.message}`);
224
270
  const tokens = recordTokens(null);
225
- options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
271
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
226
272
 
227
273
  // Return null for recoverable errors
228
274
  if (workflowError.recoverable) {
229
275
  return null;
230
276
  }
231
277
  throw workflowError;
278
+ } finally {
279
+ // Always tear down the worktree, even on timeout/abort.
280
+ if (worktree?.isolated) await removeWorktree(worktree);
232
281
  }
233
282
  });
234
283
  };
@@ -481,6 +530,23 @@ function defaultAgentLabel(phase: string | undefined, index: number): string {
481
530
  return phase ? `${phase} agent ${index}` : `agent ${index}`;
482
531
  }
483
532
 
533
+ /** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
534
+ function hashAgentCall(
535
+ prompt: string,
536
+ model: string | undefined,
537
+ phase: string | undefined,
538
+ options: AgentOptions,
539
+ ): string {
540
+ const identity = JSON.stringify({
541
+ prompt,
542
+ model: model ?? null,
543
+ phase: phase ?? null,
544
+ agentType: options.agentType ?? null,
545
+ schema: options.schema ?? null,
546
+ });
547
+ return createHash("sha256").update(identity).digest("hex");
548
+ }
549
+
484
550
  function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
485
551
  const lines = [];
486
552
  if (phase) lines.push(`Workflow phase: ${phase}`);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
3
+ * it runs in a throwaway worktree on its own branch so parallel agents can edit the
4
+ * same files without conflict. Results are NOT auto-merged — the path is surfaced for
5
+ * the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { join } from "node:path";
10
+ import { promisify } from "node:util";
11
+
12
+ const exec = promisify(execFile);
13
+
14
+ export interface Worktree {
15
+ /** True when a real worktree was created; false means "ran in the shared tree". */
16
+ isolated: boolean;
17
+ /** cwd the agent should run in (worktree path when isolated, else the base cwd). */
18
+ cwd: string;
19
+ branch?: string;
20
+ /** Repo root the worktree was added to (for teardown). */
21
+ repoRoot?: string;
22
+ /** Why isolation was skipped, when isolated === false. */
23
+ reason?: string;
24
+ }
25
+
26
+ function slug(name: string): string {
27
+ return (
28
+ name
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 32) || "agent"
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
38
+ * `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
39
+ * never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
40
+ */
41
+ export async function createWorktree(baseCwd: string, name: string): Promise<Worktree> {
42
+ const id = slug(name);
43
+ let repoRoot: string;
44
+ try {
45
+ const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
46
+ repoRoot = stdout.trim();
47
+ } catch {
48
+ return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
49
+ }
50
+
51
+ const path = join(repoRoot, ".pi", "worktrees", id);
52
+ const branch = `pi/wf/${id}`;
53
+ try {
54
+ await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
55
+ return { isolated: true, cwd: path, branch, repoRoot };
56
+ } catch (error) {
57
+ return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ }
60
+
61
+ /** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
62
+ export async function removeWorktree(wt: Worktree): Promise<void> {
63
+ if (!wt.isolated || !wt.repoRoot) return;
64
+ try {
65
+ await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
66
+ } catch {
67
+ // already gone / locked — fall through
68
+ }
69
+ if (wt.branch) {
70
+ try {
71
+ await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
72
+ } catch {
73
+ // branch already deleted
74
+ }
75
+ }
76
+ }