pi-extensible-workflows 3.2.0 → 3.4.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 +2 -31
  2. package/dist/src/agent-execution.d.ts +67 -9
  3. package/dist/src/agent-execution.js +385 -141
  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 +156 -22
  8. package/dist/src/doctor-cleanup.js +68 -31
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.d.ts +1 -1
  11. package/dist/src/execution.js +29 -13
  12. package/dist/src/host.d.ts +12 -9
  13. package/dist/src/host.js +525 -195
  14. package/dist/src/index.d.ts +5 -4
  15. package/dist/src/index.js +3 -2
  16. package/dist/src/persistence.d.ts +43 -8
  17. package/dist/src/persistence.js +54 -0
  18. package/dist/src/registry.d.ts +3 -2
  19. package/dist/src/registry.js +16 -4
  20. package/dist/src/session-inspector.d.ts +12 -2
  21. package/dist/src/session-inspector.js +50 -24
  22. package/dist/src/types.d.ts +141 -60
  23. package/dist/src/types.js +1 -0
  24. package/dist/src/validation.js +28 -7
  25. package/dist/src/workflow-artifacts.d.ts +1 -0
  26. package/dist/src/workflow-artifacts.js +1 -0
  27. package/dist/src/workflow-evals.d.ts +7 -1
  28. package/dist/src/workflow-evals.js +23 -3
  29. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  30. package/evals/cases/recovery-failed-run.yaml +14 -0
  31. package/package.json +1 -1
  32. package/skills/pi-extensible-workflows/SKILL.md +13 -5
  33. package/src/agent-execution.ts +302 -107
  34. package/src/bundles.ts +471 -0
  35. package/src/cli.ts +130 -22
  36. package/src/doctor-cleanup.ts +38 -4
  37. package/src/eval-capture-extension.ts +15 -1
  38. package/src/execution.ts +27 -15
  39. package/src/host.ts +454 -175
  40. package/src/index.ts +5 -4
  41. package/src/persistence.ts +58 -4
  42. package/src/registry.ts +14 -5
  43. package/src/session-inspector.ts +49 -26
  44. package/src/types.ts +55 -30
  45. package/src/validation.ts +19 -6
  46. package/src/workflow-artifacts.ts +1 -0
  47. package/src/workflow-evals.ts +24 -3
package/dist/src/cli.js CHANGED
@@ -8,7 +8,8 @@ import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionF
8
8
  import { Value } from "typebox/value";
9
9
  import { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
10
10
  import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport } from "./doctor-cleanup.js";
11
- import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath } from "./index.js";
11
+ import workflowExtension, { formatWorkflowProgress, loadAgentDefinitions, registeredWorkflowFunctions, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath } from "./index.js";
12
+ import { portableEngineVersion, portablePiVersion, writePortableWorkflowBundle } from "./bundles.js";
12
13
  import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
13
14
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
14
15
  function has(value, key) { return Object.prototype.hasOwnProperty.call(value, key); }
@@ -193,7 +194,30 @@ function launcherHelpLines() {
193
194
  ];
194
195
  }
195
196
  function workflowUsage() { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
196
- function exportUsage() { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
197
+ function exportUsage() { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
198
+ function bundleUsage() { 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"; }
199
+ function parseInspectArgs(rawArgs) {
200
+ let sessionId;
201
+ let mode = "tui";
202
+ let failedOnly = false;
203
+ for (const arg of rawArgs) {
204
+ if (arg === "--json" || arg === "--summary") {
205
+ const next = arg === "--json" ? "json" : "summary";
206
+ if (mode !== "tui" && mode !== next)
207
+ throw new Error("inspect accepts only one output mode");
208
+ mode = next;
209
+ }
210
+ else if (arg === "--failed")
211
+ failedOnly = true;
212
+ else if (arg.startsWith("--"))
213
+ throw new Error(`Unknown inspect option: ${arg}`);
214
+ else if (sessionId !== undefined)
215
+ throw new Error(`Unexpected argument: ${arg}`);
216
+ else
217
+ sessionId = arg;
218
+ }
219
+ return { ...(sessionId ? { sessionId } : {}), mode: failedOnly && mode === "tui" ? "summary" : mode, failedOnly };
220
+ }
197
221
  export function parseDoctorCleanupArgs(rawArgs) {
198
222
  let olderThanDays = 90;
199
223
  let yes = false;
@@ -286,7 +310,7 @@ async function createWorkflowRuntime(options, shutdownHandlers = []) {
286
310
  cwd,
287
311
  agentDir,
288
312
  settingsManager,
289
- resourceLoaderOptions: {},
313
+ resourceLoaderOptions: { ...(options.skillPaths?.length ? { additionalSkillPaths: [...options.skillPaths] } : {}) },
290
314
  resourceLoaderReloadOptions: { resolveProjectTrust },
291
315
  });
292
316
  const extensions = services.resourceLoader.getExtensions();
@@ -303,7 +327,7 @@ async function createWorkflowRuntime(options, shutdownHandlers = []) {
303
327
  sendMessage() { },
304
328
  events: { emit() { } },
305
329
  };
306
- workflowExtension(headlessPi, homedir(), undefined, undefined, agentDir);
330
+ workflowExtension(headlessPi, homedir(), undefined, undefined, agentDir, options.skillPaths);
307
331
  const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow");
308
332
  if (!workflowTool)
309
333
  throw new Error("The workflow runtime could not be initialized");
@@ -356,28 +380,41 @@ function terminalProgressStyles(enabled) {
356
380
  }
357
381
  class CliProgress {
358
382
  stderr;
383
+ onRunId;
359
384
  #lastStable = "";
360
385
  #lines = 0;
361
386
  #frame = 0;
362
387
  #run;
388
+ #runId;
389
+ #runtimeStartedAt = 0;
390
+ #runtimeBaseMs = 0;
363
391
  #timer;
364
392
  #interactive;
365
393
  #styles;
366
- constructor(stderr, tty) {
394
+ constructor(stderr, tty, onRunId) {
367
395
  this.stderr = stderr;
396
+ this.onRunId = onRunId;
368
397
  this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
369
398
  this.#styles = terminalProgressStyles(this.#interactive);
370
399
  }
371
400
  update(run) {
372
- const stable = formatWorkflowProgress(run, "◇", this.#styles);
401
+ if (this.#runId !== run.id) {
402
+ this.#runId = run.id;
403
+ this.onRunId(run.id);
404
+ this.#runtimeStartedAt = Date.now();
405
+ this.#runtimeBaseMs = run.usage?.durationMs ?? 0;
406
+ }
407
+ else if (this.#run && this.#run.state !== "running" && run.state === "running") {
408
+ this.#runtimeStartedAt = Date.now();
409
+ this.#runtimeBaseMs = run.usage?.durationMs ?? 0;
410
+ }
411
+ this.#run = run;
373
412
  if (!this.#interactive) {
374
- if (stable !== this.#lastStable) {
375
- this.#lastStable = stable;
376
- this.stderr(`${stable}\n`);
377
- }
413
+ this.#timer ??= setInterval(() => { this.render(); }, 1000);
414
+ this.#timer.unref();
415
+ this.render();
378
416
  return;
379
417
  }
380
- this.#run = run;
381
418
  this.#timer ??= setInterval(() => { this.render(); }, 80);
382
419
  this.#timer.unref();
383
420
  this.render();
@@ -385,9 +422,18 @@ class CliProgress {
385
422
  render() {
386
423
  if (!this.#run)
387
424
  return;
425
+ const run = this.#run.state !== "running" ? this.#run : { ...this.#run, usage: { ...(this.#run.usage ?? { tokens: 0, costUsd: 0, durationMs: 0, agentLaunches: 0 }), durationMs: Math.max(this.#run.usage?.durationMs ?? 0, this.#runtimeBaseMs + Date.now() - this.#runtimeStartedAt) } };
426
+ if (!this.#interactive) {
427
+ const stable = formatWorkflowProgress(run, "◇", this.#styles);
428
+ if (stable !== this.#lastStable) {
429
+ this.#lastStable = stable;
430
+ this.stderr(`${stable}\n`);
431
+ }
432
+ return;
433
+ }
388
434
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
389
435
  const width = process.stderr.columns || 80;
390
- const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
436
+ const text = truncateWorkflowProgress(formatWorkflowProgress(run, spinner, this.#styles), width).join("\n");
391
437
  this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
392
438
  this.#lines = text.split("\n").length;
393
439
  }
@@ -406,18 +452,24 @@ class CliProgress {
406
452
  async function invokeWorkflow(fn, args, runtime, options, context) {
407
453
  if (!Value.Check(fn.input, args))
408
454
  throw new Error(`Invalid input for ${fn.name}`);
409
- const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY);
455
+ let announcedRunId;
456
+ const announceRunId = (runId) => { if (announcedRunId === runId)
457
+ return; announcedRunId = runId; options.stderr(`Run ID: ${runId}\n`); };
458
+ const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY, announceRunId);
410
459
  try {
411
460
  const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update) => { if (object(update) && object(update.details) && object(update.details.run))
412
461
  progress.update(update.details.run); }, context);
413
462
  const details = object(result.details) ? result.details : {};
463
+ const runId = typeof details.runId === "string" ? details.runId : undefined;
464
+ if (runId)
465
+ announceRunId(runId);
414
466
  if (has(details, "value"))
415
- return details.value;
467
+ return { value: details.value, ...(runId ? { runId } : {}) };
416
468
  const first = result.content[0];
417
469
  if (!first || first.type !== "text")
418
470
  throw new Error("Workflow returned no result");
419
471
  try {
420
- return parseJsonInput(first.text);
472
+ return { value: parseJsonInput(first.text), ...(runId ? { runId } : {}) };
421
473
  }
422
474
  catch {
423
475
  throw new Error("Workflow returned invalid JSON");
@@ -471,14 +523,16 @@ async function runWorkflowCli(rawArgs, options) {
471
523
  return 0;
472
524
  }
473
525
  const input = parseWorkflowCliArgs(fn.input, args.slice(1));
474
- const value = await invokeWorkflow(fn, input, runtime, options, context);
475
- options.write(`${JSON.stringify(value)}\n`);
526
+ const result = await invokeWorkflow(fn, input, runtime, options, context);
527
+ options.write(`${JSON.stringify(result.value)}\n`);
476
528
  return 0;
477
529
  });
478
530
  }
479
531
  async function exportWorkflowCli(rawArgs, options) {
480
532
  const parsed = stripTrustOptions(rawArgs);
481
533
  const args = parsed.args;
534
+ if (args.includes("--bundle"))
535
+ return bundleWorkflowCli(args.filter((arg) => arg !== "--bundle"), options);
482
536
  if (!args.length || args[0] === "--help" || args[0] === "-h") {
483
537
  options.write(exportUsage());
484
538
  return args.length ? 0 : 1;
@@ -534,6 +588,80 @@ async function exportWorkflowCli(rawArgs, options) {
534
588
  return 0;
535
589
  });
536
590
  }
591
+ async function bundleWorkflowCli(rawArgs, options) {
592
+ const parsed = stripTrustOptions(rawArgs);
593
+ const args = parsed.args;
594
+ if (!args.length || args[0] === "--help" || args[0] === "-h") {
595
+ options.write(bundleUsage());
596
+ return args.length ? 0 : 1;
597
+ }
598
+ const workflowName = args[0];
599
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
600
+ let name;
601
+ let output;
602
+ let force = false;
603
+ const requirements = { roles: [], aliases: [], tools: [], commands: [], environment: [] };
604
+ const resources = { extensions: [], skills: [], static: [], dependencies: [] };
605
+ for (let index = 1; index < args.length; index += 1) {
606
+ const arg = args[index];
607
+ if (arg === "--force") {
608
+ force = true;
609
+ continue;
610
+ }
611
+ const equals = arg.indexOf("=");
612
+ const option = equals >= 0 ? arg.slice(0, equals) : arg;
613
+ const requirementOptions = { "--role": "roles", "--alias": "aliases", "--tool": "tools", "--command": "commands", "--environment": "environment" };
614
+ const resourceOptions = { "--extension": "extensions", "--skill": "skills", "--resource": "static", "--dependency": "dependencies" };
615
+ if (option === "--name" || option === "--output" || Object.prototype.hasOwnProperty.call(requirementOptions, option) || Object.prototype.hasOwnProperty.call(resourceOptions, option)) {
616
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
617
+ if (!value)
618
+ throw new Error(`Missing value for ${option}`);
619
+ if (option === "--name")
620
+ name = value;
621
+ else if (option === "--output")
622
+ output = value;
623
+ else if (Object.prototype.hasOwnProperty.call(requirementOptions, option))
624
+ requirements[requirementOptions[option]].push(value);
625
+ else
626
+ resources[resourceOptions[option]].push(value);
627
+ continue;
628
+ }
629
+ if (arg === "--help" || arg === "-h") {
630
+ options.write(bundleUsage());
631
+ return 0;
632
+ }
633
+ throw new Error(`Unknown option: ${arg}`);
634
+ }
635
+ const fn = runtime.catalog.functions.find((candidate) => candidate.name === workflowName);
636
+ if (!fn)
637
+ throw new Error(`Unknown workflow function: ${workflowName}`);
638
+ const registered = registeredWorkflowFunctions()[workflowName];
639
+ if (!registered)
640
+ throw new Error(`Workflow ${workflowName} is not exportable because its registered implementation is unavailable`);
641
+ const definitions = requirements.roles.length ? loadAgentDefinitions(options.cwd ?? process.cwd(), options.agentDir ?? getAgentDir(), runtime.services.settingsManager.isProjectTrusted()) : {};
642
+ const roles = Object.fromEntries(requirements.roles.map((role) => {
643
+ if (!role || role === "." || role === ".." || role.includes("/") || role.includes("\\"))
644
+ throw new Error(`Invalid role name for bundle: ${role}`);
645
+ const definition = definitions[role];
646
+ if (!definition)
647
+ throw new Error(`Unknown role for bundle: ${role}`);
648
+ return [role, definition];
649
+ }));
650
+ const command = commandName(name ?? kebabCase(workflowName));
651
+ if (!command)
652
+ throw new Error("Command name must be a non-empty name without path separators");
653
+ const destination = output ?? join(homedir(), ".local", "share", "pi-extensible-workflows", "bundles", command);
654
+ const aliasTargets = Object.fromEntries(requirements.aliases.flatMap((name) => {
655
+ const target = runtime.catalog.modelAliases?.[name];
656
+ return typeof target === "string" ? [[name, target]] : [];
657
+ }));
658
+ const selectedResources = Object.values(resources).some((entries) => entries.length) ? resources : undefined;
659
+ writePortableWorkflowBundle({ destination, command, workflow: fn, functionSource: registered.run.toString(), requirements, aliasTargets, roles, ...(selectedResources ? { resources: selectedResources } : {}), piVersion: portablePiVersion(), engineVersion: portableEngineVersion(), force });
660
+ options.write(`Bundled ${workflowName} at ${destination}\n`);
661
+ options.write(`Run ${join(destination, command)} setup before launching the workflow.\n`);
662
+ return 0;
663
+ });
664
+ }
537
665
  export async function runCli(args, options = {}, write = (text) => { process.stdout.write(text); }) {
538
666
  const stderr = options.stderr ?? ((text) => { process.stderr.write(text); });
539
667
  if (args[0] === "doctor" && args.length === 1) {
@@ -558,9 +686,13 @@ export async function runCli(args, options = {}, write = (text) => { process.std
558
686
  return 1;
559
687
  }
560
688
  }
561
- if (args[0] === "inspect" && args.length <= 2) {
689
+ if (args[0] === "inspect") {
562
690
  try {
563
- await (options.inspect ?? runSessionInspector)(args[1]);
691
+ const parsed = parseInspectArgs(args.slice(1));
692
+ if (options.inspect)
693
+ await options.inspect(parsed.sessionId, parsed.mode, parsed.failedOnly);
694
+ else
695
+ await runSessionInspector(parsed.sessionId, parsed.mode, options.cwd ?? process.cwd(), undefined, write, parsed.failedOnly);
564
696
  return 0;
565
697
  }
566
698
  catch (error) {
@@ -581,9 +713,11 @@ export async function runCli(args, options = {}, write = (text) => { process.std
581
713
  return 1;
582
714
  }
583
715
  }
584
- if (args[0] === "run" || args[0] === "export") {
716
+ if (args[0] === "bundle" || args[0] === "run" || args[0] === "export") {
585
717
  try {
586
- const workflowOptions = { 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 } : {}) };
718
+ const workflowOptions = { 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] } : {}) };
719
+ if (args[0] === "bundle")
720
+ return await bundleWorkflowCli(args.slice(1), workflowOptions);
587
721
  return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
588
722
  }
589
723
  catch (error) {
@@ -591,7 +725,7 @@ export async function runCli(args, options = {}, write = (text) => { process.std
591
725
  return 1;
592
726
  }
593
727
  }
594
- 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");
728
+ 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");
595
729
  return 1;
596
730
  }
597
731
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
@@ -10,7 +10,7 @@ import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, Run
10
10
  const TERMINAL_STATES = new Set(["completed", "failed", "stopped"]);
11
11
  const DAY_MS = 24 * 60 * 60 * 1000;
12
12
  const REQUIRED_RUN_FILES = ["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"];
13
- const OPTIONAL_RUN_FILES = new Set(["result.json"]);
13
+ const OPTIONAL_RUN_FILES = new Set(["result.json", "summary.json"]);
14
14
  const RUN_DIRECTORIES = new Set(["worktrees"]);
15
15
  const RUN_FILES = new Set([...REQUIRED_RUN_FILES, ...OPTIONAL_RUN_FILES]);
16
16
  const AGENT_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
@@ -63,35 +63,70 @@ function validateScheduledOptions(value, label) { if (!object(value) || typeof v
63
63
  optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`);
64
64
  optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`);
65
65
  } }
66
- function validateAgent(value, label) { 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))
67
- throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined)
68
- 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) {
69
- if (!Array.isArray(value.attemptDetails))
70
- throw new Error(`${label}.attemptDetails is invalid`);
71
- for (const [index, attempt] of value.attemptDetails.entries()) {
72
- const at = `${label}.attemptDetails[${String(index)}]`;
73
- if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile)
74
- throw new Error(`${at} is invalid`);
75
- accounting(attempt.accounting, `${at}.accounting`);
76
- if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string"))
77
- throw new Error(`${at}.error is invalid`);
78
- if (attempt.setup !== undefined) {
79
- if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string")
80
- throw new Error(`${at}.setup is invalid`);
81
- model(attempt.setup.model, `${at}.setup.model`);
82
- stringList(attempt.setup.tools, `${at}.setup.tools`);
83
- resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`);
66
+ function validateSessionReference(value, label) {
67
+ if (!object(value) || typeof value.transport !== "string" || !value.transport || typeof value.sessionId !== "string" || !value.sessionId || (value.locator !== undefined && !jsonValue(value.locator)))
68
+ throw new Error(`${label} is invalid`);
69
+ }
70
+ function validateAgentSetup(value, label) {
71
+ if (!object(value))
72
+ throw new Error(`${label} is invalid`);
73
+ stringList(value.hookNames, `${label}.hookNames`);
74
+ model(value.model, `${label}.model`);
75
+ stringList(value.tools, `${label}.tools`);
76
+ if (typeof value.cwd !== "string" || !value.cwd)
77
+ throw new Error(`${label}.cwd is invalid`);
78
+ resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`);
79
+ }
80
+ function validateAgent(value, label) {
81
+ 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))
82
+ throw new Error(`${label} is invalid`);
83
+ optionalString(value.systemPrompt, `${label}.systemPrompt`);
84
+ optionalString(value.prompt, `${label}.prompt`);
85
+ optionalString(value.label, `${label}.label`);
86
+ optionalString(value.parentId, `${label}.parentId`);
87
+ if (value.structuralPath !== undefined)
88
+ stringList(value.structuralPath, `${label}.structuralPath`);
89
+ optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`);
90
+ optionalString(value.worktreeOwner, `${label}.worktreeOwner`);
91
+ optionalString(value.role, `${label}.role`);
92
+ optionalString(value.requestedModel, `${label}.requestedModel`);
93
+ model(value.model, `${label}.model`);
94
+ stringList(value.tools, `${label}.tools`);
95
+ nonNegativeInteger(value.attempts, `${label}.attempts`);
96
+ if (value.attemptDetails !== undefined) {
97
+ if (!Array.isArray(value.attemptDetails))
98
+ throw new Error(`${label}.attemptDetails is invalid`);
99
+ for (const [index, attempt] of value.attemptDetails.entries()) {
100
+ const at = `${label}.attemptDetails[${String(index)}]`;
101
+ if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.transport !== "string" || !attempt.transport || Object.hasOwn(attempt, "sessionId") || Object.hasOwn(attempt, "sessionFile"))
102
+ throw new Error(`${at} is invalid`);
103
+ validateAgentSetup(attempt.setup, `${at}.setup`);
104
+ if (attempt.session !== undefined) {
105
+ validateSessionReference(attempt.session, `${at}.session`);
106
+ if (attempt.session.transport !== attempt.transport)
107
+ throw new Error(`${at}.session transport does not match attempt transport`);
108
+ }
109
+ accounting(attempt.accounting, `${at}.accounting`);
110
+ if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string"))
111
+ throw new Error(`${at}.error is invalid`);
84
112
  }
85
113
  }
86
- } if (value.accounting !== undefined)
87
- accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) {
88
- if (!Array.isArray(value.toolCalls))
89
- throw new Error(`${label}.toolCalls is invalid`);
90
- for (const call of value.toolCalls)
91
- if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state))
114
+ if (value.accounting !== undefined)
115
+ accounting(value.accounting, `${label}.accounting`);
116
+ if (value.toolCalls !== undefined) {
117
+ if (!Array.isArray(value.toolCalls))
92
118
  throw new Error(`${label}.toolCalls is invalid`);
93
- } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind) || typeof value.activity.text !== "string"))
94
- throw new Error(`${label}.activity is invalid`); }
119
+ for (const [index, call] of value.toolCalls.entries())
120
+ if (!object(call) || typeof call.id !== "string" || !call.id || typeof call.name !== "string" || !call.name || !["running", "completed", "failed"].includes(call.state))
121
+ throw new Error(`${label}.toolCalls[${String(index)}] is invalid`);
122
+ }
123
+ if (value.activity !== undefined) {
124
+ if (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind) || typeof value.activity.text !== "string")
125
+ throw new Error(`${label}.activity is invalid`);
126
+ }
127
+ if (value.lastEventAt !== undefined)
128
+ finiteNumber(value.lastEventAt, `${label}.lastEventAt`);
129
+ }
95
130
  function validateUsage(value, label) { if (!object(value))
96
131
  throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"])
97
132
  finiteNumber(value[key], `${label}.${key}`); }
@@ -106,7 +141,7 @@ function validateBudgetEvents(value) { if (value === undefined)
106
141
  } }
107
142
  function validateRunRecord(run) {
108
143
  const value = run;
109
- if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.workflowName !== "string" || !value.workflowName || typeof value.cwd !== "string" || !value.cwd || typeof value.sessionId !== "string" || !value.sessionId || !RUN_STATES.includes(value.state) || !Array.isArray(value.agents) || !Array.isArray(value.nativeSessions))
144
+ if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.workflowName !== "string" || !value.workflowName || typeof value.cwd !== "string" || !value.cwd || typeof value.sessionId !== "string" || !value.sessionId || !RUN_STATES.includes(value.state) || !Array.isArray(value.agents) || !Array.isArray(value.agentSessions) || Object.hasOwn(value, "nativeSessions"))
110
145
  throw new Error("Persisted run state is invalid");
111
146
  const agents = value.agents;
112
147
  agents.forEach((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); });
@@ -131,10 +166,11 @@ function validateRunRecord(run) {
131
166
  parent = typeof parentAgent?.parentId === "string" ? parentAgent.parentId : undefined;
132
167
  }
133
168
  }
134
- for (const [index, session] of value.nativeSessions.entries())
135
- if (!object(session) || typeof session.sessionId !== "string" || !session.sessionId || typeof session.sessionFile !== "string" || !session.sessionFile)
136
- throw new Error(`nativeSessions[${String(index)}] is invalid`);
169
+ const sessions = value.agentSessions;
170
+ for (const [index, session] of sessions.entries())
171
+ validateSessionReference(session, `agentSessions[${String(index)}]`);
137
172
  optionalString(value.parentRunId, "Persisted parent run");
173
+ optionalString(value.failedAt, "Persisted failed path");
138
174
  if (value.retry !== undefined) {
139
175
  if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId)
140
176
  throw new Error("Persisted retry provenance is invalid");
@@ -265,6 +301,7 @@ async function validateRunArtifacts(store, workflowScript, state) {
265
301
  throw new Error(`${label} is invalid`);
266
302
  ownershipIds.add(record.id);
267
303
  optionalString(record.parentId, `${label}.parentId`);
304
+ optionalString(record.prompt, `${label}.prompt`);
268
305
  validateScheduledOptions(record.options, `${label}.options`);
269
306
  }
270
307
  for (const record of ownership)
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { validateWorkflowLaunch, WorkflowError, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET } from "./index.js";
4
+ import { validateWorkflowLaunch, WorkflowError, WORKFLOW_RETRY_PARAMETERS, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET } from "./index.js";
5
5
  export const CAPTURE_IDENTITY = "pi-extensible-workflows-eval-capture-v1";
6
6
  export const CAPTURE_ERROR_PREFIX = `${CAPTURE_IDENTITY}:`;
7
7
  export function resolveWorkflowSkillPath() {
@@ -45,4 +45,19 @@ export default function evalCaptureExtension(pi) {
45
45
  }
46
46
  },
47
47
  });
48
+ pi.registerTool({
49
+ name: "workflow_retry",
50
+ label: "Workflow Retry",
51
+ description: "Capture a recovery selection without executing it",
52
+ parameters: WORKFLOW_RETRY_PARAMETERS,
53
+ async execute(_id, params) {
54
+ if (!params.runId.trim())
55
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `${CAPTURE_ERROR_PREFIX}RESUME_INCOMPATIBLE: workflow_retry requires an explicit run ID`);
56
+ const selection = { tool: "workflow_retry", arguments: { runId: params.runId } };
57
+ return {
58
+ content: [{ type: "text", text: JSON.stringify({ captured: true, ...selection }) }],
59
+ details: { captured: true, captureIdentity: CAPTURE_IDENTITY, realWorkflowAgentsLaunched: 0, selection },
60
+ };
61
+ },
62
+ });
48
63
  }
@@ -12,6 +12,6 @@ export declare function agentWorktree(identity: AgentIdentity): {
12
12
  };
13
13
  export declare function executeShellCommand(command: string, options: ShellOptions, signal: AbortSignal, cwd?: string): Promise<ShellResult>;
14
14
  export declare function runWorkflow(script: string, args?: JsonValue, bridge?: WorkflowBridge, signal?: AbortSignal): WorkflowExecution;
15
- export declare function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void>;
15
+ export declare function persistActiveAgentAttempt(store: RunStore, id: string, active: AgentAttempt): Promise<void>;
16
16
  export declare function persistAgentAttempts(store: RunStore, id: string, attempts: readonly AgentAttempt[]): Promise<void>;
17
17
  export type { AgentIdentity, ShellIdentity, WorkflowBridge, WorkflowExecution } from "./types.js";
@@ -9,6 +9,7 @@ import { asWorkflowError, errorText, fail, isWorkflowAuthored, jsonValue, markWo
9
9
  import { instrumentWorkflow, validateAgentOptions, validateShellCommand, validateShellOptions } from "./validation.js";
10
10
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
11
11
  export const HEARTBEAT_TIMEOUT_MS = 5000;
12
+ const HEARTBEAT_INTERVAL_MS = 1000;
12
13
  const OUTCOME_ERRORS = new Set(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
13
14
  const WORK_RESULT_BRAND = "__workResult";
14
15
  const childSource = String.raw `
@@ -58,7 +59,7 @@ process.on("message", raw => {
58
59
  if (message.ok) request.resolve(message.value);
59
60
  else request.reject(workflowError(message.error));
60
61
  });
61
- const heartbeat = setInterval(() => send({ type: "heartbeat" }), 1000);
62
+ const heartbeat = setInterval(() => send({ type: "heartbeat" }), ${HEARTBEAT_INTERVAL_MS});
62
63
  send({ type: "heartbeat" });
63
64
  const BRAND = "${WORK_RESULT_BRAND}";
64
65
  const workError = (code, message) => Object.assign(new Error(message), { code });
@@ -458,7 +459,20 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
458
459
  const controller = new AbortController();
459
460
  let settled = false;
460
461
  let rejectResult = () => undefined;
461
- let watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
462
+ let lastHeartbeatAt = performance.now();
463
+ let lastWatchdogCheckAt = lastHeartbeatAt;
464
+ const watchdog = setInterval(() => {
465
+ const now = performance.now();
466
+ // A check delayed enough to bridge the normal heartbeat margin makes elapsed time unreliable.
467
+ const watchdogCheckWasDelayed = now - lastWatchdogCheckAt >= HEARTBEAT_TIMEOUT_MS - HEARTBEAT_INTERVAL_MS;
468
+ lastWatchdogCheckAt = now;
469
+ if (watchdogCheckWasDelayed) {
470
+ lastHeartbeatAt = now;
471
+ return;
472
+ }
473
+ if (now - lastHeartbeatAt >= HEARTBEAT_TIMEOUT_MS)
474
+ stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat");
475
+ }, HEARTBEAT_INTERVAL_MS);
462
476
  const result = new Promise((resolve, reject) => {
463
477
  rejectResult = reject;
464
478
  child.on("message", (raw) => {
@@ -469,8 +483,7 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
469
483
  if (!jsonValue(message))
470
484
  fail("RPC_LIMIT_EXCEEDED", "Worker RPC must contain JSON-compatible values");
471
485
  if (message.type === "heartbeat") {
472
- clearTimeout(watchdog);
473
- watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
486
+ lastHeartbeatAt = performance.now();
474
487
  return;
475
488
  }
476
489
  if (message.type === "result") {
@@ -502,7 +515,7 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
502
515
  child.kill("SIGKILL"); }, 1000).unref();
503
516
  }
504
517
  }
505
- function finish() { settled = true; clearTimeout(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
518
+ function finish() { settled = true; clearInterval(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
506
519
  function stop(code, message) { if (settled)
507
520
  return; controller.abort(); finish(); rejectResult(new WorkflowError(code, message)); }
508
521
  function branded(result) { return { ...result, [WORK_RESULT_BRAND]: true }; }
@@ -612,8 +625,8 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
612
625
  signal?.addEventListener("abort", cancel, { once: true });
613
626
  return { result, cancel };
614
627
  }
615
- function nativeSessionReference(attempt) {
616
- return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
628
+ function attemptSummary(attempt, accounting = attempt.accounting) {
629
+ return { attempt: attempt.attempt, transport: attempt.transport, ...(attempt.session ? { session: attempt.session } : {}), ...(attempt.error ? { error: attempt.error } : {}), accounting, setup: attempt.setup };
617
630
  }
618
631
  export async function persistActiveAgentAttempt(store, id, active) {
619
632
  await store.updateState((run) => {
@@ -621,9 +634,11 @@ export async function persistActiveAgentAttempt(store, id, active) {
621
634
  if (!agent)
622
635
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
623
636
  const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
624
- const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
625
- const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
626
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
637
+ const detail = attemptSummary(active, accounting);
638
+ const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), detail];
639
+ const session = active.session;
640
+ const agentSessions = session && !run.agentSessions.some(({ transport, sessionId }) => transport === session.transport && sessionId === session.sessionId) ? [...run.agentSessions, session] : run.agentSessions;
641
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), agentSessions };
627
642
  });
628
643
  }
629
644
  export async function persistAgentAttempts(store, id, attempts) {
@@ -632,8 +647,9 @@ export async function persistAgentAttempts(store, id, attempts) {
632
647
  if (!agent)
633
648
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
634
649
  const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
635
- const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
636
- const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
637
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
650
+ const attemptDetails = attempts.map((attempt) => attemptSummary(attempt));
651
+ const sessions = attempts.map((attempt) => attempt.session).filter((session) => session !== undefined);
652
+ const sessionKeys = new Set(sessions.map(({ transport, sessionId }) => `${transport}:${sessionId}`));
653
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), agentSessions: [...run.agentSessions.filter(({ transport, sessionId }) => !sessionKeys.has(`${transport}:${sessionId}`)), ...sessions] };
638
654
  });
639
655
  }