pi-extensible-workflows 3.1.0 → 3.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.
Files changed (47) hide show
  1. package/README.md +11 -47
  2. package/dist/src/agent-execution.d.ts +4 -0
  3. package/dist/src/agent-execution.js +146 -60
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +146 -21
  8. package/dist/src/doctor-cleanup.js +4 -2
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +51 -8
  12. package/dist/src/host.js +1181 -487
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +40 -1
  16. package/dist/src/persistence.js +51 -0
  17. package/dist/src/session-inspector.d.ts +12 -2
  18. package/dist/src/session-inspector.js +36 -2
  19. package/dist/src/types.d.ts +19 -0
  20. package/dist/src/types.js +1 -0
  21. package/dist/src/validation.js +42 -2
  22. package/dist/src/workflow-artifacts.d.ts +13 -0
  23. package/dist/src/workflow-artifacts.js +39 -0
  24. package/dist/src/workflow-evals.d.ts +7 -1
  25. package/dist/src/workflow-evals.js +23 -3
  26. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  27. package/evals/cases/recovery-failed-run.yaml +14 -0
  28. package/examples/workflow-extension-template/README.md +37 -0
  29. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  30. package/examples/workflow-extension-template/index.js +51 -0
  31. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  32. package/package.json +3 -2
  33. package/skills/pi-extensible-workflows/SKILL.md +21 -28
  34. package/src/agent-execution.ts +102 -30
  35. package/src/bundles.ts +471 -0
  36. package/src/cli.ts +118 -21
  37. package/src/doctor-cleanup.ts +3 -2
  38. package/src/eval-capture-extension.ts +15 -1
  39. package/src/execution.ts +13 -4
  40. package/src/host.ts +992 -447
  41. package/src/index.ts +4 -2
  42. package/src/persistence.ts +53 -1
  43. package/src/session-inspector.ts +36 -4
  44. package/src/types.ts +12 -5
  45. package/src/validation.ts +33 -2
  46. package/src/workflow-artifacts.ts +34 -0
  47. package/src/workflow-evals.ts +24 -3
package/src/cli.ts CHANGED
@@ -8,12 +8,13 @@ import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionF
8
8
  import { Value } from "typebox/value";
9
9
  import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
10
10
  import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport, type DoctorCleanupOptions } from "./doctor-cleanup.js";
11
- import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
12
- import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
11
+ import workflowExtension, { formatWorkflowProgress, loadAgentDefinitions, registeredWorkflowFunctions, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
12
+ import { portableEngineVersion, portablePiVersion, writePortableWorkflowBundle } from "./bundles.js";
13
+ import { runSessionInspector, transcriptFileLines, type InspectMode } from "./session-inspector.js";
13
14
  import type { PersistedRun } from "./persistence.js";
14
15
  import type { WorkflowCatalogFunction } from "./index.js";
15
16
 
16
- export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean }
17
+ export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string, mode?: InspectMode, failedOnly?: boolean) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean; skillPaths?: readonly string[] }
17
18
 
18
19
  type CliScalar = "string" | "integer" | "number" | "boolean";
19
20
  type CliField = { name: string; option: string; schema: Record<string, unknown>; type: CliScalar | "array"; itemType?: CliScalar; required: boolean };
@@ -158,7 +159,24 @@ function launcherHelpLines(): string[] {
158
159
  ];
159
160
  }
160
161
  function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
161
- function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
162
+ function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
163
+ function bundleUsage(): string { return [`Usage: pi-extensible-workflows bundle <workflow-name> [--name <command>] [--output <directory>] [--force]`, "", "The bundle contains a launcher, manifest, workflow payload, and external-runtime setup instructions.", "Repeat --role, --alias, --tool, --command, or --environment to declare recipient requirements.", "Use --extension, --skill, --resource, and --dependency to copy selected payload resources."].join("\n") + "\n"; }
164
+ function parseInspectArgs(rawArgs: readonly string[]): { sessionId?: string; mode: InspectMode; failedOnly: boolean } {
165
+ let sessionId: string | undefined;
166
+ let mode: InspectMode = "tui";
167
+ let failedOnly = false;
168
+ for (const arg of rawArgs) {
169
+ if (arg === "--json" || arg === "--summary") {
170
+ const next: InspectMode = arg === "--json" ? "json" : "summary";
171
+ if (mode !== "tui" && mode !== next) throw new Error("inspect accepts only one output mode");
172
+ mode = next;
173
+ } else if (arg === "--failed") failedOnly = true;
174
+ else if (arg.startsWith("--")) throw new Error(`Unknown inspect option: ${arg}`);
175
+ else if (sessionId !== undefined) throw new Error(`Unexpected argument: ${arg}`);
176
+ else sessionId = arg;
177
+ }
178
+ return { ...(sessionId ? { sessionId } : {}), mode: failedOnly && mode === "tui" ? "summary" : mode, failedOnly };
179
+ }
162
180
  export function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">> {
163
181
  let olderThanDays = 90;
164
182
  let yes = false;
@@ -193,7 +211,7 @@ function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustO
193
211
  }
194
212
  return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
195
213
  }
196
- type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal };
214
+ type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal; skillPaths?: readonly string[] };
197
215
 
198
216
  type HeadlessWorkflowTool = { execute: (toolCallId: string, params: Record<string, JsonValue>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, context: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown }> };
199
217
  type ShutdownHandler = (event: unknown, context: unknown) => Promise<void> | void;
@@ -235,7 +253,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
235
253
  cwd,
236
254
  agentDir,
237
255
  settingsManager,
238
- resourceLoaderOptions: {},
256
+ resourceLoaderOptions: { ...(options.skillPaths?.length ? { additionalSkillPaths: [...options.skillPaths] } : {}) },
239
257
  resourceLoaderReloadOptions: { resolveProjectTrust },
240
258
  });
241
259
  const extensions = services.resourceLoader.getExtensions();
@@ -251,7 +269,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
251
269
  sendMessage() {},
252
270
  events: { emit() {} },
253
271
  };
254
- workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
272
+ workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir, options.skillPaths);
255
273
  const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
256
274
  if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
257
275
  return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
@@ -304,23 +322,34 @@ class CliProgress {
304
322
  #lines = 0;
305
323
  #frame = 0;
306
324
  #run: PersistedRun | undefined;
325
+ #runId: string | undefined;
307
326
  #timer: ReturnType<typeof setInterval> | undefined;
308
327
  #interactive: boolean;
309
328
  #styles: WorkflowProgressStyles;
310
- constructor(private readonly stderr: (text: string) => void, tty: boolean) {
329
+ constructor(private readonly stderr: (text: string) => void, tty: boolean, private readonly onRunId: (runId: string) => void) {
311
330
  this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
312
331
  this.#styles = terminalProgressStyles(this.#interactive);
313
332
  }
314
333
  update(run: PersistedRun): void {
315
- const stable = formatWorkflowProgress(run, "◇", this.#styles);
316
- if (!this.#interactive) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
334
+ if (this.#runId !== run.id) { this.#runId = run.id; this.onRunId(run.id); }
317
335
  this.#run = run;
336
+ if (!this.#interactive) {
337
+ this.#timer ??= setInterval(() => { this.render(); }, 1000);
338
+ this.#timer.unref();
339
+ this.render();
340
+ return;
341
+ }
318
342
  this.#timer ??= setInterval(() => { this.render(); }, 80);
319
343
  this.#timer.unref();
320
344
  this.render();
321
345
  }
322
346
  render(): void {
323
347
  if (!this.#run) return;
348
+ if (!this.#interactive) {
349
+ const stable = formatWorkflowProgress(this.#run, "◇", this.#styles);
350
+ if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); }
351
+ return;
352
+ }
324
353
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
325
354
  const width = process.stderr.columns || 80;
326
355
  const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
@@ -334,16 +363,21 @@ class CliProgress {
334
363
  }
335
364
  }
336
365
 
337
- async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<JsonValue> {
366
+ type CliWorkflowResult = { value: JsonValue; runId?: string };
367
+ async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<CliWorkflowResult> {
338
368
  if (!Value.Check(fn.input, args)) throw new Error(`Invalid input for ${fn.name}`);
339
- const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY);
369
+ let announcedRunId: string | undefined;
370
+ const announceRunId = (runId: string) => { if (announcedRunId === runId) return; announcedRunId = runId; options.stderr(`Run ID: ${runId}\n`); };
371
+ const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY, announceRunId);
340
372
  try {
341
373
  const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update: unknown) => { if (object(update) && object(update.details) && object(update.details.run)) progress.update(update.details.run as unknown as PersistedRun); }, context);
342
374
  const details = object(result.details) ? result.details : {};
343
- if (has(details, "value")) return details.value as JsonValue;
375
+ const runId = typeof details.runId === "string" ? details.runId : undefined;
376
+ if (runId) announceRunId(runId);
377
+ if (has(details, "value")) return { value: details.value as JsonValue, ...(runId ? { runId } : {}) };
344
378
  const first = result.content[0];
345
379
  if (!first || first.type !== "text") throw new Error("Workflow returned no result");
346
- try { return parseJsonInput(first.text); } catch { throw new Error("Workflow returned invalid JSON"); }
380
+ try { return { value: parseJsonInput(first.text), ...(runId ? { runId } : {}) }; } catch { throw new Error("Workflow returned invalid JSON"); }
347
381
  } finally {
348
382
  progress.finish();
349
383
  }
@@ -385,8 +419,8 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
385
419
  if (!fn) throw new Error(`Unknown workflow function: ${name}`);
386
420
  if (help) { options.write(formatWorkflowCliHelp(fn)); return 0; }
387
421
  const input = parseWorkflowCliArgs(fn.input, args.slice(1));
388
- const value = await invokeWorkflow(fn, input, runtime, options, context);
389
- options.write(`${JSON.stringify(value)}\n`);
422
+ const result = await invokeWorkflow(fn, input, runtime, options, context);
423
+ options.write(`${JSON.stringify(result.value)}\n`);
390
424
  return 0;
391
425
  });
392
426
  }
@@ -394,6 +428,7 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
394
428
  async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
395
429
  const parsed = stripTrustOptions(rawArgs);
396
430
  const args = parsed.args;
431
+ if (args.includes("--bundle")) return bundleWorkflowCli(args.filter((arg) => arg !== "--bundle"), options);
397
432
  if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(exportUsage()); return args.length ? 0 : 1; }
398
433
  const workflowName = args[0] as string;
399
434
  return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
@@ -429,6 +464,62 @@ async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo
429
464
  });
430
465
  }
431
466
 
467
+ async function bundleWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
468
+ const parsed = stripTrustOptions(rawArgs);
469
+ const args = parsed.args;
470
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(bundleUsage()); return args.length ? 0 : 1; }
471
+ const workflowName = args[0] as string;
472
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
473
+ let name: string | undefined;
474
+ let output: string | undefined;
475
+ let force = false;
476
+ const requirements = { roles: [] as string[], aliases: [] as string[], tools: [] as string[], commands: [] as string[], environment: [] as string[] };
477
+ const resources = { extensions: [] as string[], skills: [] as string[], static: [] as string[], dependencies: [] as string[] };
478
+ for (let index = 1; index < args.length; index += 1) {
479
+ const arg = args[index] as string;
480
+ if (arg === "--force") { force = true; continue; }
481
+ const equals = arg.indexOf("=");
482
+ const option = equals >= 0 ? arg.slice(0, equals) : arg;
483
+ const requirementOptions = { "--role": "roles", "--alias": "aliases", "--tool": "tools", "--command": "commands", "--environment": "environment" } as const;
484
+ const resourceOptions = { "--extension": "extensions", "--skill": "skills", "--resource": "static", "--dependency": "dependencies" } as const;
485
+ if (option === "--name" || option === "--output" || Object.prototype.hasOwnProperty.call(requirementOptions, option) || Object.prototype.hasOwnProperty.call(resourceOptions, option)) {
486
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
487
+ if (!value) throw new Error(`Missing value for ${option}`);
488
+ if (option === "--name") name = value;
489
+ else if (option === "--output") output = value;
490
+ else if (Object.prototype.hasOwnProperty.call(requirementOptions, option)) requirements[requirementOptions[option as keyof typeof requirementOptions]].push(value);
491
+ else resources[resourceOptions[option as keyof typeof resourceOptions]].push(value);
492
+ continue;
493
+ }
494
+ if (arg === "--help" || arg === "-h") { options.write(bundleUsage()); return 0; }
495
+ throw new Error(`Unknown option: ${arg}`);
496
+ }
497
+ const fn = runtime.catalog.functions.find((candidate) => candidate.name === workflowName);
498
+ if (!fn) throw new Error(`Unknown workflow function: ${workflowName}`);
499
+ const registered = registeredWorkflowFunctions()[workflowName];
500
+ if (!registered) throw new Error(`Workflow ${workflowName} is not exportable because its registered implementation is unavailable`);
501
+ const definitions = requirements.roles.length ? loadAgentDefinitions(options.cwd ?? process.cwd(), options.agentDir ?? getAgentDir(), runtime.services.settingsManager.isProjectTrusted()) : {};
502
+ const roles = Object.fromEntries(requirements.roles.map((role) => {
503
+ if (!role || role === "." || role === ".." || role.includes("/") || role.includes("\\")) throw new Error(`Invalid role name for bundle: ${role}`);
504
+ const definition = definitions[role];
505
+ if (!definition) throw new Error(`Unknown role for bundle: ${role}`);
506
+ return [role, definition];
507
+ }));
508
+ const command = commandName(name ?? kebabCase(workflowName));
509
+ if (!command) throw new Error("Command name must be a non-empty name without path separators");
510
+ const destination = output ?? join(homedir(), ".local", "share", "pi-extensible-workflows", "bundles", command);
511
+ const aliasTargets = Object.fromEntries(requirements.aliases.flatMap((name) => {
512
+ const target = runtime.catalog.modelAliases?.[name];
513
+ return typeof target === "string" ? [[name, target]] : [];
514
+ }));
515
+ const selectedResources = Object.values(resources).some((entries) => entries.length) ? resources : undefined;
516
+ writePortableWorkflowBundle({ destination, command, workflow: fn, functionSource: registered.run.toString(), requirements, aliasTargets, roles, ...(selectedResources ? { resources: selectedResources } : {}), piVersion: portablePiVersion(), engineVersion: portableEngineVersion(), force });
517
+ options.write(`Bundled ${workflowName} at ${destination}\n`);
518
+ options.write(`Run ${join(destination, command)} setup before launching the workflow.\n`);
519
+ return 0;
520
+ });
521
+ }
522
+
432
523
  export async function runCli(args: readonly string[], options: CliOptions = {}, write: (text: string) => void = (text) => { process.stdout.write(text); }): Promise<number> {
433
524
  const stderr = options.stderr ?? ((text: string) => { process.stderr.write(text); });
434
525
  if (args[0] === "doctor" && args.length === 1) {
@@ -446,8 +537,13 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
446
537
  return doctorCleanupExitCode(report);
447
538
  } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
448
539
  }
449
- if (args[0] === "inspect" && args.length <= 2) {
450
- try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
540
+ if (args[0] === "inspect") {
541
+ try {
542
+ const parsed = parseInspectArgs(args.slice(1));
543
+ if (options.inspect) await options.inspect(parsed.sessionId, parsed.mode, parsed.failedOnly);
544
+ else await runSessionInspector(parsed.sessionId, parsed.mode, options.cwd ?? process.cwd(), undefined, write, parsed.failedOnly);
545
+ return 0;
546
+ }
451
547
  catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
452
548
  }
453
549
  if (args[0] === "transcript" && args.length === 2) {
@@ -457,13 +553,14 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
457
553
  return 0;
458
554
  } catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
459
555
  }
460
- if (args[0] === "run" || args[0] === "export") {
556
+ if (args[0] === "bundle" || args[0] === "run" || args[0] === "export") {
461
557
  try {
462
- const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}) };
558
+ const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}), ...(options.skillPaths?.length ? { skillPaths: [...options.skillPaths] } : {}) };
559
+ if (args[0] === "bundle") return await bundleWorkflowCli(args.slice(1), workflowOptions);
463
560
  return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
464
561
  } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
465
562
  }
466
- write("Usage: pi-extensible-workflows doctor | inspect [session-id] | transcript <session-file> | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]\n");
563
+ write("Usage: pi-extensible-workflows doctor | inspect [session-id] [--json|--summary] [--failed] | transcript <session-file> | bundle <workflow-name> [--name <command>] [--output <path>] [--force] | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]\n");
467
564
  return 1;
468
565
  }
469
566
 
@@ -11,7 +11,7 @@ import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, Run
11
11
  const TERMINAL_STATES = new Set<RunState>(["completed", "failed", "stopped"]);
12
12
  const DAY_MS = 24 * 60 * 60 * 1000;
13
13
  const REQUIRED_RUN_FILES = ["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"] as const;
14
- const OPTIONAL_RUN_FILES = new Set(["result.json"]);
14
+ const OPTIONAL_RUN_FILES = new Set(["result.json", "summary.json"]);
15
15
  const RUN_DIRECTORIES = new Set(["worktrees"]);
16
16
  const RUN_FILES = new Set<string>([...REQUIRED_RUN_FILES, ...OPTIONAL_RUN_FILES]);
17
17
  const AGENT_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
@@ -46,7 +46,7 @@ function accounting(value: unknown, label: string): void { if (!object(value)) t
46
46
  function resourceExclusions(value: unknown, label: string): void { if (value === undefined) return; if (!object(value)) throw new Error(`${label} is invalid`); if (value.skills !== undefined) stringList(value.skills, `${label}.skills`); if (value.extensions !== undefined) stringList(value.extensions, `${label}.extensions`); }
47
47
  function agentDefinition(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.description, `${label}.description`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); if (value.tools !== undefined) stringList(value.tools, `${label}.tools`); resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`); }
48
48
  function validateScheduledOptions(value: unknown, label: string): void { if (!object(value) || typeof value.label !== "string" || !value.label || typeof value.cwd !== "string" || !value.cwd) throw new Error(`${label} is invalid`); optionalString(value.requestedLabel, `${label}.requestedLabel`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); stringList(value.tools, `${label}.tools`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); optionalString(value.role, `${label}.role`); if (value.schema !== undefined) validateSchema(value.schema, `${label}.schema`); if (value.retries !== undefined) nonNegativeInteger(value.retries, `${label}.retries`); if (value.timeoutMs !== undefined && value.timeoutMs !== null) positiveInteger(value.timeoutMs, `${label}.timeoutMs`); if (value.agentOptions !== undefined && (!object(value.agentOptions) || !jsonValue(value.agentOptions))) throw new Error(`${label}.agentOptions is invalid`); if (value.agentIdentity !== undefined) { if (!object(value.agentIdentity) || !Array.isArray(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string") throw new Error(`${label}.agentIdentity is invalid`); positiveInteger(value.agentIdentity.occurrence, `${label}.agentIdentity.occurrence`); optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`); optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`); } }
49
- function validateAgent(value: unknown, label: string): void { if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state)) throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined) stringList(value.structuralPath, `${label}.structuralPath`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.role, `${label}.role`); optionalString(value.requestedModel, `${label}.requestedModel`); model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`); if (value.attemptDetails !== undefined) { if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`); for (const [index, attempt] of value.attemptDetails.entries()) { const at = `${label}.attemptDetails[${String(index)}]`; if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile) throw new Error(`${at} is invalid`); accounting(attempt.accounting, `${at}.accounting`); if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`); if (attempt.setup !== undefined) { if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string") throw new Error(`${at}.setup is invalid`); model(attempt.setup.model, `${at}.setup.model`); stringList(attempt.setup.tools, `${at}.setup.tools`); resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`); } } } if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const call of value.toolCalls) if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls is invalid`); } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string")) throw new Error(`${label}.activity is invalid`); }
49
+ function validateAgent(value: unknown, label: string): void { if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state)) throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined) stringList(value.structuralPath, `${label}.structuralPath`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.role, `${label}.role`); optionalString(value.requestedModel, `${label}.requestedModel`); model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`); if (value.lastEventAt !== undefined) finiteNumber(value.lastEventAt, `${label}.lastEventAt`); if (value.attemptDetails !== undefined) { if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`); for (const [index, attempt] of value.attemptDetails.entries()) { const at = `${label}.attemptDetails[${String(index)}]`; if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile) throw new Error(`${at} is invalid`); accounting(attempt.accounting, `${at}.accounting`); if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`); if (attempt.setup !== undefined) { if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string") throw new Error(`${at}.setup is invalid`); model(attempt.setup.model, `${at}.setup.model`); stringList(attempt.setup.tools, `${at}.setup.tools`); resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`); } } } if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const call of value.toolCalls) if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls is invalid`); } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string")) throw new Error(`${label}.activity is invalid`); }
50
50
  function validateUsage(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"]) finiteNumber(value[key], `${label}.${key}`); }
51
51
  function validateBudgetEvents(value: unknown): void { if (value === undefined) return; if (!Array.isArray(value)) throw new Error("Persisted budget events are invalid"); for (const [index, event] of value.entries()) { const label = `budgetEvents[${String(index)}]`; if (!object(event) || typeof event.type !== "string" || !BUDGET_EVENT_TYPES.has(event.type) || !Number.isSafeInteger(event.budgetVersion) || Number(event.budgetVersion) < 1 || !Array.isArray(event.dimensions) || event.dimensions.some((dimension) => typeof dimension !== "string" || !BUDGET_DIMENSIONS.has(dimension)) || typeof event.at !== "number" || !Number.isFinite(event.at) || event.limits === undefined) throw new Error(`${label} is invalid`); validateUsage(event.usage, `${label}.usage`); validateBudget(event.limits); } }
52
52
  function validateRunRecord(run: PersistedRun): void {
@@ -74,6 +74,7 @@ function validateRunRecord(run: PersistedRun): void {
74
74
  }
75
75
  for (const [index, session] of (value.nativeSessions as unknown[]).entries()) if (!object(session) || typeof session.sessionId !== "string" || !session.sessionId || typeof session.sessionFile !== "string" || !session.sessionFile) throw new Error(`nativeSessions[${String(index)}] is invalid`);
76
76
  optionalString(value.parentRunId, "Persisted parent run");
77
+ optionalString(value.failedAt, "Persisted failed path");
77
78
  if (value.retry !== undefined) {
78
79
  if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId) throw new Error("Persisted retry provenance is invalid");
79
80
  const sourceRunId = value.retry.sourceRunId;
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { validateWorkflowLaunch, WorkflowError, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET, type WorkflowValidationParameters } from "./index.js";
5
+ import { validateWorkflowLaunch, WorkflowError, WORKFLOW_RETRY_PARAMETERS, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET, type WorkflowValidationParameters } from "./index.js";
6
6
 
7
7
  export const CAPTURE_IDENTITY = "pi-extensible-workflows-eval-capture-v1";
8
8
  export const CAPTURE_ERROR_PREFIX = `${CAPTURE_IDENTITY}:`;
@@ -51,4 +51,18 @@ export default function evalCaptureExtension(pi: ExtensionAPI): void {
51
51
  }
52
52
  },
53
53
  } as never);
54
+ pi.registerTool({
55
+ name: "workflow_retry",
56
+ label: "Workflow Retry",
57
+ description: "Capture a recovery selection without executing it",
58
+ parameters: WORKFLOW_RETRY_PARAMETERS,
59
+ async execute(_id: string, params: { runId: string }) {
60
+ if (!params.runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", `${CAPTURE_ERROR_PREFIX}RESUME_INCOMPATIBLE: workflow_retry requires an explicit run ID`);
61
+ const selection = { tool: "workflow_retry", arguments: { runId: params.runId } };
62
+ return {
63
+ content: [{ type: "text" as const, text: JSON.stringify({ captured: true, ...selection }) }],
64
+ details: { captured: true, captureIdentity: CAPTURE_IDENTITY, realWorkflowAgentsLaunched: 0, selection },
65
+ };
66
+ },
67
+ } as never);
54
68
  }
package/src/execution.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fork, spawn, type ChildProcess } from "node:child_process";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
@@ -78,6 +78,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
78
78
  const path = (...names) => names.map(encodeURIComponent).join("/");
79
79
  const inheritedAgentPath = new AsyncLocalStorage();
80
80
  const agentOccurrences = new Map();
81
+ const agentInflight = new Set();
81
82
  const shellOccurrences = new Map();
82
83
  const worktreeOwners = new AsyncLocalStorage();
83
84
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
@@ -98,14 +99,22 @@ const internalAgent = (...values) => {
98
99
  const callSite = values.pop();
99
100
  if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
100
101
  const inherited = inheritedAgentPath.getStore() || [];
101
- // ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
102
102
  const occurrenceKey = JSON.stringify([inherited, callSite]);
103
+ if (agentInflight.has(occurrenceKey)) throw workError("INVALID_METADATA", "Concurrent agent calls from the same source call site are unsupported; use parallel(...) or pipeline(...)");
104
+ agentInflight.add(occurrenceKey);
103
105
  const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
104
106
  agentOccurrences.set(occurrenceKey, occurrence);
105
107
  const options = values.length < 2 || values[1] === undefined ? {} : values[1];
106
108
  const worktreeOwner = worktreeOwners.getStore();
107
109
  const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
108
- const result = rpc("agent", [values[0], options, identity]).then(unwrap);
110
+ let result;
111
+ try {
112
+ result = rpc("agent", [values[0], options, identity]).then(unwrap);
113
+ } catch (error) {
114
+ agentInflight.delete(occurrenceKey);
115
+ throw error;
116
+ }
117
+ void result.then(() => agentInflight.delete(occurrenceKey), () => agentInflight.delete(occurrenceKey));
109
118
  Object.defineProperties(result, {
110
119
  toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
111
120
  toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
@@ -386,7 +395,7 @@ function workflowErrorFromWorker(error: WorkerErrorShape): WorkflowError {
386
395
  export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
387
396
  encoded(args);
388
397
  const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
389
- const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
398
+ const childDir = realpathSync(mkdtempSync(join(tmpdir(), "pi-wf-")));
390
399
  const childFile = join(childDir, "child.cjs");
391
400
  writeFileSync(childFile, childSource);
392
401
  const child: ChildProcess = fork(childFile, [String(RPC_LIMIT_BYTES), config], {