@nicknisi/pi-workflows 0.1.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/index.ts ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * @nicknisi/pi-workflows — the model-facing front door to the first-party
3
+ * workflow engine.
4
+ *
5
+ * One `workflow` tool with actions: run (inline JS script OR a saved workflow
6
+ * file name), list, status (runId), stop (runId). `run` compiles the script in
7
+ * a node:vm context exactly like codemode compiles its snippets — `export const
8
+ * meta =` is rewritten so the vm compiles, the body is wrapped in an async
9
+ * function so a top-level `return` works, and meta.name/description surface in
10
+ * the result. Injected globals: agent, parallel, pipeline, phase, log, args,
11
+ * budget, cwd — the contract the old third-party tool's scripts were written
12
+ * against, so existing scripts run unchanged.
13
+ *
14
+ * Saved workflows are plain files: ~/.pi/agent/workflows/*.js (global) and
15
+ * .pi/workflows/*.js (project, trusted-only). The registry is `ls` — no
16
+ * database, no manifest.
17
+ *
18
+ * Runs are visible: agent() spawns through @nicknisi/pi-shared's subagent
19
+ * runtime (namespace 'workflows'), so child spawns appear in the fleet radar
20
+ * from @nicknisi/pi-subagents. status/stop read from / cancel via the same
21
+ * runtime's run records — no parallel store.
22
+ *
23
+ * The platform story: subagents runtime + codemode VM + shared/workflow.ts
24
+ * engine + this tool = the workflow platform; the third-party
25
+ * @quintinshaw/pi-dynamic-workflows engine is being evicted.
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
31
+ import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent';
32
+ import {
33
+ createSubagentRuntime,
34
+ readRunArtifacts,
35
+ sweepRunArtifactsOnce,
36
+ type RunArtifact,
37
+ type SpawnOptions,
38
+ type SpawnResult,
39
+ type SubagentRuntime,
40
+ } from '@nicknisi/pi-shared';
41
+ import { Type } from 'typebox';
42
+ import {
43
+ runScript,
44
+ type EngineSpawnFn,
45
+ type EngineSpawnOptions,
46
+ type EngineSpawnResult,
47
+ type RunScriptResult,
48
+ } from './engine.js';
49
+
50
+ const ARTIFACTS_ROOT = path.join(getAgentDir(), 'subagent-runs');
51
+ const NAMESPACE = 'workflows';
52
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
53
+ const MAX_TIMEOUT_MS = 30 * 60 * 1000;
54
+ const MAX_RESULT_CHARS = 16 * 1024;
55
+ const MAX_LOG_CHARS = 2000;
56
+
57
+ // ── Live runId → AbortController, so `stop` can cancel in-flight spawns. ──
58
+ // Mirrors @nicknisi/pi-subagents' cascading-cancellation registry. Each
59
+ // agent() call spawns detached and registers its controller here; stop(runId)
60
+ // aborts it. Runs from other hosts show up via readRunArtifacts but are not
61
+ // cancellable here (they belong to a different process).
62
+ // Scoped per factory invocation (session) — two concurrent sessions in one
63
+ // process must never share (or cross-abort) each other's runs.
64
+ type Cancellables = Map<string, AbortController>;
65
+
66
+ function spawnCancellable(
67
+ cancellables: Cancellables,
68
+ runtime: SubagentRuntime,
69
+ opts: SpawnOptions,
70
+ externalSignal: AbortSignal | undefined,
71
+ ): Promise<SpawnResult> {
72
+ const controller = new AbortController();
73
+ const onExternalAbort = () => controller.abort();
74
+ if (externalSignal) {
75
+ if (externalSignal.aborted) controller.abort();
76
+ else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
77
+ }
78
+ const { runId, done } = runtime.spawnDetached({ ...opts, signal: controller.signal });
79
+ cancellables.set(runId, controller);
80
+ void done.finally(() => {
81
+ cancellables.delete(runId);
82
+ externalSignal?.removeEventListener('abort', onExternalAbort);
83
+ });
84
+ return Object.assign(done, { runId }) as Promise<SpawnResult>;
85
+ }
86
+
87
+ function makeSpawnFn(
88
+ cancellables: Cancellables,
89
+ runtime: SubagentRuntime,
90
+ cwd: string,
91
+ externalSignal: AbortSignal | undefined,
92
+ ): EngineSpawnFn {
93
+ return async (opts: EngineSpawnOptions): Promise<EngineSpawnResult> => {
94
+ const spawnOpts: SpawnOptions = {
95
+ prompt: opts.prompt,
96
+ cwd,
97
+ ...(opts.agent !== undefined ? { agent: opts.agent } : {}),
98
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
99
+ ...(opts.systemPrompt !== undefined ? { systemPrompt: opts.systemPrompt } : {}),
100
+ ...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
101
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
102
+ ...(opts.maxTurns !== undefined ? { maxTurns: opts.maxTurns } : {}),
103
+ ...(opts.outputSchema !== undefined ? { outputSchema: opts.outputSchema as never } : {}),
104
+ ...(opts.worktree === true ? { worktree: true } : {}),
105
+ };
106
+ // Default children to read-only, matching codemode/dispatch; pi's own
107
+ // default (read/bash/edit/write) would apply otherwise.
108
+ if (opts.tools !== undefined) spawnOpts.tools = opts.tools;
109
+ else spawnOpts.tools = ['read', 'grep', 'find', 'ls'];
110
+ const res = await spawnCancellable(cancellables, runtime, spawnOpts, externalSignal);
111
+ // Surface the worktree `.patch` path (recorded on the run record after
112
+ // settle) so workflow scripts can return it for the `/patches` apply flow.
113
+ if (res.ok) {
114
+ const record = runtime.listRuns().find((r) => r.runId === res.runId);
115
+ const patchPath = record?.worktree?.patchPath;
116
+ if (patchPath) {
117
+ return { ok: true, text: res.text, data: res.data, usage: res.usage, runId: res.runId, patchPath };
118
+ }
119
+ }
120
+ return res;
121
+ };
122
+ }
123
+
124
+ // ── Saved-workflow discovery ──────────────────────────────────────────────
125
+ // Directory convention ONLY — no registry, no index, no config keys. The
126
+ // registry is `ls`; the package manager is git; the search engine is grep.
127
+
128
+ function workflowDirs(cwd: string, trusted: boolean): string[] {
129
+ const dirs = [path.join(getAgentDir(), 'workflows')];
130
+ if (trusted) dirs.push(path.join(cwd, CONFIG_DIR_NAME, 'workflows'));
131
+ return dirs;
132
+ }
133
+
134
+ /** Bare file stems only — never a path (defends against `../` escaping). */
135
+ function isValidName(name: string): boolean {
136
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name);
137
+ }
138
+
139
+ export function findWorkflowFile(name: string, cwd: string, trusted: boolean): string | undefined {
140
+ if (!isValidName(name)) return undefined;
141
+ for (const dir of workflowDirs(cwd, trusted)) {
142
+ const f = path.join(dir, `${name}.js`);
143
+ try {
144
+ if (fs.statSync(f).isFile()) return f;
145
+ } catch {
146
+ // not present — try next dir
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ export interface SavedWorkflow {
153
+ name: string;
154
+ scope: 'global' | 'project';
155
+ description?: string;
156
+ }
157
+
158
+ export function listWorkflows(cwd: string, trusted: boolean): SavedWorkflow[] {
159
+ const out: SavedWorkflow[] = [];
160
+ const seen = new Set<string>();
161
+ const dirs = workflowDirs(cwd, trusted);
162
+ const scopes: Array<'global' | 'project'> = ['global', 'project'];
163
+ for (let i = 0; i < dirs.length; i++) {
164
+ const dir = dirs[i]!;
165
+ const scope = scopes[i]!;
166
+ let files: string[] = [];
167
+ try {
168
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
169
+ } catch {
170
+ continue;
171
+ }
172
+ for (const f of files) {
173
+ const name = f.slice(0, -3);
174
+ if (seen.has(name)) continue;
175
+ seen.add(name);
176
+ const item: SavedWorkflow = { name, scope };
177
+ try {
178
+ const src = fs.readFileSync(path.join(dir, f), 'utf8');
179
+ const m = /\{\s*name:\s*['"`]([^'"`]+)['"`][^}]*description:\s*['"`]([^'"`]+)['"`]/.exec(src);
180
+ if (m) item.description = m[2]!;
181
+ } catch {
182
+ // best-effort description
183
+ }
184
+ out.push(item);
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ // ── Formatting helpers ────────────────────────────────────────────────────
191
+
192
+ function truncate(text: string, max: number): string {
193
+ return text.length <= max ? text : `${text.slice(0, max)}\n…[truncated at ${max} chars]`;
194
+ }
195
+
196
+ function formatValue(value: unknown): string {
197
+ if (typeof value === 'string') return truncate(value, MAX_RESULT_CHARS);
198
+ if (value === undefined) return '(undefined — did the script forget to return a value?)';
199
+ try {
200
+ return truncate(JSON.stringify(value, null, 2) ?? String(value), MAX_RESULT_CHARS);
201
+ } catch {
202
+ return truncate(String(value), MAX_RESULT_CHARS);
203
+ }
204
+ }
205
+
206
+ function formatRun(run: RunArtifact): string {
207
+ const id = run.runId.slice(0, 8);
208
+ const ns = run.namespace;
209
+ const status = run.status;
210
+ const agent = run.agent ? ` ${run.agent}` : '';
211
+ const model = run.model ? ` ${run.model}` : '';
212
+ const preview = (run.promptPreview ?? '').replace(/\s+/g, ' ').trim();
213
+ const tokens = run.usage ? ` · ${run.usage.totalTokens} tok` : '';
214
+ const dur = run.endedAt ? ` · ${Math.round((run.endedAt - run.startedAt) / 1000)}s` : '';
215
+ let line = `${id} [${ns}]${agent}${model} — ${status}${tokens}${dur}`;
216
+ if (run.error) line += `\n error: ${truncate(run.error, 200)}`;
217
+ if (preview) line += `\n ${truncate(preview, 120)}`;
218
+ return line;
219
+ }
220
+
221
+ function formatRunResult(result: RunScriptResult, label: string): string {
222
+ const header = `✓ ${label}${result.meta?.name ? ` — ${result.meta.name}` : ''} (${Math.round(result.durationMs / 1000)}s)`;
223
+ const desc = result.meta?.description ? `\n${result.meta.description}` : '';
224
+ const budget = result.usage.totalTokens > 0 ? `\nbudget: spent ${result.usage.totalTokens} tok` : '';
225
+ const body = `\n${formatValue(result.value)}`;
226
+ const logsBlock =
227
+ result.logs.length > 0
228
+ ? `\n\nlogs (${result.logs.length}):\n${result.logs.map((l) => ` ${truncate(l, MAX_LOG_CHARS)}`).join('\n')}`
229
+ : '';
230
+ return `${header}${desc}${budget}${body}${logsBlock}`;
231
+ }
232
+
233
+ // ── Extension ─────────────────────────────────────────────────────────────
234
+
235
+ export default function workflows(pi: ExtensionAPI): void {
236
+ const cancellables: Cancellables = new Map();
237
+ const runtime = createSubagentRuntime({ namespace: NAMESPACE, artifactsDir: ARTIFACTS_ROOT });
238
+ sweepRunArtifactsOnce(ARTIFACTS_ROOT);
239
+
240
+ pi.registerTool({
241
+ name: 'workflow',
242
+ label: 'Workflow',
243
+ description: [
244
+ 'Run a JavaScript workflow script that orchestrates subagents over the first-party runtime,',
245
+ "or manage runs. Actions: 'run' (compile a script in a vm and execute it with injected",
246
+ 'globals), "list" (saved workflow files), "status" (a run record by runId), "stop" (cancel a',
247
+ "run by runId). For 'run', pass EITHER `script` (inline JS) OR `name` (a saved workflow file",
248
+ 'stem from ~/.pi/agent/workflows/*.js or .pi/workflows/*.js). Optional `args` (any JSON value)',
249
+ "is passed in as the script's `args` global.",
250
+ '',
251
+ 'Script contract — injected globals: agent(prompt, opts), parallel(thunks),',
252
+ 'pipeline(items, ...stages), phase(name), log(...args), args, budget ({total, spent,',
253
+ "remaining}), cwd. The script's FIRST statement SHOULD be `export const meta = { name,",
254
+ 'description }` (rewritten so the vm compiles; meta.name/description surface in the result).',
255
+ 'The script returns a value via a trailing expression or a top-level `return` (the body is',
256
+ 'wrapped in an async function).',
257
+ '',
258
+ 'agent() opts: model, tools (default read-only [read, grep, find, ls]), label, systemPrompt,',
259
+ 'schema (validated; lands in result.data), effort (thinking level), timeoutMs, maxTurns,',
260
+ 'agentType (accepted but ignored — no agent-type registry; resolve systemPrompt in the',
261
+ 'script). agent() throws `${kind}: ${error}` on failure — wrap with a safeAgent that returns',
262
+ '{ ok, value, error } so a failure inside parallel() does not collapse the wave.',
263
+ '',
264
+ 'Runs are visible: every agent() spawn is a child in the subagent fleet (use the `fleet`',
265
+ 'tool / `/fleet` from @nicknisi/pi-subagents, or `status`/`stop` here). The script itself',
266
+ 'executes in the host process with full Node access — the same trust boundary as the bash',
267
+ 'tool. Keep the returned value small: summaries, counts, key findings — never raw file dumps.',
268
+ ].join(' '),
269
+ promptSnippet: 'Run a JS workflow script orchestrating subagents',
270
+ promptGuidelines: [
271
+ 'The script body is wrapped in an async function — a top-level `return value` is the contract for the result.',
272
+ 'Lead with `export const meta = { name, description }` so the run is labeled in the result.',
273
+ 'agent() throws on failure; wrap it in a safeAgent() that returns { ok, value, error } so a failure inside parallel() reports which stage died instead of collapsing the wave to null.',
274
+ 'parallel(thunks) awaits Promise.all over zero-arg thunks — pass `() => agent(...)`, not `agent(...)`.',
275
+ 'Children default to read-only tools (read, grep, find, ls); pass `tools` explicitly for builders.',
276
+ 'Use log(...) for progress notes; they come back in the result details. phase(name) is a logging marker only.',
277
+ 'Keep the returned value small — summaries, counts, key findings — never raw file contents.',
278
+ ],
279
+ parameters: Type.Object({
280
+ action: Type.Union([Type.Literal('run'), Type.Literal('list'), Type.Literal('status'), Type.Literal('stop')], {
281
+ description: 'Action: run | list | status | stop',
282
+ }),
283
+ script: Type.Optional(Type.String({ description: 'Inline JS workflow script (action: run).' })),
284
+ name: Type.Optional(Type.String({ description: 'Saved workflow file stem (action: run).' })),
285
+ args: Type.Optional(
286
+ Type.Any({ description: "Any JSON value passed as the script's `args` global (action: run)." }),
287
+ ),
288
+ runId: Type.Optional(Type.String({ description: 'Run id (action: status | stop).' })),
289
+ timeoutMs: Type.Optional(Type.Number({ description: 'Wall-clock cap for run. Default 10 min, max 30 min.' })),
290
+ }),
291
+
292
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
293
+ const action = params.action;
294
+
295
+ if (action === 'list') {
296
+ const items = listWorkflows(ctx.cwd, ctx.isProjectTrusted());
297
+ if (items.length === 0) {
298
+ return {
299
+ content: [
300
+ {
301
+ type: 'text' as const,
302
+ text: 'No saved workflows. Drop .js files in ~/.pi/agent/workflows/ or .pi/workflows/.',
303
+ },
304
+ ],
305
+ details: {},
306
+ };
307
+ }
308
+ const lines = items.map((w) => {
309
+ const tail = w.description ? ` — ${w.description}` : '';
310
+ return ` ${w.name} [${w.scope}]${tail}`;
311
+ });
312
+ return {
313
+ content: [{ type: 'text' as const, text: `Saved workflows (${items.length}):\n${lines.join('\n')}` }],
314
+ details: { count: items.length },
315
+ };
316
+ }
317
+
318
+ if (action === 'status') {
319
+ const runId = params.runId;
320
+ if (!runId) {
321
+ return { content: [{ type: 'text' as const, text: 'status requires runId.' }], details: {} };
322
+ }
323
+ const record = findRun(runtime, runId);
324
+ if (!record) {
325
+ return {
326
+ content: [{ type: 'text' as const, text: `No run '${runId.slice(0, 8)}' (full or prefix).` }],
327
+ details: {},
328
+ };
329
+ }
330
+ return {
331
+ content: [{ type: 'text' as const, text: formatRun(record) }],
332
+ details: { runId: record.runId, status: record.status },
333
+ };
334
+ }
335
+
336
+ if (action === 'stop') {
337
+ const runId = params.runId;
338
+ if (!runId) {
339
+ return { content: [{ type: 'text' as const, text: 'stop requires runId.' }], details: {} };
340
+ }
341
+ const controller = resolveCancellable(cancellables, runtime, runId);
342
+ if (!controller) {
343
+ const record = findRun(runtime, runId);
344
+ const msg =
345
+ record && (record.status === 'running' || record.status === 'queued')
346
+ ? `Run ${runId.slice(0, 8)} isn't cancellable from here (it belongs to a different host process).`
347
+ : `Run ${runId.slice(0, 8)} already finished.`;
348
+ return { content: [{ type: 'text' as const, text: msg }], details: {} };
349
+ }
350
+ controller.abort();
351
+ return {
352
+ content: [{ type: 'text' as const, text: `Cancelled run ${runId.slice(0, 8)}.` }],
353
+ details: { runId },
354
+ };
355
+ }
356
+
357
+ // action === 'run'
358
+ const timeoutMs = Math.min(Math.max(params.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
359
+ let src: string;
360
+ let label: string;
361
+ if (params.name) {
362
+ const file = findWorkflowFile(params.name, ctx.cwd, ctx.isProjectTrusted());
363
+ if (!file) {
364
+ return {
365
+ content: [
366
+ {
367
+ type: 'text' as const,
368
+ text: `No workflow '${params.name}' in ~/.pi/agent/workflows or ${CONFIG_DIR_NAME}/workflows.`,
369
+ },
370
+ ],
371
+ details: {},
372
+ };
373
+ }
374
+ try {
375
+ src = fs.readFileSync(file, 'utf8');
376
+ } catch (err) {
377
+ return {
378
+ content: [
379
+ {
380
+ type: 'text' as const,
381
+ text: `Failed to read workflow: ${err instanceof Error ? err.message : String(err)}`,
382
+ },
383
+ ],
384
+ details: {},
385
+ };
386
+ }
387
+ label = params.name;
388
+ } else if (params.script) {
389
+ src = params.script;
390
+ label = 'inline';
391
+ } else {
392
+ return {
393
+ content: [
394
+ { type: 'text' as const, text: 'run requires either `script` (inline JS) or `name` (saved workflow).' },
395
+ ],
396
+ details: {},
397
+ };
398
+ }
399
+
400
+ // One controller for BOTH abort sources: the tool's signal AND the
401
+ // timeout. spawnCancellable wires it into every child spawn, so firing
402
+ // it actually cancels in-flight work (previously the timeout controller
403
+ // was connected to nothing — dead code).
404
+ const controller = new AbortController();
405
+ let timedOut = false;
406
+ const onToolAbort = () => controller.abort();
407
+ if (signal) {
408
+ if (signal.aborted) controller.abort();
409
+ else signal.addEventListener('abort', onToolAbort, { once: true });
410
+ }
411
+ const timer = setTimeout(() => {
412
+ timedOut = true;
413
+ controller.abort();
414
+ }, timeoutMs);
415
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, controller.signal);
416
+ const timeoutPromise = new Promise<never>((_, reject) => {
417
+ controller.signal.addEventListener(
418
+ 'abort',
419
+ () =>
420
+ reject(
421
+ new Error(
422
+ timedOut
423
+ ? `Timed out after ${timeoutMs}ms (in-flight subagents were aborted)`
424
+ : 'Aborted by the host session',
425
+ ),
426
+ ),
427
+ { once: true },
428
+ );
429
+ });
430
+ try {
431
+ const result = await Promise.race([
432
+ runScript({
433
+ script: src,
434
+ args: params.args,
435
+ spawn: spawnFn,
436
+ cwd: ctx.cwd,
437
+ onLog: () => {},
438
+ }),
439
+ timeoutPromise,
440
+ ]);
441
+ const text = formatRunResult(result, label);
442
+ return {
443
+ content: [{ type: 'text' as const, text }],
444
+ details: {
445
+ label,
446
+ meta: result.meta,
447
+ logs: result.logs,
448
+ usage: result.usage,
449
+ durationMs: result.durationMs,
450
+ },
451
+ };
452
+ } catch (err) {
453
+ const msg = err instanceof Error ? err.message : String(err);
454
+ return {
455
+ content: [{ type: 'text' as const, text: `${label} failed:\n\n${msg}` }],
456
+ details: { label, error: msg },
457
+ };
458
+ } finally {
459
+ clearTimeout(timer);
460
+ signal?.removeEventListener('abort', onToolAbort);
461
+ }
462
+ },
463
+ });
464
+
465
+ // ── /wf — thin human-facing wrapper ───────────────────────────────────
466
+ pi.registerCommand('wf', {
467
+ description:
468
+ '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).',
469
+ getArgumentCompletions: (argumentPrefix) => {
470
+ if (argumentPrefix.includes(' ')) return null;
471
+ const prefix = argumentPrefix.trim();
472
+ const subs = ['list', 'run', 'status', 'stop'].filter((s) => s.startsWith(prefix));
473
+ if (subs.length === 0) return null;
474
+ return subs.map((s) => ({ value: s + ' ', label: s }));
475
+ },
476
+ handler: async (args, ctx) => {
477
+ await cmdWf(args, ctx, runtime, cancellables);
478
+ },
479
+ });
480
+ }
481
+
482
+ // ── /wf handler (shared, typed against the runtime) ───────────────────────
483
+
484
+ async function cmdWf(
485
+ args: string,
486
+ ctx: ExtensionCommandContext,
487
+ runtime: SubagentRuntime,
488
+ cancellables: Cancellables,
489
+ ): Promise<void> {
490
+ const parts = args.trim().split(/\s+/).filter(Boolean);
491
+ const sub = parts[0];
492
+
493
+ if (!sub || sub === 'list') {
494
+ const items = listWorkflows(ctx.cwd, ctx.isProjectTrusted());
495
+ if (items.length === 0) {
496
+ ctx.ui.notify('No saved workflows. Drop .js files in ~/.pi/agent/workflows/ or .pi/workflows/.', 'info');
497
+ return;
498
+ }
499
+ const lines = items.map((w) => ` ${w.name} [${w.scope}]${w.description ? ` — ${w.description}` : ''}`);
500
+ ctx.ui.notify(`Saved workflows (${items.length}):\n${lines.join('\n')}`, 'info');
501
+ return;
502
+ }
503
+
504
+ if (sub === 'run') {
505
+ const name = parts[1];
506
+ if (!name) {
507
+ ctx.ui.notify('Usage: /wf run <name> [argsJson]', 'warning');
508
+ return;
509
+ }
510
+ const file = findWorkflowFile(name, ctx.cwd, ctx.isProjectTrusted());
511
+ if (!file) {
512
+ ctx.ui.notify(`No workflow '${name}' in ~/.pi/agent/workflows or ${CONFIG_DIR_NAME}/workflows`, 'error');
513
+ return;
514
+ }
515
+ let src: string;
516
+ try {
517
+ src = fs.readFileSync(file, 'utf8');
518
+ } catch (err) {
519
+ ctx.ui.notify(`Failed to read workflow: ${err instanceof Error ? err.message : String(err)}`, 'error');
520
+ return;
521
+ }
522
+ const argsJson = parts.slice(2).join(' ');
523
+ let parsedArgs: unknown = undefined;
524
+ if (argsJson) {
525
+ try {
526
+ parsedArgs = JSON.parse(argsJson);
527
+ } catch {
528
+ // Fall back to the raw string — many scripts treat args as a string.
529
+ parsedArgs = argsJson;
530
+ }
531
+ }
532
+ ctx.ui.setStatus('workflows', `running ${name}…`);
533
+ try {
534
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.signal ?? undefined);
535
+ const result = await runScript({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
536
+ ctx.ui.notify(formatRunResult(result, name), 'info');
537
+ } catch (err) {
538
+ ctx.ui.notify(`${name} failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
539
+ } finally {
540
+ ctx.ui.setStatus('workflows', undefined);
541
+ }
542
+ return;
543
+ }
544
+
545
+ if (sub === 'status') {
546
+ const runId = parts[1];
547
+ if (!runId) {
548
+ ctx.ui.notify('Usage: /wf status <runId>', 'warning');
549
+ return;
550
+ }
551
+ const record = findRun(runtime, runId);
552
+ if (!record) {
553
+ ctx.ui.notify(`No run '${runId.slice(0, 8)}' (full or prefix).`, 'warning');
554
+ return;
555
+ }
556
+ ctx.ui.notify(formatRun(record), 'info');
557
+ return;
558
+ }
559
+
560
+ if (sub === 'stop') {
561
+ const runId = parts[1];
562
+ if (!runId) {
563
+ ctx.ui.notify('Usage: /wf stop <runId>', 'warning');
564
+ return;
565
+ }
566
+ const controller = resolveCancellable(cancellables, runtime, runId);
567
+ if (!controller) {
568
+ const record = findRun(runtime, runId);
569
+ ctx.ui.notify(
570
+ record && (record.status === 'running' || record.status === 'queued')
571
+ ? `Run ${runId.slice(0, 8)} isn't cancellable from here (different host process).`
572
+ : `Run ${runId.slice(0, 8)} already finished.`,
573
+ 'warning',
574
+ );
575
+ return;
576
+ }
577
+ controller.abort();
578
+ ctx.ui.notify(`Cancelled run ${runId.slice(0, 8)}`, 'info');
579
+ return;
580
+ }
581
+
582
+ ctx.ui.notify('Usage: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId>', 'warning');
583
+ }
584
+
585
+ // ── Run lookup / cancellation resolution ──────────────────────────────────
586
+
587
+ /** Find a run by full id or unique 8-char prefix across live + persisted records. */
588
+ function findRun(runtime: SubagentRuntime, runId: string): RunArtifact | undefined {
589
+ const live = runtime.listRuns() as RunArtifact[];
590
+ const persisted = readRunArtifacts(ARTIFACTS_ROOT);
591
+ const byId = new Map<string, RunArtifact>();
592
+ for (const r of persisted) byId.set(r.runId, r);
593
+ for (const r of live) byId.set(r.runId, { ...byId.get(r.runId), ...r });
594
+ if (byId.has(runId)) return byId.get(runId);
595
+ // Unique prefix match (>=8 chars) — defends against ambiguous short prefixes.
596
+ if (runId.length >= 8) {
597
+ const matches = [...byId.values()].filter((r) => r.runId.startsWith(runId));
598
+ if (matches.length === 1) return matches[0];
599
+ }
600
+ return undefined;
601
+ }
602
+
603
+ /** Resolve a cancellable controller by full id or unique prefix. */
604
+ function resolveCancellable(
605
+ cancellables: Cancellables,
606
+ _runtime: SubagentRuntime,
607
+ runId: string,
608
+ ): AbortController | undefined {
609
+ if (cancellables.has(runId)) return cancellables.get(runId);
610
+ if (runId.length >= 8) {
611
+ const matches = [...cancellables.entries()].filter(([id]) => id.startsWith(runId));
612
+ if (matches.length === 1) return matches[0]![1];
613
+ }
614
+ return undefined;
615
+ }
616
+
617
+ // Exported for typechecking against the ExtensionContext used by the tool.
618
+ export type { ExtensionContext };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@nicknisi/pi-workflows",
3
+ "version": "0.1.0",
4
+ "description": "Model-facing front door to the first-party workflow engine — run JS workflow scripts over the subagent runtime, replacing the third-party @quintinshaw/pi-dynamic-workflows extension",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-coding-agent",
8
+ "pi-package"
9
+ ],
10
+ "homepage": "https://github.com/nicknisi/pi-extensions/tree/main/packages/workflows#readme",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/nicknisi/pi-extensions.git",
15
+ "directory": "packages/workflows"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "index.ts",
20
+ "engine.ts"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "typebox": "^1.1.0",
31
+ "@nicknisi/pi-shared": "0.3.0"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*"
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./index.ts"
39
+ ]
40
+ }
41
+ }