pi-extensible-workflows 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +18 -11
  2. package/dist/src/agent-execution.d.ts +4 -94
  3. package/dist/src/agent-execution.js +34 -208
  4. package/dist/src/budget.d.ts +38 -0
  5. package/dist/src/budget.js +160 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +60 -8
  8. package/dist/src/doctor-cleanup.d.ts +41 -0
  9. package/dist/src/doctor-cleanup.js +597 -0
  10. package/dist/src/doctor.d.ts +7 -2
  11. package/dist/src/doctor.js +127 -22
  12. package/dist/src/execution.d.ts +17 -0
  13. package/dist/src/execution.js +630 -0
  14. package/dist/src/host.d.ts +101 -0
  15. package/dist/src/host.js +3084 -0
  16. package/dist/src/index.d.ts +13 -634
  17. package/dist/src/index.js +10 -4432
  18. package/dist/src/persistence.d.ts +9 -19
  19. package/dist/src/persistence.js +175 -61
  20. package/dist/src/registry.d.ts +43 -0
  21. package/dist/src/registry.js +279 -0
  22. package/dist/src/session-inspector.js +5 -3
  23. package/dist/src/types.d.ts +692 -0
  24. package/dist/src/types.js +27 -0
  25. package/dist/src/utils.d.ts +32 -0
  26. package/dist/src/utils.js +168 -0
  27. package/dist/src/validation.d.ts +28 -0
  28. package/dist/src/validation.js +832 -0
  29. package/package.json +3 -2
  30. package/skills/pi-extensible-workflows/SKILL.md +69 -34
  31. package/src/agent-execution.ts +37 -189
  32. package/src/budget.ts +75 -0
  33. package/src/cli.ts +47 -7
  34. package/src/doctor-cleanup.ts +337 -0
  35. package/src/doctor.ts +117 -24
  36. package/src/execution.ts +540 -0
  37. package/src/host.ts +2598 -0
  38. package/src/index.ts +14 -3856
  39. package/src/persistence.ts +122 -37
  40. package/src/registry.ts +240 -0
  41. package/src/session-inspector.ts +5 -3
  42. package/src/types.ts +158 -0
  43. package/src/utils.ts +142 -0
  44. package/src/validation.ts +688 -0
package/src/index.ts CHANGED
@@ -1,3858 +1,16 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import { fork, spawn, type ChildProcess } from "node:child_process";
3
- import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
- import { homedir, tmpdir } from "node:os";
6
- import { basename, dirname, extname, join, resolve } from "node:path";
7
- import { fileURLToPath } from "node:url";
8
- import { StringDecoder } from "node:string_decoder";
9
- import * as acorn from "acorn";
10
- import { Script } from "node:vm";
11
- import { Type } from "@earendil-works/pi-ai";
12
- import { Value } from "typebox/value";
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";
17
- import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
18
-
19
- export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
20
- export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
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];
23
- export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
24
- export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
25
- export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
26
- export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
27
- export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
28
- export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
29
- export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
30
- export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
31
- export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
32
- export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
33
- const SETTLED_AGENT_STATES: ReadonlySet<AgentState> = new Set(["completed", "failed", "cancelled"]);
34
- export const ERROR_CODES = [
35
- "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
36
- "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "SHELL_FAILED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
37
- "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
38
- ] as const;
39
-
40
- export type RunState = (typeof RUN_STATES)[number];
41
- export type AgentState = (typeof AGENT_STATES)[number];
42
- export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
43
- export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
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 }
47
- export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
48
- export interface BudgetLimits { soft?: number; hard?: number }
49
- export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
50
- export type WorkflowBudgetPatch = Partial<Record<BudgetDimension, BudgetLimits | { soft?: number | null; hard?: number | null } | null>>;
51
- export interface WorkflowBudgetUsage { tokens: number; costUsd: number; durationMs: number; agentLaunches: number }
52
- export type BudgetEventType = "soft_crossed" | "hard_overrun" | "hard_exhausted" | "adjustment_requested" | "adjustment_approved" | "adjustment_rejected";
53
- export interface BudgetEvent { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; at: number; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
54
- export interface BudgetApprovalRequest { kind: "budget"; proposalId: string; runId: string; consumed: WorkflowBudgetUsage; previous: WorkflowBudget; proposed: WorkflowBudget; budgetVersion: number }
55
- export interface WorkflowErrorShape { code: WorkflowErrorCode; message: string }
56
- export interface WorkflowEventBase { runId: string; sessionId: string; workflowName: string; cwd: string; runDirectory: string; timestamp: number }
57
- export type WorkflowRunStartedEvent = WorkflowEventBase;
58
- export type WorkflowRunResumedEvent = WorkflowEventBase;
59
- export interface WorkflowRunStateChangedEvent extends WorkflowEventBase { previousState: RunState; state: RunState; reason?: string; errorCode?: WorkflowErrorCode }
60
- export interface WorkflowRunCompletedEvent extends WorkflowEventBase { resultPath: string }
61
- export interface WorkflowRunFailedEvent extends WorkflowEventBase { error: WorkflowErrorShape }
62
- export interface WorkflowAgentStateChangedEvent extends WorkflowEventBase { agentId: string; displayLabel: string; role?: string; structuralPath: readonly string[]; parentId?: string; parentBreadcrumb?: string; worktreeOwner?: string; previousState?: AgentState; state: AgentState; attempt: number }
63
- export interface WorkflowPhaseChangedEvent extends WorkflowEventBase { previousPhase?: string; phase: string }
64
- export type WorkflowCheckpointState = "awaiting" | "approved" | "rejected";
65
- export interface WorkflowCheckpointStateChangedEvent extends WorkflowEventBase { name: string; state: WorkflowCheckpointState }
66
- export interface WorkflowBudgetEvent extends WorkflowEventBase { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
67
- export interface ModelSpec { provider: string; model: string; thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" }
68
- export interface WorkflowMetadata { name: string; description?: string }
69
- export interface WorkflowSettings { concurrency: number; modelAliases?: Readonly<Record<string, string>>; disabledAgentResources?: Readonly<AgentResourceExclusions> }
70
- export interface AgentResourceExclusions { skills: readonly string[]; extensions: readonly string[] }
71
- export interface AgentResourcePolicy { globalSettingsPath: string; projectSettingsPath: string; projectTrusted: boolean; global: AgentResourceExclusions; project: AgentResourceExclusions; effective: AgentResourceExclusions; unmatchedSkills: string[]; unmatchedExtensions: string[] }
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[] } }
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 }
74
- export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
75
- export interface WorkflowWorktreeReference { readonly path: string; readonly branch: string }
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 }
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[] }
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 }
82
- export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
83
- export interface WorkflowOrchestrationContext {
84
- agent: (...args: readonly unknown[]) => Promise<JsonValue>;
85
- shell: (command: string, options?: ShellOptions) => Promise<ShellResult>;
86
- prompt: (template: string, values: Readonly<Record<string, JsonValue>>) => string;
87
- parallel: (...args: readonly unknown[]) => Promise<JsonValue>;
88
- pipeline: (...args: readonly unknown[]) => Promise<JsonValue>;
89
- withWorktree: {
90
- (callback: WorkflowWorktreeCallback): Promise<JsonValue>;
91
- (name: string, callback: WorkflowWorktreeCallback): Promise<JsonValue>;
92
- };
93
- checkpoint: (...args: readonly unknown[]) => Promise<boolean>;
94
- phase: (name: string) => void;
95
- log: (message: string) => void;
96
- }
97
- export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
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>;
103
- export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
104
- export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
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>> }
106
- export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
107
-
108
- export class WorkflowError extends Error {
109
- constructor(public readonly code: WorkflowErrorCode, message: string) { super(message); this.name = "WorkflowError"; }
110
- }
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
-
141
- const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
142
- type WorkerErrorShape = WorkflowErrorShape & { authored?: boolean; failedAt?: string };
143
-
144
- function errorText(error: unknown): string { return error && typeof error === "object" && typeof (error as { message?: unknown }).message === "string" ? (error as { message: string }).message : error instanceof Error ? error.message : String(error); }
145
- function errorCode(error: unknown): WorkflowErrorCode | undefined {
146
- if (error instanceof WorkflowError) return ERROR_CODES.includes(error.code) ? error.code : undefined;
147
- if (!error || typeof error !== "object") return undefined;
148
- const code = (error as { code?: unknown }).code;
149
- return typeof code === "string" && ERROR_CODES.includes(code as WorkflowErrorCode) ? code as WorkflowErrorCode : undefined;
150
- }
151
- function markWorkflowAuthored(error: WorkflowError, authored = false): WorkflowError {
152
- if (authored) Object.defineProperty(error, WORKFLOW_AUTHORED_ERROR, { value: true });
153
- return error;
154
- }
155
- function isWorkflowAuthored(error: unknown): boolean { return Boolean(error && typeof error === "object" && WORKFLOW_AUTHORED_ERROR in error); }
156
- function workflowDetail(message: string): string {
157
- const detail = message.trim().replace(new RegExp(`\\b(?:${ERROR_CODES.join("|")})\\b:?\\s*`, "g"), "").replace(/^\s*[A-Z][A-Z0-9_]+:\s*/, "").split("\n").filter((line) => !/^\s*at\s/.test(line)).join("\n").replace(/^Run \S+(?=\s(?:exceeded|is))/i, "Run").replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "the workflow").replace(/^(?:Pi )session \S+(?=\s(?:is|has))/i, "session").replace(/^(Unknown scheduler run|Missing production ownership record|Persisted agent belongs to another run):\s*\S+/i, "$1").replace(/\b(?:runId|sessionId|callSite|occurrence|failedAt|id)[:=]\s*\S+/gi, "").replace(/\s{2,}/g, " ").trim();
158
- return detail || "No further details were provided";
159
- }
160
-
161
- const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string> = {
162
- CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
163
- INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
164
- INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
165
- INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
166
- DUPLICATE_NAME: (detail) => `The workflow contains a duplicate name: ${detail}.`,
167
- INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
168
- REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
169
- GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
170
- MISSING_WORKFLOW: (detail) => `The registered workflow function is unavailable: ${detail}.`,
171
- UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
172
- UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
173
- UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
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}.`,
175
- RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
176
- SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
177
- AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
178
- AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
179
- RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
180
- CANCELLED: (detail) => `The workflow was cancelled: ${detail}.`,
181
- WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
182
- WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
183
- RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
184
- BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
185
- INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
186
- };
187
- export function formatWorkflowFailure(error: unknown): string {
188
- if (isWorkflowAuthored(error)) return errorText(error);
189
- const code = errorCode(error);
190
- if (code) return WORKFLOW_ERROR_PROSE[code](workflowDetail(errorText(error)));
191
- if (error instanceof Error) return error.message || "The workflow failed without an error message.";
192
- return `The workflow failed with value ${String(error)}.`;
193
- }
194
- function workflowErrorFromWorker(error: WorkerErrorShape): WorkflowError {
195
- const code = errorCode(error);
196
- const typed = markWorkflowAuthored(new WorkflowError(code ?? "INTERNAL_ERROR", error.message), error.authored || !code);
197
- return error.failedAt === undefined ? typed : Object.assign(typed, { failedAt: error.failedAt });
198
- }
199
- function asWorkflowError(error: unknown): WorkflowError {
200
- const code = errorCode(error);
201
- return markWorkflowAuthored(error instanceof WorkflowError && code ? error : new WorkflowError(code ?? "INTERNAL_ERROR", errorText(error)), isWorkflowAuthored(error) || !code);
202
- }
203
- function mainAgentError(error: unknown): WorkflowError {
204
- const typed = asWorkflowError(error);
205
- const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
206
- Object.assign(presented, typed);
207
- presented.message = formatWorkflowFailure(typed);
208
- return presented;
209
- }
210
-
211
- export class RunLifecycle {
212
- #state: RunState;
213
- #active = 0;
214
- #waiters: Array<() => void> = [];
215
-
216
- constructor(state: RunState = "running", private readonly changed?: (state: RunState, previousState: RunState, reason?: string) => void | Promise<void>) { this.#state = state; }
217
- get state(): RunState { return this.#state; }
218
-
219
- async enter(): Promise<void> {
220
- while (this.#state === "pausing" || this.#state === "paused" || this.#state === "awaiting_input") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
221
- if (this.#state !== "running") throw new WorkflowError("CANCELLED", `Run is ${this.#state}`);
222
- this.#active += 1;
223
- }
224
-
225
- async leave(): Promise<void> {
226
- if (this.#active > 0) this.#active -= 1;
227
- if (this.#state === "pausing" && this.#active === 0) await this.#set("paused", "pause");
228
- }
229
-
230
- async enterAwaitingInput(): Promise<void> {
231
- while (this.#state === "pausing" || this.#state === "paused") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
232
- if (this.#state === "awaiting_input") return;
233
- if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
234
- await this.#set("awaiting_input", "awaiting_input");
235
- }
236
-
237
- async resolveAwaitingInput(): Promise<void> {
238
- if (this.#state !== "awaiting_input") return;
239
- await this.#set("running", "checkpoint_resolved");
240
- for (const resolve of this.#waiters.splice(0)) resolve();
241
- }
242
-
243
- async pause(): Promise<void> {
244
- if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
245
- await this.#set("pausing", "pause");
246
- if (this.#active === 0) await this.#set("paused", "pause");
247
- }
248
-
249
- async resume(): Promise<void> {
250
- if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
251
- await this.#set("running", "resume");
252
- for (const resolve of this.#waiters.splice(0)) resolve();
253
- }
254
-
255
- async providerPause(): Promise<void> {
256
- await this.leave();
257
- if (this.#state === "running") await this.pause();
258
- await this.enter();
259
- }
260
-
261
- async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
262
- if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
263
- await this.#set(state, reason ?? state);
264
- for (const resolve of this.#waiters.splice(0)) resolve();
265
- }
266
-
267
- async #set(state: RunState, reason?: string): Promise<void> {
268
- const previousState = this.#state;
269
- this.#state = state;
270
- await this.changed?.(state, previousState, reason);
271
- }
272
- }
273
-
274
- export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
275
-
276
- function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
277
- function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
278
- export { object as isObject };
279
- function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
280
- function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
281
- export function validateBudget(value: unknown): WorkflowBudget | undefined {
282
- if (value === undefined) return undefined;
283
- if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
284
- const result: WorkflowBudget = {};
285
- for (const [dimension, raw] of Object.entries(value)) {
286
- if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
287
- if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
288
- if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
289
- const isCost = dimension === "costUsd";
290
- for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
291
- if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
292
- const limits: BudgetLimits = {};
293
- if (raw.soft !== undefined) limits.soft = raw.soft as number;
294
- if (raw.hard !== undefined) limits.hard = raw.hard as number;
295
- if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
296
- }
297
- return Object.freeze(result);
298
- }
299
- export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
300
- if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
301
- const result: WorkflowBudgetPatch = {};
302
- for (const [dimension, raw] of Object.entries(value)) {
303
- if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
304
- if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
305
- if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
306
- const limits: { soft?: number | null; hard?: number | null } = {};
307
- for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
308
- if (raw[key] === null) limits[key] = null;
309
- else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
310
- }
311
- if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
312
- (result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
313
- }
314
- return result;
315
- }
316
- function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
317
- export class WorkflowBudgetRuntime {
318
- readonly #now: () => number;
319
- readonly #onChange: (() => void) | undefined;
320
- readonly #injected = new Set<string>();
321
- readonly #seen = new Set<string>();
322
- #active: boolean;
323
- #activeSince: number | undefined;
324
- #usage: WorkflowBudgetUsage;
325
- #events: BudgetEvent[];
326
- #turnAccounting?: { input: number; output: number; cost: number };
327
- constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) {
328
- this.#now = options.now ?? (() => Date.now());
329
- this.#onChange = options.onChange;
330
- this.#active = options.active ?? true;
331
- this.#activeSince = this.#active ? this.#now() : undefined;
332
- this.#usage = budgetUsage(usage);
333
- this.#events = [...events];
334
- for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type);
335
- }
336
- get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
337
- get events(): readonly BudgetEvent[] { return this.#events; }
338
- get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
339
- checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
340
- beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
341
- beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
342
- afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
343
- #applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void {
344
- this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
345
- this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
346
- this.#evaluate();
347
- if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]);
348
- }
349
- instruction(agentId = "agent"): string | undefined {
350
- if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined;
351
- this.#injected.add(agentId);
352
- return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`;
353
- }
354
- forAgent(agentId: string): AgentBudgetHooks {
355
- let attempt = 0;
356
- let previous: { input: number; output: number; cost: number } | undefined;
357
- return {
358
- beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
359
- beforeTurn: () => { this.beforeTurn(); },
360
- afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
361
- instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
362
- };
363
- }
364
- transition(state: RunState): void {
365
- const active = state === "running";
366
- if (active === this.#active) return;
367
- if (active) { this.#active = true; this.#activeSince = this.#now(); }
368
- else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; }
369
- this.#onChange?.();
370
- }
371
- #syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
372
- #hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
373
- #checkHard(dimensions: readonly BudgetDimension[]): void {
374
- const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
375
- if (!exhausted.length) return;
376
- this.#record("hard_exhausted", exhausted);
377
- const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", ");
378
- throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`);
379
- }
380
- #evaluate(): void {
381
- const budget = this.budget;
382
- if (!budget) return;
383
- const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
384
- if (soft.length) this.#record("soft_crossed", soft);
385
- const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
386
- if (overrun.length) this.#record("hard_overrun", overrun);
387
- }
388
- #record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
389
- recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
390
- snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
391
- }
392
- export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined {
393
- const merged: WorkflowBudget = structuredClone(budget ?? {});
394
- for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
395
- const value = patch[dimension];
396
- if (value === null) { Reflect.deleteProperty(merged, dimension); continue; }
397
- const next: BudgetLimits = { ...(merged[dimension] ?? {}) };
398
- for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; }
399
- if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension);
400
- }
401
- return validateBudget(merged);
402
- }
403
- export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
404
- export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] {
405
- if (!budget) return [];
406
- return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
407
- }
408
- export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
409
- function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; }
410
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
411
- const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
412
- export function parseModelReference(value: string): ModelSpec {
413
- const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
414
- if (!match?.[1] || !match[2]) fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
415
- const thinking = match[3];
416
- if (thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
417
- return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking as NonNullable<ModelSpec["thinking"]> } : {}) };
418
- }
419
-
420
- function aliasError(message: string, settingsPath: string): never { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
421
- export function validateModelAliases(value: unknown, settingsPath = "workflow settings"): Readonly<Record<string, string>> {
422
- if (!object(value)) aliasError("modelAliases must be an object", settingsPath);
423
- const aliases: Record<string, string> = {};
424
- for (const [name, target] of Object.entries(value)) {
425
- if (!MODEL_ALIAS_NAME.test(name)) aliasError(`Invalid model alias name: ${name}`, settingsPath);
426
- if (typeof target !== "string" || !target.trim()) aliasError(`Invalid model alias target for ${name}`, settingsPath);
427
- try { parseModelReference(target); } catch (error) { aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath); }
428
- aliases[name] = target;
429
- }
430
- return Object.freeze(aliases);
431
- }
432
-
433
- function unknownModel(value: string, target: string | undefined, settingsPath?: string): never {
434
- const resolved = target ? ` resolved to ${target}` : "";
435
- const path = settingsPath ? ` (settings: ${settingsPath})` : "";
436
- fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
437
- }
438
-
439
- export function resolveModelReference(value: string, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
440
- const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
441
- if (target !== undefined) {
442
- try { return parseModelReference(target); } catch { unknownModel(value, target, settingsPath); }
443
- }
444
- if (value.includes("/")) return parseModelReference(value);
445
- const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
446
- const thinking = match?.[2];
447
- if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) unknownModel(value, undefined, settingsPath);
448
- const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
449
- if (candidates.length === 1) {
450
- const parsed = parseModelReference(candidates[0] as string);
451
- return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
452
- }
453
- unknownModel(value, undefined, settingsPath);
454
- }
455
-
456
- function modelCapability(value: string, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string {
457
- const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
458
- return `${parsed.provider}/${parsed.model}`;
459
- }
460
-
461
- function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[] {
462
- return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
463
- }
464
-
465
- export interface CheckpointInput { name: string; prompt: string; context: JsonValue }
466
- export function validateCheckpoint(value: unknown): CheckpointInput {
467
- if (!object(value) || Object.keys(value).some((key) => !["name", "prompt", "context"].includes(key)) || typeof value.name !== "string" || value.name.trim() === "" || typeof value.prompt !== "string" || !jsonValue(value.context)) fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
468
- if (Buffer.byteLength(value.prompt) > 1024) fail("INVALID_METADATA", "checkpoint prompt exceeds 1024 UTF-8 bytes");
469
- if (Buffer.byteLength(JSON.stringify(value.context)) > 4096) fail("INVALID_METADATA", "checkpoint context exceeds 4096 UTF-8 bytes");
470
- return { name: value.name, prompt: value.prompt, context: value.context };
471
- }
472
-
473
- export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
474
- export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
475
- const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
476
- function normalizedResourcePath(value: string, settingsPath: string): string {
477
- let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
478
- if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
479
- const resolved = resolve(dirname(settingsPath), expanded);
480
- try { return realpathSync(resolved); } catch { return resolved; }
481
- }
482
- export function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions {
483
- return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
484
- }
485
- function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
486
- if (value === undefined) return undefined;
487
- const base = `${settingsPath}.disabledAgentResources`;
488
- if (!object(value)) fail(errorCode, `${base} must be an object`);
489
- for (const key of Object.keys(value)) if (key !== "skills" && key !== "extensions") fail(errorCode, `${base}.${key} is not supported`);
490
- const normalized: { skills: string[]; extensions: string[] } = { skills: [], extensions: [] };
491
- for (const kind of ["skills", "extensions"] as const) {
492
- const entries = value[kind];
493
- if (entries === undefined) continue;
494
- if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
495
- const seen = new Set<string>();
496
- for (const [index, entry] of entries.entries()) {
497
- if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
498
- let selector = entry.trim();
499
- if (kind === "extensions") {
500
- try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
501
- }
502
- if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
503
- }
504
- }
505
- return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
506
- }
507
- export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
508
- let parsed: unknown;
509
- try { parsed = JSON.parse(readFileSync(path, "utf8")); }
510
- catch (error) {
511
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
512
- fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
513
- }
514
- if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
515
- const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
516
- const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
517
- if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
518
- const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
519
- if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
520
- const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
521
- const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
522
- return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
523
- }
524
- export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
525
- const projectSettingsPath = workflowProjectSettingsPath(cwd);
526
- const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
527
- const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
528
- const effective = mergeAgentResourceExclusions(global, project);
529
- return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
530
- }
531
- export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
532
- const normalized = validateModelAliases(aliases, path);
533
- let parsed: Record<string, unknown> = {};
534
- try {
535
- loadSettings(path);
536
- parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
537
- } catch (error) {
538
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
539
- }
540
- mkdirSync(dirname(path), { recursive: true });
541
- atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
542
- }
543
-
544
- export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
545
- if (!strict) {
546
- if (!content.startsWith("---\n")) return { prompt: content };
547
- const end = content.indexOf("\n---", 4);
548
- if (end < 0) return { prompt: content };
549
- const meta: Record<string, string> = {};
550
- for (const line of content.slice(4, end).split("\n")) {
551
- const match = /^(model|thinking|tools|description)\s*:\s*(.+)$/.exec(line.trim());
552
- if (match?.[1] && match[2]) meta[match[1]] = match[2].trim();
553
- }
554
- const tools = meta.tools ? meta.tools.replace(/^\[|\]$/g, "").split(",").map((tool) => tool.trim().replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "")).filter(Boolean) : undefined;
555
- const thinking = meta.thinking?.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
556
- if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)) fail("INVALID_METADATA", `Invalid role thinking level: ${thinking}`);
557
- const definition: AgentDefinition = { prompt: content.slice(end + 4).replace(/^\n/, "") };
558
- if (meta.model) definition.model = meta.model.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
559
- if (meta.description) definition.description = meta.description.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
560
- if (thinking) definition.thinking = thinking as NonNullable<AgentDefinition["thinking"]>;
561
- if (tools) definition.tools = tools;
562
- return definition;
563
- }
564
- const normalized = content.replace(/\r\n?/g, "\n");
565
- if (normalized.startsWith("---\n") && normalized.indexOf("\n---", 3) < 0) fail("INVALID_METADATA", "Role frontmatter is missing its closing delimiter");
566
- let parsed: ReturnType<typeof parseFrontmatter>;
567
- try { parsed = parseFrontmatter(content); }
568
- catch (error) { fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`); }
569
- if (!object(parsed.frontmatter)) fail("INVALID_METADATA", "Role frontmatter must be an object");
570
- const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
571
- if (model !== undefined && (typeof model !== "string" || model.trim() === "")) fail("INVALID_METADATA", "Role model must be a non-empty string");
572
- if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))) fail("INVALID_METADATA", `Invalid role thinking level: ${typeof thinking === "string" ? thinking : typeof thinking}`);
573
- if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description))) fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
574
- if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === ""))) fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
575
- const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
576
- return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking as NonNullable<AgentDefinition["thinking"]> } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => (tool as string).trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
577
- }
578
-
579
- const ROLE_DIRECTORY = "pi-extensible-workflows";
580
-
581
- export function workflowRoleDirectories(agentDir = getAgentDir()): readonly string[] {
582
- return [join(agentDir, ROLE_DIRECTORY, "roles")];
583
- }
584
-
585
- function projectRoleDirectories(root: string): readonly string[] {
586
- return [join(root, ROLE_DIRECTORY, "roles")];
587
- }
588
-
589
- function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
590
- try {
591
- return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
592
- .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
593
- .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
594
- } catch (error) {
595
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
596
- throw error;
597
- }
598
- }
599
-
600
- function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefinition> {
601
- return Object.fromEntries(dirs.flatMap((dir) => Object.entries(readAgentDefinitions(dir))));
602
- }
603
-
604
- export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
605
- return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
606
- }
607
- function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>, aliases: Readonly<Record<string, string>> = {}, knownModels = availableModels, settingsPath?: string): void {
608
- for (const role of roles) {
609
- const definition = definitions[role];
610
- if (!definition) continue;
611
- if (definition.model !== undefined) {
612
- const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
613
- if (!availableModels.has(resolved)) {
614
- if (Object.prototype.hasOwnProperty.call(aliases, definition.model)) unknownModel(definition.model, resolved, settingsPath);
615
- fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
616
- }
617
- }
618
- const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
619
- if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
620
- }
621
- }
622
-
623
- function validateWorkflowMetadata(value: unknown): WorkflowMetadata {
624
- if (!object(value) || typeof value.name !== "string" || value.name.trim() === "") fail("INVALID_METADATA", "Workflow metadata requires a non-empty name");
625
- if (value.description !== undefined && (typeof value.description !== "string" || value.description.trim() === "")) fail("INVALID_METADATA", "Workflow description must be a non-empty string when provided");
626
- if (Object.keys(value).some((key) => !["name", "description"].includes(key))) fail("INVALID_METADATA", "Unknown workflow metadata");
627
- return Object.freeze({ name: value.name.trim(), ...(typeof value.description === "string" ? { description: value.description.trim() } : {}) });
628
- }
629
-
630
- function workflowBody(script: string): string {
631
- if (typeof script !== "string" || script.trim() === "") fail("INVALID_SYNTAX", "Workflow script must be non-empty");
632
- try {
633
- const program = acorn.parse(script, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
634
- const first = program.body[0];
635
- if (first?.type === "ExportNamedDeclaration" && first.declaration?.type === "VariableDeclaration") {
636
- const declarator = first.declaration.declarations[0];
637
- if (declarator?.id.type === "Identifier" && declarator.id.name === "meta") return script.slice(first.end).replace(/^\s*/, "");
638
- }
639
- return script;
640
- } catch (error) { fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`); }
641
- }
642
-
643
- function parseWorkflow(script: string): acorn.Program {
644
- const body = workflowBody(script);
645
- try {
646
- new Script(`(async()=>{${body}\n})`);
647
- return acorn.parse(body, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
648
- } catch (error) { fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`); }
649
- }
650
-
651
- type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
652
-
653
- function astNode(value: unknown): value is acorn.AnyNode {
654
- return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
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
- }
670
- function workflowCalls(program: acorn.Program): WorkflowCall[] {
671
- const calls: WorkflowCall[] = [];
672
- const visit = (node: acorn.AnyNode): void => {
673
- if (workflowCallKind(node)) calls.push(node as WorkflowCall);
674
- for (const child of astChildren(node)) visit(child);
675
- };
676
- visit(program);
677
- return calls.sort((left, right) => left.start - right.start);
678
- }
679
- export type StaticWorkflowExecution = "parallel" | "sequential";
680
- export interface StaticWorkflowScope { kind: "parallel" | "pipeline"; name: string | null; key: string | null }
681
- type StaticWorkflowContext = { execution: StaticWorkflowExecution; structure: readonly StaticWorkflowScope[] };
682
- function workflowCallsWithStructure(program: acorn.Program): Array<{ call: WorkflowCall; execution: StaticWorkflowExecution; structure: readonly StaticWorkflowScope[] }> {
683
- const calls: Array<{ call: WorkflowCall; execution: StaticWorkflowExecution; structure: readonly StaticWorkflowScope[] }> = [];
684
- const visit = (node: acorn.AnyNode, context: StaticWorkflowContext): void => {
685
- let current = context;
686
- if (node.type === "Property" && current.structure.length) {
687
- const scope = current.structure.at(-1);
688
- const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
689
- if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
690
- }
691
- const operation = workflowCallKind(node);
692
- if (operation) {
693
- const call = node as WorkflowCall;
694
- const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
695
- calls.push({ call, execution, structure: current.structure });
696
- for (const [index, argument] of call.arguments.entries()) {
697
- if (argument.type === "SpreadElement") continue;
698
- const scopeKind = operation === "parallel" && index === 1 ? "parallel" : operation === "pipeline" && index === 2 ? "pipeline" : undefined;
699
- visit(argument, scopeKind ? { execution, structure: [...current.structure, { kind: scopeKind, name: staticString(callArgument(call, 0)), key: null }] } : current);
700
- }
701
- return;
702
- }
703
- for (const child of astChildren(node)) visit(child, current);
704
- };
705
- visit(program, { execution: "sequential", structure: [] });
706
- return calls.sort((left, right) => left.call.start - right.call.start);
707
- }
708
- function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
709
- const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
710
- if (node.type === "Identifier" && node.name === name) {
711
- const directCall = parent?.type === "CallExpression" && parent.callee === node;
712
- const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
713
- if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
714
- }
715
- for (const child of astChildren(node)) visit(child, node);
716
- };
717
- visit(program);
718
- }
719
- function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
720
- if (node.type === "Identifier" && node.name === name) return true;
721
- return astChildren(node).some((child) => hasIdentifier(child, name));
722
- }
723
-
724
- const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
725
- const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
726
- const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
727
- const INTERNAL_SHELL_NAME = "__pi_extensible_workflows_shell";
728
-
729
- function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
730
- let previous: acorn.Token | undefined;
731
- let current: acorn.Token | undefined;
732
- for (const token of acorn.tokenizer(source.slice(call.start, call.end), { ecmaVersion: "latest", sourceType: "module" })) {
733
- previous = current;
734
- current = token;
735
- }
736
- return current?.type.label === ")" && previous?.type.label === ",";
737
- }
738
-
739
- function instrumentWorkflow(script: string): string {
740
- const body = workflowBody(script);
741
- if (!body.trim()) return body;
742
- const program = parseWorkflow(body);
743
- if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
744
- if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
745
- if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
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));
748
- const edits = calls.flatMap((call) => {
749
- const callSite = `${String(call.start)}:${String(call.end)}`;
750
- const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
751
- return [
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 },
753
- { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
754
- ];
755
- }).sort((left, right) => right.start - left.start);
756
- let instrumented = body;
757
- for (const edit of edits) instrumented = instrumented.slice(0, edit.start) + edit.text + instrumented.slice(edit.end);
758
- return instrumented;
759
- }
760
-
761
- function literalString(node: acorn.AnyNode | undefined): string | undefined {
762
- return node?.type === "Literal" && typeof node.value === "string" ? node.value : undefined;
763
- }
764
-
765
- function propertyNode(node: acorn.AnyNode | undefined, name: string): acorn.AnyNode | undefined {
766
- if (node?.type !== "ObjectExpression") return undefined;
767
- for (let index = node.properties.length - 1; index >= 0; index -= 1) {
768
- const property = node.properties[index];
769
- if (!property || property.type === "SpreadElement" || property.computed) return undefined;
770
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
771
- if (key === name) return property.value;
772
- }
773
- return undefined;
774
- }
775
-
776
- function stableName(node: acorn.AnyNode | undefined): boolean | undefined {
777
- if (!node) return false;
778
- if (node.type !== "ObjectExpression") {
779
- if (["Literal", "ArrayExpression", "ArrowFunctionExpression", "FunctionExpression", "ClassExpression", "TemplateLiteral", "UnaryExpression", "UpdateExpression", "BinaryExpression"].includes(node.type)) return false;
780
- return undefined;
781
- }
782
- let result: boolean | undefined = false;
783
- for (const property of node.properties) {
784
- if (property.type === "SpreadElement" || property.computed) { result = undefined; continue; }
785
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
786
- if (key !== "name") continue;
787
- const value = literalString(property.value);
788
- result = value === undefined ? property.value.type === "Literal" ? false : undefined : value.trim() !== "";
789
- }
790
- return result;
791
- }
792
-
793
-
794
- function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
795
- if (value === null || typeof value === "boolean" || typeof value === "string") return true;
796
- if (typeof value === "number") return Number.isFinite(value);
797
- if (typeof value !== "object" || seen.has(value)) return false;
798
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
799
- const keys = Reflect.ownKeys(value);
800
- if (keys.some((key) => typeof key !== "string")) return false;
801
- seen.add(value);
802
- const values = Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string]);
803
- const valid = values.every((item) => jsonValue(item, seen));
804
- seen.delete(value);
805
- return valid;
806
- }
807
-
808
- function workflowPrompt(template: string, values: Readonly<Record<string, JsonValue>>): string {
809
- if (typeof template !== "string") fail("INVALID_METADATA", "prompt() template must be a string");
810
- if (!object(values) || Array.isArray(values) || !jsonValue(values)) fail("INVALID_METADATA", "prompt() values must be a plain JSON-compatible object");
811
- const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap((match) => match[1] === undefined ? [] : [match[1]]);
812
- const used = new Set(placeholders);
813
- const keys = Object.keys(values);
814
- const missing = placeholders.find((key) => !Object.prototype.hasOwnProperty.call(values, key));
815
- if (missing) fail("INVALID_METADATA", `Missing prompt value "${missing}"`);
816
- const unused = keys.find((key) => !used.has(key));
817
- if (unused !== undefined) fail("INVALID_METADATA", `Unused prompt value "${unused}"`);
818
- return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key: string | undefined) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key as string] === "string" ? values[key as string] as string : JSON.stringify(values[key as string], null, 2));
819
- }
820
-
821
- function validateSchema(schema: unknown, at = "schema"): asserts schema is JsonSchema {
822
- if (!object(schema) || Object.getPrototypeOf(schema) !== Object.prototype || !jsonValue(schema)) fail("INVALID_SCHEMA", `${at} must be a plain JSON-compatible Schema object`);
823
- if (typeof schema.type !== "string" && !Array.isArray(schema.type) && schema.$ref === undefined && schema.anyOf === undefined && schema.oneOf === undefined && schema.allOf === undefined && schema.const === undefined && schema.enum === undefined) fail("INVALID_SCHEMA", `${at} has no JSON Schema shape`);
824
- if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))) fail("INVALID_SCHEMA", `${at}.required must be an array of strings`);
825
- if (schema.properties !== undefined && !object(schema.properties)) fail("INVALID_SCHEMA", `${at}.properties must be an object`);
826
- }
827
-
828
- const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
829
- function validateAgentOption(key: string, value: unknown, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
830
- switch (key) {
831
- case "label":
832
- if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent label must be a non-empty string");
833
- break;
834
- case "model":
835
- if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent model must be a non-empty string");
836
- if (aliases !== undefined) resolveModelReference(value, aliases, knownModels, settingsPath);
837
- break;
838
- case "thinking":
839
- if (typeof value !== "string" || !parseThinking(value)) fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
840
- break;
841
- case "tools":
842
- if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) fail("INVALID_METADATA", "agent tools must be an array of strings");
843
- break;
844
- case "role":
845
- if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent role must be a non-empty string");
846
- break;
847
- case "outputSchema":
848
- validateSchema(value, "agent outputSchema");
849
- break;
850
- case "retries":
851
- if (!Number.isInteger(value) || (value as number) < 0) fail("INVALID_METADATA", "agent retries must be a non-negative integer");
852
- break;
853
- case "timeoutMs":
854
- if (value !== null && !positiveInteger(value)) fail("INVALID_METADATA", "agent timeoutMs must be null or a positive integer");
855
- break;
856
- }
857
- }
858
- function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue>> {
859
- if (!object(value) || !jsonValue(value)) fail("INVALID_METADATA", "agent options must be a JSON object");
860
- for (const [key, option] of Object.entries(value)) if (AGENT_OPTION_KEYS.has(key)) validateAgentOption(key, option);
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");
862
- return value;
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
- }
876
-
877
- type StaticValue = { known: true; value: unknown } | { known: false };
878
-
879
- function staticValue(node: acorn.AnyNode | undefined): StaticValue {
880
- if (!node) return { known: false };
881
- if (node.type === "Literal") return { known: true, value: node.value };
882
- if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
883
- const argument = staticValue(node.argument);
884
- return argument.known && typeof argument.value === "number" ? { known: true, value: node.operator === "-" ? -argument.value : argument.value } : { known: false };
885
- }
886
- if (node.type === "ArrayExpression") {
887
- const values: unknown[] = [];
888
- for (const element of node.elements) {
889
- if (!element || element.type === "SpreadElement") return { known: false };
890
- const value = staticValue(element);
891
- if (!value.known) return { known: false };
892
- values.push(value.value);
893
- }
894
- return { known: true, value: values };
895
- }
896
- if (node.type === "ObjectExpression") {
897
- const value: Record<string, unknown> = {};
898
- for (const property of node.properties) {
899
- if (property.type === "SpreadElement" || property.computed) return { known: false };
900
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
901
- const child = staticValue(property.value);
902
- if (!key || !child.known) return { known: false };
903
- value[key] = child.value;
904
- }
905
- return { known: true, value };
906
- }
907
- return { known: false };
908
- }
909
-
910
- export interface StaticWorkflowCall {
911
- kind: WorkflowCallKind;
912
- start: number;
913
- end: number;
914
- name: string | null;
915
- prompt: string | null;
916
- model: string | null;
917
- label?: string | null;
918
- role: string | null;
919
- retries?: number | null;
920
- outputSchema?: JsonSchema | null;
921
- options?: Readonly<Record<string, JsonValue>> | null;
922
- optionKeys?: readonly string[];
923
- execution?: StaticWorkflowExecution;
924
- structure?: readonly StaticWorkflowScope[];
925
- }
926
-
927
- function callArgument(call: WorkflowCall, index: number): acorn.AnyNode | undefined {
928
- const argument = call.arguments[index];
929
- return argument?.type === "SpreadElement" ? undefined : argument;
930
- }
931
-
932
- function staticString(node: acorn.AnyNode | undefined): string | null {
933
- const value = staticValue(node);
934
- return value.known && typeof value.value === "string" ? value.value : null;
935
- }
936
-
937
- export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
938
- return workflowCallsWithStructure(parseWorkflow(script)).map(({ call, execution, structure }) => {
939
- const kind = call.callee.name as StaticWorkflowCall["kind"];
940
- const first = callArgument(call, 0);
941
- const options = callArgument(call, 1);
942
- const placement = { execution, structure };
943
- if (kind === "agent" || kind === "conversation") {
944
- const retries = staticValue(propertyNode(options, "retries"));
945
- const outputSchema = staticValue(propertyNode(options, "outputSchema"));
946
- const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
947
- if (property.type === "SpreadElement" || property.computed) return [];
948
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
949
- return key ? [key] : [];
950
- }) : [];
951
- const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; })) as Record<string, JsonValue>;
952
- const base = { ...placement, kind, start: call.start, end: call.end, name: kind === "conversation" ? staticString(first) : null, prompt: kind === "conversation" ? null : staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
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 } : {}) };
954
- }
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 };
957
- return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
958
- });
959
- }
960
-
961
- function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
962
- if (node?.type !== "ObjectExpression") return;
963
- const options = staticValue(node);
964
- if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value as Record<string, unknown>, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
965
- for (const key of AGENT_OPTION_KEYS) {
966
- const value = staticValue(propertyNode(node, key));
967
- if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
968
- }
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
- }
978
-
979
- function validateStaticWithWorktree(call: WorkflowCall): void {
980
- if (call.arguments.some((argument) => argument.type === "SpreadElement")) return;
981
- if (call.arguments.length !== 1 && call.arguments.length !== 2) fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
982
- const callback = call.arguments[call.arguments.length - 1];
983
- if (staticValue(callback).known) fail("INVALID_METADATA", "withWorktree callback must be a function");
984
- if (call.arguments.length === 2) {
985
- const name = staticValue(call.arguments[0]);
986
- if (name.known && (typeof name.value !== "string" || !name.value.trim())) fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
987
- }
988
- }
989
-
990
- export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
991
- export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
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"]);
998
- const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
999
- const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
1000
-
1001
- export class WorkflowRegistry {
1002
- readonly #extensions = new Set<Readonly<WorkflowExtension>>();
1003
- readonly #globals = new Map<string, string>();
1004
- readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
1005
- #frozen = false;
1006
-
1007
- get frozen(): boolean { return this.#frozen; }
1008
- freeze(): void { this.#frozen = true; }
1009
-
1010
- register(extension: WorkflowExtension): void {
1011
- if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
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");
1014
- const functions = extension.functions ?? {};
1015
- const variables = extension.variables ?? {};
1016
- const agentSetupHooks = extension.agentSetupHooks ?? {};
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");
1018
- const names = [...Object.keys(functions), ...Object.keys(variables)];
1019
- if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
1020
- for (const name of names) {
1021
- if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
1022
- if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
1023
- if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
1024
- }
1025
- for (const [name, fn] of Object.entries(functions)) {
1026
- if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
1027
- validateSchema(fn.input, `${name} input`);
1028
- validateSchema(fn.output, `${name} output`);
1029
- if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
1030
- }
1031
- for (const [name, variable] of Object.entries(variables)) {
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}`);
1033
- validateSchema(variable.schema, `${name} schema`);
1034
- }
1035
- for (const [name, hook] of Object.entries(agentSetupHooks)) {
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}`);
1037
- if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
1038
- }
1039
- const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
1040
- this.#extensions.add(stored);
1041
- for (const name of names) this.#globals.set(name, name);
1042
- for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
1043
- }
1044
-
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;
1050
- }
1051
-
1052
- functions(): Readonly<Record<string, WorkflowFunction>> {
1053
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
1054
- }
1055
-
1056
- catalog(): WorkflowCatalog {
1057
- const functions: WorkflowCatalogFunction[] = [];
1058
- const variables: WorkflowCatalogVariable[] = [];
1059
- for (const extension of this.#extensions) {
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) });
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) });
1062
- }
1063
- let aliases: Readonly<Record<string, string>> | undefined;
1064
- try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
1065
- const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
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}` } });
1083
- }
1084
-
1085
- globals(): Readonly<Record<string, { name: string }>> {
1086
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
1087
- }
1088
-
1089
- async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
1090
- const fn = this.function(name);
1091
- if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
1092
- const replayed = journal.get(path);
1093
- if (replayed !== undefined) {
1094
- if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
1095
- return structuredClone(replayed);
1096
- }
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 }));
1098
- if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
1099
- const stored = structuredClone(result);
1100
- journal.put(path, stored);
1101
- return structuredClone(stored);
1102
- }
1103
-
1104
- variables(): readonly { name: string; variable: WorkflowVariable }[] {
1105
- return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
1106
- }
1107
- agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
1108
- return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
1109
- }
1110
- }
1111
- type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
1112
- interface WorkflowRegistryHost { api: WorkflowRegistryApi }
1113
- const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
1114
- const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
1115
- function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistryApi {
1116
- return {
1117
- get frozen() { return registry.frozen; },
1118
- freeze: () => { registry.freeze(); },
1119
- register: (extension) => { registry.register(extension); },
1120
- function: (name) => registry.function(name),
1121
- functions: () => registry.functions(),
1122
- catalog: () => registry.catalog(),
1123
- catalogIndex: () => registry.catalogIndex(),
1124
- catalogDetail: (name) => registry.catalogDetail(name),
1125
- globals: () => registry.globals(),
1126
- invokeFunction: (...args) => registry.invokeFunction(...args),
1127
- variables: () => registry.variables(),
1128
- agentSetupHooks: () => registry.agentSetupHooks(),
1129
- };
1130
- }
1131
- function workflowRegistryHost(): WorkflowRegistryHost {
1132
- return globalRegistry[WORKFLOW_REGISTRY_KEY] ??= { api: createWorkflowRegistryApi(new WorkflowRegistry()) };
1133
- }
1134
- function resetWorkflowRegistry(): void {
1135
- workflowRegistryHost().api = createWorkflowRegistryApi(new WorkflowRegistry());
1136
- }
1137
- function beginWorkflowExtensionLoading(): void {
1138
- if (workflowRegistryHost().api.frozen) resetWorkflowRegistry();
1139
- }
1140
- function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().api; }
1141
- beginWorkflowExtensionLoading();
1142
- export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
1143
- export function workflowCatalog(): WorkflowCatalog { return loadingRegistry().catalog(); }
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(); }
1147
-
1148
-
1149
- export function formatWorkflowPreview(args: { script?: unknown; workflow?: unknown; name?: unknown; description?: unknown }): string {
1150
- const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : typeof args.workflow === "string" && args.workflow.trim() ? args.workflow : "workflow";
1151
- if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered function" : ""}`;
1152
- return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
1153
- }
1154
- export const WORKFLOW_TOOL_LABEL = "Workflow";
1155
- export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
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.";
1157
- export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
1158
- name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
1159
- description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
1160
- script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
1161
- workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
1162
- args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
1163
- foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
1164
- concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
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" })),
1167
- });
1168
-
1169
- function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
1170
- if (!node) return false;
1171
- if (node.type !== "ObjectExpression") return true;
1172
- for (let index = node.properties.length - 1; index >= 0; index -= 1) {
1173
- const property = node.properties[index];
1174
- if (!property || property.type === "SpreadElement" || property.computed) return true;
1175
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
1176
- if (key === "role") return literalString(property.value) === undefined;
1177
- }
1178
- return false;
1179
- }
1180
-
1181
- export function preflight(script: string, capabilities: PreflightCapabilities, schemas: readonly unknown[] = [], metadata: WorkflowMetadata = { name: "workflow" }): PreflightResult {
1182
- const checkedMetadata = validateWorkflowMetadata(metadata);
1183
- const program = parseWorkflow(script);
1184
- if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1185
- if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
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`);
1188
- validateDirectPrimitiveReferences(program, "withWorktree");
1189
- validateDirectPrimitiveReferences(program, "conversation");
1190
- validateDirectPrimitiveReferences(program, "shell");
1191
- for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
1192
- const calls = workflowCalls(program);
1193
- const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
1194
- for (const call of calls) {
1195
- const operation = call.callee.name;
1196
- if (operation === "agent" || operation === "conversation") {
1197
- if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2)) fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
1198
- validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1199
- }
1200
- if (operation === "withWorktree") validateStaticWithWorktree(call);
1201
- if (operation === "shell") validateStaticShellOptions(call);
1202
- if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
1203
- if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
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");
1205
- if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
1206
- }
1207
- const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
1208
- const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
1209
- const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
1210
- for (const [index, schema] of staticSchemas.entries()) validateSchema(schema, `agent outputSchema[${String(index)}]`);
1211
- const checkedSchemas = [...schemas, ...staticSchemas];
1212
- const modelRefs = agentCalls.flatMap((call) => { const requested = literalString(propertyNode(call.arguments[1], "model")); return requested === undefined ? [] : [{ requested, resolved: modelCapability(requested, capabilities.modelAliases, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath) }]; });
1213
- const models = modelRefs.map(({ resolved }) => resolved);
1214
- const tools = agentCalls.flatMap((call) => {
1215
- const value = propertyNode(call.arguments[1], "tools");
1216
- return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
1217
- });
1218
- const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
1219
- const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
1220
- if (missingModel) {
1221
- if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested)) unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
1222
- fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
1223
- }
1224
- const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
1225
- if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
1226
- const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
1227
- if (missingType) fail("UNKNOWN_AGENT_TYPE", `Unknown agent type: ${missingType}`);
1228
- return Object.freeze({ metadata: deepFreeze(checkedMetadata), referenced: deepFreeze({ phases, models, tools, agentTypes }), schemas: deepFreeze(checkedSchemas) as readonly JsonSchema[], dynamicAgentRoles });
1229
- }
1230
-
1231
- export interface WorkflowValidationParameters {
1232
- name?: string;
1233
- description?: string;
1234
- script?: string;
1235
- workflow?: string;
1236
- args?: unknown;
1237
- }
1238
-
1239
- export interface WorkflowValidationContext {
1240
- cwd: string;
1241
- projectTrusted: boolean;
1242
- availableModels: ReadonlySet<string>;
1243
- rootTools: ReadonlySet<string>;
1244
- modelAliases?: Readonly<Record<string, string>>;
1245
- knownModels?: ReadonlySet<string>;
1246
- settingsPath?: string;
1247
- agentDir?: string;
1248
- }
1249
-
1250
-
1251
- export interface ValidatedWorkflowLaunch {
1252
- script: string;
1253
- checked: PreflightResult;
1254
- agentDefinitions: Readonly<Record<string, AgentDefinition>>;
1255
- projectAgentDefinitions: Readonly<Record<string, AgentDefinition>>;
1256
- roleNames: readonly string[];
1257
- functionName?: string;
1258
- }
1259
-
1260
- function functionLaunchScript(name: string): string { return `return await ${name}(args);`; }
1261
-
1262
- export function validateWorkflowLaunch(params: WorkflowValidationParameters, context: WorkflowValidationContext): ValidatedWorkflowLaunch {
1263
- return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
1264
- }
1265
- function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
1266
- if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
1267
- if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
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() : "");
1275
- if (!workflowName) fail("INVALID_METADATA", "Inline workflows require name");
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);
1278
- const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
1279
- const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
1280
- const aliases = context.modelAliases ?? {};
1281
- const knownModels = context.knownModels ?? context.availableModels;
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);
1283
- const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
1284
- validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
1285
- return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, ...(functionName ? { functionName } : {}) };
1286
- }
1287
-
1288
- function deepFreeze<T>(value: T): T {
1289
- if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
1290
- Object.freeze(value);
1291
- for (const child of Object.values(value)) deepFreeze(child);
1292
- }
1293
- return value;
1294
- }
1295
-
1296
- type LaunchSnapshotInput = Omit<LaunchSnapshot, "identityVersion"> & { identityVersion?: number };
1297
-
1298
- export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<LaunchSnapshot> {
1299
- return deepFreeze(structuredClone({ ...input, launchKind: input.launchKind ?? (input.functionName ? "function" : "inline"), identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
1300
- }
1301
-
1302
- export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapshot> {
1303
- return deepFreeze(structuredClone(input));
1304
- }
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
-
1316
- export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
1317
- export const HEARTBEAT_TIMEOUT_MS = 5000;
1318
-
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 }
1321
- export interface WorkflowBridge {
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>;
1324
- checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
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>>;
1327
- functions?: Readonly<Record<string, { name: string }>>;
1328
- variables?: Readonly<Record<string, JsonValue>>;
1329
- phase?: (name: string) => void | Promise<void>;
1330
- log?: (message: string) => void | Promise<void>;
1331
- }
1332
-
1333
- export interface WorkflowExecution { result: Promise<JsonValue>; cancel: () => void }
1334
-
1335
- const OUTCOME_ERRORS = new Set<string>(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
1336
- const WORK_RESULT_BRAND = "__workResult";
1337
-
1338
- const childSource = String.raw`
1339
- "use strict";
1340
- const { AsyncLocalStorage } = require("node:async_hooks");
1341
- const vm = require("node:vm");
1342
- const LIMIT = parseInt(process.argv[2], 10);
1343
- const config = JSON.parse(process.argv[3]);
1344
- for (const key of ["getBuiltinModule","binding","_linkedBinding","dlopen","kill","abort","exit","reallyExit","_kill","umask","chdir","setuid","setgid","seteuid","setegid","setgroups","initgroups"]) {
1345
- if (key in process) process[key] = undefined;
1346
- }
1347
- let nextId = 0;
1348
- let cancelled = false;
1349
- const pending = new Map();
1350
- const inflight = new Set();
1351
- const hasMessage = error => Boolean(error && typeof error === "object" && typeof error.message === "string");
1352
- const errorText = error => hasMessage(error) ? error.message : String(error);
1353
- const errorCode = error => { if (!error || typeof error !== "object") return undefined; const code = error.code; return typeof code === "string" ? code : undefined; };
1354
- const errorAuthored = error => Boolean(error && typeof error === "object" && error.authored === true);
1355
- const workerError = error => { const code = errorCode(error); return { code: code || "INTERNAL_ERROR", message: errorText(error), ...(error && typeof error === "object" && typeof error.failedAt === "string" ? { failedAt: error.failedAt } : {}), ...(errorAuthored(error) || hasMessage(error) && !code ? { authored: true } : {}) }; };
1356
- const workflowError = error => Object.assign(new Error(errorText(error)), workerError(error));
1357
- function send(value) {
1358
- const json = JSON.stringify(value);
1359
- if (json === undefined || Buffer.byteLength(json) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
1360
- process.send(json);
1361
- }
1362
- function rpc(method, args) {
1363
- if (cancelled) throw Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" });
1364
- const id = ++nextId;
1365
- send({ type: "rpc", id, method, args });
1366
- const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
1367
- inflight.add(promise);
1368
- void promise.then(() => inflight.delete(promise), () => inflight.delete(promise));
1369
- return promise;
1370
- }
1371
- process.on("message", raw => {
1372
- let message;
1373
- try {
1374
- if (typeof raw !== "string" || Buffer.byteLength(raw) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
1375
- message = JSON.parse(raw);
1376
- } catch (error) { send({ type: "error", error: workerError(error) }); return; }
1377
- if (message.type === "cancel") { cancelled = true; for (const { reject } of pending.values()) reject(Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" })); pending.clear(); return; }
1378
- if (message.type !== "rpcResult") return;
1379
- const request = pending.get(message.id);
1380
- if (!request) return;
1381
- pending.delete(message.id);
1382
- if (message.ok) request.resolve(message.value);
1383
- else request.reject(workflowError(message.error));
1384
- });
1385
- const heartbeat = setInterval(() => send({ type: "heartbeat" }), 1000);
1386
- send({ type: "heartbeat" });
1387
- const BRAND = "${WORK_RESULT_BRAND}";
1388
- const workError = (code, message) => Object.assign(new Error(message), { code });
1389
- const isBranded = value => value && typeof value === "object" && value[BRAND] === true;
1390
- const unwrap = result => {
1391
- if (!isBranded(result)) return result;
1392
- if (result.ok) return result.value;
1393
- throw Object.assign(workflowError(result.error), { failedAt: result.failedAt });
1394
- };
1395
- const named = (value, kind) => { if (typeof value !== "string" || !value.trim()) throw workError("INVALID_METADATA", kind + " requires a stable explicit name"); return value; };
1396
- const path = (...names) => names.map(encodeURIComponent).join("/");
1397
- const inheritedAgentPath = new AsyncLocalStorage();
1398
- const agentOccurrences = new Map();
1399
- const shellOccurrences = new Map();
1400
- const conversationOccurrences = new Map();
1401
- const worktreeOwners = new AsyncLocalStorage();
1402
- const worktreeOccurrences = new Map();
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"); };
1405
- const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
1406
- const internalWithWorktree = async (...values) => {
1407
- const callSite = values.pop();
1408
- if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing withWorktree call-site identity");
1409
- if (values.length !== 1 && values.length !== 2) throw workError("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
1410
- const callback = values[values.length - 1];
1411
- if (typeof callback !== "function") throw workError("INVALID_METADATA", "withWorktree callback must be a function");
1412
- let owner;
1413
- if (values.length === 2) {
1414
- if (typeof values[0] !== "string" || !values[0].trim()) throw workError("INVALID_METADATA", "withWorktree name must be a non-empty string");
1415
- owner = path("worktree", "named", values[0].trim());
1416
- } else {
1417
- const inherited = inheritedAgentPath.getStore() || [];
1418
- const occurrenceKey = JSON.stringify([inherited, callSite]);
1419
- const occurrence = (worktreeOccurrences.get(occurrenceKey) || 0) + 1;
1420
- worktreeOccurrences.set(occurrenceKey, occurrence);
1421
- owner = path("worktree", "unnamed", ...inherited, "callsite:" + callSite, "occurrence:" + String(occurrence));
1422
- }
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 })));
1426
- };
1427
- const internalConversation = (...values) => {
1428
- const callSite = values.pop();
1429
- if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
1430
- const name = values[0];
1431
- if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
1432
- const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
1433
- if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
1434
- const inherited = inheritedAgentPath.getStore() || [];
1435
- const occurrenceKey = JSON.stringify([inherited, callSite, name]);
1436
- const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
1437
- conversationOccurrences.set(occurrenceKey, occurrence);
1438
- const fixedOptions = structuredClone(conversationOptions);
1439
- const defaultTimeout = fixedOptions.timeoutMs;
1440
- const defaultRetries = fixedOptions.retries;
1441
- delete fixedOptions.timeoutMs;
1442
- delete fixedOptions.retries;
1443
- const worktreeOwner = worktreeOwners.getStore();
1444
- let turn = 0;
1445
- let active = false;
1446
- return Object.freeze({
1447
- run(prompt, turnOptions = {}) {
1448
- if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
1449
- if (!turnOptions || typeof turnOptions !== "object" || Array.isArray(turnOptions) || Object.keys(turnOptions).some(key => key !== "timeoutMs" && key !== "retries")) throw workError("INVALID_METADATA", "conversation.run options only support timeoutMs and retries");
1450
- if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
1451
- active = true;
1452
- const turnNumber = turn + 1;
1453
- const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
1454
- const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
1455
- const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
1456
- Object.defineProperties(result, {
1457
- toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
1458
- toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1459
- [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1460
- });
1461
- return result;
1462
- },
1463
- });
1464
- };
1465
- const internalAgent = (...values) => {
1466
- const callSite = values.pop();
1467
- if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
1468
- const inherited = inheritedAgentPath.getStore() || [];
1469
- // ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
1470
- const occurrenceKey = JSON.stringify([inherited, callSite]);
1471
- const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
1472
- agentOccurrences.set(occurrenceKey, occurrence);
1473
- const options = values.length < 2 || values[1] === undefined ? {} : values[1];
1474
- const worktreeOwner = worktreeOwners.getStore();
1475
- const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
1476
- const result = rpc("agent", [values[0], options, identity]).then(unwrap);
1477
- Object.defineProperties(result, {
1478
- toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
1479
- toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
1480
- [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
1481
- });
1482
- return result;
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;
1509
- const agent = rejectAgent;
1510
- const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
1511
- const plainPromptObject = value => {
1512
- const proto = Object.getPrototypeOf(value);
1513
- return proto === null || Object.getPrototypeOf(proto) === null && Object.prototype.hasOwnProperty.call(proto, "constructor") && typeof proto.constructor === "function" && Function.prototype.toString.call(proto.constructor) === Function.prototype.toString.call(Object);
1514
- };
1515
- const promptValue = (value, at, seen) => {
1516
- if (value === null || typeof value === "string" || typeof value === "boolean") return;
1517
- if (typeof value === "number") { if (!Number.isFinite(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a finite number"); return; }
1518
- if (typeof value !== "object") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot be " + typeof value);
1519
- if (typeof value.then === "function") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" is a Promise or thenable; await it before calling prompt()");
1520
- if (!Array.isArray(value) && !plainPromptObject(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a plain object");
1521
- if (seen.has(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a cycle");
1522
- const keys = Reflect.ownKeys(value);
1523
- const symbol = keys.find(key => typeof key === "symbol");
1524
- if (symbol) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a symbol key");
1525
- seen.add(value);
1526
- if (Array.isArray(value)) {
1527
- for (let index = 0; index < value.length; index += 1) promptProperty(value, String(index), at + "[" + index + "]", seen);
1528
- for (const key of keys) {
1529
- const index = Number(key);
1530
- if (key !== "length" && !(Number.isInteger(index) && index >= 0 && index < value.length && String(index) === key)) promptProperty(value, key, promptPath(at, key), seen);
1531
- }
1532
- } else for (const key of keys) promptProperty(value, key, promptPath(at, key), seen);
1533
- seen.delete(value);
1534
- };
1535
- const promptProperty = (value, key, at, seen) => {
1536
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
1537
- if (descriptor && (descriptor.get || descriptor.set)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot use getters or setters");
1538
- promptValue(descriptor && descriptor.value, at, seen);
1539
- };
1540
- const prompt = (template, values) => {
1541
- if (typeof template !== "string") throw workError("INVALID_METADATA", "prompt() template must be a string");
1542
- if (!values || typeof values !== "object" || Array.isArray(values) || !plainPromptObject(values)) throw workError("INVALID_METADATA", "prompt() values must be a plain object");
1543
- const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]);
1544
- const used = new Set(placeholders);
1545
- const keys = Reflect.ownKeys(values);
1546
- const symbol = keys.find(key => typeof key === "symbol");
1547
- if (symbol) throw workError("INVALID_METADATA", "prompt() values must use string keys");
1548
- const missing = placeholders.find(key => !Object.prototype.hasOwnProperty.call(values, key));
1549
- if (missing) throw workError("INVALID_METADATA", "Missing prompt value \"" + missing + "\"");
1550
- const unused = keys.find(key => !used.has(key));
1551
- if (unused !== undefined) throw workError("INVALID_METADATA", "Unused prompt value \"" + unused + "\"");
1552
- for (const key of keys) promptProperty(values, key, key, new Set());
1553
- return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key] === "string" ? values[key] : JSON.stringify(values[key], null, 2));
1554
- };
1555
- const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
1556
- const phase = name => rpc("phase", [name]);
1557
- const log = message => rpc("log", [message]);
1558
- const functionOccurrences = new Map();
1559
- const functionPath = name => {
1560
- const inherited = inheritedAgentPath.getStore() || [];
1561
- const key = JSON.stringify([inherited, name]);
1562
- const occurrence = (functionOccurrences.get(key) || 0) + 1;
1563
- functionOccurrences.set(key, occurrence);
1564
- return path("function", ...inherited, name, String(occurrence));
1565
- };
1566
- const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
1567
- if (values.length !== 1 || !values[0] || typeof values[0] !== "object" || Array.isArray(values[0])) throw workError("RESULT_INVALID", local + " requires exactly one JSON object argument");
1568
- const inherited = inheritedAgentPath.getStore() || [];
1569
- const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
1570
- Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
1571
- return result;
1572
- }])));
1573
- const freeze = value => { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); } return value; };
1574
- const recordEntries = (value, kind) => {
1575
- if (!value || typeof value !== "object" || Array.isArray(value)) throw workError("INVALID_METADATA", kind + " must be a record");
1576
- return Object.entries(value);
1577
- };
1578
- const parallel = async (operationName, tasks) => {
1579
- named(operationName, "parallel");
1580
- const entries = recordEntries(tasks, "parallel tasks");
1581
- for (const [name, run] of entries) {
1582
- named(name, "parallel task");
1583
- if (typeof run !== "function") throw workError("INVALID_METADATA", "parallel task values must be run functions");
1584
- }
1585
- const results = await Promise.all(entries.map(async ([name, run]) => {
1586
- try {
1587
- const parent = inheritedAgentPath.getStore() || [];
1588
- return { name, ok: true, value: await inheritedAgentPath.run([...parent, operationName, name], run) };
1589
- } catch (error) {
1590
- if (errorCode(error) === "CANCELLED") throw error;
1591
- const failedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
1592
- return { name, ok: false, failedAt: failedAt ? path(operationName, name, failedAt) : path(operationName, name), error: workerError(error) };
1593
- }
1594
- }));
1595
- const failure = results.find(result => !result.ok);
1596
- if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
1597
- return Object.fromEntries(results.map(result => [result.name, result.value]));
1598
- };
1599
- const pipeline = async (operationName, items, stages) => {
1600
- named(operationName, "pipeline");
1601
- const itemEntries = recordEntries(items, "pipeline items");
1602
- const stageEntries = recordEntries(stages, "pipeline stages");
1603
- if (!stageEntries.length) throw workError("INVALID_METADATA", "pipeline requires at least one stage");
1604
- for (const [name] of itemEntries) named(name, "pipeline item");
1605
- for (const [stageName, run] of stageEntries) {
1606
- named(stageName, "pipeline stage");
1607
- if (typeof run !== "function") throw workError("INVALID_METADATA", "pipeline stage values must be run functions");
1608
- }
1609
- const results = await Promise.all(itemEntries.map(async ([name, initial]) => {
1610
- let value = initial;
1611
- let failedAt = path(operationName, name);
1612
- try {
1613
- for (const [stageName, run] of stageEntries) {
1614
- failedAt = path(operationName, name, stageName);
1615
- const parent = inheritedAgentPath.getStore() || [];
1616
- value = await inheritedAgentPath.run([...parent, operationName, name, stageName], () => run(value));
1617
- }
1618
- return { name, ok: true, value };
1619
- } catch (error) {
1620
- if (errorCode(error) === "CANCELLED") throw error;
1621
- const nestedFailedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
1622
- return { name, ok: false, failedAt: nestedFailedAt ? path(failedAt, nestedFailedAt) : failedAt, error: workerError(error) };
1623
- }
1624
- }));
1625
- const failure = results.find(result => !result.ok);
1626
- if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
1627
- return Object.fromEntries(results.map(result => [result.name, result.value]));
1628
- };
1629
- const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1630
- const sandbox = { agent, conversation: internalConversation, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1631
- for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1632
- for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
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;
1634
- const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1635
- const body = config.script;
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))
1637
- .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1638
- .catch(error => send({ type: "error", error: workerError(error) }))
1639
- .finally(() => clearInterval(heartbeat));
1640
- `;
1641
-
1642
- function encoded(value: unknown): string {
1643
- if (!jsonValue(value)) fail("RPC_LIMIT_EXCEEDED", "RPC values must be JSON-compatible");
1644
- const json = JSON.stringify(value);
1645
- if (Buffer.byteLength(json) > RPC_LIMIT_BYTES) fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
1646
- return json;
1647
- }
1648
-
1649
- function encodedRpcResult(id: number, value: JsonValue): string {
1650
- return encoded({ type: "rpcResult", id, ok: true, value });
1651
- }
1652
-
1653
- function readAgentIdentity(value: unknown): AgentIdentity {
1654
- if (!object(value)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1655
- const structuralPath = value.structuralPath;
1656
- const callSite = value.callSite;
1657
- const occurrence = value.occurrence;
1658
- const worktreeOwner = value.worktreeOwner;
1659
- const parentBreadcrumb = value.parentBreadcrumb;
1660
- const conversation = value.conversation;
1661
- const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
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");
1663
- return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1664
- }
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
- }
1670
- function agentIdentityPath(identity: AgentIdentity): string {
1671
- return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
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
- }
1676
- function conversationIdentityPath(identity: AgentIdentity): string {
1677
- if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1678
- return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1679
- }
1680
- function conversationTurnPath(identity: AgentIdentity): string {
1681
- if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1682
- return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
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
- }
1688
- function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
1689
- return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
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
- }
1760
- export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
1761
- encoded(args);
1762
- const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
1763
- const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
1764
- const childFile = join(childDir, "child.cjs");
1765
- writeFileSync(childFile, childSource);
1766
- const child: ChildProcess = fork(childFile, [String(RPC_LIMIT_BYTES), config], {
1767
- execArgv: (() => {
1768
- const filtered: string[] = [];
1769
- const skip = new Set(["--input-type", "-e", "--eval", "-p", "--print"]);
1770
- let skipNext = false;
1771
- for (const arg of process.execArgv) {
1772
- if (skipNext) { skipNext = false; continue; }
1773
- if (skip.has(arg) || skip.has(arg.split("=")[0] ?? "")) { if (!arg.includes("=")) skipNext = true; continue; }
1774
- filtered.push(arg);
1775
- }
1776
- return [...filtered, "--max-old-space-size=128", "--permission", `--allow-fs-read=${childDir}`];
1777
- })(),
1778
- stdio: ["ignore", "ignore", "ignore", "ipc"],
1779
- serialization: "advanced",
1780
- });
1781
- const controller = new AbortController();
1782
- let settled = false;
1783
- let rejectResult: (error: WorkflowError) => void = () => undefined;
1784
- let watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
1785
- const result = new Promise<JsonValue>((resolve, reject) => {
1786
- rejectResult = reject;
1787
- child.on("message", (raw: unknown) => {
1788
- try {
1789
- if (typeof raw !== "string" || Buffer.byteLength(raw) > RPC_LIMIT_BYTES) fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
1790
- const message = JSON.parse(raw) as { type?: string; id?: number; method?: string; args?: JsonValue[]; ok?: boolean; value?: JsonValue; error?: WorkerErrorShape };
1791
- if (!jsonValue(message)) fail("RPC_LIMIT_EXCEEDED", "Worker RPC must contain JSON-compatible values");
1792
- if (message.type === "heartbeat") { clearTimeout(watchdog); watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS); return; }
1793
- if (message.type === "result") { encoded(message.value); finish(); resolve(message.value ?? null); return; }
1794
- if (message.type === "error") { finish(); reject(workflowErrorFromWorker(message.error ?? { code: "INTERNAL_ERROR", message: "Worker failed" })); return; }
1795
- if (message.type === "rpc" && message.id !== undefined) void handleRpc(message.id, message.method ?? "", message.args ?? []);
1796
- } catch (error) { stop(error instanceof WorkflowError ? error.code : "INTERNAL_ERROR", error instanceof Error ? error.message : String(error)); }
1797
- });
1798
- child.on("error", (error: Error) => { stop("INTERNAL_ERROR", error.message); });
1799
- child.on("exit", (code) => { if (!settled && code !== 0) stop("INTERNAL_ERROR", `Workflow child exited with code ${String(code)}`); });
1800
- });
1801
- function killChild() {
1802
- if (!child.killed) {
1803
- child.kill("SIGTERM");
1804
- setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 1000).unref();
1805
- }
1806
- }
1807
- function finish() { settled = true; clearTimeout(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
1808
- function stop(code: WorkflowErrorCode, message: string) { if (settled) return; controller.abort(); finish(); rejectResult(new WorkflowError(code, message)); }
1809
- function branded(result: Record<string, JsonValue>): JsonValue { return { ...result, [WORK_RESULT_BRAND]: true }; }
1810
- async function handleRpc(id: number, method: string, values: JsonValue[]) {
1811
- try {
1812
- encoded(values);
1813
- let value: JsonValue = null;
1814
- if (method === "agent") {
1815
- if (!bridge.agent) fail("AGENT_FAILED", "No agent bridge is available");
1816
- if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "agent prompt must be a string");
1817
- const opts = validateAgentOptions(values[1]);
1818
- const identity = readAgentIdentity(values[2]);
1819
- const path = agentIdentityPath(identity);
1820
- const label = typeof opts.label === "string" ? opts.label : typeof opts.role === "string" ? opts.role : "agent";
1821
- try {
1822
- const result = await bridge.agent(values[0], opts, controller.signal, identity);
1823
- value = branded({ name: label, ok: true, value: result ?? null });
1824
- } catch (error) {
1825
- const typed = asWorkflowError(error);
1826
- if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1827
- value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
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;
1835
- } else if (method === "checkpoint") {
1836
- if (!bridge.checkpoint || !object(values[0])) fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
1837
- const name = typeof values[0].name === "string" ? values[0].name : "checkpoint";
1838
- try {
1839
- const result = await bridge.checkpoint(values[0], controller.signal);
1840
- if (typeof result !== "boolean") fail("INTERNAL_ERROR", "checkpoint must return a boolean");
1841
- value = branded({ name, ok: true, value: result ? "approved" : "rejected" });
1842
- } catch (error) {
1843
- const typed = asWorkflowError(error);
1844
- if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1845
- value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1846
- }
1847
- } else if (method === "function") {
1848
- const worktreeOwner = values[3] === undefined || values[3] === null ? undefined : typeof values[3] === "string" && values[3] ? values[3] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
1849
- const structuralPath = values[4] === undefined ? [] : values[4];
1850
- if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim()))) fail("INTERNAL_ERROR", "function structural scope is invalid");
1851
- if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string") fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
1852
- const name = values[0];
1853
- try {
1854
- const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
1855
- value = branded({ name, ok: true, value: result ?? null });
1856
- } catch (error) {
1857
- const typed = asWorkflowError(error);
1858
- if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
1859
- value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
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);
1864
- } else if (method === "phase") {
1865
- if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "phase name must be a string");
1866
- await bridge.phase?.(values[0]);
1867
- } else if (method === "log") {
1868
- if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "log message must be a string");
1869
- await bridge.log?.(values[0]);
1870
- }
1871
- else fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
1872
- encoded(value);
1873
- child.send(encodedRpcResult(id, value));
1874
- } catch (error) {
1875
- const typed = asWorkflowError(error);
1876
- child.send(encoded({ type: "rpcResult", id, ok: false, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } }));
1877
- }
1878
- }
1879
- function cancel() {
1880
- if (settled) return;
1881
- controller.abort();
1882
- child.send(encoded({ type: "cancel" }));
1883
- stop("CANCELLED", "Workflow cancelled");
1884
- }
1885
- if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
1886
- return { result, cancel };
1887
- }
1888
- function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessionFile">): { sessionId: string; sessionFile: string } {
1889
- return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
1890
- }
1891
-
1892
- export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
1893
- await store.updateState((run) => {
1894
- const agent = run.agents.find((candidate) => candidate.id === id);
1895
- if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1896
- const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1897
- const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
1898
- const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
1899
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
1900
- });
1901
- }
1902
-
1903
- export async function persistAgentAttempts(store: RunStore, id: string, attempts: readonly AgentAttempt[]): Promise<void> {
1904
- await store.updateState((run) => {
1905
- const agent = run.agents.find((candidate) => candidate.id === id);
1906
- if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1907
- const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
1908
- const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
1909
- const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
1910
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
1911
- });
1912
- }
1913
-
1914
- type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
1915
-
1916
- type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
1917
- function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
1918
- function agentGroupLabel(agents: readonly AgentRecord[]): string {
1919
- const structural = agents[0]?.structuralPath ?? [];
1920
- const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value): value is string => Boolean(value)))];
1921
- return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
1922
- }
1923
- function agentGroups(agents: readonly AgentRecord[]): AgentGroup[] {
1924
- const byId = new Map(agents.map((agent) => [agent.id, agent]));
1925
- const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
1926
- for (const [index, agent] of agents.entries()) {
1927
- let depth = 0;
1928
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
1929
- const key = agentGroupKey(agent);
1930
- const group = groups.get(key) ?? { agents: [] };
1931
- group.agents.push({ agent, index, depth });
1932
- groups.set(key, group);
1933
- }
1934
- return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
1935
- }
1936
- function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => string): string[] {
1937
- const groups = agentGroups(agents);
1938
- const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
1939
- return groups.flatMap((group) => [
1940
- ...(grouped ? [` ${group.label}`] : []),
1941
- ...group.entries.map((entry) => render(entry, grouped)),
1942
- ]);
1943
- }
1944
- export function formatWorkflowProgress(run: PersistedRun, spinner = "◇"): string {
1945
- const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
1946
- const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
1947
- if (run.phase) lines.push(` Phase: ${run.phase}`);
1948
- lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
1949
- const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
1950
- lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1951
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
1952
- const indent = " ".repeat((grouped ? 2 : 1) + depth);
1953
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
1954
- const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1955
- return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
1956
- }));
1957
- return lines.join("\n");
1958
- }
1959
-
1960
- function workflowToolUpdate(run: PersistedRun): WorkflowToolUpdate {
1961
- return { content: [{ type: "text", text: formatWorkflowProgress(run) }], details: { runId: run.id, run } };
1962
- }
1963
-
1964
- const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1965
-
1966
- function textBlock(text: string) {
1967
- return {
1968
- render(width: number) {
1969
- return text.split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`);
1970
- },
1971
- invalidate() {},
1972
- };
1973
- }
1974
-
1975
- function workflowProgressBlock(run: PersistedRun) {
1976
- return {
1977
- render(width: number) {
1978
- const frame = workflowSpinner[Math.floor(Date.now() / 80) % workflowSpinner.length] ?? "◇";
1979
- return formatWorkflowProgress(run, frame).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`);
1980
- },
1981
- invalidate() {},
1982
- };
1983
- }
1984
- export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
1985
- const usage = budgetUsage(run.usage);
1986
- if (!run.budget || !Object.keys(run.budget).length) return ["Budget: unlimited"];
1987
- const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
1988
- for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) {
1989
- const limits = run.budget[dimension];
1990
- if (!limits || (limits.soft === undefined && limits.hard === undefined)) continue;
1991
- const limit = limits.hard ?? limits.soft;
1992
- const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
1993
- const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
1994
- lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
1995
- }
1996
- const events = run.budgetEvents ?? [];
1997
- if (events.length) lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
1998
- return lines;
1999
- }
2000
-
2001
- function formatCompactBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
2002
- if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined)) return [];
2003
- return formatBudgetStatus(run);
2004
- }
2005
-
2006
- const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
2007
-
2008
- function navigatorAttentionSort<T extends { loaded: { run: PersistedRun } }>(entries: readonly T[]): T[] {
2009
- return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
2010
- }
2011
-
2012
- function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run: PersistedRun } }[]): string[] {
2013
- const nameCount = new Map<string, number>();
2014
- for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
2015
- return entries.map(({ store, loaded: { run } }) => {
2016
- const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
2017
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
2018
- const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
2019
- const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
2020
- const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
2021
- return `${glyph} ${run.workflowName}${suffix} ${run.state} ${run.phase ?? ""} ${String(done)}/${String(run.agents.length)} agents${costStr}`;
2022
- });
2023
- }
2024
-
2025
- function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>): string {
2026
- const name = agent.label ?? agent.name;
2027
- const parts: string[] = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
2028
- const seen = new Set<string>([agent.id]);
2029
- for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
2030
- if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
2031
- seen.add(parentId);
2032
- const parent = byId.get(parentId);
2033
- if (parent) parts.push(parent.label ?? parent.name);
2034
- else break;
2035
- }
2036
- parts.push(name);
2037
- return parts.length > 1 ? parts.join(" > ") : name;
2038
- }
2039
-
2040
- function formatAgentActivity(agent: AgentRecord, spinner: string): string {
2041
- if (agent.activity?.kind === "reasoning") return `${spinner} reasoning`;
2042
- if (agent.activity?.kind === "text") return `${spinner} responding`;
2043
- if (agent.activity?.kind === "tool") return `${spinner} ${agent.activity.text}`;
2044
- const tool = [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running");
2045
- return tool ? `${spinner} ${tool.name}` : "";
2046
- }
2047
-
2048
- function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
2049
- const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
2050
- return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
2051
- }
2052
-
2053
-
2054
- export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
2055
- void worktrees;
2056
- const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
2057
- const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
2058
- const hasAccounting = run.agents.some((a) => a.accounting);
2059
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
2060
- const header = `${glyph} ${run.workflowName}`;
2061
- const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
2062
- const lines = [header, meta, ...formatCompactBudgetStatus(run)];
2063
- if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
2064
- if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
2065
- lines.push("");
2066
- const byId = new Map(run.agents.map((a) => [a.id, a]));
2067
- const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
2068
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
2069
- const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
2070
- const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
2071
- const indent = " ".repeat((grouped ? 2 : 1) + depth);
2072
- const result = [`${indent}${icon} ${breadcrumb} · ${agent.state}${tokens ? ` · ${tokens}` : ""}`];
2073
- if (agent.state === "failed" && agent.attemptDetails?.length) {
2074
- const last = agent.attemptDetails[agent.attemptDetails.length - 1];
2075
- if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
2076
- }
2077
- const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
2078
- if (activity) result.push(`${indent} ${activity}`);
2079
- return result.join("\n");
2080
- };
2081
- lines.push(...renderGroupedAgents(run.agents, render));
2082
- if (checkpoints.length) { lines.push(""); for (const cp of checkpoints) lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`); }
2083
- return lines.join("\n");
2084
- }
2085
-
2086
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
2087
- const { run, snapshot } = loaded;
2088
- const lines = [
2089
- `Workflow: ${run.workflowName}`,
2090
- `Run: ${run.id}`,
2091
- `Status: ${run.state}`,
2092
- `Phase: ${run.phase ?? "(none)"}`,
2093
- `Launch cwd: ${run.cwd}`,
2094
- ...formatCompactBudgetStatus(run),
2095
- `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
2096
- ];
2097
- if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
2098
- if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
2099
- const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
2100
- if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
2101
- lines.push("Agents / ownership:");
2102
- if (!run.agents.length) lines.push(" (none)");
2103
- const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
2104
- lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
2105
- const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
2106
- const role = agent.role ? ` role=${agent.role}` : "";
2107
- const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
2108
- const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${String(agent.accounting.cost)}` : "";
2109
- const indent = " ".repeat((grouped ? 2 : 1) + depth);
2110
- const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
2111
- for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
2112
- for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
2113
- return result.join("\n");
2114
- }));
2115
- lines.push("Checkpoints:");
2116
- if (!checkpoints.length) lines.push(" (none)");
2117
- for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
2118
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
2119
- lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
2120
- return lines.join("\n");
2121
- }
2122
- function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
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");
2125
- }
2126
-
2127
- const DELIVERY_LIMIT_BYTES = 4 * 1024;
2128
- const WORKFLOW_LOG_ENTRY = "workflow-log";
2129
- interface WorkflowLogEntry { workflowName: string; message: string }
2130
-
2131
- function completionDelivery(name: string, value: JsonValue, resultPath: string, worktrees: readonly { branch: string; path: string }[]): string {
2132
- const locations = worktrees.length ? ` Changes: ${worktrees.map(({ branch, path }) => `${branch} (${path})`).join(", ")}.` : "";
2133
- const message = `Workflow ${name} completed: ${JSON.stringify(value)}${locations}`;
2134
- if (Buffer.byteLength(message) <= DELIVERY_LIMIT_BYTES) return message;
2135
- const suffix = `... Full result: ${resultPath}${locations}`;
2136
- const suffixBytes = Buffer.byteLength(suffix);
2137
- if (suffixBytes >= DELIVERY_LIMIT_BYTES) return utf8Prefix(suffix, DELIVERY_LIMIT_BYTES);
2138
- return utf8Prefix(message, DELIVERY_LIMIT_BYTES - suffixBytes) + suffix;
2139
- }
2140
-
2141
- function utf8Prefix(value: string, maxBytes: number): string {
2142
- const bytes = Buffer.from(value);
2143
- let end = Math.min(bytes.length, maxBytes);
2144
- while (end < bytes.length && end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end -= 1;
2145
- return bytes.subarray(0, end).toString("utf8");
2146
- }
2147
- const DIAGNOSTIC_LIMIT_BYTES = DELIVERY_LIMIT_BYTES - 512;
2148
- function failureDiagnosticsFrom(error: unknown): WorkflowFailureDiagnostics | undefined {
2149
- if (!error || typeof error !== "object") return undefined;
2150
- return (error as { [WORKFLOW_FAILURE_DIAGNOSTICS]?: WorkflowFailureDiagnostics })[WORKFLOW_FAILURE_DIAGNOSTICS];
2151
- }
2152
-
2153
- function boundedWorkflowFailureDiagnostics(value: WorkflowFailureDiagnostics): WorkflowFailureDiagnostics {
2154
- let bounded: WorkflowFailureDiagnostics = {
2155
- runId: utf8Prefix(value.runId, 128),
2156
- workflowName: utf8Prefix(value.workflowName, 256),
2157
- state: value.state,
2158
- failedAt: value.failedAt === null ? null : utf8Prefix(value.failedAt, 1024),
2159
- error: { code: value.error.code, message: utf8Prefix(value.error.message, 1024) },
2160
- ...(value.failedAgent ? { failedAgent: {
2161
- id: utf8Prefix(value.failedAgent.id, 128),
2162
- ...(value.failedAgent.label ? { label: utf8Prefix(value.failedAgent.label, 128) } : {}),
2163
- ...(value.failedAgent.role ? { role: utf8Prefix(value.failedAgent.role, 128) } : {}),
2164
- structuralPath: value.failedAgent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2165
- attempt: value.failedAgent.attempt,
2166
- ...(value.failedAgent.sessionId ? { sessionId: utf8Prefix(value.failedAgent.sessionId, 256) } : {}),
2167
- ...(value.failedAgent.sessionFile ? { sessionFile: utf8Prefix(value.failedAgent.sessionFile, 1024) } : {}),
2168
- } } : {}),
2169
- completedSiblingAgents: (value.completedSiblingAgents ?? []).slice(0, 16).map((agent) => ({
2170
- id: utf8Prefix(agent.id, 128),
2171
- ...(agent.label ? { label: utf8Prefix(agent.label, 128) } : {}),
2172
- ...(agent.role ? { role: utf8Prefix(agent.role, 128) } : {}),
2173
- structuralPath: agent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
2174
- })),
2175
- completedSiblingPaths: value.completedSiblingPaths.slice(0, 16).map((path) => path.slice(0, 8).map((part) => utf8Prefix(part, 128))),
2176
- artifacts: { runDirectory: utf8Prefix(value.artifacts.runDirectory, 1024), statePath: utf8Prefix(value.artifacts.statePath, 1024), journalPath: utf8Prefix(value.artifacts.journalPath, 1024) },
2177
- };
2178
- const size = () => Buffer.byteLength(JSON.stringify(bounded));
2179
- while (size() > DIAGNOSTIC_LIMIT_BYTES) {
2180
- if (bounded.completedSiblingAgents?.length || bounded.completedSiblingPaths.length) {
2181
- bounded = { ...bounded, completedSiblingAgents: bounded.completedSiblingAgents?.slice(0, -1) ?? [], completedSiblingPaths: bounded.completedSiblingPaths.slice(0, -1) };
2182
- continue;
2183
- }
2184
- if (bounded.failedAgent?.sessionFile) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionFile; bounded = { ...bounded, failedAgent }; continue; }
2185
- if (bounded.failedAgent?.sessionId) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionId; bounded = { ...bounded, failedAgent }; continue; }
2186
- if (Buffer.byteLength(bounded.artifacts.runDirectory) > 256) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, runDirectory: utf8Prefix(bounded.artifacts.runDirectory, 256) } }; continue; }
2187
- if (Buffer.byteLength(bounded.error.message) > 256) { bounded = { ...bounded, error: { ...bounded.error, message: utf8Prefix(bounded.error.message, 256) } }; continue; }
2188
- if (bounded.failedAt !== null && Buffer.byteLength(bounded.failedAt) > 256) { bounded = { ...bounded, failedAt: utf8Prefix(bounded.failedAt, 256) }; continue; }
2189
- if (bounded.failedAgent && bounded.failedAgent.structuralPath.length > 4) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.slice(0, 4) } }; continue; }
2190
- if (bounded.failedAgent?.structuralPath.some((part) => Buffer.byteLength(part) > 64)) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.map((part) => utf8Prefix(part, 64)) } }; continue; }
2191
- if (Buffer.byteLength(bounded.artifacts.statePath) > 512 || Buffer.byteLength(bounded.artifacts.journalPath) > 512) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, statePath: utf8Prefix(bounded.artifacts.statePath, 512), journalPath: utf8Prefix(bounded.artifacts.journalPath, 512) } }; continue; }
2192
- if (Buffer.byteLength(bounded.workflowName) > 128) { bounded = { ...bounded, workflowName: utf8Prefix(bounded.workflowName, 128) }; continue; }
2193
- break;
2194
- }
2195
- return bounded;
2196
- }
2197
-
2198
- function createWorkflowFailureDiagnostics(store: RunStore, metadata: WorkflowMetadata, error: unknown, run: PersistedRun): WorkflowFailureDiagnostics {
2199
- const rawFailedAt = error && typeof error === "object" ? (error as { failedAt?: unknown }).failedAt : undefined;
2200
- const failedAt = typeof rawFailedAt === "string" && rawFailedAt ? rawFailedAt : null;
2201
- const failedAgents = run.agents.filter((agent) => agent.state === "failed");
2202
- const failedAgentRecord = failedAgents.find((agent) => {
2203
- if (failedAt === null) return false;
2204
- try { return failedAt.includes(`${operationPath("agent", ...(agent.structuralPath ?? []))}/`); } catch { return false; }
2205
- }) ?? failedAgents.at(-1);
2206
- const failedAttempt = failedAgentRecord ? [...(failedAgentRecord.attemptDetails ?? [])].reverse().find((attempt) => attempt.error) ?? failedAgentRecord.attemptDetails?.at(-1) : undefined;
2207
- const failedAgent = failedAgentRecord ? {
2208
- id: failedAgentRecord.id,
2209
- ...(failedAgentRecord.label ?? failedAgentRecord.name ? { label: failedAgentRecord.label ?? failedAgentRecord.name } : {}),
2210
- ...(failedAgentRecord.role ? { role: failedAgentRecord.role } : {}),
2211
- structuralPath: [...(failedAgentRecord.structuralPath ?? [])],
2212
- attempt: Math.max(1, failedAttempt?.attempt ?? failedAgentRecord.attempts),
2213
- ...(failedAttempt?.sessionId ? { sessionId: failedAttempt.sessionId } : {}),
2214
- ...(failedAttempt?.sessionFile ? { sessionFile: failedAttempt.sessionFile } : {}),
2215
- } satisfies WorkflowFailureAgent : undefined;
2216
- const completedSiblingAgents = run.agents.filter((agent) => {
2217
- if (agent.state !== "completed" || agent.id === failedAgentRecord?.id) return false;
2218
- return failedAgentRecord?.parentId === undefined ? agent.parentId === undefined : agent.parentId === failedAgentRecord.parentId;
2219
- }).map((agent) => ({
2220
- id: agent.id,
2221
- ...(agent.label ?? agent.name ? { label: agent.label ?? agent.name } : {}),
2222
- ...(agent.role ? { role: agent.role } : {}),
2223
- structuralPath: [...(agent.structuralPath ?? [])],
2224
- } satisfies WorkflowSiblingAgent));
2225
- const completedSiblingPaths = completedSiblingAgents.map((agent) => [...agent.structuralPath]);
2226
- return boundedWorkflowFailureDiagnostics({
2227
- runId: run.id, workflowName: metadata.name, state: run.state, failedAt,
2228
- error: { code: errorCode(error) ?? "INTERNAL_ERROR", message: errorText(error) || "The workflow failed without an error message." },
2229
- ...(failedAgent ? { failedAgent } : {}), completedSiblingAgents, completedSiblingPaths,
2230
- artifacts: { runDirectory: store.directory, statePath: join(store.directory, "state.json"), journalPath: join(store.directory, "journal.json") },
2231
- });
2232
- }
2233
-
2234
- export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string {
2235
- const failedAgent = diagnostic.failedAgent ? `${diagnostic.failedAgent.label ?? diagnostic.failedAgent.id}${diagnostic.failedAgent.role ? ` role=${diagnostic.failedAgent.role}` : ""} attempt=${String(diagnostic.failedAgent.attempt)} path=${diagnostic.failedAgent.structuralPath.join(" > ") || "(root)"}${diagnostic.failedAgent.sessionFile ? ` session=${diagnostic.failedAgent.sessionFile}` : ""}` : "(not persisted)";
2236
- const siblingAgents = diagnostic.completedSiblingAgents;
2237
- const siblings = siblingAgents ? siblingAgents.map((agent) => `${agent.label ?? agent.id}${agent.role ? ` role=${agent.role}` : ""} path=${agent.structuralPath.join(" > ") || "(root)"}`).join(", ") || "(none)" : diagnostic.completedSiblingPaths.map((path) => path.join(" > ") || "(root)").join(", ") || "(none)";
2238
- return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
2239
- }
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
- }
2245
- function deliver(pi: ExtensionAPI, content: string): void {
2246
- pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
2247
- }
2248
- function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
2249
- deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
2250
- }
2251
-
2252
- type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
2253
-
2254
- function safeEventError(error: unknown): WorkflowErrorShape {
2255
- const code = errorCode(error) ?? "INTERNAL_ERROR";
2256
- return { code, message: `Workflow execution failed (${code})` };
2257
- }
2258
-
2259
- class WorkflowEventPublisher {
2260
- #queues = new Map<string, Promise<void>>();
2261
- #budgetEvents = new Map<string, Set<string>>();
2262
- #worktrees = new Map<string, Set<string>>();
2263
-
2264
- constructor(private readonly sink: WorkflowEventSink | undefined) {}
2265
-
2266
- removeRun(runId: string): void {
2267
- this.#queues.delete(runId);
2268
- this.#budgetEvents.delete(runId);
2269
- this.#worktrees.delete(runId);
2270
- }
2271
-
2272
- seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
2273
- const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
2274
- for (const event of events ?? []) seen.add(this.budgetKey(event));
2275
- this.#budgetEvents.set(runId, seen);
2276
- }
2277
-
2278
- async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
2279
- async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
2280
-
2281
- async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
2282
- await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason as WorkflowErrorCode) ? { errorCode: reason } : {}) });
2283
- if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
2284
- }
2285
-
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> {
2288
- if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
2289
- }
2290
-
2291
- async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
2292
- await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, ...(agent.role ? { role: agent.role } : {}), structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
2293
- }
2294
-
2295
- async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
2296
- const previousById = new Map(previous.map((agent) => [agent.id, agent]));
2297
- for (const agent of current) {
2298
- const old = previousById.get(agent.id);
2299
- if (!old || old.state !== agent.state || old.attempts !== agent.attempts) await this.agentState(store, metadata, old, agent);
2300
- }
2301
- }
2302
-
2303
- async phase(store: RunStore, metadata: WorkflowMetadata, previousPhase: string | undefined, phase: string): Promise<void> {
2304
- if (previousPhase !== phase) await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
2305
- }
2306
-
2307
- async checkpoint(store: RunStore, metadata: WorkflowMetadata, name: string, state: WorkflowCheckpointState): Promise<void> { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
2308
-
2309
- async budget(store: RunStore, metadata: WorkflowMetadata, run: Pick<PersistedRun, "budgetEvents">): Promise<void> {
2310
- const seen = this.#budgetEvents.get(store.runId) ?? new Set<string>();
2311
- this.#budgetEvents.set(store.runId, seen);
2312
- for (const event of run.budgetEvents ?? []) {
2313
- const key = this.budgetKey(event);
2314
- if (seen.has(key)) continue;
2315
- seen.add(key);
2316
- await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
2317
- }
2318
- }
2319
-
2320
- async worktree(store: RunStore, metadata: WorkflowMetadata, worktree: WorktreeReference): Promise<void> {
2321
- const seen = this.#worktrees.get(store.runId) ?? new Set<string>();
2322
- this.#worktrees.set(store.runId, seen);
2323
- if (seen.has(worktree.owner)) return;
2324
- seen.add(worktree.owner);
2325
- await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
2326
- }
2327
-
2328
- async #publish(store: RunStore, metadata: WorkflowMetadata, name: string, payload: Record<string, unknown>): Promise<void> {
2329
- const base: WorkflowEventBase = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
2330
- const previous = this.#queues.get(store.runId) ?? Promise.resolve();
2331
- const next = previous.then(() => {
2332
- try { void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined); } catch { /* Best effort: listeners cannot affect a run. */ }
2333
- });
2334
- this.#queues.set(store.runId, next.catch(() => undefined));
2335
- await next;
2336
- }
2337
-
2338
- private budgetKey(event: BudgetEvent): string { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
2339
- }
2340
-
2341
- const inheritedHostAgentPath = new AsyncLocalStorage<readonly string[]>();
2342
- const inheritedHostWorktreeOwner = new AsyncLocalStorage<string>();
2343
-
2344
-
2345
- function namedRecord(value: unknown, kind: string): Array<[string, unknown]> {
2346
- if (!object(value)) fail("INVALID_METADATA", `${kind} must be a record`);
2347
- return Object.entries(value);
2348
- }
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> {
2354
- if (args.length !== 1 && args.length !== 2) fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
2355
- const callback = args[args.length - 1];
2356
- if (typeof callback !== "function") fail("INVALID_METADATA", "withWorktree callback must be a function");
2357
- let owner: string;
2358
- if (args.length === 2) {
2359
- if (typeof args[0] !== "string" || !args[0].trim()) fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
2360
- owner = operationPath("worktree", "named", args[0].trim());
2361
- } else {
2362
- const structuralPath = inheritedHostAgentPath.getStore() ?? [];
2363
- const key = `${identity}\0${JSON.stringify(structuralPath)}`;
2364
- const occurrence = (occurrences.get(key) ?? 0) + 1;
2365
- occurrences.set(key, occurrence);
2366
- owner = operationPath("worktree", "unnamed", "function", identity, ...structuralPath, `occurrence:${String(occurrence)}`);
2367
- }
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>;
2371
- }
2372
- function workflowRunContext(cwd: string, sessionId: string, runId: string, workflow: WorkflowMetadata, args: JsonValue, signal: AbortSignal): Readonly<WorkflowRunContext> {
2373
- return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
2374
- }
2375
-
2376
- async function resolveWorkflowVariables(run: Readonly<WorkflowRunContext>, controller: AbortController, registry: WorkflowRegistryApi): Promise<Readonly<Record<string, JsonValue>>> {
2377
- let first: WorkflowError | undefined;
2378
- const tasks = registry.variables().map(async ({ name, variable }) => {
2379
- try {
2380
- const result: unknown = await variable.resolve(run);
2381
- if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
2382
- return [name, deepFreeze(structuredClone(result))] as const;
2383
- } catch (error) {
2384
- const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
2385
- if (!first) { first = typed; controller.abort(); }
2386
- throw typed;
2387
- }
2388
- });
2389
- await Promise.allSettled(tasks);
2390
- if (first) throw first;
2391
- return Object.freeze(Object.fromEntries((await Promise.all(tasks)).map(([name, value]) => [name, value])));
2392
- }
2393
-
2394
- async function hostParallel(rawOperation: unknown, rawTasks: unknown): Promise<JsonValue> {
2395
- if (typeof rawOperation !== "string" || !rawOperation.trim()) fail("INVALID_METADATA", "parallel requires a stable explicit name");
2396
- const tasks = namedRecord(rawTasks, "parallel tasks");
2397
- for (const [name, run] of tasks) {
2398
- if (!name.trim()) fail("INVALID_METADATA", "parallel task requires a stable explicit name");
2399
- if (typeof run !== "function") fail("INVALID_METADATA", "parallel task values must be run functions");
2400
- }
2401
- const results = await Promise.all(tasks.map(async ([name, run]) => {
2402
- try {
2403
- const parent = inheritedHostAgentPath.getStore() ?? [];
2404
- return { name, value: await inheritedHostAgentPath.run([...parent, rawOperation, name], run as () => unknown) as JsonValue };
2405
- } catch (error) {
2406
- const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
2407
- if (typed.code === "CANCELLED") throw typed;
2408
- return { name, error: typed };
2409
- }
2410
- }));
2411
- const failure = results.find((result) => result.error);
2412
- if (failure?.error) throw failure.error;
2413
- return Object.fromEntries(results.map((result) => [result.name, result.value as JsonValue]));
2414
- }
2415
-
2416
- async function hostPipeline(rawOperation: unknown, rawItems: unknown, rawStages: unknown): Promise<JsonValue> {
2417
- if (typeof rawOperation !== "string" || !rawOperation.trim()) fail("INVALID_METADATA", "pipeline requires a stable explicit name");
2418
- const items = namedRecord(rawItems, "pipeline items");
2419
- const stages = namedRecord(rawStages, "pipeline stages");
2420
- if (!stages.length) fail("INVALID_METADATA", "pipeline requires at least one stage");
2421
- for (const [name] of items) if (!name.trim()) fail("INVALID_METADATA", "pipeline item requires a stable explicit name");
2422
- for (const [stageName, run] of stages) {
2423
- if (!stageName.trim()) fail("INVALID_METADATA", "pipeline stage requires a stable explicit name");
2424
- if (typeof run !== "function") fail("INVALID_METADATA", "pipeline stage values must be run functions");
2425
- }
2426
- const results = await Promise.all(items.map(async ([name, initial]) => {
2427
- let current = initial;
2428
- try {
2429
- for (const [stageName, run] of stages) {
2430
- const parent = inheritedHostAgentPath.getStore() ?? [];
2431
- current = await inheritedHostAgentPath.run([...parent, rawOperation, name, stageName], () => (run as (value: unknown) => unknown)(current));
2432
- }
2433
- return { name, value: current as JsonValue };
2434
- } catch (error) {
2435
- const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
2436
- if (typed.code === "CANCELLED") throw typed;
2437
- return { name, error: typed };
2438
- }
2439
- }));
2440
- const failure = results.find((result) => result.error);
2441
- if (failure?.error) throw failure.error;
2442
- return Object.fromEntries(results.map((result) => [result.name, result.value as JsonValue]));
2443
- }
2444
-
2445
- function nextNamedOccurrence(counters: Map<string, number>, label: string): string {
2446
- const count = (counters.get(label) ?? 0) + 1;
2447
- counters.set(label, count);
2448
- return count === 1 ? label : `${label}#${String(count)}`;
2449
- }
2450
-
2451
- function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
2452
- const functionAgentOccurrences = new Map<string, number>();
2453
- const functionShellOccurrences = new Map<string, number>();
2454
- const functionWorktreeOccurrences = new Map<string, number>();
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> => {
2457
- const replayed = await store.replay(path);
2458
- let stored: JsonValue | undefined;
2459
- const sideEffects: Promise<void>[] = [];
2460
- const functionBreadcrumb = breadcrumb ?? name;
2461
- const context: WorkflowFunctionContext = {
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
- },
2472
- agent: async (...args: readonly unknown[]) => {
2473
- if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
2474
- const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
2475
- const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2476
- const inherited = inheritedHostAgentPath.getStore() ?? [];
2477
- const key = `${path}\0${JSON.stringify(inherited)}`;
2478
- const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
2479
- functionAgentOccurrences.set(key, occurrence);
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 } : {}) });
2492
- },
2493
- prompt: workflowPrompt,
2494
- parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
2495
- pipeline: (...args: readonly unknown[]) => hostPipeline(args[0], args[1], args[2]),
2496
- withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, path, functionWorktreeOccurrences, bridge.worktree, signal),
2497
- checkpoint: async (...args: readonly unknown[]) => {
2498
- if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0])) fail("INTERNAL_ERROR", "No checkpoint bridge is available");
2499
- return bridge.checkpoint(args[0], signal);
2500
- },
2501
- phase: (name: string) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
2502
- log: (message: string) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
2503
- };
2504
- const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
2505
- await Promise.all(sideEffects);
2506
- if (!replayed) await store.complete(path, stored ?? result);
2507
- return result;
2508
- };
2509
- return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
2510
- }
2511
-
2512
- function projectTrusted(ctx: unknown): boolean {
2513
- const check = object(ctx) ? ctx.isProjectTrusted : undefined;
2514
- return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
2515
- }
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; }
2575
- function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2576
- switch (value) {
2577
- case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
2578
- default: return undefined;
2579
- }
2580
- }
2581
-
2582
- export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
2583
- beginWorkflowExtensionLoading();
2584
- const registry = loadingRegistry();
2585
- const extensionAgentDir = agentDir ?? getAgentDir();
2586
- const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
2587
- registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
2588
- const data = entry.data;
2589
- return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
2590
- });
2591
- const logBridge = (lifecycle: RunLifecycle, workflowName: string) => async (message: string) => {
2592
- await lifecycle.enter();
2593
- try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
2594
- finally { await lifecycle.leave(); }
2595
- };
2596
- const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
2597
- pi.on("resources_discover", () => {
2598
- if (!pi.getActiveTools().includes("workflow")) return;
2599
- const extensionDir = dirname(fileURLToPath(import.meta.url));
2600
- const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
2601
- return skillPath ? { skillPaths: [skillPath] } : undefined;
2602
- });
2603
- type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
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
- };
2652
- const conversationLocks = new Set<string>();
2653
- const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
2654
- let sessionLease: SessionLease | undefined;
2655
- let sessionLeasePromise: Promise<SessionLease> | undefined;
2656
- const ensureSessionLease = async (cwd: string, sessionId: string) => {
2657
- if (sessionLease?.active) return;
2658
- const pending = sessionLeasePromise ?? (sessionLeasePromise = acquireSessionLease(cwd, sessionId, home));
2659
- try { sessionLease = await pending; }
2660
- finally { if (sessionLeasePromise === pending) sessionLeasePromise = undefined; }
2661
- };
2662
- const releaseSessionLease = async () => {
2663
- const lease = sessionLease ?? await sessionLeasePromise?.catch(() => undefined);
2664
- sessionLease = undefined;
2665
- sessionLeasePromise = undefined;
2666
- await lease?.release();
2667
- };
2668
- const persistRunState = async (store: RunStore, metadata: WorkflowMetadata, update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> => {
2669
- const persisted = await store.updateState(update);
2670
- await eventPublisher.budget(store, metadata, persisted);
2671
- return persisted;
2672
- };
2673
- const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
2674
- const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2675
- const worktree = await store.worktree(owner);
2676
- if (!existing && await store.ownsWorktree(owner)) await eventPublisher.worktree(store, metadata, worktree);
2677
- return worktree;
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
- };
2700
- const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
2701
- if (next !== "pausing") budget.transition(next);
2702
- const persisted = await persistRunState(store, metadata, (current) => {
2703
- const nextRun = { ...current, state: next, ...budget.snapshot() };
2704
- if (next === "running" || next === "completed") delete nextRun.error;
2705
- return nextRun;
2706
- });
2707
- await eventPublisher.runState(store, metadata, previous, next, reason);
2708
- runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2709
- });
2710
- const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
2711
- const run = runs.get(runId);
2712
- if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
2713
- try {
2714
- const budget = run.budget.forAgent(id);
2715
- const onProgress = async (progress: AgentProgress) => {
2716
- let runState: PersistedRun;
2717
- if (progress.persist) {
2718
- runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
2719
- } else {
2720
- const loaded = await run.store.load();
2721
- if (!loaded.run.agents.some((agent) => agent.id === id)) return;
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) };
2723
- }
2724
- if (!runState.agents.some((agent) => agent.id === id)) return;
2725
- setLiveActivity(runId, id, progress.activity);
2726
- run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2727
- };
2728
- const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
2729
- await scheduler.flush();
2730
- scheduler.attemptStarted(id);
2731
- await scheduler.flush();
2732
- const before = (await run.store.load()).run;
2733
- await persistActiveAgentAttempt(run.store, id, attempt);
2734
- const active = (await run.store.load()).run;
2735
- await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2736
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2737
- run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2738
- };
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); });
2740
- const before = (await run.store.load()).run;
2741
- await persistAgentAttempts(run.store, id, result.attempts);
2742
- const completed = (await run.store.load()).run;
2743
- await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2744
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2745
- setLiveActivity(runId, id);
2746
- run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2747
- return result.value;
2748
- } catch (error) {
2749
- const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
2750
- if (attempts?.length) {
2751
- const before = (await run.store.load()).run;
2752
- await persistAgentAttempts(run.store, id, attempts);
2753
- const failed = (await run.store.load()).run;
2754
- await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2755
- }
2756
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2757
- setLiveActivity(runId, id);
2758
- run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2759
- throw error;
2760
- }
2761
- }, 16, async (runId, ownership) => {
2762
- const run = runs.get(runId);
2763
- if (!run) return;
2764
- await run.store.saveOwnership(ownership);
2765
- let previousAgents: readonly AgentRecord[] = [];
2766
- const runState = await persistRunState(run.store, run.metadata, (current) => {
2767
- previousAgents = current.agents;
2768
- const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
2769
- const agents = ownership.map((node) => {
2770
- const previous = existing.get(node.id);
2771
- const requested = { label: node.options.label, workflowName: run.metadata.name, ...(node.options.model ? { model: node.options.model } : {}), ...(node.options.thinking ? { thinking: node.options.thinking } : {}), ...(node.options.role ? { role: node.options.role } : {}), effectiveTools: node.options.tools };
2772
- let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
2773
- try { effective = run.executor.resolve(requested); }
2774
- catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
2775
- return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
2776
- });
2777
- return { ...current, agents };
2778
- });
2779
- await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2780
- run.update?.(workflowToolUpdate(withLiveActivities(runState)));
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
- };
2795
- type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
2796
- const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
2797
- const run = runs.get(runId);
2798
- const terminalState = terminalRunStates.get(runId);
2799
- if (!run) return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
2800
- const state = run.lifecycle.state;
2801
- if (state === "completed" || state === "failed" || state === "stopped") return { runId, state, stopped: false, reason: "already_terminal" };
2802
- await run.lifecycle.terminal("stopped");
2803
- run.abortController.abort();
2804
- run.execution?.cancel();
2805
- await scheduler.cancelRun(run.store.runId);
2806
- await scheduler.flush();
2807
- await cleanupTerminalRun(runId);
2808
- return { runId, state: "stopped", stopped: true };
2809
- };
2810
- const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
2811
- const run = runs.get(runId);
2812
- if (!run) return false;
2813
- const checkpoint = await run.store.answerCheckpoint(name, approved);
2814
- if (!checkpoint) return false;
2815
- await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
2816
- if ((await run.store.awaitingCheckpoints()).length === 0) await run.lifecycle.resolveAwaitingInput();
2817
- run.checkpointResolvers.get(checkpoint.path)?.(approved);
2818
- run.checkpointResolvers.delete(checkpoint.path);
2819
- if (!silent) deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
2820
- return true;
2821
- };
2822
- const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
2823
- const appendBudgetDecisionEvent = async (run: NonNullable<ReturnType<typeof runs.get>>, request: BudgetApprovalRequest, type: "adjustment_requested" | "adjustment_approved" | "adjustment_rejected") => {
2824
- run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
2825
- await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2826
- };
2827
- const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
2828
- const run = runs.get(runId);
2829
- if (!run) return undefined;
2830
- const request = await run.store.answerWorkflowDecision(proposalId, approved);
2831
- if (!request) return undefined;
2832
- await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2833
- const result = await applyBudgetDecision(request, approved);
2834
- if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2835
- return result;
2836
- };
2837
- const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, headless = false) => {
2838
- const checkpointCounters = new Map<string, number>();
2839
- return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
2840
- const input = validateCheckpoint(raw);
2841
- const label = nextNamedOccurrence(checkpointCounters, input.name);
2842
- const path = operationPath("checkpoint", label);
2843
- if (headless) fail("RESUME_INCOMPATIBLE", "Headless CLI checkpoints are unsupported");
2844
- if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
2845
- const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
2846
- const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
2847
- if (replayed !== undefined) return replayed;
2848
- if (!alreadyAwaiting) await eventPublisher.checkpoint(store, metadata, label, "awaiting");
2849
- const run = runs.get(runId);
2850
- await run?.lifecycle.enterAwaitingInput();
2851
- if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
2852
- const decision = new Promise<boolean>((resolve, reject) => {
2853
- run?.checkpointResolvers.set(path, resolve);
2854
- if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
2855
- else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
2856
- });
2857
- const answered = await store.awaitCheckpoint({ ...input, name: label, path });
2858
- if (answered !== undefined) {
2859
- if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
2860
- run?.checkpointResolvers.get(path)?.(answered);
2861
- run?.checkpointResolvers.delete(path);
2862
- }
2863
- if (ui?.select) void (async () => {
2864
- while (!signal.aborted && run?.checkpointResolvers.has(path)) {
2865
- const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
2866
- if (!choice) {
2867
- if (foreground) continue; // foreground: retry until answered
2868
- deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
2869
- return;
2870
- }
2871
- if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
2872
- }
2873
- })().catch(() => undefined);
2874
- return decision;
2875
- };
2876
- };
2877
-
2878
- pi.registerTool({
2879
- name: "workflow_respond",
2880
- label: "Workflow Respond",
2881
- description: "Approve or reject one pending workflow checkpoint or budget decision",
2882
- parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
2883
- async execute(_id, params) {
2884
- try {
2885
- if (params.proposalId) {
2886
- const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
2887
- if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { content: [{ type: "text" as const, text: JSON.stringify(denied) }], details: denied }; }
2888
- return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
2889
- }
2890
- if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
2891
- const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
2892
- return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } as never };
2893
- } catch (error) {
2894
- throw mainAgentError(error);
2895
- }
2896
- },
2897
- });
2898
- pi.registerTool({
2899
- name: "workflow_stop",
2900
- label: "Workflow Stop",
2901
- description: "Stop an active workflow run by ID",
2902
- parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
2903
- async execute(_id, params) {
2904
- try {
2905
- const result = await stopWorkflowRun(params.runId);
2906
- return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
2907
- } catch (error) {
2908
- throw mainAgentError(error);
2909
- }
2910
- },
2911
- });
2912
- let catalogRegistered = false;
2913
- let sessionStarted = false;
2914
- const registerCatalog = () => {
2915
- if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
2916
- const catalog = registry.catalog();
2917
- const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2918
- if (!catalog.functions.length && !catalog.variables.length && !hasAliases) return;
2919
- pi.registerTool({
2920
- name: "workflow_catalog",
2921
- label: "Workflow Catalog",
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
- }
2928
- });
2929
- catalogRegistered = true;
2930
- };
2931
- const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
2932
- const loaded = await run.store.load();
2933
- const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2934
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2935
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2936
- const settingsPath = workflowSettingsPath(extensionAgentDir);
2937
- const currentSettings = loadSettings(settingsPath);
2938
- resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2939
- const currentAliases = currentSettings.modelAliases ?? {};
2940
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2941
- const modelRegistry = context?.modelRegistry;
2942
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2943
- if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
2944
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2945
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2946
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2947
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2948
- await run.store.saveSnapshot(snapshot);
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);
2950
- run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2951
- const drift = aliasDrift(previousAliases, currentAliases);
2952
- if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2953
- };
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 }) => {
2955
- const loaded = await run.store.load();
2956
- await run.store.validateBorrowedWorktrees();
2957
- if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
2958
- if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
2959
- if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
2960
- const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
2961
- if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
2962
- const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2963
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2964
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2965
- const settingsPath = workflowSettingsPath(extensionAgentDir);
2966
- const currentSettings = loadSettings(settingsPath);
2967
- resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2968
- const currentAliases = currentSettings.modelAliases ?? {};
2969
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2970
- const modelRegistry = context?.modelRegistry;
2971
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2972
- if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
2973
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2974
- const resumeAliases = { ...previousAliases, ...currentAliases };
2975
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2976
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
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);
2979
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2980
- await run.store.saveSnapshot(snapshot);
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);
2982
- const drift = aliasDrift(previousAliases, currentAliases);
2983
- if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2984
- const controller = new AbortController();
2985
- run.abortController = controller;
2986
- const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
2987
- run.executor.setRunContext(runContext);
2988
- let variables: Readonly<Record<string, JsonValue>>;
2989
- try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
2990
- catch (error) {
2991
- const typed = asWorkflowError(error);
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);
2994
- throw typed;
2995
- }
2996
- await scheduler.cancelRun(run.store.runId);
2997
- await run.lifecycle.resume();
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) => {
2999
- await run.lifecycle.enter();
3000
- const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
3001
- const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
3002
- try {
3003
- const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
3004
- const replayed = await run.store.replay(path);
3005
- if (replayed) {
3006
- if (conversationId) {
3007
- const conversation = await run.store.conversation(conversationId);
3008
- if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
3009
- }
3010
- return replayed.value;
3011
- }
3012
- if (conversationId) {
3013
- if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
3014
- const conversation = await run.store.conversation(conversationId);
3015
- if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
3016
- conversationLocks.add(conversationLock);
3017
- }
3018
- const worktree = agentWorktree(identity);
3019
- const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
3020
- const role = typeof options.role === "string" ? options.role : undefined;
3021
- const model = typeof options.model === "string" ? options.model : undefined;
3022
- const thinking = parseThinking(options.thinking);
3023
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
3024
- const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
3025
- const label = displayAgentName(requestedLabel, role, resolved.model);
3026
- const tools = resolved.tools;
3027
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
3028
- const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
3029
- const cancel = () => { scheduler.cancel(spawned.id); };
3030
- signal.addEventListener("abort", cancel, { once: true });
3031
- const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
3032
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
3033
- await run.store.complete(path, outcome.value);
3034
- return outcome.value;
3035
- } finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
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);
3037
- run.execution = execution;
3038
- const completion = execution.result.then(async (value) => {
3039
- await scheduler.flush();
3040
- if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
3041
- const resultPath = await run.store.saveResult(value);
3042
- await run.lifecycle.terminal("completed", "completed");
3043
- await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
3044
- return { value, resultPath };
3045
- }).catch(async (error: unknown) => {
3046
- await scheduler.flush();
3047
- const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
3048
- if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
3049
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
3050
- const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
3051
- await eventPublisher.runFailed(run.store, run.metadata, typed, state);
3052
- run.update?.(workflowToolUpdate(persisted));
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;
3056
- void completion;
3057
- };
3058
- const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
3059
- const run = runs.get(request.runId);
3060
- if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
3061
- if (!approved) return { state: "budget_exhausted", approved: false };
3062
- const nextBudget = validateBudget(request.proposed);
3063
- const nextVersion = request.budgetVersion + 1;
3064
- const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
3065
- run.budget = runtime;
3066
- await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
3067
- await coldResumeRun(run, false, {}, true);
3068
- return { state: "running", approved: true };
3069
- };
3070
- const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
3071
- const run = runs.get(runId);
3072
- if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
3073
- const loaded = await run.store.load();
3074
- if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
3075
- const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
3076
- const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
3077
- const nextBudget = mergeBudget(currentBudget, patch);
3078
- const usage = budgetUsage(loaded.run.usage);
3079
- if (!resumeBudgetAllowed(nextBudget, usage)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
3080
- if (budgetRelaxed(currentBudget, nextBudget)) {
3081
- const proposalId = randomUUID();
3082
- const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
3083
- await run.store.requestWorkflowDecision(request);
3084
- await appendBudgetDecisionEvent(run, request, "adjustment_requested");
3085
- deliver(pi, budgetDecisionDelivery(run.metadata, request));
3086
- return { state: "awaiting_approval", proposalId };
3087
- }
3088
- const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
3089
- if (changed) {
3090
- const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
3091
- const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
3092
- run.budget = runtime;
3093
- await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
3094
- }
3095
- await coldResumeRun(run, false, {}, true);
3096
- return { state: "running" };
3097
- };
3098
- pi.registerTool({
3099
- name: "workflow_resume",
3100
- label: "Workflow Resume",
3101
- description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
3102
- parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
3103
- async execute(_id, params) {
3104
- try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
3105
- catch (error) { throw mainAgentError(error); }
3106
- },
3107
- });
3108
- pi.on("session_start", async (_event, ctx) => {
3109
- if (sessionStarted) return;
3110
- sessionStarted = true;
3111
- registry.freeze();
3112
- registerCatalog();
3113
- await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
3114
- try {
3115
- for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
3116
- if (runs.has(runId)) continue;
3117
- const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
3118
- let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
3119
- try { loaded = await store.load(); } catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); continue; }
3120
- if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
3121
- if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
3122
- const previousState = loaded.run.state;
3123
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
3124
- loaded = { ...loaded, run: (await store.load()).run };
3125
- await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
3126
- loaded = { ...loaded, run: (await store.load()).run };
3127
- }
3128
- const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
3129
- const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
3130
- eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
3131
- const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
3132
- const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
3133
- const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
3134
- const roleDefinitions = loaded.snapshot.roles ?? {};
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 } : {}) });
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.`);
3139
- for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
3140
- scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
3141
- }
3142
- const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
3143
- if (ctx.hasUI && resumeSelect) {
3144
- const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
3145
- if (interrupted.length > 0) {
3146
- const labels = interrupted.map((r) => `Resume: ${r.metadata.name} (${r.store.runId.slice(0, 8)})`);
3147
- const options = [...labels, ...(interrupted.length > 1 ? ["Resume all"] : []), "Skip"];
3148
- const choice = await resumeSelect(`${String(interrupted.length)} interrupted workflow${interrupted.length > 1 ? "s" : ""} found`, options);
3149
- if (choice && choice !== "Skip") {
3150
- const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
3151
- for (const run of toResume) {
3152
- try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
3153
- catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
3154
- }
3155
- }
3156
- }
3157
- }
3158
- } catch (error) { await releaseSessionLease(); throw error; }
3159
- });
3160
- pi.on("before_agent_start", (event, ctx) => {
3161
- if (!pi.getActiveTools().includes("workflow")) return;
3162
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
3163
- if (!roles.length) return;
3164
- const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
3165
- return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
3166
- });
3167
- pi.registerTool({
3168
- name: "workflow",
3169
- label: WORKFLOW_TOOL_LABEL,
3170
- description: WORKFLOW_TOOL_DESCRIPTION,
3171
- promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
3172
- parameters: WORKFLOW_TOOL_PARAMETERS,
3173
- async execute(toolCallId, params, signal, onUpdate, ctx) {
3174
- try {
3175
- const headless = object(ctx) && ctx.headless === true;
3176
- const settingsPath = workflowSettingsPath(extensionAgentDir);
3177
- const defaults = loadSettings(settingsPath);
3178
- if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
3179
- const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
3180
- const budget = validateBudget(params.budget);
3181
- const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
3182
- const rootModelName = `${rootModel.provider}/${rootModel.model}`;
3183
- const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
3184
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
3185
- knownModels.add(rootModelName);
3186
- const availableModels = knownModels;
3187
- const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
3188
- const trustedProject = projectTrusted(ctx);
3189
- if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
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;
3192
- await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
3193
- const runId = randomUUID();
3194
- const args = (params.args ?? null) as JsonValue;
3195
- encoded(args);
3196
- const runController = new AbortController();
3197
- if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
3198
- const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
3199
- const variables = await resolveWorkflowVariables(runContext, runController, registry);
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);
3203
- const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
3204
- const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
3205
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
3206
- const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
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 });
3208
- const budgetRuntime = new WorkflowBudgetRuntime(budget);
3209
- const initialBudget = budgetRuntime.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);
3211
- const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
3212
- const background = !params.foreground;
3213
- const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
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 } : {}) });
3217
- if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
3218
- scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
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) => {
3220
- await lifecycle.enter();
3221
- const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
3222
- const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
3223
- try {
3224
- const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
3225
- const replayed = await store.replay(path);
3226
- if (replayed) {
3227
- if (conversationId) {
3228
- const conversation = await store.conversation(conversationId);
3229
- if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
3230
- }
3231
- return replayed.value;
3232
- }
3233
- if (conversationId) {
3234
- if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
3235
- const conversation = await store.conversation(conversationId);
3236
- if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
3237
- conversationLocks.add(conversationLock);
3238
- }
3239
- const worktree = agentWorktree(identity);
3240
- const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
3241
- const role = typeof options.role === "string" ? options.role : undefined;
3242
- const model = typeof options.model === "string" ? options.model : undefined;
3243
- const thinking = parseThinking(options.thinking);
3244
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
3245
- const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
3246
- const label = displayAgentName(requestedLabel, role, resolved.model);
3247
- const tools = resolved.tools;
3248
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
3249
- const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
3250
- const cancel = () => { scheduler.cancel(spawned.id); };
3251
- if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
3252
- const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
3253
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
3254
- await store.complete(path, outcome.value);
3255
- return outcome.value;
3256
- } finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
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) => {
3258
- await lifecycle.enter();
3259
- try {
3260
- let previousPhase: string | undefined;
3261
- const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
3262
- await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
3263
- runs.get(runId)?.update?.(workflowToolUpdate(persisted));
3264
- } finally { await lifecycle.leave(); }
3265
- }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
3266
- (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
3267
- await eventPublisher.runStarted(store, checked.metadata);
3268
- const finish = execution.result.then(async (value) => {
3269
- await scheduler.flush();
3270
- if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
3271
- const resultPath = await store.saveResult(value);
3272
- await lifecycle.terminal("completed", "completed");
3273
- await eventPublisher.runCompleted(store, checked.metadata, resultPath);
3274
- return { value, resultPath };
3275
- }).catch(async (error: unknown) => {
3276
- await scheduler.flush();
3277
- const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
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);
3279
- const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3280
- const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
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);
3285
- throw typed;
3286
- });
3287
- const completion = finish.finally(() => cleanupTerminalRun(runId));
3288
- (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
3289
- if (background) {
3290
- void completion.then(async ({ value, resultPath }) => {
3291
- deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
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
- });
3297
- return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
3298
- }
3299
- const { value } = await completion;
3300
- const run = (await store.load()).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 } };
3302
- } catch (error) {
3303
- throw mainAgentError(error);
3304
- }
3305
- },
3306
- renderCall(args) {
3307
- return textBlock(formatWorkflowPreview(args));
3308
- },
3309
- renderResult(result, { isPartial }, _theme, context) {
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;
3313
- const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval> };
3314
- if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
3315
- state.workflowSpinner = setInterval(context.invalidate, 80);
3316
- state.workflowSpinner.unref();
3317
- } else if ((!isPartial || runDetails?.run?.state !== "running") && state.workflowSpinner) {
3318
- clearInterval(state.workflowSpinner);
3319
- delete state.workflowSpinner;
3320
- }
3321
- if (runDetails?.run) return workflowProgressBlock(runDetails.run);
3322
- const content = result.content[0];
3323
- return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
3324
- },
3325
- });
3326
- pi.registerCommand("workflow", {
3327
- description: "Inspect and control workflows for this Pi session",
3328
- handler: async (args, ctx) => {
3329
- const command = args.trim();
3330
- if (command === "doctor") {
3331
- const { doctor, doctorExitCode, formatDoctorReport } = await import("./doctor.js");
3332
- const report = await doctor({ cwd: ctx.cwd, activeTools: pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond") });
3333
- ctx.ui.notify(formatDoctorReport(report), doctorExitCode(report) ? "warning" : "info");
3334
- return;
3335
- }
3336
- await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
3337
- const loadStores = async () => {
3338
- const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
3339
- const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
3340
- try { return { store, loaded: await store.load() }; }
3341
- catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
3342
- }));
3343
- return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
3344
- };
3345
- let stores = await loadStores();
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."
3347
- const setWorkflowStatus = (text: string | undefined) => {
3348
- const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
3349
- setStatus?.call(ctx.ui, "workflow-stop", text);
3350
- };
3351
- const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
3352
- const [action, runId, ...rest] = actionCommand.split(/\s+/);
3353
- try {
3354
- const run = runId ? runs.get(runId) : undefined;
3355
- const storedEntry = runId ? stores.find(({ store }) => store.runId === runId) : undefined;
3356
- const stored = storedEntry ? { store: storedEntry.store, loaded: await storedEntry.store.load() } : undefined;
3357
- if ((action === "approve" || action === "reject") && runId && rest.length) {
3358
- const accepted = await answerCheckpoint(runId, rest.join(" "), action === "approve", true);
3359
- ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
3360
- return keepContext ? "dashboard" : "done";
3361
- }
3362
- if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
3363
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
3364
- ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
3365
- return keepContext ? "dashboard" : "done";
3366
- }
3367
- if (action === "delete" && stored) {
3368
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
3369
- if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
3370
- await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
3371
- }
3372
- if (action === "pause" && run) { await run.lifecycle.pause(); ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done"; }
3373
- if (action === "resume" && run) {
3374
- if (run.lifecycle.state === "budget_exhausted") {
3375
- const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
3376
- const result = await resumeWorkflowRun(run.store.runId, patch);
3377
- 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");
3378
- } else {
3379
- if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
3380
- else {
3381
- if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
3382
- await run.lifecycle.resume();
3383
- }
3384
- ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
3385
- }
3386
- return keepContext ? "dashboard" : "done";
3387
- }
3388
- if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
3389
- const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
3390
- if (input === undefined) return keepContext ? "dashboard" : "done";
3391
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
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");
3393
- return keepContext ? "dashboard" : "done";
3394
- }
3395
- if (action === "stop" && run) {
3396
- const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
3397
- if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
3398
- if (keepContext) status(`Stopping workflow ${workflowName}...`);
3399
- await stopWorkflowRun(run.store.runId);
3400
- if (keepContext) status(`Workflow ${run.store.runId} stopped.`);
3401
- ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
3402
- }
3403
- if (keepContext && action && runId) { ctx.ui.notify(`Cannot ${action} workflow ${runId}: the run is no longer available.`, "warning"); return "dashboard"; }
3404
- ctx.ui.notify(usage, "warning");
3405
- return "done";
3406
- } catch (error) {
3407
- if (!keepContext) throw error;
3408
- const message = error instanceof Error ? error.message : String(error);
3409
- if (action === "stop") status(`Could not stop workflow ${runId ?? ""}: ${message}`);
3410
- ctx.ui.notify(`Cannot ${action ?? "workflow action"}${runId ? ` for ${runId}` : ""}: ${message}`, "warning");
3411
- return "dashboard";
3412
- }
3413
- };
3414
- const manageAliases = async (): Promise<void> => {
3415
- const settingsPath = workflowSettingsPath(extensionAgentDir);
3416
- const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
3417
- const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
3418
- const selectTarget = async (): Promise<string | undefined> => {
3419
- const models = available();
3420
- const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
3421
- if (!choice || choice === "Back") return undefined;
3422
- if (choice !== "Manual model ID") return choice;
3423
- return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
3424
- };
3425
- const save = (aliases: Readonly<Record<string, string>>): boolean => {
3426
- try { saveModelAliases(settingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info"); return true; }
3427
- catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
3428
- };
3429
- for (;;) {
3430
- let aliases: Readonly<Record<string, string>>;
3431
- try { aliases = loadSettings(settingsPath).modelAliases ?? {}; }
3432
- catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
3433
- const names = Object.keys(aliases).sort();
3434
- const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
3435
- const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
3436
- const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
3437
- if (!choice || choice === "Back") return;
3438
- if (choice === "Add alias") {
3439
- const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
3440
- if (!name) continue;
3441
- if (Object.prototype.hasOwnProperty.call(aliases, name)) { ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning"); continue; }
3442
- const target = await selectTarget();
3443
- if (!target) continue;
3444
- const next = { ...aliases, [name]: target };
3445
- try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
3446
- const parsed = parseModelReference(target);
3447
- if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3448
- ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3449
- if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
3450
- }
3451
- save(next);
3452
- continue;
3453
- }
3454
- const edit = /^Edit (.+)$/.exec(choice);
3455
- if (edit?.[1]) {
3456
- const target = await selectTarget();
3457
- if (!target) continue;
3458
- const next = { ...aliases, [edit[1]]: target };
3459
- try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
3460
- const parsed = parseModelReference(target);
3461
- if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3462
- ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3463
- if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
3464
- }
3465
- save(next);
3466
- continue;
3467
- }
3468
- const deletion = /^Delete (.+)$/.exec(choice);
3469
- if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
3470
- const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
3471
- save(next);
3472
- }
3473
- }
3474
- };
3475
- if (command === "model-aliases") {
3476
- if (!ctx.hasUI) { ctx.ui.notify("Model alias management requires UI.", "warning"); return; }
3477
- await manageAliases();
3478
- return;
3479
- }
3480
- if (!command) {
3481
- for (;;) {
3482
- if (!ctx.hasUI) {
3483
- if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
3484
- const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
3485
- ctx.ui.notify(details.join("\n\n"), "info"); return;
3486
- }
3487
- const sorted = navigatorAttentionSort(stores);
3488
- const labels = navigatorRunLabels(sorted);
3489
- const terminalStates = new Set(["completed", "failed", "stopped"]);
3490
- const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
3491
- const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
3492
- const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
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
- }
3503
- if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
3504
- if (runChoice === "Delete all completed") {
3505
- if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
3506
- for (const entry of sorted) {
3507
- if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
3508
- }
3509
- ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
3510
- }
3511
- const runIndex = labels.indexOf(runChoice);
3512
- if (runIndex < 0) return;
3513
- const selected = sorted[runIndex];
3514
- if (!selected) return;
3515
- const { store } = selected;
3516
- const copyArtifact = async (value: string, artifact: string) => {
3517
- try {
3518
- await clipboard(value);
3519
- ctx.ui.notify(`Copied ${artifact}.`, "info");
3520
- } catch (error) {
3521
- ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
3522
- }
3523
- };
3524
- const loadDashboard = async () => {
3525
- const loaded = await store.load();
3526
- const checkpoints = await store.awaitingCheckpoints();
3527
- const worktrees = await store.worktrees();
3528
- const actions = new Map<string, string>();
3529
- const copies = new Map<string, { value: string; artifact: string }>();
3530
- const reviews = new Map<string, AwaitingCheckpoint>();
3531
- const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
3532
- const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
3533
- if (loaded.run.state === "running") add("Pause", "pause");
3534
- if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
3535
- if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
3536
- for (const decision of await store.pendingWorkflowDecisions()) {
3537
- const id = decision.proposalId.slice(0, 8);
3538
- actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
3539
- actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
3540
- }
3541
- if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
3542
- for (const cp of checkpoints) {
3543
- if (ctx.mode === "tui") {
3544
- const label = `Review ${cp.name}`;
3545
- actions.set(label, "review");
3546
- reviews.set(label, cp);
3547
- } else {
3548
- actions.set(`Approve ${cp.name}`, `approve ${store.runId} ${cp.name}`);
3549
- actions.set(`Reject ${cp.name}`, `reject ${store.runId} ${cp.name}`);
3550
- }
3551
- }
3552
- if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
3553
- else actions.set("View script", "view-script");
3554
- if (loaded.run.agents.length) actions.set("Agents...", "agents");
3555
- if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
3556
- if (ctx.mode === "tui") {
3557
- addCopy("Copy run path", store.directory, "run path");
3558
- addCopy("Copy run ID", store.runId, "run ID");
3559
- }
3560
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3561
- };
3562
- const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
3563
- const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
3564
- const title = (agent: AgentRecord): string => {
3565
- const parents: string[] = [];
3566
- for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
3567
- const parent = byId.get(parentId);
3568
- if (!parent) break;
3569
- parents.unshift(parent.label ?? parent.name);
3570
- }
3571
- return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
3572
- };
3573
- const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
3574
- const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3575
- const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3576
- const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3577
- if (!selected) return;
3578
- const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3579
- const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3580
- const actions = [
3581
- ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3582
- ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3583
- "Copy agent ID",
3584
- "Back",
3585
- ];
3586
- const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
3587
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3588
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3589
- const index = choice ? choices.indexOf(choice) : -1;
3590
- return index >= 0 ? attempts[index] : undefined;
3591
- };
3592
- for (;;) {
3593
- const action = await ctx.ui.select(title(selected), actions);
3594
- if (!action || action === "Back") return;
3595
- if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
3596
- if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
3597
- if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
3598
- if (action === "Fork as Pi session in pane") {
3599
- const attempt = await chooseAttempt();
3600
- if (!attempt) continue;
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
- }
3609
- }
3610
- }
3611
- };
3612
- for (;;) {
3613
- let view = await loadDashboard();
3614
- const actionChoice = ctx.mode === "tui"
3615
- ? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3616
- let options = [...view.actions.keys(), "Close"];
3617
- let selectedIndex = 0;
3618
- let dashboardOffset = 0;
3619
- let refreshing = false;
3620
- let disposed = false;
3621
- let stopRequested = false;
3622
- let stopStatus: string | undefined;
3623
- const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3624
- const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3625
- const keyLabel = (binding: string, fallback: string) => {
3626
- const keys = keybindingKeys(keybindings, binding);
3627
- return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
3628
- };
3629
- const dashboardLayout = () => {
3630
- const rows = terminalRows();
3631
- const hintRows = rows >= 4 ? 1 : 0;
3632
- const separatorRows = rows >= 8 ? 1 : 0;
3633
- const available = Math.max(1, rows - hintRows - separatorRows);
3634
- const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
3635
- return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3636
- };
3637
- const updateDashboard = async (selectedOption: string | undefined) => {
3638
- const next = await loadDashboard();
3639
- if (disposed) return;
3640
- view = next;
3641
- options = [...view.actions.keys(), "Close"];
3642
- selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
3643
- tui.requestRender();
3644
- };
3645
- const requestStop = () => {
3646
- if (stopRequested) return;
3647
- stopRequested = true;
3648
- stopStatus = undefined;
3649
- setWorkflowStatus(undefined);
3650
- const selectedOption = options[selectedIndex];
3651
- void runAction(`stop ${store.runId}`, true, (status) => {
3652
- stopStatus = status;
3653
- setWorkflowStatus(status);
3654
- if (!disposed) tui.requestRender();
3655
- }).then(() => updateDashboard(selectedOption)).catch((error: unknown) => {
3656
- if (disposed) return;
3657
- stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
3658
- tui.requestRender();
3659
- }).finally(() => {
3660
- stopRequested = false;
3661
- if (!disposed) tui.requestRender();
3662
- });
3663
- };
3664
- const timer = setInterval(() => {
3665
- if (refreshing || stopRequested) return;
3666
- refreshing = true;
3667
- const selectedOption = options[selectedIndex];
3668
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3669
- }, 1000);
3670
- timer.unref();
3671
- return borderWorkflowOverlay({
3672
- render(width: number) {
3673
- const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
3674
- const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
3675
- const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
3676
- const layout = dashboardLayout();
3677
- const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} navigate${dashboardLines.length > layout.dashboardViewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · ${keyLabel("tui.select.confirm", "enter")} select · ${keyLabel("tui.select.cancel", "esc")} close · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3678
- const compact = [...dashboardLines, "", ...actionLines, "", hint];
3679
- if (compact.length <= layout.rows) { dashboardOffset = 0; return compact; }
3680
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
3681
- dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
3682
- const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
3683
- return [
3684
- ...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
3685
- ...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
3686
- ...actionLines.slice(actionStart, actionStart + layout.actionViewport),
3687
- ...(layout.hintRows ? [hint] : []),
3688
- ];
3689
- },
3690
- invalidate() {},
3691
- handleInput(data: string) {
3692
- if (stopRequested) return;
3693
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
3694
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
3695
- else if (keybindings.matches(data, "tui.select.pageUp")) {
3696
- dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
3697
- }
3698
- else if (keybindings.matches(data, "tui.select.pageDown")) {
3699
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
3700
- }
3701
- else if (keybindings.matches(data, "tui.select.confirm")) {
3702
- if (options[selectedIndex] === "Stop") requestStop();
3703
- else done(options[selectedIndex]);
3704
- }
3705
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3706
- tui.requestRender();
3707
- },
3708
- dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
3709
- }, theme);
3710
- }, { overlay: true })
3711
- : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
3712
- if (!actionChoice || actionChoice === "Close") return;
3713
- if (actionChoice === "Agents...") { await selectAgent(view); continue; }
3714
- if (actionChoice === "Refresh") continue;
3715
- if (actionChoice === "View script") {
3716
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3717
- const highlighted = highlightCode(view.script, "javascript");
3718
- let offset = 0;
3719
- let renderedLines: string[] = [];
3720
- const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
3721
- const move = (delta: number) => {
3722
- const maxOffset = Math.max(0, renderedLines.length - viewport());
3723
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
3724
- };
3725
- return borderWorkflowOverlay({
3726
- render(width: number) {
3727
- renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3728
- const maxOffset = Math.max(0, renderedLines.length - viewport());
3729
- offset = Math.min(offset, maxOffset);
3730
- return [
3731
- theme.fg("accent", "Workflow script"),
3732
- ...renderedLines.slice(offset, offset + viewport()),
3733
- "",
3734
- theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
3735
- ];
3736
- },
3737
- invalidate() {},
3738
- handleInput(data: string) {
3739
- if (keybindings.matches(data, "tui.select.up")) move(-1);
3740
- else if (keybindings.matches(data, "tui.select.down")) move(1);
3741
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
3742
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
3743
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3744
- tui.requestRender();
3745
- },
3746
- }, theme);
3747
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3748
- continue;
3749
- }
3750
- const copy = view.copies.get(actionChoice);
3751
- if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
3752
- if (actionChoice.startsWith("Review ")) {
3753
- const checkpoint = view.reviews.get(actionChoice);
3754
- if (!checkpoint) continue;
3755
- const decision = await ctx.ui.custom<"Approve" | "Reject" | undefined>((tui, theme, keybindings, done) => {
3756
- const options = ["Approve", "Reject", "Cancel"];
3757
- let selectedIndex = 0;
3758
- let offset = 0;
3759
- let renderedLines: string[] = [];
3760
- const layout = () => {
3761
- const rows = Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
3762
- const compactControls = rows < 4;
3763
- const titleRows = rows >= 5 ? 1 : 0;
3764
- const hintRows = rows >= 8 ? 1 : 0;
3765
- const separatorRows = rows >= 8 ? 1 : 0;
3766
- const controlRows = compactControls ? 1 : options.length;
3767
- const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
3768
- return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
3769
- };
3770
- const move = (delta: number) => {
3771
- const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
3772
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
3773
- };
3774
- return borderWorkflowOverlay({
3775
- render(width: number) {
3776
- renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
3777
- const currentLayout = layout();
3778
- const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
3779
- offset = Math.min(offset, maxOffset);
3780
- const hint = truncateToVisualLines(theme.fg("dim", "↑↓/pgup/pgdn scroll · enter select · esc cancel"), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3781
- const controls = currentLayout.compactControls
3782
- ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
3783
- : options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
3784
- return [
3785
- ...(currentLayout.titleRows ? [theme.fg("accent", "Checkpoint review")] : []),
3786
- ...renderedLines.slice(offset, offset + currentLayout.contentViewport),
3787
- ...(currentLayout.separatorRows ? [""] : []),
3788
- ...controls,
3789
- ...(currentLayout.hintRows ? [hint] : []),
3790
- ];
3791
- },
3792
- invalidate() {},
3793
- handleInput(data: string) {
3794
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
3795
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
3796
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-layout().contentViewport);
3797
- else if (keybindings.matches(data, "tui.select.pageDown")) move(layout().contentViewport);
3798
- else if (keybindings.matches(data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
3799
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3800
- tui.requestRender();
3801
- },
3802
- }, theme);
3803
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3804
- if (decision) {
3805
- const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
3806
- if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
3807
- }
3808
- continue;
3809
- }
3810
- const actionCommand = view.actions.get(actionChoice);
3811
- if (!actionCommand) { ctx.ui.notify(`Cannot select workflow action: ${actionChoice}`, "warning"); continue; }
3812
- const outcome = await runAction(actionCommand, true);
3813
- if (outcome === "picker") { stores = await loadStores(); break; }
3814
- }
3815
- }
3816
- }
3817
- await runAction(command, false);
3818
- },
3819
- });
3820
- pi.on("session_shutdown", async () => {
3821
- try {
3822
- await Promise.all([...runs.entries()].map(async ([runId, run]) => {
3823
- if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
3824
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
3825
- try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
3826
- run.abortController.abort();
3827
- run.execution?.cancel();
3828
- await scheduler.cancelRun(runId);
3829
- }
3830
- await run.completion?.catch(() => undefined);
3831
- }));
3832
- await scheduler.flush();
3833
- } finally {
3834
- await releaseSessionLease();
3835
- resetWorkflowRegistry();
3836
- }
3837
- });
3838
- }
3839
-
3840
- function displayAgentName(label: string | undefined, role: string | undefined, model: ModelSpec): string {
3841
- return label ?? role ?? model.model;
3842
- }
3843
-
3844
- function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
3845
- try {
3846
- const parsed = parseModelReference(value);
3847
- return { ...parsed, ...(parsed.thinking || !fallback.thinking ? {} : { thinking: fallback.thinking }) };
3848
- } catch {
3849
- return fallback;
3850
- }
3851
- }
3852
-
3853
- export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
3854
- export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
1
+ export * from "./types.js";
2
+ export * from "./utils.js";
3
+ export * from "./budget.js";
4
+ export * from "./validation.js";
5
+ export * from "./registry.js";
6
+ export * from "./execution.js";
7
+ export * from "./host.js";
8
+ export { default } from "./host.js";
9
+ export { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
10
+ export type { AwaitingCheckpoint, CompletedOperation, NativeSessionReference, PendingWorkflowDecision, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
3855
11
  export { FairAgentScheduler, WorkflowAgentExecutor } 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";
12
+ export type { AgentAttempt, AgentBudgetHooks, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentProviderFailure, AgentProviderRecovery, AgentToolCallProgress, AgentSetup, AgentSetupContext, AgentSetupHook, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput } from "./agent-execution.js";
3857
13
  export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
3858
- export type { DoctorDiagnostic, DoctorFunction, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust } from "./doctor.js";
14
+ export type { DoctorDiagnostic, DoctorFunction, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust } from "./doctor.js";
15
+ export { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport } from "./doctor-cleanup.js";
16
+ export type { CleanupFailure, CleanupRunResult, CleanupSessionReport, DoctorCleanupOptions, DoctorCleanupReport } from "./doctor-cleanup.js";