pi-extensible-workflows 1.0.0 → 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,24 +1,25 @@
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
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
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";
15
- import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.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";
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_ASYNC_STARTED_EVENT = "workflow:async-started";
21
- export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
21
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree", "shell"] as const;
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";
24
25
  export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
@@ -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,35 +72,72 @@ 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
- export interface WorkflowRunEvent { type: "warning"; 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[] }
77
+ export interface WorkflowRunEvent { type: string; message: string }
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;
88
96
  }
89
97
  export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
90
- export interface WorkflowFunctionContext extends WorkflowOrchestrationContext { run: Readonly<WorkflowRunContext> }
98
+ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
99
+ run: Readonly<WorkflowRunContext>;
100
+ invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
101
+ }
102
+ export type WorkflowWorktreeCallback = (reference: Readonly<WorkflowWorktreeReference>) => JsonValue | Promise<JsonValue>;
91
103
  export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
92
104
  export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
93
- export interface WorkflowScriptDefinition { description: string; script: string }
94
- 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>> }
95
106
  export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
96
107
 
97
108
  export class WorkflowError extends Error {
98
109
  constructor(public readonly code: WorkflowErrorCode, message: string) { super(message); this.name = "WorkflowError"; }
99
110
  }
100
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
+
101
141
  const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
102
142
  type WorkerErrorShape = WorkflowErrorShape & { authored?: boolean; failedAt?: string };
103
143
 
@@ -127,12 +167,13 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
127
167
  INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
128
168
  REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
129
169
  GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
130
- MISSING_WORKFLOW: (detail) => `The workflow primitive is unavailable: ${detail}.`,
170
+ MISSING_WORKFLOW: (detail) => `The registered workflow function is unavailable: ${detail}.`,
131
171
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
132
172
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
133
173
  UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
134
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}.`,
135
175
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
176
+ SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
136
177
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
137
178
  AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
138
179
  RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
@@ -234,6 +275,7 @@ export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ conc
234
275
 
235
276
  function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
236
277
  function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
278
+ export { object as isObject };
237
279
  function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
238
280
  function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
239
281
  export function validateBudget(value: unknown): WorkflowBudget | undefined {
@@ -496,9 +538,7 @@ export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonl
496
538
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
497
539
  }
498
540
  mkdirSync(dirname(path), { recursive: true });
499
- const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
500
- writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
501
- renameSync(temporary, path);
541
+ atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
502
542
  }
503
543
 
504
544
  export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
@@ -613,15 +653,25 @@ type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
613
653
  function astNode(value: unknown): value is acorn.AnyNode {
614
654
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
615
655
  }
656
+ function astChildren(node: acorn.AnyNode): acorn.AnyNode[] {
657
+ const children: acorn.AnyNode[] = [];
658
+ for (const value of Object.values(node) as unknown[]) {
659
+ if (Array.isArray(value)) {
660
+ for (const child of value) if (astNode(child)) children.push(child);
661
+ } else if (astNode(value)) children.push(value);
662
+ }
663
+ return children;
664
+ }
665
+ function workflowCallKind(node: acorn.AnyNode): WorkflowCallKind | undefined {
666
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return undefined;
667
+ const kind = node.callee.name as WorkflowCallKind;
668
+ return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
669
+ }
616
670
  function workflowCalls(program: acorn.Program): WorkflowCall[] {
617
671
  const calls: WorkflowCall[] = [];
618
672
  const visit = (node: acorn.AnyNode): void => {
619
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) calls.push(node as WorkflowCall);
620
- for (const value of Object.values(node) as unknown[]) {
621
- if (Array.isArray(value)) {
622
- for (const child of value as unknown[]) if (astNode(child)) visit(child);
623
- } else if (astNode(value)) visit(value);
624
- }
673
+ if (workflowCallKind(node)) calls.push(node as WorkflowCall);
674
+ for (const child of astChildren(node)) visit(child);
625
675
  };
626
676
  visit(program);
627
677
  return calls.sort((left, right) => left.start - right.start);
@@ -638,9 +688,9 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
638
688
  const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
639
689
  if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
640
690
  }
641
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
691
+ const operation = workflowCallKind(node);
692
+ if (operation) {
642
693
  const call = node as WorkflowCall;
643
- const operation = call.callee.name as StaticWorkflowCall["kind"];
644
694
  const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
645
695
  calls.push({ call, execution, structure: current.structure });
646
696
  for (const [index, argument] of call.arguments.entries()) {
@@ -650,16 +700,11 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
650
700
  }
651
701
  return;
652
702
  }
653
- for (const value of Object.values(node) as unknown[]) {
654
- if (Array.isArray(value)) {
655
- for (const child of value as unknown[]) if (astNode(child)) visit(child, current);
656
- } else if (astNode(value)) visit(value, current);
657
- }
703
+ for (const child of astChildren(node)) visit(child, current);
658
704
  };
659
705
  visit(program, { execution: "sequential", structure: [] });
660
706
  return calls.sort((left, right) => left.call.start - right.call.start);
661
707
  }
662
-
663
708
  function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
664
709
  const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
665
710
  if (node.type === "Identifier" && node.name === name) {
@@ -667,23 +712,19 @@ function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string)
667
712
  const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
668
713
  if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
669
714
  }
670
- for (const value of Object.values(node) as unknown[]) {
671
- if (Array.isArray(value)) {
672
- for (const child of value as unknown[]) if (astNode(child)) visit(child, node);
673
- } else if (astNode(value)) visit(value, node);
674
- }
715
+ for (const child of astChildren(node)) visit(child, node);
675
716
  };
676
717
  visit(program);
677
718
  }
678
-
679
719
  function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
680
720
  if (node.type === "Identifier" && node.name === name) return true;
681
- return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
721
+ return astChildren(node).some((child) => hasIdentifier(child, name));
682
722
  }
683
723
 
684
724
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
685
725
  const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
686
726
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
727
+ const INTERNAL_SHELL_NAME = "__pi_extensible_workflows_shell";
687
728
 
688
729
  function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
689
730
  let previous: acorn.Token | undefined;
@@ -702,12 +743,13 @@ function instrumentWorkflow(script: string): string {
702
743
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
703
744
  if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
704
745
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
705
- 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));
706
748
  const edits = calls.flatMap((call) => {
707
749
  const callSite = `${String(call.start)}:${String(call.end)}`;
708
750
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
709
751
  return [
710
- { 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 },
711
753
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
712
754
  ];
713
755
  }).sort((left, right) => right.start - left.start);
@@ -819,6 +861,18 @@ function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue
819
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");
820
862
  return value;
821
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
+ }
822
876
 
823
877
  type StaticValue = { known: true; value: unknown } | { known: false };
824
878
 
@@ -854,7 +908,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
854
908
  }
855
909
 
856
910
  export interface StaticWorkflowCall {
857
- kind: "agent" | "conversation" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
911
+ kind: WorkflowCallKind;
858
912
  start: number;
859
913
  end: number;
860
914
  name: string | null;
@@ -899,6 +953,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
899
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 } : {}) };
900
954
  }
901
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 };
902
957
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
903
958
  });
904
959
  }
@@ -912,6 +967,14 @@ function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Re
912
967
  if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
913
968
  }
914
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
+ }
915
978
 
916
979
  function validateStaticWithWorktree(call: WorkflowCall): void {
917
980
  if (call.arguments.some((argument) => argument.type === "SpreadElement")) return;
@@ -926,16 +989,18 @@ function validateStaticWithWorktree(call: WorkflowCall): void {
926
989
 
927
990
  export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
928
991
  export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
929
- export interface WorkflowCatalogWorkflow { name: string; version: string; headline: string; extensionDescription: string; description: string }
930
- export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[]; modelAliases?: Readonly<Record<string, string>> }
931
- 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"]);
932
998
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
933
999
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
934
1000
 
935
1001
  export class WorkflowRegistry {
936
1002
  readonly #extensions = new Set<Readonly<WorkflowExtension>>();
937
1003
  readonly #globals = new Map<string, string>();
938
- readonly #workflows = new Map<string, WorkflowScriptDefinition>();
939
1004
  readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
940
1005
  #frozen = false;
941
1006
 
@@ -944,12 +1009,12 @@ export class WorkflowRegistry {
944
1009
 
945
1010
  register(extension: WorkflowExtension): void {
946
1011
  if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
947
- 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");
948
1014
  const functions = extension.functions ?? {};
949
1015
  const variables = extension.variables ?? {};
950
- const workflows = extension.workflows ?? {};
951
1016
  const agentSetupHooks = extension.agentSetupHooks ?? {};
952
- 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");
953
1018
  const names = [...Object.keys(functions), ...Object.keys(variables)];
954
1019
  if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
955
1020
  for (const name of names) {
@@ -967,46 +1032,54 @@ export class WorkflowRegistry {
967
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}`);
968
1033
  validateSchema(variable.schema, `${name} schema`);
969
1034
  }
970
- for (const [name, workflow] of Object.entries(workflows)) {
971
- 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}`);
972
- if (this.#workflows.has(name)) fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
973
- parseWorkflow(workflow.script);
974
- }
975
1035
  for (const [name, hook] of Object.entries(agentSetupHooks)) {
976
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}`);
977
1037
  if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
978
1038
  }
979
- const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
1039
+ const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
980
1040
  this.#extensions.add(stored);
981
1041
  for (const name of names) this.#globals.set(name, name);
982
- for (const [name, workflow] of Object.entries(workflows)) this.#workflows.set(name, workflow);
983
1042
  for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
984
1043
  }
985
1044
 
986
- workflow(name: string): WorkflowScriptDefinition {
987
- if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
988
- const workflow = this.#workflows.get(name);
989
- if (!workflow) fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
990
- 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;
991
1050
  }
992
1051
 
993
- workflows(): Readonly<Record<string, WorkflowScriptDefinition>> {
994
- 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 ?? {}))));
995
1054
  }
996
1055
 
997
1056
  catalog(): WorkflowCatalog {
998
1057
  const functions: WorkflowCatalogFunction[] = [];
999
1058
  const variables: WorkflowCatalogVariable[] = [];
1000
- const workflows: WorkflowCatalogWorkflow[] = [];
1001
1059
  for (const extension of this.#extensions) {
1002
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) });
1003
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) });
1004
- for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1005
1062
  }
1006
1063
  let aliases: Readonly<Record<string, string>> | undefined;
1007
1064
  try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
1008
1065
  const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
1009
- 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}` } });
1010
1083
  }
1011
1084
 
1012
1085
  globals(): Readonly<Record<string, { name: string }>> {
@@ -1014,15 +1087,14 @@ export class WorkflowRegistry {
1014
1087
  }
1015
1088
 
1016
1089
  async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
1017
- const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1018
- if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
1090
+ const fn = this.function(name);
1019
1091
  if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
1020
1092
  const replayed = journal.get(path);
1021
1093
  if (replayed !== undefined) {
1022
1094
  if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
1023
1095
  return structuredClone(replayed);
1024
1096
  }
1025
- const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, 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 }));
1026
1098
  if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
1027
1099
  const stored = structuredClone(result);
1028
1100
  journal.put(path, stored);
@@ -1036,7 +1108,7 @@ export class WorkflowRegistry {
1036
1108
  return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
1037
1109
  }
1038
1110
  }
1039
- 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">;
1040
1112
  interface WorkflowRegistryHost { api: WorkflowRegistryApi }
1041
1113
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
1042
1114
  const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
@@ -1045,9 +1117,11 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
1045
1117
  get frozen() { return registry.frozen; },
1046
1118
  freeze: () => { registry.freeze(); },
1047
1119
  register: (extension) => { registry.register(extension); },
1048
- workflow: (name) => registry.workflow(name),
1049
- workflows: () => registry.workflows(),
1120
+ function: (name) => registry.function(name),
1121
+ functions: () => registry.functions(),
1050
1122
  catalog: () => registry.catalog(),
1123
+ catalogIndex: () => registry.catalogIndex(),
1124
+ catalogDetail: (name) => registry.catalogDetail(name),
1051
1125
  globals: () => registry.globals(),
1052
1126
  invokeFunction: (...args) => registry.invokeFunction(...args),
1053
1127
  variables: () => registry.variables(),
@@ -1067,26 +1141,29 @@ function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().
1067
1141
  beginWorkflowExtensionLoading();
1068
1142
  export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
1069
1143
  export function workflowCatalog(): WorkflowCatalog { return loadingRegistry().catalog(); }
1070
- 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(); }
1071
1147
 
1072
1148
 
1073
1149
  export function formatWorkflowPreview(args: { script?: unknown; workflow?: unknown; name?: unknown; description?: unknown }): string {
1074
1150
  const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : typeof args.workflow === "string" && args.workflow.trim() ? args.workflow : "workflow";
1075
- 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" : ""}`;
1076
1152
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
1077
1153
  }
1078
1154
  export const WORKFLOW_TOOL_LABEL = "Workflow";
1079
1155
  export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
1080
- 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.";
1081
1157
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
1082
1158
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
1083
1159
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
1084
1160
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
1085
- 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" })),
1086
1162
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
1087
1163
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
1088
1164
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
1089
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" })),
1090
1167
  });
1091
1168
 
1092
1169
  function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
@@ -1107,8 +1184,10 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
1107
1184
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1108
1185
  if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
1109
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`);
1110
1188
  validateDirectPrimitiveReferences(program, "withWorktree");
1111
1189
  validateDirectPrimitiveReferences(program, "conversation");
1190
+ validateDirectPrimitiveReferences(program, "shell");
1112
1191
  for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
1113
1192
  const calls = workflowCalls(program);
1114
1193
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
@@ -1119,6 +1198,7 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
1119
1198
  validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1120
1199
  }
1121
1200
  if (operation === "withWorktree") validateStaticWithWorktree(call);
1201
+ if (operation === "shell") validateStaticShellOptions(call);
1122
1202
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
1123
1203
  if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
1124
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");
@@ -1153,6 +1233,7 @@ export interface WorkflowValidationParameters {
1153
1233
  description?: string;
1154
1234
  script?: string;
1155
1235
  workflow?: string;
1236
+ args?: unknown;
1156
1237
  }
1157
1238
 
1158
1239
  export interface WorkflowValidationContext {
@@ -1163,6 +1244,7 @@ export interface WorkflowValidationContext {
1163
1244
  modelAliases?: Readonly<Record<string, string>>;
1164
1245
  knownModels?: ReadonlySet<string>;
1165
1246
  settingsPath?: string;
1247
+ agentDir?: string;
1166
1248
  }
1167
1249
 
1168
1250
 
@@ -1172,21 +1254,27 @@ export interface ValidatedWorkflowLaunch {
1172
1254
  agentDefinitions: Readonly<Record<string, AgentDefinition>>;
1173
1255
  projectAgentDefinitions: Readonly<Record<string, AgentDefinition>>;
1174
1256
  roleNames: readonly string[];
1257
+ functionName?: string;
1175
1258
  }
1176
1259
 
1260
+ function functionLaunchScript(name: string): string { return `return await ${name}(args);`; }
1261
+
1177
1262
  export function validateWorkflowLaunch(params: WorkflowValidationParameters, context: WorkflowValidationContext): ValidatedWorkflowLaunch {
1178
1263
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
1179
1264
  }
1180
1265
  function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
1181
1266
  if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
1182
1267
  if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
1183
- const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
1184
- const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
1185
- if (!script) fail("INVALID_SYNTAX", "Provide script or registered workflow");
1186
- 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() : "");
1187
1275
  if (!workflowName) fail("INVALID_METADATA", "Inline workflows require name");
1188
- const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : definition?.description ? { description: definition.description } : {}) });
1189
- 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);
1190
1278
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
1191
1279
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
1192
1280
  const aliases = context.modelAliases ?? {};
@@ -1194,7 +1282,7 @@ function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters
1194
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);
1195
1283
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
1196
1284
  validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
1197
- return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
1285
+ return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, ...(functionName ? { functionName } : {}) };
1198
1286
  }
1199
1287
 
1200
1288
  function deepFreeze<T>(value: T): T {
@@ -1208,22 +1296,34 @@ function deepFreeze<T>(value: T): T {
1208
1296
  type LaunchSnapshotInput = Omit<LaunchSnapshot, "identityVersion"> & { identityVersion?: number };
1209
1297
 
1210
1298
  export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<LaunchSnapshot> {
1211
- 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 }));
1212
1300
  }
1213
1301
 
1214
1302
  export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapshot> {
1215
- if (object(input.settings) && Object.prototype.hasOwnProperty.call(input.settings, "maxAgentLaunches")) fail("RESUME_INCOMPATIBLE", "Persisted workflow settings contain removed maxAgentLaunches");
1216
1303
  return deepFreeze(structuredClone(input));
1217
1304
  }
1218
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
+
1219
1316
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
1220
1317
  export const HEARTBEAT_TIMEOUT_MS = 5000;
1221
1318
 
1222
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 }
1223
1321
  export interface WorkflowBridge {
1224
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>;
1225
1324
  checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
1226
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>>;
1227
1327
  functions?: Readonly<Record<string, { name: string }>>;
1228
1328
  variables?: Readonly<Record<string, JsonValue>>;
1229
1329
  phase?: (name: string) => void | Promise<void>;
@@ -1296,10 +1396,12 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1296
1396
  const path = (...names) => names.map(encodeURIComponent).join("/");
1297
1397
  const inheritedAgentPath = new AsyncLocalStorage();
1298
1398
  const agentOccurrences = new Map();
1399
+ const shellOccurrences = new Map();
1299
1400
  const conversationOccurrences = new Map();
1300
1401
  const worktreeOwners = new AsyncLocalStorage();
1301
1402
  const worktreeOccurrences = new Map();
1302
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"); };
1303
1405
  const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
1304
1406
  const internalWithWorktree = async (...values) => {
1305
1407
  const callSite = values.pop();
@@ -1318,7 +1420,9 @@ const internalWithWorktree = async (...values) => {
1318
1420
  worktreeOccurrences.set(occurrenceKey, occurrence);
1319
1421
  owner = path("worktree", "unnamed", ...inherited, "callsite:" + callSite, "occurrence:" + String(occurrence));
1320
1422
  }
1321
- 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 })));
1322
1426
  };
1323
1427
  const internalConversation = (...values) => {
1324
1428
  const callSite = values.pop();
@@ -1377,6 +1481,31 @@ const internalAgent = (...values) => {
1377
1481
  });
1378
1482
  return result;
1379
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;
1380
1509
  const agent = rejectAgent;
1381
1510
  const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
1382
1511
  const plainPromptObject = value => {
@@ -1498,13 +1627,13 @@ const pipeline = async (operationName, items, stages) => {
1498
1627
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1499
1628
  };
1500
1629
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1501
- 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) };
1502
1631
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1503
1632
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1504
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;
1505
1634
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1506
1635
  const body = config.script;
1507
- 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))
1508
1637
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1509
1638
  .catch(error => send({ type: "error", error: workerError(error) }))
1510
1639
  .finally(() => clearInterval(heartbeat));
@@ -1517,6 +1646,10 @@ function encoded(value: unknown): string {
1517
1646
  return json;
1518
1647
  }
1519
1648
 
1649
+ function encodedRpcResult(id: number, value: JsonValue): string {
1650
+ return encoded({ type: "rpcResult", id, ok: true, value });
1651
+ }
1652
+
1520
1653
  function readAgentIdentity(value: unknown): AgentIdentity {
1521
1654
  if (!object(value)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1522
1655
  const structuralPath = value.structuralPath;
@@ -1529,10 +1662,17 @@ function readAgentIdentity(value: unknown): AgentIdentity {
1529
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");
1530
1663
  return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1531
1664
  }
1532
-
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
+ }
1533
1670
  function agentIdentityPath(identity: AgentIdentity): string {
1534
1671
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1535
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
+ }
1536
1676
  function conversationIdentityPath(identity: AgentIdentity): string {
1537
1677
  if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1538
1678
  return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
@@ -1541,9 +1681,82 @@ function conversationTurnPath(identity: AgentIdentity): string {
1541
1681
  if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1542
1682
  return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1543
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
+ }
1544
1688
  function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
1545
1689
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1546
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
+ }
1547
1760
  export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
1548
1761
  encoded(args);
1549
1762
  const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
@@ -1613,6 +1826,12 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1613
1826
  if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1614
1827
  value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1615
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;
1616
1835
  } else if (method === "checkpoint") {
1617
1836
  if (!bridge.checkpoint || !object(values[0])) fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
1618
1837
  const name = typeof values[0].name === "string" ? values[0].name : "checkpoint";
@@ -1639,6 +1858,9 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1639
1858
  if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1640
1859
  value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1641
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);
1642
1864
  } else if (method === "phase") {
1643
1865
  if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "phase name must be a string");
1644
1866
  await bridge.phase?.(values[0]);
@@ -1648,7 +1870,7 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1648
1870
  }
1649
1871
  else fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
1650
1872
  encoded(value);
1651
- child.send(encoded({ type: "rpcResult", id, ok: true, value }));
1873
+ child.send(encodedRpcResult(id, value));
1652
1874
  } catch (error) {
1653
1875
  const typed = asWorkflowError(error);
1654
1876
  child.send(encoded({ type: "rpcResult", id, ok: false, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } }));
@@ -1845,13 +2067,9 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
1845
2067
  const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
1846
2068
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
1847
2069
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1848
- const setup = agent.attemptDetails?.at(-1)?.setup;
1849
- const thinking = setup?.model.thinking ?? agent.model.thinking;
1850
- const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
1851
- const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1852
2070
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
1853
2071
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
1854
- const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
2072
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state}${tokens ? ` · ${tokens}` : ""}`];
1855
2073
  if (agent.state === "failed" && agent.attemptDetails?.length) {
1856
2074
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
1857
2075
  if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
@@ -1902,7 +2120,8 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
1902
2120
  return lines.join("\n");
1903
2121
  }
1904
2122
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
1905
- 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");
1906
2125
  }
1907
2126
 
1908
2127
  const DELIVERY_LIMIT_BYTES = 4 * 1024;
@@ -1925,12 +2144,109 @@ function utf8Prefix(value: string, maxBytes: number): string {
1925
2144
  while (end < bytes.length && end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end -= 1;
1926
2145
  return bytes.subarray(0, end).toString("utf8");
1927
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
+ }
1928
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
+ }
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
+ }
1929
2245
  function deliver(pi: ExtensionAPI, content: string): void {
1930
2246
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
1931
2247
  }
1932
- function deliverFailure(pi: ExtensionAPI, name: string, error: unknown): void {
1933
- 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)}`);
1934
2250
  }
1935
2251
 
1936
2252
  type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -1947,19 +2263,19 @@ class WorkflowEventPublisher {
1947
2263
 
1948
2264
  constructor(private readonly sink: WorkflowEventSink | undefined) {}
1949
2265
 
2266
+ removeRun(runId: string): void {
2267
+ this.#queues.delete(runId);
2268
+ this.#budgetEvents.delete(runId);
2269
+ this.#worktrees.delete(runId);
2270
+ }
2271
+
1950
2272
  seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
1951
2273
  const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
1952
2274
  for (const event of events ?? []) seen.add(this.budgetKey(event));
1953
2275
  this.#budgetEvents.set(runId, seen);
1954
2276
  }
1955
2277
 
1956
- async runStarted(store: RunStore, metadata: WorkflowMetadata, background: boolean): Promise<void> {
1957
- await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {});
1958
- if (background) await this.legacyStarted(store, metadata);
1959
- }
1960
-
1961
- async legacyStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name }); }
1962
-
2278
+ async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
1963
2279
  async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
1964
2280
 
1965
2281
  async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
@@ -1967,16 +2283,9 @@ class WorkflowEventPublisher {
1967
2283
  if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
1968
2284
  }
1969
2285
 
1970
- async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string, background: boolean): Promise<void> {
1971
- await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath });
1972
- if (background) await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: true, state: "complete" });
1973
- }
1974
- async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, background: boolean, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
2286
+ async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
2287
+ async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
1975
2288
  if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
1976
- if (background) {
1977
- const legacyState = state === "interrupted" ? "failed" : state;
1978
- await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: false, state: legacyState, ...(state === "stopped" ? { stopped: true } : {}), ...(legacyState === "budget_exhausted" || legacyState === "failed" ? { error: safeEventError(error).message } : {}) });
1979
- }
1980
2289
  }
1981
2290
 
1982
2291
  async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
@@ -2037,7 +2346,11 @@ function namedRecord(value: unknown, kind: string): Array<[string, unknown]> {
2037
2346
  if (!object(value)) fail("INVALID_METADATA", `${kind} must be a record`);
2038
2347
  return Object.entries(value);
2039
2348
  }
2040
- 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> {
2041
2354
  if (args.length !== 1 && args.length !== 2) fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
2042
2355
  const callback = args[args.length - 1];
2043
2356
  if (typeof callback !== "function") fail("INVALID_METADATA", "withWorktree callback must be a function");
@@ -2052,7 +2365,9 @@ function hostWithWorktree(args: readonly unknown[], identity: string, occurrence
2052
2365
  occurrences.set(key, occurrence);
2053
2366
  owner = operationPath("worktree", "unnamed", "function", identity, ...structuralPath, `occurrence:${String(occurrence)}`);
2054
2367
  }
2055
- 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>;
2056
2371
  }
2057
2372
  function workflowRunContext(cwd: string, sessionId: string, runId: string, workflow: WorkflowMetadata, args: JsonValue, signal: AbortSignal): Readonly<WorkflowRunContext> {
2058
2373
  return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
@@ -2133,16 +2448,27 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
2133
2448
  return count === 1 ? label : `${label}#${String(count)}`;
2134
2449
  }
2135
2450
 
2136
-
2137
2451
  function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
2138
2452
  const functionAgentOccurrences = new Map<string, number>();
2453
+ const functionShellOccurrences = new Map<string, number>();
2139
2454
  const functionWorktreeOccurrences = new Map<string, number>();
2140
- return { ...bridge, functions: registry.globals(), variables, function: async (name, input, path, signal, worktreeOwner, structuralPath = []) => {
2455
+ const functionInvokeOccurrences = new Map<string, number>();
2456
+ const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
2141
2457
  const replayed = await store.replay(path);
2142
2458
  let stored: JsonValue | undefined;
2143
2459
  const sideEffects: Promise<void>[] = [];
2460
+ const functionBreadcrumb = breadcrumb ?? name;
2144
2461
  const context: WorkflowFunctionContext = {
2145
2462
  run: runContext,
2463
+ invoke: async (targetName, targetInput) => {
2464
+ const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
2465
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2466
+ const key = JSON.stringify([path, inherited, targetName]);
2467
+ const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
2468
+ functionInvokeOccurrences.set(key, occurrence);
2469
+ const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
2470
+ return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
2471
+ },
2146
2472
  agent: async (...args: readonly unknown[]) => {
2147
2473
  if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
2148
2474
  const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
@@ -2151,12 +2477,23 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
2151
2477
  const key = `${path}\0${JSON.stringify(inherited)}`;
2152
2478
  const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
2153
2479
  functionAgentOccurrences.set(key, occurrence);
2154
- return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: name, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2480
+ return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
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 } : {}) });
2155
2492
  },
2156
2493
  prompt: workflowPrompt,
2157
2494
  parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
2158
2495
  pipeline: (...args: readonly unknown[]) => hostPipeline(args[0], args[1], args[2]),
2159
- withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, path, functionWorktreeOccurrences),
2496
+ withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, path, functionWorktreeOccurrences, bridge.worktree, signal),
2160
2497
  checkpoint: async (...args: readonly unknown[]) => {
2161
2498
  if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0])) fail("INTERNAL_ERROR", "No checkpoint bridge is available");
2162
2499
  return bridge.checkpoint(args[0], signal);
@@ -2168,14 +2505,73 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
2168
2505
  await Promise.all(sideEffects);
2169
2506
  if (!replayed) await store.complete(path, stored ?? result);
2170
2507
  return result;
2171
- } };
2508
+ };
2509
+ return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
2172
2510
  }
2173
2511
 
2174
2512
  function projectTrusted(ctx: unknown): boolean {
2175
2513
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
2176
2514
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
2177
2515
  }
2178
-
2516
+ type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
2517
+ function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
2518
+ function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
2519
+ function piHostCapabilities(pi: unknown): PiHostCapabilities {
2520
+ if (!object(pi)) return {};
2521
+ const registerEntryRenderer = pi.registerEntryRenderer;
2522
+ const events = pi.events;
2523
+ return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
2524
+ }
2525
+ type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
2526
+ type ModelSummary = { provider: string; id: string };
2527
+ type ModelRegistryGetter = () => readonly ModelSummary[];
2528
+ type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
2529
+ function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
2530
+ function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
2531
+ if (!object(ctx) || !object(ctx.modelRegistry)) return {};
2532
+ const registry = ctx.modelRegistry;
2533
+ const getAll = registry.getAll;
2534
+ const getAvailable = registry.getAvailable;
2535
+ return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
2536
+ }
2537
+ type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
2538
+ type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
2539
+ type UiSetStatus = (key: string, text?: string) => void;
2540
+ type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
2541
+ function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
2542
+ function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
2543
+ function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
2544
+ function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
2545
+ if (!object(ui)) return undefined;
2546
+ const select = ui.select;
2547
+ const input = ui.input;
2548
+ const setStatus = ui.setStatus;
2549
+ return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
2550
+ }
2551
+ type TuiHostCapabilities = { terminal?: { rows?: unknown } };
2552
+ function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
2553
+ if (!object(tui) || !object(tui.terminal)) return {};
2554
+ return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2555
+ }
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
+ }
2568
+ type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
2569
+ function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
2570
+ function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
2571
+ if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
2572
+ return { getKeys: keybindings.getKeys };
2573
+ }
2574
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
2179
2575
  function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2180
2576
  switch (value) {
2181
2577
  case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
@@ -2183,10 +2579,11 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2183
2579
  }
2184
2580
  }
2185
2581
 
2186
- 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) {
2187
2583
  beginWorkflowExtensionLoading();
2188
2584
  const registry = loadingRegistry();
2189
- const registerEntryRenderer = (pi as unknown as Partial<Pick<ExtensionAPI, "registerEntryRenderer">>).registerEntryRenderer;
2585
+ const extensionAgentDir = agentDir ?? getAgentDir();
2586
+ const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
2190
2587
  registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
2191
2588
  const data = entry.data;
2192
2589
  return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
@@ -2196,7 +2593,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2196
2593
  try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
2197
2594
  finally { await lifecycle.leave(); }
2198
2595
  };
2199
- const eventPublisher = new WorkflowEventPublisher((pi as unknown as { events?: WorkflowEventSink }).events);
2596
+ const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
2200
2597
  pi.on("resources_discover", () => {
2201
2598
  if (!pi.getActiveTools().includes("workflow")) return;
2202
2599
  const extensionDir = dirname(fileURLToPath(import.meta.url));
@@ -2204,7 +2601,54 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2204
2601
  return skillPath ? { skillPaths: [skillPath] } : undefined;
2205
2602
  });
2206
2603
  type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
2207
- 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
+ });
2634
+ const liveActivities = new Map<string, Map<string, AgentActivity>>();
2635
+ const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
2636
+ const activities = liveActivities.get(runId);
2637
+ if (activity) {
2638
+ if (activities) activities.set(agentId, activity);
2639
+ else liveActivities.set(runId, new Map([[agentId, activity]]));
2640
+ } else {
2641
+ activities?.delete(agentId);
2642
+ if (activities?.size === 0) liveActivities.delete(runId);
2643
+ }
2644
+ };
2645
+ const withLiveActivities = (run: PersistedRun): PersistedRun => {
2646
+ const activities = liveActivities.get(run.id);
2647
+ return activities?.size ? { ...run, agents: run.agents.map((agent) => {
2648
+ const activity = activities.get(agent.id);
2649
+ return activity ? { ...agent, activity } : agent;
2650
+ }) } : run;
2651
+ };
2208
2652
  const conversationLocks = new Set<string>();
2209
2653
  const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
2210
2654
  let sessionLease: SessionLease | undefined;
@@ -2229,9 +2673,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2229
2673
  const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
2230
2674
  const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2231
2675
  const worktree = await store.worktree(owner);
2232
- if (!existing) await eventPublisher.worktree(store, metadata, worktree);
2676
+ if (!existing && await store.ownsWorktree(owner)) await eventPublisher.worktree(store, metadata, worktree);
2233
2677
  return worktree;
2234
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
+ };
2235
2700
  const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
2236
2701
  if (next !== "pausing") budget.transition(next);
2237
2702
  const persisted = await persistRunState(store, metadata, (current) => {
@@ -2240,7 +2705,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2240
2705
  return nextRun;
2241
2706
  });
2242
2707
  await eventPublisher.runState(store, metadata, previous, next, reason);
2243
- runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
2708
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2244
2709
  });
2245
2710
  const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
2246
2711
  const run = runs.get(runId);
@@ -2257,7 +2722,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2257
2722
  runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
2258
2723
  }
2259
2724
  if (!runState.agents.some((agent) => agent.id === id)) return;
2260
- run.update?.(workflowToolUpdate(runState));
2725
+ setLiveActivity(runId, id, progress.activity);
2726
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2261
2727
  };
2262
2728
  const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
2263
2729
  await scheduler.flush();
@@ -2268,15 +2734,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2268
2734
  const active = (await run.store.load()).run;
2269
2735
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2270
2736
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2271
- run.update?.(workflowToolUpdate(persisted));
2737
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2272
2738
  };
2273
- 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); });
2274
2740
  const before = (await run.store.load()).run;
2275
2741
  await persistAgentAttempts(run.store, id, result.attempts);
2276
2742
  const completed = (await run.store.load()).run;
2277
2743
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2278
2744
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2279
- run.update?.(workflowToolUpdate(persisted));
2745
+ setLiveActivity(runId, id);
2746
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2280
2747
  return result.value;
2281
2748
  } catch (error) {
2282
2749
  const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
@@ -2287,7 +2754,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2287
2754
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2288
2755
  }
2289
2756
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2290
- run.update?.(workflowToolUpdate(persisted));
2757
+ setLiveActivity(runId, id);
2758
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2291
2759
  throw error;
2292
2760
  }
2293
2761
  }, 16, async (runId, ownership) => {
@@ -2309,8 +2777,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2309
2777
  return { ...current, agents };
2310
2778
  });
2311
2779
  await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2312
- run.update?.(workflowToolUpdate(runState));
2780
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2313
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
+ };
2314
2795
  type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
2315
2796
  const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
2316
2797
  const run = runs.get(runId);
@@ -2323,6 +2804,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2323
2804
  run.execution?.cancel();
2324
2805
  await scheduler.cancelRun(run.store.runId);
2325
2806
  await scheduler.flush();
2807
+ await cleanupTerminalRun(runId);
2326
2808
  return { runId, state: "stopped", stopped: true };
2327
2809
  };
2328
2810
  const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
@@ -2349,17 +2831,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2349
2831
  if (!request) return undefined;
2350
2832
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2351
2833
  const result = await applyBudgetDecision(request, approved);
2352
- run.budgetResolvers.get(proposalId)?.(result);
2353
- run.budgetResolvers.delete(proposalId);
2354
2834
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2355
2835
  return result;
2356
2836
  };
2357
- 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) => {
2358
2838
  const checkpointCounters = new Map<string, number>();
2359
2839
  return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
2360
2840
  const input = validateCheckpoint(raw);
2361
2841
  const label = nextNamedOccurrence(checkpointCounters, input.name);
2362
2842
  const path = operationPath("checkpoint", label);
2843
+ if (headless) fail("RESUME_INCOMPATIBLE", "Headless CLI checkpoints are unsupported");
2363
2844
  if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
2364
2845
  const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
2365
2846
  const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
@@ -2434,13 +2915,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2434
2915
  if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
2435
2916
  const catalog = registry.catalog();
2436
2917
  const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2437
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases) return;
2918
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases) return;
2438
2919
  pi.registerTool({
2439
2920
  name: "workflow_catalog",
2440
2921
  label: "Workflow Catalog",
2441
- description: "List global workflow functions, variables, and reusable workflows",
2442
- parameters: Type.Object({}, { additionalProperties: false }),
2443
- 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
+ }
2444
2928
  });
2445
2929
  catalogRegistered = true;
2446
2930
  };
@@ -2449,7 +2933,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2449
2933
  const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2450
2934
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2451
2935
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2452
- const settingsPath = workflowSettingsPath();
2936
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2453
2937
  const currentSettings = loadSettings(settingsPath);
2454
2938
  resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2455
2939
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2462,13 +2946,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2462
2946
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2463
2947
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2464
2948
  await run.store.saveSnapshot(snapshot);
2465
- 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);
2466
2950
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2467
2951
  const drift = aliasDrift(previousAliases, currentAliases);
2468
2952
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2469
2953
  };
2470
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 }) => {
2471
2955
  const loaded = await run.store.load();
2956
+ await run.store.validateBorrowedWorktrees();
2472
2957
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
2473
2958
  if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
2474
2959
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
@@ -2477,7 +2962,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2477
2962
  const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2478
2963
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2479
2964
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2480
- const settingsPath = workflowSettingsPath();
2965
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2481
2966
  const currentSettings = loadSettings(settingsPath);
2482
2967
  resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2483
2968
  const currentAliases = currentSettings.modelAliases ?? {};
@@ -2489,10 +2974,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2489
2974
  const resumeAliases = { ...previousAliases, ...currentAliases };
2490
2975
  const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2491
2976
  const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2492
- 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);
2493
2979
  const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2494
2980
  await run.store.saveSnapshot(snapshot);
2495
- 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);
2496
2982
  const drift = aliasDrift(previousAliases, currentAliases);
2497
2983
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2498
2984
  const controller = new AbortController();
@@ -2503,12 +2989,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2503
2989
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
2504
2990
  catch (error) {
2505
2991
  const typed = asWorkflowError(error);
2506
- 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, true, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
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);
2507
2994
  throw typed;
2508
2995
  }
2509
2996
  await scheduler.cancelRun(run.store.runId);
2510
2997
  await run.lifecycle.resume();
2511
- 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) => {
2512
2999
  await run.lifecycle.enter();
2513
3000
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2514
3001
  const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
@@ -2546,14 +3033,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2546
3033
  await run.store.complete(path, outcome.value);
2547
3034
  return outcome.value;
2548
3035
  } finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
2549
- }, 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);
2550
3037
  run.execution = execution;
2551
3038
  const completion = execution.result.then(async (value) => {
2552
3039
  await scheduler.flush();
2553
3040
  if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2554
3041
  const resultPath = await run.store.saveResult(value);
2555
3042
  await run.lifecycle.terminal("completed", "completed");
2556
- await eventPublisher.runCompleted(run.store, run.metadata, resultPath, true);
3043
+ await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
2557
3044
  return { value, resultPath };
2558
3045
  }).catch(async (error: unknown) => {
2559
3046
  await scheduler.flush();
@@ -2561,10 +3048,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2561
3048
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2562
3049
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2563
3050
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2564
- await eventPublisher.runFailed(run.store, run.metadata, typed, true, state);
3051
+ await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2565
3052
  run.update?.(workflowToolUpdate(persisted));
2566
- if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
2567
- });
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;
2568
3056
  void completion;
2569
3057
  };
2570
3058
  const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
@@ -2592,11 +3080,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2592
3080
  if (budgetRelaxed(currentBudget, nextBudget)) {
2593
3081
  const proposalId = randomUUID();
2594
3082
  const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2595
- const decision = new Promise<BudgetDecisionResult>((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2596
- 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");
2597
3085
  deliver(pi, budgetDecisionDelivery(run.metadata, request));
2598
- const decisionResult = await decision;
2599
- return { state: decisionResult.state };
3086
+ return { state: "awaiting_approval", proposalId };
2600
3087
  }
2601
3088
  const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2602
3089
  if (changed) {
@@ -2645,12 +3132,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2645
3132
  const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
2646
3133
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2647
3134
  const roleDefinitions = loaded.snapshot.roles ?? {};
2648
- 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 } : {}) });
2649
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.`);
2650
3139
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
2651
3140
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
2652
3141
  }
2653
- const resumeSelect = (ctx.ui as unknown as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } | undefined)?.select;
3142
+ const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
2654
3143
  if (ctx.hasUI && resumeSelect) {
2655
3144
  const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
2656
3145
  if (interrupted.length > 0) {
@@ -2670,7 +3159,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2670
3159
  });
2671
3160
  pi.on("before_agent_start", (event, ctx) => {
2672
3161
  if (!pi.getActiveTools().includes("workflow")) return;
2673
- 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);
2674
3163
  if (!roles.length) return;
2675
3164
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
2676
3165
  return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
@@ -2681,24 +3170,25 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2681
3170
  description: WORKFLOW_TOOL_DESCRIPTION,
2682
3171
  promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
2683
3172
  parameters: WORKFLOW_TOOL_PARAMETERS,
2684
- async execute(_id, params, signal, onUpdate, ctx) {
3173
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
2685
3174
  try {
2686
- const settingsPath = workflowSettingsPath();
3175
+ const headless = object(ctx) && ctx.headless === true;
3176
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
2687
3177
  const defaults = loadSettings(settingsPath);
2688
3178
  if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
2689
3179
  const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
2690
3180
  const budget = validateBudget(params.budget);
2691
3181
  const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
2692
3182
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
2693
- const modelRegistry = (ctx as unknown as { modelRegistry?: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
3183
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2694
3184
  const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
2695
3185
  knownModels.add(rootModelName);
2696
3186
  const availableModels = knownModels;
2697
3187
  const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
2698
3188
  const trustedProject = projectTrusted(ctx);
2699
3189
  if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
2700
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
2701
- 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;
2702
3192
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
2703
3193
  const runId = randomUUID();
2704
3194
  const args = (params.args ?? null) as JsonValue;
@@ -2708,22 +3198,25 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2708
3198
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
2709
3199
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
2710
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);
2711
3203
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
2712
3204
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2713
3205
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2714
3206
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2715
- 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 });
2716
3208
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
2717
3209
  const initialBudget = budgetRuntime.snapshot();
2718
- 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);
2719
3211
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2720
3212
  const background = !params.foreground;
2721
3213
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2722
- 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);
2723
- 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 } : {}) });
2724
3217
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
2725
3218
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2726
- 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) => {
2727
3220
  await lifecycle.enter();
2728
3221
  const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2729
3222
  const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
@@ -2761,7 +3254,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2761
3254
  await store.complete(path, outcome.value);
2762
3255
  return outcome.value;
2763
3256
  } finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
2764
- }, 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) => {
2765
3258
  await lifecycle.enter();
2766
3259
  try {
2767
3260
  let previousPhase: string | undefined;
@@ -2771,33 +3264,41 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2771
3264
  } finally { await lifecycle.leave(); }
2772
3265
  }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2773
3266
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
2774
- await eventPublisher.runStarted(store, checked.metadata, background);
3267
+ await eventPublisher.runStarted(store, checked.metadata);
2775
3268
  const finish = execution.result.then(async (value) => {
2776
3269
  await scheduler.flush();
2777
3270
  if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2778
3271
  const resultPath = await store.saveResult(value);
2779
3272
  await lifecycle.terminal("completed", "completed");
2780
- await eventPublisher.runCompleted(store, checked.metadata, resultPath, background);
3273
+ await eventPublisher.runCompleted(store, checked.metadata, resultPath);
2781
3274
  return { value, resultPath };
2782
3275
  }).catch(async (error: unknown) => {
2783
3276
  await scheduler.flush();
2784
3277
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2785
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);
2786
- 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 } }));
2787
3280
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2788
- await eventPublisher.runFailed(store, checked.metadata, typed, background, state);
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);
2789
3285
  throw typed;
2790
3286
  });
2791
- (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;
2792
3289
  if (background) {
2793
- void finish.then(async ({ value, resultPath }) => {
3290
+ void completion.then(async ({ value, resultPath }) => {
2794
3291
  deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2795
- }, (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
+ });
2796
3297
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2797
3298
  }
2798
- const { value } = await finish;
3299
+ const { value } = await completion;
2799
3300
  const run = (await store.load()).run;
2800
- 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 } };
2801
3302
  } catch (error) {
2802
3303
  throw mainAgentError(error);
2803
3304
  }
@@ -2806,18 +3307,20 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2806
3307
  return textBlock(formatWorkflowPreview(args));
2807
3308
  },
2808
3309
  renderResult(result, { isPartial }, _theme, context) {
2809
- 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;
2810
3313
  const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval> };
2811
- if (details?.run && isPartial && details.run.state === "running" && !state.workflowSpinner) {
3314
+ if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
2812
3315
  state.workflowSpinner = setInterval(context.invalidate, 80);
2813
3316
  state.workflowSpinner.unref();
2814
- } else if ((!isPartial || details?.run?.state !== "running") && state.workflowSpinner) {
3317
+ } else if ((!isPartial || runDetails?.run?.state !== "running") && state.workflowSpinner) {
2815
3318
  clearInterval(state.workflowSpinner);
2816
3319
  delete state.workflowSpinner;
2817
3320
  }
2818
- if (details?.run) return workflowProgressBlock(details.run);
3321
+ if (runDetails?.run) return workflowProgressBlock(runDetails.run);
2819
3322
  const content = result.content[0];
2820
- 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"));
2821
3324
  },
2822
3325
  });
2823
3326
  pi.registerCommand("workflow", {
@@ -2842,7 +3345,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2842
3345
  let stores = await loadStores();
2843
3346
  const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint or proposal-id]. Use workflow_resume for budget patches."
2844
3347
  const setWorkflowStatus = (text: string | undefined) => {
2845
- const setStatus = (ctx.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus;
3348
+ const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
2846
3349
  setStatus?.call(ctx.ui, "workflow-stop", text);
2847
3350
  };
2848
3351
  const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
@@ -2883,7 +3386,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2883
3386
  return keepContext ? "dashboard" : "done";
2884
3387
  }
2885
3388
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2886
- const input = await (ctx.ui as unknown as { input?: (title: string, placeholder?: string) => Promise<string | undefined> }).input?.("Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
3389
+ const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2887
3390
  if (input === undefined) return keepContext ? "dashboard" : "done";
2888
3391
  const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2889
3392
  ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
@@ -2909,8 +3412,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2909
3412
  }
2910
3413
  };
2911
3414
  const manageAliases = async (): Promise<void> => {
2912
- const settingsPath = workflowSettingsPath();
2913
- const modelRegistry = (ctx as unknown as { modelRegistry?: { getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
3415
+ const settingsPath = workflowSettingsPath(extensionAgentDir);
3416
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2914
3417
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
2915
3418
  const selectTarget = async (): Promise<string | undefined> => {
2916
3419
  const models = available();
@@ -2985,9 +3488,18 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2985
3488
  const labels = navigatorRunLabels(sorted);
2986
3489
  const terminalStates = new Set(["completed", "failed", "stopped"]);
2987
3490
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2988
- 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"] : [])];
2989
3492
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
2990
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
+ }
2991
3503
  if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
2992
3504
  if (runChoice === "Delete all completed") {
2993
3505
  if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
@@ -3009,38 +3521,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3009
3521
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
3010
3522
  }
3011
3523
  };
3012
- const openTranscript = async (transcript: string): Promise<void> => {
3013
- try {
3014
- const entries = SessionManager.open(transcript).buildContextEntries();
3015
- if (ctx.mode !== "tui") { ctx.ui.notify(`Transcript: ${transcript}`, "info"); return; }
3016
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3017
- let offset = 0;
3018
- let renderedLines: string[] = [];
3019
- const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
3020
- const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3021
- return {
3022
- render(width: number) {
3023
- renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3024
- offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3025
- return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3026
- },
3027
- invalidate() {},
3028
- handleInput(data: string) {
3029
- if (keybindings.matches(data, "tui.select.up")) move(-1);
3030
- else if (keybindings.matches(data, "tui.select.down")) move(1);
3031
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
3032
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
3033
- else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
3034
- else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
3035
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3036
- tui.requestRender();
3037
- },
3038
- };
3039
- }, { overlay: true });
3040
- } catch (error) {
3041
- ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3042
- }
3043
- };
3044
3524
  const loadDashboard = async () => {
3045
3525
  const loaded = await store.load();
3046
3526
  const checkpoints = await store.awaitingCheckpoints();
@@ -3071,16 +3551,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3071
3551
  }
3072
3552
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
3073
3553
  else actions.set("View script", "view-script");
3074
- const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
3075
3554
  if (loaded.run.agents.length) actions.set("Agents...", "agents");
3076
- if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
3077
- if (!loaded.run.agents.length && transcripts.length) actions.set("Transcript paths", "transcripts");
3078
3555
  if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
3079
3556
  if (ctx.mode === "tui") {
3080
3557
  addCopy("Copy run path", store.directory, "run path");
3081
3558
  addCopy("Copy run ID", store.runId, "run ID");
3082
3559
  }
3083
- 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 };
3084
3561
  };
3085
3562
  const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
3086
3563
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
@@ -3101,14 +3578,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3101
3578
  const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3102
3579
  const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3103
3580
  const actions = [
3104
- ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
3581
+ ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3105
3582
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3106
3583
  "Copy agent ID",
3107
3584
  "Back",
3108
3585
  ];
3109
3586
  const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
3110
3587
  const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3111
- 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"]);
3112
3589
  const index = choice ? choices.indexOf(choice) : -1;
3113
3590
  return index >= 0 ? attempts[index] : undefined;
3114
3591
  };
@@ -3118,11 +3595,17 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3118
3595
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
3119
3596
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
3120
3597
  if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
3121
- if (action === "View transcript" || action === "Copy transcript path") {
3598
+ if (action === "Fork as Pi session in pane") {
3122
3599
  const attempt = await chooseAttempt();
3123
3600
  if (!attempt) continue;
3124
- if (action === "Copy transcript path") await copyArtifact(attempt.sessionFile, "transcript path");
3125
- 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
+ }
3126
3609
  }
3127
3610
  }
3128
3611
  };
@@ -3137,19 +3620,18 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3137
3620
  let disposed = false;
3138
3621
  let stopRequested = false;
3139
3622
  let stopStatus: string | undefined;
3140
- const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
3623
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3141
3624
  const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3142
3625
  const keyLabel = (binding: string, fallback: string) => {
3143
- const getKeys = (keybindings as unknown as { getKeys?: (name: string) => readonly string[] }).getKeys;
3144
- const keys = getKeys?.call(keybindings, binding);
3626
+ const keys = keybindingKeys(keybindings, binding);
3145
3627
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
3146
3628
  };
3147
3629
  const dashboardLayout = () => {
3148
3630
  const rows = terminalRows();
3149
3631
  const hintRows = rows >= 4 ? 1 : 0;
3150
- const separatorRows = rows >= 6 ? 1 : 0;
3632
+ const separatorRows = rows >= 8 ? 1 : 0;
3151
3633
  const available = Math.max(1, rows - hintRows - separatorRows);
3152
- 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)));
3153
3635
  return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3154
3636
  };
3155
3637
  const updateDashboard = async (selectedOption: string | undefined) => {
@@ -3186,7 +3668,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3186
3668
  void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3187
3669
  }, 1000);
3188
3670
  timer.unref();
3189
- return {
3671
+ return borderWorkflowOverlay({
3190
3672
  render(width: number) {
3191
3673
  const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
3192
3674
  const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
@@ -3224,7 +3706,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3224
3706
  tui.requestRender();
3225
3707
  },
3226
3708
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
3227
- };
3709
+ }, theme);
3228
3710
  }, { overlay: true })
3229
3711
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
3230
3712
  if (!actionChoice || actionChoice === "Close") return;
@@ -3235,12 +3717,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3235
3717
  const highlighted = highlightCode(view.script, "javascript");
3236
3718
  let offset = 0;
3237
3719
  let renderedLines: string[] = [];
3238
- const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
3720
+ const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
3239
3721
  const move = (delta: number) => {
3240
3722
  const maxOffset = Math.max(0, renderedLines.length - viewport());
3241
3723
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3242
3724
  };
3243
- return {
3725
+ return borderWorkflowOverlay({
3244
3726
  render(width: number) {
3245
3727
  renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3246
3728
  const maxOffset = Math.max(0, renderedLines.length - viewport());
@@ -3261,21 +3743,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3261
3743
  else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3262
3744
  tui.requestRender();
3263
3745
  },
3264
- };
3265
- });
3266
- continue;
3267
- }
3268
- if (actionChoice === "View transcript") {
3269
- const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
3270
- if (transcript && transcript !== "Back") await openTranscript(transcript);
3271
- continue;
3272
- }
3273
- if (actionChoice === "Transcript paths") {
3274
- const transcript = await ctx.ui.select("Native Pi transcript paths", [...view.transcripts, "Back"]);
3275
- if (transcript && transcript !== "Back") {
3276
- if (ctx.mode === "tui") await copyArtifact(transcript, "transcript path");
3277
- else ctx.ui.notify(transcript, "info");
3278
- }
3746
+ }, theme);
3747
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3279
3748
  continue;
3280
3749
  }
3281
3750
  const copy = view.copies.get(actionChoice);
@@ -3289,11 +3758,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3289
3758
  let offset = 0;
3290
3759
  let renderedLines: string[] = [];
3291
3760
  const layout = () => {
3292
- const rows = Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24));
3761
+ const rows = Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3293
3762
  const compactControls = rows < 4;
3294
3763
  const titleRows = rows >= 5 ? 1 : 0;
3295
3764
  const hintRows = rows >= 8 ? 1 : 0;
3296
- const separatorRows = rows >= 8 ? 2 : 0;
3765
+ const separatorRows = rows >= 8 ? 1 : 0;
3297
3766
  const controlRows = compactControls ? 1 : options.length;
3298
3767
  const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
3299
3768
  return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
@@ -3302,7 +3771,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3302
3771
  const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
3303
3772
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
3304
3773
  };
3305
- return {
3774
+ return borderWorkflowOverlay({
3306
3775
  render(width: number) {
3307
3776
  renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
3308
3777
  const currentLayout = layout();
@@ -3317,7 +3786,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3317
3786
  ...renderedLines.slice(offset, offset + currentLayout.contentViewport),
3318
3787
  ...(currentLayout.separatorRows ? [""] : []),
3319
3788
  ...controls,
3320
- ...(currentLayout.separatorRows ? [""] : []),
3321
3789
  ...(currentLayout.hintRows ? [hint] : []),
3322
3790
  ];
3323
3791
  },
@@ -3331,8 +3799,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3331
3799
  else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3332
3800
  tui.requestRender();
3333
3801
  },
3334
- };
3335
- });
3802
+ }, theme);
3803
+ }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3336
3804
  if (decision) {
3337
3805
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3338
3806
  if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
@@ -3385,6 +3853,6 @@ function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
3385
3853
  export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
3386
3854
  export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
3387
3855
  export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
3388
- 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";
3389
3857
  export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
3390
- 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";