pi-extensible-workflows 0.3.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,47 +1,80 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { fork, type ChildProcess } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { basename, dirname, extname, join } from "node:path";
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
7
  import { fileURLToPath } from "node:url";
8
8
  import * as acorn from "acorn";
9
9
  import { Script } from "node:vm";
10
10
  import { Type } from "@earendil-works/pi-ai";
11
11
  import { Value } from "typebox/value";
12
12
  import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type SessionFactory } from "./agent-execution.js";
13
+ import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
14
14
  import { transcriptLines } from "./session-inspector.js";
15
- import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
15
+ import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
16
16
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
17
17
 
18
- export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"] as const;
18
+ export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
19
19
  export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
20
- export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
21
- export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
20
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
21
+ export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
22
+ export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
23
+ export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
24
+ export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
25
+ export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
26
+ export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
27
+ export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
28
+ export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
29
+ export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
30
+ export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
31
+ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
22
32
  const SETTLED_AGENT_STATES: ReadonlySet<AgentState> = new Set(["completed", "failed", "cancelled"]);
23
33
  export const ERROR_CODES = [
24
- "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
25
- "RUN_LIMIT_EXCEEDED", "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
26
- "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
27
- ] as const;
34
+ "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
35
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
36
+ "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
37
+ ] as const;
28
38
 
29
39
  export type RunState = (typeof RUN_STATES)[number];
30
40
  export type AgentState = (typeof AGENT_STATES)[number];
31
41
  export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
32
42
  export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
33
43
  export type JsonSchema = { [key: string]: JsonValue };
34
-
44
+ export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
45
+ export interface BudgetLimits { soft?: number; hard?: number }
46
+ export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
47
+ export type WorkflowBudgetPatch = Partial<Record<BudgetDimension, BudgetLimits | { soft?: number | null; hard?: number | null } | null>>;
48
+ export interface WorkflowBudgetUsage { tokens: number; costUsd: number; durationMs: number; agentLaunches: number }
49
+ export type BudgetEventType = "soft_crossed" | "hard_overrun" | "hard_exhausted" | "adjustment_requested" | "adjustment_approved" | "adjustment_rejected";
50
+ export interface BudgetEvent { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; at: number; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
51
+ export interface BudgetApprovalRequest { kind: "budget"; proposalId: string; runId: string; consumed: WorkflowBudgetUsage; previous: WorkflowBudget; proposed: WorkflowBudget; budgetVersion: number }
35
52
  export interface WorkflowErrorShape { code: WorkflowErrorCode; message: string }
53
+ export interface WorkflowEventBase { runId: string; sessionId: string; workflowName: string; cwd: string; runDirectory: string; timestamp: number }
54
+ export type WorkflowRunStartedEvent = WorkflowEventBase;
55
+ export type WorkflowRunResumedEvent = WorkflowEventBase;
56
+ export interface WorkflowRunStateChangedEvent extends WorkflowEventBase { previousState: RunState; state: RunState; reason?: string; errorCode?: WorkflowErrorCode }
57
+ export interface WorkflowRunCompletedEvent extends WorkflowEventBase { resultPath: string }
58
+ export interface WorkflowRunFailedEvent extends WorkflowEventBase { error: WorkflowErrorShape }
59
+ 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 }
60
+ export interface WorkflowPhaseChangedEvent extends WorkflowEventBase { previousPhase?: string; phase: string }
61
+ export type WorkflowCheckpointState = "awaiting" | "approved" | "rejected";
62
+ export interface WorkflowCheckpointStateChangedEvent extends WorkflowEventBase { name: string; state: WorkflowCheckpointState }
63
+ export interface WorkflowBudgetEvent extends WorkflowEventBase { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
36
64
  export interface ModelSpec { provider: string; model: string; thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" }
37
65
  export interface WorkflowMetadata { name: string; description?: string }
38
- export interface WorkflowSettings { concurrency: number; maxAgentLaunches: number }
39
- 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 } }
40
- export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; parentBreadcrumb?: string; role?: 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 }
41
- export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape }
42
- const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 2;
43
- export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
44
- export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string> }
66
+ export interface WorkflowSettings { concurrency: number; modelAliases?: Readonly<Record<string, string>>; disabledAgentResources?: Readonly<AgentResourceExclusions> }
67
+ export interface AgentResourceExclusions { skills: readonly string[]; extensions: readonly string[] }
68
+ export interface AgentResourcePolicy { globalSettingsPath: string; projectSettingsPath: string; projectTrusted: boolean; global: AgentResourceExclusions; project: AgentResourceExclusions; effective: AgentResourceExclusions; unmatchedSkills: string[]; unmatchedExtensions: string[] }
69
+ export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelSpec; tools: readonly string[]; cwd: string; disabledAgentResources?: { skills: readonly string[]; extensions: readonly string[]; unmatchedSkills: readonly string[]; unmatchedExtensions: readonly string[] } }
70
+ export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
71
+ export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
72
+ export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
73
+ export interface WorkflowRunEvent { type: string; message: string }
74
+ export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
75
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
76
+ export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
77
+ export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; skipModelAvailability?: boolean }
45
78
  export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
46
79
  export interface WorkflowOrchestrationContext {
47
80
  agent: (...args: readonly unknown[]) => Promise<JsonValue>;
@@ -54,11 +87,14 @@ export interface WorkflowOrchestrationContext {
54
87
  log: (message: string) => void;
55
88
  }
56
89
  export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
57
- export interface WorkflowFunctionContext extends WorkflowOrchestrationContext { run: Readonly<WorkflowRunContext> }
90
+ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
91
+ run: Readonly<WorkflowRunContext>;
92
+ invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
93
+ }
58
94
  export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
59
95
  export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
60
96
  export interface WorkflowScriptDefinition { description: string; script: string }
61
- export interface WorkflowExtension { namespace: string; version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; workflows?: Readonly<Record<string, WorkflowScriptDefinition>> }
97
+ export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; workflows?: Readonly<Record<string, WorkflowScriptDefinition>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
62
98
  export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
63
99
 
64
100
  export class WorkflowError extends Error {
@@ -86,6 +122,7 @@ function workflowDetail(message: string): string {
86
122
  }
87
123
 
88
124
  const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string> = {
125
+ CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
89
126
  INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
90
127
  INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
91
128
  INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
@@ -97,7 +134,6 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
97
134
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
98
135
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
99
136
  UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
100
- RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
101
137
  RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
102
138
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
103
139
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
@@ -107,6 +143,7 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
107
143
  WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
108
144
  WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
109
145
  RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
146
+ BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
110
147
  INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
111
148
  };
112
149
  export function formatWorkflowFailure(error: unknown): string {
@@ -133,13 +170,12 @@ function mainAgentError(error: unknown): WorkflowError {
133
170
  return presented;
134
171
  }
135
172
 
136
-
137
173
  export class RunLifecycle {
138
174
  #state: RunState;
139
175
  #active = 0;
140
176
  #waiters: Array<() => void> = [];
141
177
 
142
- constructor(state: RunState = "running", private readonly changed?: (state: RunState) => void | Promise<void>) { this.#state = state; }
178
+ constructor(state: RunState = "running", private readonly changed?: (state: RunState, previousState: RunState, reason?: string) => void | Promise<void>) { this.#state = state; }
143
179
  get state(): RunState { return this.#state; }
144
180
 
145
181
  async enter(): Promise<void> {
@@ -150,31 +186,31 @@ export class RunLifecycle {
150
186
 
151
187
  async leave(): Promise<void> {
152
188
  if (this.#active > 0) this.#active -= 1;
153
- if (this.#state === "pausing" && this.#active === 0) await this.#set("paused");
189
+ if (this.#state === "pausing" && this.#active === 0) await this.#set("paused", "pause");
154
190
  }
155
191
 
156
192
  async enterAwaitingInput(): Promise<void> {
157
193
  while (this.#state === "pausing" || this.#state === "paused") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
158
194
  if (this.#state === "awaiting_input") return;
159
195
  if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
160
- await this.#set("awaiting_input");
196
+ await this.#set("awaiting_input", "awaiting_input");
161
197
  }
162
198
 
163
199
  async resolveAwaitingInput(): Promise<void> {
164
200
  if (this.#state !== "awaiting_input") return;
165
- await this.#set("running");
201
+ await this.#set("running", "checkpoint_resolved");
166
202
  for (const resolve of this.#waiters.splice(0)) resolve();
167
203
  }
168
204
 
169
205
  async pause(): Promise<void> {
170
206
  if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
171
- await this.#set("pausing");
172
- if (this.#active === 0) await this.#set("paused");
207
+ await this.#set("pausing", "pause");
208
+ if (this.#active === 0) await this.#set("paused", "pause");
173
209
  }
174
210
 
175
211
  async resume(): Promise<void> {
176
- if (this.#state !== "paused" && this.#state !== "interrupted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
177
- await this.#set("running");
212
+ if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
213
+ await this.#set("running", "resume");
178
214
  for (const resolve of this.#waiters.splice(0)) resolve();
179
215
  }
180
216
 
@@ -184,33 +220,210 @@ export class RunLifecycle {
184
220
  await this.enter();
185
221
  }
186
222
 
187
- async terminal(state: "completed" | "failed" | "stopped" | "interrupted"): Promise<void> {
223
+ async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
188
224
  if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
189
- await this.#set(state);
225
+ await this.#set(state, reason ?? state);
190
226
  for (const resolve of this.#waiters.splice(0)) resolve();
191
227
  }
192
228
 
193
- async #set(state: RunState): Promise<void> { this.#state = state; await this.changed?.(state); }
229
+ async #set(state: RunState, reason?: string): Promise<void> {
230
+ const previousState = this.#state;
231
+ this.#state = state;
232
+ await this.changed?.(state, previousState, reason);
233
+ }
194
234
  }
195
235
 
196
- export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8, maxAgentLaunches: 1000 });
236
+ export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
197
237
 
198
238
  function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
199
239
  function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
240
+ export { object as isObject };
241
+ function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
242
+ function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
243
+ export function validateBudget(value: unknown): WorkflowBudget | undefined {
244
+ if (value === undefined) return undefined;
245
+ if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
246
+ const result: WorkflowBudget = {};
247
+ for (const [dimension, raw] of Object.entries(value)) {
248
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
249
+ if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
250
+ if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
251
+ const isCost = dimension === "costUsd";
252
+ 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"}`);
253
+ 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`);
254
+ const limits: BudgetLimits = {};
255
+ if (raw.soft !== undefined) limits.soft = raw.soft as number;
256
+ if (raw.hard !== undefined) limits.hard = raw.hard as number;
257
+ if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
258
+ }
259
+ return Object.freeze(result);
260
+ }
261
+ export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
262
+ if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
263
+ const result: WorkflowBudgetPatch = {};
264
+ for (const [dimension, raw] of Object.entries(value)) {
265
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
266
+ if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
267
+ if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
268
+ const limits: { soft?: number | null; hard?: number | null } = {};
269
+ for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
270
+ if (raw[key] === null) limits[key] = null;
271
+ else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
272
+ }
273
+ 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`);
274
+ (result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
275
+ }
276
+ return result;
277
+ }
278
+ function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
279
+ export class WorkflowBudgetRuntime {
280
+ readonly #now: () => number;
281
+ readonly #onChange: (() => void) | undefined;
282
+ readonly #injected = new Set<string>();
283
+ readonly #seen = new Set<string>();
284
+ #active: boolean;
285
+ #activeSince: number | undefined;
286
+ #usage: WorkflowBudgetUsage;
287
+ #events: BudgetEvent[];
288
+ #turnAccounting?: { input: number; output: number; cost: number };
289
+ constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) {
290
+ this.#now = options.now ?? (() => Date.now());
291
+ this.#onChange = options.onChange;
292
+ this.#active = options.active ?? true;
293
+ this.#activeSince = this.#active ? this.#now() : undefined;
294
+ this.#usage = budgetUsage(usage);
295
+ this.#events = [...events];
296
+ for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type);
297
+ }
298
+ get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
299
+ get events(): readonly BudgetEvent[] { return this.#events; }
300
+ get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
301
+ checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
302
+ beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
303
+ beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
304
+ 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 }; }
305
+ #applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void {
306
+ this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
307
+ this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
308
+ this.#evaluate();
309
+ if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]);
310
+ }
311
+ instruction(agentId = "agent"): string | undefined {
312
+ if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined;
313
+ this.#injected.add(agentId);
314
+ 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.`;
315
+ }
316
+ forAgent(agentId: string): AgentBudgetHooks {
317
+ let attempt = 0;
318
+ let previous: { input: number; output: number; cost: number } | undefined;
319
+ return {
320
+ beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
321
+ beforeTurn: () => { this.beforeTurn(); },
322
+ afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
323
+ instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
324
+ };
325
+ }
326
+ transition(state: RunState): void {
327
+ const active = state === "running";
328
+ if (active === this.#active) return;
329
+ if (active) { this.#active = true; this.#activeSince = this.#now(); }
330
+ else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; }
331
+ this.#onChange?.();
332
+ }
333
+ #syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
334
+ #hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
335
+ #checkHard(dimensions: readonly BudgetDimension[]): void {
336
+ const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
337
+ if (!exhausted.length) return;
338
+ this.#record("hard_exhausted", exhausted);
339
+ const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", ");
340
+ throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`);
341
+ }
342
+ #evaluate(): void {
343
+ const budget = this.budget;
344
+ if (!budget) return;
345
+ const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
346
+ if (soft.length) this.#record("soft_crossed", soft);
347
+ const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
348
+ if (overrun.length) this.#record("hard_overrun", overrun);
349
+ }
350
+ #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?.(); }
351
+ recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
352
+ snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
353
+ }
354
+ export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined {
355
+ const merged: WorkflowBudget = structuredClone(budget ?? {});
356
+ for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
357
+ const value = patch[dimension];
358
+ if (value === null) { Reflect.deleteProperty(merged, dimension); continue; }
359
+ const next: BudgetLimits = { ...(merged[dimension] ?? {}) };
360
+ 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; }
361
+ if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension);
362
+ }
363
+ return validateBudget(merged);
364
+ }
365
+ 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; }
366
+ export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] {
367
+ if (!budget) return [];
368
+ return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
369
+ }
370
+ export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
200
371
  function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; }
372
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
373
+ const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
201
374
  export function parseModelReference(value: string): ModelSpec {
202
375
  const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
203
376
  if (!match?.[1] || !match[2]) fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
204
377
  const thinking = match[3];
205
- if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
378
+ if (thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
206
379
  return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking as NonNullable<ModelSpec["thinking"]> } : {}) };
207
380
  }
208
381
 
209
- function modelCapability(value: string): string {
210
- const parsed = parseModelReference(value);
382
+ function aliasError(message: string, settingsPath: string): never { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
383
+ export function validateModelAliases(value: unknown, settingsPath = "workflow settings"): Readonly<Record<string, string>> {
384
+ if (!object(value)) aliasError("modelAliases must be an object", settingsPath);
385
+ const aliases: Record<string, string> = {};
386
+ for (const [name, target] of Object.entries(value)) {
387
+ if (!MODEL_ALIAS_NAME.test(name)) aliasError(`Invalid model alias name: ${name}`, settingsPath);
388
+ if (typeof target !== "string" || !target.trim()) aliasError(`Invalid model alias target for ${name}`, settingsPath);
389
+ try { parseModelReference(target); } catch (error) { aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath); }
390
+ aliases[name] = target;
391
+ }
392
+ return Object.freeze(aliases);
393
+ }
394
+
395
+ function unknownModel(value: string, target: string | undefined, settingsPath?: string): never {
396
+ const resolved = target ? ` resolved to ${target}` : "";
397
+ const path = settingsPath ? ` (settings: ${settingsPath})` : "";
398
+ fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
399
+ }
400
+
401
+ export function resolveModelReference(value: string, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
402
+ const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
403
+ if (target !== undefined) {
404
+ try { return parseModelReference(target); } catch { unknownModel(value, target, settingsPath); }
405
+ }
406
+ if (value.includes("/")) return parseModelReference(value);
407
+ const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
408
+ const thinking = match?.[2];
409
+ if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) unknownModel(value, undefined, settingsPath);
410
+ const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
411
+ if (candidates.length === 1) {
412
+ const parsed = parseModelReference(candidates[0] as string);
413
+ return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
414
+ }
415
+ unknownModel(value, undefined, settingsPath);
416
+ }
417
+
418
+ function modelCapability(value: string, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string {
419
+ const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
211
420
  return `${parsed.provider}/${parsed.model}`;
212
421
  }
213
422
 
423
+ function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[] {
424
+ return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
425
+ }
426
+
214
427
  export interface CheckpointInput { name: string; prompt: string; context: JsonValue }
215
428
  export function validateCheckpoint(value: unknown): CheckpointInput {
216
429
  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");
@@ -220,25 +433,77 @@ export function validateCheckpoint(value: unknown): CheckpointInput {
220
433
  }
221
434
 
222
435
  export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
436
+ export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
437
+ const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
438
+ function normalizedResourcePath(value: string, settingsPath: string): string {
439
+ let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
440
+ if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
441
+ const resolved = resolve(dirname(settingsPath), expanded);
442
+ try { return realpathSync(resolved); } catch { return resolved; }
443
+ }
444
+ export function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions {
445
+ return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
446
+ }
447
+ function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
448
+ if (value === undefined) return undefined;
449
+ const base = `${settingsPath}.disabledAgentResources`;
450
+ if (!object(value)) fail(errorCode, `${base} must be an object`);
451
+ for (const key of Object.keys(value)) if (key !== "skills" && key !== "extensions") fail(errorCode, `${base}.${key} is not supported`);
452
+ const normalized: { skills: string[]; extensions: string[] } = { skills: [], extensions: [] };
453
+ for (const kind of ["skills", "extensions"] as const) {
454
+ const entries = value[kind];
455
+ if (entries === undefined) continue;
456
+ if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
457
+ const seen = new Set<string>();
458
+ for (const [index, entry] of entries.entries()) {
459
+ if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
460
+ let selector = entry.trim();
461
+ if (kind === "extensions") {
462
+ try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
463
+ }
464
+ if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
465
+ }
466
+ }
467
+ return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
468
+ }
223
469
  export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
224
470
  let parsed: unknown;
225
471
  try { parsed = JSON.parse(readFileSync(path, "utf8")); }
226
472
  catch (error) {
227
473
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
228
- fail("INVALID_SETTINGS", `Invalid workflow settings: ${errorText(error)}`);
474
+ fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
229
475
  }
230
- if (!object(parsed)) fail("INVALID_SETTINGS", "Workflow settings must be an object");
231
- const allowed = new Set(["concurrency", "maxAgentLaunches"]);
476
+ if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
477
+ const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
232
478
  const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
233
- if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
479
+ if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
234
480
  const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
235
- const maxAgentLaunches = parsed.maxAgentLaunches === undefined ? DEFAULT_SETTINGS.maxAgentLaunches : parsed.maxAgentLaunches;
236
- if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", "concurrency must be an integer from 1 to 16");
237
- if (!positiveInteger(maxAgentLaunches)) fail("INVALID_SETTINGS", "maxAgentLaunches must be a positive integer");
238
- return Object.freeze({ concurrency, maxAgentLaunches });
481
+ if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
482
+ const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
483
+ const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
484
+ return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
485
+ }
486
+ export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
487
+ const projectSettingsPath = workflowProjectSettingsPath(cwd);
488
+ const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
489
+ const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
490
+ const effective = mergeAgentResourceExclusions(global, project);
491
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
492
+ }
493
+ export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
494
+ const normalized = validateModelAliases(aliases, path);
495
+ let parsed: Record<string, unknown> = {};
496
+ try {
497
+ loadSettings(path);
498
+ parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
499
+ } catch (error) {
500
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
501
+ }
502
+ mkdirSync(dirname(path), { recursive: true });
503
+ atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
239
504
  }
240
505
 
241
- export function parseRoleMarkdown(content: string, strict = false): AgentDefinition {
506
+ export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
242
507
  if (!strict) {
243
508
  if (!content.startsWith("---\n")) return { prompt: content };
244
509
  const end = content.indexOf("\n---", 4);
@@ -264,12 +529,13 @@ export function parseRoleMarkdown(content: string, strict = false): AgentDefinit
264
529
  try { parsed = parseFrontmatter(content); }
265
530
  catch (error) { fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`); }
266
531
  if (!object(parsed.frontmatter)) fail("INVALID_METADATA", "Role frontmatter must be an object");
267
- const { model, thinking, tools, description } = parsed.frontmatter;
532
+ const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
268
533
  if (model !== undefined && (typeof model !== "string" || model.trim() === "")) fail("INVALID_METADATA", "Role model must be a non-empty string");
269
534
  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}`);
270
535
  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");
271
536
  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");
272
- 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()) } : {}) };
537
+ const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
538
+ 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 } : {}) };
273
539
  }
274
540
 
275
541
  const ROLE_DIRECTORY = "pi-extensible-workflows";
@@ -286,7 +552,7 @@ function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
286
552
  try {
287
553
  return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
288
554
  .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
289
- .map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(join(dir, entry.name), "utf8"), true)]));
555
+ .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
290
556
  } catch (error) {
291
557
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
292
558
  throw error;
@@ -300,11 +566,17 @@ function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefin
300
566
  export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
301
567
  return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
302
568
  }
303
- function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>): void {
569
+ 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 {
304
570
  for (const role of roles) {
305
571
  const definition = definitions[role];
306
572
  if (!definition) continue;
307
- if (definition.model !== undefined && !availableModels.has(modelCapability(definition.model))) fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${definition.model}`);
573
+ if (definition.model !== undefined) {
574
+ const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
575
+ if (!availableModels.has(resolved)) {
576
+ if (Object.prototype.hasOwnProperty.call(aliases, definition.model)) unknownModel(definition.model, resolved, settingsPath);
577
+ fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
578
+ }
579
+ }
308
580
  const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
309
581
  if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
310
582
  }
@@ -343,15 +615,25 @@ type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
343
615
  function astNode(value: unknown): value is acorn.AnyNode {
344
616
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
345
617
  }
618
+ function astChildren(node: acorn.AnyNode): acorn.AnyNode[] {
619
+ const children: acorn.AnyNode[] = [];
620
+ for (const value of Object.values(node) as unknown[]) {
621
+ if (Array.isArray(value)) {
622
+ for (const child of value) if (astNode(child)) children.push(child);
623
+ } else if (astNode(value)) children.push(value);
624
+ }
625
+ return children;
626
+ }
627
+ function workflowCallKind(node: acorn.AnyNode): WorkflowCallKind | undefined {
628
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return undefined;
629
+ const kind = node.callee.name as WorkflowCallKind;
630
+ return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
631
+ }
346
632
  function workflowCalls(program: acorn.Program): WorkflowCall[] {
347
633
  const calls: WorkflowCall[] = [];
348
634
  const visit = (node: acorn.AnyNode): void => {
349
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) calls.push(node as WorkflowCall);
350
- for (const value of Object.values(node) as unknown[]) {
351
- if (Array.isArray(value)) {
352
- for (const child of value as unknown[]) if (astNode(child)) visit(child);
353
- } else if (astNode(value)) visit(value);
354
- }
635
+ if (workflowCallKind(node)) calls.push(node as WorkflowCall);
636
+ for (const child of astChildren(node)) visit(child);
355
637
  };
356
638
  visit(program);
357
639
  return calls.sort((left, right) => left.start - right.start);
@@ -368,9 +650,9 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
368
650
  const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
369
651
  if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
370
652
  }
371
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
653
+ const operation = workflowCallKind(node);
654
+ if (operation) {
372
655
  const call = node as WorkflowCall;
373
- const operation = call.callee.name as StaticWorkflowCall["kind"];
374
656
  const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
375
657
  calls.push({ call, execution, structure: current.structure });
376
658
  for (const [index, argument] of call.arguments.entries()) {
@@ -380,16 +662,11 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
380
662
  }
381
663
  return;
382
664
  }
383
- for (const value of Object.values(node) as unknown[]) {
384
- if (Array.isArray(value)) {
385
- for (const child of value as unknown[]) if (astNode(child)) visit(child, current);
386
- } else if (astNode(value)) visit(value, current);
387
- }
665
+ for (const child of astChildren(node)) visit(child, current);
388
666
  };
389
667
  visit(program, { execution: "sequential", structure: [] });
390
668
  return calls.sort((left, right) => left.call.start - right.call.start);
391
669
  }
392
-
393
670
  function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
394
671
  const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
395
672
  if (node.type === "Identifier" && node.name === name) {
@@ -397,21 +674,17 @@ function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string)
397
674
  const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
398
675
  if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
399
676
  }
400
- for (const value of Object.values(node) as unknown[]) {
401
- if (Array.isArray(value)) {
402
- for (const child of value as unknown[]) if (astNode(child)) visit(child, node);
403
- } else if (astNode(value)) visit(value, node);
404
- }
677
+ for (const child of astChildren(node)) visit(child, node);
405
678
  };
406
679
  visit(program);
407
680
  }
408
-
409
681
  function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
410
682
  if (node.type === "Identifier" && node.name === name) return true;
411
- return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
683
+ return astChildren(node).some((child) => hasIdentifier(child, name));
412
684
  }
413
685
 
414
686
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
687
+ const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
415
688
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
416
689
 
417
690
  function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
@@ -429,13 +702,14 @@ function instrumentWorkflow(script: string): string {
429
702
  if (!body.trim()) return body;
430
703
  const program = parseWorkflow(body);
431
704
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
705
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
432
706
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
433
- const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
707
+ const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
434
708
  const edits = calls.flatMap((call) => {
435
709
  const callSite = `${String(call.start)}:${String(call.end)}`;
436
710
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
437
711
  return [
438
- { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
712
+ { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "conversation" ? INTERNAL_CONVERSATION_NAME : INTERNAL_WORKTREE_NAME },
439
713
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
440
714
  ];
441
715
  }).sort((left, right) => right.start - left.start);
@@ -512,14 +786,14 @@ function validateSchema(schema: unknown, at = "schema"): asserts schema is JsonS
512
786
  }
513
787
 
514
788
  const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
515
- function validateAgentOption(key: string, value: unknown): void {
789
+ function validateAgentOption(key: string, value: unknown, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
516
790
  switch (key) {
517
791
  case "label":
518
792
  if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent label must be a non-empty string");
519
793
  break;
520
794
  case "model":
521
- if (typeof value !== "string") fail("INVALID_METADATA", "agent model must be a string");
522
- parseModelReference(value);
795
+ if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent model must be a non-empty string");
796
+ if (aliases !== undefined) resolveModelReference(value, aliases, knownModels, settingsPath);
523
797
  break;
524
798
  case "thinking":
525
799
  if (typeof value !== "string" || !parseThinking(value)) fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
@@ -541,12 +815,9 @@ function validateAgentOption(key: string, value: unknown): void {
541
815
  break;
542
816
  }
543
817
  }
544
-
545
818
  function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue>> {
546
819
  if (!object(value) || !jsonValue(value)) fail("INVALID_METADATA", "agent options must be a JSON object");
547
- const unknown = Object.keys(value).find((key) => !AGENT_OPTION_KEYS.has(key));
548
- if (unknown) fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
549
- for (const [key, option] of Object.entries(value)) validateAgentOption(key, option);
820
+ for (const [key, option] of Object.entries(value)) if (AGENT_OPTION_KEYS.has(key)) validateAgentOption(key, option);
550
821
  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");
551
822
  return value;
552
823
  }
@@ -585,7 +856,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
585
856
  }
586
857
 
587
858
  export interface StaticWorkflowCall {
588
- kind: "agent" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
859
+ kind: WorkflowCallKind;
589
860
  start: number;
590
861
  end: number;
591
862
  name: string | null;
@@ -617,7 +888,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
617
888
  const first = callArgument(call, 0);
618
889
  const options = callArgument(call, 1);
619
890
  const placement = { execution, structure };
620
- if (kind === "agent") {
891
+ if (kind === "agent" || kind === "conversation") {
621
892
  const retries = staticValue(propertyNode(options, "retries"));
622
893
  const outputSchema = staticValue(propertyNode(options, "outputSchema"));
623
894
  const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
@@ -626,7 +897,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
626
897
  return key ? [key] : [];
627
898
  }) : [];
628
899
  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>;
629
- const base = { ...placement, kind, start: call.start, end: call.end, name: null, prompt: staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
900
+ 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")) };
630
901
  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 } : {}) };
631
902
  }
632
903
  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 };
@@ -634,21 +905,14 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
634
905
  });
635
906
  }
636
907
 
637
- function validateStaticAgentOptions(node: acorn.AnyNode | undefined): void {
908
+ function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
638
909
  if (node?.type !== "ObjectExpression") return;
639
- for (const property of node.properties) {
640
- if (property.type === "SpreadElement" || property.computed) continue;
641
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
642
- if (key && !AGENT_OPTION_KEYS.has(key)) fail("INVALID_METADATA", `Unknown agent option: ${key}`);
643
- }
644
- const role = propertyNode(node, "role");
645
- if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined)) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
910
+ const options = staticValue(node);
911
+ 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");
646
912
  for (const key of AGENT_OPTION_KEYS) {
647
913
  const value = staticValue(propertyNode(node, key));
648
- if (value.known) validateAgentOption(key, value.value);
914
+ if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
649
915
  }
650
- const options = staticValue(node);
651
- 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");
652
916
  }
653
917
 
654
918
  function validateStaticWithWorktree(call: WorkflowCall): void {
@@ -662,17 +926,19 @@ function validateStaticWithWorktree(call: WorkflowCall): void {
662
926
  }
663
927
  }
664
928
 
665
- export interface WorkflowCatalogFunction { name: string; namespace: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
666
- export interface WorkflowCatalogVariable { name: string; namespace: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
667
- export interface WorkflowCatalogWorkflow { name: string; namespace: string; version: string; headline: string; extensionDescription: string; description: string }
668
- export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[] }
669
- const RESERVED_GLOBALS = new Set(["agent", "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"]);
929
+ export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
930
+ export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
931
+ export interface WorkflowCatalogWorkflow { name: string; version: string; headline: string; extensionDescription: string; description: string }
932
+ export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[]; modelAliases?: Readonly<Record<string, string>> }
933
+ const RESERVED_GLOBALS = new Set(["agent", "conversation", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
670
934
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
671
935
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
672
936
 
673
937
  export class WorkflowRegistry {
674
- readonly #extensions = new Map<string, Readonly<WorkflowExtension>>();
938
+ readonly #extensions = new Set<Readonly<WorkflowExtension>>();
675
939
  readonly #globals = new Map<string, string>();
940
+ readonly #workflows = new Map<string, WorkflowScriptDefinition>();
941
+ readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
676
942
  #frozen = false;
677
943
 
678
944
  get frozen(): boolean { return this.#frozen; }
@@ -680,89 +946,99 @@ export class WorkflowRegistry {
680
946
 
681
947
  register(extension: WorkflowExtension): void {
682
948
  if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
683
- if (!object(extension) || Object.keys(extension).some((key) => !["namespace", "version", "headline", "description", "functions", "variables", "workflows"].includes(key)) || typeof extension.namespace !== "string" || typeof extension.version !== "string" || !IDENTIFIER.test(extension.namespace) || !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 namespace, semantic version, headline, and description");
684
- if (this.#extensions.has(extension.namespace)) fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
949
+ if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "workflows", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
685
950
  const functions = extension.functions ?? {};
686
951
  const variables = extension.variables ?? {};
687
952
  const workflows = extension.workflows ?? {};
688
- if (!object(functions) || !object(variables) || !object(workflows) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, or workflows");
953
+ const agentSetupHooks = extension.agentSetupHooks ?? {};
954
+ if (!object(functions) || !object(variables) || !object(workflows) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0 && Object.keys(agentSetupHooks).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
689
955
  const names = [...Object.keys(functions), ...Object.keys(variables)];
690
- if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", `Global name collision inside ${extension.namespace}`);
956
+ if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
691
957
  for (const name of names) {
692
- if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${extension.namespace}.${name}`);
958
+ if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
693
959
  if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
694
- const owner = this.#globals.get(name);
695
- if (owner) fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
960
+ if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
696
961
  }
697
962
  for (const [name, fn] of Object.entries(functions)) {
698
- 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: ${extension.namespace}.${name}`);
699
- validateSchema(fn.input, `${extension.namespace}.${name} input`);
700
- validateSchema(fn.output, `${extension.namespace}.${name} output`);
701
- if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${extension.namespace}.${name} input must describe one object`);
963
+ 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}`);
964
+ validateSchema(fn.input, `${name} input`);
965
+ validateSchema(fn.output, `${name} output`);
966
+ if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
702
967
  }
703
968
  for (const [name, variable] of Object.entries(variables)) {
704
- 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: ${extension.namespace}.${name}`);
705
- validateSchema(variable.schema, `${extension.namespace}.${name} schema`);
969
+ if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
970
+ validateSchema(variable.schema, `${name} schema`);
706
971
  }
707
972
  for (const [name, workflow] of Object.entries(workflows)) {
708
- if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim()) fail("INVALID_METADATA", `Invalid workflow script: ${extension.namespace}.${name}`);
973
+ if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim()) fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
974
+ if (this.#workflows.has(name)) fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
709
975
  parseWorkflow(workflow.script);
710
976
  }
711
- const stored = deepFreeze({ ...extension, functions, variables, workflows });
712
- this.#extensions.set(extension.namespace, stored);
713
- for (const name of names) this.#globals.set(name, `${extension.namespace}.${name}`);
977
+ for (const [name, hook] of Object.entries(agentSetupHooks)) {
978
+ if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
979
+ if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
980
+ }
981
+ const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
982
+ this.#extensions.add(stored);
983
+ for (const name of names) this.#globals.set(name, name);
984
+ for (const [name, workflow] of Object.entries(workflows)) this.#workflows.set(name, workflow);
985
+ for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
714
986
  }
715
987
 
716
988
  workflow(name: string): WorkflowScriptDefinition {
717
- const separator = name.indexOf(".");
718
- if (separator <= 0 || separator !== name.lastIndexOf(".") || !IDENTIFIER.test(name.slice(0, separator)) || !IDENTIFIER.test(name.slice(separator + 1))) fail("MISSING_WORKFLOW", `Registered workflows require a qualified namespace.name: ${name}`);
719
- const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
989
+ if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
990
+ const workflow = this.#workflows.get(name);
720
991
  if (!workflow) fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
721
992
  return workflow;
722
993
  }
723
994
 
724
995
  workflows(): Readonly<Record<string, WorkflowScriptDefinition>> {
725
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.workflows ?? {}).map(([name, workflow]) => [`${namespace}.${name}`, workflow]))));
996
+ return Object.freeze(Object.fromEntries(this.#workflows));
726
997
  }
727
998
 
728
999
  catalog(): WorkflowCatalog {
729
1000
  const functions: WorkflowCatalogFunction[] = [];
730
1001
  const variables: WorkflowCatalogVariable[] = [];
731
1002
  const workflows: WorkflowCatalogWorkflow[] = [];
732
- for (const [namespace, extension] of this.#extensions) {
733
- for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
734
- for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
735
- for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name: `${namespace}.${name}`, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1003
+ for (const extension of this.#extensions) {
1004
+ for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
1005
+ for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
1006
+ for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
736
1007
  }
737
- const sort = (left: { namespace: string; name: string }, right: { namespace: string; name: string }) => left.namespace.localeCompare(right.namespace) || left.name.localeCompare(right.name);
738
- return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort((left, right) => left.name.localeCompare(right.name)) });
1008
+ let aliases: Readonly<Record<string, string>> | undefined;
1009
+ try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
1010
+ const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
1011
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
739
1012
  }
740
1013
 
741
- globals(): Readonly<Record<string, { namespace: string; name: string }>> {
742
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.keys(extension.functions ?? {}).map((name) => [name, { namespace, name }]))));
1014
+ globals(): Readonly<Record<string, { name: string }>> {
1015
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
743
1016
  }
744
1017
 
745
- async invokeFunction(namespace: string, name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
746
- const fn = this.#extensions.get(namespace)?.functions?.[name];
747
- if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${namespace}.${name}`);
748
- if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${namespace}.${name}`);
1018
+ async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
1019
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
1020
+ if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
1021
+ if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
749
1022
  const replayed = journal.get(path);
750
1023
  if (replayed !== undefined) {
751
- if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${namespace}.${name}`);
1024
+ if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
752
1025
  return structuredClone(replayed);
753
1026
  }
754
- const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
755
- if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
1027
+ const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
1028
+ if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
756
1029
  const stored = structuredClone(result);
757
1030
  journal.put(path, stored);
758
1031
  return structuredClone(stored);
759
1032
  }
760
1033
 
761
- variables(): readonly { namespace: string; name: string; variable: WorkflowVariable }[] {
762
- return [...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ namespace, name, variable })));
1034
+ variables(): readonly { name: string; variable: WorkflowVariable }[] {
1035
+ return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
1036
+ }
1037
+ agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
1038
+ return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
763
1039
  }
764
1040
  }
765
- type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables">;
1041
+ type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
766
1042
  interface WorkflowRegistryHost { api: WorkflowRegistryApi }
767
1043
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
768
1044
  const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
@@ -777,6 +1053,7 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
777
1053
  globals: () => registry.globals(),
778
1054
  invokeFunction: (...args) => registry.invokeFunction(...args),
779
1055
  variables: () => registry.variables(),
1056
+ agentSetupHooks: () => registry.agentSetupHooks(),
780
1057
  };
781
1058
  }
782
1059
  function workflowRegistryHost(): WorkflowRegistryHost {
@@ -807,11 +1084,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
807
1084
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
808
1085
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
809
1086
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
810
- workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as namespace.name" })),
1087
+ workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
811
1088
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
812
1089
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
813
1090
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
814
- maxAgentLaunches: Type.Optional(Type.Integer({ minimum: 1, description: "Total logical agent launches for the run, including nested agents; not concurrency" })),
1091
+ budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
815
1092
  });
816
1093
 
817
1094
  function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
@@ -830,33 +1107,42 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
830
1107
  const checkedMetadata = validateWorkflowMetadata(metadata);
831
1108
  const program = parseWorkflow(script);
832
1109
  if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1110
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
833
1111
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
834
1112
  validateDirectPrimitiveReferences(program, "withWorktree");
1113
+ validateDirectPrimitiveReferences(program, "conversation");
835
1114
  for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
836
1115
  const calls = workflowCalls(program);
837
1116
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
838
1117
  for (const call of calls) {
839
1118
  const operation = call.callee.name;
840
- if (operation === "agent") validateStaticAgentOptions(call.arguments[1]);
1119
+ if (operation === "agent" || operation === "conversation") {
1120
+ if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2)) fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
1121
+ validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1122
+ }
841
1123
  if (operation === "withWorktree") validateStaticWithWorktree(call);
842
1124
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
843
1125
  if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
844
1126
  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");
845
1127
  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");
846
1128
  }
847
- const agentCalls = calls.filter((call) => call.callee.name === "agent");
1129
+ const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
848
1130
  const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
849
1131
  const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
850
1132
  for (const [index, schema] of staticSchemas.entries()) validateSchema(schema, `agent outputSchema[${String(index)}]`);
851
1133
  const checkedSchemas = [...schemas, ...staticSchemas];
852
- const models = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "model")); return value === undefined ? [] : [modelCapability(value)]; });
1134
+ 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) }]; });
1135
+ const models = modelRefs.map(({ resolved }) => resolved);
853
1136
  const tools = agentCalls.flatMap((call) => {
854
1137
  const value = propertyNode(call.arguments[1], "tools");
855
1138
  return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
856
1139
  });
857
1140
  const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
858
- const missingModel = models.find((model) => !capabilities.models.has(model));
859
- if (missingModel) fail("UNKNOWN_MODEL", `Unknown model: ${missingModel}`);
1141
+ const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
1142
+ if (missingModel) {
1143
+ if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested)) unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
1144
+ fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
1145
+ }
860
1146
  const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
861
1147
  if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
862
1148
  const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
@@ -876,6 +1162,9 @@ export interface WorkflowValidationContext {
876
1162
  projectTrusted: boolean;
877
1163
  availableModels: ReadonlySet<string>;
878
1164
  rootTools: ReadonlySet<string>;
1165
+ modelAliases?: Readonly<Record<string, string>>;
1166
+ knownModels?: ReadonlySet<string>;
1167
+ settingsPath?: string;
879
1168
  }
880
1169
 
881
1170
 
@@ -891,6 +1180,7 @@ export function validateWorkflowLaunch(params: WorkflowValidationParameters, con
891
1180
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
892
1181
  }
893
1182
  function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
1183
+ if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
894
1184
  if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
895
1185
  const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
896
1186
  const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
@@ -901,9 +1191,11 @@ function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters
901
1191
  const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
902
1192
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
903
1193
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
904
- const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)) }, [], metadata);
1194
+ const aliases = context.modelAliases ?? {};
1195
+ const knownModels = context.knownModels ?? context.availableModels;
1196
+ 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);
905
1197
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
906
- validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
1198
+ validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
907
1199
  return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
908
1200
  }
909
1201
 
@@ -928,12 +1220,12 @@ export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapsh
928
1220
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
929
1221
  export const HEARTBEAT_TIMEOUT_MS = 5000;
930
1222
 
931
- export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string }
1223
+ export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string; conversation?: { name: string; turn: number } }
932
1224
  export interface WorkflowBridge {
933
1225
  agent?: (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
934
1226
  checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
935
- function?: (namespace: string, name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string) => Promise<JsonValue>;
936
- functions?: Readonly<Record<string, { namespace: string; name: string }>>;
1227
+ function?: (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath?: readonly string[]) => Promise<JsonValue>;
1228
+ functions?: Readonly<Record<string, { name: string }>>;
937
1229
  variables?: Readonly<Record<string, JsonValue>>;
938
1230
  phase?: (name: string) => void | Promise<void>;
939
1231
  log?: (message: string) => void | Promise<void>;
@@ -1005,6 +1297,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1005
1297
  const path = (...names) => names.map(encodeURIComponent).join("/");
1006
1298
  const inheritedAgentPath = new AsyncLocalStorage();
1007
1299
  const agentOccurrences = new Map();
1300
+ const conversationOccurrences = new Map();
1008
1301
  const worktreeOwners = new AsyncLocalStorage();
1009
1302
  const worktreeOccurrences = new Map();
1010
1303
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
@@ -1028,6 +1321,44 @@ const internalWithWorktree = async (...values) => {
1028
1321
  }
1029
1322
  return await worktreeOwners.run(owner, callback);
1030
1323
  };
1324
+ const internalConversation = (...values) => {
1325
+ const callSite = values.pop();
1326
+ if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
1327
+ const name = values[0];
1328
+ if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
1329
+ const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
1330
+ if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
1331
+ const inherited = inheritedAgentPath.getStore() || [];
1332
+ const occurrenceKey = JSON.stringify([inherited, callSite, name]);
1333
+ const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
1334
+ conversationOccurrences.set(occurrenceKey, occurrence);
1335
+ const fixedOptions = structuredClone(conversationOptions);
1336
+ const defaultTimeout = fixedOptions.timeoutMs;
1337
+ const defaultRetries = fixedOptions.retries;
1338
+ delete fixedOptions.timeoutMs;
1339
+ delete fixedOptions.retries;
1340
+ const worktreeOwner = worktreeOwners.getStore();
1341
+ let turn = 0;
1342
+ let active = false;
1343
+ return Object.freeze({
1344
+ run(prompt, turnOptions = {}) {
1345
+ if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
1346
+ 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");
1347
+ if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
1348
+ active = true;
1349
+ const turnNumber = turn + 1;
1350
+ const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
1351
+ const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
1352
+ const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
1353
+ Object.defineProperties(result, {
1354
+ toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
1355
+ toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1356
+ [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1357
+ });
1358
+ return result;
1359
+ },
1360
+ });
1361
+ };
1031
1362
  const internalAgent = (...values) => {
1032
1363
  const callSite = values.pop();
1033
1364
  if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
@@ -1097,16 +1428,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
1097
1428
  const phase = name => rpc("phase", [name]);
1098
1429
  const log = message => rpc("log", [message]);
1099
1430
  const functionOccurrences = new Map();
1100
- const functionPath = (namespace, name) => {
1431
+ const functionPath = name => {
1101
1432
  const inherited = inheritedAgentPath.getStore() || [];
1102
- const key = JSON.stringify([inherited, namespace, name]);
1433
+ const key = JSON.stringify([inherited, name]);
1103
1434
  const occurrence = (functionOccurrences.get(key) || 0) + 1;
1104
1435
  functionOccurrences.set(key, occurrence);
1105
- return path("function", ...inherited, namespace, name, String(occurrence));
1436
+ return path("function", ...inherited, name, String(occurrence));
1106
1437
  };
1107
1438
  const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
1108
1439
  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");
1109
- const result = rpc("function", [target.namespace, target.name, values[0], functionPath(target.namespace, target.name), worktreeOwners.getStore() || null]).then(unwrap);
1440
+ const inherited = inheritedAgentPath.getStore() || [];
1441
+ const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
1110
1442
  Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
1111
1443
  return result;
1112
1444
  }])));
@@ -1167,13 +1499,13 @@ const pipeline = async (operationName, items, stages) => {
1167
1499
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1168
1500
  };
1169
1501
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1170
- const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1502
+ const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1171
1503
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1172
1504
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1173
1505
  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;
1174
1506
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1175
1507
  const body = config.script;
1176
- Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
1508
+ Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_conversation,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalConversation, internalWithWorktree))
1177
1509
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1178
1510
  .catch(error => send({ type: "error", error: workerError(error) }))
1179
1511
  .finally(() => clearInterval(heartbeat));
@@ -1193,13 +1525,23 @@ function readAgentIdentity(value: unknown): AgentIdentity {
1193
1525
  const occurrence = value.occurrence;
1194
1526
  const worktreeOwner = value.worktreeOwner;
1195
1527
  const parentBreadcrumb = value.parentBreadcrumb;
1196
- 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)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1197
- return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
1528
+ const conversation = value.conversation;
1529
+ const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
1530
+ if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner) || conversation !== undefined && !parsedConversation) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1531
+ return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1198
1532
  }
1199
1533
 
1200
1534
  function agentIdentityPath(identity: AgentIdentity): string {
1201
1535
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1202
1536
  }
1537
+ function conversationIdentityPath(identity: AgentIdentity): string {
1538
+ if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1539
+ return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1540
+ }
1541
+ function conversationTurnPath(identity: AgentIdentity): string {
1542
+ if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1543
+ return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1544
+ }
1203
1545
  function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
1204
1546
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1205
1547
  }
@@ -1285,11 +1627,13 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
1285
1627
  value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
1286
1628
  }
1287
1629
  } else if (method === "function") {
1288
- const worktreeOwner = values[4] === undefined || values[4] === null ? undefined : typeof values[4] === "string" && values[4] ? values[4] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
1289
- if (!bridge.function || typeof values[0] !== "string" || typeof values[1] !== "string" || !object(values[2]) || typeof values[3] !== "string") fail("INTERNAL_ERROR", "function requires an available bridge, names, object input, and path");
1290
- const name = values[0] + "." + values[1];
1630
+ 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");
1631
+ const structuralPath = values[4] === undefined ? [] : values[4];
1632
+ if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim()))) fail("INTERNAL_ERROR", "function structural scope is invalid");
1633
+ 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");
1634
+ const name = values[0];
1291
1635
  try {
1292
- const result = await bridge.function(values[0], values[1], values[2], values[3], controller.signal, worktreeOwner);
1636
+ const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
1293
1637
  value = branded({ name, ok: true, value: result ?? null });
1294
1638
  } catch (error) {
1295
1639
  const typed = asWorkflowError(error);
@@ -1324,13 +1668,14 @@ function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessi
1324
1668
  return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
1325
1669
  }
1326
1670
 
1327
- export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">): Promise<void> {
1671
+ export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
1328
1672
  await store.updateState((run) => {
1329
1673
  const agent = run.agents.find((candidate) => candidate.id === id);
1330
1674
  if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1331
1675
  const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1676
+ const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
1332
1677
  const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
1333
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails: [{ ...active, accounting }] } : candidate), nativeSessions };
1678
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
1334
1679
  });
1335
1680
  }
1336
1681
 
@@ -1339,7 +1684,7 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
1339
1684
  const agent = run.agents.find((candidate) => candidate.id === id);
1340
1685
  if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1341
1686
  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 });
1342
- const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
1687
+ const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
1343
1688
  const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
1344
1689
  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))] };
1345
1690
  });
@@ -1347,19 +1692,47 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
1347
1692
 
1348
1693
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
1349
1694
 
1695
+ type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
1696
+ function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
1697
+ function agentGroupLabel(agents: readonly AgentRecord[]): string {
1698
+ const structural = agents[0]?.structuralPath ?? [];
1699
+ const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value): value is string => Boolean(value)))];
1700
+ return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
1701
+ }
1702
+ function agentGroups(agents: readonly AgentRecord[]): AgentGroup[] {
1703
+ const byId = new Map(agents.map((agent) => [agent.id, agent]));
1704
+ const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
1705
+ for (const [index, agent] of agents.entries()) {
1706
+ let depth = 0;
1707
+ for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
1708
+ const key = agentGroupKey(agent);
1709
+ const group = groups.get(key) ?? { agents: [] };
1710
+ group.agents.push({ agent, index, depth });
1711
+ groups.set(key, group);
1712
+ }
1713
+ return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
1714
+ }
1715
+ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => string): string[] {
1716
+ const groups = agentGroups(agents);
1717
+ const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
1718
+ return groups.flatMap((group) => [
1719
+ ...(grouped ? [` ${group.label}`] : []),
1720
+ ...group.entries.map((entry) => render(entry, grouped)),
1721
+ ]);
1722
+ }
1350
1723
  export function formatWorkflowProgress(run: PersistedRun, spinner = "◇"): string {
1351
1724
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
1352
- const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
1725
+ 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)`];
1353
1726
  if (run.phase) lines.push(` Phase: ${run.phase}`);
1727
+ lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
1354
1728
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
1355
- for (const [index, agent] of run.agents.entries()) {
1356
- let depth = 0;
1357
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
1729
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1358
1730
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
1359
- const indent = " ".repeat(depth + 1);
1731
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
1360
1732
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
1361
- lines.push(`${indent}#${String(index + 1)} ${icon} ${agent.parentBreadcrumb ? `${agent.parentBreadcrumb} > ` : ""}${agent.label ?? agent.name} [${agent.state}]${activity ? ` ${activity}` : ""}`);
1362
- }
1733
+ const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1734
+ return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
1735
+ }));
1363
1736
  return lines.join("\n");
1364
1737
  }
1365
1738
 
@@ -1387,8 +1760,29 @@ function workflowProgressBlock(run: PersistedRun) {
1387
1760
  invalidate() {},
1388
1761
  };
1389
1762
  }
1763
+ export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
1764
+ const usage = budgetUsage(run.usage);
1765
+ if (!run.budget || !Object.keys(run.budget).length) return ["Budget: unlimited"];
1766
+ const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
1767
+ for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) {
1768
+ const limits = run.budget[dimension];
1769
+ if (!limits || (limits.soft === undefined && limits.hard === undefined)) continue;
1770
+ const limit = limits.hard ?? limits.soft;
1771
+ const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
1772
+ const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
1773
+ lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
1774
+ }
1775
+ const events = run.budgetEvents ?? [];
1776
+ if (events.length) lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
1777
+ return lines;
1778
+ }
1779
+
1780
+ function formatCompactBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
1781
+ if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined)) return [];
1782
+ return formatBudgetStatus(run);
1783
+ }
1390
1784
 
1391
- const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0, running: 1, pausing: 2, paused: 3, interrupted: 4, failed: 5, queued: 6, stopped: 7, completed: 8 };
1785
+ 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 };
1392
1786
 
1393
1787
  function navigatorAttentionSort<T extends { loaded: { run: PersistedRun } }>(entries: readonly T[]): T[] {
1394
1788
  return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
@@ -1399,7 +1793,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
1399
1793
  for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
1400
1794
  return entries.map(({ store, loaded: { run } }) => {
1401
1795
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
1402
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1796
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1403
1797
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
1404
1798
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
1405
1799
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -1409,15 +1803,16 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
1409
1803
 
1410
1804
  function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>): string {
1411
1805
  const name = agent.label ?? agent.name;
1412
- const parts: string[] = [name];
1806
+ const parts: string[] = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
1413
1807
  const seen = new Set<string>([agent.id]);
1414
1808
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
1415
1809
  if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
1416
1810
  seen.add(parentId);
1417
1811
  const parent = byId.get(parentId);
1418
- if (parent) parts.unshift(parent.label ?? parent.name);
1812
+ if (parent) parts.push(parent.label ?? parent.name);
1419
1813
  else break;
1420
1814
  }
1815
+ parts.push(name);
1421
1816
  return parts.length > 1 ? parts.join(" > ") : name;
1422
1817
  }
1423
1818
 
@@ -1434,44 +1829,44 @@ function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): s
1434
1829
  return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
1435
1830
  }
1436
1831
 
1832
+
1437
1833
  export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
1834
+ void worktrees;
1438
1835
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
1439
1836
  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 });
1440
1837
  const hasAccounting = run.agents.some((a) => a.accounting);
1441
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1838
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1442
1839
  const header = `${glyph} ${run.workflowName}`;
1443
1840
  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(" · ");
1444
- const lines = [header, meta];
1841
+ const lines = [header, meta, ...formatCompactBudgetStatus(run)];
1445
1842
  if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
1843
+ if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
1446
1844
  lines.push("");
1447
1845
  const byId = new Map(run.agents.map((a) => [a.id, a]));
1448
- const activeStates = new Set(["running", "waiting_for_child", "queued", "retrying", "paused"]);
1449
- const prioritised = [...run.agents].sort((a, b) => {
1450
- const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
1451
- const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
1452
- return aActive - bActive;
1453
- });
1454
- for (const agent of prioritised) {
1846
+ const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
1455
1847
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
1456
- const breadcrumb = agentBreadcrumb(agent, byId);
1457
- const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
1458
- const policy = [`model=${model}`, `tools=${agent.tools.join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1848
+ const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1849
+ const setup = agent.attemptDetails?.at(-1)?.setup;
1850
+ const thinking = setup?.model.thinking ?? agent.model.thinking;
1851
+ const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
1852
+ const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1459
1853
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
1460
- const parts = [`${icon} ${breadcrumb}`, agent.state, ...policy, tokens].filter(Boolean);
1461
- lines.push(parts.join(" · "));
1854
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
1855
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
1462
1856
  if (agent.state === "failed" && agent.attemptDetails?.length) {
1463
1857
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
1464
- if (last?.error) lines.push(` error: ${last.error.code}: ${last.error.message}`);
1858
+ if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
1465
1859
  }
1466
1860
  const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
1467
- if (activity) lines.push(` ${activity}`);
1468
- }
1861
+ if (activity) result.push(`${indent} ${activity}`);
1862
+ return result.join("\n");
1863
+ };
1864
+ lines.push(...renderGroupedAgents(run.agents, render));
1469
1865
  if (checkpoints.length) { lines.push(""); for (const cp of checkpoints) lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`); }
1470
- if (worktrees.length) { lines.push(""); for (const wt of worktrees) lines.push(`branch ${wt.branch} (${wt.path})`); }
1471
1866
  return lines.join("\n");
1472
1867
  }
1473
1868
 
1474
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
1869
+ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
1475
1870
  const { run, snapshot } = loaded;
1476
1871
  const lines = [
1477
1872
  `Workflow: ${run.workflowName}`,
@@ -1479,29 +1874,32 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
1479
1874
  `Status: ${run.state}`,
1480
1875
  `Phase: ${run.phase ?? "(none)"}`,
1481
1876
  `Launch cwd: ${run.cwd}`,
1877
+ ...formatCompactBudgetStatus(run),
1482
1878
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
1483
1879
  ];
1484
1880
  if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
1881
+ if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
1882
+ const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
1883
+ if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
1485
1884
  lines.push("Agents / ownership:");
1486
1885
  if (!run.agents.length) lines.push(" (none)");
1487
- for (const agent of run.agents) {
1886
+ const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
1887
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1488
1888
  const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
1489
1889
  const role = agent.role ? ` role=${agent.role}` : "";
1490
1890
  const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
1491
1891
  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)}` : "";
1492
- lines.push(` ${agent.label ?? agent.name} (${agent.id}) state=${agent.state} parent=${agent.parentId ?? "root"} model=${model}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`);
1493
- for (const attempt of agent.attemptDetails ?? []) lines.push(` attempt ${String(attempt.attempt)} transcript=${attempt.sessionFile}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
1494
- for (const call of agent.toolCalls ?? []) lines.push(` tool ${call.name} state=${call.state}`);
1495
- }
1892
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
1893
+ 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}`];
1894
+ for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
1895
+ for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
1896
+ return result.join("\n");
1897
+ }));
1496
1898
  lines.push("Checkpoints:");
1497
1899
  if (!checkpoints.length) lines.push(" (none)");
1498
1900
  for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
1499
- lines.push("Worktrees / branches:");
1500
- if (!worktrees.length) lines.push(" (none)");
1501
- for (const worktree of worktrees) lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
1502
- lines.push("Native Pi transcript paths:");
1503
- if (!run.nativeSessions.length) lines.push(" (none)");
1504
- for (const session of run.nativeSessions) lines.push(` ${session.sessionId}: ${session.sessionFile}`);
1901
+ lines.push(`Worktrees: ${String(_worktrees.length)}`);
1902
+ lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
1505
1903
  return lines.join("\n");
1506
1904
  }
1507
1905
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
@@ -1535,6 +1933,90 @@ function deliver(pi: ExtensionAPI, content: string): void {
1535
1933
  function deliverFailure(pi: ExtensionAPI, name: string, error: unknown): void {
1536
1934
  deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
1537
1935
  }
1936
+
1937
+ type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
1938
+
1939
+ function safeEventError(error: unknown): WorkflowErrorShape {
1940
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
1941
+ return { code, message: `Workflow execution failed (${code})` };
1942
+ }
1943
+
1944
+ class WorkflowEventPublisher {
1945
+ #queues = new Map<string, Promise<void>>();
1946
+ #budgetEvents = new Map<string, Set<string>>();
1947
+ #worktrees = new Map<string, Set<string>>();
1948
+
1949
+ constructor(private readonly sink: WorkflowEventSink | undefined) {}
1950
+
1951
+ seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
1952
+ const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
1953
+ for (const event of events ?? []) seen.add(this.budgetKey(event));
1954
+ this.#budgetEvents.set(runId, seen);
1955
+ }
1956
+
1957
+ async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
1958
+ async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
1959
+
1960
+ async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
1961
+ await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason as WorkflowErrorCode) ? { errorCode: reason } : {}) });
1962
+ if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
1963
+ }
1964
+
1965
+ async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
1966
+ async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
1967
+ if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
1968
+ }
1969
+
1970
+ async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
1971
+ 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 });
1972
+ }
1973
+
1974
+ async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
1975
+ const previousById = new Map(previous.map((agent) => [agent.id, agent]));
1976
+ for (const agent of current) {
1977
+ const old = previousById.get(agent.id);
1978
+ if (!old || old.state !== agent.state || old.attempts !== agent.attempts) await this.agentState(store, metadata, old, agent);
1979
+ }
1980
+ }
1981
+
1982
+ async phase(store: RunStore, metadata: WorkflowMetadata, previousPhase: string | undefined, phase: string): Promise<void> {
1983
+ if (previousPhase !== phase) await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
1984
+ }
1985
+
1986
+ async checkpoint(store: RunStore, metadata: WorkflowMetadata, name: string, state: WorkflowCheckpointState): Promise<void> { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
1987
+
1988
+ async budget(store: RunStore, metadata: WorkflowMetadata, run: Pick<PersistedRun, "budgetEvents">): Promise<void> {
1989
+ const seen = this.#budgetEvents.get(store.runId) ?? new Set<string>();
1990
+ this.#budgetEvents.set(store.runId, seen);
1991
+ for (const event of run.budgetEvents ?? []) {
1992
+ const key = this.budgetKey(event);
1993
+ if (seen.has(key)) continue;
1994
+ seen.add(key);
1995
+ await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
1996
+ }
1997
+ }
1998
+
1999
+ async worktree(store: RunStore, metadata: WorkflowMetadata, worktree: WorktreeReference): Promise<void> {
2000
+ const seen = this.#worktrees.get(store.runId) ?? new Set<string>();
2001
+ this.#worktrees.set(store.runId, seen);
2002
+ if (seen.has(worktree.owner)) return;
2003
+ seen.add(worktree.owner);
2004
+ await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
2005
+ }
2006
+
2007
+ async #publish(store: RunStore, metadata: WorkflowMetadata, name: string, payload: Record<string, unknown>): Promise<void> {
2008
+ const base: WorkflowEventBase = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
2009
+ const previous = this.#queues.get(store.runId) ?? Promise.resolve();
2010
+ const next = previous.then(() => {
2011
+ try { void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined); } catch { /* Best effort: listeners cannot affect a run. */ }
2012
+ });
2013
+ this.#queues.set(store.runId, next.catch(() => undefined));
2014
+ await next;
2015
+ }
2016
+
2017
+ private budgetKey(event: BudgetEvent): string { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
2018
+ }
2019
+
1538
2020
  const inheritedHostAgentPath = new AsyncLocalStorage<readonly string[]>();
1539
2021
  const inheritedHostWorktreeOwner = new AsyncLocalStorage<string>();
1540
2022
 
@@ -1566,13 +2048,13 @@ function workflowRunContext(cwd: string, sessionId: string, runId: string, workf
1566
2048
 
1567
2049
  async function resolveWorkflowVariables(run: Readonly<WorkflowRunContext>, controller: AbortController, registry: WorkflowRegistryApi): Promise<Readonly<Record<string, JsonValue>>> {
1568
2050
  let first: WorkflowError | undefined;
1569
- const tasks = registry.variables().map(async ({ namespace, name, variable }) => {
2051
+ const tasks = registry.variables().map(async ({ name, variable }) => {
1570
2052
  try {
1571
2053
  const result: unknown = await variable.resolve(run);
1572
- if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
2054
+ if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
1573
2055
  return [name, deepFreeze(structuredClone(result))] as const;
1574
2056
  } catch (error) {
1575
- const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${namespace}.${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${namespace}.${name}: ${errorText(error)}`);
2057
+ const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
1576
2058
  if (!first) { first = typed; controller.abort(); }
1577
2059
  throw typed;
1578
2060
  }
@@ -1639,25 +2121,35 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
1639
2121
  return count === 1 ? label : `${label}#${String(count)}`;
1640
2122
  }
1641
2123
 
1642
-
1643
2124
  function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
1644
2125
  const functionAgentOccurrences = new Map<string, number>();
1645
2126
  const functionWorktreeOccurrences = new Map<string, number>();
1646
- return { ...bridge, functions: registry.globals(), variables, function: async (namespace, name, input, path, signal, worktreeOwner) => {
2127
+ const functionInvokeOccurrences = new Map<string, number>();
2128
+ const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
1647
2129
  const replayed = await store.replay(path);
1648
2130
  let stored: JsonValue | undefined;
1649
2131
  const sideEffects: Promise<void>[] = [];
2132
+ const functionBreadcrumb = breadcrumb ?? name;
1650
2133
  const context: WorkflowFunctionContext = {
1651
2134
  run: runContext,
2135
+ invoke: async (targetName, targetInput) => {
2136
+ const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
2137
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2138
+ const key = JSON.stringify([path, inherited, targetName]);
2139
+ const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
2140
+ functionInvokeOccurrences.set(key, occurrence);
2141
+ const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
2142
+ return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
2143
+ },
1652
2144
  agent: async (...args: readonly unknown[]) => {
1653
2145
  if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
1654
2146
  const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
1655
2147
  const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
1656
- const structuralPath = inheritedHostAgentPath.getStore() ?? [];
1657
- const key = `${path}\0${JSON.stringify(structuralPath)}`;
2148
+ const inherited = inheritedHostAgentPath.getStore() ?? [];
2149
+ const key = `${path}\0${JSON.stringify(inherited)}`;
1658
2150
  const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
1659
2151
  functionAgentOccurrences.set(key, occurrence);
1660
- return bridge.agent(args[0], options, signal, { structuralPath: [...structuralPath], callSite: `function:${path}`, occurrence, parentBreadcrumb: `${namespace}.${name}`, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2152
+ return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
1661
2153
  },
1662
2154
  prompt: workflowPrompt,
1663
2155
  parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
@@ -1670,18 +2162,66 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
1670
2162
  phase: (name: string) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
1671
2163
  log: (message: string) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
1672
2164
  };
1673
- const result = await registry.invokeFunction(namespace, name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } });
2165
+ const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
1674
2166
  await Promise.all(sideEffects);
1675
2167
  if (!replayed) await store.complete(path, stored ?? result);
1676
2168
  return result;
1677
- } };
2169
+ };
2170
+ return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
1678
2171
  }
1679
2172
 
1680
2173
  function projectTrusted(ctx: unknown): boolean {
1681
2174
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1682
2175
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1683
2176
  }
1684
-
2177
+ type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
2178
+ function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
2179
+ function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
2180
+ function piHostCapabilities(pi: unknown): PiHostCapabilities {
2181
+ if (!object(pi)) return {};
2182
+ const registerEntryRenderer = pi.registerEntryRenderer;
2183
+ const events = pi.events;
2184
+ return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
2185
+ }
2186
+ type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
2187
+ type ModelSummary = { provider: string; id: string };
2188
+ type ModelRegistryGetter = () => readonly ModelSummary[];
2189
+ type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
2190
+ function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
2191
+ function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
2192
+ if (!object(ctx) || !object(ctx.modelRegistry)) return {};
2193
+ const registry = ctx.modelRegistry;
2194
+ const getAll = registry.getAll;
2195
+ const getAvailable = registry.getAvailable;
2196
+ return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
2197
+ }
2198
+ type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
2199
+ type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
2200
+ type UiSetStatus = (key: string, text?: string) => void;
2201
+ type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
2202
+ function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
2203
+ function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
2204
+ function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
2205
+ function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
2206
+ if (!object(ui)) return undefined;
2207
+ const select = ui.select;
2208
+ const input = ui.input;
2209
+ const setStatus = ui.setStatus;
2210
+ return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
2211
+ }
2212
+ type TuiHostCapabilities = { terminal?: { rows?: unknown } };
2213
+ function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
2214
+ if (!object(tui) || !object(tui.terminal)) return {};
2215
+ return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2216
+ }
2217
+ function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
2218
+ type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
2219
+ function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
2220
+ function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
2221
+ if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
2222
+ return { getKeys: keybindings.getKeys };
2223
+ }
2224
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
1685
2225
  function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
1686
2226
  switch (value) {
1687
2227
  case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
@@ -1692,7 +2232,7 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
1692
2232
  export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession) {
1693
2233
  beginWorkflowExtensionLoading();
1694
2234
  const registry = loadingRegistry();
1695
- const registerEntryRenderer = (pi as unknown as Partial<Pick<ExtensionAPI, "registerEntryRenderer">>).registerEntryRenderer;
2235
+ const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
1696
2236
  registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
1697
2237
  const data = entry.data;
1698
2238
  return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
@@ -1702,20 +2242,35 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1702
2242
  try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
1703
2243
  finally { await lifecycle.leave(); }
1704
2244
  };
1705
- const events = (pi as unknown as { events?: ExtensionAPI["events"] }).events;
2245
+ const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
1706
2246
  pi.on("resources_discover", () => {
1707
2247
  if (!pi.getActiveTools().includes("workflow")) return;
1708
2248
  const extensionDir = dirname(fileURLToPath(import.meta.url));
1709
2249
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1710
2250
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1711
2251
  });
1712
- const emitAsyncStarted = (store: RunStore, metadata: WorkflowMetadata) => {
1713
- events?.emit(WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name });
2252
+ type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
2253
+ const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; budgetResolvers: Map<string, (result: BudgetDecisionResult) => void>; update?: (result: WorkflowToolUpdate) => void }>();
2254
+ const liveActivities = new Map<string, Map<string, AgentActivity>>();
2255
+ const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
2256
+ const activities = liveActivities.get(runId);
2257
+ if (activity) {
2258
+ if (activities) activities.set(agentId, activity);
2259
+ else liveActivities.set(runId, new Map([[agentId, activity]]));
2260
+ } else {
2261
+ activities?.delete(agentId);
2262
+ if (activities?.size === 0) liveActivities.delete(runId);
2263
+ }
1714
2264
  };
1715
- const emitAsyncComplete = (store: RunStore, state: "complete" | "failed" | "stopped", error?: Error) => {
1716
- events?.emit(WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: state === "complete", state, ...(state === "stopped" ? { stopped: true } : {}), ...(error ? { error: error.message } : {}) });
2265
+ const withLiveActivities = (run: PersistedRun): PersistedRun => {
2266
+ const activities = liveActivities.get(run.id);
2267
+ return activities?.size ? { ...run, agents: run.agents.map((agent) => {
2268
+ const activity = activities.get(agent.id);
2269
+ return activity ? { ...agent, activity } : agent;
2270
+ }) } : run;
1717
2271
  };
1718
- const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; abortController: AbortController; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
2272
+ const conversationLocks = new Set<string>();
2273
+ const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
1719
2274
  let sessionLease: SessionLease | undefined;
1720
2275
  let sessionLeasePromise: Promise<SessionLease> | undefined;
1721
2276
  const ensureSessionLease = async (cwd: string, sessionId: string) => {
@@ -1730,121 +2285,211 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1730
2285
  sessionLeasePromise = undefined;
1731
2286
  await lease?.release();
1732
2287
  };
1733
- const lifecycleFor = (store: RunStore, state: RunState) => new RunLifecycle(state, async (next) => {
1734
- const run = await store.updateState((current) => {
1735
- const nextRun = { ...current, state: next };
2288
+ const persistRunState = async (store: RunStore, metadata: WorkflowMetadata, update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> => {
2289
+ const persisted = await store.updateState(update);
2290
+ await eventPublisher.budget(store, metadata, persisted);
2291
+ return persisted;
2292
+ };
2293
+ const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
2294
+ const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2295
+ const worktree = await store.worktree(owner);
2296
+ if (!existing) await eventPublisher.worktree(store, metadata, worktree);
2297
+ return worktree;
2298
+ };
2299
+ const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
2300
+ if (next !== "pausing") budget.transition(next);
2301
+ const persisted = await persistRunState(store, metadata, (current) => {
2302
+ const nextRun = { ...current, state: next, ...budget.snapshot() };
1736
2303
  if (next === "running" || next === "completed") delete nextRun.error;
1737
2304
  return nextRun;
1738
2305
  });
1739
- runs.get(store.runId)?.update?.(workflowToolUpdate(run));
2306
+ await eventPublisher.runState(store, metadata, previous, next, reason);
2307
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1740
2308
  });
1741
2309
  const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
1742
2310
  const run = runs.get(runId);
1743
2311
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
1744
2312
  try {
2313
+ const budget = run.budget.forAgent(id);
1745
2314
  const onProgress = async (progress: AgentProgress) => {
1746
2315
  let runState: PersistedRun;
1747
2316
  if (progress.persist) {
1748
- runState = await run.store.updateState((current) => current.agents.some((agent) => agent.id === id) ? { ...current, agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
2317
+ 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);
1749
2318
  } else {
1750
2319
  const loaded = await run.store.load();
1751
2320
  if (!loaded.run.agents.some((agent) => agent.id === id)) return;
1752
- runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
2321
+ 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) };
1753
2322
  }
1754
2323
  if (!runState.agents.some((agent) => agent.id === id)) return;
1755
- run.update?.(workflowToolUpdate(runState));
2324
+ setLiveActivity(runId, id, progress.activity);
2325
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1756
2326
  };
1757
- const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => {
2327
+ const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
1758
2328
  await scheduler.flush();
2329
+ scheduler.attemptStarted(id);
2330
+ await scheduler.flush();
2331
+ const before = (await run.store.load()).run;
1759
2332
  await persistActiveAgentAttempt(run.store, id, attempt);
1760
- run.update?.(workflowToolUpdate((await run.store.load()).run));
2333
+ const active = (await run.store.load()).run;
2334
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2335
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2336
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1761
2337
  };
1762
- const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, ...(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 }) }, 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); });
2338
+ const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
2339
+ const before = (await run.store.load()).run;
1763
2340
  await persistAgentAttempts(run.store, id, result.attempts);
2341
+ const completed = (await run.store.load()).run;
2342
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2343
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2344
+ setLiveActivity(runId, id);
2345
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1764
2346
  return result.value;
1765
2347
  } catch (error) {
1766
2348
  const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
1767
- if (attempts?.length) await persistAgentAttempts(run.store, id, attempts);
2349
+ if (attempts?.length) {
2350
+ const before = (await run.store.load()).run;
2351
+ await persistAgentAttempts(run.store, id, attempts);
2352
+ const failed = (await run.store.load()).run;
2353
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2354
+ }
2355
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2356
+ setLiveActivity(runId, id);
2357
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1768
2358
  throw error;
1769
2359
  }
1770
2360
  }, 16, async (runId, ownership) => {
1771
2361
  const run = runs.get(runId);
1772
2362
  if (!run) return;
1773
2363
  await run.store.saveOwnership(ownership);
1774
- const runState = await run.store.updateState((current) => {
2364
+ let previousAgents: readonly AgentRecord[] = [];
2365
+ const runState = await persistRunState(run.store, run.metadata, (current) => {
2366
+ previousAgents = current.agents;
1775
2367
  const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
1776
2368
  const agents = ownership.map((node) => {
1777
2369
  const previous = existing.get(node.id);
1778
2370
  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 };
1779
- let effective: { model: ModelSpec; tools: readonly string[] };
2371
+ let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
1780
2372
  try { effective = run.executor.resolve(requested); }
1781
- catch { effective = previous ? { model: previous.model, tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, tools: node.options.tools }; }
1782
- 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 } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.role ? { role: node.options.role } : {}), 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 } : {}) };
2373
+ 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 }; }
2374
+ 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 } : {}) };
1783
2375
  });
1784
2376
  return { ...current, agents };
1785
2377
  });
1786
- run.update?.(workflowToolUpdate(runState));
2378
+ await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2379
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1787
2380
  });
2381
+ type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
2382
+ const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
2383
+ const run = runs.get(runId);
2384
+ const terminalState = terminalRunStates.get(runId);
2385
+ if (!run) return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
2386
+ const state = run.lifecycle.state;
2387
+ if (state === "completed" || state === "failed" || state === "stopped") return { runId, state, stopped: false, reason: "already_terminal" };
2388
+ await run.lifecycle.terminal("stopped");
2389
+ run.abortController.abort();
2390
+ run.execution?.cancel();
2391
+ await scheduler.cancelRun(run.store.runId);
2392
+ await scheduler.flush();
2393
+ return { runId, state: "stopped", stopped: true };
2394
+ };
1788
2395
  const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
1789
2396
  const run = runs.get(runId);
1790
2397
  if (!run) return false;
1791
2398
  const checkpoint = await run.store.answerCheckpoint(name, approved);
1792
2399
  if (!checkpoint) return false;
2400
+ await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
1793
2401
  if ((await run.store.awaitingCheckpoints()).length === 0) await run.lifecycle.resolveAwaitingInput();
1794
2402
  run.checkpointResolvers.get(checkpoint.path)?.(approved);
1795
2403
  run.checkpointResolvers.delete(checkpoint.path);
1796
2404
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
1797
2405
  return true;
1798
2406
  };
2407
+ 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}.`;
2408
+ const appendBudgetDecisionEvent = async (run: NonNullable<ReturnType<typeof runs.get>>, request: BudgetApprovalRequest, type: "adjustment_requested" | "adjustment_approved" | "adjustment_rejected") => {
2409
+ 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) });
2410
+ await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2411
+ };
2412
+ const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
2413
+ const run = runs.get(runId);
2414
+ if (!run) return undefined;
2415
+ const request = await run.store.answerWorkflowDecision(proposalId, approved);
2416
+ if (!request) return undefined;
2417
+ await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2418
+ const result = await applyBudgetDecision(request, approved);
2419
+ run.budgetResolvers.get(proposalId)?.(result);
2420
+ run.budgetResolvers.delete(proposalId);
2421
+ if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2422
+ return result;
2423
+ };
1799
2424
  const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }) => {
1800
2425
  const checkpointCounters = new Map<string, number>();
1801
2426
  return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
1802
- const input = validateCheckpoint(raw);
1803
- const label = nextNamedOccurrence(checkpointCounters, input.name);
1804
- const path = operationPath("checkpoint", label);
1805
- if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
1806
- const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
1807
- const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
1808
- if (replayed !== undefined) return replayed;
1809
- const run = runs.get(runId);
1810
- await run?.lifecycle.enterAwaitingInput();
1811
- if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
1812
- const decision = new Promise<boolean>((resolve, reject) => {
1813
- run?.checkpointResolvers.set(path, resolve);
1814
- if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
1815
- else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
1816
- });
1817
- const answered = await store.awaitCheckpoint({ ...input, name: label, path });
1818
- if (answered !== undefined) {
1819
- if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
1820
- run?.checkpointResolvers.get(path)?.(answered);
1821
- run?.checkpointResolvers.delete(path);
1822
- }
1823
- if (ui?.select) void (async () => {
1824
- while (!signal.aborted && run?.checkpointResolvers.has(path)) {
1825
- const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
1826
- if (!choice) {
1827
- if (foreground) continue; // foreground: retry until answered
1828
- // Background resume: user dismissed UI, fall back to LLM
1829
- deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
1830
- return;
1831
- }
1832
- if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
2427
+ const input = validateCheckpoint(raw);
2428
+ const label = nextNamedOccurrence(checkpointCounters, input.name);
2429
+ const path = operationPath("checkpoint", label);
2430
+ if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
2431
+ const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
2432
+ const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
2433
+ if (replayed !== undefined) return replayed;
2434
+ if (!alreadyAwaiting) await eventPublisher.checkpoint(store, metadata, label, "awaiting");
2435
+ const run = runs.get(runId);
2436
+ await run?.lifecycle.enterAwaitingInput();
2437
+ if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
2438
+ const decision = new Promise<boolean>((resolve, reject) => {
2439
+ run?.checkpointResolvers.set(path, resolve);
2440
+ if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
2441
+ else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
2442
+ });
2443
+ const answered = await store.awaitCheckpoint({ ...input, name: label, path });
2444
+ if (answered !== undefined) {
2445
+ if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
2446
+ run?.checkpointResolvers.get(path)?.(answered);
2447
+ run?.checkpointResolvers.delete(path);
1833
2448
  }
1834
- })().catch(() => undefined);
1835
- return decision;
1836
- };
2449
+ if (ui?.select) void (async () => {
2450
+ while (!signal.aborted && run?.checkpointResolvers.has(path)) {
2451
+ const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
2452
+ if (!choice) {
2453
+ if (foreground) continue; // foreground: retry until answered
2454
+ deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
2455
+ return;
2456
+ }
2457
+ if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
2458
+ }
2459
+ })().catch(() => undefined);
2460
+ return decision;
2461
+ };
1837
2462
  };
1838
2463
 
1839
2464
  pi.registerTool({
1840
2465
  name: "workflow_respond",
1841
2466
  label: "Workflow Respond",
1842
- description: "Approve or reject one pending workflow checkpoint",
1843
- parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
2467
+ description: "Approve or reject one pending workflow checkpoint or budget decision",
2468
+ parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
1844
2469
  async execute(_id, params) {
1845
2470
  try {
2471
+ if (params.proposalId) {
2472
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
2473
+ 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 }; }
2474
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
2475
+ }
2476
+ if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
1846
2477
  const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
1847
- return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
2478
+ 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 };
2479
+ } catch (error) {
2480
+ throw mainAgentError(error);
2481
+ }
2482
+ },
2483
+ });
2484
+ pi.registerTool({
2485
+ name: "workflow_stop",
2486
+ label: "Workflow Stop",
2487
+ description: "Stop an active workflow run by ID",
2488
+ parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
2489
+ async execute(_id, params) {
2490
+ try {
2491
+ const result = await stopWorkflowRun(params.runId);
2492
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
1848
2493
  } catch (error) {
1849
2494
  throw mainAgentError(error);
1850
2495
  }
@@ -1855,7 +2500,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1855
2500
  const registerCatalog = () => {
1856
2501
  if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
1857
2502
  const catalog = registry.catalog();
1858
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length) return;
2503
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2504
+ if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases) return;
1859
2505
  pi.registerTool({
1860
2506
  name: "workflow_catalog",
1861
2507
  label: "Workflow Catalog",
@@ -1865,37 +2511,92 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1865
2511
  });
1866
2512
  catalogRegistered = true;
1867
2513
  };
1868
- const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean) => {
2514
+ 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 }) => {
2515
+ const loaded = await run.store.load();
2516
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2517
+ const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2518
+ if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2519
+ const settingsPath = workflowSettingsPath();
2520
+ const currentSettings = loadSettings(settingsPath);
2521
+ resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2522
+ const currentAliases = currentSettings.modelAliases ?? {};
2523
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2524
+ const modelRegistry = context?.modelRegistry;
2525
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2526
+ if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
2527
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2528
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2529
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2530
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2531
+ await run.store.saveSnapshot(snapshot);
2532
+ run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2533
+ run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2534
+ const drift = aliasDrift(previousAliases, currentAliases);
2535
+ if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2536
+ };
2537
+ 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 }) => {
1869
2538
  const loaded = await run.store.load();
1870
2539
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
1871
2540
  if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
1872
2541
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1873
2542
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1874
2543
  if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1875
- const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
2544
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
1876
2545
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1877
2546
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1878
- preflight(loaded.snapshot.script, { models: new Set(loaded.snapshot.models), tools: active, agentTypes: new Set(loaded.snapshot.agentTypes) }, loaded.snapshot.schemas, loaded.snapshot.metadata);
2547
+ const settingsPath = workflowSettingsPath();
2548
+ const currentSettings = loadSettings(settingsPath);
2549
+ resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2550
+ const currentAliases = currentSettings.modelAliases ?? {};
2551
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2552
+ const modelRegistry = context?.modelRegistry;
2553
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2554
+ if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
2555
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2556
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2557
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2558
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2559
+ preflight(loaded.snapshot.script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata);
2560
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2561
+ await run.store.saveSnapshot(snapshot);
2562
+ run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2563
+ const drift = aliasDrift(previousAliases, currentAliases);
2564
+ if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1879
2565
  const controller = new AbortController();
1880
2566
  run.abortController = controller;
1881
2567
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
2568
+ run.executor.setRunContext(runContext);
1882
2569
  let variables: Readonly<Record<string, JsonValue>>;
1883
2570
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1884
2571
  catch (error) {
1885
2572
  const typed = asWorkflowError(error);
1886
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed").catch(() => undefined); await run.store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); }
2573
+ 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)); }
1887
2574
  throw typed;
1888
2575
  }
1889
2576
  await scheduler.cancelRun(run.store.runId);
1890
2577
  await run.lifecycle.resume();
1891
2578
  const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
1892
2579
  await run.lifecycle.enter();
2580
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2581
+ const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
1893
2582
  try {
1894
- const path = agentIdentityPath(identity);
2583
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
1895
2584
  const replayed = await run.store.replay(path);
1896
- if (replayed) return replayed.value;
2585
+ if (replayed) {
2586
+ if (conversationId) {
2587
+ const conversation = await run.store.conversation(conversationId);
2588
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
2589
+ }
2590
+ return replayed.value;
2591
+ }
2592
+ if (conversationId) {
2593
+ if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
2594
+ const conversation = await run.store.conversation(conversationId);
2595
+ 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");
2596
+ conversationLocks.add(conversationLock);
2597
+ }
1897
2598
  const worktree = agentWorktree(identity);
1898
- const cwd = worktree.worktreeOwner ? (await run.store.worktree(worktree.worktreeOwner)).cwd : run.store.cwd;
2599
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
1899
2600
  const role = typeof options.role === "string" ? options.role : undefined;
1900
2601
  const model = typeof options.model === "string" ? options.model : undefined;
1901
2602
  const thinking = parseThinking(options.thinking);
@@ -1904,21 +2605,86 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1904
2605
  const label = displayAgentName(requestedLabel, role, resolved.model);
1905
2606
  const tools = resolved.tools;
1906
2607
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1907
- 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 } : {}) });
2608
+ 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 });
1908
2609
  const cancel = () => { scheduler.cancel(spawned.id); };
1909
2610
  signal.addEventListener("abort", cancel, { once: true });
1910
2611
  const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
1911
2612
  if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1912
2613
  await run.store.complete(path, outcome.value);
1913
2614
  return outcome.value;
1914
- } finally { await run.lifecycle.leave(); }
1915
- }, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { await run.store.updateState((current) => ({ ...current, phase })); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2615
+ } finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
2616
+ }, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { let previousPhase: string | undefined; const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; }); await eventPublisher.phase(run.store, run.metadata, previousPhase, phase); runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted)); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
1916
2617
  run.execution = execution;
1917
- emitAsyncStarted(run.store, run.metadata);
1918
- const completion = execution.result.then(async (value) => { await scheduler.flush(); const resultPath = await run.store.saveResult(value); await run.lifecycle.terminal("completed"); emitAsyncComplete(run.store, "complete"); return { value, resultPath }; }, async (error: unknown) => { await scheduler.flush(); if (run.lifecycle.state !== "stopped" && run.lifecycle.state !== "interrupted") await run.lifecycle.terminal("failed"); const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error)); await run.store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); emitAsyncComplete(run.store, run.lifecycle.state === "stopped" ? "stopped" : "failed", typed); if (run.lifecycle.state !== "stopped" && run.lifecycle.state !== "interrupted") deliverFailure(pi, run.metadata.name, error); });
1919
- run.completion = completion;
2618
+ const completion = execution.result.then(async (value) => {
2619
+ await scheduler.flush();
2620
+ if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2621
+ const resultPath = await run.store.saveResult(value);
2622
+ await run.lifecycle.terminal("completed", "completed");
2623
+ await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
2624
+ return { value, resultPath };
2625
+ }).catch(async (error: unknown) => {
2626
+ await scheduler.flush();
2627
+ const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
2628
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2629
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2630
+ const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2631
+ await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2632
+ run.update?.(workflowToolUpdate(persisted));
2633
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
2634
+ });
1920
2635
  void completion;
1921
2636
  };
2637
+ const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
2638
+ const run = runs.get(request.runId);
2639
+ if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
2640
+ if (!approved) return { state: "budget_exhausted", approved: false };
2641
+ const nextBudget = validateBudget(request.proposed);
2642
+ const nextVersion = request.budgetVersion + 1;
2643
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
2644
+ run.budget = runtime;
2645
+ 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; });
2646
+ await coldResumeRun(run, false, {}, true);
2647
+ return { state: "running", approved: true };
2648
+ };
2649
+ const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
2650
+ const run = runs.get(runId);
2651
+ if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2652
+ const loaded = await run.store.load();
2653
+ if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2654
+ const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2655
+ const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
2656
+ const nextBudget = mergeBudget(currentBudget, patch);
2657
+ const usage = budgetUsage(loaded.run.usage);
2658
+ if (!resumeBudgetAllowed(nextBudget, usage)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
2659
+ if (budgetRelaxed(currentBudget, nextBudget)) {
2660
+ const proposalId = randomUUID();
2661
+ const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2662
+ const decision = new Promise<BudgetDecisionResult>((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2663
+ try { await run.store.requestWorkflowDecision(request); await appendBudgetDecisionEvent(run, request, "adjustment_requested"); } catch (error) { run.budgetResolvers.delete(proposalId); throw error; }
2664
+ deliver(pi, budgetDecisionDelivery(run.metadata, request));
2665
+ const decisionResult = await decision;
2666
+ return { state: decisionResult.state };
2667
+ }
2668
+ const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2669
+ if (changed) {
2670
+ const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
2671
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
2672
+ run.budget = runtime;
2673
+ 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; });
2674
+ }
2675
+ await coldResumeRun(run, false, {}, true);
2676
+ return { state: "running" };
2677
+ };
2678
+ pi.registerTool({
2679
+ name: "workflow_resume",
2680
+ label: "Workflow Resume",
2681
+ description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
2682
+ parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
2683
+ async execute(_id, params) {
2684
+ try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2685
+ catch (error) { throw mainAgentError(error); }
2686
+ },
2687
+ });
1922
2688
  pi.on("session_start", async (_event, ctx) => {
1923
2689
  if (sessionStarted) return;
1924
2690
  sessionStarted = true;
@@ -1931,20 +2697,27 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1931
2697
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
1932
2698
  let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
1933
2699
  try { loaded = await store.load(); } catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); continue; }
1934
- if (["completed", "failed", "stopped"].includes(loaded.run.state)) continue;
1935
- if (loaded.run.state !== "interrupted") {
1936
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2700
+ if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
2701
+ if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
2702
+ const previousState = loaded.run.state;
2703
+ await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2704
+ loaded = { ...loaded, run: (await store.load()).run };
2705
+ await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
1937
2706
  loaded = { ...loaded, run: (await store.load()).run };
1938
2707
  }
1939
2708
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
1940
- const lifecycle = lifecycleFor(store, loaded.run.state);
2709
+ const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2710
+ eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
2711
+ const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
2712
+ const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
1941
2713
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1942
2714
  const roleDefinitions = loaded.snapshot.roles ?? {};
1943
- runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), agentDefinitions: roleDefinitions, runStore: store, providerPause }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, abortController: new AbortController(), checkpointResolvers: new Map() });
2715
+ runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController: new AbortController(), projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map() });
1944
2716
  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.`);
1945
- scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.settings.maxAgentLaunches, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : []);
2717
+ for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
2718
+ scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
1946
2719
  }
1947
- const resumeSelect = (ctx.ui as unknown as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } | undefined)?.select;
2720
+ const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
1948
2721
  if (ctx.hasUI && resumeSelect) {
1949
2722
  const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
1950
2723
  if (interrupted.length > 0) {
@@ -1954,7 +2727,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1954
2727
  if (choice && choice !== "Skip") {
1955
2728
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
1956
2729
  for (const run of toResume) {
1957
- try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx)); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
2730
+ try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
1958
2731
  catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
1959
2732
  }
1960
2733
  }
@@ -1977,17 +2750,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1977
2750
  parameters: WORKFLOW_TOOL_PARAMETERS,
1978
2751
  async execute(_id, params, signal, onUpdate, ctx) {
1979
2752
  try {
2753
+ const settingsPath = workflowSettingsPath();
2754
+ const defaults = loadSettings(settingsPath);
1980
2755
  if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
1981
- const defaults = loadSettings();
1982
- const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, maxAgentLaunches: params.maxAgentLaunches ?? defaults.maxAgentLaunches });
2756
+ const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
2757
+ const budget = validateBudget(params.budget);
1983
2758
  const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
1984
2759
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
1985
- const modelRegistry = (ctx as unknown as { modelRegistry?: { getAvailable(): Array<{ provider: string; id: string }> } }).modelRegistry;
1986
- const availableModels = new Set((modelRegistry?.getAvailable() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
1987
- availableModels.add(rootModelName);
1988
- const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog");
2760
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2761
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
2762
+ knownModels.add(rootModelName);
2763
+ const availableModels = knownModels;
2764
+ const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
1989
2765
  const trustedProject = projectTrusted(ctx);
1990
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools) }, registry);
2766
+ if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
2767
+ const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
1991
2768
  const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
1992
2769
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1993
2770
  const runId = randomUUID();
@@ -2000,25 +2777,41 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2000
2777
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2001
2778
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
2002
2779
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2003
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
2780
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2004
2781
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2005
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2006
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [] }, snapshot);
2007
- const lifecycle = lifecycleFor(store, "running");
2782
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2783
+ const budgetRuntime = new WorkflowBudgetRuntime(budget);
2784
+ const initialBudget = budgetRuntime.snapshot();
2785
+ await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2786
+ const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2008
2787
  const background = !params.foreground;
2009
2788
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2010
- const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
2011
- runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
2789
+ const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx)), runContext }, createSession);
2790
+ runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
2012
2791
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
2013
- scheduler.addRun(runId, settings.concurrency, settings.maxAgentLaunches);
2792
+ scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2014
2793
  const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
2015
2794
  await lifecycle.enter();
2795
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2796
+ const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
2016
2797
  try {
2017
- const path = agentIdentityPath(identity);
2798
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
2018
2799
  const replayed = await store.replay(path);
2019
- if (replayed) return replayed.value;
2800
+ if (replayed) {
2801
+ if (conversationId) {
2802
+ const conversation = await store.conversation(conversationId);
2803
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
2804
+ }
2805
+ return replayed.value;
2806
+ }
2807
+ if (conversationId) {
2808
+ if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
2809
+ const conversation = await store.conversation(conversationId);
2810
+ 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");
2811
+ conversationLocks.add(conversationLock);
2812
+ }
2020
2813
  const worktree = agentWorktree(identity);
2021
- const cwd = worktree.worktreeOwner ? (await store.worktree(worktree.worktreeOwner)).cwd : ctx.cwd;
2814
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
2022
2815
  const role = typeof options.role === "string" ? options.role : undefined;
2023
2816
  const model = typeof options.model === "string" ? options.model : undefined;
2024
2817
  const thinking = parseThinking(options.thinking);
@@ -2027,30 +2820,46 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2027
2820
  const label = displayAgentName(requestedLabel, role, resolved.model);
2028
2821
  const tools = resolved.tools;
2029
2822
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2030
- 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" && Number.isInteger(options.retries) && options.retries >= 0 ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}) });
2823
+ 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 });
2031
2824
  const cancel = () => { scheduler.cancel(spawned.id); };
2032
2825
  if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
2033
2826
  const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2034
2827
  if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
2035
2828
  await store.complete(path, outcome.value);
2036
2829
  return outcome.value;
2037
- } finally { await lifecycle.leave(); }
2830
+ } finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
2038
2831
  }, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
2039
2832
  await lifecycle.enter();
2040
2833
  try {
2041
- const run = await store.updateState((current) => ({ ...current, phase }));
2042
- runs.get(runId)?.update?.(workflowToolUpdate(run));
2834
+ let previousPhase: string | undefined;
2835
+ const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
2836
+ await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
2837
+ runs.get(runId)?.update?.(workflowToolUpdate(persisted));
2043
2838
  } finally { await lifecycle.leave(); }
2044
2839
  }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2045
2840
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
2046
- if (background) emitAsyncStarted(store, checked.metadata);
2047
- const finish = execution.result.then(async (value) => { await scheduler.flush(); const resultPath = await store.saveResult(value); await lifecycle.terminal("completed"); return { value, resultPath }; }, async (error: unknown) => { await scheduler.flush(); const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error)); if (lifecycle.state !== "stopped" && lifecycle.state !== "interrupted") await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : "failed"); await store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); throw typed; });
2841
+ await eventPublisher.runStarted(store, checked.metadata);
2842
+ const finish = execution.result.then(async (value) => {
2843
+ await scheduler.flush();
2844
+ if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2845
+ const resultPath = await store.saveResult(value);
2846
+ await lifecycle.terminal("completed", "completed");
2847
+ await eventPublisher.runCompleted(store, checked.metadata, resultPath);
2848
+ return { value, resultPath };
2849
+ }).catch(async (error: unknown) => {
2850
+ await scheduler.flush();
2851
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2852
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2853
+ await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
2854
+ const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2855
+ await eventPublisher.runFailed(store, checked.metadata, typed, state);
2856
+ throw typed;
2857
+ });
2048
2858
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
2049
2859
  if (background) {
2050
2860
  void finish.then(async ({ value, resultPath }) => {
2051
- emitAsyncComplete(store, "complete");
2052
2861
  deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2053
- }, (error: unknown) => { emitAsyncComplete(store, lifecycle.state === "stopped" ? "stopped" : "failed", error instanceof Error ? error : new Error(errorText(error))); deliverFailure(pi, checked.metadata.name, error); });
2862
+ }, (error: unknown) => { deliverFailure(pi, checked.metadata.name, error); });
2054
2863
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2055
2864
  }
2056
2865
  const { value } = await finish;
@@ -2098,9 +2907,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2098
2907
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
2099
2908
  };
2100
2909
  let stores = await loadStores();
2101
- const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
2910
+ 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."
2102
2911
  const setWorkflowStatus = (text: string | undefined) => {
2103
- const setStatus = (ctx.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus;
2912
+ const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
2104
2913
  setStatus?.call(ctx.ui, "workflow-stop", text);
2105
2914
  };
2106
2915
  const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
@@ -2114,22 +2923,44 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2114
2923
  ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
2115
2924
  return keepContext ? "dashboard" : "done";
2116
2925
  }
2926
+ if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2927
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
2928
+ ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2929
+ return keepContext ? "dashboard" : "done";
2930
+ }
2117
2931
  if (action === "delete" && stored) {
2118
2932
  if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
2119
2933
  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";
2120
- await stored.store.delete(true); runs.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
2934
+ 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";
2121
2935
  }
2122
2936
  if (action === "pause" && run) { await run.lifecycle.pause(); ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done"; }
2123
2937
  if (action === "resume" && run) {
2124
- if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx));
2125
- else await run.lifecycle.resume();
2126
- ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
2938
+ if (run.lifecycle.state === "budget_exhausted") {
2939
+ const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
2940
+ const result = await resumeWorkflowRun(run.store.runId, patch);
2941
+ 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");
2942
+ } else {
2943
+ if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
2944
+ else {
2945
+ if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
2946
+ await run.lifecycle.resume();
2947
+ }
2948
+ ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
2949
+ }
2950
+ return keepContext ? "dashboard" : "done";
2951
+ }
2952
+ if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2953
+ const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2954
+ if (input === undefined) return keepContext ? "dashboard" : "done";
2955
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2956
+ 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");
2957
+ return keepContext ? "dashboard" : "done";
2127
2958
  }
2128
2959
  if (action === "stop" && run) {
2129
2960
  const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
2130
2961
  if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
2131
2962
  if (keepContext) status(`Stopping workflow ${workflowName}...`);
2132
- await run.lifecycle.terminal("stopped"); run.abortController.abort(); run.execution?.cancel(); await scheduler.cancelRun(run.store.runId); await scheduler.flush();
2963
+ await stopWorkflowRun(run.store.runId);
2133
2964
  if (keepContext) status(`Workflow ${run.store.runId} stopped.`);
2134
2965
  ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
2135
2966
  }
@@ -2144,10 +2975,76 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2144
2975
  return "dashboard";
2145
2976
  }
2146
2977
  };
2978
+ const manageAliases = async (): Promise<void> => {
2979
+ const settingsPath = workflowSettingsPath();
2980
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2981
+ const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
2982
+ const selectTarget = async (): Promise<string | undefined> => {
2983
+ const models = available();
2984
+ const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
2985
+ if (!choice || choice === "Back") return undefined;
2986
+ if (choice !== "Manual model ID") return choice;
2987
+ return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
2988
+ };
2989
+ const save = (aliases: Readonly<Record<string, string>>): boolean => {
2990
+ try { saveModelAliases(settingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info"); return true; }
2991
+ catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
2992
+ };
2993
+ for (;;) {
2994
+ let aliases: Readonly<Record<string, string>>;
2995
+ try { aliases = loadSettings(settingsPath).modelAliases ?? {}; }
2996
+ catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
2997
+ const names = Object.keys(aliases).sort();
2998
+ const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
2999
+ const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
3000
+ const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
3001
+ if (!choice || choice === "Back") return;
3002
+ if (choice === "Add alias") {
3003
+ const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
3004
+ if (!name) continue;
3005
+ if (Object.prototype.hasOwnProperty.call(aliases, name)) { ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning"); continue; }
3006
+ const target = await selectTarget();
3007
+ if (!target) continue;
3008
+ const next = { ...aliases, [name]: target };
3009
+ try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
3010
+ const parsed = parseModelReference(target);
3011
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3012
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3013
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
3014
+ }
3015
+ save(next);
3016
+ continue;
3017
+ }
3018
+ const edit = /^Edit (.+)$/.exec(choice);
3019
+ if (edit?.[1]) {
3020
+ const target = await selectTarget();
3021
+ if (!target) continue;
3022
+ const next = { ...aliases, [edit[1]]: target };
3023
+ try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
3024
+ const parsed = parseModelReference(target);
3025
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3026
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3027
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
3028
+ }
3029
+ save(next);
3030
+ continue;
3031
+ }
3032
+ const deletion = /^Delete (.+)$/.exec(choice);
3033
+ if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
3034
+ const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
3035
+ save(next);
3036
+ }
3037
+ }
3038
+ };
3039
+ if (command === "model-aliases") {
3040
+ if (!ctx.hasUI) { ctx.ui.notify("Model alias management requires UI.", "warning"); return; }
3041
+ await manageAliases();
3042
+ return;
3043
+ }
2147
3044
  if (!command) {
2148
3045
  for (;;) {
2149
- if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
2150
3046
  if (!ctx.hasUI) {
3047
+ if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
2151
3048
  const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
2152
3049
  ctx.ui.notify(details.join("\n\n"), "info"); return;
2153
3050
  }
@@ -2155,13 +3052,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2155
3052
  const labels = navigatorRunLabels(sorted);
2156
3053
  const terminalStates = new Set(["completed", "failed", "stopped"]);
2157
3054
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2158
- const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : []), "Close"];
3055
+ const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
2159
3056
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
2160
3057
  if (!runChoice || runChoice === "Close") return;
3058
+ if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
2161
3059
  if (runChoice === "Delete all completed") {
2162
3060
  if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
2163
3061
  for (const entry of sorted) {
2164
- if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); }
3062
+ if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
2165
3063
  }
2166
3064
  ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
2167
3065
  }
@@ -2178,6 +3076,38 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2178
3076
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
2179
3077
  }
2180
3078
  };
3079
+ const openTranscript = async (transcript: string): Promise<void> => {
3080
+ try {
3081
+ const entries = SessionManager.open(transcript).buildContextEntries();
3082
+ if (ctx.mode !== "tui") { ctx.ui.notify(`Transcript: ${transcript}`, "info"); return; }
3083
+ await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3084
+ let offset = 0;
3085
+ let renderedLines: string[] = [];
3086
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
3087
+ const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3088
+ return {
3089
+ render(width: number) {
3090
+ renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3091
+ offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3092
+ return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3093
+ },
3094
+ invalidate() {},
3095
+ handleInput(data: string) {
3096
+ if (keybindings.matches(data, "tui.select.up")) move(-1);
3097
+ else if (keybindings.matches(data, "tui.select.down")) move(1);
3098
+ else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
3099
+ else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
3100
+ else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
3101
+ else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
3102
+ else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3103
+ tui.requestRender();
3104
+ },
3105
+ };
3106
+ }, { overlay: true });
3107
+ } catch (error) {
3108
+ ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3109
+ }
3110
+ };
2181
3111
  const loadDashboard = async () => {
2182
3112
  const loaded = await store.load();
2183
3113
  const checkpoints = await store.awaitingCheckpoints();
@@ -2189,6 +3119,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2189
3119
  const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
2190
3120
  if (loaded.run.state === "running") add("Pause", "pause");
2191
3121
  if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
3122
+ if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
3123
+ for (const decision of await store.pendingWorkflowDecisions()) {
3124
+ const id = decision.proposalId.slice(0, 8);
3125
+ actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
3126
+ actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
3127
+ }
2192
3128
  if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
2193
3129
  for (const cp of checkpoints) {
2194
3130
  if (ctx.mode === "tui") {
@@ -2203,18 +3139,59 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2203
3139
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
2204
3140
  else actions.set("View script", "view-script");
2205
3141
  const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
2206
- if (ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
2207
- if (transcripts.length) actions.set("Transcript paths", "transcripts");
3142
+ if (loaded.run.agents.length) actions.set("Agents...", "agents");
3143
+ if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
3144
+ if (!loaded.run.agents.length && transcripts.length) actions.set("Transcript paths", "transcripts");
2208
3145
  if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
2209
3146
  if (ctx.mode === "tui") {
2210
3147
  addCopy("Copy run path", store.directory, "run path");
2211
3148
  addCopy("Copy run ID", store.runId, "run ID");
2212
- for (const worktree of worktrees) {
2213
- addCopy(`Copy branch (${worktree.owner})`, worktree.branch, "branch");
2214
- addCopy(`Copy worktree path (${worktree.owner})`, worktree.path, "worktree path");
3149
+ }
3150
+ return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
3151
+ };
3152
+ const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
3153
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
3154
+ const title = (agent: AgentRecord): string => {
3155
+ const parents: string[] = [];
3156
+ for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
3157
+ const parent = byId.get(parentId);
3158
+ if (!parent) break;
3159
+ parents.unshift(parent.label ?? parent.name);
3160
+ }
3161
+ return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
3162
+ };
3163
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
3164
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3165
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3166
+ const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3167
+ if (!selected) return;
3168
+ const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3169
+ const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3170
+ const actions = [
3171
+ ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
3172
+ ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3173
+ "Copy agent ID",
3174
+ "Back",
3175
+ ];
3176
+ const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
3177
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3178
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
3179
+ const index = choice ? choices.indexOf(choice) : -1;
3180
+ return index >= 0 ? attempts[index] : undefined;
3181
+ };
3182
+ for (;;) {
3183
+ const action = await ctx.ui.select(title(selected), actions);
3184
+ if (!action || action === "Back") return;
3185
+ if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
3186
+ if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
3187
+ if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
3188
+ if (action === "View transcript" || action === "Copy transcript path") {
3189
+ const attempt = await chooseAttempt();
3190
+ if (!attempt) continue;
3191
+ if (action === "Copy transcript path") await copyArtifact(attempt.sessionFile, "transcript path");
3192
+ else await openTranscript(attempt.sessionFile);
2215
3193
  }
2216
3194
  }
2217
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
2218
3195
  };
2219
3196
  for (;;) {
2220
3197
  let view = await loadDashboard();
@@ -2227,11 +3204,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2227
3204
  let disposed = false;
2228
3205
  let stopRequested = false;
2229
3206
  let stopStatus: string | undefined;
2230
- const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
3207
+ const terminalRows = () => Math.max(1, tuiRows(tui));
2231
3208
  const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2232
3209
  const keyLabel = (binding: string, fallback: string) => {
2233
- const getKeys = (keybindings as unknown as { getKeys?: (name: string) => readonly string[] }).getKeys;
2234
- const keys = getKeys?.call(keybindings, binding);
3210
+ const keys = keybindingKeys(keybindings, binding);
2235
3211
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2236
3212
  };
2237
3213
  const dashboardLayout = () => {
@@ -2318,13 +3294,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2318
3294
  }, { overlay: true })
2319
3295
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2320
3296
  if (!actionChoice || actionChoice === "Close") return;
3297
+ if (actionChoice === "Agents...") { await selectAgent(view); continue; }
2321
3298
  if (actionChoice === "Refresh") continue;
2322
3299
  if (actionChoice === "View script") {
2323
3300
  await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2324
3301
  const highlighted = highlightCode(view.script, "javascript");
2325
3302
  let offset = 0;
2326
3303
  let renderedLines: string[] = [];
2327
- const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
3304
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
2328
3305
  const move = (delta: number) => {
2329
3306
  const maxOffset = Math.max(0, renderedLines.length - viewport());
2330
3307
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
@@ -2356,47 +3333,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2356
3333
  }
2357
3334
  if (actionChoice === "View transcript") {
2358
3335
  const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
2359
- if (transcript && transcript !== "Back") {
2360
- try {
2361
- const entries = SessionManager.open(transcript).buildContextEntries();
2362
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2363
- let offset: number | undefined;
2364
- let renderedLines: string[] = [];
2365
- const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
2366
- const viewport = () => Math.max(1, terminalRows() - 3);
2367
- const move = (delta: number) => {
2368
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2369
- offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
2370
- };
2371
- return {
2372
- render(width: number) {
2373
- renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2374
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2375
- offset = Math.min(offset ?? maxOffset, maxOffset);
2376
- return [
2377
- theme.fg("accent", "Native Pi transcript"),
2378
- ...renderedLines.slice(offset, offset + viewport()),
2379
- "",
2380
- theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
2381
- ];
2382
- },
2383
- invalidate() {},
2384
- handleInput(data: string) {
2385
- if (keybindings.matches(data, "tui.select.up")) move(-1);
2386
- else if (keybindings.matches(data, "tui.select.down")) move(1);
2387
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
2388
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
2389
- else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
2390
- else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
2391
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2392
- tui.requestRender();
2393
- },
2394
- };
2395
- }, { overlay: true });
2396
- } catch (error) {
2397
- ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2398
- }
2399
- }
3336
+ if (transcript && transcript !== "Back") await openTranscript(transcript);
2400
3337
  continue;
2401
3338
  }
2402
3339
  if (actionChoice === "Transcript paths") {
@@ -2418,7 +3355,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2418
3355
  let offset = 0;
2419
3356
  let renderedLines: string[] = [];
2420
3357
  const layout = () => {
2421
- const rows = Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24));
3358
+ const rows = Math.max(1, tuiRows(tui));
2422
3359
  const compactControls = rows < 4;
2423
3360
  const titleRows = rows >= 5 ? 1 : 0;
2424
3361
  const hintRows = rows >= 8 ? 1 : 0;
@@ -2481,9 +3418,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2481
3418
  pi.on("session_shutdown", async () => {
2482
3419
  try {
2483
3420
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2484
- if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
2485
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
2486
- try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) throw error; }
3421
+ if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
3422
+ if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
3423
+ try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
2487
3424
  run.abortController.abort();
2488
3425
  run.execution?.cancel();
2489
3426
  await scheduler.cancelRun(runId);
@@ -2512,8 +3449,8 @@ function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
2512
3449
  }
2513
3450
 
2514
3451
  export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
2515
- export type { AwaitingCheckpoint, CompletedOperation, NativeSessionReference, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
3452
+ export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
2516
3453
  export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
2517
- export type { AgentAccounting, AgentAttempt, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentToolCallProgress } from "./agent-execution.js";
3454
+ export type { AgentAccounting, AgentAttempt, AgentBudgetHooks, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentSetup, AgentSetupContext, AgentSetupHook, AgentToolCallProgress, RegisteredAgentSetupHook, SessionInput } from "./agent-execution.js";
2518
3455
  export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
2519
3456
  export type { DoctorDiagnostic, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust, DoctorWorkflow } from "./doctor.js";