@nicknisi/pi-workflows 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ Names are bare file stems (`research`, not `research.js`, never a path — `..`
51
51
 
52
52
  ## Runs are visible
53
53
 
54
- Every `agent()` call spawns through `@nicknisi/pi-shared`'s subagent runtime with `artifactsDir` set to `~/.pi/agent/subagent-runs/` and namespace `workflows`, so child spawns appear in the `fleet` tool / `/fleet` command from `@nicknisi/pi-subagents`. `status <runId>` and `stop <runId>` here read from / cancel via the same runtime's run records — no parallel store. `stop` cancels in-flight spawns through a live `runId → AbortController` registry (mirroring subagents' cascading-cancellation); a run belonging to a different host process is reported as not cancellable from here.
54
+ Every `agent()` call spawns through `@nicknisi/pi-shared`'s subagent runtime with `artifactsDir` set to `~/.pi/agent/subagent-runs/`, namespace `workflows`, and the owning Pi session recorded. Active children therefore appear in that session's default `fleet` view; settled and machine-wide records remain available through the fleet's explicit `all` scope. `status <runId>` and `stop <runId>` here read from / cancel via the same runtime's run records — no parallel store. `stop` cancels in-flight spawns through a live `runId → AbortController` registry (mirroring subagents' cascading-cancellation); a run belonging to a different host process is reported as not cancellable from here.
55
55
 
56
56
  ## Migration from `@quintinshaw/pi-dynamic-workflows`
57
57
 
package/dist/index.js CHANGED
@@ -36,13 +36,7 @@ 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
- // ── Live runId → AbortController, so `stop` can cancel in-flight spawns. ──
40
- // Mirrors @nicknisi/pi-subagents' cascading-cancellation registry. Each
41
- // agent() call spawns detached and registers its controller here; stop(runId)
42
- // aborts it. Runs from other hosts show up via readRunArtifacts but are not
43
- // cancellable here (they belong to a different process).
44
- const cancellables = new Map();
45
- function spawnCancellable(runtime, opts, externalSignal) {
39
+ function spawnCancellable(cancellables, runtime, opts, externalSignal) {
46
40
  const controller = new AbortController();
47
41
  const onExternalAbort = () => controller.abort();
48
42
  if (externalSignal) {
@@ -59,11 +53,12 @@ function spawnCancellable(runtime, opts, externalSignal) {
59
53
  });
60
54
  return Object.assign(done, { runId });
61
55
  }
62
- function makeSpawnFn(runtime, cwd, externalSignal) {
56
+ function makeSpawnFn(cancellables, runtime, cwd, ownerSession, externalSignal) {
63
57
  return async (opts) => {
64
58
  const spawnOpts = {
65
59
  prompt: opts.prompt,
66
60
  cwd,
61
+ ...(ownerSession !== undefined ? { ownerSession } : {}),
67
62
  ...(opts.agent !== undefined ? { agent: opts.agent } : {}),
68
63
  ...(opts.model !== undefined ? { model: opts.model } : {}),
69
64
  ...(opts.systemPrompt !== undefined ? { systemPrompt: opts.systemPrompt } : {}),
@@ -79,7 +74,7 @@ function makeSpawnFn(runtime, cwd, externalSignal) {
79
74
  spawnOpts.tools = opts.tools;
80
75
  else
81
76
  spawnOpts.tools = ['read', 'grep', 'find', 'ls'];
82
- const res = await spawnCancellable(runtime, spawnOpts, externalSignal);
77
+ const res = await spawnCancellable(cancellables, runtime, spawnOpts, externalSignal);
83
78
  // Surface the worktree `.patch` path (recorded on the run record after
84
79
  // settle) so workflow scripts can return it for the `/patches` apply flow.
85
80
  if (res.ok) {
@@ -199,6 +194,7 @@ function formatRunResult(result, label) {
199
194
  }
200
195
  // ── Extension ─────────────────────────────────────────────────────────────
201
196
  export default function workflows(pi) {
197
+ const cancellables = new Map();
202
198
  const runtime = createSubagentRuntime({ namespace: NAMESPACE, artifactsDir: ARTIFACTS_ROOT });
203
199
  sweepRunArtifactsOnce(ARTIFACTS_ROOT);
204
200
  pi.registerTool({
@@ -296,7 +292,7 @@ export default function workflows(pi) {
296
292
  if (!runId) {
297
293
  return { content: [{ type: 'text', text: 'stop requires runId.' }], details: {} };
298
294
  }
299
- const controller = resolveCancellable(runtime, runId);
295
+ const controller = resolveCancellable(cancellables, runtime, runId);
300
296
  if (!controller) {
301
297
  const record = findRun(runtime, runId);
302
298
  const msg = record && (record.status === 'running' || record.status === 'queued')
@@ -355,17 +351,40 @@ export default function workflows(pi) {
355
351
  details: {},
356
352
  };
357
353
  }
358
- const spawnFn = makeSpawnFn(runtime, ctx.cwd, signal ?? undefined);
354
+ // One controller for BOTH abort sources: the tool's signal AND the
355
+ // timeout. spawnCancellable wires it into every child spawn, so firing
356
+ // it actually cancels in-flight work (previously the timeout controller
357
+ // was connected to nothing — dead code).
359
358
  const controller = new AbortController();
360
- const timer = setTimeout(() => controller.abort(), timeoutMs);
359
+ let timedOut = false;
360
+ const onToolAbort = () => controller.abort();
361
+ if (signal) {
362
+ if (signal.aborted)
363
+ controller.abort();
364
+ else
365
+ signal.addEventListener('abort', onToolAbort, { once: true });
366
+ }
367
+ const timer = setTimeout(() => {
368
+ timedOut = true;
369
+ controller.abort();
370
+ }, timeoutMs);
371
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.sessionManager.getSessionFile(), controller.signal);
372
+ const timeoutPromise = new Promise((_, reject) => {
373
+ controller.signal.addEventListener('abort', () => reject(new Error(timedOut
374
+ ? `Timed out after ${timeoutMs}ms (in-flight subagents were aborted)`
375
+ : 'Aborted by the host session')), { once: true });
376
+ });
361
377
  try {
362
- const result = await runScript({
363
- script: src,
364
- args: params.args,
365
- spawn: spawnFn,
366
- cwd: ctx.cwd,
367
- onLog: () => { },
368
- });
378
+ const result = await Promise.race([
379
+ runScript({
380
+ script: src,
381
+ args: params.args,
382
+ spawn: spawnFn,
383
+ cwd: ctx.cwd,
384
+ onLog: () => { },
385
+ }),
386
+ timeoutPromise,
387
+ ]);
369
388
  const text = formatRunResult(result, label);
370
389
  return {
371
390
  content: [{ type: 'text', text }],
@@ -387,6 +406,7 @@ export default function workflows(pi) {
387
406
  }
388
407
  finally {
389
408
  clearTimeout(timer);
409
+ signal?.removeEventListener('abort', onToolAbort);
390
410
  }
391
411
  },
392
412
  });
@@ -403,12 +423,12 @@ export default function workflows(pi) {
403
423
  return subs.map((s) => ({ value: s + ' ', label: s }));
404
424
  },
405
425
  handler: async (args, ctx) => {
406
- await cmdWf(args, ctx, runtime);
426
+ await cmdWf(args, ctx, runtime, cancellables);
407
427
  },
408
428
  });
409
429
  }
410
430
  // ── /wf handler (shared, typed against the runtime) ───────────────────────
411
- async function cmdWf(args, ctx, runtime) {
431
+ async function cmdWf(args, ctx, runtime, cancellables) {
412
432
  const parts = args.trim().split(/\s+/).filter(Boolean);
413
433
  const sub = parts[0];
414
434
  if (!sub || sub === 'list') {
@@ -453,7 +473,7 @@ async function cmdWf(args, ctx, runtime) {
453
473
  }
454
474
  ctx.ui.setStatus('workflows', `running ${name}…`);
455
475
  try {
456
- const spawnFn = makeSpawnFn(runtime, ctx.cwd, ctx.signal ?? undefined);
476
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.sessionManager.getSessionFile(), ctx.signal ?? undefined);
457
477
  const result = await runScript({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
458
478
  ctx.ui.notify(formatRunResult(result, name), 'info');
459
479
  }
@@ -485,7 +505,7 @@ async function cmdWf(args, ctx, runtime) {
485
505
  ctx.ui.notify('Usage: /wf stop <runId>', 'warning');
486
506
  return;
487
507
  }
488
- const controller = resolveCancellable(runtime, runId);
508
+ const controller = resolveCancellable(cancellables, runtime, runId);
489
509
  if (!controller) {
490
510
  const record = findRun(runtime, runId);
491
511
  ctx.ui.notify(record && (record.status === 'running' || record.status === 'queued')
@@ -520,7 +540,7 @@ function findRun(runtime, runId) {
520
540
  return undefined;
521
541
  }
522
542
  /** Resolve a cancellable controller by full id or unique prefix. */
523
- function resolveCancellable(_runtime, runId) {
543
+ function resolveCancellable(cancellables, _runtime, runId) {
524
544
  if (cancellables.has(runId))
525
545
  return cancellables.get(runId);
526
546
  if (runId.length >= 8) {
package/index.ts CHANGED
@@ -88,12 +88,14 @@ function makeSpawnFn(
88
88
  cancellables: Cancellables,
89
89
  runtime: SubagentRuntime,
90
90
  cwd: string,
91
+ ownerSession: string | undefined,
91
92
  externalSignal: AbortSignal | undefined,
92
93
  ): EngineSpawnFn {
93
94
  return async (opts: EngineSpawnOptions): Promise<EngineSpawnResult> => {
94
95
  const spawnOpts: SpawnOptions = {
95
96
  prompt: opts.prompt,
96
97
  cwd,
98
+ ...(ownerSession !== undefined ? { ownerSession } : {}),
97
99
  ...(opts.agent !== undefined ? { agent: opts.agent } : {}),
98
100
  ...(opts.model !== undefined ? { model: opts.model } : {}),
99
101
  ...(opts.systemPrompt !== undefined ? { systemPrompt: opts.systemPrompt } : {}),
@@ -412,7 +414,13 @@ export default function workflows(pi: ExtensionAPI): void {
412
414
  timedOut = true;
413
415
  controller.abort();
414
416
  }, timeoutMs);
415
- const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, controller.signal);
417
+ const spawnFn = makeSpawnFn(
418
+ cancellables,
419
+ runtime,
420
+ ctx.cwd,
421
+ ctx.sessionManager.getSessionFile(),
422
+ controller.signal,
423
+ );
416
424
  const timeoutPromise = new Promise<never>((_, reject) => {
417
425
  controller.signal.addEventListener(
418
426
  'abort',
@@ -531,7 +539,13 @@ async function cmdWf(
531
539
  }
532
540
  ctx.ui.setStatus('workflows', `running ${name}…`);
533
541
  try {
534
- const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.signal ?? undefined);
542
+ const spawnFn = makeSpawnFn(
543
+ cancellables,
544
+ runtime,
545
+ ctx.cwd,
546
+ ctx.sessionManager.getSessionFile(),
547
+ ctx.signal ?? undefined,
548
+ );
535
549
  const result = await runScript({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
536
550
  ctx.ui.notify(formatRunResult(result, name), 'info');
537
551
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-workflows",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
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
5
  "keywords": [
6
6
  "pi",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "typebox": "^1.1.0",
31
- "@nicknisi/pi-shared": "0.3.0"
31
+ "@nicknisi/pi-shared": "0.5.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-coding-agent": "*"