pi-extensible-workflows 0.3.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, 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";
@@ -12,18 +12,27 @@ import { Value } from "typebox/value";
12
12
  import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
13
13
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
14
14
  import { transcriptLines } from "./session-inspector.js";
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"];
15
+ import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
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
- export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
19
- export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
18
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
19
+ export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
20
+ export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
21
+ export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
22
+ export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
23
+ export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
24
+ export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
25
+ export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
26
+ export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
27
+ export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
28
+ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
20
29
  const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
21
30
  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",
31
+ "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
32
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
33
+ "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
25
34
  ];
26
- const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 2;
35
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
27
36
  export class WorkflowError extends Error {
28
37
  code;
29
38
  constructor(code, message) {
@@ -53,6 +62,7 @@ function workflowDetail(message) {
53
62
  return detail || "No further details were provided";
54
63
  }
55
64
  const WORKFLOW_ERROR_PROSE = {
65
+ CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
56
66
  INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
57
67
  INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
58
68
  INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
@@ -64,7 +74,6 @@ const WORKFLOW_ERROR_PROSE = {
64
74
  UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
65
75
  UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
66
76
  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
77
  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
78
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
70
79
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
@@ -74,6 +83,7 @@ const WORKFLOW_ERROR_PROSE = {
74
83
  WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
75
84
  WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
76
85
  RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
86
+ BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
77
87
  INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
78
88
  };
79
89
  export function formatWorkflowFailure(error) {
@@ -123,7 +133,7 @@ export class RunLifecycle {
123
133
  if (this.#active > 0)
124
134
  this.#active -= 1;
125
135
  if (this.#state === "pausing" && this.#active === 0)
126
- await this.#set("paused");
136
+ await this.#set("paused", "pause");
127
137
  }
128
138
  async enterAwaitingInput() {
129
139
  while (this.#state === "pausing" || this.#state === "paused")
@@ -132,26 +142,26 @@ export class RunLifecycle {
132
142
  return;
133
143
  if (this.#state !== "running")
134
144
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
135
- await this.#set("awaiting_input");
145
+ await this.#set("awaiting_input", "awaiting_input");
136
146
  }
137
147
  async resolveAwaitingInput() {
138
148
  if (this.#state !== "awaiting_input")
139
149
  return;
140
- await this.#set("running");
150
+ await this.#set("running", "checkpoint_resolved");
141
151
  for (const resolve of this.#waiters.splice(0))
142
152
  resolve();
143
153
  }
144
154
  async pause() {
145
155
  if (this.#state !== "running")
146
156
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
147
- await this.#set("pausing");
157
+ await this.#set("pausing", "pause");
148
158
  if (this.#active === 0)
149
- await this.#set("paused");
159
+ await this.#set("paused", "pause");
150
160
  }
151
161
  async resume() {
152
- if (this.#state !== "paused" && this.#state !== "interrupted")
162
+ if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted")
153
163
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
154
- await this.#set("running");
164
+ await this.#set("running", "resume");
155
165
  for (const resolve of this.#waiters.splice(0))
156
166
  resolve();
157
167
  }
@@ -161,32 +171,291 @@ export class RunLifecycle {
161
171
  await this.pause();
162
172
  await this.enter();
163
173
  }
164
- async terminal(state) {
174
+ async terminal(state, reason) {
165
175
  if (["completed", "failed", "stopped"].includes(this.#state))
166
176
  throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
167
- await this.#set(state);
177
+ await this.#set(state, reason ?? state);
168
178
  for (const resolve of this.#waiters.splice(0))
169
179
  resolve();
170
180
  }
171
- async #set(state) { this.#state = state; await this.changed?.(state); }
181
+ async #set(state, reason) {
182
+ const previousState = this.#state;
183
+ this.#state = state;
184
+ await this.changed?.(state, previousState, reason);
185
+ }
172
186
  }
173
- export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8, maxAgentLaunches: 1000 });
187
+ export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
174
188
  function fail(code, message) { throw new WorkflowError(code, message); }
175
189
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
190
+ export { object as isObject };
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,43 @@ 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
+ atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
223
566
  }
224
- export function parseRoleMarkdown(content, strict = false) {
567
+ export function parseRoleMarkdown(content, strict = false, rolePath) {
225
568
  if (!strict) {
226
569
  if (!content.startsWith("---\n"))
227
570
  return { prompt: content };
@@ -261,7 +604,7 @@ export function parseRoleMarkdown(content, strict = false) {
261
604
  }
262
605
  if (!object(parsed.frontmatter))
263
606
  fail("INVALID_METADATA", "Role frontmatter must be an object");
264
- const { model, thinking, tools, description } = parsed.frontmatter;
607
+ const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
265
608
  if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
266
609
  fail("INVALID_METADATA", "Role model must be a non-empty string");
267
610
  if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
@@ -270,7 +613,8 @@ export function parseRoleMarkdown(content, strict = false) {
270
613
  fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
271
614
  if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
272
615
  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()) } : {}) };
616
+ const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
617
+ 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
618
  }
275
619
  const ROLE_DIRECTORY = "pi-extensible-workflows";
276
620
  export function workflowRoleDirectories(agentDir = getAgentDir()) {
@@ -283,7 +627,7 @@ function readAgentDefinitions(dir) {
283
627
  try {
284
628
  return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
285
629
  .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
286
- .map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(join(dir, entry.name), "utf8"), true)]));
630
+ .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
287
631
  }
288
632
  catch (error) {
289
633
  if (error.code === "ENOENT")
@@ -297,13 +641,19 @@ function readRoleDefinitions(dirs) {
297
641
  export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
298
642
  return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
299
643
  }
300
- function validateRolePolicies(definitions, roles, availableModels, rootTools) {
644
+ function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
301
645
  for (const role of roles) {
302
646
  const definition = definitions[role];
303
647
  if (!definition)
304
648
  continue;
305
- if (definition.model !== undefined && !availableModels.has(modelCapability(definition.model)))
306
- fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${definition.model}`);
649
+ if (definition.model !== undefined) {
650
+ const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
651
+ if (!availableModels.has(resolved)) {
652
+ if (Object.prototype.hasOwnProperty.call(aliases, definition.model))
653
+ unknownModel(definition.model, resolved, settingsPath);
654
+ fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
655
+ }
656
+ }
307
657
  const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
308
658
  if (missingTool)
309
659
  fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
@@ -348,20 +698,32 @@ function parseWorkflow(script) {
348
698
  function astNode(value) {
349
699
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
350
700
  }
701
+ function astChildren(node) {
702
+ const children = [];
703
+ for (const value of Object.values(node)) {
704
+ if (Array.isArray(value)) {
705
+ for (const child of value)
706
+ if (astNode(child))
707
+ children.push(child);
708
+ }
709
+ else if (astNode(value))
710
+ children.push(value);
711
+ }
712
+ return children;
713
+ }
714
+ function workflowCallKind(node) {
715
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier")
716
+ return undefined;
717
+ const kind = node.callee.name;
718
+ return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
719
+ }
351
720
  function workflowCalls(program) {
352
721
  const calls = [];
353
722
  const visit = (node) => {
354
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
723
+ if (workflowCallKind(node))
355
724
  calls.push(node);
356
- for (const value of Object.values(node)) {
357
- if (Array.isArray(value)) {
358
- for (const child of value)
359
- if (astNode(child))
360
- visit(child);
361
- }
362
- else if (astNode(value))
363
- visit(value);
364
- }
725
+ for (const child of astChildren(node))
726
+ visit(child);
365
727
  };
366
728
  visit(program);
367
729
  return calls.sort((left, right) => left.start - right.start);
@@ -376,9 +738,9 @@ function workflowCallsWithStructure(program) {
376
738
  if (scope?.key === null && key)
377
739
  current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
378
740
  }
379
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
741
+ const operation = workflowCallKind(node);
742
+ if (operation) {
380
743
  const call = node;
381
- const operation = call.callee.name;
382
744
  const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
383
745
  calls.push({ call, execution, structure: current.structure });
384
746
  for (const [index, argument] of call.arguments.entries()) {
@@ -389,15 +751,8 @@ function workflowCallsWithStructure(program) {
389
751
  }
390
752
  return;
391
753
  }
392
- for (const value of Object.values(node)) {
393
- if (Array.isArray(value)) {
394
- for (const child of value)
395
- if (astNode(child))
396
- visit(child, current);
397
- }
398
- else if (astNode(value))
399
- visit(value, current);
400
- }
754
+ for (const child of astChildren(node))
755
+ visit(child, current);
401
756
  };
402
757
  visit(program, { execution: "sequential", structure: [] });
403
758
  return calls.sort((left, right) => left.call.start - right.call.start);
@@ -410,24 +765,18 @@ function validateDirectPrimitiveReferences(program, name) {
410
765
  if (!directCall && !propertyKey)
411
766
  fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
412
767
  }
413
- for (const value of Object.values(node)) {
414
- if (Array.isArray(value)) {
415
- for (const child of value)
416
- if (astNode(child))
417
- visit(child, node);
418
- }
419
- else if (astNode(value))
420
- visit(value, node);
421
- }
768
+ for (const child of astChildren(node))
769
+ visit(child, node);
422
770
  };
423
771
  visit(program);
424
772
  }
425
773
  function hasIdentifier(node, name) {
426
774
  if (node.type === "Identifier" && node.name === name)
427
775
  return true;
428
- return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
776
+ return astChildren(node).some((child) => hasIdentifier(child, name));
429
777
  }
430
778
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
779
+ const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
431
780
  const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
432
781
  function callHasTrailingComma(source, call) {
433
782
  let previous;
@@ -445,14 +794,16 @@ function instrumentWorkflow(script) {
445
794
  const program = parseWorkflow(body);
446
795
  if (hasIdentifier(program, INTERNAL_AGENT_NAME))
447
796
  fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
797
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
798
+ fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
448
799
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
449
800
  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));
801
+ const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
451
802
  const edits = calls.flatMap((call) => {
452
803
  const callSite = `${String(call.start)}:${String(call.end)}`;
453
804
  const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
454
805
  return [
455
- { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
806
+ { 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
807
  { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
457
808
  ];
458
809
  }).sort((left, right) => right.start - left.start);
@@ -544,16 +895,17 @@ function validateSchema(schema, at = "schema") {
544
895
  fail("INVALID_SCHEMA", `${at}.properties must be an object`);
545
896
  }
546
897
  const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
547
- function validateAgentOption(key, value) {
898
+ function validateAgentOption(key, value, aliases, knownModels, settingsPath) {
548
899
  switch (key) {
549
900
  case "label":
550
901
  if (typeof value !== "string" || !value.trim())
551
902
  fail("INVALID_METADATA", "agent label must be a non-empty string");
552
903
  break;
553
904
  case "model":
554
- if (typeof value !== "string")
555
- fail("INVALID_METADATA", "agent model must be a string");
556
- parseModelReference(value);
905
+ if (typeof value !== "string" || !value.trim())
906
+ fail("INVALID_METADATA", "agent model must be a non-empty string");
907
+ if (aliases !== undefined)
908
+ resolveModelReference(value, aliases, knownModels, settingsPath);
557
909
  break;
558
910
  case "thinking":
559
911
  if (typeof value !== "string" || !parseThinking(value))
@@ -583,11 +935,9 @@ function validateAgentOption(key, value) {
583
935
  function validateAgentOptions(value) {
584
936
  if (!object(value) || !jsonValue(value))
585
937
  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
938
  for (const [key, option] of Object.entries(value))
590
- validateAgentOption(key, option);
939
+ if (AGENT_OPTION_KEYS.has(key))
940
+ validateAgentOption(key, option);
591
941
  if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
592
942
  fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
593
943
  return value;
@@ -642,7 +992,7 @@ export function inspectWorkflowScript(script) {
642
992
  const first = callArgument(call, 0);
643
993
  const options = callArgument(call, 1);
644
994
  const placement = { execution, structure };
645
- if (kind === "agent") {
995
+ if (kind === "agent" || kind === "conversation") {
646
996
  const retries = staticValue(propertyNode(options, "retries"));
647
997
  const outputSchema = staticValue(propertyNode(options, "outputSchema"));
648
998
  const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
@@ -652,7 +1002,7 @@ export function inspectWorkflowScript(script) {
652
1002
  return key ? [key] : [];
653
1003
  }) : [];
654
1004
  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")) };
1005
+ 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
1006
  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
1007
  }
658
1008
  if (kind === "checkpoint")
@@ -660,27 +1010,17 @@ export function inspectWorkflowScript(script) {
660
1010
  return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
661
1011
  });
662
1012
  }
663
- function validateStaticAgentOptions(node) {
1013
+ function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPath) {
664
1014
  if (node?.type !== "ObjectExpression")
665
1015
  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))
1016
+ const options = staticValue(node);
1017
+ if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
675
1018
  fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
676
1019
  for (const key of AGENT_OPTION_KEYS) {
677
1020
  const value = staticValue(propertyNode(node, key));
678
1021
  if (value.known)
679
- validateAgentOption(key, value.value);
1022
+ validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
680
1023
  }
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
1024
  }
685
1025
  function validateStaticWithWorktree(call) {
686
1026
  if (call.arguments.some((argument) => argument.type === "SpreadElement"))
@@ -696,113 +1036,134 @@ function validateStaticWithWorktree(call) {
696
1036
  fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
697
1037
  }
698
1038
  }
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"]);
1039
+ 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
1040
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
701
1041
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
702
1042
  export class WorkflowRegistry {
703
- #extensions = new Map();
1043
+ #extensions = new Set();
704
1044
  #globals = new Map();
1045
+ #workflows = new Map();
1046
+ #hooks = new Map();
705
1047
  #frozen = false;
706
1048
  get frozen() { return this.#frozen; }
707
1049
  freeze() { this.#frozen = true; }
708
1050
  register(extension) {
709
1051
  if (this.#frozen)
710
1052
  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}`);
1053
+ 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())
1054
+ fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
715
1055
  const functions = extension.functions ?? {};
716
1056
  const variables = extension.variables ?? {};
717
1057
  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");
1058
+ const agentSetupHooks = extension.agentSetupHooks ?? {};
1059
+ 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))
1060
+ fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
720
1061
  const names = [...Object.keys(functions), ...Object.keys(variables)];
721
1062
  if (new Set(names).size !== names.length)
722
- fail("GLOBAL_COLLISION", `Global name collision inside ${extension.namespace}`);
1063
+ fail("GLOBAL_COLLISION", "Global name collision inside extension");
723
1064
  for (const name of names) {
724
1065
  if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_"))
725
- fail("INVALID_METADATA", `Invalid global name: ${extension.namespace}.${name}`);
1066
+ fail("INVALID_METADATA", `Invalid global name: ${name}`);
726
1067
  if (RESERVED_GLOBALS.has(name))
727
1068
  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}`);
1069
+ if (this.#globals.has(name))
1070
+ fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
731
1071
  }
732
1072
  for (const [name, fn] of Object.entries(functions)) {
733
1073
  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`);
1074
+ fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
1075
+ validateSchema(fn.input, `${name} input`);
1076
+ validateSchema(fn.output, `${name} output`);
737
1077
  if (fn.input.type !== "object")
738
- fail("INVALID_SCHEMA", `${extension.namespace}.${name} input must describe one object`);
1078
+ fail("INVALID_SCHEMA", `${name} input must describe one object`);
739
1079
  }
740
1080
  for (const [name, variable] of Object.entries(variables)) {
741
1081
  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`);
1082
+ fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
1083
+ validateSchema(variable.schema, `${name} schema`);
744
1084
  }
745
1085
  for (const [name, workflow] of Object.entries(workflows)) {
746
1086
  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}`);
1087
+ fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
1088
+ if (this.#workflows.has(name))
1089
+ fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
748
1090
  parseWorkflow(workflow.script);
749
1091
  }
750
- const stored = deepFreeze({ ...extension, functions, variables, workflows });
751
- this.#extensions.set(extension.namespace, stored);
1092
+ for (const [name, hook] of Object.entries(agentSetupHooks)) {
1093
+ 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)))
1094
+ fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
1095
+ if (this.#hooks.has(name))
1096
+ fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
1097
+ }
1098
+ const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
1099
+ this.#extensions.add(stored);
752
1100
  for (const name of names)
753
- this.#globals.set(name, `${extension.namespace}.${name}`);
1101
+ this.#globals.set(name, name);
1102
+ for (const [name, workflow] of Object.entries(workflows))
1103
+ this.#workflows.set(name, workflow);
1104
+ for (const [name, hook] of Object.entries(agentSetupHooks))
1105
+ this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
754
1106
  }
755
1107
  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)];
1108
+ if (!IDENTIFIER.test(name))
1109
+ fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
1110
+ const workflow = this.#workflows.get(name);
760
1111
  if (!workflow)
761
1112
  fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
762
1113
  return workflow;
763
1114
  }
764
1115
  workflows() {
765
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.workflows ?? {}).map(([name, workflow]) => [`${namespace}.${name}`, workflow]))));
1116
+ return Object.freeze(Object.fromEntries(this.#workflows));
766
1117
  }
767
1118
  catalog() {
768
1119
  const functions = [];
769
1120
  const variables = [];
770
1121
  const workflows = [];
771
- for (const [namespace, extension] of this.#extensions) {
1122
+ for (const extension of this.#extensions) {
772
1123
  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) });
1124
+ functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
774
1125
  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) });
1126
+ variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
776
1127
  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 });
1128
+ workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
1129
+ }
1130
+ let aliases;
1131
+ try {
1132
+ aliases = loadSettings().modelAliases;
778
1133
  }
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)) });
1134
+ catch {
1135
+ aliases = undefined;
1136
+ }
1137
+ const sort = (left, right) => left.name.localeCompare(right.name);
1138
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
781
1139
  }
782
1140
  globals() {
783
- return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.keys(extension.functions ?? {}).map((name) => [name, { namespace, name }]))));
1141
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
784
1142
  }
785
- async invokeFunction(namespace, name, input, context, path, journal) {
786
- const fn = this.#extensions.get(namespace)?.functions?.[name];
1143
+ async invokeFunction(name, input, context, path, journal) {
1144
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
787
1145
  if (!fn)
788
- fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${namespace}.${name}`);
1146
+ fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
789
1147
  if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
790
- fail("RESULT_INVALID", `Invalid input for ${namespace}.${name}`);
1148
+ fail("RESULT_INVALID", `Invalid input for ${name}`);
791
1149
  const replayed = journal.get(path);
792
1150
  if (replayed !== undefined) {
793
1151
  if (!jsonValue(replayed) || !Value.Check(fn.output, replayed))
794
- fail("RESULT_INVALID", `Invalid replay for ${namespace}.${name}`);
1152
+ fail("RESULT_INVALID", `Invalid replay for ${name}`);
795
1153
  return structuredClone(replayed);
796
1154
  }
797
- 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 }));
1155
+ const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
798
1156
  if (!jsonValue(result) || !Value.Check(fn.output, result))
799
- fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
1157
+ fail("RESULT_INVALID", `Invalid output from ${name}`);
800
1158
  const stored = structuredClone(result);
801
1159
  journal.put(path, stored);
802
1160
  return structuredClone(stored);
803
1161
  }
804
1162
  variables() {
805
- return [...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ namespace, name, variable })));
1163
+ return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
1164
+ }
1165
+ agentSetupHooks() {
1166
+ return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
806
1167
  }
807
1168
  }
808
1169
  const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
@@ -818,6 +1179,7 @@ function createWorkflowRegistryApi(registry) {
818
1179
  globals: () => registry.globals(),
819
1180
  invokeFunction: (...args) => registry.invokeFunction(...args),
820
1181
  variables: () => registry.variables(),
1182
+ agentSetupHooks: () => registry.agentSetupHooks(),
821
1183
  };
822
1184
  }
823
1185
  function workflowRegistryHost() {
@@ -848,11 +1210,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
848
1210
  name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
849
1211
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
850
1212
  script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
851
- workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as namespace.name" })),
1213
+ workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
852
1214
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
853
1215
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
854
1216
  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" })),
1217
+ budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
856
1218
  });
857
1219
  function hasDynamicAgentRole(node) {
858
1220
  if (!node)
@@ -874,17 +1236,23 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
874
1236
  const program = parseWorkflow(script);
875
1237
  if (hasIdentifier(program, INTERNAL_AGENT_NAME))
876
1238
  fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
1239
+ if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
1240
+ fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
877
1241
  if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
878
1242
  fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
879
1243
  validateDirectPrimitiveReferences(program, "withWorktree");
1244
+ validateDirectPrimitiveReferences(program, "conversation");
880
1245
  for (const [index, schema] of schemas.entries())
881
1246
  validateSchema(schema, `schema[${String(index)}]`);
882
1247
  const calls = workflowCalls(program);
883
1248
  const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
884
1249
  for (const call of calls) {
885
1250
  const operation = call.callee.name;
886
- if (operation === "agent")
887
- validateStaticAgentOptions(call.arguments[1]);
1251
+ if (operation === "agent" || operation === "conversation") {
1252
+ if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2))
1253
+ fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
1254
+ validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
1255
+ }
888
1256
  if (operation === "withWorktree")
889
1257
  validateStaticWithWorktree(call);
890
1258
  if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
@@ -896,21 +1264,25 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
896
1264
  if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
897
1265
  fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
898
1266
  }
899
- const agentCalls = calls.filter((call) => call.callee.name === "agent");
1267
+ const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
900
1268
  const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
901
1269
  const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
902
1270
  for (const [index, schema] of staticSchemas.entries())
903
1271
  validateSchema(schema, `agent outputSchema[${String(index)}]`);
904
1272
  const checkedSchemas = [...schemas, ...staticSchemas];
905
- const models = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "model")); return value === undefined ? [] : [modelCapability(value)]; });
1273
+ 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) }]; });
1274
+ const models = modelRefs.map(({ resolved }) => resolved);
906
1275
  const tools = agentCalls.flatMap((call) => {
907
1276
  const value = propertyNode(call.arguments[1], "tools");
908
1277
  return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
909
1278
  });
910
1279
  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}`);
1280
+ const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
1281
+ if (missingModel) {
1282
+ if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested))
1283
+ unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
1284
+ fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
1285
+ }
914
1286
  const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
915
1287
  if (missingTool)
916
1288
  fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
@@ -923,6 +1295,8 @@ export function validateWorkflowLaunch(params, context) {
923
1295
  return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
924
1296
  }
925
1297
  function validateWorkflowLaunchWithRegistry(params, context, registry) {
1298
+ if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches"))
1299
+ fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
926
1300
  if (params.script !== undefined && params.workflow !== undefined)
927
1301
  fail("INVALID_METADATA", "Provide either script or workflow, not both");
928
1302
  const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
@@ -936,9 +1310,11 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
936
1310
  const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
937
1311
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
938
1312
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
939
- const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)) }, [], metadata);
1313
+ const aliases = context.modelAliases ?? {};
1314
+ const knownModels = context.knownModels ?? context.availableModels;
1315
+ 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
1316
  const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
941
- validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
1317
+ validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
942
1318
  return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
943
1319
  }
944
1320
  function deepFreeze(value) {
@@ -1020,6 +1396,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
1020
1396
  const path = (...names) => names.map(encodeURIComponent).join("/");
1021
1397
  const inheritedAgentPath = new AsyncLocalStorage();
1022
1398
  const agentOccurrences = new Map();
1399
+ const conversationOccurrences = new Map();
1023
1400
  const worktreeOwners = new AsyncLocalStorage();
1024
1401
  const worktreeOccurrences = new Map();
1025
1402
  const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
@@ -1043,6 +1420,44 @@ const internalWithWorktree = async (...values) => {
1043
1420
  }
1044
1421
  return await worktreeOwners.run(owner, callback);
1045
1422
  };
1423
+ const internalConversation = (...values) => {
1424
+ const callSite = values.pop();
1425
+ if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
1426
+ const name = values[0];
1427
+ if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
1428
+ const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
1429
+ if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
1430
+ const inherited = inheritedAgentPath.getStore() || [];
1431
+ const occurrenceKey = JSON.stringify([inherited, callSite, name]);
1432
+ const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
1433
+ conversationOccurrences.set(occurrenceKey, occurrence);
1434
+ const fixedOptions = structuredClone(conversationOptions);
1435
+ const defaultTimeout = fixedOptions.timeoutMs;
1436
+ const defaultRetries = fixedOptions.retries;
1437
+ delete fixedOptions.timeoutMs;
1438
+ delete fixedOptions.retries;
1439
+ const worktreeOwner = worktreeOwners.getStore();
1440
+ let turn = 0;
1441
+ let active = false;
1442
+ return Object.freeze({
1443
+ run(prompt, turnOptions = {}) {
1444
+ if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
1445
+ 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");
1446
+ if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
1447
+ active = true;
1448
+ const turnNumber = turn + 1;
1449
+ const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
1450
+ const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
1451
+ const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
1452
+ Object.defineProperties(result, {
1453
+ toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
1454
+ toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1455
+ [Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
1456
+ });
1457
+ return result;
1458
+ },
1459
+ });
1460
+ };
1046
1461
  const internalAgent = (...values) => {
1047
1462
  const callSite = values.pop();
1048
1463
  if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
@@ -1112,16 +1527,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
1112
1527
  const phase = name => rpc("phase", [name]);
1113
1528
  const log = message => rpc("log", [message]);
1114
1529
  const functionOccurrences = new Map();
1115
- const functionPath = (namespace, name) => {
1530
+ const functionPath = name => {
1116
1531
  const inherited = inheritedAgentPath.getStore() || [];
1117
- const key = JSON.stringify([inherited, namespace, name]);
1532
+ const key = JSON.stringify([inherited, name]);
1118
1533
  const occurrence = (functionOccurrences.get(key) || 0) + 1;
1119
1534
  functionOccurrences.set(key, occurrence);
1120
- return path("function", ...inherited, namespace, name, String(occurrence));
1535
+ return path("function", ...inherited, name, String(occurrence));
1121
1536
  };
1122
1537
  const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
1123
1538
  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);
1539
+ const inherited = inheritedAgentPath.getStore() || [];
1540
+ const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
1125
1541
  Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
1126
1542
  return result;
1127
1543
  }])));
@@ -1182,13 +1598,13 @@ const pipeline = async (operationName, items, stages) => {
1182
1598
  return Object.fromEntries(results.map(result => [result.name, result.value]));
1183
1599
  };
1184
1600
  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) };
1601
+ const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
1186
1602
  for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
1187
1603
  for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
1188
1604
  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
1605
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
1190
1606
  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))
1607
+ 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
1608
  .then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
1193
1609
  .catch(error => send({ type: "error", error: workerError(error) }))
1194
1610
  .finally(() => clearInterval(heartbeat));
@@ -1209,13 +1625,25 @@ function readAgentIdentity(value) {
1209
1625
  const occurrence = value.occurrence;
1210
1626
  const worktreeOwner = value.worktreeOwner;
1211
1627
  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))
1628
+ const conversation = value.conversation;
1629
+ const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
1630
+ 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
1631
  fail("INTERNAL_ERROR", "Invalid workflow agent identity");
1214
- return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
1632
+ return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
1215
1633
  }
1216
1634
  function agentIdentityPath(identity) {
1217
1635
  return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1218
1636
  }
1637
+ function conversationIdentityPath(identity) {
1638
+ if (!identity.conversation)
1639
+ throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1640
+ return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
1641
+ }
1642
+ function conversationTurnPath(identity) {
1643
+ if (!identity.conversation)
1644
+ throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
1645
+ return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
1646
+ }
1219
1647
  function agentWorktree(identity) {
1220
1648
  return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
1221
1649
  }
@@ -1340,12 +1768,15 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
1340
1768
  }
1341
1769
  }
1342
1770
  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];
1771
+ 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");
1772
+ const structuralPath = values[4] === undefined ? [] : values[4];
1773
+ if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())))
1774
+ fail("INTERNAL_ERROR", "function structural scope is invalid");
1775
+ if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string")
1776
+ fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
1777
+ const name = values[0];
1347
1778
  try {
1348
- const result = await bridge.function(values[0], values[1], values[2], values[3], controller.signal, worktreeOwner);
1779
+ const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
1349
1780
  value = branded({ name, ok: true, value: result ?? null });
1350
1781
  }
1351
1782
  catch (error) {
@@ -1397,8 +1828,9 @@ export async function persistActiveAgentAttempt(store, id, active) {
1397
1828
  if (!agent)
1398
1829
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1399
1830
  const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1831
+ const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
1400
1832
  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 };
1833
+ return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
1402
1834
  });
1403
1835
  }
1404
1836
  export async function persistAgentAttempts(store, id, attempts) {
@@ -1407,26 +1839,53 @@ export async function persistAgentAttempts(store, id, attempts) {
1407
1839
  if (!agent)
1408
1840
  throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
1409
1841
  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 }));
1842
+ const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
1411
1843
  const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
1412
1844
  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
1845
  });
1414
1846
  }
1847
+ function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
1848
+ function agentGroupLabel(agents) {
1849
+ const structural = agents[0]?.structuralPath ?? [];
1850
+ const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value) => Boolean(value)))];
1851
+ return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
1852
+ }
1853
+ function agentGroups(agents) {
1854
+ const byId = new Map(agents.map((agent) => [agent.id, agent]));
1855
+ const groups = new Map();
1856
+ for (const [index, agent] of agents.entries()) {
1857
+ let depth = 0;
1858
+ for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
1859
+ depth += 1;
1860
+ const key = agentGroupKey(agent);
1861
+ const group = groups.get(key) ?? { agents: [] };
1862
+ group.agents.push({ agent, index, depth });
1863
+ groups.set(key, group);
1864
+ }
1865
+ return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
1866
+ }
1867
+ function renderGroupedAgents(agents, render) {
1868
+ const groups = agentGroups(agents);
1869
+ const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
1870
+ return groups.flatMap((group) => [
1871
+ ...(grouped ? [` ${group.label}`] : []),
1872
+ ...group.entries.map((entry) => render(entry, grouped)),
1873
+ ]);
1874
+ }
1415
1875
  export function formatWorkflowProgress(run, spinner = "◇") {
1416
1876
  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)`];
1877
+ 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
1878
  if (run.phase)
1419
1879
  lines.push(` Phase: ${run.phase}`);
1880
+ lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
1420
1881
  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;
1882
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1425
1883
  const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
1426
- const indent = " ".repeat(depth + 1);
1884
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
1427
1885
  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
- }
1886
+ const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
1887
+ return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
1888
+ }));
1430
1889
  return lines.join("\n");
1431
1890
  }
1432
1891
  function workflowToolUpdate(run) {
@@ -1450,7 +1909,31 @@ function workflowProgressBlock(run) {
1450
1909
  invalidate() { },
1451
1910
  };
1452
1911
  }
1453
- const ATTENTION_ORDER = { awaiting_input: 0, running: 1, pausing: 2, paused: 3, interrupted: 4, failed: 5, queued: 6, stopped: 7, completed: 8 };
1912
+ export function formatBudgetStatus(run) {
1913
+ const usage = budgetUsage(run.usage);
1914
+ if (!run.budget || !Object.keys(run.budget).length)
1915
+ return ["Budget: unlimited"];
1916
+ const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
1917
+ for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
1918
+ const limits = run.budget[dimension];
1919
+ if (!limits || (limits.soft === undefined && limits.hard === undefined))
1920
+ continue;
1921
+ const limit = limits.hard ?? limits.soft;
1922
+ const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
1923
+ const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
1924
+ lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
1925
+ }
1926
+ const events = run.budgetEvents ?? [];
1927
+ if (events.length)
1928
+ lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
1929
+ return lines;
1930
+ }
1931
+ function formatCompactBudgetStatus(run) {
1932
+ if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined))
1933
+ return [];
1934
+ return formatBudgetStatus(run);
1935
+ }
1936
+ 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
1937
  function navigatorAttentionSort(entries) {
1455
1938
  return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
1456
1939
  }
@@ -1460,7 +1943,7 @@ function navigatorRunLabels(entries) {
1460
1943
  nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
1461
1944
  return entries.map(({ store, loaded: { run } }) => {
1462
1945
  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" ? "●" : "◆";
1946
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1464
1947
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
1465
1948
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
1466
1949
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -1469,7 +1952,7 @@ function navigatorRunLabels(entries) {
1469
1952
  }
1470
1953
  function agentBreadcrumb(agent, byId) {
1471
1954
  const name = agent.label ?? agent.name;
1472
- const parts = [name];
1955
+ const parts = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
1473
1956
  const seen = new Set([agent.id]);
1474
1957
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
1475
1958
  if (seen.has(parentId))
@@ -1477,10 +1960,11 @@ function agentBreadcrumb(agent, byId) {
1477
1960
  seen.add(parentId);
1478
1961
  const parent = byId.get(parentId);
1479
1962
  if (parent)
1480
- parts.unshift(parent.label ?? parent.name);
1963
+ parts.push(parent.label ?? parent.name);
1481
1964
  else
1482
1965
  break;
1483
1966
  }
1967
+ parts.push(name);
1484
1968
  return parts.length > 1 ? parts.join(" > ") : name;
1485
1969
  }
1486
1970
  function formatAgentActivity(agent, spinner) {
@@ -1498,53 +1982,49 @@ function formatAccounting(accounting) {
1498
1982
  return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
1499
1983
  }
1500
1984
  export function formatNavigatorDashboard(run, checkpoints, worktrees) {
1985
+ void worktrees;
1501
1986
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
1502
1987
  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
1988
  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" ? "●" : "◆";
1989
+ const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
1505
1990
  const header = `${glyph} ${run.workflowName}`;
1506
1991
  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];
1992
+ const lines = [header, meta, ...formatCompactBudgetStatus(run)];
1508
1993
  if (run.error)
1509
1994
  lines.push(`Error: ${run.error.code}: ${run.error.message}`);
1995
+ if (run.events?.length)
1996
+ lines.push(...run.events.map((event) => `Warning: ${event.message}`));
1510
1997
  lines.push("");
1511
1998
  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) {
1999
+ const render = ({ agent, depth }, grouped) => {
1519
2000
  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}` : ""];
2001
+ const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
2002
+ const setup = agent.attemptDetails?.at(-1)?.setup;
2003
+ const thinking = setup?.model.thinking ?? agent.model.thinking;
2004
+ const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
2005
+ const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
1523
2006
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
1524
- const parts = [`${icon} ${breadcrumb}`, agent.state, ...policy, tokens].filter(Boolean);
1525
- lines.push(parts.join(" · "));
2007
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
2008
+ const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
1526
2009
  if (agent.state === "failed" && agent.attemptDetails?.length) {
1527
2010
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
1528
2011
  if (last?.error)
1529
- lines.push(` error: ${last.error.code}: ${last.error.message}`);
2012
+ result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
1530
2013
  }
1531
2014
  const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
1532
2015
  if (activity)
1533
- lines.push(` ${activity}`);
1534
- }
2016
+ result.push(`${indent} ${activity}`);
2017
+ return result.join("\n");
2018
+ };
2019
+ lines.push(...renderGroupedAgents(run.agents, render));
1535
2020
  if (checkpoints.length) {
1536
2021
  lines.push("");
1537
2022
  for (const cp of checkpoints)
1538
2023
  lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`);
1539
2024
  }
1540
- if (worktrees.length) {
1541
- lines.push("");
1542
- for (const wt of worktrees)
1543
- lines.push(`branch ${wt.branch} (${wt.path})`);
1544
- }
1545
2025
  return lines.join("\n");
1546
2026
  }
1547
- export function formatNavigatorRun(loaded, checkpoints, worktrees) {
2027
+ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
1548
2028
  const { run, snapshot } = loaded;
1549
2029
  const lines = [
1550
2030
  `Workflow: ${run.workflowName}`,
@@ -1552,39 +2032,40 @@ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
1552
2032
  `Status: ${run.state}`,
1553
2033
  `Phase: ${run.phase ?? "(none)"}`,
1554
2034
  `Launch cwd: ${run.cwd}`,
2035
+ ...formatCompactBudgetStatus(run),
1555
2036
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
1556
2037
  ];
1557
2038
  if (run.error)
1558
2039
  lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
2040
+ if (run.events?.length)
2041
+ lines.push(...run.events.map((event) => `Warning: ${event.message}`));
2042
+ const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
2043
+ if (aliases && Object.keys(aliases).length)
2044
+ lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
1559
2045
  lines.push("Agents / ownership:");
1560
2046
  if (!run.agents.length)
1561
2047
  lines.push(" (none)");
1562
- for (const agent of run.agents) {
2048
+ const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
2049
+ lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
1563
2050
  const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
1564
2051
  const role = agent.role ? ` role=${agent.role}` : "";
1565
2052
  const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
1566
2053
  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}`);
2054
+ const indent = " ".repeat((grouped ? 2 : 1) + depth);
2055
+ 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
2056
  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}` : ""}`);
2057
+ result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
1570
2058
  for (const call of agent.toolCalls ?? [])
1571
- lines.push(` tool ${call.name} state=${call.state}`);
1572
- }
2059
+ result.push(`${indent} tool ${call.name} state=${call.state}`);
2060
+ return result.join("\n");
2061
+ }));
1573
2062
  lines.push("Checkpoints:");
1574
2063
  if (!checkpoints.length)
1575
2064
  lines.push(" (none)");
1576
2065
  for (const checkpoint of checkpoints)
1577
2066
  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}`);
2067
+ lines.push(`Worktrees: ${String(_worktrees.length)}`);
2068
+ lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
1588
2069
  return lines.join("\n");
1589
2070
  }
1590
2071
  function formatCheckpointReview(checkpoint) {
@@ -1616,6 +2097,85 @@ function deliver(pi, content) {
1616
2097
  function deliverFailure(pi, name, error) {
1617
2098
  deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
1618
2099
  }
2100
+ function safeEventError(error) {
2101
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
2102
+ return { code, message: `Workflow execution failed (${code})` };
2103
+ }
2104
+ class WorkflowEventPublisher {
2105
+ sink;
2106
+ #queues = new Map();
2107
+ #budgetEvents = new Map();
2108
+ #worktrees = new Map();
2109
+ constructor(sink) {
2110
+ this.sink = sink;
2111
+ }
2112
+ seedBudget(runId, events) {
2113
+ const seen = this.#budgetEvents.get(runId) ?? new Set();
2114
+ for (const event of events ?? [])
2115
+ seen.add(this.budgetKey(event));
2116
+ this.#budgetEvents.set(runId, seen);
2117
+ }
2118
+ async runStarted(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
2119
+ async runResumed(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
2120
+ async runState(store, metadata, previousState, state, reason) {
2121
+ await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason) ? { errorCode: reason } : {}) });
2122
+ if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running")
2123
+ await this.runResumed(store, metadata);
2124
+ }
2125
+ async runCompleted(store, metadata, resultPath) { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
2126
+ async runFailed(store, metadata, error, state) {
2127
+ if (state === "failed")
2128
+ await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
2129
+ }
2130
+ async agentState(store, metadata, previous, agent) {
2131
+ 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 });
2132
+ }
2133
+ async agentStates(store, metadata, previous, current) {
2134
+ const previousById = new Map(previous.map((agent) => [agent.id, agent]));
2135
+ for (const agent of current) {
2136
+ const old = previousById.get(agent.id);
2137
+ if (!old || old.state !== agent.state || old.attempts !== agent.attempts)
2138
+ await this.agentState(store, metadata, old, agent);
2139
+ }
2140
+ }
2141
+ async phase(store, metadata, previousPhase, phase) {
2142
+ if (previousPhase !== phase)
2143
+ await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
2144
+ }
2145
+ async checkpoint(store, metadata, name, state) { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
2146
+ async budget(store, metadata, run) {
2147
+ const seen = this.#budgetEvents.get(store.runId) ?? new Set();
2148
+ this.#budgetEvents.set(store.runId, seen);
2149
+ for (const event of run.budgetEvents ?? []) {
2150
+ const key = this.budgetKey(event);
2151
+ if (seen.has(key))
2152
+ continue;
2153
+ seen.add(key);
2154
+ await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
2155
+ }
2156
+ }
2157
+ async worktree(store, metadata, worktree) {
2158
+ const seen = this.#worktrees.get(store.runId) ?? new Set();
2159
+ this.#worktrees.set(store.runId, seen);
2160
+ if (seen.has(worktree.owner))
2161
+ return;
2162
+ seen.add(worktree.owner);
2163
+ await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
2164
+ }
2165
+ async #publish(store, metadata, name, payload) {
2166
+ const base = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
2167
+ const previous = this.#queues.get(store.runId) ?? Promise.resolve();
2168
+ const next = previous.then(() => {
2169
+ try {
2170
+ void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined);
2171
+ }
2172
+ catch { /* Best effort: listeners cannot affect a run. */ }
2173
+ });
2174
+ this.#queues.set(store.runId, next.catch(() => undefined));
2175
+ await next;
2176
+ }
2177
+ budgetKey(event) { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
2178
+ }
1619
2179
  const inheritedHostAgentPath = new AsyncLocalStorage();
1620
2180
  const inheritedHostWorktreeOwner = new AsyncLocalStorage();
1621
2181
  function namedRecord(value, kind) {
@@ -1649,15 +2209,15 @@ function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
1649
2209
  }
1650
2210
  async function resolveWorkflowVariables(run, controller, registry) {
1651
2211
  let first;
1652
- const tasks = registry.variables().map(async ({ namespace, name, variable }) => {
2212
+ const tasks = registry.variables().map(async ({ name, variable }) => {
1653
2213
  try {
1654
2214
  const result = await variable.resolve(run);
1655
2215
  if (!jsonValue(result) || !Value.Check(variable.schema, result))
1656
- fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
2216
+ fail("RESULT_INVALID", `Invalid output from ${name}`);
1657
2217
  return [name, deepFreeze(structuredClone(result))];
1658
2218
  }
1659
2219
  catch (error) {
1660
- const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${namespace}.${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${namespace}.${name}: ${errorText(error)}`);
2220
+ const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
1661
2221
  if (!first) {
1662
2222
  first = typed;
1663
2223
  controller.abort();
@@ -1742,46 +2302,100 @@ function nextNamedOccurrence(counters, label) {
1742
2302
  function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
1743
2303
  const functionAgentOccurrences = new Map();
1744
2304
  const functionWorktreeOccurrences = new Map();
1745
- return { ...bridge, functions: registry.globals(), variables, function: async (namespace, name, input, path, signal, worktreeOwner) => {
1746
- const replayed = await store.replay(path);
1747
- let stored;
1748
- const sideEffects = [];
1749
- const context = {
1750
- run: runContext,
1751
- agent: async (...args) => {
1752
- if (!bridge.agent || typeof args[0] !== "string")
1753
- fail("AGENT_FAILED", "No agent bridge is available");
1754
- const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
1755
- const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
1756
- const structuralPath = inheritedHostAgentPath.getStore() ?? [];
1757
- const key = `${path}\0${JSON.stringify(structuralPath)}`;
1758
- const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
1759
- functionAgentOccurrences.set(key, occurrence);
1760
- return bridge.agent(args[0], options, signal, { structuralPath: [...structuralPath], callSite: `function:${path}`, occurrence, parentBreadcrumb: `${namespace}.${name}`, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
1761
- },
1762
- prompt: workflowPrompt,
1763
- parallel: (...args) => hostParallel(args[0], args[1]),
1764
- pipeline: (...args) => hostPipeline(args[0], args[1], args[2]),
1765
- withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences),
1766
- checkpoint: async (...args) => {
1767
- if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0]))
1768
- fail("INTERNAL_ERROR", "No checkpoint bridge is available");
1769
- return bridge.checkpoint(args[0], signal);
1770
- },
1771
- phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
1772
- log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
1773
- };
1774
- const result = await registry.invokeFunction(namespace, name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } });
1775
- await Promise.all(sideEffects);
1776
- if (!replayed)
1777
- await store.complete(path, stored ?? result);
1778
- return result;
1779
- } };
2305
+ const functionInvokeOccurrences = new Map();
2306
+ const invokeFunction = async (name, input, path, signal, worktreeOwner, structuralPath = [], breadcrumb) => {
2307
+ const replayed = await store.replay(path);
2308
+ let stored;
2309
+ const sideEffects = [];
2310
+ const functionBreadcrumb = breadcrumb ?? name;
2311
+ const context = {
2312
+ run: runContext,
2313
+ invoke: async (targetName, targetInput) => {
2314
+ const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
2315
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2316
+ const key = JSON.stringify([path, inherited, targetName]);
2317
+ const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
2318
+ functionInvokeOccurrences.set(key, occurrence);
2319
+ const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
2320
+ return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
2321
+ },
2322
+ agent: async (...args) => {
2323
+ if (!bridge.agent || typeof args[0] !== "string")
2324
+ fail("AGENT_FAILED", "No agent bridge is available");
2325
+ const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
2326
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2327
+ const inherited = inheritedHostAgentPath.getStore() ?? [];
2328
+ const key = `${path}\0${JSON.stringify(inherited)}`;
2329
+ const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
2330
+ functionAgentOccurrences.set(key, occurrence);
2331
+ return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2332
+ },
2333
+ prompt: workflowPrompt,
2334
+ parallel: (...args) => hostParallel(args[0], args[1]),
2335
+ pipeline: (...args) => hostPipeline(args[0], args[1], args[2]),
2336
+ withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences),
2337
+ checkpoint: async (...args) => {
2338
+ if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0]))
2339
+ fail("INTERNAL_ERROR", "No checkpoint bridge is available");
2340
+ return bridge.checkpoint(args[0], signal);
2341
+ },
2342
+ phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
2343
+ log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
2344
+ };
2345
+ const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
2346
+ await Promise.all(sideEffects);
2347
+ if (!replayed)
2348
+ await store.complete(path, stored ?? result);
2349
+ return result;
2350
+ };
2351
+ return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
1780
2352
  }
1781
2353
  function projectTrusted(ctx) {
1782
2354
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1783
2355
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1784
2356
  }
2357
+ function isEntryRenderer(value) { return typeof value === "function"; }
2358
+ function isWorkflowEventSink(value) { return object(value) && typeof value.emit === "function"; }
2359
+ function piHostCapabilities(pi) {
2360
+ if (!object(pi))
2361
+ return {};
2362
+ const registerEntryRenderer = pi.registerEntryRenderer;
2363
+ const events = pi.events;
2364
+ return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
2365
+ }
2366
+ function isModelRegistryGetter(value) { return typeof value === "function"; }
2367
+ function contextHostCapabilities(ctx) {
2368
+ if (!object(ctx) || !object(ctx.modelRegistry))
2369
+ return {};
2370
+ const registry = ctx.modelRegistry;
2371
+ const getAll = registry.getAll;
2372
+ const getAvailable = registry.getAvailable;
2373
+ return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
2374
+ }
2375
+ function isUiSelect(value) { return typeof value === "function"; }
2376
+ function isUiInput(value) { return typeof value === "function"; }
2377
+ function isUiSetStatus(value) { return typeof value === "function"; }
2378
+ function uiHostCapabilities(ui) {
2379
+ if (!object(ui))
2380
+ return undefined;
2381
+ const select = ui.select;
2382
+ const input = ui.input;
2383
+ const setStatus = ui.setStatus;
2384
+ return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
2385
+ }
2386
+ function tuiHostCapabilities(tui) {
2387
+ if (!object(tui) || !object(tui.terminal))
2388
+ return {};
2389
+ return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2390
+ }
2391
+ function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
2392
+ function isKeybindingGetter(value) { return typeof value === "function"; }
2393
+ function keybindingsHostCapabilities(keybindings) {
2394
+ if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys))
2395
+ return {};
2396
+ return { getKeys: keybindings.getKeys };
2397
+ }
2398
+ function keybindingKeys(keybindings, name) { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
1785
2399
  function parseThinking(value) {
1786
2400
  switch (value) {
1787
2401
  case "off":
@@ -1797,7 +2411,7 @@ function parseThinking(value) {
1797
2411
  export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession) {
1798
2412
  beginWorkflowExtensionLoading();
1799
2413
  const registry = loadingRegistry();
1800
- const registerEntryRenderer = pi.registerEntryRenderer;
2414
+ const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
1801
2415
  registerEntryRenderer?.(WORKFLOW_LOG_ENTRY, (entry) => {
1802
2416
  const data = entry.data;
1803
2417
  return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
@@ -1811,7 +2425,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1811
2425
  await lifecycle.leave();
1812
2426
  }
1813
2427
  };
1814
- const events = pi.events;
2428
+ const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
1815
2429
  pi.on("resources_discover", () => {
1816
2430
  if (!pi.getActiveTools().includes("workflow"))
1817
2431
  return;
@@ -1819,13 +2433,31 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1819
2433
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1820
2434
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1821
2435
  });
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 });
2436
+ const runs = new Map();
2437
+ const liveActivities = new Map();
2438
+ const setLiveActivity = (runId, agentId, activity) => {
2439
+ const activities = liveActivities.get(runId);
2440
+ if (activity) {
2441
+ if (activities)
2442
+ activities.set(agentId, activity);
2443
+ else
2444
+ liveActivities.set(runId, new Map([[agentId, activity]]));
2445
+ }
2446
+ else {
2447
+ activities?.delete(agentId);
2448
+ if (activities?.size === 0)
2449
+ liveActivities.delete(runId);
2450
+ }
1824
2451
  };
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 } : {}) });
2452
+ const withLiveActivities = (run) => {
2453
+ const activities = liveActivities.get(run.id);
2454
+ return activities?.size ? { ...run, agents: run.agents.map((agent) => {
2455
+ const activity = activities.get(agent.id);
2456
+ return activity ? { ...agent, activity } : agent;
2457
+ }) } : run;
1827
2458
  };
1828
- const runs = new Map();
2459
+ const conversationLocks = new Set();
2460
+ const terminalRunStates = new Map();
1829
2461
  let sessionLease;
1830
2462
  let sessionLeasePromise;
1831
2463
  const ensureSessionLease = async (cwd, sessionId) => {
@@ -1846,48 +2478,84 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1846
2478
  sessionLeasePromise = undefined;
1847
2479
  await lease?.release();
1848
2480
  };
1849
- const lifecycleFor = (store, state) => new RunLifecycle(state, async (next) => {
1850
- const run = await store.updateState((current) => {
1851
- const nextRun = { ...current, state: next };
2481
+ const persistRunState = async (store, metadata, update) => {
2482
+ const persisted = await store.updateState(update);
2483
+ await eventPublisher.budget(store, metadata, persisted);
2484
+ return persisted;
2485
+ };
2486
+ const persistWorktree = async (store, metadata, owner) => {
2487
+ const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
2488
+ const worktree = await store.worktree(owner);
2489
+ if (!existing)
2490
+ await eventPublisher.worktree(store, metadata, worktree);
2491
+ return worktree;
2492
+ };
2493
+ const lifecycleFor = (store, state, budget, metadata) => new RunLifecycle(state, async (next, previous, reason) => {
2494
+ if (next !== "pausing")
2495
+ budget.transition(next);
2496
+ const persisted = await persistRunState(store, metadata, (current) => {
2497
+ const nextRun = { ...current, state: next, ...budget.snapshot() };
1852
2498
  if (next === "running" || next === "completed")
1853
2499
  delete nextRun.error;
1854
2500
  return nextRun;
1855
2501
  });
1856
- runs.get(store.runId)?.update?.(workflowToolUpdate(run));
2502
+ await eventPublisher.runState(store, metadata, previous, next, reason);
2503
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1857
2504
  });
1858
2505
  const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
1859
2506
  const run = runs.get(runId);
1860
2507
  if (!run)
1861
2508
  throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
1862
2509
  try {
2510
+ const budget = run.budget.forAgent(id);
1863
2511
  const onProgress = async (progress) => {
1864
2512
  let runState;
1865
2513
  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);
2514
+ 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
2515
  }
1868
2516
  else {
1869
2517
  const loaded = await run.store.load();
1870
2518
  if (!loaded.run.agents.some((agent) => agent.id === id))
1871
2519
  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) };
2520
+ 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
2521
  }
1874
2522
  if (!runState.agents.some((agent) => agent.id === id))
1875
2523
  return;
1876
- run.update?.(workflowToolUpdate(runState));
2524
+ setLiveActivity(runId, id, progress.activity);
2525
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1877
2526
  };
1878
2527
  const onAttempt = async (attempt) => {
1879
2528
  await scheduler.flush();
2529
+ scheduler.attemptStarted(id);
2530
+ await scheduler.flush();
2531
+ const before = (await run.store.load()).run;
1880
2532
  await persistActiveAgentAttempt(run.store, id, attempt);
1881
- run.update?.(workflowToolUpdate((await run.store.load()).run));
2533
+ const active = (await run.store.load()).run;
2534
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2535
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2536
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1882
2537
  };
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); });
2538
+ 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); });
2539
+ const before = (await run.store.load()).run;
1884
2540
  await persistAgentAttempts(run.store, id, result.attempts);
2541
+ const completed = (await run.store.load()).run;
2542
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2543
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2544
+ setLiveActivity(runId, id);
2545
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1885
2546
  return result.value;
1886
2547
  }
1887
2548
  catch (error) {
1888
2549
  const attempts = error.attempts;
1889
- if (attempts?.length)
2550
+ if (attempts?.length) {
2551
+ const before = (await run.store.load()).run;
1890
2552
  await persistAgentAttempts(run.store, id, attempts);
2553
+ const failed = (await run.store.load()).run;
2554
+ await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2555
+ }
2556
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2557
+ setLiveActivity(runId, id);
2558
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1891
2559
  throw error;
1892
2560
  }
1893
2561
  }, 16, async (runId, ownership) => {
@@ -1895,7 +2563,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1895
2563
  if (!run)
1896
2564
  return;
1897
2565
  await run.store.saveOwnership(ownership);
1898
- const runState = await run.store.updateState((current) => {
2566
+ let previousAgents = [];
2567
+ const runState = await persistRunState(run.store, run.metadata, (current) => {
2568
+ previousAgents = current.agents;
1899
2569
  const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
1900
2570
  const agents = ownership.map((node) => {
1901
2571
  const previous = existing.get(node.id);
@@ -1905,14 +2575,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1905
2575
  effective = run.executor.resolve(requested);
1906
2576
  }
1907
2577
  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 };
2578
+ 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
2579
  }
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 } : {}) };
2580
+ 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
2581
  });
1912
2582
  return { ...current, agents };
1913
2583
  });
1914
- run.update?.(workflowToolUpdate(runState));
2584
+ await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2585
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1915
2586
  });
2587
+ const stopWorkflowRun = async (runId) => {
2588
+ const run = runs.get(runId);
2589
+ const terminalState = terminalRunStates.get(runId);
2590
+ if (!run)
2591
+ return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
2592
+ const state = run.lifecycle.state;
2593
+ if (state === "completed" || state === "failed" || state === "stopped")
2594
+ return { runId, state, stopped: false, reason: "already_terminal" };
2595
+ await run.lifecycle.terminal("stopped");
2596
+ run.abortController.abort();
2597
+ run.execution?.cancel();
2598
+ await scheduler.cancelRun(run.store.runId);
2599
+ await scheduler.flush();
2600
+ return { runId, state: "stopped", stopped: true };
2601
+ };
1916
2602
  const answerCheckpoint = async (runId, name, approved, silent = false) => {
1917
2603
  const run = runs.get(runId);
1918
2604
  if (!run)
@@ -1920,6 +2606,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1920
2606
  const checkpoint = await run.store.answerCheckpoint(name, approved);
1921
2607
  if (!checkpoint)
1922
2608
  return false;
2609
+ await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
1923
2610
  if ((await run.store.awaitingCheckpoints()).length === 0)
1924
2611
  await run.lifecycle.resolveAwaitingInput();
1925
2612
  run.checkpointResolvers.get(checkpoint.path)?.(approved);
@@ -1928,6 +2615,26 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1928
2615
  deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
1929
2616
  return true;
1930
2617
  };
2618
+ 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}.`;
2619
+ const appendBudgetDecisionEvent = async (run, request, type) => {
2620
+ 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) });
2621
+ await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2622
+ };
2623
+ const answerBudgetDecision = async (runId, proposalId, approved, silent = false) => {
2624
+ const run = runs.get(runId);
2625
+ if (!run)
2626
+ return undefined;
2627
+ const request = await run.store.answerWorkflowDecision(proposalId, approved);
2628
+ if (!request)
2629
+ return undefined;
2630
+ await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
2631
+ const result = await applyBudgetDecision(request, approved);
2632
+ run.budgetResolvers.get(proposalId)?.(result);
2633
+ run.budgetResolvers.delete(proposalId);
2634
+ if (!silent)
2635
+ deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
2636
+ return result;
2637
+ };
1931
2638
  const checkpointBridge = (runId, store, metadata, foreground, ui) => {
1932
2639
  const checkpointCounters = new Map();
1933
2640
  return async (raw, signal) => {
@@ -1940,6 +2647,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1940
2647
  const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
1941
2648
  if (replayed !== undefined)
1942
2649
  return replayed;
2650
+ if (!alreadyAwaiting)
2651
+ await eventPublisher.checkpoint(store, metadata, label, "awaiting");
1943
2652
  const run = runs.get(runId);
1944
2653
  await run?.lifecycle.enterAwaitingInput();
1945
2654
  if (!alreadyAwaiting && !ui?.select)
@@ -1965,7 +2674,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1965
2674
  if (!choice) {
1966
2675
  if (foreground)
1967
2676
  continue; // foreground: retry until answered
1968
- // Background resume: user dismissed UI, fall back to LLM
1969
2677
  deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
1970
2678
  return;
1971
2679
  }
@@ -1979,12 +2687,37 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1979
2687
  pi.registerTool({
1980
2688
  name: "workflow_respond",
1981
2689
  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 }),
2690
+ description: "Approve or reject one pending workflow checkpoint or budget decision",
2691
+ parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
1984
2692
  async execute(_id, params) {
1985
2693
  try {
2694
+ if (params.proposalId) {
2695
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
2696
+ if (!result) {
2697
+ const denied = { state: "budget_exhausted", approved: false, reason: "proposal_not_pending" };
2698
+ return { content: [{ type: "text", text: JSON.stringify(denied) }], details: denied };
2699
+ }
2700
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
2701
+ }
2702
+ if (!params.name)
2703
+ throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
1986
2704
  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 } };
2705
+ 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" } };
2706
+ }
2707
+ catch (error) {
2708
+ throw mainAgentError(error);
2709
+ }
2710
+ },
2711
+ });
2712
+ pi.registerTool({
2713
+ name: "workflow_stop",
2714
+ label: "Workflow Stop",
2715
+ description: "Stop an active workflow run by ID",
2716
+ parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
2717
+ async execute(_id, params) {
2718
+ try {
2719
+ const result = await stopWorkflowRun(params.runId);
2720
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
1988
2721
  }
1989
2722
  catch (error) {
1990
2723
  throw mainAgentError(error);
@@ -1997,7 +2730,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1997
2730
  if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
1998
2731
  return;
1999
2732
  const catalog = registry.catalog();
2000
- if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length)
2733
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
2734
+ if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases)
2001
2735
  return;
2002
2736
  pi.registerTool({
2003
2737
  name: "workflow_catalog",
@@ -2008,7 +2742,33 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2008
2742
  });
2009
2743
  catalogRegistered = true;
2010
2744
  };
2011
- const coldResumeRun = async (run, hasUI, ui, trustedProject) => {
2745
+ const refreshPausedRunAliases = async (run, context) => {
2746
+ const loaded = await run.store.load();
2747
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2748
+ const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2749
+ if (missing)
2750
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
2751
+ const settingsPath = workflowSettingsPath();
2752
+ const currentSettings = loadSettings(settingsPath);
2753
+ resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
2754
+ const currentAliases = currentSettings.modelAliases ?? {};
2755
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2756
+ const modelRegistry = context?.modelRegistry;
2757
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2758
+ if (context?.model)
2759
+ knownModels.add(`${context.model.provider}/${context.model.id}`);
2760
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2761
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2762
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2763
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2764
+ await run.store.saveSnapshot(snapshot);
2765
+ 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);
2766
+ run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
2767
+ const drift = aliasDrift(previousAliases, currentAliases);
2768
+ if (drift.length)
2769
+ await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2770
+ };
2771
+ const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
2012
2772
  const loaded = await run.store.load();
2013
2773
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
2014
2774
  throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
@@ -2019,14 +2779,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2019
2779
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
2020
2780
  if (missingRole)
2021
2781
  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"));
2782
+ const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
2023
2783
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2024
2784
  if (missing)
2025
2785
  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);
2786
+ const settingsPath = workflowSettingsPath();
2787
+ const currentSettings = loadSettings(settingsPath);
2788
+ resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
2789
+ const currentAliases = currentSettings.modelAliases ?? {};
2790
+ const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2791
+ const modelRegistry = context?.modelRegistry;
2792
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
2793
+ if (context?.model)
2794
+ knownModels.add(`${context.model.provider}/${context.model.id}`);
2795
+ const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
2796
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2797
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2798
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2799
+ 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);
2800
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2801
+ await run.store.saveSnapshot(snapshot);
2802
+ 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);
2803
+ const drift = aliasDrift(previousAliases, currentAliases);
2804
+ if (drift.length)
2805
+ await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
2027
2806
  const controller = new AbortController();
2028
2807
  run.abortController = controller;
2029
2808
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
2809
+ run.executor.setRunContext(runContext);
2030
2810
  let variables;
2031
2811
  try {
2032
2812
  variables = await resolveWorkflowVariables(runContext, controller, registry);
@@ -2034,8 +2814,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2034
2814
  catch (error) {
2035
2815
  const typed = asWorkflowError(error);
2036
2816
  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 } }));
2817
+ await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
2818
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
2819
+ await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
2820
+ run.update?.(workflowToolUpdate(persisted));
2039
2821
  }
2040
2822
  throw typed;
2041
2823
  }
@@ -2043,13 +2825,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2043
2825
  await run.lifecycle.resume();
2044
2826
  const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
2045
2827
  await run.lifecycle.enter();
2828
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
2829
+ const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
2046
2830
  try {
2047
- const path = agentIdentityPath(identity);
2831
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
2048
2832
  const replayed = await run.store.replay(path);
2049
- if (replayed)
2833
+ if (replayed) {
2834
+ if (conversationId) {
2835
+ const conversation = await run.store.conversation(conversationId);
2836
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
2837
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
2838
+ }
2050
2839
  return replayed.value;
2840
+ }
2841
+ if (conversationId) {
2842
+ if (conversationLocks.has(conversationLock))
2843
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
2844
+ const conversation = await run.store.conversation(conversationId);
2845
+ if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
2846
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
2847
+ conversationLocks.add(conversationLock);
2848
+ }
2051
2849
  const worktree = agentWorktree(identity);
2052
- const cwd = worktree.worktreeOwner ? (await run.store.worktree(worktree.worktreeOwner)).cwd : run.store.cwd;
2850
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
2053
2851
  const role = typeof options.role === "string" ? options.role : undefined;
2054
2852
  const model = typeof options.model === "string" ? options.model : undefined;
2055
2853
  const thinking = parseThinking(options.thinking);
@@ -2058,7 +2856,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2058
2856
  const label = displayAgentName(requestedLabel, role, resolved.model);
2059
2857
  const tools = resolved.tools;
2060
2858
  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 } : {}) });
2859
+ 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
2860
  const cancel = () => { scheduler.cancel(spawned.id); };
2063
2861
  signal.addEventListener("abort", cancel, { once: true });
2064
2862
  const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
@@ -2068,22 +2866,116 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2068
2866
  return outcome.value;
2069
2867
  }
2070
2868
  finally {
2869
+ if (conversationLock)
2870
+ conversationLocks.delete(conversationLock);
2071
2871
  await run.lifecycle.leave();
2072
2872
  }
2073
2873
  }, 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 }));
2874
+ let previousPhase;
2875
+ const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
2876
+ await eventPublisher.phase(run.store, run.metadata, previousPhase, phase);
2877
+ runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted));
2075
2878
  }
2076
2879
  finally {
2077
2880
  await run.lifecycle.leave();
2078
2881
  } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2079
2882
  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;
2883
+ const completion = execution.result.then(async (value) => {
2884
+ await scheduler.flush();
2885
+ if (run.budget.hardExhausted)
2886
+ throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2887
+ const resultPath = await run.store.saveResult(value);
2888
+ await run.lifecycle.terminal("completed", "completed");
2889
+ await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
2890
+ return { value, resultPath };
2891
+ }).catch(async (error) => {
2892
+ await scheduler.flush();
2893
+ const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
2894
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
2895
+ await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2896
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2897
+ const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2898
+ await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2899
+ run.update?.(workflowToolUpdate(persisted));
2900
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
2901
+ deliverFailure(pi, run.metadata.name, error);
2902
+ });
2085
2903
  void completion;
2086
2904
  };
2905
+ const applyBudgetDecision = async (request, approved) => {
2906
+ const run = runs.get(request.runId);
2907
+ if (!run)
2908
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
2909
+ if (!approved)
2910
+ return { state: "budget_exhausted", approved: false };
2911
+ const nextBudget = validateBudget(request.proposed);
2912
+ const nextVersion = request.budgetVersion + 1;
2913
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
2914
+ run.budget = runtime;
2915
+ await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
2916
+ next.budget = nextBudget;
2917
+ else
2918
+ delete next.budget; return next; });
2919
+ await coldResumeRun(run, false, {}, true);
2920
+ return { state: "running", approved: true };
2921
+ };
2922
+ const resumeWorkflowRun = async (runId, rawPatch) => {
2923
+ const run = runs.get(runId);
2924
+ if (!run)
2925
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2926
+ const loaded = await run.store.load();
2927
+ if (loaded.run.state !== "budget_exhausted")
2928
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2929
+ const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
2930
+ const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
2931
+ const nextBudget = mergeBudget(currentBudget, patch);
2932
+ const usage = budgetUsage(loaded.run.usage);
2933
+ if (!resumeBudgetAllowed(nextBudget, usage))
2934
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
2935
+ if (budgetRelaxed(currentBudget, nextBudget)) {
2936
+ const proposalId = randomUUID();
2937
+ const request = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
2938
+ const decision = new Promise((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
2939
+ try {
2940
+ await run.store.requestWorkflowDecision(request);
2941
+ await appendBudgetDecisionEvent(run, request, "adjustment_requested");
2942
+ }
2943
+ catch (error) {
2944
+ run.budgetResolvers.delete(proposalId);
2945
+ throw error;
2946
+ }
2947
+ deliver(pi, budgetDecisionDelivery(run.metadata, request));
2948
+ const decisionResult = await decision;
2949
+ return { state: decisionResult.state };
2950
+ }
2951
+ const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
2952
+ if (changed) {
2953
+ const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
2954
+ const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
2955
+ run.budget = runtime;
2956
+ await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
2957
+ next.budget = nextBudget;
2958
+ else
2959
+ delete next.budget; return next; });
2960
+ }
2961
+ await coldResumeRun(run, false, {}, true);
2962
+ return { state: "running" };
2963
+ };
2964
+ pi.registerTool({
2965
+ name: "workflow_resume",
2966
+ label: "Workflow Resume",
2967
+ description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
2968
+ parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
2969
+ async execute(_id, params) {
2970
+ try {
2971
+ const result = await resumeWorkflowRun(params.runId, params.budget);
2972
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
2973
+ }
2974
+ catch (error) {
2975
+ throw mainAgentError(error);
2976
+ }
2977
+ },
2978
+ });
2087
2979
  pi.on("session_start", async (_event, ctx) => {
2088
2980
  if (sessionStarted)
2089
2981
  return;
@@ -2105,22 +2997,32 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2105
2997
  await store.delete(true).catch(() => undefined);
2106
2998
  continue;
2107
2999
  }
2108
- if (["completed", "failed", "stopped"].includes(loaded.run.state))
3000
+ if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") {
3001
+ terminalRunStates.set(runId, loaded.run.state);
2109
3002
  continue;
2110
- if (loaded.run.state !== "interrupted") {
2111
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted"].includes(current.state) ? current : { ...current, state: "interrupted" });
3003
+ }
3004
+ if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
3005
+ const previousState = loaded.run.state;
3006
+ await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
3007
+ loaded = { ...loaded, run: (await store.load()).run };
3008
+ await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
2112
3009
  loaded = { ...loaded, run: (await store.load()).run };
2113
3010
  }
2114
3011
  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);
3012
+ const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
3013
+ eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
3014
+ const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
3015
+ const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
2116
3016
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
2117
3017
  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() });
3018
+ 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
3019
  for (const checkpoint of await store.awaitingCheckpoints())
2120
3020
  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() : []);
3021
+ for (const decision of await store.pendingWorkflowDecisions())
3022
+ deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
3023
+ scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
2122
3024
  }
2123
- const resumeSelect = ctx.ui?.select;
3025
+ const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
2124
3026
  if (ctx.hasUI && resumeSelect) {
2125
3027
  const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
2126
3028
  if (interrupted.length > 0) {
@@ -2131,7 +3033,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2131
3033
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
2132
3034
  for (const run of toResume) {
2133
3035
  try {
2134
- await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx));
3036
+ await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx);
2135
3037
  ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
2136
3038
  }
2137
3039
  catch (err) {
@@ -2164,18 +3066,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2164
3066
  parameters: WORKFLOW_TOOL_PARAMETERS,
2165
3067
  async execute(_id, params, signal, onUpdate, ctx) {
2166
3068
  try {
3069
+ const settingsPath = workflowSettingsPath();
3070
+ const defaults = loadSettings(settingsPath);
2167
3071
  if (!ctx.model)
2168
3072
  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 });
3073
+ const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
3074
+ const budget = validateBudget(params.budget);
2171
3075
  const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
2172
3076
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
2173
- 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");
3077
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
3078
+ const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
3079
+ knownModels.add(rootModelName);
3080
+ const availableModels = knownModels;
3081
+ const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
2177
3082
  const trustedProject = projectTrusted(ctx);
2178
- const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools) }, registry);
3083
+ if (typeof ctx.cwd === "string")
3084
+ resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
3085
+ const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
2179
3086
  const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
2180
3087
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
2181
3088
  const runId = randomUUID();
@@ -2191,28 +3098,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2191
3098
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2192
3099
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
2193
3100
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2194
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
3101
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2195
3102
  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");
3103
+ 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 });
3104
+ const budgetRuntime = new WorkflowBudgetRuntime(budget);
3105
+ const initialBudget = budgetRuntime.snapshot();
3106
+ 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);
3107
+ const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2199
3108
  const background = !params.foreground;
2200
3109
  const providerPause = async () => { if (background)
2201
3110
  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 } : {}) });
3111
+ 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);
3112
+ 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
3113
  if (params.foreground && onUpdate)
2205
3114
  onUpdate(workflowToolUpdate((await store.load()).run));
2206
- scheduler.addRun(runId, settings.concurrency, settings.maxAgentLaunches);
3115
+ scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2207
3116
  const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
2208
3117
  await lifecycle.enter();
3118
+ const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
3119
+ const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
2209
3120
  try {
2210
- const path = agentIdentityPath(identity);
3121
+ const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
2211
3122
  const replayed = await store.replay(path);
2212
- if (replayed)
3123
+ if (replayed) {
3124
+ if (conversationId) {
3125
+ const conversation = await store.conversation(conversationId);
3126
+ if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
3127
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
3128
+ }
2213
3129
  return replayed.value;
3130
+ }
3131
+ if (conversationId) {
3132
+ if (conversationLocks.has(conversationLock))
3133
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
3134
+ const conversation = await store.conversation(conversationId);
3135
+ if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
3136
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
3137
+ conversationLocks.add(conversationLock);
3138
+ }
2214
3139
  const worktree = agentWorktree(identity);
2215
- const cwd = worktree.worktreeOwner ? (await store.worktree(worktree.worktreeOwner)).cwd : ctx.cwd;
3140
+ const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
2216
3141
  const role = typeof options.role === "string" ? options.role : undefined;
2217
3142
  const model = typeof options.model === "string" ? options.model : undefined;
2218
3143
  const thinking = parseThinking(options.thinking);
@@ -2221,7 +3146,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2221
3146
  const label = displayAgentName(requestedLabel, role, resolved.model);
2222
3147
  const tools = resolved.tools;
2223
3148
  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 } : {}) });
3149
+ 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
3150
  const cancel = () => { scheduler.cancel(spawned.id); };
2226
3151
  if (agentSignal.aborted)
2227
3152
  cancel();
@@ -2234,29 +3159,47 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2234
3159
  return outcome.value;
2235
3160
  }
2236
3161
  finally {
3162
+ if (conversationLock)
3163
+ conversationLocks.delete(conversationLock);
2237
3164
  await lifecycle.leave();
2238
3165
  }
2239
3166
  }, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
2240
3167
  await lifecycle.enter();
2241
3168
  try {
2242
- const run = await store.updateState((current) => ({ ...current, phase }));
2243
- runs.get(runId)?.update?.(workflowToolUpdate(run));
3169
+ let previousPhase;
3170
+ const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
3171
+ await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
3172
+ runs.get(runId)?.update?.(workflowToolUpdate(persisted));
2244
3173
  }
2245
3174
  finally {
2246
3175
  await lifecycle.leave();
2247
3176
  }
2248
3177
  }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2249
3178
  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; });
3179
+ await eventPublisher.runStarted(store, checked.metadata);
3180
+ const finish = execution.result.then(async (value) => {
3181
+ await scheduler.flush();
3182
+ if (budgetRuntime.hardExhausted)
3183
+ throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
3184
+ const resultPath = await store.saveResult(value);
3185
+ await lifecycle.terminal("completed", "completed");
3186
+ await eventPublisher.runCompleted(store, checked.metadata, resultPath);
3187
+ return { value, resultPath };
3188
+ }).catch(async (error) => {
3189
+ await scheduler.flush();
3190
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
3191
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
3192
+ await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
3193
+ await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
3194
+ const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
3195
+ await eventPublisher.runFailed(store, checked.metadata, typed, state);
3196
+ throw typed;
3197
+ });
2254
3198
  runs.get(runId).completion = finish;
2255
3199
  if (background) {
2256
3200
  void finish.then(async ({ value, resultPath }) => {
2257
- emitAsyncComplete(store, "complete");
2258
3201
  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); });
3202
+ }, (error) => { deliverFailure(pi, checked.metadata.name, error); });
2260
3203
  return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2261
3204
  }
2262
3205
  const { value } = await finish;
@@ -2313,9 +3256,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2313
3256
  return entries.filter((entry) => entry !== undefined);
2314
3257
  };
2315
3258
  let stores = await loadStores();
2316
- const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
3259
+ 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
3260
  const setWorkflowStatus = (text) => {
2318
- const setStatus = ctx.ui.setStatus;
3261
+ const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
2319
3262
  setStatus?.call(ctx.ui, "workflow-stop", text);
2320
3263
  };
2321
3264
  const runAction = async (actionCommand, keepContext, status = setWorkflowStatus) => {
@@ -2329,6 +3272,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2329
3272
  ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
2330
3273
  return keepContext ? "dashboard" : "done";
2331
3274
  }
3275
+ if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
3276
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
3277
+ ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
3278
+ return keepContext ? "dashboard" : "done";
3279
+ }
2332
3280
  if (action === "delete" && stored) {
2333
3281
  if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
2334
3282
  ctx.ui.notify("Stop the workflow before deleting it.", "warning");
@@ -2338,6 +3286,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2338
3286
  return keepContext ? "dashboard" : "done";
2339
3287
  await stored.store.delete(true);
2340
3288
  runs.delete(stored.store.runId);
3289
+ terminalRunStates.delete(stored.store.runId);
2341
3290
  ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info");
2342
3291
  return keepContext ? "picker" : "done";
2343
3292
  }
@@ -2347,11 +3296,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2347
3296
  return keepContext ? "dashboard" : "done";
2348
3297
  }
2349
3298
  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");
3299
+ if (run.lifecycle.state === "budget_exhausted") {
3300
+ const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
3301
+ const result = await resumeWorkflowRun(run.store.runId, patch);
3302
+ 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");
3303
+ }
3304
+ else {
3305
+ if (run.lifecycle.state === "interrupted")
3306
+ await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
3307
+ else {
3308
+ if (run.lifecycle.state === "paused")
3309
+ await refreshPausedRunAliases(run, ctx);
3310
+ await run.lifecycle.resume();
3311
+ }
3312
+ ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
3313
+ }
3314
+ return keepContext ? "dashboard" : "done";
3315
+ }
3316
+ if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
3317
+ const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
3318
+ if (input === undefined)
3319
+ return keepContext ? "dashboard" : "done";
3320
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
3321
+ 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
3322
  return keepContext ? "dashboard" : "done";
2356
3323
  }
2357
3324
  if (action === "stop" && run) {
@@ -2360,11 +3327,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2360
3327
  return "dashboard";
2361
3328
  if (keepContext)
2362
3329
  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();
3330
+ await stopWorkflowRun(run.store.runId);
2368
3331
  if (keepContext)
2369
3332
  status(`Workflow ${run.store.runId} stopped.`);
2370
3333
  ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info");
@@ -2387,13 +3350,117 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2387
3350
  return "dashboard";
2388
3351
  }
2389
3352
  };
2390
- if (!command) {
3353
+ const manageAliases = async () => {
3354
+ const settingsPath = workflowSettingsPath();
3355
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
3356
+ const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
3357
+ const selectTarget = async () => {
3358
+ const models = available();
3359
+ const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
3360
+ if (!choice || choice === "Back")
3361
+ return undefined;
3362
+ if (choice !== "Manual model ID")
3363
+ return choice;
3364
+ return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
3365
+ };
3366
+ const save = (aliases) => {
3367
+ try {
3368
+ saveModelAliases(settingsPath, aliases);
3369
+ ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info");
3370
+ return true;
3371
+ }
3372
+ catch (error) {
3373
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3374
+ return false;
3375
+ }
3376
+ };
2391
3377
  for (;;) {
2392
- if (!stores.length) {
2393
- ctx.ui.notify("No workflow runs in this session.", "info");
3378
+ let aliases;
3379
+ try {
3380
+ aliases = loadSettings(settingsPath).modelAliases ?? {};
3381
+ }
3382
+ catch (error) {
3383
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3384
+ return;
3385
+ }
3386
+ const names = Object.keys(aliases).sort();
3387
+ const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
3388
+ const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
3389
+ const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
3390
+ if (!choice || choice === "Back")
2394
3391
  return;
3392
+ if (choice === "Add alias") {
3393
+ const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
3394
+ if (!name)
3395
+ continue;
3396
+ if (Object.prototype.hasOwnProperty.call(aliases, name)) {
3397
+ ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning");
3398
+ continue;
3399
+ }
3400
+ const target = await selectTarget();
3401
+ if (!target)
3402
+ continue;
3403
+ const next = { ...aliases, [name]: target };
3404
+ try {
3405
+ validateModelAliases(next, settingsPath);
3406
+ }
3407
+ catch (error) {
3408
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3409
+ continue;
3410
+ }
3411
+ const parsed = parseModelReference(target);
3412
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3413
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3414
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
3415
+ continue;
3416
+ }
3417
+ save(next);
3418
+ continue;
2395
3419
  }
3420
+ const edit = /^Edit (.+)$/.exec(choice);
3421
+ if (edit?.[1]) {
3422
+ const target = await selectTarget();
3423
+ if (!target)
3424
+ continue;
3425
+ const next = { ...aliases, [edit[1]]: target };
3426
+ try {
3427
+ validateModelAliases(next, settingsPath);
3428
+ }
3429
+ catch (error) {
3430
+ ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
3431
+ continue;
3432
+ }
3433
+ const parsed = parseModelReference(target);
3434
+ if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
3435
+ ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
3436
+ if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
3437
+ continue;
3438
+ }
3439
+ save(next);
3440
+ continue;
3441
+ }
3442
+ const deletion = /^Delete (.+)$/.exec(choice);
3443
+ if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
3444
+ const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
3445
+ save(next);
3446
+ }
3447
+ }
3448
+ };
3449
+ if (command === "model-aliases") {
3450
+ if (!ctx.hasUI) {
3451
+ ctx.ui.notify("Model alias management requires UI.", "warning");
3452
+ return;
3453
+ }
3454
+ await manageAliases();
3455
+ return;
3456
+ }
3457
+ if (!command) {
3458
+ for (;;) {
2396
3459
  if (!ctx.hasUI) {
3460
+ if (!stores.length) {
3461
+ ctx.ui.notify("No workflow runs in this session.", "info");
3462
+ return;
3463
+ }
2397
3464
  const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
2398
3465
  ctx.ui.notify(details.join("\n\n"), "info");
2399
3466
  return;
@@ -2402,10 +3469,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2402
3469
  const labels = navigatorRunLabels(sorted);
2403
3470
  const terminalStates = new Set(["completed", "failed", "stopped"]);
2404
3471
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2405
- const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : []), "Close"];
3472
+ const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
2406
3473
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
2407
3474
  if (!runChoice || runChoice === "Close")
2408
3475
  return;
3476
+ if (runChoice === "Model aliases") {
3477
+ await manageAliases();
3478
+ stores = await loadStores();
3479
+ continue;
3480
+ }
2409
3481
  if (runChoice === "Delete all completed") {
2410
3482
  if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone."))
2411
3483
  continue;
@@ -2413,6 +3485,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2413
3485
  if (entry.loaded.run.state === "completed") {
2414
3486
  await entry.store.delete(true);
2415
3487
  runs.delete(entry.store.runId);
3488
+ terminalRunStates.delete(entry.store.runId);
2416
3489
  }
2417
3490
  }
2418
3491
  ctx.ui.notify("Deleted all completed workflow runs.", "info");
@@ -2435,6 +3508,49 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2435
3508
  ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
2436
3509
  }
2437
3510
  };
3511
+ const openTranscript = async (transcript) => {
3512
+ try {
3513
+ const entries = SessionManager.open(transcript).buildContextEntries();
3514
+ if (ctx.mode !== "tui") {
3515
+ ctx.ui.notify(`Transcript: ${transcript}`, "info");
3516
+ return;
3517
+ }
3518
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
3519
+ let offset = 0;
3520
+ let renderedLines = [];
3521
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
3522
+ const move = (delta) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3523
+ return {
3524
+ render(width) {
3525
+ renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
3526
+ offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
3527
+ return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
3528
+ },
3529
+ invalidate() { },
3530
+ handleInput(data) {
3531
+ if (keybindings.matches(data, "tui.select.up"))
3532
+ move(-1);
3533
+ else if (keybindings.matches(data, "tui.select.down"))
3534
+ move(1);
3535
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3536
+ move(-viewport());
3537
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3538
+ move(viewport());
3539
+ else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
3540
+ offset = 0;
3541
+ else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
3542
+ offset = Math.max(0, renderedLines.length - viewport());
3543
+ else if (keybindings.matches(data, "tui.select.cancel"))
3544
+ done(undefined);
3545
+ tui.requestRender();
3546
+ },
3547
+ };
3548
+ }, { overlay: true });
3549
+ }
3550
+ catch (error) {
3551
+ ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
3552
+ }
3553
+ };
2438
3554
  const loadDashboard = async () => {
2439
3555
  const loaded = await store.load();
2440
3556
  const checkpoints = await store.awaitingCheckpoints();
@@ -2448,6 +3564,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2448
3564
  add("Pause", "pause");
2449
3565
  if (["paused", "interrupted"].includes(loaded.run.state))
2450
3566
  add("Resume", "resume");
3567
+ if (loaded.run.state === "budget_exhausted") {
3568
+ actions.set("Resume unchanged", `resume ${store.runId}`);
3569
+ actions.set("Adjust budget", `adjust ${store.runId}`);
3570
+ }
3571
+ for (const decision of await store.pendingWorkflowDecisions()) {
3572
+ const id = decision.proposalId.slice(0, 8);
3573
+ actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
3574
+ actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
3575
+ }
2451
3576
  if (!terminalStates.has(loaded.run.state))
2452
3577
  add("Stop", "stop");
2453
3578
  for (const cp of checkpoints) {
@@ -2466,21 +3591,78 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2466
3591
  else
2467
3592
  actions.set("View script", "view-script");
2468
3593
  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)
3594
+ if (loaded.run.agents.length)
3595
+ actions.set("Agents...", "agents");
3596
+ if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length)
2470
3597
  actions.set("View transcript", "view-transcript");
2471
- if (transcripts.length)
3598
+ if (!loaded.run.agents.length && transcripts.length)
2472
3599
  actions.set("Transcript paths", "transcripts");
2473
3600
  if (terminalStates.has(loaded.run.state))
2474
3601
  add("Delete", "delete");
2475
3602
  if (ctx.mode === "tui") {
2476
3603
  addCopy("Copy run path", store.directory, "run path");
2477
3604
  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");
3605
+ }
3606
+ return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
3607
+ };
3608
+ const selectAgent = async (dashboard) => {
3609
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
3610
+ const title = (agent) => {
3611
+ const parents = [];
3612
+ for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
3613
+ const parent = byId.get(parentId);
3614
+ if (!parent)
3615
+ break;
3616
+ parents.unshift(parent.label ?? parent.name);
3617
+ }
3618
+ return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
3619
+ };
3620
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
3621
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3622
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3623
+ const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3624
+ if (!selected)
3625
+ return;
3626
+ const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3627
+ const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
3628
+ const actions = [
3629
+ ...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
3630
+ ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3631
+ "Copy agent ID",
3632
+ "Back",
3633
+ ];
3634
+ const chooseAttempt = async () => {
3635
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3636
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
3637
+ const index = choice ? choices.indexOf(choice) : -1;
3638
+ return index >= 0 ? attempts[index] : undefined;
3639
+ };
3640
+ for (;;) {
3641
+ const action = await ctx.ui.select(title(selected), actions);
3642
+ if (!action || action === "Back")
3643
+ return;
3644
+ if (action === "Copy agent ID") {
3645
+ await copyArtifact(selected.id, "agent ID");
3646
+ continue;
3647
+ }
3648
+ if (action === "Copy branch" && worktree) {
3649
+ await copyArtifact(worktree.branch, "branch");
3650
+ continue;
3651
+ }
3652
+ if (action === "Copy worktree path" && worktree) {
3653
+ await copyArtifact(worktree.path, "worktree path");
3654
+ continue;
3655
+ }
3656
+ if (action === "View transcript" || action === "Copy transcript path") {
3657
+ const attempt = await chooseAttempt();
3658
+ if (!attempt)
3659
+ continue;
3660
+ if (action === "Copy transcript path")
3661
+ await copyArtifact(attempt.sessionFile, "transcript path");
3662
+ else
3663
+ await openTranscript(attempt.sessionFile);
2481
3664
  }
2482
3665
  }
2483
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
2484
3666
  };
2485
3667
  for (;;) {
2486
3668
  let view = await loadDashboard();
@@ -2493,11 +3675,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2493
3675
  let disposed = false;
2494
3676
  let stopRequested = false;
2495
3677
  let stopStatus;
2496
- const terminalRows = () => Math.max(1, (tui.terminal?.rows) ?? 24);
3678
+ const terminalRows = () => Math.max(1, tuiRows(tui));
2497
3679
  const keyLabels = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2498
3680
  const keyLabel = (binding, fallback) => {
2499
- const getKeys = keybindings.getKeys;
2500
- const keys = getKeys?.call(keybindings, binding);
3681
+ const keys = keybindingKeys(keybindings, binding);
2501
3682
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2502
3683
  };
2503
3684
  const dashboardLayout = () => {
@@ -2600,6 +3781,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2600
3781
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2601
3782
  if (!actionChoice || actionChoice === "Close")
2602
3783
  return;
3784
+ if (actionChoice === "Agents...") {
3785
+ await selectAgent(view);
3786
+ continue;
3787
+ }
2603
3788
  if (actionChoice === "Refresh")
2604
3789
  continue;
2605
3790
  if (actionChoice === "View script") {
@@ -2607,7 +3792,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2607
3792
  const highlighted = highlightCode(view.script, "javascript");
2608
3793
  let offset = 0;
2609
3794
  let renderedLines = [];
2610
- const viewport = () => Math.max(1, ((tui.terminal?.rows) ?? 24) - 3);
3795
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
2611
3796
  const move = (delta) => {
2612
3797
  const maxOffset = Math.max(0, renderedLines.length - viewport());
2613
3798
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
@@ -2644,55 +3829,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2644
3829
  }
2645
3830
  if (actionChoice === "View transcript") {
2646
3831
  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
- }
3832
+ if (transcript && transcript !== "Back")
3833
+ await openTranscript(transcript);
2696
3834
  continue;
2697
3835
  }
2698
3836
  if (actionChoice === "Transcript paths") {
@@ -2720,7 +3858,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2720
3858
  let offset = 0;
2721
3859
  let renderedLines = [];
2722
3860
  const layout = () => {
2723
- const rows = Math.max(1, ((tui.terminal?.rows) ?? 24));
3861
+ const rows = Math.max(1, tuiRows(tui));
2724
3862
  const compactControls = rows < 4;
2725
3863
  const titleRows = rows >= 5 ? 1 : 0;
2726
3864
  const hintRows = rows >= 8 ? 1 : 0;
@@ -2796,16 +3934,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2796
3934
  pi.on("session_shutdown", async () => {
2797
3935
  try {
2798
3936
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2799
- if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
3937
+ if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2800
3938
  await run.completion?.catch(() => undefined);
2801
3939
  return;
2802
3940
  }
2803
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
3941
+ if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2804
3942
  try {
2805
3943
  await run.lifecycle.terminal("interrupted");
2806
3944
  }
2807
3945
  catch (error) {
2808
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state))
3946
+ if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
2809
3947
  throw error;
2810
3948
  }
2811
3949
  run.abortController.abort();