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/dist/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { fork } 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";
@@ -13,17 +13,27 @@ import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionM
13
13
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
14
14
  import { transcriptLines } from "./session-inspector.js";
15
15
  import { acquireSessionLease, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
16
- export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"];
16
+ export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"];
17
17
  export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"];
18
18
  export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
19
19
  export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
20
+ export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
21
+ export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
22
+ export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
23
+ export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
24
+ export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
25
+ export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
26
+ export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
27
+ export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
28
+ export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
29
+ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
20
30
  const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
21
31
  export const ERROR_CODES = [
22
- "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
23
- "RUN_LIMIT_EXCEEDED", "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
24
- "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
32
+ "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
33
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
34
+ "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
25
35
  ];
26
- const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 2;
36
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
27
37
  export class WorkflowError extends Error {
28
38
  code;
29
39
  constructor(code, message) {
@@ -53,6 +63,7 @@ function workflowDetail(message) {
53
63
  return detail || "No further details were provided";
54
64
  }
55
65
  const WORKFLOW_ERROR_PROSE = {
66
+ CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
56
67
  INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
57
68
  INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
58
69
  INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
@@ -64,7 +75,6 @@ const WORKFLOW_ERROR_PROSE = {
64
75
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
65
76
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
66
77
  UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
67
- RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
68
78
  RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
69
79
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
70
80
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
@@ -74,6 +84,7 @@ const WORKFLOW_ERROR_PROSE = {
74
84
  WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
75
85
  WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
76
86
  RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
87
+ BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
77
88
  INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
78
89
  };
79
90
  export function formatWorkflowFailure(error) {
@@ -123,7 +134,7 @@ export class RunLifecycle {
123
134
  if (this.#active > 0)
124
135
  this.#active -= 1;
125
136
  if (this.#state === "pausing" && this.#active === 0)
126
- await this.#set("paused");
137
+ await this.#set("paused", "pause");
127
138
  }
128
139
  async enterAwaitingInput() {
129
140
  while (this.#state === "pausing" || this.#state === "paused")
@@ -132,26 +143,26 @@ export class RunLifecycle {
132
143
  return;
133
144
  if (this.#state !== "running")
134
145
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
135
- await this.#set("awaiting_input");
146
+ await this.#set("awaiting_input", "awaiting_input");
136
147
  }
137
148
  async resolveAwaitingInput() {
138
149
  if (this.#state !== "awaiting_input")
139
150
  return;
140
- await this.#set("running");
151
+ await this.#set("running", "checkpoint_resolved");
141
152
  for (const resolve of this.#waiters.splice(0))
142
153
  resolve();
143
154
  }
144
155
  async pause() {
145
156
  if (this.#state !== "running")
146
157
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
147
- await this.#set("pausing");
158
+ await this.#set("pausing", "pause");
148
159
  if (this.#active === 0)
149
- await this.#set("paused");
160
+ await this.#set("paused", "pause");
150
161
  }
151
162
  async resume() {
152
- if (this.#state !== "paused" && this.#state !== "interrupted")
163
+ if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted")
153
164
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
154
- await this.#set("running");
165
+ await this.#set("running", "resume");
155
166
  for (const resolve of this.#waiters.splice(0))
156
167
  resolve();
157
168
  }
@@ -161,32 +172,290 @@ export class RunLifecycle {
161
172
  await this.pause();
162
173
  await this.enter();
163
174
  }
164
- async terminal(state) {
175
+ async terminal(state, reason) {
165
176
  if (["completed", "failed", "stopped"].includes(this.#state))
166
177
  throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
167
- await this.#set(state);
178
+ await this.#set(state, reason ?? state);
168
179
  for (const resolve of this.#waiters.splice(0))
169
180
  resolve();
170
181
  }
171
- async #set(state) { this.#state = state; await this.changed?.(state); }
182
+ async #set(state, reason) {
183
+ const previousState = this.#state;
184
+ this.#state = state;
185
+ await this.changed?.(state, previousState, reason);
186
+ }
172
187
  }
173
- export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8, maxAgentLaunches: 1000 });
188
+ export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
174
189
  function fail(code, message) { throw new WorkflowError(code, message); }
175
190
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
191
+ function nonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; }
192
+ function nonNegativeFinite(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
193
+ export function validateBudget(value) {
194
+ if (value === undefined)
195
+ return undefined;
196
+ if (!object(value))
197
+ fail("INVALID_METADATA", "budget must be an object");
198
+ const result = {};
199
+ for (const [dimension, raw] of Object.entries(value)) {
200
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
201
+ fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
202
+ if (!object(raw))
203
+ fail("INVALID_METADATA", `${dimension} budget must be an object`);
204
+ if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
205
+ fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
206
+ const isCost = dimension === "costUsd";
207
+ for (const key of ["soft", "hard"])
208
+ if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key])))
209
+ fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
210
+ if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard)
211
+ fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
212
+ const limits = {};
213
+ if (raw.soft !== undefined)
214
+ limits.soft = raw.soft;
215
+ if (raw.hard !== undefined)
216
+ limits.hard = raw.hard;
217
+ if (Object.keys(limits).length)
218
+ result[dimension] = limits;
219
+ }
220
+ return Object.freeze(result);
221
+ }
222
+ export function validateBudgetPatch(value) {
223
+ if (!object(value))
224
+ fail("INVALID_METADATA", "budget patch must be an object");
225
+ const result = {};
226
+ for (const [dimension, raw] of Object.entries(value)) {
227
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
228
+ fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
229
+ if (raw === null) {
230
+ result[dimension] = null;
231
+ continue;
232
+ }
233
+ if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
234
+ fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
235
+ const limits = {};
236
+ for (const key of ["soft", "hard"])
237
+ if (Object.prototype.hasOwnProperty.call(raw, key)) {
238
+ if (raw[key] === null)
239
+ limits[key] = null;
240
+ else {
241
+ const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension];
242
+ if (checked?.[key] !== undefined)
243
+ limits[key] = checked[key];
244
+ }
245
+ }
246
+ if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard)
247
+ fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
248
+ result[dimension] = limits;
249
+ }
250
+ return result;
251
+ }
252
+ function budgetUsage(value) { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
253
+ export class WorkflowBudgetRuntime {
254
+ budget;
255
+ version;
256
+ #now;
257
+ #onChange;
258
+ #injected = new Set();
259
+ #seen = new Set();
260
+ #active;
261
+ #activeSince;
262
+ #usage;
263
+ #events;
264
+ #turnAccounting;
265
+ constructor(budget, version = 1, usage, events = [], options = {}) {
266
+ this.budget = budget;
267
+ this.version = version;
268
+ this.#now = options.now ?? (() => Date.now());
269
+ this.#onChange = options.onChange;
270
+ this.#active = options.active ?? true;
271
+ this.#activeSince = this.#active ? this.#now() : undefined;
272
+ this.#usage = budgetUsage(usage);
273
+ this.#events = [...events];
274
+ for (const event of events)
275
+ if (event.budgetVersion === version)
276
+ this.#seen.add(event.type);
277
+ }
278
+ get usage() { this.#syncDuration(); return { ...this.#usage }; }
279
+ get events() { return this.#events; }
280
+ get hardExhausted() { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
281
+ checkAgentLaunch() { this.#checkHard(["agentLaunches"]); }
282
+ beforeAttempt() { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
283
+ beforeTurn() { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
284
+ afterTurn(accounting, final) { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
285
+ #applyTurn(accounting, final, previous = { input: 0, output: 0, cost: 0 }) {
286
+ this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
287
+ this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
288
+ this.#evaluate();
289
+ if (!final)
290
+ this.#checkHard(["tokens", "costUsd", "durationMs"]);
291
+ }
292
+ instruction(agentId = "agent") {
293
+ if (!this.#hasSoftCrossed() || this.#injected.has(agentId))
294
+ return undefined;
295
+ this.#injected.add(agentId);
296
+ 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.`;
297
+ }
298
+ forAgent(agentId) {
299
+ let attempt = 0;
300
+ let previous;
301
+ return {
302
+ beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
303
+ beforeTurn: () => { this.beforeTurn(); },
304
+ afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
305
+ instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
306
+ };
307
+ }
308
+ transition(state) {
309
+ const active = state === "running";
310
+ if (active === this.#active)
311
+ return;
312
+ if (active) {
313
+ this.#active = true;
314
+ this.#activeSince = this.#now();
315
+ }
316
+ else {
317
+ this.#syncDuration();
318
+ this.#evaluate();
319
+ this.#active = false;
320
+ this.#activeSince = undefined;
321
+ }
322
+ this.#onChange?.();
323
+ }
324
+ #syncDuration() { if (this.#active && this.#activeSince !== undefined) {
325
+ const now = this.#now();
326
+ this.#usage.durationMs += Math.max(0, now - this.#activeSince);
327
+ this.#activeSince = now;
328
+ } }
329
+ #hasSoftCrossed() { return !!this.budget && Object.entries(this.budget).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
330
+ #checkHard(dimensions) {
331
+ const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
332
+ if (!exhausted.length)
333
+ 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() {
339
+ const budget = this.budget;
340
+ if (!budget)
341
+ return;
342
+ const soft = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
343
+ if (soft.length)
344
+ this.#record("soft_crossed", soft);
345
+ const overrun = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
346
+ if (overrun.length)
347
+ this.#record("hard_overrun", overrun);
348
+ }
349
+ #record(type, dimensions) { if (this.#seen.has(type))
350
+ return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
351
+ recordEvent(event) { this.#events.push(structuredClone(event)); }
352
+ snapshot() { return { usage: this.usage, budgetEvents: [...this.#events] }; }
353
+ }
354
+ export function mergeBudget(budget, patch) {
355
+ const merged = structuredClone(budget ?? {});
356
+ for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"])
357
+ if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
358
+ const value = patch[dimension];
359
+ if (value === null) {
360
+ Reflect.deleteProperty(merged, dimension);
361
+ continue;
362
+ }
363
+ const next = { ...(merged[dimension] ?? {}) };
364
+ for (const key of ["soft", "hard"])
365
+ if (value && Object.prototype.hasOwnProperty.call(value, key)) {
366
+ const limit = value[key];
367
+ if (limit === null)
368
+ Reflect.deleteProperty(next, key);
369
+ else if (limit !== undefined)
370
+ next[key] = limit;
371
+ }
372
+ if (Object.keys(next).length)
373
+ merged[dimension] = next;
374
+ else
375
+ Reflect.deleteProperty(merged, dimension);
376
+ }
377
+ return validateBudget(merged);
378
+ }
379
+ export function budgetRelaxed(previous, next) { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
380
+ const oldLimit = previous?.[dimension];
381
+ const newLimit = next?.[dimension];
382
+ for (const key of ["soft", "hard"])
383
+ if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key]))
384
+ return true;
385
+ } return false; }
386
+ export function exhaustedBudgetDimensions(budget, usage) {
387
+ if (!budget)
388
+ return [];
389
+ return Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
390
+ }
391
+ export function resumeBudgetAllowed(budget, usage) { return exhaustedBudgetDimensions(budget, usage).length === 0; }
176
392
  function positiveInteger(value) { return Number.isInteger(value) && value > 0; }
393
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
394
+ const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
177
395
  export function parseModelReference(value) {
178
396
  const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
179
397
  if (!match?.[1] || !match[2])
180
398
  fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
181
399
  const thinking = match[3];
182
- if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))
400
+ if (thinking && !THINKING_LEVELS.includes(thinking))
183
401
  fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
184
402
  return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking } : {}) };
185
403
  }
186
- function modelCapability(value) {
187
- const parsed = parseModelReference(value);
404
+ function aliasError(message, settingsPath) { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
405
+ export function validateModelAliases(value, settingsPath = "workflow settings") {
406
+ if (!object(value))
407
+ aliasError("modelAliases must be an object", settingsPath);
408
+ const aliases = {};
409
+ for (const [name, target] of Object.entries(value)) {
410
+ if (!MODEL_ALIAS_NAME.test(name))
411
+ aliasError(`Invalid model alias name: ${name}`, settingsPath);
412
+ if (typeof target !== "string" || !target.trim())
413
+ aliasError(`Invalid model alias target for ${name}`, settingsPath);
414
+ try {
415
+ parseModelReference(target);
416
+ }
417
+ catch (error) {
418
+ aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath);
419
+ }
420
+ aliases[name] = target;
421
+ }
422
+ return Object.freeze(aliases);
423
+ }
424
+ function unknownModel(value, target, settingsPath) {
425
+ const resolved = target ? ` resolved to ${target}` : "";
426
+ const path = settingsPath ? ` (settings: ${settingsPath})` : "";
427
+ fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
428
+ }
429
+ export function resolveModelReference(value, aliases = {}, knownModels, settingsPath) {
430
+ const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
431
+ if (target !== undefined) {
432
+ try {
433
+ return parseModelReference(target);
434
+ }
435
+ catch {
436
+ unknownModel(value, target, settingsPath);
437
+ }
438
+ }
439
+ if (value.includes("/"))
440
+ return parseModelReference(value);
441
+ const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
442
+ const thinking = match?.[2];
443
+ if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking))
444
+ unknownModel(value, undefined, settingsPath);
445
+ const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
446
+ if (candidates.length === 1) {
447
+ const parsed = parseModelReference(candidates[0]);
448
+ return thinking ? { ...parsed, thinking: thinking } : parsed;
449
+ }
450
+ unknownModel(value, undefined, settingsPath);
451
+ }
452
+ function modelCapability(value, aliases, knownModels, settingsPath) {
453
+ const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
188
454
  return `${parsed.provider}/${parsed.model}`;
189
455
  }
456
+ function aliasDrift(previous, current) {
457
+ return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
458
+ }
190
459
  export function validateCheckpoint(value) {
191
460
  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))
192
461
  fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
@@ -197,6 +466,60 @@ export function validateCheckpoint(value) {
197
466
  return { name: value.name, prompt: value.prompt, context: value.context };
198
467
  }
199
468
  export function workflowSettingsPath(agentDir = getAgentDir()) { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
469
+ export function workflowProjectSettingsPath(cwd) { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
470
+ const EMPTY_AGENT_RESOURCE_EXCLUSIONS = Object.freeze({ skills: [], extensions: [] });
471
+ function normalizedResourcePath(value, settingsPath) {
472
+ let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
473
+ if (expanded.startsWith("file://"))
474
+ expanded = fileURLToPath(expanded);
475
+ const resolved = resolve(dirname(settingsPath), expanded);
476
+ try {
477
+ return realpathSync(resolved);
478
+ }
479
+ catch {
480
+ return resolved;
481
+ }
482
+ }
483
+ export function mergeAgentResourceExclusions(...values) {
484
+ return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
485
+ }
486
+ function validateAgentResourceExclusions(value, settingsPath, errorCode = "INVALID_SETTINGS") {
487
+ if (value === undefined)
488
+ return undefined;
489
+ const base = `${settingsPath}.disabledAgentResources`;
490
+ if (!object(value))
491
+ fail(errorCode, `${base} must be an object`);
492
+ for (const key of Object.keys(value))
493
+ if (key !== "skills" && key !== "extensions")
494
+ fail(errorCode, `${base}.${key} is not supported`);
495
+ const normalized = { skills: [], extensions: [] };
496
+ for (const kind of ["skills", "extensions"]) {
497
+ const entries = value[kind];
498
+ if (entries === undefined)
499
+ continue;
500
+ if (!Array.isArray(entries))
501
+ fail(errorCode, `${base}.${kind} must be an array`);
502
+ const seen = new Set();
503
+ for (const [index, entry] of entries.entries()) {
504
+ if (typeof entry !== "string" || !entry.trim())
505
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
506
+ let selector = entry.trim();
507
+ if (kind === "extensions") {
508
+ try {
509
+ selector = normalizedResourcePath(selector, settingsPath);
510
+ }
511
+ catch (error) {
512
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`);
513
+ }
514
+ }
515
+ if (!seen.has(selector)) {
516
+ seen.add(selector);
517
+ normalized[kind].push(selector);
518
+ }
519
+ }
520
+ }
521
+ return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
522
+ }
200
523
  export function loadSettings(path = workflowSettingsPath()) {
201
524
  let parsed;
202
525
  try {
@@ -205,23 +528,45 @@ export function loadSettings(path = workflowSettingsPath()) {
205
528
  catch (error) {
206
529
  if (error.code === "ENOENT")
207
530
  return DEFAULT_SETTINGS;
208
- fail("INVALID_SETTINGS", `Invalid workflow settings: ${errorText(error)}`);
531
+ fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
209
532
  }
210
533
  if (!object(parsed))
211
- fail("INVALID_SETTINGS", "Workflow settings must be an object");
212
- const allowed = new Set(["concurrency", "maxAgentLaunches"]);
534
+ fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
535
+ const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
213
536
  const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
214
537
  if (unknown)
215
- fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
538
+ fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
216
539
  const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
217
- const maxAgentLaunches = parsed.maxAgentLaunches === undefined ? DEFAULT_SETTINGS.maxAgentLaunches : parsed.maxAgentLaunches;
218
540
  if (!positiveInteger(concurrency) || concurrency > 16)
219
- fail("INVALID_SETTINGS", "concurrency must be an integer from 1 to 16");
220
- if (!positiveInteger(maxAgentLaunches))
221
- fail("INVALID_SETTINGS", "maxAgentLaunches must be a positive integer");
222
- return Object.freeze({ concurrency, maxAgentLaunches });
541
+ fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
542
+ const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
543
+ const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
544
+ return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
545
+ }
546
+ export function resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
547
+ const projectSettingsPath = workflowProjectSettingsPath(cwd);
548
+ const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
549
+ const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
550
+ const effective = mergeAgentResourceExclusions(global, project);
551
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
552
+ }
553
+ export function saveModelAliases(path = workflowSettingsPath(), aliases = {}) {
554
+ const normalized = validateModelAliases(aliases, path);
555
+ let parsed = {};
556
+ try {
557
+ loadSettings(path);
558
+ parsed = JSON.parse(readFileSync(path, "utf8"));
559
+ }
560
+ catch (error) {
561
+ if (error.code !== "ENOENT")
562
+ throw error;
563
+ }
564
+ mkdirSync(dirname(path), { recursive: true });
565
+ const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
566
+ writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
567
+ renameSync(temporary, path);
223
568
  }
224
- export function parseRoleMarkdown(content, strict = false) {
569
+ export function parseRoleMarkdown(content, strict = false, rolePath) {
225
570
  if (!strict) {
226
571
  if (!content.startsWith("---\n"))
227
572
  return { prompt: content };
@@ -261,7 +606,7 @@ export function parseRoleMarkdown(content, strict = false) {
261
606
  }
262
607
  if (!object(parsed.frontmatter))
263
608
  fail("INVALID_METADATA", "Role frontmatter must be an object");
264
- const { model, thinking, tools, description } = parsed.frontmatter;
609
+ const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
265
610
  if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
266
611
  fail("INVALID_METADATA", "Role model must be a non-empty string");
267
612
  if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
@@ -270,7 +615,8 @@ export function parseRoleMarkdown(content, strict = false) {
270
615
  fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
271
616
  if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
272
617
  fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
273
- return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => tool.trim()) } : {}) };
618
+ const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
619
+ return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => tool.trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
274
620
  }
275
621
  const ROLE_DIRECTORY = "pi-extensible-workflows";
276
622
  export function workflowRoleDirectories(agentDir = getAgentDir()) {
@@ -283,7 +629,7 @@ function readAgentDefinitions(dir) {
283
629
  try {
284
630
  return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
285
631
  .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
286
- .map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(join(dir, entry.name), "utf8"), true)]));
632
+ .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
287
633
  }
288
634
  catch (error) {
289
635
  if (error.code === "ENOENT")
@@ -297,13 +643,19 @@ function readRoleDefinitions(dirs) {
297
643
  export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
298
644
  return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
299
645
  }
300
- function validateRolePolicies(definitions, roles, availableModels, rootTools) {
646
+ function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
301
647
  for (const role of roles) {
302
648
  const definition = definitions[role];
303
649
  if (!definition)
304
650
  continue;
305
- if (definition.model !== undefined && !availableModels.has(modelCapability(definition.model)))
306
- fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${definition.model}`);
651
+ if (definition.model !== undefined) {
652
+ const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
653
+ if (!availableModels.has(resolved)) {
654
+ if (Object.prototype.hasOwnProperty.call(aliases, definition.model))
655
+ unknownModel(definition.model, resolved, settingsPath);
656
+ fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
657
+ }
658
+ }
307
659
  const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
308
660
  if (missingTool)
309
661
  fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
@@ -351,7 +703,7 @@ function astNode(value) {
351
703
  function workflowCalls(program) {
352
704
  const calls = [];
353
705
  const visit = (node) => {
354
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
706
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
355
707
  calls.push(node);
356
708
  for (const value of Object.values(node)) {
357
709
  if (Array.isArray(value)) {
@@ -376,7 +728,7 @@ function workflowCallsWithStructure(program) {
376
728
  if (scope?.key === null && key)
377
729
  current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
378
730
  }
379
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
731
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
380
732
  const call = node;
381
733
  const operation = call.callee.name;
382
734
  const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
@@ -428,6 +780,7 @@ function hasIdentifier(node, name) {
428
780
  return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
429
781
  }
430
782
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
783
+ const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
431
784
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
432
785
  function callHasTrailingComma(source, call) {
433
786
  let previous;
@@ -445,14 +798,16 @@ function instrumentWorkflow(script) {
445
798
  const program = parseWorkflow(body);
446
799
  if (hasIdentifier(program, INTERNAL_AGENT_NAME))
447
800
  fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
801
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
802
+ fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
448
803
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
449
804
  fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
450
- const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
805
+ const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
451
806
  const edits = calls.flatMap((call) => {
452
807
  const callSite = `${String(call.start)}:${String(call.end)}`;
453
808
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
454
809
  return [
455
- { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
810
+ { 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 },
456
811
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
457
812
  ];
458
813
  }).sort((left, right) => right.start - left.start);
@@ -544,16 +899,17 @@ function validateSchema(schema, at = "schema") {
544
899
  fail("INVALID_SCHEMA", `${at}.properties must be an object`);
545
900
  }
546
901
  const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
547
- function validateAgentOption(key, value) {
902
+ function validateAgentOption(key, value, aliases, knownModels, settingsPath) {
548
903
  switch (key) {
549
904
  case "label":
550
905
  if (typeof value !== "string" || !value.trim())
551
906
  fail("INVALID_METADATA", "agent label must be a non-empty string");
552
907
  break;
553
908
  case "model":
554
- if (typeof value !== "string")
555
- fail("INVALID_METADATA", "agent model must be a string");
556
- parseModelReference(value);
909
+ if (typeof value !== "string" || !value.trim())
910
+ fail("INVALID_METADATA", "agent model must be a non-empty string");
911
+ if (aliases !== undefined)
912
+ resolveModelReference(value, aliases, knownModels, settingsPath);
557
913
  break;
558
914
  case "thinking":
559
915
  if (typeof value !== "string" || !parseThinking(value))
@@ -583,11 +939,9 @@ function validateAgentOption(key, value) {
583
939
  function validateAgentOptions(value) {
584
940
  if (!object(value) || !jsonValue(value))
585
941
  fail("INVALID_METADATA", "agent options must be a JSON object");
586
- const unknown = Object.keys(value).find((key) => !AGENT_OPTION_KEYS.has(key));
587
- if (unknown)
588
- fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
589
942
  for (const [key, option] of Object.entries(value))
590
- validateAgentOption(key, option);
943
+ if (AGENT_OPTION_KEYS.has(key))
944
+ validateAgentOption(key, option);
591
945
  if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
592
946
  fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
593
947
  return value;
@@ -642,7 +996,7 @@ export function inspectWorkflowScript(script) {
642
996
  const first = callArgument(call, 0);
643
997
  const options = callArgument(call, 1);
644
998
  const placement = { execution, structure };
645
- if (kind === "agent") {
999
+ if (kind === "agent" || kind === "conversation") {
646
1000
  const retries = staticValue(propertyNode(options, "retries"));
647
1001
  const outputSchema = staticValue(propertyNode(options, "outputSchema"));
648
1002
  const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
@@ -652,7 +1006,7 @@ export function inspectWorkflowScript(script) {
652
1006
  return key ? [key] : [];
653
1007
  }) : [];
654
1008
  const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; }));
655
- 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")) };
1009
+ 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")) };
656
1010
  return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
657
1011
  }
658
1012
  if (kind === "checkpoint")
@@ -660,27 +1014,17 @@ export function inspectWorkflowScript(script) {
660
1014
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
661
1015
  });
662
1016
  }
663
- function validateStaticAgentOptions(node) {
1017
+ function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPath) {
664
1018
  if (node?.type !== "ObjectExpression")
665
1019
  return;
666
- for (const property of node.properties) {
667
- if (property.type === "SpreadElement" || property.computed)
668
- continue;
669
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
670
- if (key && !AGENT_OPTION_KEYS.has(key))
671
- fail("INVALID_METADATA", `Unknown agent option: ${key}`);
672
- }
673
- const role = propertyNode(node, "role");
674
- if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined))
1020
+ const options = staticValue(node);
1021
+ if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
675
1022
  fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
676
1023
  for (const key of AGENT_OPTION_KEYS) {
677
1024
  const value = staticValue(propertyNode(node, key));
678
1025
  if (value.known)
679
- validateAgentOption(key, value.value);
1026
+ validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
680
1027
  }
681
- const options = staticValue(node);
682
- if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
683
- fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
684
1028
  }
685
1029
  function validateStaticWithWorktree(call) {
686
1030
  if (call.arguments.some((argument) => argument.type === "SpreadElement"))
@@ -696,113 +1040,134 @@ function validateStaticWithWorktree(call) {
696
1040
  fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
697
1041
  }
698
1042
  }
699
- 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"]);
1043
+ 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"]);
700
1044
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
701
1045
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
702
1046
  export class WorkflowRegistry {
703
- #extensions = new Map();
1047
+ #extensions = new Set();
704
1048
  #globals = new Map();
1049
+ #workflows = new Map();
1050
+ #hooks = new Map();
705
1051
  #frozen = false;
706
1052
  get frozen() { return this.#frozen; }
707
1053
  freeze() { this.#frozen = true; }
708
1054
  register(extension) {
709
1055
  if (this.#frozen)
710
1056
  fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
711
- 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())
712
- fail("INVALID_METADATA", "Workflow extensions require a namespace, semantic version, headline, and description");
713
- if (this.#extensions.has(extension.namespace))
714
- fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
1057
+ 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())
1058
+ fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
715
1059
  const functions = extension.functions ?? {};
716
1060
  const variables = extension.variables ?? {};
717
1061
  const workflows = extension.workflows ?? {};
718
- if (!object(functions) || !object(variables) || !object(workflows) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0))
719
- fail("INVALID_METADATA", "Workflow extensions require functions, variables, or workflows");
1062
+ const agentSetupHooks = extension.agentSetupHooks ?? {};
1063
+ 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))
1064
+ fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
720
1065
  const names = [...Object.keys(functions), ...Object.keys(variables)];
721
1066
  if (new Set(names).size !== names.length)
722
- fail("GLOBAL_COLLISION", `Global name collision inside ${extension.namespace}`);
1067
+ fail("GLOBAL_COLLISION", "Global name collision inside extension");
723
1068
  for (const name of names) {
724
1069
  if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_"))
725
- fail("INVALID_METADATA", `Invalid global name: ${extension.namespace}.${name}`);
1070
+ fail("INVALID_METADATA", `Invalid global name: ${name}`);
726
1071
  if (RESERVED_GLOBALS.has(name))
727
1072
  fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
728
- const owner = this.#globals.get(name);
729
- if (owner)
730
- fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
1073
+ if (this.#globals.has(name))
1074
+ fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
731
1075
  }
732
1076
  for (const [name, fn] of Object.entries(functions)) {
733
1077
  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")
734
- fail("INVALID_METADATA", `Invalid workflow function: ${extension.namespace}.${name}`);
735
- validateSchema(fn.input, `${extension.namespace}.${name} input`);
736
- validateSchema(fn.output, `${extension.namespace}.${name} output`);
1078
+ fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
1079
+ validateSchema(fn.input, `${name} input`);
1080
+ validateSchema(fn.output, `${name} output`);
737
1081
  if (fn.input.type !== "object")
738
- fail("INVALID_SCHEMA", `${extension.namespace}.${name} input must describe one object`);
1082
+ fail("INVALID_SCHEMA", `${name} input must describe one object`);
739
1083
  }
740
1084
  for (const [name, variable] of Object.entries(variables)) {
741
1085
  if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function")
742
- fail("INVALID_METADATA", `Invalid workflow variable: ${extension.namespace}.${name}`);
743
- validateSchema(variable.schema, `${extension.namespace}.${name} schema`);
1086
+ fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
1087
+ validateSchema(variable.schema, `${name} schema`);
744
1088
  }
745
1089
  for (const [name, workflow] of Object.entries(workflows)) {
746
1090
  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())
747
- fail("INVALID_METADATA", `Invalid workflow script: ${extension.namespace}.${name}`);
1091
+ fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
1092
+ if (this.#workflows.has(name))
1093
+ fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
748
1094
  parseWorkflow(workflow.script);
749
1095
  }
750
- const stored = deepFreeze({ ...extension, functions, variables, workflows });
751
- this.#extensions.set(extension.namespace, stored);
1096
+ for (const [name, hook] of Object.entries(agentSetupHooks)) {
1097
+ 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)))
1098
+ fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
1099
+ if (this.#hooks.has(name))
1100
+ fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
1101
+ }
1102
+ const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
1103
+ this.#extensions.add(stored);
752
1104
  for (const name of names)
753
- this.#globals.set(name, `${extension.namespace}.${name}`);
1105
+ this.#globals.set(name, name);
1106
+ for (const [name, workflow] of Object.entries(workflows))
1107
+ this.#workflows.set(name, workflow);
1108
+ for (const [name, hook] of Object.entries(agentSetupHooks))
1109
+ this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
754
1110
  }
755
1111
  workflow(name) {
756
- const separator = name.indexOf(".");
757
- if (separator <= 0 || separator !== name.lastIndexOf(".") || !IDENTIFIER.test(name.slice(0, separator)) || !IDENTIFIER.test(name.slice(separator + 1)))
758
- fail("MISSING_WORKFLOW", `Registered workflows require a qualified namespace.name: ${name}`);
759
- const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
1112
+ if (!IDENTIFIER.test(name))
1113
+ fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
1114
+ const workflow = this.#workflows.get(name);
760
1115
  if (!workflow)
761
1116
  fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
762
1117
  return workflow;
763
1118
  }
764
1119
  workflows() {
765
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.workflows ?? {}).map(([name, workflow]) => [`${namespace}.${name}`, workflow]))));
1120
+ return Object.freeze(Object.fromEntries(this.#workflows));
766
1121
  }
767
1122
  catalog() {
768
1123
  const functions = [];
769
1124
  const variables = [];
770
1125
  const workflows = [];
771
- for (const [namespace, extension] of this.#extensions) {
1126
+ for (const extension of this.#extensions) {
772
1127
  for (const [name, fn] of Object.entries(extension.functions ?? {}))
773
- functions.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
1128
+ functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
774
1129
  for (const [name, variable] of Object.entries(extension.variables ?? {}))
775
- variables.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
1130
+ variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
776
1131
  for (const [name, workflow] of Object.entries(extension.workflows ?? {}))
777
- workflows.push({ name: `${namespace}.${name}`, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1132
+ workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1133
+ }
1134
+ let aliases;
1135
+ try {
1136
+ aliases = loadSettings().modelAliases;
778
1137
  }
779
- const sort = (left, right) => left.namespace.localeCompare(right.namespace) || left.name.localeCompare(right.name);
780
- return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort((left, right) => left.name.localeCompare(right.name)) });
1138
+ catch {
1139
+ aliases = undefined;
1140
+ }
1141
+ const sort = (left, right) => left.name.localeCompare(right.name);
1142
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
781
1143
  }
782
1144
  globals() {
783
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.keys(extension.functions ?? {}).map((name) => [name, { namespace, name }]))));
1145
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
784
1146
  }
785
- async invokeFunction(namespace, name, input, context, path, journal) {
786
- const fn = this.#extensions.get(namespace)?.functions?.[name];
1147
+ async invokeFunction(name, input, context, path, journal) {
1148
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
787
1149
  if (!fn)
788
- fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${namespace}.${name}`);
1150
+ fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
789
1151
  if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
790
- fail("RESULT_INVALID", `Invalid input for ${namespace}.${name}`);
1152
+ fail("RESULT_INVALID", `Invalid input for ${name}`);
791
1153
  const replayed = journal.get(path);
792
1154
  if (replayed !== undefined) {
793
1155
  if (!jsonValue(replayed) || !Value.Check(fn.output, replayed))
794
- fail("RESULT_INVALID", `Invalid replay for ${namespace}.${name}`);
1156
+ fail("RESULT_INVALID", `Invalid replay for ${name}`);
795
1157
  return structuredClone(replayed);
796
1158
  }
797
1159
  const result = 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 }));
798
1160
  if (!jsonValue(result) || !Value.Check(fn.output, result))
799
- fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
1161
+ fail("RESULT_INVALID", `Invalid output from ${name}`);
800
1162
  const stored = structuredClone(result);
801
1163
  journal.put(path, stored);
802
1164
  return structuredClone(stored);
803
1165
  }
804
1166
  variables() {
805
- return [...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ namespace, name, variable })));
1167
+ return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
1168
+ }
1169
+ agentSetupHooks() {
1170
+ return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
806
1171
  }
807
1172
  }
808
1173
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
@@ -818,6 +1183,7 @@ function createWorkflowRegistryApi(registry) {
818
1183
  globals: () => registry.globals(),
819
1184
  invokeFunction: (...args) => registry.invokeFunction(...args),
820
1185
  variables: () => registry.variables(),
1186
+ agentSetupHooks: () => registry.agentSetupHooks(),
821
1187
  };
822
1188
  }
823
1189
  function workflowRegistryHost() {
@@ -848,11 +1214,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
848
1214
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
849
1215
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
850
1216
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
851
- workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as namespace.name" })),
1217
+ workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
852
1218
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
853
1219
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
854
1220
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
855
- maxAgentLaunches: Type.Optional(Type.Integer({ minimum: 1, description: "Total logical agent launches for the run, including nested agents; not concurrency" })),
1221
+ budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
856
1222
  });
857
1223
  function hasDynamicAgentRole(node) {
858
1224
  if (!node)
@@ -874,17 +1240,23 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
874
1240
  const program = parseWorkflow(script);
875
1241
  if (hasIdentifier(program, INTERNAL_AGENT_NAME))
876
1242
  fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1243
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
1244
+ fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
877
1245
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
878
1246
  fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
879
1247
  validateDirectPrimitiveReferences(program, "withWorktree");
1248
+ validateDirectPrimitiveReferences(program, "conversation");
880
1249
  for (const [index, schema] of schemas.entries())
881
1250
  validateSchema(schema, `schema[${String(index)}]`);
882
1251
  const calls = workflowCalls(program);
883
1252
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
884
1253
  for (const call of calls) {
885
1254
  const operation = call.callee.name;
886
- if (operation === "agent")
887
- validateStaticAgentOptions(call.arguments[1]);
1255
+ if (operation === "agent" || operation === "conversation") {
1256
+ if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2))
1257
+ fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
1258
+ validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1259
+ }
888
1260
  if (operation === "withWorktree")
889
1261
  validateStaticWithWorktree(call);
890
1262
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
@@ -896,21 +1268,25 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
896
1268
  if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
897
1269
  fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
898
1270
  }
899
- const agentCalls = calls.filter((call) => call.callee.name === "agent");
1271
+ const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
900
1272
  const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
901
1273
  const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
902
1274
  for (const [index, schema] of staticSchemas.entries())
903
1275
  validateSchema(schema, `agent outputSchema[${String(index)}]`);
904
1276
  const checkedSchemas = [...schemas, ...staticSchemas];
905
- const models = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "model")); return value === undefined ? [] : [modelCapability(value)]; });
1277
+ 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) }]; });
1278
+ const models = modelRefs.map(({ resolved }) => resolved);
906
1279
  const tools = agentCalls.flatMap((call) => {
907
1280
  const value = propertyNode(call.arguments[1], "tools");
908
1281
  return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
909
1282
  });
910
1283
  const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
911
- const missingModel = models.find((model) => !capabilities.models.has(model));
912
- if (missingModel)
913
- fail("UNKNOWN_MODEL", `Unknown model: ${missingModel}`);
1284
+ const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
1285
+ if (missingModel) {
1286
+ if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested))
1287
+ unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
1288
+ fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
1289
+ }
914
1290
  const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
915
1291
  if (missingTool)
916
1292
  fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
@@ -923,6 +1299,8 @@ export function validateWorkflowLaunch(params, context) {
923
1299
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
924
1300
  }
925
1301
  function validateWorkflowLaunchWithRegistry(params, context, registry) {
1302
+ if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches"))
1303
+ fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
926
1304
  if (params.script !== undefined && params.workflow !== undefined)
927
1305
  fail("INVALID_METADATA", "Provide either script or workflow, not both");
928
1306
  const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
@@ -936,9 +1314,11 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
936
1314
  const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
937
1315
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
938
1316
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
939
- const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)) }, [], metadata);
1317
+ const aliases = context.modelAliases ?? {};
1318
+ const knownModels = context.knownModels ?? context.availableModels;
1319
+ 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);
940
1320
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
941
- validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
1321
+ validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
942
1322
  return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
943
1323
  }
944
1324
  function deepFreeze(value) {
@@ -953,6 +1333,8 @@ export function createLaunchSnapshot(input) {
953
1333
  return deepFreeze(structuredClone({ ...input, identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
954
1334
  }
955
1335
  export function loadLaunchSnapshot(input) {
1336
+ if (object(input.settings) && Object.prototype.hasOwnProperty.call(input.settings, "maxAgentLaunches"))
1337
+ fail("RESUME_INCOMPATIBLE", "Persisted workflow settings contain removed maxAgentLaunches");
956
1338
  return deepFreeze(structuredClone(input));
957
1339
  }
958
1340
  export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
@@ -1020,6 +1402,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1020
1402
  const path = (...names) => names.map(encodeURIComponent).join("/");
1021
1403
  const inheritedAgentPath = new AsyncLocalStorage();
1022
1404
  const agentOccurrences = new Map();
1405
+ const conversationOccurrences = new Map();
1023
1406
  const worktreeOwners = new AsyncLocalStorage();
1024
1407
  const worktreeOccurrences = new Map();
1025
1408
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
@@ -1043,6 +1426,44 @@ const internalWithWorktree = async (...values) => {
1043
1426
  }
1044
1427
  return await worktreeOwners.run(owner, callback);
1045
1428
  };
1429
+ const internalConversation = (...values) => {
1430
+ const callSite = values.pop();
1431
+ if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
1432
+ const name = values[0];
1433
+ if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
1434
+ const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
1435
+ if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
1436
+ const inherited = inheritedAgentPath.getStore() || [];
1437
+ const occurrenceKey = JSON.stringify([inherited, callSite, name]);
1438
+ const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
1439
+ conversationOccurrences.set(occurrenceKey, occurrence);
1440
+ const fixedOptions = structuredClone(conversationOptions);
1441
+ const defaultTimeout = fixedOptions.timeoutMs;
1442
+ const defaultRetries = fixedOptions.retries;
1443
+ delete fixedOptions.timeoutMs;
1444
+ delete fixedOptions.retries;
1445
+ const worktreeOwner = worktreeOwners.getStore();
1446
+ let turn = 0;
1447
+ let active = false;
1448
+ return Object.freeze({
1449
+ run(prompt, turnOptions = {}) {
1450
+ if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
1451
+ 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");
1452
+ if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
1453
+ active = true;
1454
+ const turnNumber = turn + 1;
1455
+ const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
1456
+ const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
1457
+ const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
1458
+ Object.defineProperties(result, {
1459
+ toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
1460
+ toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1461
+ [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1462
+ });
1463
+ return result;
1464
+ },
1465
+ });
1466
+ };
1046
1467
  const internalAgent = (...values) => {
1047
1468
  const callSite = values.pop();
1048
1469
  if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
@@ -1112,16 +1533,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
1112
1533
  const phase = name => rpc("phase", [name]);
1113
1534
  const log = message => rpc("log", [message]);
1114
1535
  const functionOccurrences = new Map();
1115
- const functionPath = (namespace, name) => {
1536
+ const functionPath = name => {
1116
1537
  const inherited = inheritedAgentPath.getStore() || [];
1117
- const key = JSON.stringify([inherited, namespace, name]);
1538
+ const key = JSON.stringify([inherited, name]);
1118
1539
  const occurrence = (functionOccurrences.get(key) || 0) + 1;
1119
1540
  functionOccurrences.set(key, occurrence);
1120
- return path("function", ...inherited, namespace, name, String(occurrence));
1541
+ return path("function", ...inherited, name, String(occurrence));
1121
1542
  };
1122
1543
  const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
1123
1544
  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");
1124
- const result = rpc("function", [target.namespace, target.name, values[0], functionPath(target.namespace, target.name), worktreeOwners.getStore() || null]).then(unwrap);
1545
+ const inherited = inheritedAgentPath.getStore() || [];
1546
+ const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
1125
1547
  Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
1126
1548
  return result;
1127
1549
  }])));
@@ -1182,13 +1604,13 @@ const pipeline = async (operationName, items, stages) => {
1182
1604
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1183
1605
  };
1184
1606
  const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
1185
- const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1607
+ const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1186
1608
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1187
1609
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1188
1610
  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;
1189
1611
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1190
1612
  const body = config.script;
1191
- Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
1613
+ 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))
1192
1614
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1193
1615
  .catch(error => send({ type: "error", error: workerError(error) }))
1194
1616
  .finally(() => clearInterval(heartbeat));
@@ -1209,13 +1631,25 @@ function readAgentIdentity(value) {
1209
1631
  const occurrence = value.occurrence;
1210
1632
  const worktreeOwner = value.worktreeOwner;
1211
1633
  const parentBreadcrumb = value.parentBreadcrumb;
1212
- if (!Array.isArray(structuralPath) || !structuralPath.every((part) => 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))
1634
+ const conversation = value.conversation;
1635
+ const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
1636
+ if (!Array.isArray(structuralPath) || !structuralPath.every((part) => 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)
1213
1637
  fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1214
- return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
1638
+ return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1215
1639
  }
1216
1640
  function agentIdentityPath(identity) {
1217
1641
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1218
1642
  }
1643
+ function conversationIdentityPath(identity) {
1644
+ if (!identity.conversation)
1645
+ throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1646
+ return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1647
+ }
1648
+ function conversationTurnPath(identity) {
1649
+ if (!identity.conversation)
1650
+ throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1651
+ return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1652
+ }
1219
1653
  function agentWorktree(identity) {
1220
1654
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1221
1655
  }
@@ -1340,12 +1774,15 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
1340
1774
  }
1341
1775
  }
1342
1776
  else if (method === "function") {
1343
- 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");
1344
- if (!bridge.function || typeof values[0] !== "string" || typeof values[1] !== "string" || !object(values[2]) || typeof values[3] !== "string")
1345
- fail("INTERNAL_ERROR", "function requires an available bridge, names, object input, and path");
1346
- const name = values[0] + "." + values[1];
1777
+ 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");
1778
+ const structuralPath = values[4] === undefined ? [] : values[4];
1779
+ if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())))
1780
+ fail("INTERNAL_ERROR", "function structural scope is invalid");
1781
+ if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string")
1782
+ fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
1783
+ const name = values[0];
1347
1784
  try {
1348
- const result = await bridge.function(values[0], values[1], values[2], values[3], controller.signal, worktreeOwner);
1785
+ const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
1349
1786
  value = branded({ name, ok: true, value: result ?? null });
1350
1787
  }
1351
1788
  catch (error) {
@@ -1397,8 +1834,9 @@ export async function persistActiveAgentAttempt(store, id, active) {
1397
1834
  if (!agent)
1398
1835
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1399
1836
  const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1837
+ const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
1400
1838
  const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
1401
- return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails: [{ ...active, accounting }] } : candidate), nativeSessions };
1839
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
1402
1840
  });
1403
1841
  }
1404
1842
  export async function persistAgentAttempts(store, id, attempts) {
@@ -1407,26 +1845,53 @@ export async function persistAgentAttempts(store, id, attempts) {
1407
1845
  if (!agent)
1408
1846
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1409
1847
  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 });
1410
- const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
1848
+ const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
1411
1849
  const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
1412
1850
  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))] };
1413
1851
  });
1414
1852
  }
1853
+ function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
1854
+ function agentGroupLabel(agents) {
1855
+ const structural = agents[0]?.structuralPath ?? [];
1856
+ const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value) => Boolean(value)))];
1857
+ return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
1858
+ }
1859
+ function agentGroups(agents) {
1860
+ const byId = new Map(agents.map((agent) => [agent.id, agent]));
1861
+ const groups = new Map();
1862
+ for (const [index, agent] of agents.entries()) {
1863
+ let depth = 0;
1864
+ for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
1865
+ depth += 1;
1866
+ const key = agentGroupKey(agent);
1867
+ const group = groups.get(key) ?? { agents: [] };
1868
+ group.agents.push({ agent, index, depth });
1869
+ groups.set(key, group);
1870
+ }
1871
+ return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
1872
+ }
1873
+ function renderGroupedAgents(agents, render) {
1874
+ const groups = agentGroups(agents);
1875
+ const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
1876
+ return groups.flatMap((group) => [
1877
+ ...(grouped ? [` ${group.label}`] : []),
1878
+ ...group.entries.map((entry) => render(entry, grouped)),
1879
+ ]);
1880
+ }
1415
1881
  export function formatWorkflowProgress(run, spinner = "◇") {
1416
1882
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
1417
- 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)`];
1883
+ 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)`];
1418
1884
  if (run.phase)
1419
1885
  lines.push(` Phase: ${run.phase}`);
1886
+ lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
1420
1887
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
1421
- for (const [index, agent] of run.agents.entries()) {
1422
- let depth = 0;
1423
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
1424
- depth += 1;
1888
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1425
1889
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
1426
- const indent = " ".repeat(depth + 1);
1890
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
1427
1891
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
1428
- lines.push(`${indent}#${String(index + 1)} ${icon} ${agent.parentBreadcrumb ? `${agent.parentBreadcrumb} > ` : ""}${agent.label ?? agent.name} [${agent.state}]${activity ? ` ${activity}` : ""}`);
1429
- }
1892
+ const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1893
+ return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
1894
+ }));
1430
1895
  return lines.join("\n");
1431
1896
  }
1432
1897
  function workflowToolUpdate(run) {
@@ -1450,7 +1915,31 @@ function workflowProgressBlock(run) {
1450
1915
  invalidate() { },
1451
1916
  };
1452
1917
  }
1453
- const ATTENTION_ORDER = { awaiting_input: 0, running: 1, pausing: 2, paused: 3, interrupted: 4, failed: 5, queued: 6, stopped: 7, completed: 8 };
1918
+ export function formatBudgetStatus(run) {
1919
+ const usage = budgetUsage(run.usage);
1920
+ if (!run.budget || !Object.keys(run.budget).length)
1921
+ return ["Budget: unlimited"];
1922
+ const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
1923
+ for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
1924
+ const limits = run.budget[dimension];
1925
+ if (!limits || (limits.soft === undefined && limits.hard === undefined))
1926
+ continue;
1927
+ const limit = limits.hard ?? limits.soft;
1928
+ const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
1929
+ const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
1930
+ lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
1931
+ }
1932
+ const events = run.budgetEvents ?? [];
1933
+ if (events.length)
1934
+ lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
1935
+ return lines;
1936
+ }
1937
+ function formatCompactBudgetStatus(run) {
1938
+ if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined))
1939
+ return [];
1940
+ return formatBudgetStatus(run);
1941
+ }
1942
+ const ATTENTION_ORDER = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
1454
1943
  function navigatorAttentionSort(entries) {
1455
1944
  return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
1456
1945
  }
@@ -1460,7 +1949,7 @@ function navigatorRunLabels(entries) {
1460
1949
  nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
1461
1950
  return entries.map(({ store, loaded: { run } }) => {
1462
1951
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
1463
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1952
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1464
1953
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
1465
1954
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
1466
1955
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -1469,7 +1958,7 @@ function navigatorRunLabels(entries) {
1469
1958
  }
1470
1959
  function agentBreadcrumb(agent, byId) {
1471
1960
  const name = agent.label ?? agent.name;
1472
- const parts = [name];
1961
+ const parts = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
1473
1962
  const seen = new Set([agent.id]);
1474
1963
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
1475
1964
  if (seen.has(parentId))
@@ -1477,10 +1966,11 @@ function agentBreadcrumb(agent, byId) {
1477
1966
  seen.add(parentId);
1478
1967
  const parent = byId.get(parentId);
1479
1968
  if (parent)
1480
- parts.unshift(parent.label ?? parent.name);
1969
+ parts.push(parent.label ?? parent.name);
1481
1970
  else
1482
1971
  break;
1483
1972
  }
1973
+ parts.push(name);
1484
1974
  return parts.length > 1 ? parts.join(" > ") : name;
1485
1975
  }
1486
1976
  function formatAgentActivity(agent, spinner) {
@@ -1498,53 +1988,49 @@ function formatAccounting(accounting) {
1498
1988
  return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
1499
1989
  }
1500
1990
  export function formatNavigatorDashboard(run, checkpoints, worktrees) {
1991
+ void worktrees;
1501
1992
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
1502
1993
  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 });
1503
1994
  const hasAccounting = run.agents.some((a) => a.accounting);
1504
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1995
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1505
1996
  const header = `${glyph} ${run.workflowName}`;
1506
1997
  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(" · ");
1507
- const lines = [header, meta];
1998
+ const lines = [header, meta, ...formatCompactBudgetStatus(run)];
1508
1999
  if (run.error)
1509
2000
  lines.push(`Error: ${run.error.code}: ${run.error.message}`);
2001
+ if (run.events?.length)
2002
+ lines.push(...run.events.map((event) => `Warning: ${event.message}`));
1510
2003
  lines.push("");
1511
2004
  const byId = new Map(run.agents.map((a) => [a.id, a]));
1512
- const activeStates = new Set(["running", "waiting_for_child", "queued", "retrying", "paused"]);
1513
- const prioritised = [...run.agents].sort((a, b) => {
1514
- const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
1515
- const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
1516
- return aActive - bActive;
1517
- });
1518
- for (const agent of prioritised) {
2005
+ const render = ({ agent, depth }, grouped) => {
1519
2006
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
1520
- const breadcrumb = agentBreadcrumb(agent, byId);
1521
- const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
1522
- const policy = [`model=${model}`, `tools=${agent.tools.join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
2007
+ const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
2008
+ const setup = agent.attemptDetails?.at(-1)?.setup;
2009
+ const thinking = setup?.model.thinking ?? agent.model.thinking;
2010
+ const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
2011
+ const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1523
2012
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
1524
- const parts = [`${icon} ${breadcrumb}`, agent.state, ...policy, tokens].filter(Boolean);
1525
- lines.push(parts.join(" · "));
2013
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
2014
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
1526
2015
  if (agent.state === "failed" && agent.attemptDetails?.length) {
1527
2016
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
1528
2017
  if (last?.error)
1529
- lines.push(` error: ${last.error.code}: ${last.error.message}`);
2018
+ result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
1530
2019
  }
1531
2020
  const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
1532
2021
  if (activity)
1533
- lines.push(` ${activity}`);
1534
- }
2022
+ result.push(`${indent} ${activity}`);
2023
+ return result.join("\n");
2024
+ };
2025
+ lines.push(...renderGroupedAgents(run.agents, render));
1535
2026
  if (checkpoints.length) {
1536
2027
  lines.push("");
1537
2028
  for (const cp of checkpoints)
1538
2029
  lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`);
1539
2030
  }
1540
- if (worktrees.length) {
1541
- lines.push("");
1542
- for (const wt of worktrees)
1543
- lines.push(`branch ${wt.branch} (${wt.path})`);
1544
- }
1545
2031
  return lines.join("\n");
1546
2032
  }
1547
- export function formatNavigatorRun(loaded, checkpoints, worktrees) {
2033
+ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
1548
2034
  const { run, snapshot } = loaded;
1549
2035
  const lines = [
1550
2036
  `Workflow: ${run.workflowName}`,
@@ -1552,39 +2038,40 @@ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
1552
2038
  `Status: ${run.state}`,
1553
2039
  `Phase: ${run.phase ?? "(none)"}`,
1554
2040
  `Launch cwd: ${run.cwd}`,
2041
+ ...formatCompactBudgetStatus(run),
1555
2042
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
1556
2043
  ];
1557
2044
  if (run.error)
1558
2045
  lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
2046
+ if (run.events?.length)
2047
+ lines.push(...run.events.map((event) => `Warning: ${event.message}`));
2048
+ const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
2049
+ if (aliases && Object.keys(aliases).length)
2050
+ lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
1559
2051
  lines.push("Agents / ownership:");
1560
2052
  if (!run.agents.length)
1561
2053
  lines.push(" (none)");
1562
- for (const agent of run.agents) {
2054
+ const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
2055
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1563
2056
  const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
1564
2057
  const role = agent.role ? ` role=${agent.role}` : "";
1565
2058
  const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
1566
2059
  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)}` : "";
1567
- 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}`);
2060
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
2061
+ 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}`];
1568
2062
  for (const attempt of agent.attemptDetails ?? [])
1569
- lines.push(` attempt ${String(attempt.attempt)} transcript=${attempt.sessionFile}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
2063
+ result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
1570
2064
  for (const call of agent.toolCalls ?? [])
1571
- lines.push(` tool ${call.name} state=${call.state}`);
1572
- }
2065
+ result.push(`${indent} tool ${call.name} state=${call.state}`);
2066
+ return result.join("\n");
2067
+ }));
1573
2068
  lines.push("Checkpoints:");
1574
2069
  if (!checkpoints.length)
1575
2070
  lines.push(" (none)");
1576
2071
  for (const checkpoint of checkpoints)
1577
2072
  lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
1578
- lines.push("Worktrees / branches:");
1579
- if (!worktrees.length)
1580
- lines.push(" (none)");
1581
- for (const worktree of worktrees)
1582
- lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
1583
- lines.push("Native Pi transcript paths:");
1584
- if (!run.nativeSessions.length)
1585
- lines.push(" (none)");
1586
- for (const session of run.nativeSessions)
1587
- lines.push(` ${session.sessionId}: ${session.sessionFile}`);
2073
+ lines.push(`Worktrees: ${String(_worktrees.length)}`);
2074
+ lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
1588
2075
  return lines.join("\n");
1589
2076
  }
1590
2077
  function formatCheckpointReview(checkpoint) {
@@ -1616,6 +2103,98 @@ function deliver(pi, content) {
1616
2103
  function deliverFailure(pi, name, error) {
1617
2104
  deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
1618
2105
  }
2106
+ function safeEventError(error) {
2107
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
2108
+ return { code, message: `Workflow execution failed (${code})` };
2109
+ }
2110
+ class WorkflowEventPublisher {
2111
+ sink;
2112
+ #queues = new Map();
2113
+ #budgetEvents = new Map();
2114
+ #worktrees = new Map();
2115
+ constructor(sink) {
2116
+ this.sink = sink;
2117
+ }
2118
+ seedBudget(runId, events) {
2119
+ const seen = this.#budgetEvents.get(runId) ?? new Set();
2120
+ for (const event of events ?? [])
2121
+ seen.add(this.budgetKey(event));
2122
+ this.#budgetEvents.set(runId, seen);
2123
+ }
2124
+ async runStarted(store, metadata, background) {
2125
+ await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {});
2126
+ if (background)
2127
+ await this.legacyStarted(store, metadata);
2128
+ }
2129
+ async legacyStarted(store, metadata) { 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 }); }
2130
+ async runResumed(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
2131
+ async runState(store, metadata, previousState, state, reason) {
2132
+ await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason) ? { errorCode: reason } : {}) });
2133
+ if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running")
2134
+ await this.runResumed(store, metadata);
2135
+ }
2136
+ async runCompleted(store, metadata, resultPath, background) {
2137
+ await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath });
2138
+ if (background)
2139
+ 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" });
2140
+ }
2141
+ async runFailed(store, metadata, error, background, state) {
2142
+ if (state === "failed")
2143
+ await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
2144
+ if (background) {
2145
+ const legacyState = state === "interrupted" ? "failed" : state;
2146
+ 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 } : {}) });
2147
+ }
2148
+ }
2149
+ async agentState(store, metadata, previous, agent) {
2150
+ 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 });
2151
+ }
2152
+ async agentStates(store, metadata, previous, current) {
2153
+ const previousById = new Map(previous.map((agent) => [agent.id, agent]));
2154
+ for (const agent of current) {
2155
+ const old = previousById.get(agent.id);
2156
+ if (!old || old.state !== agent.state || old.attempts !== agent.attempts)
2157
+ await this.agentState(store, metadata, old, agent);
2158
+ }
2159
+ }
2160
+ async phase(store, metadata, previousPhase, phase) {
2161
+ if (previousPhase !== phase)
2162
+ await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
2163
+ }
2164
+ async checkpoint(store, metadata, name, state) { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
2165
+ async budget(store, metadata, run) {
2166
+ const seen = this.#budgetEvents.get(store.runId) ?? new Set();
2167
+ this.#budgetEvents.set(store.runId, seen);
2168
+ for (const event of run.budgetEvents ?? []) {
2169
+ const key = this.budgetKey(event);
2170
+ if (seen.has(key))
2171
+ continue;
2172
+ seen.add(key);
2173
+ await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
2174
+ }
2175
+ }
2176
+ async worktree(store, metadata, worktree) {
2177
+ const seen = this.#worktrees.get(store.runId) ?? new Set();
2178
+ this.#worktrees.set(store.runId, seen);
2179
+ if (seen.has(worktree.owner))
2180
+ return;
2181
+ seen.add(worktree.owner);
2182
+ await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
2183
+ }
2184
+ async #publish(store, metadata, name, payload) {
2185
+ const base = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
2186
+ const previous = this.#queues.get(store.runId) ?? Promise.resolve();
2187
+ const next = previous.then(() => {
2188
+ try {
2189
+ void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined);
2190
+ }
2191
+ catch { /* Best effort: listeners cannot affect a run. */ }
2192
+ });
2193
+ this.#queues.set(store.runId, next.catch(() => undefined));
2194
+ await next;
2195
+ }
2196
+ budgetKey(event) { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
2197
+ }
1619
2198
  const inheritedHostAgentPath = new AsyncLocalStorage();
1620
2199
  const inheritedHostWorktreeOwner = new AsyncLocalStorage();
1621
2200
  function namedRecord(value, kind) {
@@ -1649,15 +2228,15 @@ function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
1649
2228
  }
1650
2229
  async function resolveWorkflowVariables(run, controller, registry) {
1651
2230
  let first;
1652
- const tasks = registry.variables().map(async ({ namespace, name, variable }) => {
2231
+ const tasks = registry.variables().map(async ({ name, variable }) => {
1653
2232
  try {
1654
2233
  const result = await variable.resolve(run);
1655
2234
  if (!jsonValue(result) || !Value.Check(variable.schema, result))
1656
- fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
2235
+ fail("RESULT_INVALID", `Invalid output from ${name}`);
1657
2236
  return [name, deepFreeze(structuredClone(result))];
1658
2237
  }
1659
2238
  catch (error) {
1660
- const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${namespace}.${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${namespace}.${name}: ${errorText(error)}`);
2239
+ const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
1661
2240
  if (!first) {
1662
2241
  first = typed;
1663
2242
  controller.abort();
@@ -1742,7 +2321,7 @@ function nextNamedOccurrence(counters, label) {
1742
2321
  function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
1743
2322
  const functionAgentOccurrences = new Map();
1744
2323
  const functionWorktreeOccurrences = new Map();
1745
- return { ...bridge, functions: registry.globals(), variables, function: async (namespace, name, input, path, signal, worktreeOwner) => {
2324
+ return { ...bridge, functions: registry.globals(), variables, function: async (name, input, path, signal, worktreeOwner, structuralPath = []) => {
1746
2325
  const replayed = await store.replay(path);
1747
2326
  let stored;
1748
2327
  const sideEffects = [];
@@ -1753,11 +2332,11 @@ function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
1753
2332
  fail("AGENT_FAILED", "No agent bridge is available");
1754
2333
  const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
1755
2334
  const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
1756
- const structuralPath = inheritedHostAgentPath.getStore() ?? [];
1757
- const key = `${path}\0${JSON.stringify(structuralPath)}`;
2335
+ const inherited = inheritedHostAgentPath.getStore() ?? [];
2336
+ const key = `${path}\0${JSON.stringify(inherited)}`;
1758
2337
  const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
1759
2338
  functionAgentOccurrences.set(key, occurrence);
1760
- return bridge.agent(args[0], options, signal, { structuralPath: [...structuralPath], callSite: `function:${path}`, occurrence, parentBreadcrumb: `${namespace}.${name}`, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2339
+ return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: name, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
1761
2340
  },
1762
2341
  prompt: workflowPrompt,
1763
2342
  parallel: (...args) => hostParallel(args[0], args[1]),
@@ -1771,7 +2350,7 @@ function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
1771
2350
  phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
1772
2351
  log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
1773
2352
  };
1774
- const result = await registry.invokeFunction(namespace, name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } });
2353
+ const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
1775
2354
  await Promise.all(sideEffects);
1776
2355
  if (!replayed)
1777
2356
  await store.complete(path, stored ?? result);
@@ -1811,7 +2390,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1811
2390
  await lifecycle.leave();
1812
2391
  }
1813
2392
  };
1814
- const events = pi.events;
2393
+ const eventPublisher = new WorkflowEventPublisher(pi.events);
1815
2394
  pi.on("resources_discover", () => {
1816
2395
  if (!pi.getActiveTools().includes("workflow"))
1817
2396
  return;
@@ -1819,13 +2398,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1819
2398
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1820
2399
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1821
2400
  });
1822
- const emitAsyncStarted = (store, metadata) => {
1823
- events?.emit(WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name });
1824
- };
1825
- const emitAsyncComplete = (store, state, error) => {
1826
- 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 } : {}) });
1827
- };
1828
2401
  const runs = new Map();
2402
+ const conversationLocks = new Set();
2403
+ const terminalRunStates = new Map();
1829
2404
  let sessionLease;
1830
2405
  let sessionLeasePromise;
1831
2406
  const ensureSessionLease = async (cwd, sessionId) => {
@@ -1846,30 +2421,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1846
2421
  sessionLeasePromise = undefined;
1847
2422
  await lease?.release();
1848
2423
  };
1849
- const lifecycleFor = (store, state) => new RunLifecycle(state, async (next) => {
1850
- const run = await store.updateState((current) => {
1851
- const nextRun = { ...current, state: next };
2424
+ const persistRunState = async (store, metadata, update) => {
2425
+ const persisted = await store.updateState(update);
2426
+ await eventPublisher.budget(store, metadata, persisted);
2427
+ return persisted;
2428
+ };
2429
+ const persistWorktree = async (store, metadata, owner) => {
2430
+ const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2431
+ const worktree = await store.worktree(owner);
2432
+ if (!existing)
2433
+ await eventPublisher.worktree(store, metadata, worktree);
2434
+ return worktree;
2435
+ };
2436
+ const lifecycleFor = (store, state, budget, metadata) => new RunLifecycle(state, async (next, previous, reason) => {
2437
+ if (next !== "pausing")
2438
+ budget.transition(next);
2439
+ const persisted = await persistRunState(store, metadata, (current) => {
2440
+ const nextRun = { ...current, state: next, ...budget.snapshot() };
1852
2441
  if (next === "running" || next === "completed")
1853
2442
  delete nextRun.error;
1854
2443
  return nextRun;
1855
2444
  });
1856
- runs.get(store.runId)?.update?.(workflowToolUpdate(run));
2445
+ await eventPublisher.runState(store, metadata, previous, next, reason);
2446
+ runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
1857
2447
  });
1858
2448
  const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
1859
2449
  const run = runs.get(runId);
1860
2450
  if (!run)
1861
2451
  throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
1862
2452
  try {
2453
+ const budget = run.budget.forAgent(id);
1863
2454
  const onProgress = async (progress) => {
1864
2455
  let runState;
1865
2456
  if (progress.persist) {
1866
- 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);
2457
+ 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);
1867
2458
  }
1868
2459
  else {
1869
2460
  const loaded = await run.store.load();
1870
2461
  if (!loaded.run.agents.some((agent) => agent.id === id))
1871
2462
  return;
1872
- runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
2463
+ 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) };
1873
2464
  }
1874
2465
  if (!runState.agents.some((agent) => agent.id === id))
1875
2466
  return;
@@ -1877,17 +2468,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1877
2468
  };
1878
2469
  const onAttempt = async (attempt) => {
1879
2470
  await scheduler.flush();
2471
+ scheduler.attemptStarted(id);
2472
+ await scheduler.flush();
2473
+ const before = (await run.store.load()).run;
1880
2474
  await persistActiveAgentAttempt(run.store, id, attempt);
1881
- run.update?.(workflowToolUpdate((await run.store.load()).run));
2475
+ const active = (await run.store.load()).run;
2476
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2477
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2478
+ run.update?.(workflowToolUpdate(persisted));
1882
2479
  };
1883
- 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); });
2480
+ 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); });
2481
+ const before = (await run.store.load()).run;
1884
2482
  await persistAgentAttempts(run.store, id, result.attempts);
2483
+ const completed = (await run.store.load()).run;
2484
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2485
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2486
+ run.update?.(workflowToolUpdate(persisted));
1885
2487
  return result.value;
1886
2488
  }
1887
2489
  catch (error) {
1888
2490
  const attempts = error.attempts;
1889
- if (attempts?.length)
2491
+ if (attempts?.length) {
2492
+ const before = (await run.store.load()).run;
1890
2493
  await persistAgentAttempts(run.store, id, attempts);
2494
+ const failed = (await run.store.load()).run;
2495
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2496
+ }
2497
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2498
+ run.update?.(workflowToolUpdate(persisted));
1891
2499
  throw error;
1892
2500
  }
1893
2501
  }, 16, async (runId, ownership) => {
@@ -1895,7 +2503,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1895
2503
  if (!run)
1896
2504
  return;
1897
2505
  await run.store.saveOwnership(ownership);
1898
- const runState = await run.store.updateState((current) => {
2506
+ let previousAgents = [];
2507
+ const runState = await persistRunState(run.store, run.metadata, (current) => {
2508
+ previousAgents = current.agents;
1899
2509
  const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
1900
2510
  const agents = ownership.map((node) => {
1901
2511
  const previous = existing.get(node.id);
@@ -1905,14 +2515,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1905
2515
  effective = run.executor.resolve(requested);
1906
2516
  }
1907
2517
  catch {
1908
- 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 };
2518
+ 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 };
1909
2519
  }
1910
- 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 } : {}) };
2520
+ 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 } : {}) };
1911
2521
  });
1912
2522
  return { ...current, agents };
1913
2523
  });
2524
+ await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
1914
2525
  run.update?.(workflowToolUpdate(runState));
1915
2526
  });
2527
+ const stopWorkflowRun = async (runId) => {
2528
+ const run = runs.get(runId);
2529
+ const terminalState = terminalRunStates.get(runId);
2530
+ if (!run)
2531
+ return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
2532
+ const state = run.lifecycle.state;
2533
+ if (state === "completed" || state === "failed" || state === "stopped")
2534
+ return { runId, state, stopped: false, reason: "already_terminal" };
2535
+ await run.lifecycle.terminal("stopped");
2536
+ run.abortController.abort();
2537
+ run.execution?.cancel();
2538
+ await scheduler.cancelRun(run.store.runId);
2539
+ await scheduler.flush();
2540
+ return { runId, state: "stopped", stopped: true };
2541
+ };
1916
2542
  const answerCheckpoint = async (runId, name, approved, silent = false) => {
1917
2543
  const run = runs.get(runId);
1918
2544
  if (!run)
@@ -1920,6 +2546,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1920
2546
  const checkpoint = await run.store.answerCheckpoint(name, approved);
1921
2547
  if (!checkpoint)
1922
2548
  return false;
2549
+ await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
1923
2550
  if ((await run.store.awaitingCheckpoints()).length === 0)
1924
2551
  await run.lifecycle.resolveAwaitingInput();
1925
2552
  run.checkpointResolvers.get(checkpoint.path)?.(approved);
@@ -1928,6 +2555,26 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1928
2555
  deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
1929
2556
  return true;
1930
2557
  };
2558
+ const budgetDecisionDelivery = (metadata, request) => `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}.`;
2559
+ const appendBudgetDecisionEvent = async (run, request, type) => {
2560
+ 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) });
2561
+ await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2562
+ };
2563
+ const answerBudgetDecision = async (runId, proposalId, approved, silent = false) => {
2564
+ const run = runs.get(runId);
2565
+ if (!run)
2566
+ return undefined;
2567
+ const request = await run.store.answerWorkflowDecision(proposalId, approved);
2568
+ if (!request)
2569
+ return undefined;
2570
+ await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2571
+ const result = await applyBudgetDecision(request, approved);
2572
+ run.budgetResolvers.get(proposalId)?.(result);
2573
+ run.budgetResolvers.delete(proposalId);
2574
+ if (!silent)
2575
+ deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2576
+ return result;
2577
+ };
1931
2578
  const checkpointBridge = (runId, store, metadata, foreground, ui) => {
1932
2579
  const checkpointCounters = new Map();
1933
2580
  return async (raw, signal) => {
@@ -1940,6 +2587,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1940
2587
  const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
1941
2588
  if (replayed !== undefined)
1942
2589
  return replayed;
2590
+ if (!alreadyAwaiting)
2591
+ await eventPublisher.checkpoint(store, metadata, label, "awaiting");
1943
2592
  const run = runs.get(runId);
1944
2593
  await run?.lifecycle.enterAwaitingInput();
1945
2594
  if (!alreadyAwaiting && !ui?.select)
@@ -1965,7 +2614,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1965
2614
  if (!choice) {
1966
2615
  if (foreground)
1967
2616
  continue; // foreground: retry until answered
1968
- // Background resume: user dismissed UI, fall back to LLM
1969
2617
  deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
1970
2618
  return;
1971
2619
  }
@@ -1979,12 +2627,37 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1979
2627
  pi.registerTool({
1980
2628
  name: "workflow_respond",
1981
2629
  label: "Workflow Respond",
1982
- description: "Approve or reject one pending workflow checkpoint",
1983
- parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
2630
+ description: "Approve or reject one pending workflow checkpoint or budget decision",
2631
+ parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
1984
2632
  async execute(_id, params) {
1985
2633
  try {
2634
+ if (params.proposalId) {
2635
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
2636
+ if (!result) {
2637
+ const denied = { state: "budget_exhausted", approved: false, reason: "proposal_not_pending" };
2638
+ return { content: [{ type: "text", text: JSON.stringify(denied) }], details: denied };
2639
+ }
2640
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
2641
+ }
2642
+ if (!params.name)
2643
+ throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
1986
2644
  const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
1987
- return { content: [{ type: "text", text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
2645
+ return { content: [{ type: "text", text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } };
2646
+ }
2647
+ catch (error) {
2648
+ throw mainAgentError(error);
2649
+ }
2650
+ },
2651
+ });
2652
+ pi.registerTool({
2653
+ name: "workflow_stop",
2654
+ label: "Workflow Stop",
2655
+ description: "Stop an active workflow run by ID",
2656
+ parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
2657
+ async execute(_id, params) {
2658
+ try {
2659
+ const result = await stopWorkflowRun(params.runId);
2660
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
1988
2661
  }
1989
2662
  catch (error) {
1990
2663
  throw mainAgentError(error);
@@ -1997,7 +2670,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1997
2670
  if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
1998
2671
  return;
1999
2672
  const catalog = registry.catalog();
2000
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length)
2673
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2674
+ if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases)
2001
2675
  return;
2002
2676
  pi.registerTool({
2003
2677
  name: "workflow_catalog",
@@ -2008,7 +2682,33 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2008
2682
  });
2009
2683
  catalogRegistered = true;
2010
2684
  };
2011
- const coldResumeRun = async (run, hasUI, ui, trustedProject) => {
2685
+ const refreshPausedRunAliases = async (run, context) => {
2686
+ const loaded = await run.store.load();
2687
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2688
+ const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2689
+ if (missing)
2690
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2691
+ const settingsPath = workflowSettingsPath();
2692
+ const currentSettings = loadSettings(settingsPath);
2693
+ resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2694
+ const currentAliases = currentSettings.modelAliases ?? {};
2695
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2696
+ const modelRegistry = context?.modelRegistry;
2697
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2698
+ if (context?.model)
2699
+ knownModels.add(`${context.model.provider}/${context.model.id}`);
2700
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2701
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2702
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2703
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2704
+ await run.store.saveSnapshot(snapshot);
2705
+ 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);
2706
+ run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2707
+ const drift = aliasDrift(previousAliases, currentAliases);
2708
+ if (drift.length)
2709
+ await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2710
+ };
2711
+ const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
2012
2712
  const loaded = await run.store.load();
2013
2713
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
2014
2714
  throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
@@ -2019,14 +2719,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2019
2719
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
2020
2720
  if (missingRole)
2021
2721
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
2022
- const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
2722
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2023
2723
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2024
2724
  if (missing)
2025
2725
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2026
- preflight(loaded.snapshot.script, { models: new Set(loaded.snapshot.models), tools: active, agentTypes: new Set(loaded.snapshot.agentTypes) }, loaded.snapshot.schemas, loaded.snapshot.metadata);
2726
+ const settingsPath = workflowSettingsPath();
2727
+ const currentSettings = loadSettings(settingsPath);
2728
+ resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2729
+ const currentAliases = currentSettings.modelAliases ?? {};
2730
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2731
+ const modelRegistry = context?.modelRegistry;
2732
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2733
+ if (context?.model)
2734
+ knownModels.add(`${context.model.provider}/${context.model.id}`);
2735
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2736
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2737
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2738
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2739
+ 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);
2740
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2741
+ await run.store.saveSnapshot(snapshot);
2742
+ 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);
2743
+ const drift = aliasDrift(previousAliases, currentAliases);
2744
+ if (drift.length)
2745
+ await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2027
2746
  const controller = new AbortController();
2028
2747
  run.abortController = controller;
2029
2748
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
2749
+ run.executor.setRunContext(runContext);
2030
2750
  let variables;
2031
2751
  try {
2032
2752
  variables = await resolveWorkflowVariables(runContext, controller, registry);
@@ -2034,8 +2754,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2034
2754
  catch (error) {
2035
2755
  const typed = asWorkflowError(error);
2036
2756
  if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
2037
- await run.lifecycle.terminal("failed").catch(() => undefined);
2038
- await run.store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
2757
+ await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
2758
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
2759
+ await eventPublisher.runFailed(run.store, run.metadata, typed, true, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
2760
+ run.update?.(workflowToolUpdate(persisted));
2039
2761
  }
2040
2762
  throw typed;
2041
2763
  }
@@ -2043,13 +2765,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2043
2765
  await run.lifecycle.resume();
2044
2766
  const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
2045
2767
  await run.lifecycle.enter();
2768
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2769
+ const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
2046
2770
  try {
2047
- const path = agentIdentityPath(identity);
2771
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
2048
2772
  const replayed = await run.store.replay(path);
2049
- if (replayed)
2773
+ if (replayed) {
2774
+ if (conversationId) {
2775
+ const conversation = await run.store.conversation(conversationId);
2776
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
2777
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
2778
+ }
2050
2779
  return replayed.value;
2780
+ }
2781
+ if (conversationId) {
2782
+ if (conversationLocks.has(conversationLock))
2783
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
2784
+ const conversation = await run.store.conversation(conversationId);
2785
+ if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
2786
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
2787
+ conversationLocks.add(conversationLock);
2788
+ }
2051
2789
  const worktree = agentWorktree(identity);
2052
- const cwd = worktree.worktreeOwner ? (await run.store.worktree(worktree.worktreeOwner)).cwd : run.store.cwd;
2790
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
2053
2791
  const role = typeof options.role === "string" ? options.role : undefined;
2054
2792
  const model = typeof options.model === "string" ? options.model : undefined;
2055
2793
  const thinking = parseThinking(options.thinking);
@@ -2058,7 +2796,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2058
2796
  const label = displayAgentName(requestedLabel, role, resolved.model);
2059
2797
  const tools = resolved.tools;
2060
2798
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2061
- 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 } : {}) });
2799
+ 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 });
2062
2800
  const cancel = () => { scheduler.cancel(spawned.id); };
2063
2801
  signal.addEventListener("abort", cancel, { once: true });
2064
2802
  const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
@@ -2068,22 +2806,116 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2068
2806
  return outcome.value;
2069
2807
  }
2070
2808
  finally {
2809
+ if (conversationLock)
2810
+ conversationLocks.delete(conversationLock);
2071
2811
  await run.lifecycle.leave();
2072
2812
  }
2073
2813
  }, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
2074
- await run.store.updateState((current) => ({ ...current, phase }));
2814
+ let previousPhase;
2815
+ const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
2816
+ await eventPublisher.phase(run.store, run.metadata, previousPhase, phase);
2817
+ runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted));
2075
2818
  }
2076
2819
  finally {
2077
2820
  await run.lifecycle.leave();
2078
2821
  } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2079
2822
  run.execution = execution;
2080
- emitAsyncStarted(run.store, run.metadata);
2081
- 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) => { await scheduler.flush(); if (run.lifecycle.state !== "stopped" && run.lifecycle.state !== "interrupted")
2082
- 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")
2083
- deliverFailure(pi, run.metadata.name, error); });
2084
- run.completion = completion;
2823
+ const completion = execution.result.then(async (value) => {
2824
+ await scheduler.flush();
2825
+ if (run.budget.hardExhausted)
2826
+ throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2827
+ const resultPath = await run.store.saveResult(value);
2828
+ await run.lifecycle.terminal("completed", "completed");
2829
+ await eventPublisher.runCompleted(run.store, run.metadata, resultPath, true);
2830
+ return { value, resultPath };
2831
+ }).catch(async (error) => {
2832
+ await scheduler.flush();
2833
+ const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
2834
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
2835
+ await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2836
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2837
+ const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2838
+ await eventPublisher.runFailed(run.store, run.metadata, typed, true, state);
2839
+ run.update?.(workflowToolUpdate(persisted));
2840
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
2841
+ deliverFailure(pi, run.metadata.name, error);
2842
+ });
2085
2843
  void completion;
2086
2844
  };
2845
+ const applyBudgetDecision = async (request, approved) => {
2846
+ const run = runs.get(request.runId);
2847
+ if (!run)
2848
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
2849
+ if (!approved)
2850
+ return { state: "budget_exhausted", approved: false };
2851
+ const nextBudget = validateBudget(request.proposed);
2852
+ const nextVersion = request.budgetVersion + 1;
2853
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
2854
+ run.budget = runtime;
2855
+ await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
2856
+ next.budget = nextBudget;
2857
+ else
2858
+ delete next.budget; return next; });
2859
+ await coldResumeRun(run, false, {}, true);
2860
+ return { state: "running", approved: true };
2861
+ };
2862
+ const resumeWorkflowRun = async (runId, rawPatch) => {
2863
+ const run = runs.get(runId);
2864
+ if (!run)
2865
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2866
+ const loaded = await run.store.load();
2867
+ if (loaded.run.state !== "budget_exhausted")
2868
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2869
+ const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2870
+ const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
2871
+ const nextBudget = mergeBudget(currentBudget, patch);
2872
+ const usage = budgetUsage(loaded.run.usage);
2873
+ if (!resumeBudgetAllowed(nextBudget, usage))
2874
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
2875
+ if (budgetRelaxed(currentBudget, nextBudget)) {
2876
+ const proposalId = randomUUID();
2877
+ const request = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2878
+ const decision = new Promise((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2879
+ try {
2880
+ await run.store.requestWorkflowDecision(request);
2881
+ await appendBudgetDecisionEvent(run, request, "adjustment_requested");
2882
+ }
2883
+ catch (error) {
2884
+ run.budgetResolvers.delete(proposalId);
2885
+ throw error;
2886
+ }
2887
+ deliver(pi, budgetDecisionDelivery(run.metadata, request));
2888
+ const decisionResult = await decision;
2889
+ return { state: decisionResult.state };
2890
+ }
2891
+ const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2892
+ if (changed) {
2893
+ const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
2894
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
2895
+ run.budget = runtime;
2896
+ await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
2897
+ next.budget = nextBudget;
2898
+ else
2899
+ delete next.budget; return next; });
2900
+ }
2901
+ await coldResumeRun(run, false, {}, true);
2902
+ return { state: "running" };
2903
+ };
2904
+ pi.registerTool({
2905
+ name: "workflow_resume",
2906
+ label: "Workflow Resume",
2907
+ description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
2908
+ parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
2909
+ async execute(_id, params) {
2910
+ try {
2911
+ const result = await resumeWorkflowRun(params.runId, params.budget);
2912
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2913
+ }
2914
+ catch (error) {
2915
+ throw mainAgentError(error);
2916
+ }
2917
+ },
2918
+ });
2087
2919
  pi.on("session_start", async (_event, ctx) => {
2088
2920
  if (sessionStarted)
2089
2921
  return;
@@ -2105,20 +2937,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2105
2937
  await store.delete(true).catch(() => undefined);
2106
2938
  continue;
2107
2939
  }
2108
- if (["completed", "failed", "stopped"].includes(loaded.run.state))
2940
+ if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") {
2941
+ terminalRunStates.set(runId, loaded.run.state);
2109
2942
  continue;
2110
- if (loaded.run.state !== "interrupted") {
2111
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2943
+ }
2944
+ if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
2945
+ const previousState = loaded.run.state;
2946
+ await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2947
+ loaded = { ...loaded, run: (await store.load()).run };
2948
+ await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
2112
2949
  loaded = { ...loaded, run: (await store.load()).run };
2113
2950
  }
2114
2951
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
2115
- const lifecycle = lifecycleFor(store, loaded.run.state);
2952
+ const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2953
+ eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
2954
+ const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
2955
+ const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
2116
2956
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2117
2957
  const roleDefinitions = loaded.snapshot.roles ?? {};
2118
- 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() });
2958
+ 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() });
2119
2959
  for (const checkpoint of await store.awaitingCheckpoints())
2120
2960
  deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
2121
- scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.settings.maxAgentLaunches, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : []);
2961
+ for (const decision of await store.pendingWorkflowDecisions())
2962
+ deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
2963
+ scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
2122
2964
  }
2123
2965
  const resumeSelect = ctx.ui?.select;
2124
2966
  if (ctx.hasUI && resumeSelect) {
@@ -2131,7 +2973,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2131
2973
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
2132
2974
  for (const run of toResume) {
2133
2975
  try {
2134
- await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx));
2976
+ await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx);
2135
2977
  ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
2136
2978
  }
2137
2979
  catch (err) {
@@ -2164,18 +3006,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2164
3006
  parameters: WORKFLOW_TOOL_PARAMETERS,
2165
3007
  async execute(_id, params, signal, onUpdate, ctx) {
2166
3008
  try {
3009
+ const settingsPath = workflowSettingsPath();
3010
+ const defaults = loadSettings(settingsPath);
2167
3011
  if (!ctx.model)
2168
3012
  throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
2169
- const defaults = loadSettings();
2170
- const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, maxAgentLaunches: params.maxAgentLaunches ?? defaults.maxAgentLaunches });
3013
+ const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
3014
+ const budget = validateBudget(params.budget);
2171
3015
  const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
2172
3016
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
2173
3017
  const modelRegistry = ctx.modelRegistry;
2174
- const availableModels = new Set((modelRegistry?.getAvailable() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
2175
- availableModels.add(rootModelName);
2176
- const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog");
3018
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
3019
+ knownModels.add(rootModelName);
3020
+ const availableModels = knownModels;
3021
+ const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
2177
3022
  const trustedProject = projectTrusted(ctx);
2178
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools) }, registry);
3023
+ if (typeof ctx.cwd === "string")
3024
+ resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
3025
+ const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
2179
3026
  const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
2180
3027
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
2181
3028
  const runId = randomUUID();
@@ -2191,28 +3038,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2191
3038
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2192
3039
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
2193
3040
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2194
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
3041
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2195
3042
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2196
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2197
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [] }, snapshot);
2198
- const lifecycle = lifecycleFor(store, "running");
3043
+ 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 });
3044
+ const budgetRuntime = new WorkflowBudgetRuntime(budget);
3045
+ const initialBudget = budgetRuntime.snapshot();
3046
+ 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);
3047
+ const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2199
3048
  const background = !params.foreground;
2200
3049
  const providerPause = async () => { if (background)
2201
3050
  deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2202
- const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
2203
- runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
3051
+ 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);
3052
+ 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 } : {}) });
2204
3053
  if (params.foreground && onUpdate)
2205
3054
  onUpdate(workflowToolUpdate((await store.load()).run));
2206
- scheduler.addRun(runId, settings.concurrency, settings.maxAgentLaunches);
3055
+ scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2207
3056
  const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
2208
3057
  await lifecycle.enter();
3058
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
3059
+ const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
2209
3060
  try {
2210
- const path = agentIdentityPath(identity);
3061
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
2211
3062
  const replayed = await store.replay(path);
2212
- if (replayed)
3063
+ if (replayed) {
3064
+ if (conversationId) {
3065
+ const conversation = await store.conversation(conversationId);
3066
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
3067
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
3068
+ }
2213
3069
  return replayed.value;
3070
+ }
3071
+ if (conversationId) {
3072
+ if (conversationLocks.has(conversationLock))
3073
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
3074
+ const conversation = await store.conversation(conversationId);
3075
+ if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
3076
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
3077
+ conversationLocks.add(conversationLock);
3078
+ }
2214
3079
  const worktree = agentWorktree(identity);
2215
- const cwd = worktree.worktreeOwner ? (await store.worktree(worktree.worktreeOwner)).cwd : ctx.cwd;
3080
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
2216
3081
  const role = typeof options.role === "string" ? options.role : undefined;
2217
3082
  const model = typeof options.model === "string" ? options.model : undefined;
2218
3083
  const thinking = parseThinking(options.thinking);
@@ -2221,7 +3086,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2221
3086
  const label = displayAgentName(requestedLabel, role, resolved.model);
2222
3087
  const tools = resolved.tools;
2223
3088
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2224
- 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 } : {}) });
3089
+ 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 });
2225
3090
  const cancel = () => { scheduler.cancel(spawned.id); };
2226
3091
  if (agentSignal.aborted)
2227
3092
  cancel();
@@ -2234,29 +3099,47 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2234
3099
  return outcome.value;
2235
3100
  }
2236
3101
  finally {
3102
+ if (conversationLock)
3103
+ conversationLocks.delete(conversationLock);
2237
3104
  await lifecycle.leave();
2238
3105
  }
2239
3106
  }, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
2240
3107
  await lifecycle.enter();
2241
3108
  try {
2242
- const run = await store.updateState((current) => ({ ...current, phase }));
2243
- runs.get(runId)?.update?.(workflowToolUpdate(run));
3109
+ let previousPhase;
3110
+ const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
3111
+ await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
3112
+ runs.get(runId)?.update?.(workflowToolUpdate(persisted));
2244
3113
  }
2245
3114
  finally {
2246
3115
  await lifecycle.leave();
2247
3116
  }
2248
3117
  }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2249
3118
  runs.get(runId).execution = execution;
2250
- if (background)
2251
- emitAsyncStarted(store, checked.metadata);
2252
- 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) => { await scheduler.flush(); const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error)); if (lifecycle.state !== "stopped" && lifecycle.state !== "interrupted")
2253
- await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : "failed"); await store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); throw typed; });
3119
+ await eventPublisher.runStarted(store, checked.metadata, background);
3120
+ const finish = execution.result.then(async (value) => {
3121
+ await scheduler.flush();
3122
+ if (budgetRuntime.hardExhausted)
3123
+ throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
3124
+ const resultPath = await store.saveResult(value);
3125
+ await lifecycle.terminal("completed", "completed");
3126
+ await eventPublisher.runCompleted(store, checked.metadata, resultPath, background);
3127
+ return { value, resultPath };
3128
+ }).catch(async (error) => {
3129
+ await scheduler.flush();
3130
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
3131
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
3132
+ await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
3133
+ await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3134
+ const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
3135
+ await eventPublisher.runFailed(store, checked.metadata, typed, background, state);
3136
+ throw typed;
3137
+ });
2254
3138
  runs.get(runId).completion = finish;
2255
3139
  if (background) {
2256
3140
  void finish.then(async ({ value, resultPath }) => {
2257
- emitAsyncComplete(store, "complete");
2258
3141
  deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2259
- }, (error) => { emitAsyncComplete(store, lifecycle.state === "stopped" ? "stopped" : "failed", error instanceof Error ? error : new Error(errorText(error))); deliverFailure(pi, checked.metadata.name, error); });
3142
+ }, (error) => { deliverFailure(pi, checked.metadata.name, error); });
2260
3143
  return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2261
3144
  }
2262
3145
  const { value } = await finish;
@@ -2313,7 +3196,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2313
3196
  return entries.filter((entry) => entry !== undefined);
2314
3197
  };
2315
3198
  let stores = await loadStores();
2316
- const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
3199
+ 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.";
2317
3200
  const setWorkflowStatus = (text) => {
2318
3201
  const setStatus = ctx.ui.setStatus;
2319
3202
  setStatus?.call(ctx.ui, "workflow-stop", text);
@@ -2329,6 +3212,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2329
3212
  ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
2330
3213
  return keepContext ? "dashboard" : "done";
2331
3214
  }
3215
+ if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
3216
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
3217
+ ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
3218
+ return keepContext ? "dashboard" : "done";
3219
+ }
2332
3220
  if (action === "delete" && stored) {
2333
3221
  if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
2334
3222
  ctx.ui.notify("Stop the workflow before deleting it.", "warning");
@@ -2338,6 +3226,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2338
3226
  return keepContext ? "dashboard" : "done";
2339
3227
  await stored.store.delete(true);
2340
3228
  runs.delete(stored.store.runId);
3229
+ terminalRunStates.delete(stored.store.runId);
2341
3230
  ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info");
2342
3231
  return keepContext ? "picker" : "done";
2343
3232
  }
@@ -2347,11 +3236,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2347
3236
  return keepContext ? "dashboard" : "done";
2348
3237
  }
2349
3238
  if (action === "resume" && run) {
2350
- if (run.lifecycle.state === "interrupted")
2351
- await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx));
2352
- else
2353
- await run.lifecycle.resume();
2354
- ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
3239
+ if (run.lifecycle.state === "budget_exhausted") {
3240
+ const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
3241
+ const result = await resumeWorkflowRun(run.store.runId, patch);
3242
+ 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");
3243
+ }
3244
+ else {
3245
+ if (run.lifecycle.state === "interrupted")
3246
+ await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
3247
+ else {
3248
+ if (run.lifecycle.state === "paused")
3249
+ await refreshPausedRunAliases(run, ctx);
3250
+ await run.lifecycle.resume();
3251
+ }
3252
+ ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
3253
+ }
3254
+ return keepContext ? "dashboard" : "done";
3255
+ }
3256
+ if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
3257
+ const input = await ctx.ui.input?.("Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
3258
+ if (input === undefined)
3259
+ return keepContext ? "dashboard" : "done";
3260
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
3261
+ 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");
2355
3262
  return keepContext ? "dashboard" : "done";
2356
3263
  }
2357
3264
  if (action === "stop" && run) {
@@ -2360,11 +3267,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2360
3267
  return "dashboard";
2361
3268
  if (keepContext)
2362
3269
  status(`Stopping workflow ${workflowName}...`);
2363
- await run.lifecycle.terminal("stopped");
2364
- run.abortController.abort();
2365
- run.execution?.cancel();
2366
- await scheduler.cancelRun(run.store.runId);
2367
- await scheduler.flush();
3270
+ await stopWorkflowRun(run.store.runId);
2368
3271
  if (keepContext)
2369
3272
  status(`Workflow ${run.store.runId} stopped.`);
2370
3273
  ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info");
@@ -2387,13 +3290,117 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2387
3290
  return "dashboard";
2388
3291
  }
2389
3292
  };
2390
- if (!command) {
3293
+ const manageAliases = async () => {
3294
+ const settingsPath = workflowSettingsPath();
3295
+ const modelRegistry = ctx.modelRegistry;
3296
+ const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
3297
+ const selectTarget = async () => {
3298
+ const models = available();
3299
+ const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
3300
+ if (!choice || choice === "Back")
3301
+ return undefined;
3302
+ if (choice !== "Manual model ID")
3303
+ return choice;
3304
+ return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
3305
+ };
3306
+ const save = (aliases) => {
3307
+ try {
3308
+ saveModelAliases(settingsPath, aliases);
3309
+ ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info");
3310
+ return true;
3311
+ }
3312
+ catch (error) {
3313
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3314
+ return false;
3315
+ }
3316
+ };
2391
3317
  for (;;) {
2392
- if (!stores.length) {
2393
- ctx.ui.notify("No workflow runs in this session.", "info");
3318
+ let aliases;
3319
+ try {
3320
+ aliases = loadSettings(settingsPath).modelAliases ?? {};
3321
+ }
3322
+ catch (error) {
3323
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2394
3324
  return;
2395
3325
  }
3326
+ const names = Object.keys(aliases).sort();
3327
+ const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
3328
+ const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
3329
+ const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
3330
+ if (!choice || choice === "Back")
3331
+ return;
3332
+ if (choice === "Add alias") {
3333
+ const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
3334
+ if (!name)
3335
+ continue;
3336
+ if (Object.prototype.hasOwnProperty.call(aliases, name)) {
3337
+ ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning");
3338
+ continue;
3339
+ }
3340
+ const target = await selectTarget();
3341
+ if (!target)
3342
+ continue;
3343
+ const next = { ...aliases, [name]: target };
3344
+ try {
3345
+ validateModelAliases(next, settingsPath);
3346
+ }
3347
+ catch (error) {
3348
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3349
+ continue;
3350
+ }
3351
+ const parsed = parseModelReference(target);
3352
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3353
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3354
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
3355
+ continue;
3356
+ }
3357
+ save(next);
3358
+ continue;
3359
+ }
3360
+ const edit = /^Edit (.+)$/.exec(choice);
3361
+ if (edit?.[1]) {
3362
+ const target = await selectTarget();
3363
+ if (!target)
3364
+ continue;
3365
+ const next = { ...aliases, [edit[1]]: target };
3366
+ try {
3367
+ validateModelAliases(next, settingsPath);
3368
+ }
3369
+ catch (error) {
3370
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3371
+ continue;
3372
+ }
3373
+ const parsed = parseModelReference(target);
3374
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3375
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3376
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
3377
+ continue;
3378
+ }
3379
+ save(next);
3380
+ continue;
3381
+ }
3382
+ const deletion = /^Delete (.+)$/.exec(choice);
3383
+ if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
3384
+ const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
3385
+ save(next);
3386
+ }
3387
+ }
3388
+ };
3389
+ if (command === "model-aliases") {
3390
+ if (!ctx.hasUI) {
3391
+ ctx.ui.notify("Model alias management requires UI.", "warning");
3392
+ return;
3393
+ }
3394
+ await manageAliases();
3395
+ return;
3396
+ }
3397
+ if (!command) {
3398
+ for (;;) {
2396
3399
  if (!ctx.hasUI) {
3400
+ if (!stores.length) {
3401
+ ctx.ui.notify("No workflow runs in this session.", "info");
3402
+ return;
3403
+ }
2397
3404
  const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
2398
3405
  ctx.ui.notify(details.join("\n\n"), "info");
2399
3406
  return;
@@ -2402,10 +3409,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2402
3409
  const labels = navigatorRunLabels(sorted);
2403
3410
  const terminalStates = new Set(["completed", "failed", "stopped"]);
2404
3411
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2405
- const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : []), "Close"];
3412
+ const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
2406
3413
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
2407
3414
  if (!runChoice || runChoice === "Close")
2408
3415
  return;
3416
+ if (runChoice === "Model aliases") {
3417
+ await manageAliases();
3418
+ stores = await loadStores();
3419
+ continue;
3420
+ }
2409
3421
  if (runChoice === "Delete all completed") {
2410
3422
  if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone."))
2411
3423
  continue;
@@ -2413,6 +3425,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2413
3425
  if (entry.loaded.run.state === "completed") {
2414
3426
  await entry.store.delete(true);
2415
3427
  runs.delete(entry.store.runId);
3428
+ terminalRunStates.delete(entry.store.runId);
2416
3429
  }
2417
3430
  }
2418
3431
  ctx.ui.notify("Deleted all completed workflow runs.", "info");
@@ -2435,6 +3448,49 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2435
3448
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
2436
3449
  }
2437
3450
  };
3451
+ const openTranscript = async (transcript) => {
3452
+ try {
3453
+ const entries = SessionManager.open(transcript).buildContextEntries();
3454
+ if (ctx.mode !== "tui") {
3455
+ ctx.ui.notify(`Transcript: ${transcript}`, "info");
3456
+ return;
3457
+ }
3458
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
3459
+ let offset = 0;
3460
+ let renderedLines = [];
3461
+ const viewport = () => Math.max(1, ((tui.terminal?.rows) ?? 24) - 3);
3462
+ const move = (delta) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3463
+ return {
3464
+ render(width) {
3465
+ renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3466
+ offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3467
+ return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3468
+ },
3469
+ invalidate() { },
3470
+ handleInput(data) {
3471
+ if (keybindings.matches(data, "tui.select.up"))
3472
+ move(-1);
3473
+ else if (keybindings.matches(data, "tui.select.down"))
3474
+ move(1);
3475
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3476
+ move(-viewport());
3477
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3478
+ move(viewport());
3479
+ else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
3480
+ offset = 0;
3481
+ else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
3482
+ offset = Math.max(0, renderedLines.length - viewport());
3483
+ else if (keybindings.matches(data, "tui.select.cancel"))
3484
+ done(undefined);
3485
+ tui.requestRender();
3486
+ },
3487
+ };
3488
+ }, { overlay: true });
3489
+ }
3490
+ catch (error) {
3491
+ ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3492
+ }
3493
+ };
2438
3494
  const loadDashboard = async () => {
2439
3495
  const loaded = await store.load();
2440
3496
  const checkpoints = await store.awaitingCheckpoints();
@@ -2448,6 +3504,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2448
3504
  add("Pause", "pause");
2449
3505
  if (["paused", "interrupted"].includes(loaded.run.state))
2450
3506
  add("Resume", "resume");
3507
+ if (loaded.run.state === "budget_exhausted") {
3508
+ actions.set("Resume unchanged", `resume ${store.runId}`);
3509
+ actions.set("Adjust budget", `adjust ${store.runId}`);
3510
+ }
3511
+ for (const decision of await store.pendingWorkflowDecisions()) {
3512
+ const id = decision.proposalId.slice(0, 8);
3513
+ actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
3514
+ actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
3515
+ }
2451
3516
  if (!terminalStates.has(loaded.run.state))
2452
3517
  add("Stop", "stop");
2453
3518
  for (const cp of checkpoints) {
@@ -2466,21 +3531,78 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2466
3531
  else
2467
3532
  actions.set("View script", "view-script");
2468
3533
  const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
2469
- if (ctx.mode === "tui" && transcripts.length)
3534
+ if (loaded.run.agents.length)
3535
+ actions.set("Agents...", "agents");
3536
+ if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length)
2470
3537
  actions.set("View transcript", "view-transcript");
2471
- if (transcripts.length)
3538
+ if (!loaded.run.agents.length && transcripts.length)
2472
3539
  actions.set("Transcript paths", "transcripts");
2473
3540
  if (terminalStates.has(loaded.run.state))
2474
3541
  add("Delete", "delete");
2475
3542
  if (ctx.mode === "tui") {
2476
3543
  addCopy("Copy run path", store.directory, "run path");
2477
3544
  addCopy("Copy run ID", store.runId, "run ID");
2478
- for (const worktree of worktrees) {
2479
- addCopy(`Copy branch (${worktree.owner})`, worktree.branch, "branch");
2480
- addCopy(`Copy worktree path (${worktree.owner})`, worktree.path, "worktree path");
3545
+ }
3546
+ return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
3547
+ };
3548
+ const selectAgent = async (dashboard) => {
3549
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
3550
+ const title = (agent) => {
3551
+ const parents = [];
3552
+ for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
3553
+ const parent = byId.get(parentId);
3554
+ if (!parent)
3555
+ break;
3556
+ parents.unshift(parent.label ?? parent.name);
3557
+ }
3558
+ return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
3559
+ };
3560
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
3561
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3562
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3563
+ const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3564
+ if (!selected)
3565
+ return;
3566
+ const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3567
+ const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3568
+ const actions = [
3569
+ ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
3570
+ ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3571
+ "Copy agent ID",
3572
+ "Back",
3573
+ ];
3574
+ const chooseAttempt = async () => {
3575
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3576
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
3577
+ const index = choice ? choices.indexOf(choice) : -1;
3578
+ return index >= 0 ? attempts[index] : undefined;
3579
+ };
3580
+ for (;;) {
3581
+ const action = await ctx.ui.select(title(selected), actions);
3582
+ if (!action || action === "Back")
3583
+ return;
3584
+ if (action === "Copy agent ID") {
3585
+ await copyArtifact(selected.id, "agent ID");
3586
+ continue;
3587
+ }
3588
+ if (action === "Copy branch" && worktree) {
3589
+ await copyArtifact(worktree.branch, "branch");
3590
+ continue;
3591
+ }
3592
+ if (action === "Copy worktree path" && worktree) {
3593
+ await copyArtifact(worktree.path, "worktree path");
3594
+ continue;
3595
+ }
3596
+ if (action === "View transcript" || action === "Copy transcript path") {
3597
+ const attempt = await chooseAttempt();
3598
+ if (!attempt)
3599
+ continue;
3600
+ if (action === "Copy transcript path")
3601
+ await copyArtifact(attempt.sessionFile, "transcript path");
3602
+ else
3603
+ await openTranscript(attempt.sessionFile);
2481
3604
  }
2482
3605
  }
2483
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
2484
3606
  };
2485
3607
  for (;;) {
2486
3608
  let view = await loadDashboard();
@@ -2600,6 +3722,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2600
3722
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2601
3723
  if (!actionChoice || actionChoice === "Close")
2602
3724
  return;
3725
+ if (actionChoice === "Agents...") {
3726
+ await selectAgent(view);
3727
+ continue;
3728
+ }
2603
3729
  if (actionChoice === "Refresh")
2604
3730
  continue;
2605
3731
  if (actionChoice === "View script") {
@@ -2644,55 +3770,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2644
3770
  }
2645
3771
  if (actionChoice === "View transcript") {
2646
3772
  const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
2647
- if (transcript && transcript !== "Back") {
2648
- try {
2649
- const entries = SessionManager.open(transcript).buildContextEntries();
2650
- await ctx.ui.custom((tui, theme, keybindings, done) => {
2651
- let offset;
2652
- let renderedLines = [];
2653
- const terminalRows = () => Math.max(1, (tui.terminal?.rows) ?? 24);
2654
- const viewport = () => Math.max(1, terminalRows() - 3);
2655
- const move = (delta) => {
2656
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2657
- offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
2658
- };
2659
- return {
2660
- render(width) {
2661
- renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2662
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2663
- offset = Math.min(offset ?? maxOffset, maxOffset);
2664
- return [
2665
- theme.fg("accent", "Native Pi transcript"),
2666
- ...renderedLines.slice(offset, offset + viewport()),
2667
- "",
2668
- theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
2669
- ];
2670
- },
2671
- invalidate() { },
2672
- handleInput(data) {
2673
- if (keybindings.matches(data, "tui.select.up"))
2674
- move(-1);
2675
- else if (keybindings.matches(data, "tui.select.down"))
2676
- move(1);
2677
- else if (keybindings.matches(data, "tui.select.pageUp"))
2678
- move(-viewport());
2679
- else if (keybindings.matches(data, "tui.select.pageDown"))
2680
- move(viewport());
2681
- else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
2682
- offset = 0;
2683
- else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
2684
- offset = Math.max(0, renderedLines.length - viewport());
2685
- else if (keybindings.matches(data, "tui.select.cancel"))
2686
- done(undefined);
2687
- tui.requestRender();
2688
- },
2689
- };
2690
- }, { overlay: true });
2691
- }
2692
- catch (error) {
2693
- ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2694
- }
2695
- }
3773
+ if (transcript && transcript !== "Back")
3774
+ await openTranscript(transcript);
2696
3775
  continue;
2697
3776
  }
2698
3777
  if (actionChoice === "Transcript paths") {
@@ -2796,16 +3875,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2796
3875
  pi.on("session_shutdown", async () => {
2797
3876
  try {
2798
3877
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2799
- if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
3878
+ if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2800
3879
  await run.completion?.catch(() => undefined);
2801
3880
  return;
2802
3881
  }
2803
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
3882
+ if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2804
3883
  try {
2805
3884
  await run.lifecycle.terminal("interrupted");
2806
3885
  }
2807
3886
  catch (error) {
2808
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state))
3887
+ if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
2809
3888
  throw error;
2810
3889
  }
2811
3890
  run.abortController.abort();