@nicknisi/pi-workflows 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/engine.d.ts +26 -2
- package/dist/engine.js +64 -6
- package/dist/examples.test.js +9 -2
- package/dist/index.js +125 -16
- package/dist/workflow.test.js +89 -1
- package/engine.ts +100 -6
- package/examples/autoimplement.js +80 -0
- package/examples/autoplan.js +123 -0
- package/examples/bake-off.js +107 -0
- package/examples/gates.js +106 -0
- package/examples/lanes.js +70 -0
- package/examples/sanity-check.js +85 -0
- package/index.ts +168 -14
- package/package.json +7 -2
- package/skills/autoplan/SKILL.md +29 -0
package/README.md
CHANGED
|
@@ -8,8 +8,11 @@ Four pieces compose the workflow platform: `@nicknisi/pi-shared`'s **subagent ru
|
|
|
8
8
|
|
|
9
9
|
## What it adds
|
|
10
10
|
|
|
11
|
-
- **`workflow` tool** (model-facing) — actions: `run` (inline JS `script` OR `name` of a saved workflow file), `list`, `status <runId>`, `stop <runId
|
|
12
|
-
- **`/wf` command** (human-facing) — `/wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId
|
|
11
|
+
- **`workflow` tool** (model-facing) — actions: `run` (inline JS `script` OR `name` of a saved workflow file), `list`, `status <runId>`, `stop <runId>`, `pause`, `resume`.
|
|
12
|
+
- **`/wf` command** (human-facing) — `/wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId> | /wf pause | /wf resume`.
|
|
13
|
+
- **Human gates inside scripts** — `checkpoint(label?)` pauses the run on a confirm dialog (reject/dismiss stops the run); `ask(question, options?)` asks mid-run (select with options, yes/no without, `undefined` when dismissed).
|
|
14
|
+
- **Footer status** — while a run is active the footer shows `wf <name> [running|paused] <last phase>`.
|
|
15
|
+
- **`autoplan` skill** — teaches the model the "autoplan this" trigger: run the saved `autoplan` workflow with args derived from the conversation, then respect the run's decision outcome. Disable with `"skills": ["-skills/autoplan"]` on the package entry.
|
|
13
16
|
|
|
14
17
|
## The script contract
|
|
15
18
|
|
|
@@ -30,14 +33,18 @@ return { answered: results.length, results };
|
|
|
30
33
|
| `agent(prompt, opts)` | Spawns a hermetic in-process child via the subagent runtime (namespace `workflows`). Throws `${kind}: ${error}` on failure — wrap with a `safeAgent` that returns `{ ok, value, error }` so a failure inside `parallel()` reports which stage died instead of collapsing the wave to `null`. Returns `res.data ?? res.text ?? null`. |
|
|
31
34
|
| `parallel(thunks)` | `Promise.all` over zero-arg thunks — pass `() => agent(...)`, not `agent(...)`. |
|
|
32
35
|
| `pipeline(items, ...stages)` | Folds items through stages: each stage maps over the previous stage's outputs in parallel, producing the next array. |
|
|
33
|
-
| `phase(name)` | Logging marker
|
|
36
|
+
| `phase(name)` | Logging marker that also drives the footer status (`wf <name> [state] <phase>`). NOT a budget boundary. |
|
|
34
37
|
| `log(...args)` | Captured into the result logs. |
|
|
38
|
+
| `checkpoint(label?)` | Human gate: suspends the run on a confirm dialog; rejecting throws and stops the run. Without a UI host it is a logged no-op. Put it before destructive or expensive steps. |
|
|
39
|
+
| `ask(question, options?)` | Human answer mid-run: a select when `options` are given, a yes/no confirm otherwise; `undefined` when dismissed. Without a UI host it throws — never invent an answer. |
|
|
35
40
|
| `args` | The `args` JSON value passed to `run`. |
|
|
36
41
|
| `budget` | `{ total, spent, remaining }` over the run's token usage. `total` defaults to `Infinity`; `spent` accumulates across `agent()` calls. Read-only. |
|
|
37
42
|
| `cwd` | The session working directory. |
|
|
38
43
|
|
|
39
44
|
`agent()` opts: `model` (`'provider/id'`), `tools` (allowlist — default read-only `['read','grep','find','ls']`; pass `['read','bash','edit','write']` for builders), `label` (child agent label), `systemPrompt`, `schema` (validated; parsed JSON lands in `result.data`), `effort` (thinking level), `timeoutMs`, `maxTurns`, `worktree` (run the child in an isolated git worktree; on settle the change set is captured to a `.patch` and `agent()` returns `{ value, patchPath, runId }` instead of the bare value — opt-in, so non-worktree calls are unchanged), `agentType` (accepted but ignored — no agent-type registry; resolve `systemPrompt` in the script itself).
|
|
40
45
|
|
|
46
|
+
`agent()` awaits the run's pause gate before every spawn: `pause` lets the in-flight step finish, then holds the run before the next one; `resume` releases it. Pause/resume are session-scoped (`/wf pause`, `/wf resume`, or the tool actions) — they apply to every active run, in practice one. Stopping or timing out a run aborts the gate, so a parked run rejects instead of hanging.
|
|
47
|
+
|
|
41
48
|
The script executes **in the host process with full Node access** — `process`, `require`, and `fs` are all reachable, the same trust boundary as the `bash` tool. Keep the returned value small: summaries, counts, key findings — never raw file dumps.
|
|
42
49
|
|
|
43
50
|
## Saved workflows
|
|
@@ -71,6 +78,9 @@ The `examples/` directory ships standalone, copy-and-adapt workflow scripts —
|
|
|
71
78
|
- **`lanes.js`** — N parallel agents editing FILE-DISJOINT lanes of one repo under a hard-rules preamble (each lane owns a fixed file set; no git, no installs; the parent integrates centrally). Use it when a task splits into independent edits that don't overlap on files. Adapt by setting `VERIFY` to your typecheck command and filling the `LANES` array with `{ name, files, brief }` per lane.
|
|
72
79
|
- **`gates.js`** — three judge/verify prompt builders returning prompt strings: adversarial refutation (defeats confirmation bias), deep-research coverage (defeats silent source omission), and a 3-way code-review verdict (defeats verdict collapse). Use it when you need a reliable gate inside your own workflow. Adapt by copying the builder whose failure mode you need and calling it from an `agent()` with a JSON schema. Prompt patterns distilled from `@quintinshaw/pi-dynamic-workflows`.
|
|
73
80
|
- **`bake-off.js`** — race N models on the SAME task in isolated worktrees (`worktree: true`), then an advisory judge reads each contender's `.patch` and picks a winner. Use it on hard build tasks where a single GLM-5.2-class builder produces decent-but-flawed code; the 2x token cost buys a measurably better hit rate. Adapt by setting `CONTENDERS` to the models to race and passing `task` in `args`; the workflow returns the winner's `patchPath` to apply via `/patches`.
|
|
81
|
+
- **`autoplan.js`** — 3 solution candidates in parallel + a Holy Grail pass, an advisor that ranks with a recommendation (curbing Grail ideas that need upstream changes), then the HUMAN decides via `ask()` — recommendation on top, reject-all always offered — and the chosen option gets the full plan write. Ported from osolmaz/pi-workflows' decision-gate demo. Pass `{ problem, scope, constraints }` in `args`, or say "autoplan this" and the bundled `autoplan` skill derives the args from the conversation.
|
|
82
|
+
- **`sanity-check.js`** — read-only contribution review: evidence collection, four parallel area reviewers (necessity, duplication, contracts, scope/tests), then a verifier that tries to REFUTE every finding before the keep/simplify/refactor/drop/needs-evidence verdict. Ported from osolmaz/pi-workflows. Pass `{ baseRef }` in `args`.
|
|
83
|
+
- **`autoimplement.js`** — implement a supplied plan (never devises one) behind an `ask` plan gate, then a bounded build → verify → review/fix loop where P0/P1 block and the round cap prevents an unbounded fix spiral. Ported from osolmaz/pi-workflows. Pass `{ task, plan, verify }` in `args`.
|
|
74
84
|
|
|
75
85
|
## Dependencies
|
|
76
86
|
|
|
@@ -81,6 +91,8 @@ The `examples/` directory ships standalone, copy-and-adapt workflow scripts —
|
|
|
81
91
|
## Caveats
|
|
82
92
|
|
|
83
93
|
- The script runs in the host process with full Node access — the same trust boundary as the `bash` and `codemode` tools. Your model, your session.
|
|
94
|
+
- `checkpoint`/`ask` need an interactive UI host. In a headless/RPC host `checkpoint` degrades to a logged no-op and `ask` throws — scripts that require an answer fail loudly instead of inventing one.
|
|
95
|
+
- Pause/resume are session-scoped and best-effort for tool-initiated runs: slash commands may queue behind an in-flight turn, so a `pause` issued mid-turn engages at the next `agent()` boundary after it's processed. For a guaranteed human gate, put `checkpoint`/`ask` in the script itself.
|
|
84
96
|
- Project-local workflows (`.pi/workflows/`) load only in trusted projects; untrusted projects are limited to global workflows so a cloned repo cannot silently inject orchestration scripts.
|
|
85
97
|
- `agent()` cannot spawn children of its own (the ecosystem recursion guard refuses nested orchestration). For dependent multi-stage work where stages spawn, use `@nicknisi/pi-codemode`'s `runWorkflow` instead.
|
|
86
98
|
- `stop` cancels only runs spawned by this host process; persisted runs from other hosts show in `status` but are not cancellable here.
|
package/dist/engine.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The workflow script engine — pi-free and testable.
|
|
3
3
|
*
|
|
4
4
|
* A workflow script is a JavaScript statement body with injected globals
|
|
5
|
-
* (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
|
|
5
|
+
* (args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask) and a leading
|
|
6
6
|
* `export const meta = { name, description }` declaration. It returns a value
|
|
7
7
|
* by evaluating a trailing expression or a top-level `return` (the body is
|
|
8
8
|
* wrapped in an async function so a bare `return` compiles).
|
|
@@ -59,6 +59,17 @@ export interface EngineBudget {
|
|
|
59
59
|
spent: number;
|
|
60
60
|
remaining: number;
|
|
61
61
|
}
|
|
62
|
+
export interface RunGate {
|
|
63
|
+
readonly paused: boolean;
|
|
64
|
+
pause(): void;
|
|
65
|
+
resume(): void;
|
|
66
|
+
/** Reject all current and future waiters (run stopped/timed out). */
|
|
67
|
+
abort(): void;
|
|
68
|
+
wait(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
export declare function createRunGate(): RunGate;
|
|
71
|
+
/** Script-global human question: select when options given, confirm otherwise. undefined = dismissed. */
|
|
72
|
+
export type EngineAskFn = (question: string, options?: string[]) => Promise<string | boolean | undefined>;
|
|
62
73
|
export interface ScriptMeta {
|
|
63
74
|
name?: string;
|
|
64
75
|
description?: string;
|
|
@@ -71,6 +82,19 @@ export interface RunScriptOptions {
|
|
|
71
82
|
cwd: string;
|
|
72
83
|
budgetTotal?: number;
|
|
73
84
|
onLog?: (line: string) => void;
|
|
85
|
+
/** Pause gate; agent() awaits it before every spawn. */
|
|
86
|
+
gate?: RunGate;
|
|
87
|
+
/**
|
|
88
|
+
* Host-side human gate for the `checkpoint(label?)` global. When omitted,
|
|
89
|
+
* checkpoint is a no-op that logs a skip note (pi-free hosts, tests).
|
|
90
|
+
*/
|
|
91
|
+
checkpoint?: (label?: string) => Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Host-side human question for the `ask(question, options?)` global. When
|
|
94
|
+
* omitted, ask throws — a script that needs an answer should fail loudly
|
|
95
|
+
* rather than invent one.
|
|
96
|
+
*/
|
|
97
|
+
ask?: EngineAskFn;
|
|
74
98
|
}
|
|
75
99
|
export interface RunScriptResult {
|
|
76
100
|
value: unknown;
|
|
@@ -81,7 +105,7 @@ export interface RunScriptResult {
|
|
|
81
105
|
}
|
|
82
106
|
export declare function runScript(opts: RunScriptOptions): Promise<RunScriptResult>;
|
|
83
107
|
/** The compiled async body; invoking it runs the script with injected globals. */
|
|
84
|
-
export type CompiledFn = (args: unknown, agent: unknown, parallel: unknown, pipeline: unknown, phase: unknown, log: unknown, budget: unknown, cwd: string) => Promise<unknown>;
|
|
108
|
+
export type CompiledFn = (args: unknown, agent: unknown, parallel: unknown, pipeline: unknown, phase: unknown, log: unknown, budget: unknown, cwd: string, checkpoint: unknown, ask: unknown) => Promise<unknown>;
|
|
85
109
|
export interface CompiledScript {
|
|
86
110
|
/** The script's `meta` export, read via a stub dry-run (no real spawn). */
|
|
87
111
|
meta: ScriptMeta | undefined;
|
package/dist/engine.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The workflow script engine — pi-free and testable.
|
|
3
3
|
*
|
|
4
4
|
* A workflow script is a JavaScript statement body with injected globals
|
|
5
|
-
* (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
|
|
5
|
+
* (args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask) and a leading
|
|
6
6
|
* `export const meta = { name, description }` declaration. It returns a value
|
|
7
7
|
* by evaluating a trailing expression or a top-level `return` (the body is
|
|
8
8
|
* wrapped in an async function so a bare `return` compiles).
|
|
@@ -17,6 +17,42 @@
|
|
|
17
17
|
* meta.name/description.
|
|
18
18
|
*/
|
|
19
19
|
import vm from 'node:vm';
|
|
20
|
+
export function createRunGate() {
|
|
21
|
+
let paused = false;
|
|
22
|
+
let aborted = false;
|
|
23
|
+
let waiters = [];
|
|
24
|
+
return {
|
|
25
|
+
get paused() {
|
|
26
|
+
return paused;
|
|
27
|
+
},
|
|
28
|
+
pause() {
|
|
29
|
+
paused = true;
|
|
30
|
+
},
|
|
31
|
+
resume() {
|
|
32
|
+
paused = false;
|
|
33
|
+
const w = waiters;
|
|
34
|
+
waiters = [];
|
|
35
|
+
for (const { resolve } of w)
|
|
36
|
+
resolve();
|
|
37
|
+
},
|
|
38
|
+
abort() {
|
|
39
|
+
aborted = true;
|
|
40
|
+
const w = waiters;
|
|
41
|
+
waiters = [];
|
|
42
|
+
for (const { reject } of w)
|
|
43
|
+
reject(new Error('run aborted while paused'));
|
|
44
|
+
},
|
|
45
|
+
wait() {
|
|
46
|
+
if (aborted)
|
|
47
|
+
return Promise.reject(new Error('run aborted while paused'));
|
|
48
|
+
if (!paused)
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
waiters.push({ resolve, reject });
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
20
56
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
21
57
|
const STRIP_META = /export\s+const\s+meta\s*=/;
|
|
22
58
|
const DEFAULT_TOOLS = ['read', 'grep', 'find', 'ls'];
|
|
@@ -84,6 +120,8 @@ export async function runScript(opts) {
|
|
|
84
120
|
if (typeof agentOpts.agentType === 'string') {
|
|
85
121
|
log(`(agentType '${agentOpts.agentType}' accepted but ignored — no agent-type registry)`);
|
|
86
122
|
}
|
|
123
|
+
// Pause gate: hold here (between steps) until resumed. Rejects on abort.
|
|
124
|
+
await opts.gate?.wait();
|
|
87
125
|
const spawnOpts = {
|
|
88
126
|
prompt,
|
|
89
127
|
...(typeof agentOpts.label === 'string' ? { agent: agentOpts.label } : {}),
|
|
@@ -108,6 +146,24 @@ export async function runScript(opts) {
|
|
|
108
146
|
return { value: res.data ?? res.text ?? null, patchPath: res.patchPath, runId: res.runId };
|
|
109
147
|
return res.data ?? res.text ?? null;
|
|
110
148
|
};
|
|
149
|
+
// checkpoint(label?): script-internal human gate. The host decides what
|
|
150
|
+
// "continue?" means (a confirm dialog in pi); default is a logged no-op.
|
|
151
|
+
const checkpoint = async (label) => {
|
|
152
|
+
if (!opts.checkpoint) {
|
|
153
|
+
log(`(checkpoint${label ? ` '${label}'` : ''} skipped — no host gate)`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
log(`⏸ checkpoint${label ? `: ${label}` : ''}`);
|
|
157
|
+
await opts.checkpoint(label);
|
|
158
|
+
};
|
|
159
|
+
// ask(question, options?) — human answer inside a run. select with options,
|
|
160
|
+
// confirm without. Default throws: never invent an answer.
|
|
161
|
+
const ask = async (question, options) => {
|
|
162
|
+
if (!opts.ask)
|
|
163
|
+
throw new Error('ask() unavailable in this host');
|
|
164
|
+
log(`? ${question}`);
|
|
165
|
+
return opts.ask(question, options);
|
|
166
|
+
};
|
|
111
167
|
const parallel = (thunks) => Promise.all(thunks.map((t) => t()));
|
|
112
168
|
// pipeline(items, ...stages): each stage maps over the previous stage's
|
|
113
169
|
// outputs in parallel, producing the next array. A fold over Promise.all.
|
|
@@ -121,7 +177,7 @@ export async function runScript(opts) {
|
|
|
121
177
|
// Compile + run. compileScript is extracted so callers (tests, future
|
|
122
178
|
// tooling) can compile + read `meta` without a real spawn.
|
|
123
179
|
const compiled = compileScript(script);
|
|
124
|
-
const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd);
|
|
180
|
+
const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask);
|
|
125
181
|
return { value, meta: compiled.meta, logs, usage, durationMs: Date.now() - startedAt };
|
|
126
182
|
}
|
|
127
183
|
/**
|
|
@@ -138,11 +194,11 @@ export async function runScript(opts) {
|
|
|
138
194
|
export function compileScript(script) {
|
|
139
195
|
const metaHolder = { value: undefined };
|
|
140
196
|
const stripped = script.replace(STRIP_META, 'const meta = metaHolder.value =');
|
|
141
|
-
const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder){\n${stripped}\n})`;
|
|
197
|
+
const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask, metaHolder){\n${stripped}\n})`;
|
|
142
198
|
const raw = new vm.Script(wrapped, { filename: 'workflow.js' }).runInThisContext();
|
|
143
|
-
// Bind metaHolder so callers invoke
|
|
199
|
+
// Bind metaHolder so callers invoke a 10-arg fn; the holder rides the call
|
|
144
200
|
// (runInThisContext cannot see a closure variable, so it must be a parameter).
|
|
145
|
-
const fn = (args, agent, parallel, pipeline, phase, log, budget, cwd) => raw(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder);
|
|
201
|
+
const fn = (args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask) => raw(args, agent, parallel, pipeline, phase, log, budget, cwd, checkpoint, ask, metaHolder);
|
|
146
202
|
// Stub dry-run to read `meta`. Well-formed scripts declare `meta` first, so
|
|
147
203
|
// it is assigned synchronously before the first `await`; we await the whole
|
|
148
204
|
// stubbed body anyway so a script that computes meta from `args` works too.
|
|
@@ -156,7 +212,9 @@ export function compileScript(script) {
|
|
|
156
212
|
return values;
|
|
157
213
|
};
|
|
158
214
|
const stubBudget = { total: Infinity, spent: 0, remaining: Infinity };
|
|
159
|
-
|
|
215
|
+
const stubCheckpoint = async () => { };
|
|
216
|
+
const stubAsk = async () => undefined;
|
|
217
|
+
void fn(undefined, stubAgent, stubParallel, stubPipeline, () => { }, () => { }, stubBudget, '/tmp', stubCheckpoint, stubAsk).catch(() => { });
|
|
160
218
|
// `meta` is a getter so a caller that re-runs `fn` with real globals sees
|
|
161
219
|
// the post-run meta (the stub dry-run may have thrown on args-derived meta;
|
|
162
220
|
// the real run sets it). For static meta the stub already populated it.
|
package/dist/examples.test.js
CHANGED
|
@@ -18,8 +18,15 @@ const examples = fs
|
|
|
18
18
|
.filter((f) => f.endsWith('.js'))
|
|
19
19
|
.map((f) => path.join(examplesDir, f));
|
|
20
20
|
describe('examples smoke: compile + meta', () => {
|
|
21
|
-
it('discovers exactly the
|
|
22
|
-
expect(examples.map((e) => path.basename(e)).sort()).toEqual([
|
|
21
|
+
it('discovers exactly the six example files', () => {
|
|
22
|
+
expect(examples.map((e) => path.basename(e)).sort()).toEqual([
|
|
23
|
+
'autoimplement.js',
|
|
24
|
+
'autoplan.js',
|
|
25
|
+
'bake-off.js',
|
|
26
|
+
'gates.js',
|
|
27
|
+
'lanes.js',
|
|
28
|
+
'sanity-check.js',
|
|
29
|
+
]);
|
|
23
30
|
});
|
|
24
31
|
for (const file of examples) {
|
|
25
32
|
const name = path.basename(file);
|
package/dist/index.js
CHANGED
|
@@ -29,13 +29,72 @@ import * as path from 'node:path';
|
|
|
29
29
|
import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
30
30
|
import { createSubagentRuntime, readRunArtifacts, sweepRunArtifactsOnce, } from '@nicknisi/pi-shared';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
|
-
import { runScript, } from './engine.js';
|
|
32
|
+
import { createRunGate, runScript, } from './engine.js';
|
|
33
33
|
const ARTIFACTS_ROOT = path.join(getAgentDir(), 'subagent-runs');
|
|
34
34
|
const NAMESPACE = 'workflows';
|
|
35
35
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
36
|
const MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
37
37
|
const MAX_RESULT_CHARS = 16 * 1024;
|
|
38
38
|
const MAX_LOG_CHARS = 2000;
|
|
39
|
+
function pauseAll(activeRuns) {
|
|
40
|
+
for (const run of activeRuns) {
|
|
41
|
+
run.gate.pause();
|
|
42
|
+
run.refresh();
|
|
43
|
+
}
|
|
44
|
+
return activeRuns.size;
|
|
45
|
+
}
|
|
46
|
+
function resumeAll(activeRuns) {
|
|
47
|
+
for (const run of activeRuns) {
|
|
48
|
+
run.gate.resume();
|
|
49
|
+
run.refresh();
|
|
50
|
+
}
|
|
51
|
+
return activeRuns.size;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Per-run scaffolding: gate (pause/resume/abort), footer status that tracks
|
|
55
|
+
* phase() markers, and the checkpoint/ask host functions backed by pi's UI.
|
|
56
|
+
* checkpoint is a confirm dialog — aborting it rejects so a dismissed gate
|
|
57
|
+
* stops the run instead of silently continuing.
|
|
58
|
+
*/
|
|
59
|
+
function makeRunControls(label, ui, activeRuns) {
|
|
60
|
+
const gate = createRunGate();
|
|
61
|
+
let lastPhase = '';
|
|
62
|
+
const run = {
|
|
63
|
+
label,
|
|
64
|
+
gate,
|
|
65
|
+
refresh: () => {
|
|
66
|
+
const state = gate.paused ? 'paused' : 'running';
|
|
67
|
+
ui.setStatus('workflows', `wf ${label} [${state}]${lastPhase ? ` ${lastPhase}` : ''}`);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const checkpoint = async (checkpointLabel) => {
|
|
71
|
+
const ok = await ui.confirm('Workflow checkpoint', checkpointLabel ? `${checkpointLabel} — continue?` : 'Continue?');
|
|
72
|
+
if (!ok)
|
|
73
|
+
throw new Error(`checkpoint${checkpointLabel ? ` '${checkpointLabel}'` : ''} rejected`);
|
|
74
|
+
};
|
|
75
|
+
const ask = async (question, options) => {
|
|
76
|
+
if (options && options.length > 0)
|
|
77
|
+
return ui.select(question, options);
|
|
78
|
+
return ui.confirm('Workflow', question);
|
|
79
|
+
};
|
|
80
|
+
activeRuns.add(run);
|
|
81
|
+
run.refresh();
|
|
82
|
+
return {
|
|
83
|
+
gate,
|
|
84
|
+
checkpoint,
|
|
85
|
+
ask,
|
|
86
|
+
onLog: (line) => {
|
|
87
|
+
if (line.startsWith('── ')) {
|
|
88
|
+
lastPhase = line.slice(3);
|
|
89
|
+
run.refresh();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
dispose: () => {
|
|
93
|
+
activeRuns.delete(run);
|
|
94
|
+
ui.setStatus('workflows', undefined);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
39
98
|
function spawnCancellable(cancellables, runtime, opts, externalSignal) {
|
|
40
99
|
const controller = new AbortController();
|
|
41
100
|
const onExternalAbort = () => controller.abort();
|
|
@@ -195,6 +254,7 @@ function formatRunResult(result, label) {
|
|
|
195
254
|
// ── Extension ─────────────────────────────────────────────────────────────
|
|
196
255
|
export default function workflows(pi) {
|
|
197
256
|
const cancellables = new Map();
|
|
257
|
+
const activeRuns = new Set();
|
|
198
258
|
const runtime = createSubagentRuntime({ namespace: NAMESPACE, artifactsDir: ARTIFACTS_ROOT });
|
|
199
259
|
sweepRunArtifactsOnce(ARTIFACTS_ROOT);
|
|
200
260
|
pi.registerTool({
|
|
@@ -204,14 +264,21 @@ export default function workflows(pi) {
|
|
|
204
264
|
'Run a JavaScript workflow script that orchestrates subagents over the first-party runtime,',
|
|
205
265
|
"or manage runs. Actions: 'run' (compile a script in a vm and execute it with injected",
|
|
206
266
|
'globals), "list" (saved workflow files), "status" (a run record by runId), "stop" (cancel a',
|
|
207
|
-
|
|
267
|
+
'run by runId), "pause"/"resume" (hold active runs before their next agent step, then',
|
|
268
|
+
"continue them). For 'run', pass EITHER `script` (inline JS) OR `name` (a saved workflow file",
|
|
208
269
|
'stem from ~/.pi/agent/workflows/*.js or .pi/workflows/*.js). Optional `args` (any JSON value)',
|
|
209
270
|
"is passed in as the script's `args` global.",
|
|
210
271
|
'',
|
|
211
272
|
'Script contract — injected globals: agent(prompt, opts), parallel(thunks),',
|
|
212
273
|
'pipeline(items, ...stages), phase(name), log(...args), args, budget ({total, spent,',
|
|
213
|
-
"remaining}), cwd. The script's FIRST statement
|
|
214
|
-
'description }` (rewritten so the vm compiles;
|
|
274
|
+
"remaining}), cwd, checkpoint(label?), ask(question, options?). The script's FIRST statement",
|
|
275
|
+
'SHOULD be `export const meta = { name, description }` (rewritten so the vm compiles;',
|
|
276
|
+
'meta.name/description surface in the result).',
|
|
277
|
+
'',
|
|
278
|
+
'checkpoint(label?) gates the run on a human confirm (dismiss/reject throws and stops the',
|
|
279
|
+
'run); ask(question, options?) asks the human mid-run — select when options are given,',
|
|
280
|
+
'yes/no confirm otherwise, undefined when dismissed. Use them before destructive or',
|
|
281
|
+
'expensive steps.',
|
|
215
282
|
'The script returns a value via a trailing expression or a top-level `return` (the body is',
|
|
216
283
|
'wrapped in an async function).',
|
|
217
284
|
'',
|
|
@@ -237,9 +304,14 @@ export default function workflows(pi) {
|
|
|
237
304
|
'Keep the returned value small — summaries, counts, key findings — never raw file contents.',
|
|
238
305
|
],
|
|
239
306
|
parameters: Type.Object({
|
|
240
|
-
action: Type.Union([
|
|
241
|
-
|
|
242
|
-
|
|
307
|
+
action: Type.Union([
|
|
308
|
+
Type.Literal('run'),
|
|
309
|
+
Type.Literal('list'),
|
|
310
|
+
Type.Literal('status'),
|
|
311
|
+
Type.Literal('stop'),
|
|
312
|
+
Type.Literal('pause'),
|
|
313
|
+
Type.Literal('resume'),
|
|
314
|
+
], { description: 'Action: run | list | status | stop | pause | resume' }),
|
|
243
315
|
script: Type.Optional(Type.String({ description: 'Inline JS workflow script (action: run).' })),
|
|
244
316
|
name: Type.Optional(Type.String({ description: 'Saved workflow file stem (action: run).' })),
|
|
245
317
|
args: Type.Optional(Type.Any({ description: "Any JSON value passed as the script's `args` global (action: run)." })),
|
|
@@ -287,6 +359,13 @@ export default function workflows(pi) {
|
|
|
287
359
|
details: { runId: record.runId, status: record.status },
|
|
288
360
|
};
|
|
289
361
|
}
|
|
362
|
+
if (action === 'pause' || action === 'resume') {
|
|
363
|
+
const n = action === 'pause' ? pauseAll(activeRuns) : resumeAll(activeRuns);
|
|
364
|
+
const text = n === 0
|
|
365
|
+
? 'No active workflow run in this session.'
|
|
366
|
+
: `${action === 'pause' ? 'Paused' : 'Resumed'} ${n} active run${n === 1 ? '' : 's'}.`;
|
|
367
|
+
return { content: [{ type: 'text', text }], details: { affected: n } };
|
|
368
|
+
}
|
|
290
369
|
if (action === 'stop') {
|
|
291
370
|
const runId = params.runId;
|
|
292
371
|
if (!runId) {
|
|
@@ -369,6 +448,10 @@ export default function workflows(pi) {
|
|
|
369
448
|
controller.abort();
|
|
370
449
|
}, timeoutMs);
|
|
371
450
|
const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.sessionManager.getSessionFile(), controller.signal);
|
|
451
|
+
const controls = makeRunControls(label, ctx.ui, activeRuns);
|
|
452
|
+
// A parked run (gate.wait / checkpoint) must not leak when the tool is
|
|
453
|
+
// aborted or times out — aborting the gate rejects its waiters.
|
|
454
|
+
controller.signal.addEventListener('abort', () => controls.gate.abort(), { once: true });
|
|
372
455
|
const timeoutPromise = new Promise((_, reject) => {
|
|
373
456
|
controller.signal.addEventListener('abort', () => reject(new Error(timedOut
|
|
374
457
|
? `Timed out after ${timeoutMs}ms (in-flight subagents were aborted)`
|
|
@@ -381,7 +464,10 @@ export default function workflows(pi) {
|
|
|
381
464
|
args: params.args,
|
|
382
465
|
spawn: spawnFn,
|
|
383
466
|
cwd: ctx.cwd,
|
|
384
|
-
onLog:
|
|
467
|
+
onLog: controls.onLog,
|
|
468
|
+
gate: controls.gate,
|
|
469
|
+
checkpoint: controls.checkpoint,
|
|
470
|
+
ask: controls.ask,
|
|
385
471
|
}),
|
|
386
472
|
timeoutPromise,
|
|
387
473
|
]);
|
|
@@ -407,28 +493,29 @@ export default function workflows(pi) {
|
|
|
407
493
|
finally {
|
|
408
494
|
clearTimeout(timer);
|
|
409
495
|
signal?.removeEventListener('abort', onToolAbort);
|
|
496
|
+
controls.dispose();
|
|
410
497
|
}
|
|
411
498
|
},
|
|
412
499
|
});
|
|
413
500
|
// ── /wf — thin human-facing wrapper ───────────────────────────────────
|
|
414
501
|
pi.registerCommand('wf', {
|
|
415
|
-
description: 'Workflows: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId
|
|
502
|
+
description: 'Workflows: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId> | /wf pause | /wf resume. Saved workflows live in ~/.pi/agent/workflows/*.js (global) and .pi/workflows/*.js (project, trusted only).',
|
|
416
503
|
getArgumentCompletions: (argumentPrefix) => {
|
|
417
504
|
if (argumentPrefix.includes(' '))
|
|
418
505
|
return null;
|
|
419
506
|
const prefix = argumentPrefix.trim();
|
|
420
|
-
const subs = ['list', 'run', 'status', 'stop'].filter((s) => s.startsWith(prefix));
|
|
507
|
+
const subs = ['list', 'run', 'status', 'stop', 'pause', 'resume'].filter((s) => s.startsWith(prefix));
|
|
421
508
|
if (subs.length === 0)
|
|
422
509
|
return null;
|
|
423
510
|
return subs.map((s) => ({ value: s + ' ', label: s }));
|
|
424
511
|
},
|
|
425
512
|
handler: async (args, ctx) => {
|
|
426
|
-
await cmdWf(args, ctx, runtime, cancellables);
|
|
513
|
+
await cmdWf(args, ctx, runtime, cancellables, activeRuns);
|
|
427
514
|
},
|
|
428
515
|
});
|
|
429
516
|
}
|
|
430
517
|
// ── /wf handler (shared, typed against the runtime) ───────────────────────
|
|
431
|
-
async function cmdWf(args, ctx, runtime, cancellables) {
|
|
518
|
+
async function cmdWf(args, ctx, runtime, cancellables, activeRuns) {
|
|
432
519
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
433
520
|
const sub = parts[0];
|
|
434
521
|
if (!sub || sub === 'list') {
|
|
@@ -471,20 +558,42 @@ async function cmdWf(args, ctx, runtime, cancellables) {
|
|
|
471
558
|
parsedArgs = argsJson;
|
|
472
559
|
}
|
|
473
560
|
}
|
|
474
|
-
ctx.ui
|
|
561
|
+
const controls = makeRunControls(name, ctx.ui, activeRuns);
|
|
562
|
+
if (ctx.signal) {
|
|
563
|
+
if (ctx.signal.aborted)
|
|
564
|
+
controls.gate.abort();
|
|
565
|
+
else
|
|
566
|
+
ctx.signal.addEventListener('abort', () => controls.gate.abort(), { once: true });
|
|
567
|
+
}
|
|
475
568
|
try {
|
|
476
569
|
const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.sessionManager.getSessionFile(), ctx.signal ?? undefined);
|
|
477
|
-
const result = await runScript({
|
|
570
|
+
const result = await runScript({
|
|
571
|
+
script: src,
|
|
572
|
+
args: parsedArgs,
|
|
573
|
+
spawn: spawnFn,
|
|
574
|
+
cwd: ctx.cwd,
|
|
575
|
+
onLog: controls.onLog,
|
|
576
|
+
gate: controls.gate,
|
|
577
|
+
checkpoint: controls.checkpoint,
|
|
578
|
+
ask: controls.ask,
|
|
579
|
+
});
|
|
478
580
|
ctx.ui.notify(formatRunResult(result, name), 'info');
|
|
479
581
|
}
|
|
480
582
|
catch (err) {
|
|
481
583
|
ctx.ui.notify(`${name} failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
|
|
482
584
|
}
|
|
483
585
|
finally {
|
|
484
|
-
|
|
586
|
+
controls.dispose();
|
|
485
587
|
}
|
|
486
588
|
return;
|
|
487
589
|
}
|
|
590
|
+
if (sub === 'pause' || sub === 'resume') {
|
|
591
|
+
const n = sub === 'pause' ? pauseAll(activeRuns) : resumeAll(activeRuns);
|
|
592
|
+
ctx.ui.notify(n === 0
|
|
593
|
+
? 'No active workflow run in this session.'
|
|
594
|
+
: `${sub === 'pause' ? 'Paused' : 'Resumed'} ${n} run${n === 1 ? '' : 's'}.`, n === 0 ? 'warning' : 'info');
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
488
597
|
if (sub === 'status') {
|
|
489
598
|
const runId = parts[1];
|
|
490
599
|
if (!runId) {
|
|
@@ -517,7 +626,7 @@ async function cmdWf(args, ctx, runtime, cancellables) {
|
|
|
517
626
|
ctx.ui.notify(`Cancelled run ${runId.slice(0, 8)}`, 'info');
|
|
518
627
|
return;
|
|
519
628
|
}
|
|
520
|
-
ctx.ui.notify('Usage: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId>', 'warning');
|
|
629
|
+
ctx.ui.notify('Usage: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId> | /wf pause | /wf resume', 'warning');
|
|
521
630
|
}
|
|
522
631
|
// ── Run lookup / cancellation resolution ──────────────────────────────────
|
|
523
632
|
/** Find a run by full id or unique 8-char prefix across live + persisted records. */
|
package/dist/workflow.test.js
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from 'node:fs';
|
|
|
11
11
|
import * as os from 'node:os';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
-
import { runScript } from './engine.js';
|
|
14
|
+
import { createRunGate, runScript } from './engine.js';
|
|
15
15
|
const tmpdirs = [];
|
|
16
16
|
function tmpRoot() {
|
|
17
17
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-workflows-test-'));
|
|
@@ -221,6 +221,94 @@ describe('engine: agent opts + failures', () => {
|
|
|
221
221
|
expect(r.value).toBe('PASS');
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
|
+
describe('engine: gate (pause/resume)', () => {
|
|
225
|
+
const tick = () => new Promise((r) => setTimeout(r, 10));
|
|
226
|
+
it('a paused gate holds agent() before the spawn until resumed', async () => {
|
|
227
|
+
const gate = createRunGate();
|
|
228
|
+
let spawns = 0;
|
|
229
|
+
const spawn = async () => {
|
|
230
|
+
spawns++;
|
|
231
|
+
return { ok: true, text: 'ok', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } };
|
|
232
|
+
};
|
|
233
|
+
gate.pause();
|
|
234
|
+
const run = runScript({ script: 'await agent("x"); return "done";', spawn, cwd: '/tmp', gate });
|
|
235
|
+
await tick();
|
|
236
|
+
expect(spawns).toBe(0); // held at the gate, spawn never fired
|
|
237
|
+
gate.resume();
|
|
238
|
+
const r = await run;
|
|
239
|
+
expect(spawns).toBe(1);
|
|
240
|
+
expect(r.value).toBe('done');
|
|
241
|
+
});
|
|
242
|
+
it('abort() rejects a parked run instead of hanging it', async () => {
|
|
243
|
+
const gate = createRunGate();
|
|
244
|
+
gate.pause();
|
|
245
|
+
const run = runScript({ script: 'await agent("x"); return "done";', spawn: fakeSpawn(), cwd: '/tmp', gate });
|
|
246
|
+
gate.abort();
|
|
247
|
+
await expect(run).rejects.toThrow('aborted');
|
|
248
|
+
});
|
|
249
|
+
it('abort() rejects future waits too (no re-arm after stop)', async () => {
|
|
250
|
+
const gate = createRunGate();
|
|
251
|
+
gate.abort();
|
|
252
|
+
await expect(gate.wait()).rejects.toThrow('aborted');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
describe('engine: checkpoint + ask', () => {
|
|
256
|
+
it('checkpoint without a host is a logged no-op', async () => {
|
|
257
|
+
const r = await runScript({
|
|
258
|
+
script: 'await checkpoint("review"); return "ok";',
|
|
259
|
+
spawn: fakeSpawn(),
|
|
260
|
+
cwd: '/tmp',
|
|
261
|
+
});
|
|
262
|
+
expect(r.value).toBe('ok');
|
|
263
|
+
expect(r.logs.some((l) => l.includes("checkpoint 'review' skipped"))).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
it('checkpoint with a host is invoked with its label and gates the run', async () => {
|
|
266
|
+
const calls = [];
|
|
267
|
+
const r = await runScript({
|
|
268
|
+
script: 'await checkpoint(); await checkpoint("ship?"); return "ok";',
|
|
269
|
+
spawn: fakeSpawn(),
|
|
270
|
+
cwd: '/tmp',
|
|
271
|
+
checkpoint: async (label) => {
|
|
272
|
+
calls.push(label);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
expect(r.value).toBe('ok');
|
|
276
|
+
expect(calls).toEqual([undefined, 'ship?']);
|
|
277
|
+
});
|
|
278
|
+
it('a throwing checkpoint host stops the run (rejected gate = stop)', async () => {
|
|
279
|
+
await expect(runScript({
|
|
280
|
+
script: 'await checkpoint("review"); return "unreachable";',
|
|
281
|
+
spawn: fakeSpawn(),
|
|
282
|
+
cwd: '/tmp',
|
|
283
|
+
checkpoint: async () => {
|
|
284
|
+
throw new Error('rejected');
|
|
285
|
+
},
|
|
286
|
+
})).rejects.toThrow('rejected');
|
|
287
|
+
});
|
|
288
|
+
it('ask without a host throws (never invent an answer)', async () => {
|
|
289
|
+
const r = await runScript({
|
|
290
|
+
script: 'try { await ask("continue?"); } catch (e) { return e.message; }',
|
|
291
|
+
spawn: fakeSpawn(),
|
|
292
|
+
cwd: '/tmp',
|
|
293
|
+
});
|
|
294
|
+
expect(r.value).toBe('ask() unavailable in this host');
|
|
295
|
+
});
|
|
296
|
+
it('ask passes question + options to the host and returns the answer', async () => {
|
|
297
|
+
const seen = [];
|
|
298
|
+
const r = await runScript({
|
|
299
|
+
script: 'const a = await ask("pick", ["a", "b"]); const b = await ask("ok?"); return { a, b };',
|
|
300
|
+
spawn: fakeSpawn(),
|
|
301
|
+
cwd: '/tmp',
|
|
302
|
+
ask: async (q, opts) => {
|
|
303
|
+
seen.push({ q, ...(opts ? { opts } : {}) });
|
|
304
|
+
return opts ? 'b' : true;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
expect(r.value).toEqual({ a: 'b', b: true });
|
|
308
|
+
expect(seen).toEqual([{ q: 'pick', opts: ['a', 'b'] }, { q: 'ok?' }]);
|
|
309
|
+
expect(r.logs.some((l) => l === '? pick')).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
224
312
|
// ── Saved-workflow discovery ───────────────────────────────────────────────
|
|
225
313
|
describe('saved-workflow discovery', () => {
|
|
226
314
|
let prevAgentDir;
|