@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 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 only NOT a budget boundary. Appends `── name` to the result logs. |
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 an 8-arg fn; the holder rides the call
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
- void fn(undefined, stubAgent, stubParallel, stubPipeline, () => { }, () => { }, stubBudget, '/tmp').catch(() => { });
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.
@@ -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 three example files', () => {
22
- expect(examples.map((e) => path.basename(e)).sort()).toEqual(['bake-off.js', 'gates.js', 'lanes.js']);
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
- "run by runId). For 'run', pass EITHER `script` (inline JS) OR `name` (a saved workflow file",
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 SHOULD be `export const meta = { name,",
214
- 'description }` (rewritten so the vm compiles; meta.name/description surface in the result).',
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([Type.Literal('run'), Type.Literal('list'), Type.Literal('status'), Type.Literal('stop')], {
241
- description: 'Action: run | list | status | stop',
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>. Saved workflows live in ~/.pi/agent/workflows/*.js (global) and .pi/workflows/*.js (project, trusted only).',
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.setStatus('workflows', `running ${name}…`);
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({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
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
- ctx.ui.setStatus('workflows', undefined);
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. */
@@ -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;