pi-extensible-workflows 0.3.2 → 1.0.0

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