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/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,26 +322,48 @@ class CliProgress {
304
322
  #lines = 0;
305
323
  #frame = 0;
306
324
  #run: PersistedRun | undefined;
325
+ #runId: string | undefined;
326
+ #runtimeStartedAt = 0;
327
+ #runtimeBaseMs = 0;
307
328
  #timer: ReturnType<typeof setInterval> | undefined;
308
329
  #interactive: boolean;
309
330
  #styles: WorkflowProgressStyles;
310
- constructor(private readonly stderr: (text: string) => void, tty: boolean) {
331
+ constructor(private readonly stderr: (text: string) => void, tty: boolean, private readonly onRunId: (runId: string) => void) {
311
332
  this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
312
333
  this.#styles = terminalProgressStyles(this.#interactive);
313
334
  }
314
335
  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; }
336
+ if (this.#runId !== run.id) {
337
+ this.#runId = run.id;
338
+ this.onRunId(run.id);
339
+ this.#runtimeStartedAt = Date.now();
340
+ this.#runtimeBaseMs = run.usage?.durationMs ?? 0;
341
+ } else if (this.#run && this.#run.state !== "running" && run.state === "running") {
342
+ this.#runtimeStartedAt = Date.now();
343
+ this.#runtimeBaseMs = run.usage?.durationMs ?? 0;
344
+ }
317
345
  this.#run = run;
346
+ if (!this.#interactive) {
347
+ this.#timer ??= setInterval(() => { this.render(); }, 1000);
348
+ this.#timer.unref();
349
+ this.render();
350
+ return;
351
+ }
318
352
  this.#timer ??= setInterval(() => { this.render(); }, 80);
319
353
  this.#timer.unref();
320
354
  this.render();
321
355
  }
322
356
  render(): void {
323
357
  if (!this.#run) return;
358
+ 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) } };
359
+ if (!this.#interactive) {
360
+ const stable = formatWorkflowProgress(run, "◇", this.#styles);
361
+ if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); }
362
+ return;
363
+ }
324
364
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
325
365
  const width = process.stderr.columns || 80;
326
- const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
366
+ const text = truncateWorkflowProgress(formatWorkflowProgress(run, spinner, this.#styles), width).join("\n");
327
367
  this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
328
368
  this.#lines = text.split("\n").length;
329
369
  }
@@ -334,16 +374,21 @@ class CliProgress {
334
374
  }
335
375
  }
336
376
 
337
- async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<JsonValue> {
377
+ type CliWorkflowResult = { value: JsonValue; runId?: string };
378
+ async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<CliWorkflowResult> {
338
379
  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);
380
+ let announcedRunId: string | undefined;
381
+ const announceRunId = (runId: string) => { if (announcedRunId === runId) return; announcedRunId = runId; options.stderr(`Run ID: ${runId}\n`); };
382
+ const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY, announceRunId);
340
383
  try {
341
384
  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
385
  const details = object(result.details) ? result.details : {};
343
- if (has(details, "value")) return details.value as JsonValue;
386
+ const runId = typeof details.runId === "string" ? details.runId : undefined;
387
+ if (runId) announceRunId(runId);
388
+ if (has(details, "value")) return { value: details.value as JsonValue, ...(runId ? { runId } : {}) };
344
389
  const first = result.content[0];
345
390
  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"); }
391
+ try { return { value: parseJsonInput(first.text), ...(runId ? { runId } : {}) }; } catch { throw new Error("Workflow returned invalid JSON"); }
347
392
  } finally {
348
393
  progress.finish();
349
394
  }
@@ -385,8 +430,8 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
385
430
  if (!fn) throw new Error(`Unknown workflow function: ${name}`);
386
431
  if (help) { options.write(formatWorkflowCliHelp(fn)); return 0; }
387
432
  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`);
433
+ const result = await invokeWorkflow(fn, input, runtime, options, context);
434
+ options.write(`${JSON.stringify(result.value)}\n`);
390
435
  return 0;
391
436
  });
392
437
  }
@@ -394,6 +439,7 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
394
439
  async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
395
440
  const parsed = stripTrustOptions(rawArgs);
396
441
  const args = parsed.args;
442
+ if (args.includes("--bundle")) return bundleWorkflowCli(args.filter((arg) => arg !== "--bundle"), options);
397
443
  if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(exportUsage()); return args.length ? 0 : 1; }
398
444
  const workflowName = args[0] as string;
399
445
  return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
@@ -429,6 +475,62 @@ async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo
429
475
  });
430
476
  }
431
477
 
478
+ async function bundleWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
479
+ const parsed = stripTrustOptions(rawArgs);
480
+ const args = parsed.args;
481
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(bundleUsage()); return args.length ? 0 : 1; }
482
+ const workflowName = args[0] as string;
483
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
484
+ let name: string | undefined;
485
+ let output: string | undefined;
486
+ let force = false;
487
+ const requirements = { roles: [] as string[], aliases: [] as string[], tools: [] as string[], commands: [] as string[], environment: [] as string[] };
488
+ const resources = { extensions: [] as string[], skills: [] as string[], static: [] as string[], dependencies: [] as string[] };
489
+ for (let index = 1; index < args.length; index += 1) {
490
+ const arg = args[index] as string;
491
+ if (arg === "--force") { force = true; continue; }
492
+ const equals = arg.indexOf("=");
493
+ const option = equals >= 0 ? arg.slice(0, equals) : arg;
494
+ const requirementOptions = { "--role": "roles", "--alias": "aliases", "--tool": "tools", "--command": "commands", "--environment": "environment" } as const;
495
+ const resourceOptions = { "--extension": "extensions", "--skill": "skills", "--resource": "static", "--dependency": "dependencies" } as const;
496
+ if (option === "--name" || option === "--output" || Object.prototype.hasOwnProperty.call(requirementOptions, option) || Object.prototype.hasOwnProperty.call(resourceOptions, option)) {
497
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
498
+ if (!value) throw new Error(`Missing value for ${option}`);
499
+ if (option === "--name") name = value;
500
+ else if (option === "--output") output = value;
501
+ else if (Object.prototype.hasOwnProperty.call(requirementOptions, option)) requirements[requirementOptions[option as keyof typeof requirementOptions]].push(value);
502
+ else resources[resourceOptions[option as keyof typeof resourceOptions]].push(value);
503
+ continue;
504
+ }
505
+ if (arg === "--help" || arg === "-h") { options.write(bundleUsage()); return 0; }
506
+ throw new Error(`Unknown option: ${arg}`);
507
+ }
508
+ const fn = runtime.catalog.functions.find((candidate) => candidate.name === workflowName);
509
+ if (!fn) throw new Error(`Unknown workflow function: ${workflowName}`);
510
+ const registered = registeredWorkflowFunctions()[workflowName];
511
+ if (!registered) throw new Error(`Workflow ${workflowName} is not exportable because its registered implementation is unavailable`);
512
+ const definitions = requirements.roles.length ? loadAgentDefinitions(options.cwd ?? process.cwd(), options.agentDir ?? getAgentDir(), runtime.services.settingsManager.isProjectTrusted()) : {};
513
+ const roles = Object.fromEntries(requirements.roles.map((role) => {
514
+ if (!role || role === "." || role === ".." || role.includes("/") || role.includes("\\")) throw new Error(`Invalid role name for bundle: ${role}`);
515
+ const definition = definitions[role];
516
+ if (!definition) throw new Error(`Unknown role for bundle: ${role}`);
517
+ return [role, definition];
518
+ }));
519
+ const command = commandName(name ?? kebabCase(workflowName));
520
+ if (!command) throw new Error("Command name must be a non-empty name without path separators");
521
+ const destination = output ?? join(homedir(), ".local", "share", "pi-extensible-workflows", "bundles", command);
522
+ const aliasTargets = Object.fromEntries(requirements.aliases.flatMap((name) => {
523
+ const target = runtime.catalog.modelAliases?.[name];
524
+ return typeof target === "string" ? [[name, target]] : [];
525
+ }));
526
+ const selectedResources = Object.values(resources).some((entries) => entries.length) ? resources : undefined;
527
+ writePortableWorkflowBundle({ destination, command, workflow: fn, functionSource: registered.run.toString(), requirements, aliasTargets, roles, ...(selectedResources ? { resources: selectedResources } : {}), piVersion: portablePiVersion(), engineVersion: portableEngineVersion(), force });
528
+ options.write(`Bundled ${workflowName} at ${destination}\n`);
529
+ options.write(`Run ${join(destination, command)} setup before launching the workflow.\n`);
530
+ return 0;
531
+ });
532
+ }
533
+
432
534
  export async function runCli(args: readonly string[], options: CliOptions = {}, write: (text: string) => void = (text) => { process.stdout.write(text); }): Promise<number> {
433
535
  const stderr = options.stderr ?? ((text: string) => { process.stderr.write(text); });
434
536
  if (args[0] === "doctor" && args.length === 1) {
@@ -446,8 +548,13 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
446
548
  return doctorCleanupExitCode(report);
447
549
  } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
448
550
  }
449
- if (args[0] === "inspect" && args.length <= 2) {
450
- try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
551
+ if (args[0] === "inspect") {
552
+ try {
553
+ const parsed = parseInspectArgs(args.slice(1));
554
+ if (options.inspect) await options.inspect(parsed.sessionId, parsed.mode, parsed.failedOnly);
555
+ else await runSessionInspector(parsed.sessionId, parsed.mode, options.cwd ?? process.cwd(), undefined, write, parsed.failedOnly);
556
+ return 0;
557
+ }
451
558
  catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
452
559
  }
453
560
  if (args[0] === "transcript" && args.length === 2) {
@@ -457,13 +564,14 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
457
564
  return 0;
458
565
  } catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
459
566
  }
460
- if (args[0] === "run" || args[0] === "export") {
567
+ if (args[0] === "bundle" || args[0] === "run" || args[0] === "export") {
461
568
  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 } : {}) };
569
+ 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] } : {}) };
570
+ if (args[0] === "bundle") return await bundleWorkflowCli(args.slice(1), workflowOptions);
463
571
  return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
464
572
  } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
465
573
  }
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");
574
+ 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
575
  return 1;
468
576
  }
469
577
 
@@ -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,12 +46,43 @@ 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 validateSessionReference(value: unknown, label: string): void {
50
+ if (!object(value) || typeof value.transport !== "string" || !value.transport || typeof value.sessionId !== "string" || !value.sessionId || (value.locator !== undefined && !jsonValue(value.locator))) throw new Error(`${label} is invalid`);
51
+ }
52
+ function validateAgentSetup(value: unknown, label: string): void {
53
+ if (!object(value)) throw new Error(`${label} is invalid`);
54
+ stringList(value.hookNames, `${label}.hookNames`);
55
+ model(value.model, `${label}.model`);
56
+ stringList(value.tools, `${label}.tools`);
57
+ if (typeof value.cwd !== "string" || !value.cwd) throw new Error(`${label}.cwd is invalid`);
58
+ resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`);
59
+ }
60
+ function validateAgent(value: unknown, label: string): void {
61
+ 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`);
62
+ optionalString(value.systemPrompt, `${label}.systemPrompt`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`);
63
+ 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`);
64
+ model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`);
65
+ if (value.attemptDetails !== undefined) {
66
+ if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`);
67
+ for (const [index, attempt] of value.attemptDetails.entries()) {
68
+ const at = `${label}.attemptDetails[${String(index)}]`;
69
+ 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")) throw new Error(`${at} is invalid`);
70
+ validateAgentSetup(attempt.setup, `${at}.setup`);
71
+ if (attempt.session !== undefined) { validateSessionReference(attempt.session, `${at}.session`); if ((attempt.session as Record<string, unknown>).transport !== attempt.transport) throw new Error(`${at}.session transport does not match attempt transport`); }
72
+ accounting(attempt.accounting, `${at}.accounting`);
73
+ if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`);
74
+ }
75
+ }
76
+ if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`);
77
+ if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const [index, call] of value.toolCalls.entries()) if (!object(call) || typeof call.id !== "string" || !call.id || typeof call.name !== "string" || !call.name || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls[${String(index)}] is invalid`); }
78
+ if (value.activity !== undefined) { if (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string") throw new Error(`${label}.activity is invalid`); }
79
+ if (value.lastEventAt !== undefined) finiteNumber(value.lastEventAt, `${label}.lastEventAt`);
80
+ }
50
81
  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
82
  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
83
  function validateRunRecord(run: PersistedRun): void {
53
84
  const value = run as unknown;
54
- 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 as RunState) || !Array.isArray(value.agents) || !Array.isArray(value.nativeSessions)) throw new Error("Persisted run state is invalid");
85
+ 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 as RunState) || !Array.isArray(value.agents) || !Array.isArray(value.agentSessions) || Object.hasOwn(value, "nativeSessions")) throw new Error("Persisted run state is invalid");
55
86
  const agents = value.agents as Record<string, unknown>[];
56
87
  agents.forEach((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); });
57
88
  const agentIds = new Set<string>();
@@ -72,8 +103,10 @@ function validateRunRecord(run: PersistedRun): void {
72
103
  parent = typeof parentAgent?.parentId === "string" ? parentAgent.parentId : undefined;
73
104
  }
74
105
  }
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`);
106
+ const sessions = value.agentSessions as unknown[];
107
+ for (const [index, session] of sessions.entries()) validateSessionReference(session, `agentSessions[${String(index)}]`);
76
108
  optionalString(value.parentRunId, "Persisted parent run");
109
+ optionalString(value.failedAt, "Persisted failed path");
77
110
  if (value.retry !== undefined) {
78
111
  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
112
  const sourceRunId = value.retry.sourceRunId;
@@ -145,6 +178,7 @@ async function validateRunArtifacts(store: RunStore, workflowScript: string, sta
145
178
  if (!object(record) || typeof record.id !== "string" || !record.id || ownershipIds.has(record.id) || typeof record.label !== "string" || !record.label || typeof record.state !== "string" || !SCHEDULER_STATES.has(record.state)) throw new Error(`${label} is invalid`);
146
179
  ownershipIds.add(record.id);
147
180
  optionalString(record.parentId, `${label}.parentId`);
181
+ optionalString(record.prompt, `${label}.prompt`);
148
182
  validateScheduledOptions(record.options, `${label}.options`);
149
183
  }
150
184
  for (const record of ownership) if (object(record) && record.parentId !== undefined && (typeof record.parentId !== "string" || !ownershipIds.has(record.parentId))) throw new Error("Persisted ownership parent is missing");
@@ -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
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { StringDecoder } from "node:string_decoder";
6
6
  import { RunStore, structuralPath as operationPath } from "./persistence.js";
7
7
  import type { AgentAttempt } from "./agent-execution.js";
8
- import type { AgentIdentity, JsonValue, ShellIdentity, ShellOptions, ShellResult, WorkflowBridge, WorkflowErrorCode, WorkflowExecution } from "./types.js";
8
+ import type { AgentIdentity, AgentAttemptSummary, JsonValue, ShellIdentity, ShellOptions, ShellResult, WorkflowAgentSessionReference, WorkflowBridge, WorkflowErrorCode, WorkflowExecution } from "./types.js";
9
9
  import { WorkflowError } from "./types.js";
10
10
  import { asWorkflowError, errorText, fail, isWorkflowAuthored, jsonValue, markWorkflowAuthored, object, positiveInteger } from "./utils.js";
11
11
  import { instrumentWorkflow, validateAgentOptions, validateShellCommand, validateShellOptions } from "./validation.js";
@@ -13,6 +13,7 @@ import { instrumentWorkflow, validateAgentOptions, validateShellCommand, validat
13
13
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
14
14
  type WorkerErrorShape = { code?: string; message: string; authored?: boolean; failedAt?: string };
15
15
  export const HEARTBEAT_TIMEOUT_MS = 5000;
16
+ const HEARTBEAT_INTERVAL_MS = 1000;
16
17
 
17
18
  const OUTCOME_ERRORS = new Set<string>(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
18
19
  const WORK_RESULT_BRAND = "__workResult";
@@ -64,7 +65,7 @@ process.on("message", raw => {
64
65
  if (message.ok) request.resolve(message.value);
65
66
  else request.reject(workflowError(message.error));
66
67
  });
67
- const heartbeat = setInterval(() => send({ type: "heartbeat" }), 1000);
68
+ const heartbeat = setInterval(() => send({ type: "heartbeat" }), ${HEARTBEAT_INTERVAL_MS});
68
69
  send({ type: "heartbeat" });
69
70
  const BRAND = "${WORK_RESULT_BRAND}";
70
71
  const workError = (code, message) => Object.assign(new Error(message), { code });
@@ -416,7 +417,16 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
416
417
  const controller = new AbortController();
417
418
  let settled = false;
418
419
  let rejectResult: (error: WorkflowError) => void = () => undefined;
419
- let watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
420
+ let lastHeartbeatAt = performance.now();
421
+ let lastWatchdogCheckAt = lastHeartbeatAt;
422
+ const watchdog = setInterval(() => {
423
+ const now = performance.now();
424
+ // A check delayed enough to bridge the normal heartbeat margin makes elapsed time unreliable.
425
+ const watchdogCheckWasDelayed = now - lastWatchdogCheckAt >= HEARTBEAT_TIMEOUT_MS - HEARTBEAT_INTERVAL_MS;
426
+ lastWatchdogCheckAt = now;
427
+ if (watchdogCheckWasDelayed) { lastHeartbeatAt = now; return; }
428
+ if (now - lastHeartbeatAt >= HEARTBEAT_TIMEOUT_MS) stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat");
429
+ }, HEARTBEAT_INTERVAL_MS);
420
430
  const result = new Promise<JsonValue>((resolve, reject) => {
421
431
  rejectResult = reject;
422
432
  child.on("message", (raw: unknown) => {
@@ -424,7 +434,7 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
424
434
  if (typeof raw !== "string" || Buffer.byteLength(raw) > RPC_LIMIT_BYTES) fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
425
435
  const message = JSON.parse(raw) as { type?: string; id?: number; method?: string; args?: JsonValue[]; ok?: boolean; value?: JsonValue; error?: WorkerErrorShape };
426
436
  if (!jsonValue(message)) fail("RPC_LIMIT_EXCEEDED", "Worker RPC must contain JSON-compatible values");
427
- if (message.type === "heartbeat") { clearTimeout(watchdog); watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS); return; }
437
+ if (message.type === "heartbeat") { lastHeartbeatAt = performance.now(); return; }
428
438
  if (message.type === "result") { encoded(message.value); finish(); resolve(message.value ?? null); return; }
429
439
  if (message.type === "error") { finish(); reject(workflowErrorFromWorker(message.error ?? { code: "INTERNAL_ERROR", message: "Worker failed" })); return; }
430
440
  if (message.type === "rpc" && message.id !== undefined) void handleRpc(message.id, message.method ?? "", message.args ?? []);
@@ -439,7 +449,7 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
439
449
  setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 1000).unref();
440
450
  }
441
451
  }
442
- function finish() { settled = true; clearTimeout(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
452
+ function finish() { settled = true; clearInterval(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
443
453
  function stop(code: WorkflowErrorCode, message: string) { if (settled) return; controller.abort(); finish(); rejectResult(new WorkflowError(code, message)); }
444
454
  function branded(result: Record<string, JsonValue>): JsonValue { return { ...result, [WORK_RESULT_BRAND]: true }; }
445
455
  async function handleRpc(id: number, method: string, values: JsonValue[]) {
@@ -520,18 +530,19 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
520
530
  if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
521
531
  return { result, cancel };
522
532
  }
523
- function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessionFile">): { sessionId: string; sessionFile: string } {
524
- return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
533
+ function attemptSummary(attempt: AgentAttempt, accounting = attempt.accounting): AgentAttemptSummary {
534
+ return { attempt: attempt.attempt, transport: attempt.transport, ...(attempt.session ? { session: attempt.session } : {}), ...(attempt.error ? { error: attempt.error } : {}), accounting, setup: attempt.setup };
525
535
  }
526
-
527
- export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
536
+ export async function persistActiveAgentAttempt(store: RunStore, id: string, active: AgentAttempt): Promise<void> {
528
537
  await store.updateState((run) => {
529
538
  const agent = run.agents.find((candidate) => candidate.id === id);
530
539
  if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
531
540
  const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
532
- const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
533
- const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
534
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
541
+ const detail = attemptSummary(active, accounting);
542
+ const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), detail];
543
+ const session = active.session;
544
+ const agentSessions = session && !run.agentSessions.some(({ transport, sessionId }) => transport === session.transport && sessionId === session.sessionId) ? [...run.agentSessions, session] : run.agentSessions;
545
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), agentSessions };
535
546
  });
536
547
  }
537
548
 
@@ -540,9 +551,10 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
540
551
  const agent = run.agents.find((candidate) => candidate.id === id);
541
552
  if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
542
553
  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 });
543
- const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
544
- const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
545
- 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))] };
554
+ const attemptDetails = attempts.map((attempt) => attemptSummary(attempt));
555
+ const sessions = attempts.map((attempt) => attempt.session).filter((session): session is WorkflowAgentSessionReference => session !== undefined);
556
+ const sessionKeys = new Set(sessions.map(({ transport, sessionId }) => `${transport}:${sessionId}`));
557
+ 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] };
546
558
  });
547
559
  }
548
560