pi-extensible-workflows 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/index.js CHANGED
@@ -1,21 +1,22 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { fork } from "node:child_process";
2
+ import { fork, spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, extname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { StringDecoder } from "node:string_decoder";
8
9
  import * as acorn from "acorn";
9
10
  import { Script } from "node:vm";
10
11
  import { Type } from "@earendil-works/pi-ai";
11
12
  import { Value } from "typebox/value";
12
- import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
13
+ import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
13
14
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
14
- import { transcriptLines } from "./session-inspector.js";
15
+ import { herdrPaneId, openHerdrPane } from "./herdr.js";
15
16
  import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
16
17
  export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"];
17
18
  export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"];
18
- export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
19
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree", "shell"];
19
20
  export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
20
21
  export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
21
22
  export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
@@ -29,10 +30,10 @@ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
29
30
  const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
30
31
  export const ERROR_CODES = [
31
32
  "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
32
- "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
33
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "SHELL_FAILED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
33
34
  "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
34
35
  ];
35
- export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
36
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 4;
36
37
  export class WorkflowError extends Error {
37
38
  code;
38
39
  constructor(code, message) {
@@ -41,6 +42,7 @@ export class WorkflowError extends Error {
41
42
  this.name = "WorkflowError";
42
43
  }
43
44
  }
45
+ const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
44
46
  const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
45
47
  function errorText(error) { return error && typeof error === "object" && typeof error.message === "string" ? error.message : error instanceof Error ? error.message : String(error); }
46
48
  function errorCode(error) {
@@ -70,12 +72,13 @@ const WORKFLOW_ERROR_PROSE = {
70
72
  INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
71
73
  REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
72
74
  GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
73
- MISSING_WORKFLOW: (detail) => `The workflow primitive is unavailable: ${detail}.`,
75
+ MISSING_WORKFLOW: (detail) => `The registered workflow function is unavailable: ${detail}.`,
74
76
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
75
77
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
76
78
  UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
77
79
  RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
78
80
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
81
+ SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
79
82
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
80
83
  AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
81
84
  RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
@@ -778,6 +781,7 @@ function hasIdentifier(node, name) {
778
781
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
779
782
  const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
780
783
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
784
+ const INTERNAL_SHELL_NAME = "__pi_extensible_workflows_shell";
781
785
  function callHasTrailingComma(source, call) {
782
786
  let previous;
783
787
  let current;
@@ -798,12 +802,14 @@ function instrumentWorkflow(script) {
798
802
  fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
799
803
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
800
804
  fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
801
- const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
805
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME))
806
+ fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
807
+ const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree", "shell"].includes(call.callee.name));
802
808
  const edits = calls.flatMap((call) => {
803
809
  const callSite = `${String(call.start)}:${String(call.end)}`;
804
810
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
805
811
  return [
806
- { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "conversation" ? INTERNAL_CONVERSATION_NAME : INTERNAL_WORKTREE_NAME },
812
+ { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "conversation" ? INTERNAL_CONVERSATION_NAME : call.callee.name === "withWorktree" ? INTERNAL_WORKTREE_NAME : INTERNAL_SHELL_NAME },
807
813
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
808
814
  ];
809
815
  }).sort((left, right) => right.start - left.start);
@@ -942,6 +948,23 @@ function validateAgentOptions(value) {
942
948
  fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
943
949
  return value;
944
950
  }
951
+ const SHELL_OPTION_KEYS = new Set(["timeoutMs", "env"]);
952
+ function validateShellOptions(value) {
953
+ if (value === undefined)
954
+ return {};
955
+ if (!object(value) || !jsonValue(value) || Object.keys(value).some((key) => !SHELL_OPTION_KEYS.has(key)))
956
+ fail("INVALID_METADATA", "shell options must contain only timeoutMs and env");
957
+ if (value.timeoutMs !== undefined && !positiveInteger(value.timeoutMs))
958
+ fail("INVALID_METADATA", "shell timeoutMs must be a positive integer");
959
+ if (value.env !== undefined && (!object(value.env) || Object.values(value.env).some((entry) => typeof entry !== "string")))
960
+ fail("INVALID_METADATA", "shell env must be an object of strings");
961
+ return { ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }), ...(value.env === undefined ? {} : { env: value.env }) };
962
+ }
963
+ function validateShellCommand(value) {
964
+ if (typeof value !== "string")
965
+ fail("INVALID_METADATA", "shell command must be a string");
966
+ return value;
967
+ }
945
968
  function staticValue(node) {
946
969
  if (!node)
947
970
  return { known: false };
@@ -1007,6 +1030,8 @@ export function inspectWorkflowScript(script) {
1007
1030
  }
1008
1031
  if (kind === "checkpoint")
1009
1032
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
1033
+ if (kind === "shell")
1034
+ return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
1010
1035
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
1011
1036
  });
1012
1037
  }
@@ -1022,6 +1047,18 @@ function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPat
1022
1047
  validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
1023
1048
  }
1024
1049
  }
1050
+ function validateStaticShellOptions(call) {
1051
+ if (call.arguments.some((argument) => argument.type === "SpreadElement"))
1052
+ return;
1053
+ if (call.arguments.length !== 1 && call.arguments.length !== 2)
1054
+ fail("INVALID_METADATA", "shell requires a command string and optional options");
1055
+ const command = staticValue(callArgument(call, 0));
1056
+ if (command.known)
1057
+ validateShellCommand(command.value);
1058
+ const options = staticValue(callArgument(call, 1));
1059
+ if (options.known)
1060
+ validateShellOptions(options.value);
1061
+ }
1025
1062
  function validateStaticWithWorktree(call) {
1026
1063
  if (call.arguments.some((argument) => argument.type === "SpreadElement"))
1027
1064
  return;
@@ -1036,13 +1073,12 @@ function validateStaticWithWorktree(call) {
1036
1073
  fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
1037
1074
  }
1038
1075
  }
1039
- const RESERVED_GLOBALS = new Set(["agent", "conversation", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
1076
+ const RESERVED_GLOBALS = new Set(["agent", "conversation", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
1040
1077
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
1041
1078
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
1042
1079
  export class WorkflowRegistry {
1043
1080
  #extensions = new Set();
1044
1081
  #globals = new Map();
1045
- #workflows = new Map();
1046
1082
  #hooks = new Map();
1047
1083
  #frozen = false;
1048
1084
  get frozen() { return this.#frozen; }
@@ -1050,14 +1086,15 @@ export class WorkflowRegistry {
1050
1086
  register(extension) {
1051
1087
  if (this.#frozen)
1052
1088
  fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
1053
- if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "workflows", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim())
1089
+ if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows"))
1090
+ fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
1091
+ if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim())
1054
1092
  fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
1055
1093
  const functions = extension.functions ?? {};
1056
1094
  const variables = extension.variables ?? {};
1057
- const workflows = extension.workflows ?? {};
1058
1095
  const agentSetupHooks = extension.agentSetupHooks ?? {};
1059
- if (!object(functions) || !object(variables) || !object(workflows) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0 && Object.keys(agentSetupHooks).length === 0))
1060
- fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
1096
+ if (!object(functions) || !object(variables) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(agentSetupHooks).length === 0))
1097
+ fail("INVALID_METADATA", "Workflow extensions require functions, variables, or agent setup hooks");
1061
1098
  const names = [...Object.keys(functions), ...Object.keys(variables)];
1062
1099
  if (new Set(names).size !== names.length)
1063
1100
  fail("GLOBAL_COLLISION", "Global name collision inside extension");
@@ -1082,50 +1119,38 @@ export class WorkflowRegistry {
1082
1119
  fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
1083
1120
  validateSchema(variable.schema, `${name} schema`);
1084
1121
  }
1085
- for (const [name, workflow] of Object.entries(workflows)) {
1086
- if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim())
1087
- fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
1088
- if (this.#workflows.has(name))
1089
- fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
1090
- parseWorkflow(workflow.script);
1091
- }
1092
1122
  for (const [name, hook] of Object.entries(agentSetupHooks)) {
1093
1123
  if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority)))
1094
1124
  fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
1095
1125
  if (this.#hooks.has(name))
1096
1126
  fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
1097
1127
  }
1098
- const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
1128
+ const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
1099
1129
  this.#extensions.add(stored);
1100
1130
  for (const name of names)
1101
1131
  this.#globals.set(name, name);
1102
- for (const [name, workflow] of Object.entries(workflows))
1103
- this.#workflows.set(name, workflow);
1104
1132
  for (const [name, hook] of Object.entries(agentSetupHooks))
1105
1133
  this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
1106
1134
  }
1107
- workflow(name) {
1135
+ function(name) {
1108
1136
  if (!IDENTIFIER.test(name))
1109
- fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
1110
- const workflow = this.#workflows.get(name);
1111
- if (!workflow)
1112
- fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
1113
- return workflow;
1137
+ fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
1138
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1139
+ if (!fn)
1140
+ fail("MISSING_WORKFLOW", `Registered function is unavailable: ${name}; the separate registered-workflow format was removed`);
1141
+ return fn;
1114
1142
  }
1115
- workflows() {
1116
- return Object.freeze(Object.fromEntries(this.#workflows));
1143
+ functions() {
1144
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
1117
1145
  }
1118
1146
  catalog() {
1119
1147
  const functions = [];
1120
1148
  const variables = [];
1121
- const workflows = [];
1122
1149
  for (const extension of this.#extensions) {
1123
1150
  for (const [name, fn] of Object.entries(extension.functions ?? {}))
1124
1151
  functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
1125
1152
  for (const [name, variable] of Object.entries(extension.variables ?? {}))
1126
1153
  variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
1127
- for (const [name, workflow] of Object.entries(extension.workflows ?? {}))
1128
- workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1129
1154
  }
1130
1155
  let aliases;
1131
1156
  try {
@@ -1135,15 +1160,28 @@ export class WorkflowRegistry {
1135
1160
  aliases = undefined;
1136
1161
  }
1137
1162
  const sort = (left, right) => left.name.localeCompare(right.name);
1138
- return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
1163
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
1164
+ }
1165
+ catalogIndex() {
1166
+ const catalog = this.catalog();
1167
+ return deepFreeze({
1168
+ functions: catalog.functions.map(({ name, description, input }) => ({ name, description, input: structuredClone(input) })),
1169
+ variables: catalog.variables.map(({ name, description, schema }) => ({ name, description, schema: structuredClone(schema) })),
1170
+ ...(catalog.modelAliases ? { modelAliases: structuredClone(catalog.modelAliases) } : {}),
1171
+ });
1172
+ }
1173
+ catalogDetail(name) {
1174
+ const catalog = this.catalog();
1175
+ const entry = catalog.functions.find((candidate) => candidate.name === name) ?? catalog.variables.find((candidate) => candidate.name === name);
1176
+ if (entry)
1177
+ return entry;
1178
+ return deepFreeze({ error: { code: "NOT_FOUND", name, message: `No registered workflow function or variable is available: ${name}` } });
1139
1179
  }
1140
1180
  globals() {
1141
1181
  return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
1142
1182
  }
1143
1183
  async invokeFunction(name, input, context, path, journal) {
1144
- const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1145
- if (!fn)
1146
- fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
1184
+ const fn = this.function(name);
1147
1185
  if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
1148
1186
  fail("RESULT_INVALID", `Invalid input for ${name}`);
1149
1187
  const replayed = journal.get(path);
@@ -1152,7 +1190,7 @@ export class WorkflowRegistry {
1152
1190
  fail("RESULT_INVALID", `Invalid replay for ${name}`);
1153
1191
  return structuredClone(replayed);
1154
1192
  }
1155
- const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
1193
+ const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, shell: context.shell, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
1156
1194
  if (!jsonValue(result) || !Value.Check(fn.output, result))
1157
1195
  fail("RESULT_INVALID", `Invalid output from ${name}`);
1158
1196
  const stored = structuredClone(result);
@@ -1173,9 +1211,11 @@ function createWorkflowRegistryApi(registry) {
1173
1211
  get frozen() { return registry.frozen; },
1174
1212
  freeze: () => { registry.freeze(); },
1175
1213
  register: (extension) => { registry.register(extension); },
1176
- workflow: (name) => registry.workflow(name),
1177
- workflows: () => registry.workflows(),
1214
+ function: (name) => registry.function(name),
1215
+ functions: () => registry.functions(),
1178
1216
  catalog: () => registry.catalog(),
1217
+ catalogIndex: () => registry.catalogIndex(),
1218
+ catalogDetail: (name) => registry.catalogDetail(name),
1179
1219
  globals: () => registry.globals(),
1180
1220
  invokeFunction: (...args) => registry.invokeFunction(...args),
1181
1221
  variables: () => registry.variables(),
@@ -1196,25 +1236,28 @@ function loadingRegistry() { return workflowRegistryHost().api; }
1196
1236
  beginWorkflowExtensionLoading();
1197
1237
  export function registerWorkflowExtension(extension) { loadingRegistry().register(extension); }
1198
1238
  export function workflowCatalog() { return loadingRegistry().catalog(); }
1199
- export function registeredWorkflowDefinitions() { return loadingRegistry().workflows(); }
1239
+ export function workflowCatalogIndex() { return loadingRegistry().catalogIndex(); }
1240
+ export function workflowCatalogDetail(name) { return loadingRegistry().catalogDetail(name); }
1241
+ export function registeredWorkflowFunctions() { return loadingRegistry().functions(); }
1200
1242
  export function formatWorkflowPreview(args) {
1201
1243
  const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : typeof args.workflow === "string" && args.workflow.trim() ? args.workflow : "workflow";
1202
1244
  if (typeof args.script !== "string" || !args.script.trim())
1203
- return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered workflow" : ""}`;
1245
+ return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered function" : ""}`;
1204
1246
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
1205
1247
  }
1206
1248
  export const WORKFLOW_TOOL_LABEL = "Workflow";
1207
1249
  export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
1208
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline scripts require a name; registered workflows use their workflow name. Runs in the background by default; completion arrives as a follow-up message.";
1250
+ export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline scripts require a name; registered functions use their function name. Runs in the background by default; completion arrives as a follow-up message. Foreground results include the completed run ID, and parentRunId reuses matching named worktrees from a terminal run.";
1209
1251
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
1210
1252
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
1211
1253
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
1212
1254
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
1213
- workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
1255
+ workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
1214
1256
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
1215
1257
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
1216
1258
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
1217
1259
  budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
1260
+ parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
1218
1261
  });
1219
1262
  function hasDynamicAgentRole(node) {
1220
1263
  if (!node)
@@ -1240,8 +1283,11 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
1240
1283
  fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
1241
1284
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
1242
1285
  fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
1286
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME))
1287
+ fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
1243
1288
  validateDirectPrimitiveReferences(program, "withWorktree");
1244
1289
  validateDirectPrimitiveReferences(program, "conversation");
1290
+ validateDirectPrimitiveReferences(program, "shell");
1245
1291
  for (const [index, schema] of schemas.entries())
1246
1292
  validateSchema(schema, `schema[${String(index)}]`);
1247
1293
  const calls = workflowCalls(program);
@@ -1255,6 +1301,8 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
1255
1301
  }
1256
1302
  if (operation === "withWorktree")
1257
1303
  validateStaticWithWorktree(call);
1304
+ if (operation === "shell")
1305
+ validateStaticShellOptions(call);
1258
1306
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
1259
1307
  continue;
1260
1308
  if (operation === "checkpoint" && stableName(call.arguments[0]) === false)
@@ -1291,6 +1339,7 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
1291
1339
  fail("UNKNOWN_AGENT_TYPE", `Unknown agent type: ${missingType}`);
1292
1340
  return Object.freeze({ metadata: deepFreeze(checkedMetadata), referenced: deepFreeze({ phases, models, tools, agentTypes }), schemas: deepFreeze(checkedSchemas), dynamicAgentRoles });
1293
1341
  }
1342
+ function functionLaunchScript(name) { return `return await ${name}(args);`; }
1294
1343
  export function validateWorkflowLaunch(params, context) {
1295
1344
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
1296
1345
  }
@@ -1299,15 +1348,19 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
1299
1348
  fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
1300
1349
  if (params.script !== undefined && params.workflow !== undefined)
1301
1350
  fail("INVALID_METADATA", "Provide either script or workflow, not both");
1302
- const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
1303
- const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
1351
+ const functionName = typeof params.workflow === "string" ? params.workflow : undefined;
1352
+ const fn = functionName === undefined ? undefined : registry.function(functionName);
1353
+ const args = params.args === undefined ? null : params.args;
1354
+ if (functionName !== undefined && fn && (!object(args) || !jsonValue(args) || !Value.Check(fn.input, args)))
1355
+ fail("RESULT_INVALID", `Invalid input for ${functionName}`);
1356
+ const script = functionName !== undefined && fn ? functionLaunchScript(functionName) : typeof params.script === "string" && params.script.trim() ? params.script : "";
1304
1357
  if (!script)
1305
- fail("INVALID_SYNTAX", "Provide script or registered workflow");
1306
- const workflowName = typeof params.name === "string" && params.name.trim() ? params.name.trim() : typeof params.workflow === "string" ? params.workflow : "";
1358
+ fail("INVALID_SYNTAX", "Provide script or registered function");
1359
+ const workflowName = functionName ?? (typeof params.name === "string" && params.name.trim() ? params.name.trim() : "");
1307
1360
  if (!workflowName)
1308
1361
  fail("INVALID_METADATA", "Inline workflows require name");
1309
- const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : definition?.description ? { description: definition.description } : {}) });
1310
- const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
1362
+ const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : fn?.description ? { description: fn.description } : {}) });
1363
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false);
1311
1364
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
1312
1365
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
1313
1366
  const aliases = context.modelAliases ?? {};
@@ -1315,7 +1368,7 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
1315
1368
  const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)), modelAliases: aliases, knownModels, ...(context.settingsPath ? { settingsPath: context.settingsPath } : {}) }, [], metadata);
1316
1369
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
1317
1370
  validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
1318
- return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
1371
+ return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, ...(functionName ? { functionName } : {}) };
1319
1372
  }
1320
1373
  function deepFreeze(value) {
1321
1374
  if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
@@ -1326,11 +1379,29 @@ function deepFreeze(value) {
1326
1379
  return value;
1327
1380
  }
1328
1381
  export function createLaunchSnapshot(input) {
1329
- return deepFreeze(structuredClone({ ...input, identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
1382
+ return deepFreeze(structuredClone({ ...input, launchKind: input.launchKind ?? (input.functionName ? "function" : "inline"), identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
1330
1383
  }
1331
1384
  export function loadLaunchSnapshot(input) {
1332
1385
  return deepFreeze(structuredClone(input));
1333
1386
  }
1387
+ function launchScriptForSnapshot(snapshot, registry) {
1388
+ if (snapshot.launchKind === "function") {
1389
+ if (!snapshot.functionName)
1390
+ fail("RESUME_INCOMPATIBLE", "Persisted registered function launch is missing its function name");
1391
+ try {
1392
+ registry.function(snapshot.functionName);
1393
+ }
1394
+ catch (error) {
1395
+ if (error instanceof WorkflowError && error.code === "MISSING_WORKFLOW")
1396
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted registered function is unavailable: ${snapshot.functionName}`);
1397
+ throw error;
1398
+ }
1399
+ return functionLaunchScript(snapshot.functionName);
1400
+ }
1401
+ if (snapshot.launchKind === "inline")
1402
+ return snapshot.script;
1403
+ fail("RESUME_INCOMPATIBLE", "This persisted run uses the removed registered-workflow format; launch it again as a registered function or inline script");
1404
+ }
1334
1405
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
1335
1406
  export const HEARTBEAT_TIMEOUT_MS = 5000;
1336
1407
  const OUTCOME_ERRORS = new Set(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
@@ -1396,10 +1467,12 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1396
1467
  const path = (...names) => names.map(encodeURIComponent).join("/");
1397
1468
  const inheritedAgentPath = new AsyncLocalStorage();
1398
1469
  const agentOccurrences = new Map();
1470
+ const shellOccurrences = new Map();
1399
1471
  const conversationOccurrences = new Map();
1400
1472
  const worktreeOwners = new AsyncLocalStorage();
1401
1473
  const worktreeOccurrences = new Map();
1402
1474
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
1475
+ const rejectShell = () => { throw workError("INVALID_METADATA", "Workflow shell calls must use a direct shell(...) call; aliases and indirect calls are unsupported"); };
1403
1476
  const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
1404
1477
  const internalWithWorktree = async (...values) => {
1405
1478
  const callSite = values.pop();
@@ -1418,7 +1491,9 @@ const internalWithWorktree = async (...values) => {
1418
1491
  worktreeOccurrences.set(occurrenceKey, occurrence);
1419
1492
  owner = path("worktree", "unnamed", ...inherited, "callsite:" + callSite, "occurrence:" + String(occurrence));
1420
1493
  }
1421
- return await worktreeOwners.run(owner, callback);
1494
+ const reference = await rpc("worktree", [owner]);
1495
+ if (!reference || typeof reference !== "object" || typeof reference.path !== "string" || typeof reference.branch !== "string") throw workError("WORKTREE_FAILED", "Worktree reference is invalid");
1496
+ return await worktreeOwners.run(owner, () => callback(Object.freeze({ path: reference.path, branch: reference.branch })));
1422
1497
  };
1423
1498
  const internalConversation = (...values) => {
1424
1499
  const callSite = values.pop();
@@ -1477,6 +1552,31 @@ const internalAgent = (...values) => {
1477
1552
  });
1478
1553
  return result;
1479
1554
  };
1555
+ const internalShell = (...values) => {
1556
+ const callSite = values.pop();
1557
+ if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow shell call-site identity");
1558
+ if (values.length !== 1 && values.length !== 2) throw workError("INVALID_METADATA", "shell requires a command string and optional options");
1559
+ const command = values[0];
1560
+ if (typeof command !== "string") throw workError("INVALID_METADATA", "shell command must be a string");
1561
+ const options = values.length < 2 || values[1] === undefined ? {} : values[1];
1562
+ if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "timeoutMs" && key !== "env")) throw workError("INVALID_METADATA", "shell options must contain only timeoutMs and env");
1563
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw workError("INVALID_METADATA", "shell timeoutMs must be a positive integer");
1564
+ if (options.env !== undefined && (!options.env || typeof options.env !== "object" || Array.isArray(options.env) || Object.values(options.env).some(value => typeof value !== "string"))) throw workError("INVALID_METADATA", "shell env must be an object of strings");
1565
+ const inherited = inheritedAgentPath.getStore() || [];
1566
+ const worktreeOwner = worktreeOwners.getStore();
1567
+ const occurrenceKey = JSON.stringify([inherited, callSite, worktreeOwner || null]);
1568
+ const occurrence = (shellOccurrences.get(occurrenceKey) || 0) + 1;
1569
+ shellOccurrences.set(occurrenceKey, occurrence);
1570
+ const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
1571
+ const result = rpc("shell", [command, options, identity]);
1572
+ Object.defineProperties(result, {
1573
+ toJSON: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before serialization"); } },
1574
+ toString: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
1575
+ [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
1576
+ });
1577
+ return result;
1578
+ };
1579
+ const shell = rejectShell;
1480
1580
  const agent = rejectAgent;
1481
1581
  const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
1482
1582
  const plainPromptObject = value => {
@@ -1598,13 +1698,13 @@ const pipeline = async (operationName, items, stages) => {
1598
1698
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1599
1699
  };
1600
1700
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1601
- const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1701
+ const sandbox = { agent, conversation: internalConversation, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1602
1702
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1603
1703
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1604
1704
  for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
1605
1705
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1606
1706
  const body = config.script;
1607
- Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_conversation,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalConversation, internalWithWorktree))
1707
+ Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_conversation,__pi_extensible_workflows_withWorktree,__pi_extensible_workflows_shell)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalConversation, internalWithWorktree, internalShell))
1608
1708
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1609
1709
  .catch(error => send({ type: "error", error: workerError(error) }))
1610
1710
  .finally(() => clearInterval(heartbeat));
@@ -1617,6 +1717,9 @@ function encoded(value) {
1617
1717
  fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
1618
1718
  return json;
1619
1719
  }
1720
+ function encodedRpcResult(id, value) {
1721
+ return encoded({ type: "rpcResult", id, ok: true, value });
1722
+ }
1620
1723
  function readAgentIdentity(value) {
1621
1724
  if (!object(value))
1622
1725
  fail("INTERNAL_ERROR", "Invalid workflow agent identity");
@@ -1631,9 +1734,18 @@ function readAgentIdentity(value) {
1631
1734
  fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1632
1735
  return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1633
1736
  }
1737
+ function readShellIdentity(value) {
1738
+ const identity = readAgentIdentity(value);
1739
+ if (identity.conversation)
1740
+ fail("INTERNAL_ERROR", "Shell identity cannot include a conversation");
1741
+ return { structuralPath: identity.structuralPath, callSite: identity.callSite, occurrence: identity.occurrence, ...(identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {}) };
1742
+ }
1634
1743
  function agentIdentityPath(identity) {
1635
1744
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1636
1745
  }
1746
+ function shellIdentityPath(identity) {
1747
+ return operationPath("shell", ...(identity.worktreeOwner ? ["worktree", identity.worktreeOwner] : []), ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1748
+ }
1637
1749
  function conversationIdentityPath(identity) {
1638
1750
  if (!identity.conversation)
1639
1751
  throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
@@ -1644,9 +1756,122 @@ function conversationTurnPath(identity) {
1644
1756
  throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1645
1757
  return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1646
1758
  }
1759
+ function readShellResult(value) {
1760
+ if (!object(value) || (value.exitCode !== null && !Number.isInteger(value.exitCode)) || typeof value.stdout !== "string" || typeof value.stderr !== "string")
1761
+ fail("SHELL_FAILED", "Shell bridge returned an invalid result");
1762
+ return { exitCode: value.exitCode, stdout: value.stdout, stderr: value.stderr };
1763
+ }
1647
1764
  function agentWorktree(identity) {
1648
1765
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1649
1766
  }
1767
+ function shellProcessKill(child) {
1768
+ let forceKill;
1769
+ const killProcessTree = (signal) => {
1770
+ try {
1771
+ if (child.pid && process.platform !== "win32")
1772
+ process.kill(-child.pid, signal);
1773
+ else if (child.pid && process.platform === "win32") {
1774
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
1775
+ killer.unref();
1776
+ }
1777
+ else
1778
+ child.kill(signal);
1779
+ }
1780
+ catch {
1781
+ try {
1782
+ child.kill(signal);
1783
+ }
1784
+ catch { /* The process may already have exited. */ }
1785
+ }
1786
+ };
1787
+ child.once("close", () => { if (forceKill)
1788
+ clearTimeout(forceKill); });
1789
+ killProcessTree("SIGTERM");
1790
+ forceKill = setTimeout(() => { forceKill = undefined; killProcessTree("SIGKILL"); }, 1000);
1791
+ forceKill.unref();
1792
+ }
1793
+ function executeShellCommand(command, options, signal, cwd = process.cwd()) {
1794
+ return new Promise((resolve, reject) => {
1795
+ let child;
1796
+ try {
1797
+ child = spawn(command, { shell: true, cwd, env: { ...process.env, ...(options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
1798
+ }
1799
+ catch (error) {
1800
+ reject(new WorkflowError("SHELL_FAILED", errorText(error)));
1801
+ return;
1802
+ }
1803
+ let settled = false;
1804
+ let timedOut = false;
1805
+ let outputBytes = 0;
1806
+ let timeout;
1807
+ const stdoutDecoder = new StringDecoder("utf8");
1808
+ const stderrDecoder = new StringDecoder("utf8");
1809
+ let stdout = "";
1810
+ let stderr = "";
1811
+ const cleanup = () => {
1812
+ if (timeout)
1813
+ clearTimeout(timeout);
1814
+ signal.removeEventListener("abort", onAbort);
1815
+ };
1816
+ const failShell = (error) => {
1817
+ if (settled)
1818
+ return;
1819
+ settled = true;
1820
+ cleanup();
1821
+ shellProcessKill(child);
1822
+ reject(error);
1823
+ };
1824
+ const onAbort = () => { failShell(new WorkflowError("CANCELLED", "Workflow cancelled")); };
1825
+ const capture = (target, chunk) => {
1826
+ if (settled)
1827
+ return;
1828
+ outputBytes += chunk.byteLength;
1829
+ if (outputBytes > RPC_LIMIT_BYTES) {
1830
+ failShell(new WorkflowError("RPC_LIMIT_EXCEEDED", "Shell result exceeds the 10 MB JSON boundary"));
1831
+ return;
1832
+ }
1833
+ if (target === "stdout")
1834
+ stdout += stdoutDecoder.write(chunk);
1835
+ else
1836
+ stderr += stderrDecoder.write(chunk);
1837
+ };
1838
+ child.stdout?.on("data", (chunk) => { capture("stdout", chunk); });
1839
+ child.stderr?.on("data", (chunk) => { capture("stderr", chunk); });
1840
+ child.once("error", (error) => { failShell(new WorkflowError("SHELL_FAILED", errorText(error))); });
1841
+ child.once("close", (exitCode) => {
1842
+ if (settled)
1843
+ return;
1844
+ settled = true;
1845
+ cleanup();
1846
+ stdout += stdoutDecoder.end();
1847
+ stderr += stderrDecoder.end();
1848
+ if (signal.aborted) {
1849
+ reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
1850
+ return;
1851
+ }
1852
+ if (timedOut) {
1853
+ reject(new WorkflowError("SHELL_FAILED", `Shell command timed out after ${String(options.timeoutMs)}ms`));
1854
+ return;
1855
+ }
1856
+ const result = { exitCode: exitCode === null ? null : exitCode, stdout, stderr };
1857
+ try {
1858
+ encodedRpcResult(Number.MAX_SAFE_INTEGER, result);
1859
+ }
1860
+ catch (error) {
1861
+ reject(error instanceof WorkflowError ? error : new WorkflowError("RPC_LIMIT_EXCEEDED", errorText(error)));
1862
+ return;
1863
+ }
1864
+ resolve(result);
1865
+ });
1866
+ if (signal.aborted) {
1867
+ onAbort();
1868
+ return;
1869
+ }
1870
+ signal.addEventListener("abort", onAbort, { once: true });
1871
+ if (options.timeoutMs !== undefined)
1872
+ timeout = setTimeout(() => { timedOut = true; shellProcessKill(child); }, options.timeoutMs);
1873
+ });
1874
+ }
1650
1875
  export function runWorkflow(script, args = null, bridge = {}, signal) {
1651
1876
  encoded(args);
1652
1877
  const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
@@ -1750,6 +1975,14 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
1750
1975
  value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1751
1976
  }
1752
1977
  }
1978
+ else if (method === "shell") {
1979
+ if (!bridge.shell)
1980
+ fail("SHELL_FAILED", "No shell bridge is available");
1981
+ const command = validateShellCommand(values[0]);
1982
+ const options = validateShellOptions(values[1]);
1983
+ const identity = readShellIdentity(values[2]);
1984
+ value = readShellResult(await bridge.shell(command, options, controller.signal, identity));
1985
+ }
1753
1986
  else if (method === "checkpoint") {
1754
1987
  if (!bridge.checkpoint || !object(values[0]))
1755
1988
  fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
@@ -1786,6 +2019,11 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
1786
2019
  value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1787
2020
  }
1788
2021
  }
2022
+ else if (method === "worktree") {
2023
+ if (!bridge.worktree || typeof values[0] !== "string" || !values[0])
2024
+ fail("INTERNAL_ERROR", "worktree requires an active host bridge and scope");
2025
+ value = await bridge.worktree(values[0], controller.signal);
2026
+ }
1789
2027
  else if (method === "phase") {
1790
2028
  if (typeof values[0] !== "string")
1791
2029
  fail("INTERNAL_ERROR", "phase name must be a string");
@@ -1799,7 +2037,7 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
1799
2037
  else
1800
2038
  fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
1801
2039
  encoded(value);
1802
- child.send(encoded({ type: "rpcResult", id, ok: true, value }));
2040
+ child.send(encodedRpcResult(id, value));
1803
2041
  }
1804
2042
  catch (error) {
1805
2043
  const typed = asWorkflowError(error);
@@ -1999,13 +2237,9 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
1999
2237
  const render = ({ agent, depth }, grouped) => {
2000
2238
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
2001
2239
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
2002
- const setup = agent.attemptDetails?.at(-1)?.setup;
2003
- const thinking = setup?.model.thinking ?? agent.model.thinking;
2004
- const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
2005
- const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
2006
2240
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
2007
2241
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
2008
- const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
2242
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state}${tokens ? ` · ${tokens}` : ""}`];
2009
2243
  if (agent.state === "failed" && agent.attemptDetails?.length) {
2010
2244
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
2011
2245
  if (last?.error)
@@ -2069,7 +2303,8 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
2069
2303
  return lines.join("\n");
2070
2304
  }
2071
2305
  function formatCheckpointReview(checkpoint) {
2072
- return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, "Context:", JSON.stringify(checkpoint.context, null, 2)].join("\n");
2306
+ const context = JSON.stringify(checkpoint.context, null, 2);
2307
+ return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
2073
2308
  }
2074
2309
  const DELIVERY_LIMIT_BYTES = 4 * 1024;
2075
2310
  const WORKFLOW_LOG_ENTRY = "workflow-log";
@@ -2091,11 +2326,144 @@ function utf8Prefix(value, maxBytes) {
2091
2326
  end -= 1;
2092
2327
  return bytes.subarray(0, end).toString("utf8");
2093
2328
  }
2329
+ const DIAGNOSTIC_LIMIT_BYTES = DELIVERY_LIMIT_BYTES - 512;
2330
+ function failureDiagnosticsFrom(error) {
2331
+ if (!error || typeof error !== "object")
2332
+ return undefined;
2333
+ return error[WORKFLOW_FAILURE_DIAGNOSTICS];
2334
+ }
2335
+ function boundedWorkflowFailureDiagnostics(value) {
2336
+ let bounded = {
2337
+ runId: utf8Prefix(value.runId, 128),
2338
+ workflowName: utf8Prefix(value.workflowName, 256),
2339
+ state: value.state,
2340
+ failedAt: value.failedAt === null ? null : utf8Prefix(value.failedAt, 1024),
2341
+ error: { code: value.error.code, message: utf8Prefix(value.error.message, 1024) },
2342
+ ...(value.failedAgent ? { failedAgent: {
2343
+ id: utf8Prefix(value.failedAgent.id, 128),
2344
+ ...(value.failedAgent.label ? { label: utf8Prefix(value.failedAgent.label, 128) } : {}),
2345
+ ...(value.failedAgent.role ? { role: utf8Prefix(value.failedAgent.role, 128) } : {}),
2346
+ structuralPath: value.failedAgent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2347
+ attempt: value.failedAgent.attempt,
2348
+ ...(value.failedAgent.sessionId ? { sessionId: utf8Prefix(value.failedAgent.sessionId, 256) } : {}),
2349
+ ...(value.failedAgent.sessionFile ? { sessionFile: utf8Prefix(value.failedAgent.sessionFile, 1024) } : {}),
2350
+ } } : {}),
2351
+ completedSiblingAgents: (value.completedSiblingAgents ?? []).slice(0, 16).map((agent) => ({
2352
+ id: utf8Prefix(agent.id, 128),
2353
+ ...(agent.label ? { label: utf8Prefix(agent.label, 128) } : {}),
2354
+ ...(agent.role ? { role: utf8Prefix(agent.role, 128) } : {}),
2355
+ structuralPath: agent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2356
+ })),
2357
+ completedSiblingPaths: value.completedSiblingPaths.slice(0, 16).map((path) => path.slice(0, 8).map((part) => utf8Prefix(part, 128))),
2358
+ artifacts: { runDirectory: utf8Prefix(value.artifacts.runDirectory, 1024), statePath: utf8Prefix(value.artifacts.statePath, 1024), journalPath: utf8Prefix(value.artifacts.journalPath, 1024) },
2359
+ };
2360
+ const size = () => Buffer.byteLength(JSON.stringify(bounded));
2361
+ while (size() > DIAGNOSTIC_LIMIT_BYTES) {
2362
+ if (bounded.completedSiblingAgents?.length || bounded.completedSiblingPaths.length) {
2363
+ bounded = { ...bounded, completedSiblingAgents: bounded.completedSiblingAgents?.slice(0, -1) ?? [], completedSiblingPaths: bounded.completedSiblingPaths.slice(0, -1) };
2364
+ continue;
2365
+ }
2366
+ if (bounded.failedAgent?.sessionFile) {
2367
+ const failedAgent = { ...bounded.failedAgent };
2368
+ delete failedAgent.sessionFile;
2369
+ bounded = { ...bounded, failedAgent };
2370
+ continue;
2371
+ }
2372
+ if (bounded.failedAgent?.sessionId) {
2373
+ const failedAgent = { ...bounded.failedAgent };
2374
+ delete failedAgent.sessionId;
2375
+ bounded = { ...bounded, failedAgent };
2376
+ continue;
2377
+ }
2378
+ if (Buffer.byteLength(bounded.artifacts.runDirectory) > 256) {
2379
+ bounded = { ...bounded, artifacts: { ...bounded.artifacts, runDirectory: utf8Prefix(bounded.artifacts.runDirectory, 256) } };
2380
+ continue;
2381
+ }
2382
+ if (Buffer.byteLength(bounded.error.message) > 256) {
2383
+ bounded = { ...bounded, error: { ...bounded.error, message: utf8Prefix(bounded.error.message, 256) } };
2384
+ continue;
2385
+ }
2386
+ if (bounded.failedAt !== null && Buffer.byteLength(bounded.failedAt) > 256) {
2387
+ bounded = { ...bounded, failedAt: utf8Prefix(bounded.failedAt, 256) };
2388
+ continue;
2389
+ }
2390
+ if (bounded.failedAgent && bounded.failedAgent.structuralPath.length > 4) {
2391
+ bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.slice(0, 4) } };
2392
+ continue;
2393
+ }
2394
+ if (bounded.failedAgent?.structuralPath.some((part) => Buffer.byteLength(part) > 64)) {
2395
+ bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.map((part) => utf8Prefix(part, 64)) } };
2396
+ continue;
2397
+ }
2398
+ if (Buffer.byteLength(bounded.artifacts.statePath) > 512 || Buffer.byteLength(bounded.artifacts.journalPath) > 512) {
2399
+ bounded = { ...bounded, artifacts: { ...bounded.artifacts, statePath: utf8Prefix(bounded.artifacts.statePath, 512), journalPath: utf8Prefix(bounded.artifacts.journalPath, 512) } };
2400
+ continue;
2401
+ }
2402
+ if (Buffer.byteLength(bounded.workflowName) > 128) {
2403
+ bounded = { ...bounded, workflowName: utf8Prefix(bounded.workflowName, 128) };
2404
+ continue;
2405
+ }
2406
+ break;
2407
+ }
2408
+ return bounded;
2409
+ }
2410
+ function createWorkflowFailureDiagnostics(store, metadata, error, run) {
2411
+ const rawFailedAt = error && typeof error === "object" ? error.failedAt : undefined;
2412
+ const failedAt = typeof rawFailedAt === "string" && rawFailedAt ? rawFailedAt : null;
2413
+ const failedAgents = run.agents.filter((agent) => agent.state === "failed");
2414
+ const failedAgentRecord = failedAgents.find((agent) => {
2415
+ if (failedAt === null)
2416
+ return false;
2417
+ try {
2418
+ return failedAt.includes(`${operationPath("agent", ...(agent.structuralPath ?? []))}/`);
2419
+ }
2420
+ catch {
2421
+ return false;
2422
+ }
2423
+ }) ?? failedAgents.at(-1);
2424
+ const failedAttempt = failedAgentRecord ? [...(failedAgentRecord.attemptDetails ?? [])].reverse().find((attempt) => attempt.error) ?? failedAgentRecord.attemptDetails?.at(-1) : undefined;
2425
+ const failedAgent = failedAgentRecord ? {
2426
+ id: failedAgentRecord.id,
2427
+ ...(failedAgentRecord.label ?? failedAgentRecord.name ? { label: failedAgentRecord.label ?? failedAgentRecord.name } : {}),
2428
+ ...(failedAgentRecord.role ? { role: failedAgentRecord.role } : {}),
2429
+ structuralPath: [...(failedAgentRecord.structuralPath ?? [])],
2430
+ attempt: Math.max(1, failedAttempt?.attempt ?? failedAgentRecord.attempts),
2431
+ ...(failedAttempt?.sessionId ? { sessionId: failedAttempt.sessionId } : {}),
2432
+ ...(failedAttempt?.sessionFile ? { sessionFile: failedAttempt.sessionFile } : {}),
2433
+ } : undefined;
2434
+ const completedSiblingAgents = run.agents.filter((agent) => {
2435
+ if (agent.state !== "completed" || agent.id === failedAgentRecord?.id)
2436
+ return false;
2437
+ return failedAgentRecord?.parentId === undefined ? agent.parentId === undefined : agent.parentId === failedAgentRecord.parentId;
2438
+ }).map((agent) => ({
2439
+ id: agent.id,
2440
+ ...(agent.label ?? agent.name ? { label: agent.label ?? agent.name } : {}),
2441
+ ...(agent.role ? { role: agent.role } : {}),
2442
+ structuralPath: [...(agent.structuralPath ?? [])],
2443
+ }));
2444
+ const completedSiblingPaths = completedSiblingAgents.map((agent) => [...agent.structuralPath]);
2445
+ return boundedWorkflowFailureDiagnostics({
2446
+ runId: run.id, workflowName: metadata.name, state: run.state, failedAt,
2447
+ error: { code: errorCode(error) ?? "INTERNAL_ERROR", message: errorText(error) || "The workflow failed without an error message." },
2448
+ ...(failedAgent ? { failedAgent } : {}), completedSiblingAgents, completedSiblingPaths,
2449
+ artifacts: { runDirectory: store.directory, statePath: join(store.directory, "state.json"), journalPath: join(store.directory, "journal.json") },
2450
+ });
2451
+ }
2452
+ export function formatWorkflowFailureDiagnostics(diagnostic) {
2453
+ const failedAgent = diagnostic.failedAgent ? `${diagnostic.failedAgent.label ?? diagnostic.failedAgent.id}${diagnostic.failedAgent.role ? ` role=${diagnostic.failedAgent.role}` : ""} attempt=${String(diagnostic.failedAgent.attempt)} path=${diagnostic.failedAgent.structuralPath.join(" > ") || "(root)"}${diagnostic.failedAgent.sessionFile ? ` session=${diagnostic.failedAgent.sessionFile}` : ""}` : "(not persisted)";
2454
+ const siblingAgents = diagnostic.completedSiblingAgents;
2455
+ const siblings = siblingAgents ? siblingAgents.map((agent) => `${agent.label ?? agent.id}${agent.role ? ` role=${agent.role}` : ""} path=${agent.structuralPath.join(" > ") || "(root)"}`).join(", ") || "(none)" : diagnostic.completedSiblingPaths.map((path) => path.join(" > ") || "(root)").join(", ") || "(none)";
2456
+ return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
2457
+ }
2458
+ function serializeWorkflowFailureDiagnostics(diagnostic) { return JSON.stringify(diagnostic); }
2459
+ function isWorkflowFailureDiagnostics(value) {
2460
+ return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
2461
+ }
2094
2462
  function deliver(pi, content) {
2095
2463
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
2096
2464
  }
2097
- function deliverFailure(pi, name, error) {
2098
- deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
2465
+ function deliverFailure(pi, diagnostic) {
2466
+ deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
2099
2467
  }
2100
2468
  function safeEventError(error) {
2101
2469
  const code = errorCode(error) ?? "INTERNAL_ERROR";
@@ -2109,6 +2477,11 @@ class WorkflowEventPublisher {
2109
2477
  constructor(sink) {
2110
2478
  this.sink = sink;
2111
2479
  }
2480
+ removeRun(runId) {
2481
+ this.#queues.delete(runId);
2482
+ this.#budgetEvents.delete(runId);
2483
+ this.#worktrees.delete(runId);
2484
+ }
2112
2485
  seedBudget(runId, events) {
2113
2486
  const seen = this.#budgetEvents.get(runId) ?? new Set();
2114
2487
  for (const event of events ?? [])
@@ -2183,7 +2556,12 @@ function namedRecord(value, kind) {
2183
2556
  fail("INVALID_METADATA", `${kind} must be a record`);
2184
2557
  return Object.entries(value);
2185
2558
  }
2186
- function hostWithWorktree(args, identity, occurrences) {
2559
+ function publicWorktreeReference(reference) {
2560
+ if (!object(reference) || typeof reference.path !== "string" || typeof reference.branch !== "string")
2561
+ fail("WORKTREE_FAILED", "Worktree reference is invalid");
2562
+ return Object.freeze({ path: reference.path, branch: reference.branch });
2563
+ }
2564
+ async function hostWithWorktree(args, identity, occurrences, resolveWorktree, signal) {
2187
2565
  if (args.length !== 1 && args.length !== 2)
2188
2566
  fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
2189
2567
  const callback = args[args.length - 1];
@@ -2202,7 +2580,10 @@ function hostWithWorktree(args, identity, occurrences) {
2202
2580
  occurrences.set(key, occurrence);
2203
2581
  owner = operationPath("worktree", "unnamed", "function", identity, ...structuralPath, `occurrence:${String(occurrence)}`);
2204
2582
  }
2205
- return inheritedHostWorktreeOwner.run(owner, async () => await callback());
2583
+ if (!resolveWorktree)
2584
+ fail("WORKTREE_FAILED", "No worktree bridge is available");
2585
+ const reference = publicWorktreeReference(await resolveWorktree(owner, signal));
2586
+ return inheritedHostWorktreeOwner.run(owner, async () => await callback(reference));
2206
2587
  }
2207
2588
  function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
2208
2589
  return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
@@ -2301,6 +2682,7 @@ function nextNamedOccurrence(counters, label) {
2301
2682
  }
2302
2683
  function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
2303
2684
  const functionAgentOccurrences = new Map();
2685
+ const functionShellOccurrences = new Map();
2304
2686
  const functionWorktreeOccurrences = new Map();
2305
2687
  const functionInvokeOccurrences = new Map();
2306
2688
  const invokeFunction = async (name, input, path, signal, worktreeOwner, structuralPath = [], breadcrumb) => {
@@ -2330,10 +2712,23 @@ function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
2330
2712
  functionAgentOccurrences.set(key, occurrence);
2331
2713
  return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2332
2714
  },
2715
+ shell: async (...args) => {
2716
+ if (!bridge.shell)
2717
+ fail("SHELL_FAILED", "No shell bridge is available");
2718
+ if (typeof args[0] !== "string")
2719
+ fail("INVALID_METADATA", "shell command must be a string");
2720
+ const options = validateShellOptions(args[1] === undefined ? {} : args[1]);
2721
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2722
+ const inherited = inheritedHostAgentPath.getStore() ?? [];
2723
+ const key = `${path}\0${JSON.stringify([inherited, scopedWorktreeOwner ?? null])}`;
2724
+ const occurrence = (functionShellOccurrences.get(key) ?? 0) + 1;
2725
+ functionShellOccurrences.set(key, occurrence);
2726
+ return bridge.shell(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2727
+ },
2333
2728
  prompt: workflowPrompt,
2334
2729
  parallel: (...args) => hostParallel(args[0], args[1]),
2335
2730
  pipeline: (...args) => hostPipeline(args[0], args[1], args[2]),
2336
- withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences),
2731
+ withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences, bridge.worktree, signal),
2337
2732
  checkpoint: async (...args) => {
2338
2733
  if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0]))
2339
2734
  fail("INTERNAL_ERROR", "No checkpoint bridge is available");
@@ -2389,6 +2784,16 @@ function tuiHostCapabilities(tui) {
2389
2784
  return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2390
2785
  }
2391
2786
  function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
2787
+ const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
2788
+ function borderWorkflowOverlay(component, theme) {
2789
+ return {
2790
+ ...component,
2791
+ render(width) {
2792
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
2793
+ return [border, ...component.render(width), border];
2794
+ },
2795
+ };
2796
+ }
2392
2797
  function isKeybindingGetter(value) { return typeof value === "function"; }
2393
2798
  function keybindingsHostCapabilities(keybindings) {
2394
2799
  if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys))
@@ -2408,9 +2813,10 @@ function parseThinking(value) {
2408
2813
  default: return undefined;
2409
2814
  }
2410
2815
  }
2411
- export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession) {
2816
+ export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession, agentDir) {
2412
2817
  beginWorkflowExtensionLoading();
2413
2818
  const registry = loadingRegistry();
2819
+ const extensionAgentDir = agentDir ?? getAgentDir();
2414
2820
  const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
2415
2821
  registerEntryRenderer?.(WORKFLOW_LOG_ENTRY, (entry) => {
2416
2822
  const data = entry.data;
@@ -2434,6 +2840,41 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2434
2840
  return skillPath ? { skillPaths: [skillPath] } : undefined;
2435
2841
  });
2436
2842
  const runs = new Map();
2843
+ let providerRecoveryQueue = Promise.resolve();
2844
+ const enqueueProviderRecovery = (task) => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
2845
+ const createProviderErrorRecovery = (host, fallbackModels, abort) => {
2846
+ if (!object(host) || host.mode !== "tui" || host.hasUI !== true)
2847
+ return undefined;
2848
+ const ui = object(host.ui) ? host.ui : undefined;
2849
+ const select = uiHostCapabilities(ui)?.select;
2850
+ if (!select)
2851
+ return undefined;
2852
+ const hostModels = contextHostCapabilities(host).modelRegistry;
2853
+ const choose = (title, options) => select.call(ui, title, options);
2854
+ return (failure) => enqueueProviderRecovery(async () => {
2855
+ const action = await choose(`Subagent "${failure.label}" failed\nCurrent provider/model: ${failure.provider}/${failure.model}\nProvider error: ${failure.error}\nChoose what to do`, ["Retry", "Change model", "Abort workflow"]);
2856
+ if (action === "Retry")
2857
+ return "retry";
2858
+ if (action === "Change model") {
2859
+ const available = hostModels?.getAvailable?.().map((model) => `${model.provider}/${model.id}`) ?? [...fallbackModels];
2860
+ const selected = await choose(`Available models for subagent "${failure.label}"`, [...new Set(available)].sort());
2861
+ if (selected)
2862
+ return { model: selected };
2863
+ }
2864
+ abort();
2865
+ return "abort";
2866
+ });
2867
+ };
2868
+ const pendingFailureDiagnostics = new Map();
2869
+ pi.on("tool_result", (event) => {
2870
+ if (event.toolName !== "workflow" || !event.isError)
2871
+ return;
2872
+ const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
2873
+ if (!diagnostic)
2874
+ return;
2875
+ pendingFailureDiagnostics.delete(event.toolCallId);
2876
+ return { content: [{ type: "text", text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
2877
+ });
2437
2878
  const liveActivities = new Map();
2438
2879
  const setLiveActivity = (runId, agentId, activity) => {
2439
2880
  const activities = liveActivities.get(runId);
@@ -2486,10 +2927,39 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2486
2927
  const persistWorktree = async (store, metadata, owner) => {
2487
2928
  const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2488
2929
  const worktree = await store.worktree(owner);
2489
- if (!existing)
2930
+ if (!existing && await store.ownsWorktree(owner))
2490
2931
  await eventPublisher.worktree(store, metadata, worktree);
2491
2932
  return worktree;
2492
2933
  };
2934
+ const resolveWorktree = async (store, metadata, owner) => {
2935
+ const run = runs.get(store.runId);
2936
+ if (!run)
2937
+ fail("INTERNAL_ERROR", `Unknown production run: ${store.runId}`);
2938
+ await run.lifecycle.enter();
2939
+ try {
2940
+ const worktree = await persistWorktree(store, metadata, owner);
2941
+ return { path: worktree.path, branch: worktree.branch };
2942
+ }
2943
+ finally {
2944
+ await run.lifecycle.leave();
2945
+ }
2946
+ };
2947
+ const shellForRun = async (store, metadata, lifecycle, command, options, signal, identity) => {
2948
+ await lifecycle.enter();
2949
+ try {
2950
+ const path = shellIdentityPath(identity);
2951
+ const replayed = await store.replay(path);
2952
+ if (replayed)
2953
+ return readShellResult(replayed.value);
2954
+ const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
2955
+ const result = await executeShellCommand(command, options, signal, cwd);
2956
+ await store.complete(path, result);
2957
+ return result;
2958
+ }
2959
+ finally {
2960
+ await lifecycle.leave();
2961
+ }
2962
+ };
2493
2963
  const lifecycleFor = (store, state, budget, metadata) => new RunLifecycle(state, async (next, previous, reason) => {
2494
2964
  if (next !== "pausing")
2495
2965
  budget.transition(next);
@@ -2535,7 +3005,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2535
3005
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2536
3006
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2537
3007
  };
2538
- const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
3008
+ const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(run.providerErrorRecovery ? { providerErrorRecovery: run.providerErrorRecovery } : {}), ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
2539
3009
  const before = (await run.store.load()).run;
2540
3010
  await persistAgentAttempts(run.store, id, result.attempts);
2541
3011
  const completed = (await run.store.load()).run;
@@ -2584,6 +3054,21 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2584
3054
  await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2585
3055
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2586
3056
  });
3057
+ const cleanupTerminalRun = async (runId) => {
3058
+ const run = runs.get(runId);
3059
+ if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state))
3060
+ return;
3061
+ await scheduler.cancelRun(runId);
3062
+ await scheduler.flush();
3063
+ if (runs.get(runId) !== run)
3064
+ return;
3065
+ scheduler.removeRun(runId);
3066
+ terminalRunStates.set(runId, run.lifecycle.state);
3067
+ run.checkpointResolvers.clear();
3068
+ liveActivities.delete(runId);
3069
+ eventPublisher.removeRun(runId);
3070
+ runs.delete(runId);
3071
+ };
2587
3072
  const stopWorkflowRun = async (runId) => {
2588
3073
  const run = runs.get(runId);
2589
3074
  const terminalState = terminalRunStates.get(runId);
@@ -2597,6 +3082,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2597
3082
  run.execution?.cancel();
2598
3083
  await scheduler.cancelRun(run.store.runId);
2599
3084
  await scheduler.flush();
3085
+ await cleanupTerminalRun(runId);
2600
3086
  return { runId, state: "stopped", stopped: true };
2601
3087
  };
2602
3088
  const answerCheckpoint = async (runId, name, approved, silent = false) => {
@@ -2629,18 +3115,18 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2629
3115
  return undefined;
2630
3116
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2631
3117
  const result = await applyBudgetDecision(request, approved);
2632
- run.budgetResolvers.get(proposalId)?.(result);
2633
- run.budgetResolvers.delete(proposalId);
2634
3118
  if (!silent)
2635
3119
  deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2636
3120
  return result;
2637
3121
  };
2638
- const checkpointBridge = (runId, store, metadata, foreground, ui) => {
3122
+ const checkpointBridge = (runId, store, metadata, foreground, ui, headless = false) => {
2639
3123
  const checkpointCounters = new Map();
2640
3124
  return async (raw, signal) => {
2641
3125
  const input = validateCheckpoint(raw);
2642
3126
  const label = nextNamedOccurrence(checkpointCounters, input.name);
2643
3127
  const path = operationPath("checkpoint", label);
3128
+ if (headless)
3129
+ fail("RESUME_INCOMPATIBLE", "Headless CLI checkpoints are unsupported");
2644
3130
  if (foreground && !ui?.select)
2645
3131
  fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
2646
3132
  const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
@@ -2731,14 +3217,17 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2731
3217
  return;
2732
3218
  const catalog = registry.catalog();
2733
3219
  const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2734
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases)
3220
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases)
2735
3221
  return;
2736
3222
  pi.registerTool({
2737
3223
  name: "workflow_catalog",
2738
3224
  label: "Workflow Catalog",
2739
- description: "List global workflow functions, variables, and reusable workflows",
2740
- parameters: Type.Object({}, { additionalProperties: false }),
2741
- async execute() { return { content: [{ type: "text", text: JSON.stringify(registry.catalog()) }], details: {} }; }
3225
+ description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
3226
+ parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or variable name for full detail" })) }, { additionalProperties: false }),
3227
+ async execute(_id, params = {}) {
3228
+ const result = params.name === undefined ? registry.catalogIndex() : registry.catalogDetail(params.name);
3229
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
3230
+ }
2742
3231
  });
2743
3232
  catalogRegistered = true;
2744
3233
  };
@@ -2748,7 +3237,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2748
3237
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2749
3238
  if (missing)
2750
3239
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2751
- const settingsPath = workflowSettingsPath();
3240
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2752
3241
  const currentSettings = loadSettings(settingsPath);
2753
3242
  resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2754
3243
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2762,7 +3251,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2762
3251
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2763
3252
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2764
3253
  await run.store.saveSnapshot(snapshot);
2765
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
3254
+ run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2766
3255
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2767
3256
  const drift = aliasDrift(previousAliases, currentAliases);
2768
3257
  if (drift.length)
@@ -2770,6 +3259,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2770
3259
  };
2771
3260
  const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
2772
3261
  const loaded = await run.store.load();
3262
+ await run.store.validateBorrowedWorktrees();
2773
3263
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
2774
3264
  throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
2775
3265
  if (loaded.snapshot.roles === undefined)
@@ -2783,7 +3273,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2783
3273
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2784
3274
  if (missing)
2785
3275
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2786
- const settingsPath = workflowSettingsPath();
3276
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2787
3277
  const currentSettings = loadSettings(settingsPath);
2788
3278
  resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2789
3279
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2796,10 +3286,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2796
3286
  const resumeAliases = { ...previousAliases, ...currentAliases };
2797
3287
  const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2798
3288
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2799
- preflight(loaded.snapshot.script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata);
3289
+ const script = launchScriptForSnapshot(loaded.snapshot, registry);
3290
+ preflight(script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata);
2800
3291
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2801
3292
  await run.store.saveSnapshot(snapshot);
2802
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
3293
+ run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2803
3294
  const drift = aliasDrift(previousAliases, currentAliases);
2804
3295
  if (drift.length)
2805
3296
  await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
@@ -2819,11 +3310,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2819
3310
  await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
2820
3311
  run.update?.(workflowToolUpdate(persisted));
2821
3312
  }
3313
+ await cleanupTerminalRun(run.store.runId);
2822
3314
  throw typed;
2823
3315
  }
2824
3316
  await scheduler.cancelRun(run.store.runId);
2825
3317
  await run.lifecycle.resume();
2826
- const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
3318
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (prompt, options, signal, identity) => {
2827
3319
  await run.lifecycle.enter();
2828
3320
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2829
3321
  const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
@@ -2870,7 +3362,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2870
3362
  conversationLocks.delete(conversationLock);
2871
3363
  await run.lifecycle.leave();
2872
3364
  }
2873
- }, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
3365
+ }, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
2874
3366
  let previousPhase;
2875
3367
  const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
2876
3368
  await eventPublisher.phase(run.store, run.metadata, previousPhase, phase);
@@ -2898,8 +3390,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2898
3390
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2899
3391
  run.update?.(workflowToolUpdate(persisted));
2900
3392
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
2901
- deliverFailure(pi, run.metadata.name, error);
2902
- });
3393
+ deliverFailure(pi, createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted));
3394
+ }).finally(() => cleanupTerminalRun(run.store.runId));
3395
+ run.completion = completion;
2903
3396
  void completion;
2904
3397
  };
2905
3398
  const applyBudgetDecision = async (request, approved) => {
@@ -2935,18 +3428,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2935
3428
  if (budgetRelaxed(currentBudget, nextBudget)) {
2936
3429
  const proposalId = randomUUID();
2937
3430
  const request = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2938
- const decision = new Promise((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2939
- try {
2940
- await run.store.requestWorkflowDecision(request);
2941
- await appendBudgetDecisionEvent(run, request, "adjustment_requested");
2942
- }
2943
- catch (error) {
2944
- run.budgetResolvers.delete(proposalId);
2945
- throw error;
2946
- }
3431
+ await run.store.requestWorkflowDecision(request);
3432
+ await appendBudgetDecisionEvent(run, request, "adjustment_requested");
2947
3433
  deliver(pi, budgetDecisionDelivery(run.metadata, request));
2948
- const decisionResult = await decision;
2949
- return { state: decisionResult.state };
3434
+ return { state: "awaiting_approval", proposalId };
2950
3435
  }
2951
3436
  const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2952
3437
  if (changed) {
@@ -3015,7 +3500,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3015
3500
  const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
3016
3501
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
3017
3502
  const roleDefinitions = loaded.snapshot.roles ?? {};
3018
- runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController: new AbortController(), projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map() });
3503
+ const abortController = new AbortController();
3504
+ const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
3505
+ runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx), loaded.snapshot.settingsPath ?? workflowSettingsPath(extensionAgentDir)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
3019
3506
  for (const checkpoint of await store.awaitingCheckpoints())
3020
3507
  deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
3021
3508
  for (const decision of await store.pendingWorkflowDecisions())
@@ -3052,7 +3539,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3052
3539
  pi.on("before_agent_start", (event, ctx) => {
3053
3540
  if (!pi.getActiveTools().includes("workflow"))
3054
3541
  return;
3055
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, undefined, projectTrusted(ctx))).filter(([, definition]) => definition.description);
3542
+ const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
3056
3543
  if (!roles.length)
3057
3544
  return;
3058
3545
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
@@ -3064,9 +3551,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3064
3551
  description: WORKFLOW_TOOL_DESCRIPTION,
3065
3552
  promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
3066
3553
  parameters: WORKFLOW_TOOL_PARAMETERS,
3067
- async execute(_id, params, signal, onUpdate, ctx) {
3554
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
3068
3555
  try {
3069
- const settingsPath = workflowSettingsPath();
3556
+ const headless = object(ctx) && ctx.headless === true;
3557
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
3070
3558
  const defaults = loadSettings(settingsPath);
3071
3559
  if (!ctx.model)
3072
3560
  throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
@@ -3082,8 +3570,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3082
3570
  const trustedProject = projectTrusted(ctx);
3083
3571
  if (typeof ctx.cwd === "string")
3084
3572
  resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
3085
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
3086
- const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
3573
+ const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
3574
+ const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
3087
3575
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
3088
3576
  const runId = randomUUID();
3089
3577
  const args = (params.args ?? null);
@@ -3096,24 +3584,28 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3096
3584
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
3097
3585
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
3098
3586
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
3587
+ const parentRunId = params.parentRunId;
3588
+ if (parentRunId !== undefined)
3589
+ await store.validateParentRun(parentRunId);
3099
3590
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
3100
3591
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
3101
3592
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
3102
3593
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
3103
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
3594
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(functionName ? { launchKind: "function", functionName } : {}), ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
3104
3595
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
3105
3596
  const initialBudget = budgetRuntime.snapshot();
3106
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
3597
+ await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
3107
3598
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
3108
3599
  const background = !params.foreground;
3109
3600
  const providerPause = async () => { if (background)
3110
3601
  deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
3111
- const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx)), runContext }, createSession);
3112
- runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
3602
+ const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
3603
+ const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx), settingsPath), runContext }, createSession);
3604
+ runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
3113
3605
  if (params.foreground && onUpdate)
3114
3606
  onUpdate(workflowToolUpdate((await store.load()).run));
3115
3607
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
3116
- const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
3608
+ const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (prompt, options, agentSignal, identity) => {
3117
3609
  await lifecycle.enter();
3118
3610
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
3119
3611
  const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
@@ -3163,7 +3655,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3163
3655
  conversationLocks.delete(conversationLock);
3164
3656
  await lifecycle.leave();
3165
3657
  }
3166
- }, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
3658
+ }, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: async (phase) => {
3167
3659
  await lifecycle.enter();
3168
3660
  try {
3169
3661
  let previousPhase;
@@ -3190,21 +3682,32 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3190
3682
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
3191
3683
  if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
3192
3684
  await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
3193
- await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3685
+ const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3194
3686
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
3195
3687
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
3688
+ const diagnostic = createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
3689
+ Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic });
3690
+ if (params.foreground)
3691
+ pendingFailureDiagnostics.set(toolCallId, diagnostic);
3196
3692
  throw typed;
3197
3693
  });
3198
- runs.get(runId).completion = finish;
3694
+ const completion = finish.finally(() => cleanupTerminalRun(runId));
3695
+ runs.get(runId).completion = completion;
3199
3696
  if (background) {
3200
- void finish.then(async ({ value, resultPath }) => {
3697
+ void completion.then(async ({ value, resultPath }) => {
3201
3698
  deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
3202
- }, (error) => { deliverFailure(pi, checked.metadata.name, error); });
3699
+ }, (error) => {
3700
+ const diagnostic = failureDiagnosticsFrom(error);
3701
+ if (diagnostic)
3702
+ deliverFailure(pi, diagnostic);
3703
+ else
3704
+ deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
3705
+ });
3203
3706
  return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
3204
3707
  }
3205
- const { value } = await finish;
3708
+ const { value } = await completion;
3206
3709
  const run = (await store.load()).run;
3207
- return { content: [{ type: "text", text: JSON.stringify(value) }], details: { runId, value, run } };
3710
+ return { content: [{ type: "text", text: JSON.stringify(value) }, { type: "text", text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
3208
3711
  }
3209
3712
  catch (error) {
3210
3713
  throw mainAgentError(error);
@@ -3215,19 +3718,22 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3215
3718
  },
3216
3719
  renderResult(result, { isPartial }, _theme, context) {
3217
3720
  const details = result.details;
3721
+ if (isWorkflowFailureDiagnostics(details))
3722
+ return textBlock(formatWorkflowFailureDiagnostics(details));
3723
+ const runDetails = details;
3218
3724
  const state = context.state;
3219
- if (details?.run && isPartial && details.run.state === "running" && !state.workflowSpinner) {
3725
+ if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
3220
3726
  state.workflowSpinner = setInterval(context.invalidate, 80);
3221
3727
  state.workflowSpinner.unref();
3222
3728
  }
3223
- else if ((!isPartial || details?.run?.state !== "running") && state.workflowSpinner) {
3729
+ else if ((!isPartial || runDetails?.run?.state !== "running") && state.workflowSpinner) {
3224
3730
  clearInterval(state.workflowSpinner);
3225
3731
  delete state.workflowSpinner;
3226
3732
  }
3227
- if (details?.run)
3228
- return workflowProgressBlock(details.run);
3733
+ if (runDetails?.run)
3734
+ return workflowProgressBlock(runDetails.run);
3229
3735
  const content = result.content[0];
3230
- return textBlock(isPartial ? "Workflow starting..." : details?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
3736
+ return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
3231
3737
  },
3232
3738
  });
3233
3739
  pi.registerCommand("workflow", {
@@ -3351,7 +3857,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3351
3857
  }
3352
3858
  };
3353
3859
  const manageAliases = async () => {
3354
- const settingsPath = workflowSettingsPath();
3860
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
3355
3861
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
3356
3862
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
3357
3863
  const selectTarget = async () => {
@@ -3469,10 +3975,20 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3469
3975
  const labels = navigatorRunLabels(sorted);
3470
3976
  const terminalStates = new Set(["completed", "failed", "stopped"]);
3471
3977
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
3472
- const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
3978
+ const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
3473
3979
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
3474
3980
  if (!runChoice || runChoice === "Close")
3475
3981
  return;
3982
+ if (runChoice === "Inspect session in pane") {
3983
+ try {
3984
+ await openHerdrPane({ action: "inspect", cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() });
3985
+ ctx.ui.notify("Opened session inspector in pane.", "info");
3986
+ }
3987
+ catch (error) {
3988
+ ctx.ui.notify(`Cannot open session inspector in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3989
+ }
3990
+ continue;
3991
+ }
3476
3992
  if (runChoice === "Model aliases") {
3477
3993
  await manageAliases();
3478
3994
  stores = await loadStores();
@@ -3508,49 +4024,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3508
4024
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
3509
4025
  }
3510
4026
  };
3511
- const openTranscript = async (transcript) => {
3512
- try {
3513
- const entries = SessionManager.open(transcript).buildContextEntries();
3514
- if (ctx.mode !== "tui") {
3515
- ctx.ui.notify(`Transcript: ${transcript}`, "info");
3516
- return;
3517
- }
3518
- await ctx.ui.custom((tui, theme, keybindings, done) => {
3519
- let offset = 0;
3520
- let renderedLines = [];
3521
- const viewport = () => Math.max(1, tuiRows(tui) - 3);
3522
- const move = (delta) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3523
- return {
3524
- render(width) {
3525
- renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3526
- offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3527
- return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3528
- },
3529
- invalidate() { },
3530
- handleInput(data) {
3531
- if (keybindings.matches(data, "tui.select.up"))
3532
- move(-1);
3533
- else if (keybindings.matches(data, "tui.select.down"))
3534
- move(1);
3535
- else if (keybindings.matches(data, "tui.select.pageUp"))
3536
- move(-viewport());
3537
- else if (keybindings.matches(data, "tui.select.pageDown"))
3538
- move(viewport());
3539
- else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
3540
- offset = 0;
3541
- else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
3542
- offset = Math.max(0, renderedLines.length - viewport());
3543
- else if (keybindings.matches(data, "tui.select.cancel"))
3544
- done(undefined);
3545
- tui.requestRender();
3546
- },
3547
- };
3548
- }, { overlay: true });
3549
- }
3550
- catch (error) {
3551
- ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3552
- }
3553
- };
3554
4027
  const loadDashboard = async () => {
3555
4028
  const loaded = await store.load();
3556
4029
  const checkpoints = await store.awaitingCheckpoints();
@@ -3590,20 +4063,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3590
4063
  actions.set("Refresh", "refresh");
3591
4064
  else
3592
4065
  actions.set("View script", "view-script");
3593
- const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
3594
4066
  if (loaded.run.agents.length)
3595
4067
  actions.set("Agents...", "agents");
3596
- if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length)
3597
- actions.set("View transcript", "view-transcript");
3598
- if (!loaded.run.agents.length && transcripts.length)
3599
- actions.set("Transcript paths", "transcripts");
3600
4068
  if (terminalStates.has(loaded.run.state))
3601
4069
  add("Delete", "delete");
3602
4070
  if (ctx.mode === "tui") {
3603
4071
  addCopy("Copy run path", store.directory, "run path");
3604
4072
  addCopy("Copy run ID", store.runId, "run ID");
3605
4073
  }
3606
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
4074
+ return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3607
4075
  };
3608
4076
  const selectAgent = async (dashboard) => {
3609
4077
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
@@ -3626,14 +4094,14 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3626
4094
  const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3627
4095
  const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3628
4096
  const actions = [
3629
- ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
4097
+ ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3630
4098
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3631
4099
  "Copy agent ID",
3632
4100
  "Back",
3633
4101
  ];
3634
4102
  const chooseAttempt = async () => {
3635
4103
  const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3636
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
4104
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3637
4105
  const index = choice ? choices.indexOf(choice) : -1;
3638
4106
  return index >= 0 ? attempts[index] : undefined;
3639
4107
  };
@@ -3653,14 +4121,20 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3653
4121
  await copyArtifact(worktree.path, "worktree path");
3654
4122
  continue;
3655
4123
  }
3656
- if (action === "View transcript" || action === "Copy transcript path") {
4124
+ if (action === "Fork as Pi session in pane") {
3657
4125
  const attempt = await chooseAttempt();
3658
4126
  if (!attempt)
3659
4127
  continue;
3660
- if (action === "Copy transcript path")
3661
- await copyArtifact(attempt.sessionFile, "transcript path");
3662
- else
3663
- await openTranscript(attempt.sessionFile);
4128
+ const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
4129
+ if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?"))
4130
+ continue;
4131
+ try {
4132
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
4133
+ ctx.ui.notify("Forked Pi session in pane.", "info");
4134
+ }
4135
+ catch (error) {
4136
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
4137
+ }
3664
4138
  }
3665
4139
  }
3666
4140
  };
@@ -3675,7 +4149,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3675
4149
  let disposed = false;
3676
4150
  let stopRequested = false;
3677
4151
  let stopStatus;
3678
- const terminalRows = () => Math.max(1, tuiRows(tui));
4152
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3679
4153
  const keyLabels = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3680
4154
  const keyLabel = (binding, fallback) => {
3681
4155
  const keys = keybindingKeys(keybindings, binding);
@@ -3684,9 +4158,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3684
4158
  const dashboardLayout = () => {
3685
4159
  const rows = terminalRows();
3686
4160
  const hintRows = rows >= 4 ? 1 : 0;
3687
- const separatorRows = rows >= 6 ? 1 : 0;
4161
+ const separatorRows = rows >= 8 ? 1 : 0;
3688
4162
  const available = Math.max(1, rows - hintRows - separatorRows);
3689
- const actionViewport = Math.min(options.length, Math.max(1, Math.floor(available / 2)));
4163
+ const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
3690
4164
  return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3691
4165
  };
3692
4166
  const updateDashboard = async (selectedOption) => {
@@ -3729,7 +4203,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3729
4203
  void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3730
4204
  }, 1000);
3731
4205
  timer.unref();
3732
- return {
4206
+ return borderWorkflowOverlay({
3733
4207
  render(width) {
3734
4208
  const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
3735
4209
  const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
@@ -3776,7 +4250,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3776
4250
  tui.requestRender();
3777
4251
  },
3778
4252
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
3779
- };
4253
+ }, theme);
3780
4254
  }, { overlay: true })
3781
4255
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
3782
4256
  if (!actionChoice || actionChoice === "Close")
@@ -3792,12 +4266,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3792
4266
  const highlighted = highlightCode(view.script, "javascript");
3793
4267
  let offset = 0;
3794
4268
  let renderedLines = [];
3795
- const viewport = () => Math.max(1, tuiRows(tui) - 3);
4269
+ const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
3796
4270
  const move = (delta) => {
3797
4271
  const maxOffset = Math.max(0, renderedLines.length - viewport());
3798
4272
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3799
4273
  };
3800
- return {
4274
+ return borderWorkflowOverlay({
3801
4275
  render(width) {
3802
4276
  renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3803
4277
  const maxOffset = Math.max(0, renderedLines.length - viewport());
@@ -3823,24 +4297,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3823
4297
  done(undefined);
3824
4298
  tui.requestRender();
3825
4299
  },
3826
- };
3827
- });
3828
- continue;
3829
- }
3830
- if (actionChoice === "View transcript") {
3831
- const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
3832
- if (transcript && transcript !== "Back")
3833
- await openTranscript(transcript);
3834
- continue;
3835
- }
3836
- if (actionChoice === "Transcript paths") {
3837
- const transcript = await ctx.ui.select("Native Pi transcript paths", [...view.transcripts, "Back"]);
3838
- if (transcript && transcript !== "Back") {
3839
- if (ctx.mode === "tui")
3840
- await copyArtifact(transcript, "transcript path");
3841
- else
3842
- ctx.ui.notify(transcript, "info");
3843
- }
4300
+ }, theme);
4301
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3844
4302
  continue;
3845
4303
  }
3846
4304
  const copy = view.copies.get(actionChoice);
@@ -3858,11 +4316,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3858
4316
  let offset = 0;
3859
4317
  let renderedLines = [];
3860
4318
  const layout = () => {
3861
- const rows = Math.max(1, tuiRows(tui));
4319
+ const rows = Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3862
4320
  const compactControls = rows < 4;
3863
4321
  const titleRows = rows >= 5 ? 1 : 0;
3864
4322
  const hintRows = rows >= 8 ? 1 : 0;
3865
- const separatorRows = rows >= 8 ? 2 : 0;
4323
+ const separatorRows = rows >= 8 ? 1 : 0;
3866
4324
  const controlRows = compactControls ? 1 : options.length;
3867
4325
  const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
3868
4326
  return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
@@ -3871,7 +4329,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3871
4329
  const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
3872
4330
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3873
4331
  };
3874
- return {
4332
+ return borderWorkflowOverlay({
3875
4333
  render(width) {
3876
4334
  renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
3877
4335
  const currentLayout = layout();
@@ -3886,7 +4344,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3886
4344
  ...renderedLines.slice(offset, offset + currentLayout.contentViewport),
3887
4345
  ...(currentLayout.separatorRows ? [""] : []),
3888
4346
  ...controls,
3889
- ...(currentLayout.separatorRows ? [""] : []),
3890
4347
  ...(currentLayout.hintRows ? [hint] : []),
3891
4348
  ];
3892
4349
  },
@@ -3906,8 +4363,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
3906
4363
  done(undefined);
3907
4364
  tui.requestRender();
3908
4365
  },
3909
- };
3910
- });
4366
+ }, theme);
4367
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3911
4368
  if (decision) {
3912
4369
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3913
4370
  if (!accepted)