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/src/index.ts CHANGED
@@ -1,23 +1,24 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { fork, type ChildProcess } from "node:child_process";
2
+ import { fork, spawn, type ChildProcess } 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, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
14
- import { transcriptLines } from "./session-inspector.js";
13
+ import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
15
+ import { herdrPaneId, openHerdrPane } from "./herdr.js";
15
16
  import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
16
17
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
17
18
 
18
19
  export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
19
20
  export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
20
- export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
21
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree", "shell"] as const;
21
22
  export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
22
23
  export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
23
24
  export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
@@ -32,7 +33,7 @@ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
32
33
  const SETTLED_AGENT_STATES: ReadonlySet<AgentState> = new Set(["completed", "failed", "cancelled"]);
33
34
  export const ERROR_CODES = [
34
35
  "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
35
- "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
36
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "SHELL_FAILED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
36
37
  "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
37
38
  ] as const;
38
39
 
@@ -41,6 +42,8 @@ export type AgentState = (typeof AGENT_STATES)[number];
41
42
  export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
42
43
  export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
43
44
  export type JsonSchema = { [key: string]: JsonValue };
45
+ export interface ShellOptions { timeoutMs?: number; env?: Record<string, string> }
46
+ export interface ShellResult { exitCode: number | null; stdout: string; stderr: string }
44
47
  export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
45
48
  export interface BudgetLimits { soft?: number; hard?: number }
46
49
  export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
@@ -69,19 +72,24 @@ export interface AgentResourcePolicy { globalSettingsPath: string; projectSettin
69
72
  export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelSpec; tools: readonly string[]; cwd: string; disabledAgentResources?: { skills: readonly string[]; extensions: readonly string[]; unmatchedSkills: readonly string[]; unmatchedExtensions: readonly string[] } }
70
73
  export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
71
74
  export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
75
+ export interface WorkflowWorktreeReference { readonly path: string; readonly branch: string }
72
76
  export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
73
77
  export interface WorkflowRunEvent { type: string; message: string }
74
- export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
75
- export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
76
- export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
78
+ export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; parentRunId?: string; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
79
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 4;
80
+ export interface LaunchSnapshot { identityVersion?: number; launchKind?: "inline" | "function"; functionName?: string; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
77
81
  export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; skipModelAvailability?: boolean }
78
82
  export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
79
83
  export interface WorkflowOrchestrationContext {
80
84
  agent: (...args: readonly unknown[]) => Promise<JsonValue>;
85
+ shell: (command: string, options?: ShellOptions) => Promise<ShellResult>;
81
86
  prompt: (template: string, values: Readonly<Record<string, JsonValue>>) => string;
82
87
  parallel: (...args: readonly unknown[]) => Promise<JsonValue>;
83
88
  pipeline: (...args: readonly unknown[]) => Promise<JsonValue>;
84
- withWorktree: (...args: readonly unknown[]) => Promise<JsonValue>;
89
+ withWorktree: {
90
+ (callback: WorkflowWorktreeCallback): Promise<JsonValue>;
91
+ (name: string, callback: WorkflowWorktreeCallback): Promise<JsonValue>;
92
+ };
85
93
  checkpoint: (...args: readonly unknown[]) => Promise<boolean>;
86
94
  phase: (name: string) => void;
87
95
  log: (message: string) => void;
@@ -91,16 +99,45 @@ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
91
99
  run: Readonly<WorkflowRunContext>;
92
100
  invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
93
101
  }
102
+ export type WorkflowWorktreeCallback = (reference: Readonly<WorkflowWorktreeReference>) => JsonValue | Promise<JsonValue>;
94
103
  export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
95
104
  export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
96
- export interface WorkflowScriptDefinition { description: string; script: string }
97
- export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; workflows?: Readonly<Record<string, WorkflowScriptDefinition>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
105
+ export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
98
106
  export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
99
107
 
100
108
  export class WorkflowError extends Error {
101
109
  constructor(public readonly code: WorkflowErrorCode, message: string) { super(message); this.name = "WorkflowError"; }
102
110
  }
103
111
 
112
+ export interface WorkflowFailureAgent {
113
+ id: string;
114
+ label?: string;
115
+ role?: string;
116
+ structuralPath: readonly string[];
117
+ attempt: number;
118
+ sessionId?: string;
119
+ sessionFile?: string;
120
+ }
121
+ export interface WorkflowSiblingAgent {
122
+ id: string;
123
+ label?: string;
124
+ role?: string;
125
+ structuralPath: readonly string[];
126
+ }
127
+ export interface WorkflowFailureDiagnostics {
128
+ runId: string;
129
+ workflowName: string;
130
+ state: RunState;
131
+ failedAt: string | null;
132
+ error: WorkflowErrorShape;
133
+ failedAgent?: WorkflowFailureAgent;
134
+ completedSiblingAgents?: readonly WorkflowSiblingAgent[];
135
+ completedSiblingPaths: readonly (readonly string[])[];
136
+ artifacts: { runDirectory: string; statePath: string; journalPath: string };
137
+ }
138
+
139
+ const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
140
+
104
141
  const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
105
142
  type WorkerErrorShape = WorkflowErrorShape & { authored?: boolean; failedAt?: string };
106
143
 
@@ -130,12 +167,13 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
130
167
  INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
131
168
  REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
132
169
  GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
133
- MISSING_WORKFLOW: (detail) => `The workflow primitive is unavailable: ${detail}.`,
170
+ MISSING_WORKFLOW: (detail) => `The registered workflow function is unavailable: ${detail}.`,
134
171
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
135
172
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
136
173
  UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
137
174
  RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
138
175
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
176
+ SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
139
177
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
140
178
  AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
141
179
  RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
@@ -686,6 +724,7 @@ function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
686
724
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
687
725
  const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
688
726
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
727
+ const INTERNAL_SHELL_NAME = "__pi_extensible_workflows_shell";
689
728
 
690
729
  function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
691
730
  let previous: acorn.Token | undefined;
@@ -704,12 +743,13 @@ function instrumentWorkflow(script: string): string {
704
743
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
705
744
  if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
706
745
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
707
- const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
746
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME)) fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
747
+ const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree", "shell"].includes(call.callee.name));
708
748
  const edits = calls.flatMap((call) => {
709
749
  const callSite = `${String(call.start)}:${String(call.end)}`;
710
750
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
711
751
  return [
712
- { 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 },
752
+ { 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 },
713
753
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
714
754
  ];
715
755
  }).sort((left, right) => right.start - left.start);
@@ -821,6 +861,18 @@ function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue
821
861
  if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
822
862
  return value;
823
863
  }
864
+ const SHELL_OPTION_KEYS = new Set(["timeoutMs", "env"]);
865
+ function validateShellOptions(value: unknown): ShellOptions {
866
+ if (value === undefined) return {};
867
+ if (!object(value) || !jsonValue(value) || Object.keys(value).some((key) => !SHELL_OPTION_KEYS.has(key))) fail("INVALID_METADATA", "shell options must contain only timeoutMs and env");
868
+ if (value.timeoutMs !== undefined && !positiveInteger(value.timeoutMs)) fail("INVALID_METADATA", "shell timeoutMs must be a positive integer");
869
+ if (value.env !== undefined && (!object(value.env) || Object.values(value.env).some((entry) => typeof entry !== "string"))) fail("INVALID_METADATA", "shell env must be an object of strings");
870
+ return { ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }), ...(value.env === undefined ? {} : { env: value.env as Record<string, string> }) };
871
+ }
872
+ function validateShellCommand(value: unknown): string {
873
+ if (typeof value !== "string") fail("INVALID_METADATA", "shell command must be a string");
874
+ return value;
875
+ }
824
876
 
825
877
  type StaticValue = { known: true; value: unknown } | { known: false };
826
878
 
@@ -901,6 +953,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
901
953
  return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value as JsonSchema } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
902
954
  }
903
955
  if (kind === "checkpoint") return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
956
+ if (kind === "shell") return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
904
957
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
905
958
  });
906
959
  }
@@ -914,6 +967,14 @@ function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Re
914
967
  if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
915
968
  }
916
969
  }
970
+ function validateStaticShellOptions(call: WorkflowCall): void {
971
+ if (call.arguments.some((argument) => argument.type === "SpreadElement")) return;
972
+ if (call.arguments.length !== 1 && call.arguments.length !== 2) fail("INVALID_METADATA", "shell requires a command string and optional options");
973
+ const command = staticValue(callArgument(call, 0));
974
+ if (command.known) validateShellCommand(command.value);
975
+ const options = staticValue(callArgument(call, 1));
976
+ if (options.known) validateShellOptions(options.value);
977
+ }
917
978
 
918
979
  function validateStaticWithWorktree(call: WorkflowCall): void {
919
980
  if (call.arguments.some((argument) => argument.type === "SpreadElement")) return;
@@ -928,16 +989,18 @@ function validateStaticWithWorktree(call: WorkflowCall): void {
928
989
 
929
990
  export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
930
991
  export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
931
- export interface WorkflowCatalogWorkflow { name: string; version: string; headline: string; extensionDescription: string; description: string }
932
- export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[]; modelAliases?: Readonly<Record<string, string>> }
933
- 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"]);
992
+ export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; modelAliases?: Readonly<Record<string, string>> }
993
+ export interface WorkflowCatalogIndexFunction { name: string; description: string; input: JsonSchema }
994
+ export interface WorkflowCatalogIndexVariable { name: string; description: string; schema: JsonSchema }
995
+ export interface WorkflowCatalogIndex { functions: readonly WorkflowCatalogIndexFunction[]; variables: readonly WorkflowCatalogIndexVariable[]; modelAliases?: Readonly<Record<string, string>> }
996
+ export interface WorkflowCatalogError { error: { code: "NOT_FOUND"; name: string; message: string } }
997
+ 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"]);
934
998
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
935
999
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
936
1000
 
937
1001
  export class WorkflowRegistry {
938
1002
  readonly #extensions = new Set<Readonly<WorkflowExtension>>();
939
1003
  readonly #globals = new Map<string, string>();
940
- readonly #workflows = new Map<string, WorkflowScriptDefinition>();
941
1004
  readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
942
1005
  #frozen = false;
943
1006
 
@@ -946,12 +1009,12 @@ export class WorkflowRegistry {
946
1009
 
947
1010
  register(extension: WorkflowExtension): void {
948
1011
  if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
949
- 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()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
1012
+ if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows")) fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
1013
+ 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()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
950
1014
  const functions = extension.functions ?? {};
951
1015
  const variables = extension.variables ?? {};
952
- const workflows = extension.workflows ?? {};
953
1016
  const agentSetupHooks = extension.agentSetupHooks ?? {};
954
- 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)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
1017
+ if (!object(functions) || !object(variables) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(agentSetupHooks).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, or agent setup hooks");
955
1018
  const names = [...Object.keys(functions), ...Object.keys(variables)];
956
1019
  if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
957
1020
  for (const name of names) {
@@ -969,46 +1032,54 @@ export class WorkflowRegistry {
969
1032
  if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
970
1033
  validateSchema(variable.schema, `${name} schema`);
971
1034
  }
972
- for (const [name, workflow] of Object.entries(workflows)) {
973
- 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()) fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
974
- if (this.#workflows.has(name)) fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
975
- parseWorkflow(workflow.script);
976
- }
977
1035
  for (const [name, hook] of Object.entries(agentSetupHooks)) {
978
1036
  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))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
979
1037
  if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
980
1038
  }
981
- const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
1039
+ const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
982
1040
  this.#extensions.add(stored);
983
1041
  for (const name of names) this.#globals.set(name, name);
984
- for (const [name, workflow] of Object.entries(workflows)) this.#workflows.set(name, workflow);
985
1042
  for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
986
1043
  }
987
1044
 
988
- workflow(name: string): WorkflowScriptDefinition {
989
- if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
990
- const workflow = this.#workflows.get(name);
991
- if (!workflow) fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
992
- return workflow;
1045
+ function(name: string): WorkflowFunction {
1046
+ if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
1047
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1048
+ if (!fn) fail("MISSING_WORKFLOW", `Registered function is unavailable: ${name}; the separate registered-workflow format was removed`);
1049
+ return fn;
993
1050
  }
994
1051
 
995
- workflows(): Readonly<Record<string, WorkflowScriptDefinition>> {
996
- return Object.freeze(Object.fromEntries(this.#workflows));
1052
+ functions(): Readonly<Record<string, WorkflowFunction>> {
1053
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
997
1054
  }
998
1055
 
999
1056
  catalog(): WorkflowCatalog {
1000
1057
  const functions: WorkflowCatalogFunction[] = [];
1001
1058
  const variables: WorkflowCatalogVariable[] = [];
1002
- const workflows: WorkflowCatalogWorkflow[] = [];
1003
1059
  for (const extension of this.#extensions) {
1004
1060
  for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
1005
1061
  for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
1006
- for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1007
1062
  }
1008
1063
  let aliases: Readonly<Record<string, string>> | undefined;
1009
1064
  try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
1010
1065
  const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
1011
- return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
1066
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
1067
+ }
1068
+
1069
+ catalogIndex(): WorkflowCatalogIndex {
1070
+ const catalog = this.catalog();
1071
+ return deepFreeze({
1072
+ functions: catalog.functions.map(({ name, description, input }) => ({ name, description, input: structuredClone(input) })),
1073
+ variables: catalog.variables.map(({ name, description, schema }) => ({ name, description, schema: structuredClone(schema) })),
1074
+ ...(catalog.modelAliases ? { modelAliases: structuredClone(catalog.modelAliases) } : {}),
1075
+ });
1076
+ }
1077
+
1078
+ catalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError {
1079
+ const catalog = this.catalog();
1080
+ const entry = catalog.functions.find((candidate) => candidate.name === name) ?? catalog.variables.find((candidate) => candidate.name === name);
1081
+ if (entry) return entry;
1082
+ return deepFreeze({ error: { code: "NOT_FOUND", name, message: `No registered workflow function or variable is available: ${name}` } });
1012
1083
  }
1013
1084
 
1014
1085
  globals(): Readonly<Record<string, { name: string }>> {
@@ -1016,15 +1087,14 @@ export class WorkflowRegistry {
1016
1087
  }
1017
1088
 
1018
1089
  async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
1019
- const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1020
- if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
1090
+ const fn = this.function(name);
1021
1091
  if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
1022
1092
  const replayed = journal.get(path);
1023
1093
  if (replayed !== undefined) {
1024
1094
  if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
1025
1095
  return structuredClone(replayed);
1026
1096
  }
1027
- const result: unknown = 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 }));
1097
+ const result: unknown = 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 }));
1028
1098
  if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
1029
1099
  const stored = structuredClone(result);
1030
1100
  journal.put(path, stored);
@@ -1038,7 +1108,7 @@ export class WorkflowRegistry {
1038
1108
  return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
1039
1109
  }
1040
1110
  }
1041
- type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
1111
+ type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
1042
1112
  interface WorkflowRegistryHost { api: WorkflowRegistryApi }
1043
1113
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
1044
1114
  const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
@@ -1047,9 +1117,11 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
1047
1117
  get frozen() { return registry.frozen; },
1048
1118
  freeze: () => { registry.freeze(); },
1049
1119
  register: (extension) => { registry.register(extension); },
1050
- workflow: (name) => registry.workflow(name),
1051
- workflows: () => registry.workflows(),
1120
+ function: (name) => registry.function(name),
1121
+ functions: () => registry.functions(),
1052
1122
  catalog: () => registry.catalog(),
1123
+ catalogIndex: () => registry.catalogIndex(),
1124
+ catalogDetail: (name) => registry.catalogDetail(name),
1053
1125
  globals: () => registry.globals(),
1054
1126
  invokeFunction: (...args) => registry.invokeFunction(...args),
1055
1127
  variables: () => registry.variables(),
@@ -1069,26 +1141,29 @@ function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().
1069
1141
  beginWorkflowExtensionLoading();
1070
1142
  export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
1071
1143
  export function workflowCatalog(): WorkflowCatalog { return loadingRegistry().catalog(); }
1072
- export function registeredWorkflowDefinitions(): Readonly<Record<string, WorkflowScriptDefinition>> { return loadingRegistry().workflows(); }
1144
+ export function workflowCatalogIndex(): WorkflowCatalogIndex { return loadingRegistry().catalogIndex(); }
1145
+ export function workflowCatalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError { return loadingRegistry().catalogDetail(name); }
1146
+ export function registeredWorkflowFunctions(): Readonly<Record<string, WorkflowFunction>> { return loadingRegistry().functions(); }
1073
1147
 
1074
1148
 
1075
1149
  export function formatWorkflowPreview(args: { script?: unknown; workflow?: unknown; name?: unknown; description?: unknown }): string {
1076
1150
  const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : typeof args.workflow === "string" && args.workflow.trim() ? args.workflow : "workflow";
1077
- if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered workflow" : ""}`;
1151
+ if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered function" : ""}`;
1078
1152
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
1079
1153
  }
1080
1154
  export const WORKFLOW_TOOL_LABEL = "Workflow";
1081
1155
  export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
1082
- 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.";
1156
+ 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.";
1083
1157
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
1084
1158
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
1085
1159
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
1086
1160
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
1087
- workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
1161
+ workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
1088
1162
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
1089
1163
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
1090
1164
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
1091
1165
  budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
1166
+ parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
1092
1167
  });
1093
1168
 
1094
1169
  function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
@@ -1109,8 +1184,10 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
1109
1184
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1110
1185
  if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
1111
1186
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
1187
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME)) fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
1112
1188
  validateDirectPrimitiveReferences(program, "withWorktree");
1113
1189
  validateDirectPrimitiveReferences(program, "conversation");
1190
+ validateDirectPrimitiveReferences(program, "shell");
1114
1191
  for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
1115
1192
  const calls = workflowCalls(program);
1116
1193
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
@@ -1121,6 +1198,7 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
1121
1198
  validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1122
1199
  }
1123
1200
  if (operation === "withWorktree") validateStaticWithWorktree(call);
1201
+ if (operation === "shell") validateStaticShellOptions(call);
1124
1202
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
1125
1203
  if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
1126
1204
  if (operation === "parallel" && (call.arguments.length !== 2 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "parallel requires an operation name string and tasks record");
@@ -1155,6 +1233,7 @@ export interface WorkflowValidationParameters {
1155
1233
  description?: string;
1156
1234
  script?: string;
1157
1235
  workflow?: string;
1236
+ args?: unknown;
1158
1237
  }
1159
1238
 
1160
1239
  export interface WorkflowValidationContext {
@@ -1165,6 +1244,7 @@ export interface WorkflowValidationContext {
1165
1244
  modelAliases?: Readonly<Record<string, string>>;
1166
1245
  knownModels?: ReadonlySet<string>;
1167
1246
  settingsPath?: string;
1247
+ agentDir?: string;
1168
1248
  }
1169
1249
 
1170
1250
 
@@ -1174,21 +1254,27 @@ export interface ValidatedWorkflowLaunch {
1174
1254
  agentDefinitions: Readonly<Record<string, AgentDefinition>>;
1175
1255
  projectAgentDefinitions: Readonly<Record<string, AgentDefinition>>;
1176
1256
  roleNames: readonly string[];
1257
+ functionName?: string;
1177
1258
  }
1178
1259
 
1260
+ function functionLaunchScript(name: string): string { return `return await ${name}(args);`; }
1261
+
1179
1262
  export function validateWorkflowLaunch(params: WorkflowValidationParameters, context: WorkflowValidationContext): ValidatedWorkflowLaunch {
1180
1263
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
1181
1264
  }
1182
1265
  function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
1183
1266
  if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
1184
1267
  if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
1185
- const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
1186
- const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
1187
- if (!script) fail("INVALID_SYNTAX", "Provide script or registered workflow");
1188
- const workflowName = typeof params.name === "string" && params.name.trim() ? params.name.trim() : typeof params.workflow === "string" ? params.workflow : "";
1268
+ const functionName = typeof params.workflow === "string" ? params.workflow : undefined;
1269
+ const fn = functionName === undefined ? undefined : registry.function(functionName);
1270
+ const args = params.args === undefined ? null : params.args;
1271
+ if (functionName !== undefined && fn && (!object(args) || !jsonValue(args) || !Value.Check(fn.input, args))) fail("RESULT_INVALID", `Invalid input for ${functionName}`);
1272
+ const script = functionName !== undefined && fn ? functionLaunchScript(functionName) : typeof params.script === "string" && params.script.trim() ? params.script : "";
1273
+ if (!script) fail("INVALID_SYNTAX", "Provide script or registered function");
1274
+ const workflowName = functionName ?? (typeof params.name === "string" && params.name.trim() ? params.name.trim() : "");
1189
1275
  if (!workflowName) fail("INVALID_METADATA", "Inline workflows require name");
1190
- const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : definition?.description ? { description: definition.description } : {}) });
1191
- const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
1276
+ const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : fn?.description ? { description: fn.description } : {}) });
1277
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false);
1192
1278
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
1193
1279
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
1194
1280
  const aliases = context.modelAliases ?? {};
@@ -1196,7 +1282,7 @@ function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters
1196
1282
  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);
1197
1283
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
1198
1284
  validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
1199
- return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
1285
+ return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, ...(functionName ? { functionName } : {}) };
1200
1286
  }
1201
1287
 
1202
1288
  function deepFreeze<T>(value: T): T {
@@ -1210,21 +1296,34 @@ function deepFreeze<T>(value: T): T {
1210
1296
  type LaunchSnapshotInput = Omit<LaunchSnapshot, "identityVersion"> & { identityVersion?: number };
1211
1297
 
1212
1298
  export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<LaunchSnapshot> {
1213
- return deepFreeze(structuredClone({ ...input, identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
1299
+ return deepFreeze(structuredClone({ ...input, launchKind: input.launchKind ?? (input.functionName ? "function" : "inline"), identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
1214
1300
  }
1215
1301
 
1216
1302
  export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapshot> {
1217
1303
  return deepFreeze(structuredClone(input));
1218
1304
  }
1219
1305
 
1306
+ function launchScriptForSnapshot(snapshot: Readonly<LaunchSnapshot>, registry: WorkflowRegistryApi): string {
1307
+ if (snapshot.launchKind === "function") {
1308
+ if (!snapshot.functionName) fail("RESUME_INCOMPATIBLE", "Persisted registered function launch is missing its function name");
1309
+ try { registry.function(snapshot.functionName); } catch (error) { if (error instanceof WorkflowError && error.code === "MISSING_WORKFLOW") throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted registered function is unavailable: ${snapshot.functionName}`); throw error; }
1310
+ return functionLaunchScript(snapshot.functionName);
1311
+ }
1312
+ if (snapshot.launchKind === "inline") return snapshot.script;
1313
+ fail("RESUME_INCOMPATIBLE", "This persisted run uses the removed registered-workflow format; launch it again as a registered function or inline script");
1314
+ }
1315
+
1220
1316
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
1221
1317
  export const HEARTBEAT_TIMEOUT_MS = 5000;
1222
1318
 
1223
1319
  export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string; conversation?: { name: string; turn: number } }
1320
+ export interface ShellIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; worktreeOwner?: string }
1224
1321
  export interface WorkflowBridge {
1225
1322
  agent?: (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
1323
+ shell?: (command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity) => Promise<ShellResult>;
1226
1324
  checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
1227
1325
  function?: (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath?: readonly string[]) => Promise<JsonValue>;
1326
+ worktree?: (owner: string, signal: AbortSignal) => Promise<Readonly<WorkflowWorktreeReference>>;
1228
1327
  functions?: Readonly<Record<string, { name: string }>>;
1229
1328
  variables?: Readonly<Record<string, JsonValue>>;
1230
1329
  phase?: (name: string) => void | Promise<void>;
@@ -1297,10 +1396,12 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1297
1396
  const path = (...names) => names.map(encodeURIComponent).join("/");
1298
1397
  const inheritedAgentPath = new AsyncLocalStorage();
1299
1398
  const agentOccurrences = new Map();
1399
+ const shellOccurrences = new Map();
1300
1400
  const conversationOccurrences = new Map();
1301
1401
  const worktreeOwners = new AsyncLocalStorage();
1302
1402
  const worktreeOccurrences = new Map();
1303
1403
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
1404
+ const rejectShell = () => { throw workError("INVALID_METADATA", "Workflow shell calls must use a direct shell(...) call; aliases and indirect calls are unsupported"); };
1304
1405
  const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
1305
1406
  const internalWithWorktree = async (...values) => {
1306
1407
  const callSite = values.pop();
@@ -1319,7 +1420,9 @@ const internalWithWorktree = async (...values) => {
1319
1420
  worktreeOccurrences.set(occurrenceKey, occurrence);
1320
1421
  owner = path("worktree", "unnamed", ...inherited, "callsite:" + callSite, "occurrence:" + String(occurrence));
1321
1422
  }
1322
- return await worktreeOwners.run(owner, callback);
1423
+ const reference = await rpc("worktree", [owner]);
1424
+ if (!reference || typeof reference !== "object" || typeof reference.path !== "string" || typeof reference.branch !== "string") throw workError("WORKTREE_FAILED", "Worktree reference is invalid");
1425
+ return await worktreeOwners.run(owner, () => callback(Object.freeze({ path: reference.path, branch: reference.branch })));
1323
1426
  };
1324
1427
  const internalConversation = (...values) => {
1325
1428
  const callSite = values.pop();
@@ -1378,6 +1481,31 @@ const internalAgent = (...values) => {
1378
1481
  });
1379
1482
  return result;
1380
1483
  };
1484
+ const internalShell = (...values) => {
1485
+ const callSite = values.pop();
1486
+ if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow shell call-site identity");
1487
+ if (values.length !== 1 && values.length !== 2) throw workError("INVALID_METADATA", "shell requires a command string and optional options");
1488
+ const command = values[0];
1489
+ if (typeof command !== "string") throw workError("INVALID_METADATA", "shell command must be a string");
1490
+ const options = values.length < 2 || values[1] === undefined ? {} : values[1];
1491
+ 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");
1492
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw workError("INVALID_METADATA", "shell timeoutMs must be a positive integer");
1493
+ 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");
1494
+ const inherited = inheritedAgentPath.getStore() || [];
1495
+ const worktreeOwner = worktreeOwners.getStore();
1496
+ const occurrenceKey = JSON.stringify([inherited, callSite, worktreeOwner || null]);
1497
+ const occurrence = (shellOccurrences.get(occurrenceKey) || 0) + 1;
1498
+ shellOccurrences.set(occurrenceKey, occurrence);
1499
+ const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
1500
+ const result = rpc("shell", [command, options, identity]);
1501
+ Object.defineProperties(result, {
1502
+ toJSON: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before serialization"); } },
1503
+ toString: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
1504
+ [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
1505
+ });
1506
+ return result;
1507
+ };
1508
+ const shell = rejectShell;
1381
1509
  const agent = rejectAgent;
1382
1510
  const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
1383
1511
  const plainPromptObject = value => {
@@ -1499,13 +1627,13 @@ const pipeline = async (operationName, items, stages) => {
1499
1627
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1500
1628
  };
1501
1629
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1502
- const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1630
+ const sandbox = { agent, conversation: internalConversation, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1503
1631
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1504
1632
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1505
1633
  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;
1506
1634
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1507
1635
  const body = config.script;
1508
- 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))
1636
+ 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))
1509
1637
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1510
1638
  .catch(error => send({ type: "error", error: workerError(error) }))
1511
1639
  .finally(() => clearInterval(heartbeat));
@@ -1518,6 +1646,10 @@ function encoded(value: unknown): string {
1518
1646
  return json;
1519
1647
  }
1520
1648
 
1649
+ function encodedRpcResult(id: number, value: JsonValue): string {
1650
+ return encoded({ type: "rpcResult", id, ok: true, value });
1651
+ }
1652
+
1521
1653
  function readAgentIdentity(value: unknown): AgentIdentity {
1522
1654
  if (!object(value)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1523
1655
  const structuralPath = value.structuralPath;
@@ -1530,10 +1662,17 @@ function readAgentIdentity(value: unknown): AgentIdentity {
1530
1662
  if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner) || conversation !== undefined && !parsedConversation) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1531
1663
  return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1532
1664
  }
1533
-
1665
+ function readShellIdentity(value: unknown): ShellIdentity {
1666
+ const identity = readAgentIdentity(value);
1667
+ if (identity.conversation) fail("INTERNAL_ERROR", "Shell identity cannot include a conversation");
1668
+ return { structuralPath: identity.structuralPath, callSite: identity.callSite, occurrence: identity.occurrence, ...(identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {}) };
1669
+ }
1534
1670
  function agentIdentityPath(identity: AgentIdentity): string {
1535
1671
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1536
1672
  }
1673
+ function shellIdentityPath(identity: ShellIdentity): string {
1674
+ return operationPath("shell", ...(identity.worktreeOwner ? ["worktree", identity.worktreeOwner] : []), ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1675
+ }
1537
1676
  function conversationIdentityPath(identity: AgentIdentity): string {
1538
1677
  if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1539
1678
  return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
@@ -1542,9 +1681,82 @@ function conversationTurnPath(identity: AgentIdentity): string {
1542
1681
  if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1543
1682
  return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1544
1683
  }
1684
+ function readShellResult(value: unknown): ShellResult {
1685
+ if (!object(value) || (value.exitCode !== null && !Number.isInteger(value.exitCode)) || typeof value.stdout !== "string" || typeof value.stderr !== "string") fail("SHELL_FAILED", "Shell bridge returned an invalid result");
1686
+ return { exitCode: value.exitCode as number | null, stdout: value.stdout, stderr: value.stderr };
1687
+ }
1545
1688
  function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
1546
1689
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1547
1690
  }
1691
+ function shellProcessKill(child: ChildProcess): void {
1692
+ let forceKill: ReturnType<typeof setTimeout> | undefined;
1693
+ const killProcessTree = (signal: "SIGTERM" | "SIGKILL") => {
1694
+ try {
1695
+ if (child.pid && process.platform !== "win32") process.kill(-child.pid, signal);
1696
+ else if (child.pid && process.platform === "win32") {
1697
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
1698
+ killer.unref();
1699
+ } else child.kill(signal);
1700
+ } catch {
1701
+ try { child.kill(signal); } catch { /* The process may already have exited. */ }
1702
+ }
1703
+ };
1704
+ child.once("close", () => { if (forceKill) clearTimeout(forceKill); });
1705
+ killProcessTree("SIGTERM");
1706
+ forceKill = setTimeout(() => { forceKill = undefined; killProcessTree("SIGKILL"); }, 1000);
1707
+ forceKill.unref();
1708
+ }
1709
+ function executeShellCommand(command: string, options: ShellOptions, signal: AbortSignal, cwd = process.cwd()): Promise<ShellResult> {
1710
+ return new Promise((resolve, reject) => {
1711
+ let child: ChildProcess;
1712
+ try { child = spawn(command, { shell: true, cwd, env: { ...process.env, ...(options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
1713
+ catch (error) { reject(new WorkflowError("SHELL_FAILED", errorText(error))); return; }
1714
+ let settled = false;
1715
+ let timedOut = false;
1716
+ let outputBytes = 0;
1717
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1718
+ const stdoutDecoder = new StringDecoder("utf8");
1719
+ const stderrDecoder = new StringDecoder("utf8");
1720
+ let stdout = "";
1721
+ let stderr = "";
1722
+ const cleanup = () => {
1723
+ if (timeout) clearTimeout(timeout);
1724
+ signal.removeEventListener("abort", onAbort);
1725
+ };
1726
+ const failShell = (error: WorkflowError) => {
1727
+ if (settled) return;
1728
+ settled = true;
1729
+ cleanup();
1730
+ shellProcessKill(child);
1731
+ reject(error);
1732
+ };
1733
+ const onAbort = () => { failShell(new WorkflowError("CANCELLED", "Workflow cancelled")); };
1734
+ const capture = (target: "stdout" | "stderr", chunk: Buffer) => {
1735
+ if (settled) return;
1736
+ outputBytes += chunk.byteLength;
1737
+ if (outputBytes > RPC_LIMIT_BYTES) { failShell(new WorkflowError("RPC_LIMIT_EXCEEDED", "Shell result exceeds the 10 MB JSON boundary")); return; }
1738
+ if (target === "stdout") stdout += stdoutDecoder.write(chunk); else stderr += stderrDecoder.write(chunk);
1739
+ };
1740
+ child.stdout?.on("data", (chunk: Buffer) => { capture("stdout", chunk); });
1741
+ child.stderr?.on("data", (chunk: Buffer) => { capture("stderr", chunk); });
1742
+ child.once("error", (error) => { failShell(new WorkflowError("SHELL_FAILED", errorText(error))); });
1743
+ child.once("close", (exitCode) => {
1744
+ if (settled) return;
1745
+ settled = true;
1746
+ cleanup();
1747
+ stdout += stdoutDecoder.end();
1748
+ stderr += stderrDecoder.end();
1749
+ if (signal.aborted) { reject(new WorkflowError("CANCELLED", "Workflow cancelled")); return; }
1750
+ if (timedOut) { reject(new WorkflowError("SHELL_FAILED", `Shell command timed out after ${String(options.timeoutMs)}ms`)); return; }
1751
+ const result = { exitCode: exitCode === null ? null : exitCode, stdout, stderr };
1752
+ try { encodedRpcResult(Number.MAX_SAFE_INTEGER, result); } catch (error) { reject(error instanceof WorkflowError ? error : new WorkflowError("RPC_LIMIT_EXCEEDED", errorText(error))); return; }
1753
+ resolve(result);
1754
+ });
1755
+ if (signal.aborted) { onAbort(); return; }
1756
+ signal.addEventListener("abort", onAbort, { once: true });
1757
+ if (options.timeoutMs !== undefined) timeout = setTimeout(() => { timedOut = true; shellProcessKill(child); }, options.timeoutMs);
1758
+ });
1759
+ }
1548
1760
  export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
1549
1761
  encoded(args);
1550
1762
  const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
@@ -1614,6 +1826,12 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1614
1826
  if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1615
1827
  value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1616
1828
  }
1829
+ } else if (method === "shell") {
1830
+ if (!bridge.shell) fail("SHELL_FAILED", "No shell bridge is available");
1831
+ const command = validateShellCommand(values[0]);
1832
+ const options = validateShellOptions(values[1]);
1833
+ const identity = readShellIdentity(values[2]);
1834
+ value = readShellResult(await bridge.shell(command, options, controller.signal, identity)) as unknown as JsonValue;
1617
1835
  } else if (method === "checkpoint") {
1618
1836
  if (!bridge.checkpoint || !object(values[0])) fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
1619
1837
  const name = typeof values[0].name === "string" ? values[0].name : "checkpoint";
@@ -1640,6 +1858,9 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1640
1858
  if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1641
1859
  value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1642
1860
  }
1861
+ } else if (method === "worktree") {
1862
+ if (!bridge.worktree || typeof values[0] !== "string" || !values[0]) fail("INTERNAL_ERROR", "worktree requires an active host bridge and scope");
1863
+ value = await bridge.worktree(values[0], controller.signal);
1643
1864
  } else if (method === "phase") {
1644
1865
  if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "phase name must be a string");
1645
1866
  await bridge.phase?.(values[0]);
@@ -1649,7 +1870,7 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1649
1870
  }
1650
1871
  else fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
1651
1872
  encoded(value);
1652
- child.send(encoded({ type: "rpcResult", id, ok: true, value }));
1873
+ child.send(encodedRpcResult(id, value));
1653
1874
  } catch (error) {
1654
1875
  const typed = asWorkflowError(error);
1655
1876
  child.send(encoded({ type: "rpcResult", id, ok: false, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } }));
@@ -1846,13 +2067,9 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
1846
2067
  const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
1847
2068
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
1848
2069
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1849
- const setup = agent.attemptDetails?.at(-1)?.setup;
1850
- const thinking = setup?.model.thinking ?? agent.model.thinking;
1851
- const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
1852
- const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1853
2070
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
1854
2071
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
1855
- const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
2072
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state}${tokens ? ` · ${tokens}` : ""}`];
1856
2073
  if (agent.state === "failed" && agent.attemptDetails?.length) {
1857
2074
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
1858
2075
  if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
@@ -1903,7 +2120,8 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
1903
2120
  return lines.join("\n");
1904
2121
  }
1905
2122
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
1906
- return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, "Context:", JSON.stringify(checkpoint.context, null, 2)].join("\n");
2123
+ const context = JSON.stringify(checkpoint.context, null, 2);
2124
+ return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
1907
2125
  }
1908
2126
 
1909
2127
  const DELIVERY_LIMIT_BYTES = 4 * 1024;
@@ -1926,12 +2144,109 @@ function utf8Prefix(value: string, maxBytes: number): string {
1926
2144
  while (end < bytes.length && end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end -= 1;
1927
2145
  return bytes.subarray(0, end).toString("utf8");
1928
2146
  }
2147
+ const DIAGNOSTIC_LIMIT_BYTES = DELIVERY_LIMIT_BYTES - 512;
2148
+ function failureDiagnosticsFrom(error: unknown): WorkflowFailureDiagnostics | undefined {
2149
+ if (!error || typeof error !== "object") return undefined;
2150
+ return (error as { [WORKFLOW_FAILURE_DIAGNOSTICS]?: WorkflowFailureDiagnostics })[WORKFLOW_FAILURE_DIAGNOSTICS];
2151
+ }
2152
+
2153
+ function boundedWorkflowFailureDiagnostics(value: WorkflowFailureDiagnostics): WorkflowFailureDiagnostics {
2154
+ let bounded: WorkflowFailureDiagnostics = {
2155
+ runId: utf8Prefix(value.runId, 128),
2156
+ workflowName: utf8Prefix(value.workflowName, 256),
2157
+ state: value.state,
2158
+ failedAt: value.failedAt === null ? null : utf8Prefix(value.failedAt, 1024),
2159
+ error: { code: value.error.code, message: utf8Prefix(value.error.message, 1024) },
2160
+ ...(value.failedAgent ? { failedAgent: {
2161
+ id: utf8Prefix(value.failedAgent.id, 128),
2162
+ ...(value.failedAgent.label ? { label: utf8Prefix(value.failedAgent.label, 128) } : {}),
2163
+ ...(value.failedAgent.role ? { role: utf8Prefix(value.failedAgent.role, 128) } : {}),
2164
+ structuralPath: value.failedAgent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2165
+ attempt: value.failedAgent.attempt,
2166
+ ...(value.failedAgent.sessionId ? { sessionId: utf8Prefix(value.failedAgent.sessionId, 256) } : {}),
2167
+ ...(value.failedAgent.sessionFile ? { sessionFile: utf8Prefix(value.failedAgent.sessionFile, 1024) } : {}),
2168
+ } } : {}),
2169
+ completedSiblingAgents: (value.completedSiblingAgents ?? []).slice(0, 16).map((agent) => ({
2170
+ id: utf8Prefix(agent.id, 128),
2171
+ ...(agent.label ? { label: utf8Prefix(agent.label, 128) } : {}),
2172
+ ...(agent.role ? { role: utf8Prefix(agent.role, 128) } : {}),
2173
+ structuralPath: agent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2174
+ })),
2175
+ completedSiblingPaths: value.completedSiblingPaths.slice(0, 16).map((path) => path.slice(0, 8).map((part) => utf8Prefix(part, 128))),
2176
+ artifacts: { runDirectory: utf8Prefix(value.artifacts.runDirectory, 1024), statePath: utf8Prefix(value.artifacts.statePath, 1024), journalPath: utf8Prefix(value.artifacts.journalPath, 1024) },
2177
+ };
2178
+ const size = () => Buffer.byteLength(JSON.stringify(bounded));
2179
+ while (size() > DIAGNOSTIC_LIMIT_BYTES) {
2180
+ if (bounded.completedSiblingAgents?.length || bounded.completedSiblingPaths.length) {
2181
+ bounded = { ...bounded, completedSiblingAgents: bounded.completedSiblingAgents?.slice(0, -1) ?? [], completedSiblingPaths: bounded.completedSiblingPaths.slice(0, -1) };
2182
+ continue;
2183
+ }
2184
+ if (bounded.failedAgent?.sessionFile) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionFile; bounded = { ...bounded, failedAgent }; continue; }
2185
+ if (bounded.failedAgent?.sessionId) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionId; bounded = { ...bounded, failedAgent }; continue; }
2186
+ if (Buffer.byteLength(bounded.artifacts.runDirectory) > 256) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, runDirectory: utf8Prefix(bounded.artifacts.runDirectory, 256) } }; continue; }
2187
+ if (Buffer.byteLength(bounded.error.message) > 256) { bounded = { ...bounded, error: { ...bounded.error, message: utf8Prefix(bounded.error.message, 256) } }; continue; }
2188
+ if (bounded.failedAt !== null && Buffer.byteLength(bounded.failedAt) > 256) { bounded = { ...bounded, failedAt: utf8Prefix(bounded.failedAt, 256) }; continue; }
2189
+ if (bounded.failedAgent && bounded.failedAgent.structuralPath.length > 4) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.slice(0, 4) } }; continue; }
2190
+ if (bounded.failedAgent?.structuralPath.some((part) => Buffer.byteLength(part) > 64)) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.map((part) => utf8Prefix(part, 64)) } }; continue; }
2191
+ if (Buffer.byteLength(bounded.artifacts.statePath) > 512 || Buffer.byteLength(bounded.artifacts.journalPath) > 512) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, statePath: utf8Prefix(bounded.artifacts.statePath, 512), journalPath: utf8Prefix(bounded.artifacts.journalPath, 512) } }; continue; }
2192
+ if (Buffer.byteLength(bounded.workflowName) > 128) { bounded = { ...bounded, workflowName: utf8Prefix(bounded.workflowName, 128) }; continue; }
2193
+ break;
2194
+ }
2195
+ return bounded;
2196
+ }
2197
+
2198
+ function createWorkflowFailureDiagnostics(store: RunStore, metadata: WorkflowMetadata, error: unknown, run: PersistedRun): WorkflowFailureDiagnostics {
2199
+ const rawFailedAt = error && typeof error === "object" ? (error as { failedAt?: unknown }).failedAt : undefined;
2200
+ const failedAt = typeof rawFailedAt === "string" && rawFailedAt ? rawFailedAt : null;
2201
+ const failedAgents = run.agents.filter((agent) => agent.state === "failed");
2202
+ const failedAgentRecord = failedAgents.find((agent) => {
2203
+ if (failedAt === null) return false;
2204
+ try { return failedAt.includes(`${operationPath("agent", ...(agent.structuralPath ?? []))}/`); } catch { return false; }
2205
+ }) ?? failedAgents.at(-1);
2206
+ const failedAttempt = failedAgentRecord ? [...(failedAgentRecord.attemptDetails ?? [])].reverse().find((attempt) => attempt.error) ?? failedAgentRecord.attemptDetails?.at(-1) : undefined;
2207
+ const failedAgent = failedAgentRecord ? {
2208
+ id: failedAgentRecord.id,
2209
+ ...(failedAgentRecord.label ?? failedAgentRecord.name ? { label: failedAgentRecord.label ?? failedAgentRecord.name } : {}),
2210
+ ...(failedAgentRecord.role ? { role: failedAgentRecord.role } : {}),
2211
+ structuralPath: [...(failedAgentRecord.structuralPath ?? [])],
2212
+ attempt: Math.max(1, failedAttempt?.attempt ?? failedAgentRecord.attempts),
2213
+ ...(failedAttempt?.sessionId ? { sessionId: failedAttempt.sessionId } : {}),
2214
+ ...(failedAttempt?.sessionFile ? { sessionFile: failedAttempt.sessionFile } : {}),
2215
+ } satisfies WorkflowFailureAgent : undefined;
2216
+ const completedSiblingAgents = run.agents.filter((agent) => {
2217
+ if (agent.state !== "completed" || agent.id === failedAgentRecord?.id) return false;
2218
+ return failedAgentRecord?.parentId === undefined ? agent.parentId === undefined : agent.parentId === failedAgentRecord.parentId;
2219
+ }).map((agent) => ({
2220
+ id: agent.id,
2221
+ ...(agent.label ?? agent.name ? { label: agent.label ?? agent.name } : {}),
2222
+ ...(agent.role ? { role: agent.role } : {}),
2223
+ structuralPath: [...(agent.structuralPath ?? [])],
2224
+ } satisfies WorkflowSiblingAgent));
2225
+ const completedSiblingPaths = completedSiblingAgents.map((agent) => [...agent.structuralPath]);
2226
+ return boundedWorkflowFailureDiagnostics({
2227
+ runId: run.id, workflowName: metadata.name, state: run.state, failedAt,
2228
+ error: { code: errorCode(error) ?? "INTERNAL_ERROR", message: errorText(error) || "The workflow failed without an error message." },
2229
+ ...(failedAgent ? { failedAgent } : {}), completedSiblingAgents, completedSiblingPaths,
2230
+ artifacts: { runDirectory: store.directory, statePath: join(store.directory, "state.json"), journalPath: join(store.directory, "journal.json") },
2231
+ });
2232
+ }
2233
+
2234
+ export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string {
2235
+ 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)";
2236
+ const siblingAgents = diagnostic.completedSiblingAgents;
2237
+ 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)";
2238
+ 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");
2239
+ }
1929
2240
 
2241
+ function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
2242
+ function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
2243
+ return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
2244
+ }
1930
2245
  function deliver(pi: ExtensionAPI, content: string): void {
1931
2246
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
1932
2247
  }
1933
- function deliverFailure(pi: ExtensionAPI, name: string, error: unknown): void {
1934
- deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
2248
+ function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
2249
+ deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
1935
2250
  }
1936
2251
 
1937
2252
  type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -1948,6 +2263,12 @@ class WorkflowEventPublisher {
1948
2263
 
1949
2264
  constructor(private readonly sink: WorkflowEventSink | undefined) {}
1950
2265
 
2266
+ removeRun(runId: string): void {
2267
+ this.#queues.delete(runId);
2268
+ this.#budgetEvents.delete(runId);
2269
+ this.#worktrees.delete(runId);
2270
+ }
2271
+
1951
2272
  seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
1952
2273
  const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
1953
2274
  for (const event of events ?? []) seen.add(this.budgetKey(event));
@@ -2025,7 +2346,11 @@ function namedRecord(value: unknown, kind: string): Array<[string, unknown]> {
2025
2346
  if (!object(value)) fail("INVALID_METADATA", `${kind} must be a record`);
2026
2347
  return Object.entries(value);
2027
2348
  }
2028
- function hostWithWorktree(args: readonly unknown[], identity: string, occurrences: Map<string, number>): Promise<JsonValue> {
2349
+ function publicWorktreeReference(reference: WorkflowWorktreeReference): Readonly<WorkflowWorktreeReference> {
2350
+ if (!object(reference) || typeof reference.path !== "string" || typeof reference.branch !== "string") fail("WORKTREE_FAILED", "Worktree reference is invalid");
2351
+ return Object.freeze({ path: reference.path, branch: reference.branch });
2352
+ }
2353
+ async function hostWithWorktree(args: readonly unknown[], identity: string, occurrences: Map<string, number>, resolveWorktree: ((owner: string, signal: AbortSignal) => Promise<Readonly<WorkflowWorktreeReference>>) | undefined, signal: AbortSignal): Promise<JsonValue> {
2029
2354
  if (args.length !== 1 && args.length !== 2) fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
2030
2355
  const callback = args[args.length - 1];
2031
2356
  if (typeof callback !== "function") fail("INVALID_METADATA", "withWorktree callback must be a function");
@@ -2040,7 +2365,9 @@ function hostWithWorktree(args: readonly unknown[], identity: string, occurrence
2040
2365
  occurrences.set(key, occurrence);
2041
2366
  owner = operationPath("worktree", "unnamed", "function", identity, ...structuralPath, `occurrence:${String(occurrence)}`);
2042
2367
  }
2043
- return inheritedHostWorktreeOwner.run(owner, async () => await (callback as () => unknown)()) as Promise<JsonValue>;
2368
+ if (!resolveWorktree) fail("WORKTREE_FAILED", "No worktree bridge is available");
2369
+ const reference = publicWorktreeReference(await resolveWorktree(owner, signal));
2370
+ return inheritedHostWorktreeOwner.run(owner, async () => await (callback as (reference: Readonly<WorkflowWorktreeReference>) => unknown)(reference)) as Promise<JsonValue>;
2044
2371
  }
2045
2372
  function workflowRunContext(cwd: string, sessionId: string, runId: string, workflow: WorkflowMetadata, args: JsonValue, signal: AbortSignal): Readonly<WorkflowRunContext> {
2046
2373
  return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
@@ -2123,6 +2450,7 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
2123
2450
 
2124
2451
  function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
2125
2452
  const functionAgentOccurrences = new Map<string, number>();
2453
+ const functionShellOccurrences = new Map<string, number>();
2126
2454
  const functionWorktreeOccurrences = new Map<string, number>();
2127
2455
  const functionInvokeOccurrences = new Map<string, number>();
2128
2456
  const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
@@ -2151,10 +2479,21 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
2151
2479
  functionAgentOccurrences.set(key, occurrence);
2152
2480
  return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2153
2481
  },
2482
+ shell: async (...args: readonly unknown[]) => {
2483
+ if (!bridge.shell) fail("SHELL_FAILED", "No shell bridge is available");
2484
+ if (typeof args[0] !== "string") fail("INVALID_METADATA", "shell command must be a string");
2485
+ const options = validateShellOptions(args[1] === undefined ? {} : args[1]);
2486
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2487
+ const inherited = inheritedHostAgentPath.getStore() ?? [];
2488
+ const key = `${path}\0${JSON.stringify([inherited, scopedWorktreeOwner ?? null])}`;
2489
+ const occurrence = (functionShellOccurrences.get(key) ?? 0) + 1;
2490
+ functionShellOccurrences.set(key, occurrence);
2491
+ return bridge.shell(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2492
+ },
2154
2493
  prompt: workflowPrompt,
2155
2494
  parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
2156
2495
  pipeline: (...args: readonly unknown[]) => hostPipeline(args[0], args[1], args[2]),
2157
- withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, path, functionWorktreeOccurrences),
2496
+ withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, path, functionWorktreeOccurrences, bridge.worktree, signal),
2158
2497
  checkpoint: async (...args: readonly unknown[]) => {
2159
2498
  if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0])) fail("INTERNAL_ERROR", "No checkpoint bridge is available");
2160
2499
  return bridge.checkpoint(args[0], signal);
@@ -2215,6 +2554,17 @@ function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
2215
2554
  return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2216
2555
  }
2217
2556
  function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
2557
+ const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
2558
+ type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
2559
+ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
2560
+ return {
2561
+ ...component,
2562
+ render(width: number) {
2563
+ const border = theme.fg("border", "─".repeat(Math.max(1, width)));
2564
+ return [border, ...component.render(width), border];
2565
+ },
2566
+ };
2567
+ }
2218
2568
  type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
2219
2569
  function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
2220
2570
  function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
@@ -2229,9 +2579,10 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2229
2579
  }
2230
2580
  }
2231
2581
 
2232
- export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession) {
2582
+ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
2233
2583
  beginWorkflowExtensionLoading();
2234
2584
  const registry = loadingRegistry();
2585
+ const extensionAgentDir = agentDir ?? getAgentDir();
2235
2586
  const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
2236
2587
  registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
2237
2588
  const data = entry.data;
@@ -2250,7 +2601,36 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2250
2601
  return skillPath ? { skillPaths: [skillPath] } : undefined;
2251
2602
  });
2252
2603
  type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
2253
- const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; budgetResolvers: Map<string, (result: BudgetDecisionResult) => void>; update?: (result: WorkflowToolUpdate) => void }>();
2604
+ const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
2605
+ let providerRecoveryQueue = Promise.resolve();
2606
+ const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
2607
+ const createProviderErrorRecovery = (host: unknown, fallbackModels: ReadonlySet<string>, abort: () => void) => {
2608
+ if (!object(host) || host.mode !== "tui" || host.hasUI !== true) return undefined;
2609
+ const ui = object(host.ui) ? host.ui : undefined;
2610
+ const select = uiHostCapabilities(ui)?.select;
2611
+ if (!select) return undefined;
2612
+ const hostModels = contextHostCapabilities(host).modelRegistry;
2613
+ const choose = (title: string, options: string[]) => select.call(ui, title, options);
2614
+ return (failure: AgentProviderFailure): Promise<AgentProviderRecovery> => enqueueProviderRecovery(async () => {
2615
+ 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"]);
2616
+ if (action === "Retry") return "retry";
2617
+ if (action === "Change model") {
2618
+ const available = hostModels?.getAvailable?.().map((model) => `${model.provider}/${model.id}`) ?? [...fallbackModels];
2619
+ const selected = await choose(`Available models for subagent "${failure.label}"`, [...new Set(available)].sort());
2620
+ if (selected) return { model: selected };
2621
+ }
2622
+ abort();
2623
+ return "abort";
2624
+ });
2625
+ };
2626
+ const pendingFailureDiagnostics = new Map<string, WorkflowFailureDiagnostics>();
2627
+ pi.on("tool_result", (event) => {
2628
+ if (event.toolName !== "workflow" || !event.isError) return;
2629
+ const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
2630
+ if (!diagnostic) return;
2631
+ pendingFailureDiagnostics.delete(event.toolCallId);
2632
+ return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
2633
+ });
2254
2634
  const liveActivities = new Map<string, Map<string, AgentActivity>>();
2255
2635
  const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
2256
2636
  const activities = liveActivities.get(runId);
@@ -2293,9 +2673,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2293
2673
  const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
2294
2674
  const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2295
2675
  const worktree = await store.worktree(owner);
2296
- if (!existing) await eventPublisher.worktree(store, metadata, worktree);
2676
+ if (!existing && await store.ownsWorktree(owner)) await eventPublisher.worktree(store, metadata, worktree);
2297
2677
  return worktree;
2298
2678
  };
2679
+ const resolveWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<Readonly<WorkflowWorktreeReference>> => {
2680
+ const run = runs.get(store.runId);
2681
+ if (!run) fail("INTERNAL_ERROR", `Unknown production run: ${store.runId}`);
2682
+ await run.lifecycle.enter();
2683
+ try {
2684
+ const worktree = await persistWorktree(store, metadata, owner);
2685
+ return { path: worktree.path, branch: worktree.branch };
2686
+ } finally { await run.lifecycle.leave(); }
2687
+ };
2688
+ const shellForRun = async (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity): Promise<ShellResult> => {
2689
+ await lifecycle.enter();
2690
+ try {
2691
+ const path = shellIdentityPath(identity);
2692
+ const replayed = await store.replay(path);
2693
+ if (replayed) return readShellResult(replayed.value);
2694
+ const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
2695
+ const result = await executeShellCommand(command, options, signal, cwd);
2696
+ await store.complete(path, result as unknown as JsonValue);
2697
+ return result;
2698
+ } finally { await lifecycle.leave(); }
2699
+ };
2299
2700
  const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
2300
2701
  if (next !== "pausing") budget.transition(next);
2301
2702
  const persisted = await persistRunState(store, metadata, (current) => {
@@ -2335,7 +2736,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2335
2736
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2336
2737
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2337
2738
  };
2338
- 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); });
2739
+ 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); });
2339
2740
  const before = (await run.store.load()).run;
2340
2741
  await persistAgentAttempts(run.store, id, result.attempts);
2341
2742
  const completed = (await run.store.load()).run;
@@ -2378,6 +2779,19 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2378
2779
  await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2379
2780
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2380
2781
  });
2782
+ const cleanupTerminalRun = async (runId: string): Promise<void> => {
2783
+ const run = runs.get(runId);
2784
+ if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state)) return;
2785
+ await scheduler.cancelRun(runId);
2786
+ await scheduler.flush();
2787
+ if (runs.get(runId) !== run) return;
2788
+ scheduler.removeRun(runId);
2789
+ terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
2790
+ run.checkpointResolvers.clear();
2791
+ liveActivities.delete(runId);
2792
+ eventPublisher.removeRun(runId);
2793
+ runs.delete(runId);
2794
+ };
2381
2795
  type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
2382
2796
  const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
2383
2797
  const run = runs.get(runId);
@@ -2390,6 +2804,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2390
2804
  run.execution?.cancel();
2391
2805
  await scheduler.cancelRun(run.store.runId);
2392
2806
  await scheduler.flush();
2807
+ await cleanupTerminalRun(runId);
2393
2808
  return { runId, state: "stopped", stopped: true };
2394
2809
  };
2395
2810
  const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
@@ -2416,17 +2831,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2416
2831
  if (!request) return undefined;
2417
2832
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2418
2833
  const result = await applyBudgetDecision(request, approved);
2419
- run.budgetResolvers.get(proposalId)?.(result);
2420
- run.budgetResolvers.delete(proposalId);
2421
2834
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2422
2835
  return result;
2423
2836
  };
2424
- const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }) => {
2837
+ const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, headless = false) => {
2425
2838
  const checkpointCounters = new Map<string, number>();
2426
2839
  return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
2427
2840
  const input = validateCheckpoint(raw);
2428
2841
  const label = nextNamedOccurrence(checkpointCounters, input.name);
2429
2842
  const path = operationPath("checkpoint", label);
2843
+ if (headless) fail("RESUME_INCOMPATIBLE", "Headless CLI checkpoints are unsupported");
2430
2844
  if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
2431
2845
  const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
2432
2846
  const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
@@ -2501,13 +2915,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2501
2915
  if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
2502
2916
  const catalog = registry.catalog();
2503
2917
  const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2504
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases) return;
2918
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases) return;
2505
2919
  pi.registerTool({
2506
2920
  name: "workflow_catalog",
2507
2921
  label: "Workflow Catalog",
2508
- description: "List global workflow functions, variables, and reusable workflows",
2509
- parameters: Type.Object({}, { additionalProperties: false }),
2510
- async execute() { return { content: [{ type: "text" as const, text: JSON.stringify(registry.catalog()) }], details: {} }; }
2922
+ description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
2923
+ parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or variable name for full detail" })) }, { additionalProperties: false }),
2924
+ async execute(_id, params = {}) {
2925
+ const result = params.name === undefined ? registry.catalogIndex() : registry.catalogDetail(params.name);
2926
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
2927
+ }
2511
2928
  });
2512
2929
  catalogRegistered = true;
2513
2930
  };
@@ -2516,7 +2933,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2516
2933
  const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2517
2934
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2518
2935
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2519
- const settingsPath = workflowSettingsPath();
2936
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2520
2937
  const currentSettings = loadSettings(settingsPath);
2521
2938
  resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2522
2939
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2529,13 +2946,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2529
2946
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2530
2947
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2531
2948
  await run.store.saveSnapshot(snapshot);
2532
- 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);
2949
+ 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);
2533
2950
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2534
2951
  const drift = aliasDrift(previousAliases, currentAliases);
2535
2952
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2536
2953
  };
2537
2954
  const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
2538
2955
  const loaded = await run.store.load();
2956
+ await run.store.validateBorrowedWorktrees();
2539
2957
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
2540
2958
  if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
2541
2959
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
@@ -2544,7 +2962,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2544
2962
  const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2545
2963
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2546
2964
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2547
- const settingsPath = workflowSettingsPath();
2965
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2548
2966
  const currentSettings = loadSettings(settingsPath);
2549
2967
  resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2550
2968
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2556,10 +2974,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2556
2974
  const resumeAliases = { ...previousAliases, ...currentAliases };
2557
2975
  const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2558
2976
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2559
- 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);
2977
+ const script = launchScriptForSnapshot(loaded.snapshot, registry);
2978
+ 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);
2560
2979
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2561
2980
  await run.store.saveSnapshot(snapshot);
2562
- 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);
2981
+ 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);
2563
2982
  const drift = aliasDrift(previousAliases, currentAliases);
2564
2983
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2565
2984
  const controller = new AbortController();
@@ -2571,11 +2990,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2571
2990
  catch (error) {
2572
2991
  const typed = asWorkflowError(error);
2573
2992
  if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
2993
+ await cleanupTerminalRun(run.store.runId);
2574
2994
  throw typed;
2575
2995
  }
2576
2996
  await scheduler.cancelRun(run.store.runId);
2577
2997
  await run.lifecycle.resume();
2578
- const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
2998
+ 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) => {
2579
2999
  await run.lifecycle.enter();
2580
3000
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2581
3001
  const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
@@ -2613,7 +3033,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2613
3033
  await run.store.complete(path, outcome.value);
2614
3034
  return outcome.value;
2615
3035
  } finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
2616
- }, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { let previousPhase: string | undefined; const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; }); await eventPublisher.phase(run.store, run.metadata, previousPhase, phase); runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted)); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
3036
+ }, 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 { let previousPhase: string | undefined; const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; }); await eventPublisher.phase(run.store, run.metadata, previousPhase, phase); runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted)); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2617
3037
  run.execution = execution;
2618
3038
  const completion = execution.result.then(async (value) => {
2619
3039
  await scheduler.flush();
@@ -2630,8 +3050,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2630
3050
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2631
3051
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2632
3052
  run.update?.(workflowToolUpdate(persisted));
2633
- if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
2634
- });
3053
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted));
3054
+ }).finally(() => cleanupTerminalRun(run.store.runId));
3055
+ run.completion = completion;
2635
3056
  void completion;
2636
3057
  };
2637
3058
  const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
@@ -2659,11 +3080,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2659
3080
  if (budgetRelaxed(currentBudget, nextBudget)) {
2660
3081
  const proposalId = randomUUID();
2661
3082
  const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2662
- const decision = new Promise<BudgetDecisionResult>((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2663
- try { await run.store.requestWorkflowDecision(request); await appendBudgetDecisionEvent(run, request, "adjustment_requested"); } catch (error) { run.budgetResolvers.delete(proposalId); throw error; }
3083
+ await run.store.requestWorkflowDecision(request);
3084
+ await appendBudgetDecisionEvent(run, request, "adjustment_requested");
2664
3085
  deliver(pi, budgetDecisionDelivery(run.metadata, request));
2665
- const decisionResult = await decision;
2666
- return { state: decisionResult.state };
3086
+ return { state: "awaiting_approval", proposalId };
2667
3087
  }
2668
3088
  const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2669
3089
  if (changed) {
@@ -2712,7 +3132,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2712
3132
  const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
2713
3133
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2714
3134
  const roleDefinitions = loaded.snapshot.roles ?? {};
2715
- 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() });
3135
+ const abortController = new AbortController();
3136
+ const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
3137
+ 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 } : {}) });
2716
3138
  for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
2717
3139
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
2718
3140
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -2737,7 +3159,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2737
3159
  });
2738
3160
  pi.on("before_agent_start", (event, ctx) => {
2739
3161
  if (!pi.getActiveTools().includes("workflow")) return;
2740
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, undefined, projectTrusted(ctx))).filter(([, definition]) => definition.description);
3162
+ const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
2741
3163
  if (!roles.length) return;
2742
3164
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
2743
3165
  return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
@@ -2748,9 +3170,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2748
3170
  description: WORKFLOW_TOOL_DESCRIPTION,
2749
3171
  promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
2750
3172
  parameters: WORKFLOW_TOOL_PARAMETERS,
2751
- async execute(_id, params, signal, onUpdate, ctx) {
3173
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
2752
3174
  try {
2753
- const settingsPath = workflowSettingsPath();
3175
+ const headless = object(ctx) && ctx.headless === true;
3176
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2754
3177
  const defaults = loadSettings(settingsPath);
2755
3178
  if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
2756
3179
  const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
@@ -2764,8 +3187,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2764
3187
  const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
2765
3188
  const trustedProject = projectTrusted(ctx);
2766
3189
  if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
2767
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
2768
- const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
3190
+ 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);
3191
+ const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
2769
3192
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
2770
3193
  const runId = randomUUID();
2771
3194
  const args = (params.args ?? null) as JsonValue;
@@ -2775,22 +3198,25 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2775
3198
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
2776
3199
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
2777
3200
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
3201
+ const parentRunId = params.parentRunId;
3202
+ if (parentRunId !== undefined) await store.validateParentRun(parentRunId);
2778
3203
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
2779
3204
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2780
3205
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2781
3206
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2782
- 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 });
3207
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2783
3208
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
2784
3209
  const initialBudget = budgetRuntime.snapshot();
2785
- 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);
3210
+ 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);
2786
3211
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2787
3212
  const background = !params.foreground;
2788
3213
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2789
- 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);
2790
- 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 } : {}) });
3214
+ const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
3215
+ 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);
3216
+ 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 } : {}) });
2791
3217
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
2792
3218
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2793
- const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
3219
+ 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) => {
2794
3220
  await lifecycle.enter();
2795
3221
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2796
3222
  const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
@@ -2828,7 +3254,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2828
3254
  await store.complete(path, outcome.value);
2829
3255
  return outcome.value;
2830
3256
  } finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
2831
- }, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
3257
+ }, 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) => {
2832
3258
  await lifecycle.enter();
2833
3259
  try {
2834
3260
  let previousPhase: string | undefined;
@@ -2850,21 +3276,29 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2850
3276
  await scheduler.flush();
2851
3277
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2852
3278
  if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2853
- await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3279
+ const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
2854
3280
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2855
3281
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
3282
+ const diagnostic = createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
3283
+ Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic });
3284
+ if (params.foreground) pendingFailureDiagnostics.set(toolCallId, diagnostic);
2856
3285
  throw typed;
2857
3286
  });
2858
- (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
3287
+ const completion = finish.finally(() => cleanupTerminalRun(runId));
3288
+ (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
2859
3289
  if (background) {
2860
- void finish.then(async ({ value, resultPath }) => {
3290
+ void completion.then(async ({ value, resultPath }) => {
2861
3291
  deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2862
- }, (error: unknown) => { deliverFailure(pi, checked.metadata.name, error); });
3292
+ }, (error: unknown) => {
3293
+ const diagnostic = failureDiagnosticsFrom(error);
3294
+ if (diagnostic) deliverFailure(pi, diagnostic);
3295
+ else deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
3296
+ });
2863
3297
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2864
3298
  }
2865
- const { value } = await finish;
3299
+ const { value } = await completion;
2866
3300
  const run = (await store.load()).run;
2867
- return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: { runId, value, run } };
3301
+ return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
2868
3302
  } catch (error) {
2869
3303
  throw mainAgentError(error);
2870
3304
  }
@@ -2873,18 +3307,20 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2873
3307
  return textBlock(formatWorkflowPreview(args));
2874
3308
  },
2875
3309
  renderResult(result, { isPartial }, _theme, context) {
2876
- const details = result.details as { run?: PersistedRun; value?: JsonValue; preview?: string } | undefined;
3310
+ const details = result.details;
3311
+ if (isWorkflowFailureDiagnostics(details)) return textBlock(formatWorkflowFailureDiagnostics(details));
3312
+ const runDetails = details as { run?: PersistedRun; value?: JsonValue; preview?: string } | undefined;
2877
3313
  const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval> };
2878
- if (details?.run && isPartial && details.run.state === "running" && !state.workflowSpinner) {
3314
+ if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
2879
3315
  state.workflowSpinner = setInterval(context.invalidate, 80);
2880
3316
  state.workflowSpinner.unref();
2881
- } else if ((!isPartial || details?.run?.state !== "running") && state.workflowSpinner) {
3317
+ } else if ((!isPartial || runDetails?.run?.state !== "running") && state.workflowSpinner) {
2882
3318
  clearInterval(state.workflowSpinner);
2883
3319
  delete state.workflowSpinner;
2884
3320
  }
2885
- if (details?.run) return workflowProgressBlock(details.run);
3321
+ if (runDetails?.run) return workflowProgressBlock(runDetails.run);
2886
3322
  const content = result.content[0];
2887
- return textBlock(isPartial ? "Workflow starting..." : details?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
3323
+ return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
2888
3324
  },
2889
3325
  });
2890
3326
  pi.registerCommand("workflow", {
@@ -2976,7 +3412,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2976
3412
  }
2977
3413
  };
2978
3414
  const manageAliases = async (): Promise<void> => {
2979
- const settingsPath = workflowSettingsPath();
3415
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2980
3416
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2981
3417
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
2982
3418
  const selectTarget = async (): Promise<string | undefined> => {
@@ -3052,9 +3488,18 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3052
3488
  const labels = navigatorRunLabels(sorted);
3053
3489
  const terminalStates = new Set(["completed", "failed", "stopped"]);
3054
3490
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
3055
- const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
3491
+ const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
3056
3492
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
3057
3493
  if (!runChoice || runChoice === "Close") return;
3494
+ if (runChoice === "Inspect session in pane") {
3495
+ try {
3496
+ await openHerdrPane({ action: "inspect", cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() });
3497
+ ctx.ui.notify("Opened session inspector in pane.", "info");
3498
+ } catch (error) {
3499
+ ctx.ui.notify(`Cannot open session inspector in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3500
+ }
3501
+ continue;
3502
+ }
3058
3503
  if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
3059
3504
  if (runChoice === "Delete all completed") {
3060
3505
  if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
@@ -3076,38 +3521,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3076
3521
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
3077
3522
  }
3078
3523
  };
3079
- const openTranscript = async (transcript: string): Promise<void> => {
3080
- try {
3081
- const entries = SessionManager.open(transcript).buildContextEntries();
3082
- if (ctx.mode !== "tui") { ctx.ui.notify(`Transcript: ${transcript}`, "info"); return; }
3083
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3084
- let offset = 0;
3085
- let renderedLines: string[] = [];
3086
- const viewport = () => Math.max(1, tuiRows(tui) - 3);
3087
- const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3088
- return {
3089
- render(width: number) {
3090
- renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3091
- offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3092
- return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3093
- },
3094
- invalidate() {},
3095
- handleInput(data: string) {
3096
- if (keybindings.matches(data, "tui.select.up")) move(-1);
3097
- else if (keybindings.matches(data, "tui.select.down")) move(1);
3098
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
3099
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
3100
- else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
3101
- else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
3102
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3103
- tui.requestRender();
3104
- },
3105
- };
3106
- }, { overlay: true });
3107
- } catch (error) {
3108
- ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3109
- }
3110
- };
3111
3524
  const loadDashboard = async () => {
3112
3525
  const loaded = await store.load();
3113
3526
  const checkpoints = await store.awaitingCheckpoints();
@@ -3138,16 +3551,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3138
3551
  }
3139
3552
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
3140
3553
  else actions.set("View script", "view-script");
3141
- const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
3142
3554
  if (loaded.run.agents.length) actions.set("Agents...", "agents");
3143
- if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
3144
- if (!loaded.run.agents.length && transcripts.length) actions.set("Transcript paths", "transcripts");
3145
3555
  if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
3146
3556
  if (ctx.mode === "tui") {
3147
3557
  addCopy("Copy run path", store.directory, "run path");
3148
3558
  addCopy("Copy run ID", store.runId, "run ID");
3149
3559
  }
3150
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
3560
+ return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3151
3561
  };
3152
3562
  const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
3153
3563
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
@@ -3168,14 +3578,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3168
3578
  const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3169
3579
  const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3170
3580
  const actions = [
3171
- ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
3581
+ ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3172
3582
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3173
3583
  "Copy agent ID",
3174
3584
  "Back",
3175
3585
  ];
3176
3586
  const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
3177
3587
  const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3178
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
3588
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3179
3589
  const index = choice ? choices.indexOf(choice) : -1;
3180
3590
  return index >= 0 ? attempts[index] : undefined;
3181
3591
  };
@@ -3185,11 +3595,17 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3185
3595
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
3186
3596
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
3187
3597
  if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
3188
- if (action === "View transcript" || action === "Copy transcript path") {
3598
+ if (action === "Fork as Pi session in pane") {
3189
3599
  const attempt = await chooseAttempt();
3190
3600
  if (!attempt) continue;
3191
- if (action === "Copy transcript path") await copyArtifact(attempt.sessionFile, "transcript path");
3192
- else await openTranscript(attempt.sessionFile);
3601
+ const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
3602
+ 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?")) continue;
3603
+ try {
3604
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
3605
+ ctx.ui.notify("Forked Pi session in pane.", "info");
3606
+ } catch (error) {
3607
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3608
+ }
3193
3609
  }
3194
3610
  }
3195
3611
  };
@@ -3204,7 +3620,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3204
3620
  let disposed = false;
3205
3621
  let stopRequested = false;
3206
3622
  let stopStatus: string | undefined;
3207
- const terminalRows = () => Math.max(1, tuiRows(tui));
3623
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3208
3624
  const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3209
3625
  const keyLabel = (binding: string, fallback: string) => {
3210
3626
  const keys = keybindingKeys(keybindings, binding);
@@ -3213,9 +3629,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3213
3629
  const dashboardLayout = () => {
3214
3630
  const rows = terminalRows();
3215
3631
  const hintRows = rows >= 4 ? 1 : 0;
3216
- const separatorRows = rows >= 6 ? 1 : 0;
3632
+ const separatorRows = rows >= 8 ? 1 : 0;
3217
3633
  const available = Math.max(1, rows - hintRows - separatorRows);
3218
- const actionViewport = Math.min(options.length, Math.max(1, Math.floor(available / 2)));
3634
+ const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
3219
3635
  return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3220
3636
  };
3221
3637
  const updateDashboard = async (selectedOption: string | undefined) => {
@@ -3252,7 +3668,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3252
3668
  void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3253
3669
  }, 1000);
3254
3670
  timer.unref();
3255
- return {
3671
+ return borderWorkflowOverlay({
3256
3672
  render(width: number) {
3257
3673
  const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
3258
3674
  const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
@@ -3290,7 +3706,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3290
3706
  tui.requestRender();
3291
3707
  },
3292
3708
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
3293
- };
3709
+ }, theme);
3294
3710
  }, { overlay: true })
3295
3711
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
3296
3712
  if (!actionChoice || actionChoice === "Close") return;
@@ -3301,12 +3717,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3301
3717
  const highlighted = highlightCode(view.script, "javascript");
3302
3718
  let offset = 0;
3303
3719
  let renderedLines: string[] = [];
3304
- const viewport = () => Math.max(1, tuiRows(tui) - 3);
3720
+ const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
3305
3721
  const move = (delta: number) => {
3306
3722
  const maxOffset = Math.max(0, renderedLines.length - viewport());
3307
3723
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3308
3724
  };
3309
- return {
3725
+ return borderWorkflowOverlay({
3310
3726
  render(width: number) {
3311
3727
  renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3312
3728
  const maxOffset = Math.max(0, renderedLines.length - viewport());
@@ -3327,21 +3743,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3327
3743
  else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3328
3744
  tui.requestRender();
3329
3745
  },
3330
- };
3331
- });
3332
- continue;
3333
- }
3334
- if (actionChoice === "View transcript") {
3335
- const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
3336
- if (transcript && transcript !== "Back") await openTranscript(transcript);
3337
- continue;
3338
- }
3339
- if (actionChoice === "Transcript paths") {
3340
- const transcript = await ctx.ui.select("Native Pi transcript paths", [...view.transcripts, "Back"]);
3341
- if (transcript && transcript !== "Back") {
3342
- if (ctx.mode === "tui") await copyArtifact(transcript, "transcript path");
3343
- else ctx.ui.notify(transcript, "info");
3344
- }
3746
+ }, theme);
3747
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3345
3748
  continue;
3346
3749
  }
3347
3750
  const copy = view.copies.get(actionChoice);
@@ -3355,11 +3758,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3355
3758
  let offset = 0;
3356
3759
  let renderedLines: string[] = [];
3357
3760
  const layout = () => {
3358
- const rows = Math.max(1, tuiRows(tui));
3761
+ const rows = Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3359
3762
  const compactControls = rows < 4;
3360
3763
  const titleRows = rows >= 5 ? 1 : 0;
3361
3764
  const hintRows = rows >= 8 ? 1 : 0;
3362
- const separatorRows = rows >= 8 ? 2 : 0;
3765
+ const separatorRows = rows >= 8 ? 1 : 0;
3363
3766
  const controlRows = compactControls ? 1 : options.length;
3364
3767
  const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
3365
3768
  return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
@@ -3368,7 +3771,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3368
3771
  const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
3369
3772
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3370
3773
  };
3371
- return {
3774
+ return borderWorkflowOverlay({
3372
3775
  render(width: number) {
3373
3776
  renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
3374
3777
  const currentLayout = layout();
@@ -3383,7 +3786,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3383
3786
  ...renderedLines.slice(offset, offset + currentLayout.contentViewport),
3384
3787
  ...(currentLayout.separatorRows ? [""] : []),
3385
3788
  ...controls,
3386
- ...(currentLayout.separatorRows ? [""] : []),
3387
3789
  ...(currentLayout.hintRows ? [hint] : []),
3388
3790
  ];
3389
3791
  },
@@ -3397,8 +3799,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3397
3799
  else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3398
3800
  tui.requestRender();
3399
3801
  },
3400
- };
3401
- });
3802
+ }, theme);
3803
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3402
3804
  if (decision) {
3403
3805
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3404
3806
  if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
@@ -3451,6 +3853,6 @@ function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
3451
3853
  export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
3452
3854
  export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
3453
3855
  export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
3454
- export type { AgentAccounting, AgentAttempt, AgentBudgetHooks, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentSetup, AgentSetupContext, AgentSetupHook, AgentToolCallProgress, RegisteredAgentSetupHook, SessionInput } from "./agent-execution.js";
3856
+ export type { AgentAccounting, AgentAttempt, AgentBudgetHooks, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentProviderFailure, AgentProviderRecovery, AgentSetup, AgentSetupContext, AgentSetupHook, AgentToolCallProgress, RegisteredAgentSetupHook, SessionInput } from "./agent-execution.js";
3455
3857
  export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
3456
- export type { DoctorDiagnostic, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust, DoctorWorkflow } from "./doctor.js";
3858
+ export type { DoctorDiagnostic, DoctorFunction, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust } from "./doctor.js";