@pify/workflow 0.1.1
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/LICENSE +21 -0
- package/README.md +37 -0
- package/extensions/workflow.ts +419 -0
- package/package.json +73 -0
- package/skills/workflow/SKILL.md +41 -0
- package/src/builtin.ts +48 -0
- package/src/frontmatter.ts +74 -0
- package/src/report.ts +53 -0
- package/src/sandbox.ts +159 -0
- package/src/types.ts +93 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pifydev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @pify/workflow
|
|
2
|
+
|
|
3
|
+
Deterministic multi-step agent orchestration for [pi](https://github.com/earendil-works/pi) — a Claude Code-style `workflow` tool: the model writes a small JavaScript script that fans work out across child agents, cross-checks, and returns one synthesized answer. Intermediate work stays in script variables, not your chat context.
|
|
4
|
+
|
|
5
|
+
Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install workflow`](https://github.com/pifydev/cli) or `pi install npm:@pify/workflow`.
|
|
6
|
+
|
|
7
|
+
## Example
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
export const meta = { name: "review", phases: [{ title: "Find" }, { title: "Verify" }] };
|
|
11
|
+
phase("Find");
|
|
12
|
+
const findings = await parallel([
|
|
13
|
+
() => agent("Review src/auth for security issues. Report file:line + why.", { agent: "reviewer", label: "auth" }),
|
|
14
|
+
() => agent("Review src/api for correctness. Report file:line + why.", { agent: "reviewer", label: "api" }),
|
|
15
|
+
]);
|
|
16
|
+
phase("Verify");
|
|
17
|
+
const verified = await pipeline(findings.filter(Boolean),
|
|
18
|
+
(finding) => agent(`Adversarially verify this finding — is it real?\n${finding}`, { agent: "scout" }));
|
|
19
|
+
return { findings: verified.filter(Boolean) };
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## The contract
|
|
23
|
+
|
|
24
|
+
- **Globals**: `agent(prompt, {agent?, label?, phase?})` → child's report or `null`; `parallel(thunks)` (barrier, failures → null); `pipeline(items, ...stages)` (no barrier between stages); `phase(title)`; `log(msg)`; `args`. The script's return value is the tool result.
|
|
25
|
+
- **Determinism enforced** in a poisoned `node:vm` context: `Date.now()`, `Math.random()`, argless `new Date()`, `eval`, and `Function` throw — control flow stays reproducible. (Cooperative discipline, not a security boundary: scripts run at the same trust level as the bash tool.)
|
|
26
|
+
- **One agent catalog**: `agent()` uses the same `reviewer`/`scout`/`worker` builtins and `.pi/agents/*.md` custom types as [`@pify/subagent`](https://github.com/pifydev/subagent) and [`@pify/swarm`](https://github.com/pifydev/swarm).
|
|
27
|
+
- **Limits**: 20 agents per run, 4 concurrent (shared semaphore), 10-minute script timeout.
|
|
28
|
+
- **Saved workflows**: `.pi/workflows/<name>.js` runs by name. `export const meta = {…}` prefixes are tolerated, so Claude Code-style scripts mostly run unchanged.
|
|
29
|
+
- **Background runs**: `background: true` returns a `runId`; poll with `workflow_status`. A live widget shows the current phase and agents; finished runs survive `/reload`. `/workflows` lists saved scripts and runs.
|
|
30
|
+
|
|
31
|
+
## The Pify agent stack
|
|
32
|
+
|
|
33
|
+
`agent_run` (one child) → `swarm_run` (independent parallel items) → `workflow` (scripted control flow). Use the smallest tool that fits.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT © [Pify maintainers](https://github.com/pifydev)
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pify/workflow — deterministic multi-step agent orchestration for pi.
|
|
3
|
+
*
|
|
4
|
+
* The top of the Pify agent stack (subagent → swarm → workflow): the model
|
|
5
|
+
* submits a JavaScript orchestration script to the `workflow` tool — with
|
|
6
|
+
* agent()/parallel()/pipeline()/phase()/log()/args globals — and the script
|
|
7
|
+
* fans work out across child agents from the shared .pi/agents catalog,
|
|
8
|
+
* keeping intermediate results in script variables instead of chat context.
|
|
9
|
+
* Scripts run in a poisoned vm context (Date.now/Math.random/eval throw)
|
|
10
|
+
* so control flow stays reproducible. Saved scripts in .pi/workflows/<name>.js
|
|
11
|
+
* run by name. Background runs are polled with workflow_status.
|
|
12
|
+
*
|
|
13
|
+
* Design synthesis: CC-style dynamic workflow tool (michaelliv/
|
|
14
|
+
* pi-dynamic-workflows), vm determinism discipline (tintinweb's
|
|
15
|
+
* SubagentWorkflow), saved named workflows (AgwaB), child-runner pattern
|
|
16
|
+
* proven in @pify/subagent and @pify/swarm.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
createAgentSession,
|
|
20
|
+
DefaultResourceLoader,
|
|
21
|
+
getAgentDir,
|
|
22
|
+
SessionManager,
|
|
23
|
+
type AgentSession,
|
|
24
|
+
type ExtensionAPI,
|
|
25
|
+
type ExtensionContext,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
28
|
+
import { Type } from "typebox";
|
|
29
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
30
|
+
import { basename, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
33
|
+
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
34
|
+
import { buildWidgetLines, formatResult, formatStatus } from "../src/report.ts";
|
|
35
|
+
import { runScript, type AgentOptions } from "../src/sandbox.ts";
|
|
36
|
+
import {
|
|
37
|
+
AGENT_CONCURRENCY,
|
|
38
|
+
MAX_PERSISTED_RESULT_CHARS,
|
|
39
|
+
isRecord,
|
|
40
|
+
type AgentCallState,
|
|
41
|
+
type AgentDef,
|
|
42
|
+
type WorkflowRun,
|
|
43
|
+
} from "../src/types.ts";
|
|
44
|
+
|
|
45
|
+
const RUN_ENTRY = "workflow-run";
|
|
46
|
+
const FALLBACK_AGENT = "scout";
|
|
47
|
+
|
|
48
|
+
type UiContext = ExtensionContext;
|
|
49
|
+
|
|
50
|
+
export default function workflow(pi: ExtensionAPI) {
|
|
51
|
+
let defs = new Map<string, AgentDef>();
|
|
52
|
+
const runs = new Map<string, WorkflowRun>();
|
|
53
|
+
let activeRun: WorkflowRun | null = null;
|
|
54
|
+
let runCounter = 0;
|
|
55
|
+
let lastUiCtx: UiContext | null = null;
|
|
56
|
+
|
|
57
|
+
function loadDefs(cwd: string): void {
|
|
58
|
+
defs = new Map();
|
|
59
|
+
for (const [name, content] of Object.entries(BUILTIN_AGENTS)) {
|
|
60
|
+
const def = parseAgentFile(name, content, "builtin");
|
|
61
|
+
if (def) defs.set(def.name, def);
|
|
62
|
+
}
|
|
63
|
+
for (const [dir, source] of [
|
|
64
|
+
[join(getAgentDir(), "agents"), "global"],
|
|
65
|
+
[join(cwd, ".pi", "agents"), "project"],
|
|
66
|
+
] as const) {
|
|
67
|
+
try {
|
|
68
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
69
|
+
try {
|
|
70
|
+
const def = parseAgentFile(basename(file, ".md"), readFileSync(join(dir, file), "utf8"), source);
|
|
71
|
+
if (def) defs.set(def.name, def);
|
|
72
|
+
} catch {
|
|
73
|
+
// skip
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// dir missing
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderWidget(ctx: UiContext | null = lastUiCtx): void {
|
|
83
|
+
if (!ctx || !ctx.hasUI) return;
|
|
84
|
+
lastUiCtx = ctx;
|
|
85
|
+
const run = activeRun;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
|
|
88
|
+
ctx.ui.setWidget("workflow", undefined);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ctx.ui.setWidget(
|
|
92
|
+
"workflow",
|
|
93
|
+
(_tui: unknown, theme: { fg(c: string, s: string): string; bold(s: string): string }) =>
|
|
94
|
+
new Text(buildWidgetLines(run, theme, Date.now()).join("\n"), 0, 0),
|
|
95
|
+
{ placement: "aboveEditor" },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
|
|
100
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Child agent runner behind a shared concurrency semaphore ─────────
|
|
104
|
+
|
|
105
|
+
let inFlight = 0;
|
|
106
|
+
const waiters: Array<() => void> = [];
|
|
107
|
+
|
|
108
|
+
async function acquire(): Promise<void> {
|
|
109
|
+
if (inFlight < AGENT_CONCURRENCY) {
|
|
110
|
+
inFlight++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
114
|
+
inFlight++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function release(): void {
|
|
118
|
+
inFlight--;
|
|
119
|
+
const next = waiters.shift();
|
|
120
|
+
if (next) next();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runChildAgent(
|
|
124
|
+
ctx: UiContext,
|
|
125
|
+
run: WorkflowRun,
|
|
126
|
+
prompt: string,
|
|
127
|
+
opts: AgentOptions | undefined,
|
|
128
|
+
): Promise<string | null> {
|
|
129
|
+
const def = defs.get((opts?.agent ?? FALLBACK_AGENT).toLowerCase()) ?? defs.get(FALLBACK_AGENT);
|
|
130
|
+
if (!def) return null;
|
|
131
|
+
|
|
132
|
+
const call: AgentCallState = {
|
|
133
|
+
id: run.agents.length + 1,
|
|
134
|
+
label: opts?.label ?? `${def.name}-${run.agents.length + 1}`,
|
|
135
|
+
agent: def.name,
|
|
136
|
+
phase: opts?.phase ?? (run.phases[run.phases.length - 1] ?? null),
|
|
137
|
+
status: "running",
|
|
138
|
+
turns: 0,
|
|
139
|
+
tokens: 0,
|
|
140
|
+
};
|
|
141
|
+
run.agents.push(call);
|
|
142
|
+
renderWidget();
|
|
143
|
+
|
|
144
|
+
await acquire();
|
|
145
|
+
let session: AgentSession | null = null;
|
|
146
|
+
let unsubscribe: (() => void) | null = null;
|
|
147
|
+
try {
|
|
148
|
+
let model = ctx.model ?? null;
|
|
149
|
+
if (def.model) {
|
|
150
|
+
const [provider, ...rest] = def.model.split("/");
|
|
151
|
+
const found =
|
|
152
|
+
provider && rest.length > 0 ? ctx.modelRegistry.find(provider, rest.join("/")) : undefined;
|
|
153
|
+
if (found) model = found;
|
|
154
|
+
}
|
|
155
|
+
if (!model) throw new Error("No model available");
|
|
156
|
+
|
|
157
|
+
const promptHost = ctx as unknown as {
|
|
158
|
+
getSystemPromptOptions?: () => { customPrompt?: string; appendSystemPrompt?: string };
|
|
159
|
+
};
|
|
160
|
+
const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
|
|
161
|
+
|
|
162
|
+
const created = await createAgentSession({
|
|
163
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
164
|
+
model,
|
|
165
|
+
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
166
|
+
tools: def.tools,
|
|
167
|
+
resourceLoader: new DefaultResourceLoader({
|
|
168
|
+
cwd: ctx.cwd,
|
|
169
|
+
agentDir: getAgentDir(),
|
|
170
|
+
noExtensions: true,
|
|
171
|
+
noPromptTemplates: true,
|
|
172
|
+
noThemes: true,
|
|
173
|
+
systemPrompt: promptOptions.customPrompt,
|
|
174
|
+
appendSystemPrompt: [
|
|
175
|
+
...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
|
|
176
|
+
def.systemPrompt,
|
|
177
|
+
"You are one step of a scripted workflow. Your final assistant message IS the value returned to the script — return raw data/report, no pleasantries, no questions.",
|
|
178
|
+
],
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
session = created.session;
|
|
182
|
+
|
|
183
|
+
unsubscribe = session.subscribe((event) => {
|
|
184
|
+
if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
|
|
185
|
+
call.turns++;
|
|
186
|
+
const usage = (event as { message?: { usage?: { totalTokens?: number } } }).message?.usage;
|
|
187
|
+
if (usage && typeof usage.totalTokens === "number") call.tokens += usage.totalTokens;
|
|
188
|
+
renderWidget();
|
|
189
|
+
if (call.turns >= def.maxTurns) void session?.abort().catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await session.prompt(prompt, { source: "extension" } as never);
|
|
194
|
+
|
|
195
|
+
const messages = session.messages as Array<{
|
|
196
|
+
role?: string;
|
|
197
|
+
stopReason?: unknown;
|
|
198
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
199
|
+
}>;
|
|
200
|
+
const last = [...messages].reverse().find((m) => m.role === "assistant");
|
|
201
|
+
const text = (last?.content ?? [])
|
|
202
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
203
|
+
.map((c) => c.text)
|
|
204
|
+
.join("\n")
|
|
205
|
+
.trim();
|
|
206
|
+
|
|
207
|
+
if (last?.stopReason === "aborted") {
|
|
208
|
+
call.status = "aborted";
|
|
209
|
+
return text || null;
|
|
210
|
+
}
|
|
211
|
+
if (last?.stopReason === "error" || !text) {
|
|
212
|
+
call.status = "error";
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
call.status = "done";
|
|
216
|
+
return text;
|
|
217
|
+
} catch {
|
|
218
|
+
call.status = "error";
|
|
219
|
+
return null;
|
|
220
|
+
} finally {
|
|
221
|
+
release();
|
|
222
|
+
if (unsubscribe) {
|
|
223
|
+
try {
|
|
224
|
+
unsubscribe();
|
|
225
|
+
} catch {
|
|
226
|
+
// gone
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (session) {
|
|
230
|
+
try {
|
|
231
|
+
session.dispose();
|
|
232
|
+
} catch {
|
|
233
|
+
// fine
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
renderWidget();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Execution ────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async function execute(ctx: UiContext, run: WorkflowRun, script: string, args: unknown): Promise<void> {
|
|
243
|
+
try {
|
|
244
|
+
const value = await runScript(script, args, {
|
|
245
|
+
agent: (prompt, opts) => runChildAgent(ctx, run, prompt, opts),
|
|
246
|
+
log: (message) => {
|
|
247
|
+
run.logs.push(message.slice(0, 500));
|
|
248
|
+
renderWidget();
|
|
249
|
+
},
|
|
250
|
+
phase: (title) => {
|
|
251
|
+
run.phases.push(title.slice(0, 100));
|
|
252
|
+
renderWidget();
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
run.result =
|
|
256
|
+
typeof value === "string" ? value : value === undefined ? null : JSON.stringify(value, null, 2);
|
|
257
|
+
if (run.result && run.result.length > MAX_PERSISTED_RESULT_CHARS) {
|
|
258
|
+
run.result = `${run.result.slice(0, MAX_PERSISTED_RESULT_CHARS)}\n… (truncated)`;
|
|
259
|
+
}
|
|
260
|
+
run.status = "done";
|
|
261
|
+
} catch (err) {
|
|
262
|
+
run.status = "error";
|
|
263
|
+
run.error = err instanceof Error ? err.message : String(err);
|
|
264
|
+
} finally {
|
|
265
|
+
run.finishedAt = Date.now();
|
|
266
|
+
pi.appendEntry(RUN_ENTRY, run);
|
|
267
|
+
renderWidget();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function savedWorkflows(cwd: string): string[] {
|
|
272
|
+
try {
|
|
273
|
+
return readdirSync(join(cwd, ".pi", "workflows"))
|
|
274
|
+
.filter((f) => f.endsWith(".js") || f.endsWith(".mjs"))
|
|
275
|
+
.sort();
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Tools ────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
pi.registerTool({
|
|
284
|
+
name: "workflow",
|
|
285
|
+
label: "Run workflow",
|
|
286
|
+
description:
|
|
287
|
+
"Run a deterministic JavaScript orchestration script that fans work out across child agents. " +
|
|
288
|
+
"Globals: agent(prompt, {agent?, label?, phase?}) -> Promise<string|null> (agent types: " +
|
|
289
|
+
"reviewer/scout/worker + .pi/agents custom; write prompts as self-contained briefs); " +
|
|
290
|
+
"parallel(thunks) (barrier, failures resolve null); pipeline(items, ...stages) (no barrier " +
|
|
291
|
+
"between stages); phase(title); log(msg); args. The script's return value is the tool result. " +
|
|
292
|
+
"Date.now()/Math.random()/eval throw (determinism). Provide script XOR name " +
|
|
293
|
+
"(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status.",
|
|
294
|
+
parameters: Type.Object({
|
|
295
|
+
script: Type.Optional(Type.String({ description: "JavaScript orchestration script body" })),
|
|
296
|
+
name: Type.Optional(Type.String({ description: "Saved workflow name in .pi/workflows/" })),
|
|
297
|
+
args: Type.Optional(Type.Unknown({ description: "Value exposed to the script as `args`" })),
|
|
298
|
+
background: Type.Optional(Type.Boolean()),
|
|
299
|
+
}),
|
|
300
|
+
async execute(
|
|
301
|
+
_id,
|
|
302
|
+
params: { script?: string; name?: string; args?: unknown; background?: boolean },
|
|
303
|
+
_signal,
|
|
304
|
+
_onUpdate,
|
|
305
|
+
ctx,
|
|
306
|
+
) {
|
|
307
|
+
const uiCtx = ctx as UiContext;
|
|
308
|
+
if (activeRun?.status === "running") {
|
|
309
|
+
throw new Error(`Workflow ${activeRun.runId} is still running — wait or poll workflow_status.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let script = params.script ?? "";
|
|
313
|
+
if (params.name) {
|
|
314
|
+
if (script) throw new Error("Provide script OR name, not both.");
|
|
315
|
+
const safe = params.name.trim().toLowerCase();
|
|
316
|
+
if (!/^[a-z0-9._-]+$/.test(safe)) throw new Error(`Invalid workflow name "${params.name}".`);
|
|
317
|
+
const dir = join(uiCtx.cwd, ".pi", "workflows");
|
|
318
|
+
const file = ["", ".js", ".mjs"].map((ext) => join(dir, safe + ext)).find((f) => {
|
|
319
|
+
try {
|
|
320
|
+
return readFileSync(f, "utf8") !== undefined;
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if (!file) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`No saved workflow "${safe}". Available: ${savedWorkflows(uiCtx.cwd).join(", ") || "(none)"}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
script = readFileSync(file, "utf8");
|
|
331
|
+
}
|
|
332
|
+
if (!script.trim()) throw new Error("workflow requires a script (or a saved name).");
|
|
333
|
+
|
|
334
|
+
runCounter++;
|
|
335
|
+
const run: WorkflowRun = {
|
|
336
|
+
runId: `w${runCounter}`,
|
|
337
|
+
background: params.background === true,
|
|
338
|
+
status: "running",
|
|
339
|
+
startedAt: Date.now(),
|
|
340
|
+
finishedAt: null,
|
|
341
|
+
phases: [],
|
|
342
|
+
agents: [],
|
|
343
|
+
logs: [],
|
|
344
|
+
result: null,
|
|
345
|
+
error: null,
|
|
346
|
+
};
|
|
347
|
+
runs.set(run.runId, run);
|
|
348
|
+
activeRun = run;
|
|
349
|
+
renderWidget(uiCtx);
|
|
350
|
+
|
|
351
|
+
if (run.background) {
|
|
352
|
+
void execute(uiCtx, run, script, params.args).then(() => {
|
|
353
|
+
notify(uiCtx, `workflow ${run.runId}: ${run.status}`, run.status === "done" ? "info" : "warning");
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
content: [{ type: "text", text: `Workflow ${run.runId} started. Poll workflow_status runId="${run.runId}".` }],
|
|
357
|
+
details: { runId: run.runId },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await execute(uiCtx, run, script, params.args);
|
|
362
|
+
return {
|
|
363
|
+
content: [{ type: "text", text: formatResult(run) }],
|
|
364
|
+
details: { runId: run.runId, status: run.status, agents: run.agents.length },
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
pi.registerTool({
|
|
370
|
+
name: "workflow_status",
|
|
371
|
+
label: "Workflow status",
|
|
372
|
+
description: "Progress of a workflow run (default: the latest). Returns the result when finished.",
|
|
373
|
+
parameters: Type.Object({
|
|
374
|
+
runId: Type.Optional(Type.String()),
|
|
375
|
+
}),
|
|
376
|
+
async execute(_id, params: { runId?: string }) {
|
|
377
|
+
const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
|
|
378
|
+
if (!run) throw new Error("No workflow runs this session.");
|
|
379
|
+
const text = run.status === "running" ? formatStatus(run) : formatResult(run);
|
|
380
|
+
return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ── Lifecycle & command ──────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
387
|
+
loadDefs(ctx.cwd);
|
|
388
|
+
runs.clear();
|
|
389
|
+
activeRun = null;
|
|
390
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
391
|
+
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
392
|
+
if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
|
|
393
|
+
const run = e.data as unknown as WorkflowRun;
|
|
394
|
+
if (typeof run.runId === "string" && run.status !== "running") {
|
|
395
|
+
runs.set(run.runId, run);
|
|
396
|
+
const n = Number.parseInt(run.runId.slice(1), 10);
|
|
397
|
+
if (Number.isFinite(n) && n > runCounter) runCounter = n;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
renderWidget(ctx);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
404
|
+
if (ctx.hasUI) ctx.ui.setWidget("workflow", undefined);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
pi.registerCommand("workflows", {
|
|
408
|
+
description: "List workflow runs and saved scripts (.pi/workflows/)",
|
|
409
|
+
handler: async (_args, ctx) => {
|
|
410
|
+
if (!ctx.hasUI) return;
|
|
411
|
+
const saved = savedWorkflows(ctx.cwd);
|
|
412
|
+
const runLines = [...runs.values()].map((r) => formatStatus(r)).join("\n") || "(no runs yet)";
|
|
413
|
+
ctx.ui.notify(
|
|
414
|
+
`Saved workflows\n${saved.length > 0 ? saved.join("\n") : "(none — add .pi/workflows/<name>.js)"}\n\nRuns\n${runLines}`,
|
|
415
|
+
"info",
|
|
416
|
+
);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pify/workflow",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Deterministic multi-step agent orchestration for pi: a Claude Code-style workflow tool with agent()/parallel()/pipeline() scripts over the shared agent catalog",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi",
|
|
9
|
+
"pify",
|
|
10
|
+
"workflow",
|
|
11
|
+
"orchestration"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/pifydev/workflow#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/pifydev/workflow/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/pifydev/workflow.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Pify maintainers",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.19.0"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"extensions",
|
|
29
|
+
"src",
|
|
30
|
+
"skills",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions/workflow.ts"
|
|
37
|
+
],
|
|
38
|
+
"skills": [
|
|
39
|
+
"./skills"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*",
|
|
50
|
+
"typebox": "*"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@earendil-works/pi-tui": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"typebox": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
66
|
+
"@types/node": "^22.10.2",
|
|
67
|
+
"typebox": "^1.1.38",
|
|
68
|
+
"typescript": "^5.7.2"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: workflow
|
|
3
|
+
description: Use when a task needs deterministic multi-agent orchestration - fan out, verify, synthesize across many child agents with loops/conditionals - explains the workflow tool's script contract and when NOT to use it
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Workflows
|
|
7
|
+
|
|
8
|
+
This project has the `@pify/workflow` extension installed: the `workflow`
|
|
9
|
+
tool runs a JavaScript orchestration script over child agents.
|
|
10
|
+
|
|
11
|
+
## When to reach for it
|
|
12
|
+
|
|
13
|
+
- The work decomposes into many agent calls with real control flow
|
|
14
|
+
(loops, conditionals, staged verification) — audits, migrations,
|
|
15
|
+
multi-perspective reviews with cross-checking.
|
|
16
|
+
- Intermediate results should stay in script variables instead of
|
|
17
|
+
flooding this conversation's context.
|
|
18
|
+
|
|
19
|
+
Use the smaller primitives when they fit: one task → agent_run
|
|
20
|
+
(@pify/subagent); independent parallel items → swarm_run (@pify/swarm).
|
|
21
|
+
Only orchestrate when the structure earns it.
|
|
22
|
+
|
|
23
|
+
## Script contract
|
|
24
|
+
|
|
25
|
+
- `agent(prompt, {agent?, label?, phase?})` → the child's report (or null
|
|
26
|
+
on failure). Agent types: reviewer / scout / worker / .pi/agents custom.
|
|
27
|
+
Prompts must be self-contained briefs — children see nothing else.
|
|
28
|
+
- `parallel(thunks)` is a barrier; failed thunks resolve null —
|
|
29
|
+
`.filter(Boolean)` before use. `pipeline(items, ...stages)` has no
|
|
30
|
+
barrier between stages; a throwing stage drops that item to null.
|
|
31
|
+
- `phase(title)` groups progress; `log(msg)` narrates; `args` carries the
|
|
32
|
+
input value; the script's `return` value is the tool result.
|
|
33
|
+
- Determinism is enforced: `Date.now()`, `Math.random()`, argless
|
|
34
|
+
`new Date()`, and `eval` throw. Pass timestamps in via `args`.
|
|
35
|
+
- Caps: 20 agents per run, 4 concurrent, 10-minute timeout.
|
|
36
|
+
|
|
37
|
+
## Saved workflows
|
|
38
|
+
|
|
39
|
+
Reusable scripts live in `.pi/workflows/<name>.js` and run with
|
|
40
|
+
`workflow name="<name>" args={...}`. Prefer a saved script when the user
|
|
41
|
+
runs the same orchestration repeatedly.
|
package/src/builtin.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in agent types (adapted from nicobailon/pi-subagents' agent set,
|
|
3
|
+
* trimmed to three archetypes). Overridable: a project or global .md file
|
|
4
|
+
* with the same name wins.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const BUILTIN_AGENTS: Record<string, string> = {
|
|
8
|
+
reviewer: `---
|
|
9
|
+
description: Read-only review specialist for diffs, plans, and code health
|
|
10
|
+
tools: read, grep, find, ls
|
|
11
|
+
thinking: high
|
|
12
|
+
max_turns: 25
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
You are a disciplined review subagent. Inspect, evaluate, and report findings
|
|
16
|
+
with evidence — never guess; verify from the code itself. You cannot modify
|
|
17
|
+
anything: your deliverable is the report.
|
|
18
|
+
|
|
19
|
+
For each finding give: file:line, what is wrong, why it matters, and a
|
|
20
|
+
concrete suggestion. Rank findings by severity. If the code is fine, say so
|
|
21
|
+
plainly — do not invent issues. End with a one-paragraph verdict.`,
|
|
22
|
+
|
|
23
|
+
scout: `---
|
|
24
|
+
description: Fast read-only exploration and research across the codebase
|
|
25
|
+
tools: read, grep, find, ls
|
|
26
|
+
thinking: low
|
|
27
|
+
max_turns: 25
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
You are a scout subagent: locate, map, and summarize — quickly. Answer the
|
|
31
|
+
question with file paths and line references, quoting only the smallest
|
|
32
|
+
relevant excerpts. Prefer breadth over depth unless asked otherwise. If
|
|
33
|
+
something cannot be found, report exactly what you searched so the caller
|
|
34
|
+
can redirect you. Your final message is the entire deliverable.`,
|
|
35
|
+
|
|
36
|
+
worker: `---
|
|
37
|
+
description: Implementation agent with full tool access for a scoped task
|
|
38
|
+
tools: read, bash, edit, write, grep, find, ls
|
|
39
|
+
thinking: medium
|
|
40
|
+
max_turns: 60
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
You are a worker subagent implementing one scoped task. Stay strictly within
|
|
44
|
+
the task's boundaries: no drive-by refactors, no scope creep. Follow the
|
|
45
|
+
project's existing conventions. Verify your work (build, tests, or a smoke
|
|
46
|
+
check) before finishing. Your final message must state exactly what changed,
|
|
47
|
+
what you verified, and anything you deliberately left undone.`,
|
|
48
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_TURNS,
|
|
3
|
+
THINKING_LEVELS,
|
|
4
|
+
VALID_TOOLS,
|
|
5
|
+
type AgentDef,
|
|
6
|
+
type ThinkingLevelName,
|
|
7
|
+
type ValidTool,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse an agent definition file: same schema as @pify/subagent plus the
|
|
12
|
+
* routing keys `match_patterns` / `match_keywords` (comma-separated, also
|
|
13
|
+
* accepts the camelCase spellings gjczone used). One definition file serves
|
|
14
|
+
* both packages.
|
|
15
|
+
*/
|
|
16
|
+
export function parseAgentFile(
|
|
17
|
+
name: string,
|
|
18
|
+
content: string,
|
|
19
|
+
source: AgentDef["source"],
|
|
20
|
+
): AgentDef | null {
|
|
21
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
22
|
+
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(normalized);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
|
|
25
|
+
const fields = new Map<string, string>();
|
|
26
|
+
for (const line of match[1]!.split("\n")) {
|
|
27
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
28
|
+
if (kv) fields.set(kv[1]!.toLowerCase(), kv[2]!.trim());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const description = fields.get("description") ?? "";
|
|
32
|
+
if (!description) return null;
|
|
33
|
+
|
|
34
|
+
const thinkingRaw = fields.get("thinking")?.toLowerCase();
|
|
35
|
+
const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: name.toLowerCase(),
|
|
39
|
+
description,
|
|
40
|
+
tools: parseTools(fields.get("tools")),
|
|
41
|
+
model: fields.get("model") || null,
|
|
42
|
+
thinking: (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
|
|
43
|
+
? (thinkingRaw as ThinkingLevelName)
|
|
44
|
+
: null,
|
|
45
|
+
maxTurns:
|
|
46
|
+
Number.isFinite(maxTurnsRaw) && maxTurnsRaw > 0 && maxTurnsRaw <= 200
|
|
47
|
+
? maxTurnsRaw
|
|
48
|
+
: DEFAULT_MAX_TURNS,
|
|
49
|
+
systemPrompt: match[2]!.trim(),
|
|
50
|
+
source,
|
|
51
|
+
matchPatterns: parseList(fields.get("match_patterns") ?? fields.get("matchpatterns")),
|
|
52
|
+
matchKeywords: parseList(fields.get("match_keywords") ?? fields.get("matchkeywords")).map((k) =>
|
|
53
|
+
k.toLowerCase(),
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseList(raw: string | undefined): string[] {
|
|
59
|
+
if (!raw) return [];
|
|
60
|
+
return raw
|
|
61
|
+
.replace(/^\[|\]$/g, "")
|
|
62
|
+
.split(",")
|
|
63
|
+
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseTools(raw: string | undefined): ValidTool[] {
|
|
68
|
+
if (!raw) return ["read", "grep", "find", "ls"];
|
|
69
|
+
const valid = raw
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((t) => t.trim().toLowerCase())
|
|
72
|
+
.filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
|
|
73
|
+
return valid.length > 0 ? valid : ["read", "grep", "find", "ls"];
|
|
74
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ThemeLike, WorkflowRun } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function formatResult(run: WorkflowRun): string {
|
|
4
|
+
const header = `[workflow ${run.runId}] ${run.status} — ${run.agents.length} agents, ${run.phases.length} phases`;
|
|
5
|
+
if (run.status === "error") return `${header}\nError: ${run.error ?? "unknown"}`;
|
|
6
|
+
if (run.status === "running") {
|
|
7
|
+
return `${header}\nStill running — poll workflow_status runId="${run.runId}".`;
|
|
8
|
+
}
|
|
9
|
+
return `${header}\n${run.result ?? "(script returned nothing)"}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatStatus(run: WorkflowRun): string {
|
|
13
|
+
const agents = run.agents
|
|
14
|
+
.map((a) => `${a.id}:${a.label}=${a.status}${a.turns ? `(${a.turns}t)` : ""}`)
|
|
15
|
+
.join(" · ");
|
|
16
|
+
const phase = run.phases.length > 0 ? ` · phase: ${run.phases[run.phases.length - 1]}` : "";
|
|
17
|
+
return `[workflow ${run.runId}] ${run.status}${phase}${agents ? ` — ${agents}` : ""}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const WIDTH = 54;
|
|
21
|
+
|
|
22
|
+
export function buildWidgetLines(run: WorkflowRun | null, theme: ThemeLike, now: number): string[] {
|
|
23
|
+
if (!run) return [];
|
|
24
|
+
if (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000) return [];
|
|
25
|
+
|
|
26
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
const title = ` ⚙ workflow ${run.runId} `;
|
|
29
|
+
const hint = " /workflows ";
|
|
30
|
+
const pad = Math.max(1, WIDTH - title.length - hint.length);
|
|
31
|
+
lines.push(dim(`╭${title}${"─".repeat(pad)}${hint}╮`));
|
|
32
|
+
|
|
33
|
+
if (run.phases.length > 0) {
|
|
34
|
+
lines.push(`${dim("│ ")}${theme.bold(run.phases[run.phases.length - 1]!)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const agent of run.agents.slice(-6)) {
|
|
38
|
+
const paint =
|
|
39
|
+
agent.status === "running"
|
|
40
|
+
? (s: string) => theme.fg("warning", s)
|
|
41
|
+
: agent.status === "done"
|
|
42
|
+
? (s: string) => theme.fg("success", s)
|
|
43
|
+
: (s: string) => theme.fg("error", s);
|
|
44
|
+
const icon = agent.status === "running" ? "⟳" : agent.status === "done" ? "✓" : "✗";
|
|
45
|
+
lines.push(`${dim("│ ")}${paint(`${icon} ${agent.label}`)}${dim(` (${agent.agent})`)}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lastLog = run.logs[run.logs.length - 1];
|
|
49
|
+
if (lastLog) lines.push(dim(`│ ${lastLog.length > 48 ? `${lastLog.slice(0, 48)}…` : lastLog}`));
|
|
50
|
+
|
|
51
|
+
lines.push(dim(`╰${"─".repeat(WIDTH)}╯`));
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import vm from "node:vm";
|
|
2
|
+
import { MAX_AGENTS_PER_RUN, SCRIPT_TIMEOUT_MS } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic script sandbox (tintinweb's discipline, Claude Code's
|
|
6
|
+
* contract): a fresh vm context exposing exactly the orchestration globals —
|
|
7
|
+
* agent / parallel / pipeline / phase / log / args — with Date.now(),
|
|
8
|
+
* Math.random(), argless new Date(), and string code generation poisoned so
|
|
9
|
+
* a script's control flow is reproducible. This is cooperative determinism,
|
|
10
|
+
* not a security boundary: the script runs at the same trust level as the
|
|
11
|
+
* bash tool in the same session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AgentOptions {
|
|
15
|
+
agent?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
phase?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SandboxHooks {
|
|
21
|
+
/** Spawn one child agent; resolves to its report text or null on failure. */
|
|
22
|
+
agent(prompt: string, opts?: AgentOptions): Promise<string | null>;
|
|
23
|
+
log(message: string): void;
|
|
24
|
+
phase(title: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function poisonedMath(): Math {
|
|
28
|
+
const clone = Object.create(Math) as Math;
|
|
29
|
+
Object.defineProperty(clone, "random", {
|
|
30
|
+
value: () => {
|
|
31
|
+
throw new Error("Math.random() is unavailable in workflow scripts (determinism).");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return clone;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function poisonedDate(): DateConstructor {
|
|
38
|
+
return new Proxy(Date, {
|
|
39
|
+
construct(target, args: unknown[]) {
|
|
40
|
+
if (args.length === 0) {
|
|
41
|
+
throw new Error("new Date() without arguments is unavailable in workflow scripts (determinism).");
|
|
42
|
+
}
|
|
43
|
+
return new (target as DateConstructor)(...(args as [number]));
|
|
44
|
+
},
|
|
45
|
+
get(target, prop) {
|
|
46
|
+
if (prop === "now") {
|
|
47
|
+
return () => {
|
|
48
|
+
throw new Error("Date.now() is unavailable in workflow scripts (determinism).");
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return Reflect.get(target, prop);
|
|
52
|
+
},
|
|
53
|
+
}) as DateConstructor;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* CC-style scripts may start with `export const meta = {...}`; vm scripts
|
|
58
|
+
* are not modules, so export prefixes are stripped (the binding remains).
|
|
59
|
+
*/
|
|
60
|
+
export function stripExports(script: string): string {
|
|
61
|
+
return script.replace(/^\s*export\s+(?=const|let|var|function|async)/gm, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RunScriptOptions {
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
maxAgents?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runScript(
|
|
70
|
+
script: string,
|
|
71
|
+
args: unknown,
|
|
72
|
+
hooks: SandboxHooks,
|
|
73
|
+
options: RunScriptOptions = {},
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
76
|
+
let agentCalls = 0;
|
|
77
|
+
|
|
78
|
+
const agent = (prompt: unknown, opts?: AgentOptions): Promise<string | null> => {
|
|
79
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
80
|
+
throw new Error("agent() requires a non-empty prompt string.");
|
|
81
|
+
}
|
|
82
|
+
agentCalls++;
|
|
83
|
+
if (agentCalls > maxAgents) {
|
|
84
|
+
throw new Error(`Agent cap reached (${maxAgents} per run).`);
|
|
85
|
+
}
|
|
86
|
+
return hooks.agent(prompt, opts);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const parallel = (thunks: Array<() => Promise<unknown>>): Promise<unknown[]> => {
|
|
90
|
+
if (!Array.isArray(thunks)) throw new Error("parallel() takes an array of thunks.");
|
|
91
|
+
return Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const pipeline = (items: unknown[], ...stages: Array<(prev: unknown, item: unknown, index: number) => unknown>): Promise<unknown[]> => {
|
|
95
|
+
if (!Array.isArray(items)) throw new Error("pipeline() takes an items array.");
|
|
96
|
+
return Promise.all(
|
|
97
|
+
items.map(async (item, index) => {
|
|
98
|
+
let value: unknown = item;
|
|
99
|
+
for (const stage of stages) {
|
|
100
|
+
try {
|
|
101
|
+
value = await stage(value, item, index);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const context = vm.createContext(
|
|
112
|
+
{
|
|
113
|
+
agent,
|
|
114
|
+
parallel,
|
|
115
|
+
pipeline,
|
|
116
|
+
phase: (title: unknown) => hooks.phase(String(title)),
|
|
117
|
+
log: (message: unknown) => hooks.log(String(message)),
|
|
118
|
+
args,
|
|
119
|
+
JSON,
|
|
120
|
+
Math: poisonedMath(),
|
|
121
|
+
Date: poisonedDate(),
|
|
122
|
+
Promise,
|
|
123
|
+
Array,
|
|
124
|
+
Object,
|
|
125
|
+
String,
|
|
126
|
+
Number,
|
|
127
|
+
Boolean,
|
|
128
|
+
Set,
|
|
129
|
+
Map,
|
|
130
|
+
console: { log: (message: unknown) => hooks.log(String(message)) },
|
|
131
|
+
eval: undefined,
|
|
132
|
+
Function: undefined,
|
|
133
|
+
},
|
|
134
|
+
{ codeGeneration: { strings: false, wasm: false } },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const body = stripExports(script);
|
|
138
|
+
const promise = vm.runInContext(
|
|
139
|
+
`(async () => {\n${body}\n})()`,
|
|
140
|
+
context,
|
|
141
|
+
{ timeout: 30_000 }, // guards synchronous runaway loops only
|
|
142
|
+
) as Promise<unknown>;
|
|
143
|
+
|
|
144
|
+
const timeoutMs = options.timeoutMs ?? SCRIPT_TIMEOUT_MS;
|
|
145
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([
|
|
148
|
+
promise,
|
|
149
|
+
new Promise((_resolve, reject) => {
|
|
150
|
+
timer = setTimeout(
|
|
151
|
+
() => reject(new Error(`Workflow script timed out after ${Math.round(timeoutMs / 1000)}s.`)),
|
|
152
|
+
timeoutMs,
|
|
153
|
+
);
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
} finally {
|
|
157
|
+
if (timer) clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local structural types for @pify/workflow.
|
|
3
|
+
* No imports from pi packages: src/ typechecks and runs standalone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const VALID_TOOLS = [
|
|
7
|
+
"read",
|
|
8
|
+
"bash",
|
|
9
|
+
"powershell",
|
|
10
|
+
"edit",
|
|
11
|
+
"write",
|
|
12
|
+
"grep",
|
|
13
|
+
"find",
|
|
14
|
+
"ls",
|
|
15
|
+
] as const;
|
|
16
|
+
export type ValidTool = (typeof VALID_TOOLS)[number];
|
|
17
|
+
|
|
18
|
+
export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
19
|
+
export const THINKING_LEVELS: readonly ThinkingLevelName[] = [
|
|
20
|
+
"off",
|
|
21
|
+
"minimal",
|
|
22
|
+
"low",
|
|
23
|
+
"medium",
|
|
24
|
+
"high",
|
|
25
|
+
"xhigh",
|
|
26
|
+
"max",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Agent definition — same file format as @pify/subagent and @pify/swarm. */
|
|
30
|
+
export interface AgentDef {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
tools: ValidTool[];
|
|
34
|
+
model: string | null;
|
|
35
|
+
thinking: ThinkingLevelName | null;
|
|
36
|
+
maxTurns: number;
|
|
37
|
+
systemPrompt: string;
|
|
38
|
+
source: "builtin" | "global" | "project";
|
|
39
|
+
matchPatterns: string[];
|
|
40
|
+
matchKeywords: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_MAX_TURNS = 30;
|
|
44
|
+
|
|
45
|
+
/** Workflow execution limits. */
|
|
46
|
+
export const MAX_AGENTS_PER_RUN = 20;
|
|
47
|
+
export const AGENT_CONCURRENCY = 4;
|
|
48
|
+
export const SCRIPT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
49
|
+
/** Persisted results are capped so session files stay sane. */
|
|
50
|
+
export const MAX_PERSISTED_RESULT_CHARS = 32_000;
|
|
51
|
+
|
|
52
|
+
export type AgentCallStatus = "running" | "done" | "error" | "aborted";
|
|
53
|
+
|
|
54
|
+
export interface AgentCallState {
|
|
55
|
+
id: number;
|
|
56
|
+
label: string;
|
|
57
|
+
agent: string;
|
|
58
|
+
phase: string | null;
|
|
59
|
+
status: AgentCallStatus;
|
|
60
|
+
turns: number;
|
|
61
|
+
tokens: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RunStatus = "running" | "done" | "error";
|
|
65
|
+
|
|
66
|
+
export interface WorkflowRun {
|
|
67
|
+
runId: string;
|
|
68
|
+
background: boolean;
|
|
69
|
+
status: RunStatus;
|
|
70
|
+
startedAt: number;
|
|
71
|
+
finishedAt: number | null;
|
|
72
|
+
phases: string[];
|
|
73
|
+
agents: AgentCallState[];
|
|
74
|
+
logs: string[];
|
|
75
|
+
result: string | null;
|
|
76
|
+
error: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ThemeLike {
|
|
80
|
+
fg(color: string, text: string): string;
|
|
81
|
+
bold(text: string): string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BranchEntryLike {
|
|
85
|
+
type?: string;
|
|
86
|
+
customType?: string;
|
|
87
|
+
data?: unknown;
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|