@quintinshaw/pi-dynamic-workflows 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +159 -0
  2. package/dist/adversarial-review.d.ts +20 -0
  3. package/dist/adversarial-review.js +87 -0
  4. package/dist/agent.d.ts +29 -0
  5. package/dist/agent.js +90 -0
  6. package/dist/auto-workflow.d.ts +26 -0
  7. package/dist/auto-workflow.js +121 -0
  8. package/dist/config.d.ts +17 -0
  9. package/dist/config.js +17 -0
  10. package/dist/deep-research.d.ts +22 -0
  11. package/dist/deep-research.js +110 -0
  12. package/dist/display.d.ts +62 -0
  13. package/dist/display.js +163 -0
  14. package/dist/errors.d.ts +41 -0
  15. package/dist/errors.js +63 -0
  16. package/dist/index.d.ts +28 -0
  17. package/dist/index.js +15 -0
  18. package/dist/logger.d.ts +21 -0
  19. package/dist/logger.js +67 -0
  20. package/dist/model-routing.d.ts +33 -0
  21. package/dist/model-routing.js +57 -0
  22. package/dist/run-persistence.d.ts +53 -0
  23. package/dist/run-persistence.js +78 -0
  24. package/dist/structured-output.d.ts +19 -0
  25. package/dist/structured-output.js +30 -0
  26. package/dist/workflow-manager.d.ts +74 -0
  27. package/dist/workflow-manager.js +241 -0
  28. package/dist/workflow-saved.d.ts +35 -0
  29. package/dist/workflow-saved.js +91 -0
  30. package/dist/workflow-tool.d.ts +22 -0
  31. package/dist/workflow-tool.js +216 -0
  32. package/dist/workflow.d.ts +75 -0
  33. package/dist/workflow.js +364 -0
  34. package/extensions/workflow.ts +14 -0
  35. package/package.json +70 -0
  36. package/src/adversarial-review.ts +107 -0
  37. package/src/agent.ts +135 -0
  38. package/src/auto-workflow.ts +146 -0
  39. package/src/config.ts +24 -0
  40. package/src/deep-research.ts +128 -0
  41. package/src/display.ts +236 -0
  42. package/src/errors.ts +85 -0
  43. package/src/index.ts +55 -0
  44. package/src/logger.ts +89 -0
  45. package/src/model-routing.ts +80 -0
  46. package/src/run-persistence.ts +132 -0
  47. package/src/structured-output.ts +47 -0
  48. package/src/workflow-manager.ts +294 -0
  49. package/src/workflow-saved.ts +131 -0
  50. package/src/workflow-tool.ts +254 -0
  51. package/src/workflow.ts +492 -0
@@ -0,0 +1,47 @@
1
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import type { Static, TSchema } from "typebox";
3
+
4
+ export interface StructuredOutputCapture<T = unknown> {
5
+ value: T | undefined;
6
+ called: boolean;
7
+ }
8
+
9
+ export interface StructuredOutputToolOptions<TSchemaDef extends TSchema> {
10
+ schema: TSchemaDef;
11
+ capture: StructuredOutputCapture<Static<TSchemaDef>>;
12
+ name?: string;
13
+ }
14
+
15
+ /**
16
+ * Create a terminating tool that captures validated params as the subagent result.
17
+ *
18
+ * Pi validates `params` against `schema` before execute() is called. Returning
19
+ * `terminate: true` lets the subagent finish on this tool call without paying for
20
+ * an extra assistant follow-up turn.
21
+ */
22
+ export function createStructuredOutputTool<TSchemaDef extends TSchema>({
23
+ schema,
24
+ capture,
25
+ name = "structured_output",
26
+ }: StructuredOutputToolOptions<TSchemaDef>): ToolDefinition<TSchemaDef, Static<TSchemaDef>> {
27
+ return defineTool({
28
+ name,
29
+ label: "Structured Output",
30
+ description: "Return the final machine-readable result for this subagent task.",
31
+ promptSnippet: "Return final machine-readable output",
32
+ promptGuidelines: [
33
+ `${name} is the final answer channel for this task; call ${name} exactly once when done.`,
34
+ `Do not write a prose final answer after calling ${name}.`,
35
+ ],
36
+ parameters: schema,
37
+ async execute(_toolCallId, params) {
38
+ capture.value = params;
39
+ capture.called = true;
40
+ return {
41
+ content: [{ type: "text", text: "Structured output received." }],
42
+ details: params,
43
+ terminate: true,
44
+ };
45
+ },
46
+ });
47
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Workflow manager for background execution, pause/resume, and run management.
3
+ */
4
+
5
+ import { EventEmitter } from "node:events";
6
+ import type { WorkflowSnapshot } from "./display.js";
7
+ import { WorkflowError, WorkflowErrorCode } from "./errors.js";
8
+ import {
9
+ createRunPersistence,
10
+ generateRunId,
11
+ type PersistedRunState,
12
+ type RunPersistence,
13
+ type RunStatus,
14
+ } from "./run-persistence.js";
15
+ import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
16
+
17
+ export interface ManagedRun {
18
+ runId: string;
19
+ status: RunStatus;
20
+ snapshot: WorkflowSnapshot;
21
+ result?: WorkflowRunResult;
22
+ error?: WorkflowError;
23
+ controller: AbortController;
24
+ startedAt: Date;
25
+ }
26
+
27
+ export interface WorkflowManagerOptions {
28
+ cwd?: string;
29
+ concurrency?: number;
30
+ }
31
+
32
+ export class WorkflowManager extends EventEmitter {
33
+ private runs = new Map<string, ManagedRun>();
34
+ private persistence: RunPersistence;
35
+ private cwd: string;
36
+ private concurrency: number;
37
+
38
+ constructor(options: WorkflowManagerOptions = {}) {
39
+ super();
40
+ this.cwd = options.cwd ?? process.cwd();
41
+ this.concurrency = options.concurrency ?? 8;
42
+ this.persistence = createRunPersistence(this.cwd);
43
+ }
44
+
45
+ /**
46
+ * Start a workflow in the background.
47
+ * Returns immediately with a run ID; the workflow executes asynchronously.
48
+ */
49
+ startInBackground(script: string, args?: unknown): { runId: string; promise: Promise<WorkflowRunResult> } {
50
+ const runId = generateRunId();
51
+ const controller = new AbortController();
52
+ const parsed = parseWorkflowScript(script);
53
+
54
+ const managed: ManagedRun = {
55
+ runId,
56
+ status: "running",
57
+ snapshot: {
58
+ name: parsed.meta.name,
59
+ description: parsed.meta.description,
60
+ phases: parsed.meta.phases?.map((p) => p.title) ?? [],
61
+ logs: [],
62
+ agents: [],
63
+ agentCount: 0,
64
+ runningCount: 0,
65
+ doneCount: 0,
66
+ errorCount: 0,
67
+ },
68
+ controller,
69
+ startedAt: new Date(),
70
+ };
71
+
72
+ this.runs.set(runId, managed);
73
+
74
+ // Persist initial state
75
+ this.persistence.save({
76
+ runId,
77
+ workflowName: parsed.meta.name,
78
+ script,
79
+ args,
80
+ status: "running",
81
+ phases: managed.snapshot.phases,
82
+ agents: [],
83
+ logs: [],
84
+ startedAt: managed.startedAt.toISOString(),
85
+ updatedAt: managed.startedAt.toISOString(),
86
+ });
87
+
88
+ // Run workflow asynchronously
89
+ const promise = this.executeRun(managed, script, args);
90
+
91
+ return { runId, promise };
92
+ }
93
+
94
+ /**
95
+ * Execute a workflow synchronously (blocking).
96
+ */
97
+ async runSync(script: string, args?: unknown): Promise<WorkflowRunResult> {
98
+ const runId = generateRunId();
99
+ const controller = new AbortController();
100
+ const parsed = parseWorkflowScript(script);
101
+
102
+ const managed: ManagedRun = {
103
+ runId,
104
+ status: "running",
105
+ snapshot: {
106
+ name: parsed.meta.name,
107
+ description: parsed.meta.description,
108
+ phases: parsed.meta.phases?.map((p) => p.title) ?? [],
109
+ logs: [],
110
+ agents: [],
111
+ agentCount: 0,
112
+ runningCount: 0,
113
+ doneCount: 0,
114
+ errorCount: 0,
115
+ },
116
+ controller,
117
+ startedAt: new Date(),
118
+ };
119
+
120
+ this.runs.set(runId, managed);
121
+ return this.executeRun(managed, script, args);
122
+ }
123
+
124
+ private async executeRun(managed: ManagedRun, script: string, args?: unknown): Promise<WorkflowRunResult> {
125
+ try {
126
+ const result = await runWorkflow(script, {
127
+ cwd: this.cwd,
128
+ args,
129
+ signal: managed.controller.signal,
130
+ concurrency: this.concurrency,
131
+ onLog: (message) => {
132
+ managed.snapshot.logs.push(message);
133
+ this.emit("log", { runId: managed.runId, message });
134
+ },
135
+ onPhase: (title) => {
136
+ managed.snapshot.currentPhase = title;
137
+ if (!managed.snapshot.phases.includes(title)) {
138
+ managed.snapshot.phases.push(title);
139
+ }
140
+ this.emit("phase", { runId: managed.runId, title });
141
+ },
142
+ onAgentStart: (event) => {
143
+ managed.snapshot.agents.push({
144
+ id: managed.snapshot.agents.length + 1,
145
+ label: event.label,
146
+ phase: event.phase,
147
+ prompt: event.prompt,
148
+ status: "running",
149
+ });
150
+ this.emit("agentStart", { runId: managed.runId, ...event });
151
+ },
152
+ onAgentEnd: (event) => {
153
+ const agent = [...managed.snapshot.agents]
154
+ .reverse()
155
+ .find((a) => a.label === event.label && a.status === "running");
156
+ if (agent) {
157
+ agent.status = event.result === null ? "error" : "done";
158
+ }
159
+ this.emit("agentEnd", { runId: managed.runId, ...event });
160
+ },
161
+ });
162
+
163
+ managed.status = "completed";
164
+ managed.result = result;
165
+ this.emit("complete", { runId: managed.runId, result });
166
+
167
+ // Persist final state
168
+ this.persistRun(managed);
169
+
170
+ return result;
171
+ } catch (error) {
172
+ const workflowError =
173
+ error instanceof WorkflowError
174
+ ? error
175
+ : new WorkflowError(
176
+ error instanceof Error ? error.message : String(error),
177
+ WorkflowErrorCode.WORKFLOW_ABORTED,
178
+ { recoverable: true },
179
+ );
180
+
181
+ if (managed.controller.signal.aborted) {
182
+ managed.status = "aborted";
183
+ } else {
184
+ managed.status = "failed";
185
+ }
186
+ managed.error = workflowError;
187
+ this.emit("error", { runId: managed.runId, error: workflowError });
188
+
189
+ // Persist final state
190
+ this.persistRun(managed);
191
+
192
+ throw workflowError;
193
+ }
194
+ }
195
+
196
+ private persistRun(managed: ManagedRun) {
197
+ this.persistence.save({
198
+ runId: managed.runId,
199
+ workflowName: managed.snapshot.name,
200
+ script: "", // Don't persist script for security
201
+ status: managed.status,
202
+ phases: managed.snapshot.phases,
203
+ currentPhase: managed.snapshot.currentPhase,
204
+ agents: managed.snapshot.agents.map((a) => ({
205
+ ...a,
206
+ startedAt: managed.startedAt.toISOString(),
207
+ endedAt: new Date().toISOString(),
208
+ })),
209
+ logs: managed.snapshot.logs,
210
+ result: managed.result?.result,
211
+ startedAt: managed.startedAt.toISOString(),
212
+ updatedAt: new Date().toISOString(),
213
+ completedAt: managed.status === "completed" ? new Date().toISOString() : undefined,
214
+ durationMs: managed.result?.durationMs,
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Pause a running workflow.
220
+ */
221
+ pause(runId: string): boolean {
222
+ const managed = this.runs.get(runId);
223
+ if (managed?.status !== "running") return false;
224
+
225
+ managed.controller.abort();
226
+ managed.status = "paused";
227
+ this.emit("paused", { runId });
228
+ this.persistRun(managed);
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Resume a paused workflow.
234
+ */
235
+ async resume(runId: string): Promise<boolean> {
236
+ const persisted = this.persistence.load(runId);
237
+ if (persisted?.status !== "paused") return false;
238
+
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
241
+ this.emit("resumed", { runId });
242
+ return true;
243
+ }
244
+
245
+ /**
246
+ * Stop a running workflow.
247
+ */
248
+ stop(runId: string): boolean {
249
+ const managed = this.runs.get(runId);
250
+ if (!managed || (managed.status !== "running" && managed.status !== "paused")) return false;
251
+
252
+ managed.controller.abort();
253
+ managed.status = "aborted";
254
+ this.emit("stopped", { runId });
255
+ this.persistRun(managed);
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * Get status of a specific run.
261
+ */
262
+ getRun(runId: string): ManagedRun | undefined {
263
+ return this.runs.get(runId);
264
+ }
265
+
266
+ /**
267
+ * List all runs (active + persisted).
268
+ */
269
+ listRuns(): PersistedRunState[] {
270
+ return this.persistence.list();
271
+ }
272
+
273
+ /**
274
+ * Get snapshot of a run.
275
+ */
276
+ getSnapshot(runId: string): WorkflowSnapshot | null {
277
+ return this.runs.get(runId)?.snapshot ?? null;
278
+ }
279
+
280
+ /**
281
+ * Delete a persisted run.
282
+ */
283
+ deleteRun(runId: string): boolean {
284
+ this.runs.delete(runId);
285
+ return this.persistence.delete(runId);
286
+ }
287
+
288
+ /**
289
+ * Get the persistence layer (for saving workflows).
290
+ */
291
+ getPersistence(): RunPersistence {
292
+ return this.persistence;
293
+ }
294
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Save and load reusable workflow commands.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { USER_WORKFLOW_SAVED_DIR, WORKFLOW_SAVED_DIR } from "./config.js";
8
+
9
+ export interface SavedWorkflow {
10
+ /** Command name (filename without extension). */
11
+ name: string;
12
+ /** Human-readable description. */
13
+ description: string;
14
+ /** The workflow script. */
15
+ script: string;
16
+ /** Optional parameter schema for parameterized workflows. */
17
+ parameters?: Record<string, { type: string; description?: string; required?: boolean; default?: unknown }>;
18
+ /** Where this workflow is saved. */
19
+ location: "project" | "user";
20
+ /** Full file path. */
21
+ path: string;
22
+ /** When it was saved. */
23
+ savedAt: string;
24
+ }
25
+
26
+ export interface WorkflowStorage {
27
+ /** Save a workflow. */
28
+ save(workflow: Omit<SavedWorkflow, "path" | "savedAt">, location?: "project" | "user"): SavedWorkflow;
29
+ /** Load a workflow by name. */
30
+ load(name: string): SavedWorkflow | null;
31
+ /** List all saved workflows. */
32
+ list(): SavedWorkflow[];
33
+ /** Delete a saved workflow. */
34
+ delete(name: string, location?: "project" | "user"): boolean;
35
+ }
36
+
37
+ export function createWorkflowStorage(cwd: string): WorkflowStorage {
38
+ const projectDir = join(cwd, WORKFLOW_SAVED_DIR);
39
+ const userDir = USER_WORKFLOW_SAVED_DIR.replace("~", process.env.HOME ?? "");
40
+
41
+ const ensureDir = (dir: string) => {
42
+ if (!existsSync(dir)) {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ };
46
+
47
+ const workflowPath = (name: string, location: "project" | "user") => {
48
+ const dir = location === "project" ? projectDir : userDir;
49
+ return join(dir, `${name}.json`);
50
+ };
51
+
52
+ const loadFromFile = (path: string, location: "project" | "user"): SavedWorkflow | null => {
53
+ try {
54
+ if (!existsSync(path)) return null;
55
+ const data = JSON.parse(readFileSync(path, "utf-8"));
56
+ return {
57
+ ...data,
58
+ location,
59
+ path,
60
+ };
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ return {
67
+ save(workflow, location = "project") {
68
+ const dir = location === "project" ? projectDir : userDir;
69
+ ensureDir(dir);
70
+
71
+ const path = workflowPath(workflow.name, location);
72
+ const saved: SavedWorkflow = {
73
+ ...workflow,
74
+ location,
75
+ path,
76
+ savedAt: new Date().toISOString(),
77
+ };
78
+
79
+ writeFileSync(path, JSON.stringify(saved, null, 2));
80
+ return saved;
81
+ },
82
+
83
+ load(name: string): SavedWorkflow | null {
84
+ // Project takes precedence over user
85
+ const projectPath = workflowPath(name, "project");
86
+ const project = loadFromFile(projectPath, "project");
87
+ if (project) return project;
88
+
89
+ const userPath = workflowPath(name, "user");
90
+ return loadFromFile(userPath, "user");
91
+ },
92
+
93
+ list(): SavedWorkflow[] {
94
+ const workflows: SavedWorkflow[] = [];
95
+
96
+ // Load project workflows
97
+ if (existsSync(projectDir)) {
98
+ for (const file of readdirSync(projectDir).filter((f) => f.endsWith(".json"))) {
99
+ const wf = loadFromFile(join(projectDir, file), "project");
100
+ if (wf) workflows.push(wf);
101
+ }
102
+ }
103
+
104
+ // Load user workflows
105
+ if (existsSync(userDir)) {
106
+ for (const file of readdirSync(userDir).filter((f) => f.endsWith(".json"))) {
107
+ const wf = loadFromFile(join(userDir, file), "user");
108
+ if (wf) workflows.push(wf);
109
+ }
110
+ }
111
+
112
+ return workflows.sort((a, b) => a.name.localeCompare(b.name));
113
+ },
114
+
115
+ delete(name: string, location?: "project" | "user"): boolean {
116
+ const locations = location ? [location] : (["project", "user"] as const);
117
+ let deleted = false;
118
+
119
+ for (const loc of locations) {
120
+ const path = workflowPath(name, loc);
121
+ if (existsSync(path)) {
122
+ const { unlinkSync } = require("node:fs");
123
+ unlinkSync(path);
124
+ deleted = true;
125
+ }
126
+ }
127
+
128
+ return deleted;
129
+ },
130
+ };
131
+ }