pi-extensible-workflows 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +15 -17
  2. package/dist/src/agent-execution.d.ts +4 -80
  3. package/dist/src/agent-execution.js +20 -10
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +48 -2
  6. package/dist/src/doctor-cleanup.d.ts +41 -0
  7. package/dist/src/doctor-cleanup.js +597 -0
  8. package/dist/src/doctor.d.ts +7 -2
  9. package/dist/src/doctor.js +127 -22
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +83 -4
  12. package/dist/src/host.js +1246 -410
  13. package/dist/src/index.d.ts +5 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +3 -0
  16. package/dist/src/persistence.js +49 -1
  17. package/dist/src/registry.d.ts +21 -9
  18. package/dist/src/registry.js +131 -21
  19. package/dist/src/session-inspector.js +4 -2
  20. package/dist/src/types.d.ts +135 -7
  21. package/dist/src/utils.d.ts +6 -0
  22. package/dist/src/utils.js +45 -5
  23. package/dist/src/validation.d.ts +6 -2
  24. package/dist/src/validation.js +157 -31
  25. package/dist/src/workflow-artifacts.d.ts +13 -0
  26. package/dist/src/workflow-artifacts.js +39 -0
  27. package/examples/workflow-extension-template/README.md +37 -0
  28. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  29. package/examples/workflow-extension-template/index.js +51 -0
  30. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  31. package/package.json +5 -3
  32. package/skills/pi-extensible-workflows/SKILL.md +45 -18
  33. package/src/agent-execution.ts +23 -37
  34. package/src/cli.ts +33 -2
  35. package/src/doctor-cleanup.ts +337 -0
  36. package/src/doctor.ts +117 -24
  37. package/src/execution.ts +13 -4
  38. package/src/host.ts +1039 -366
  39. package/src/index.ts +5 -2
  40. package/src/persistence.ts +35 -1
  41. package/src/registry.ts +108 -25
  42. package/src/session-inspector.ts +4 -2
  43. package/src/types.ts +53 -8
  44. package/src/utils.ts +39 -5
  45. package/src/validation.ts +130 -31
  46. package/src/workflow-artifacts.ts +34 -0
package/dist/src/host.js CHANGED
@@ -1,22 +1,46 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { Type } from "@earendil-works/pi-ai";
7
8
  import { Value } from "typebox/value";
8
- import { copyToClipboard, getAgentDir, highlightCode, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
9
+ import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
9
10
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
10
11
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
11
12
  import { acquireSessionLease, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
12
13
  import { budgetRelaxed, budgetUsage, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
13
- import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
14
- import { launchScriptForSnapshot, loadAgentDefinitions, loadSettings, preflight, resolveAgentResourcePolicy, saveModelAliases, validateAgentOptions, validateCheckpoint, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowPrompt, workflowSettingsPath } from "./validation.js";
14
+ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelAliasErrorName, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
15
+ import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
15
16
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry } from "./registry.js";
16
17
  import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
18
+ import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact } from "./workflow-artifacts.js";
17
19
  import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError } from "./types.js";
18
20
  const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
21
+ const INTERNAL_WORKFLOW_TOOLS = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
22
+ const HARD_TERMINAL_RUN_STATES = new Set(["completed", "failed", "stopped"]);
23
+ const SHUTDOWN_TERMINAL_RUN_STATES = new Set(["completed", "failed", "stopped", "budget_exhausted"]);
24
+ function snapshotResourcePolicy(snapshot, cwd, projectTrusted, globalSettingsPath) {
25
+ const empty = { skills: [], extensions: [] };
26
+ return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
27
+ }
19
28
  const PLAIN_WORKFLOW_PROGRESS_STYLES = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
29
+ function workflowLaunchSettings(cwd, projectTrusted, globalSettingsPath, concurrency) {
30
+ const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
31
+ const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
32
+ return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
33
+ }
34
+ function frozenResourcePolicy(policy) { return () => structuredClone(policy); }
35
+ function resumedSnapshotSettings(snapshot, resolution, modelAliases) {
36
+ const settings = { ...snapshot.settings, concurrency: snapshot.settingsSources === undefined || snapshot.settingsSources.concurrency === "per-run options" ? snapshot.settings.concurrency : resolution.effective.concurrency, modelAliases };
37
+ if (resolution.effective.disabledAgentResources === undefined)
38
+ delete settings.disabledAgentResources;
39
+ else
40
+ settings.disabledAgentResources = resolution.effective.disabledAgentResources;
41
+ const settingsSources = snapshot.settingsSources === undefined ? undefined : { ...snapshot.settingsSources, modelAliases: resolution.sources.modelAliases, disabledAgentResources: resolution.sources.disabledAgentResources, concurrency: snapshot.settingsSources.concurrency === "per-run options" ? "per-run options" : resolution.sources.concurrency };
42
+ return { settings, ...(settingsSources === undefined ? {} : { settingsSources }) };
43
+ }
20
44
  const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
21
45
  function workflowDetail(message) {
22
46
  const detail = message.trim().replace(new RegExp(`\\b(?:${ERROR_CODES.join("|")})\\b:?\\s*`, "g"), "").replace(/^\s*[A-Z][A-Z0-9_]+:\s*/, "").split("\n").filter((line) => !/^\s*at\s/.test(line)).join("\n").replace(/^Run \S+(?=\s(?:exceeded|is))/i, "Run").replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "the workflow").replace(/^(?:Pi )session \S+(?=\s(?:is|has))/i, "session").replace(/^(Unknown scheduler run|Missing production ownership record|Persisted agent belongs to another run):\s*\S+/i, "$1").replace(/\b(?:runId|sessionId|callSite|occurrence|failedAt|id)[:=]\s*\S+/gi, "").replace(/\s{2,}/g, " ").trim();
@@ -62,7 +86,6 @@ function mainAgentError(error) {
62
86
  const typed = asWorkflowError(error);
63
87
  const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
64
88
  Object.assign(presented, typed);
65
- presented.message = formatWorkflowFailure(typed);
66
89
  return presented;
67
90
  }
68
91
  export class RunLifecycle {
@@ -125,7 +148,7 @@ export class RunLifecycle {
125
148
  await this.enter();
126
149
  }
127
150
  async terminal(state, reason) {
128
- if (["completed", "failed", "stopped"].includes(this.#state))
151
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state))
129
152
  throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
130
153
  await this.#set(state, reason ?? state);
131
154
  for (const resolve of this.#waiters.splice(0))
@@ -146,20 +169,337 @@ export function formatWorkflowPreview(args) {
146
169
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
147
170
  }
148
171
  export const WORKFLOW_TOOL_LABEL = "Workflow";
149
- export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
150
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Runs in the background by default; completion arrives as a follow-up message. Foreground results include the completed run ID. Use workflow_retry with an explicit failed run ID to replay completed structural operations; parentRunId only reuses named worktrees.";
172
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
173
+ export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
174
+ function workflowRecoveryGuidance(action, state) {
175
+ if (action === "resume") {
176
+ if (state === "failed")
177
+ return "Failed workflow runs must use workflow_retry({ runId })";
178
+ if (state === "completed")
179
+ return "Completed workflow runs have no recovery action";
180
+ if (state === "stopped")
181
+ return "Stopped workflow runs have no recovery action; launch a new workflow";
182
+ if (state === "interrupted")
183
+ return "Interrupted workflow runs use /workflow resume, not workflow_resume";
184
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
185
+ }
186
+ if (state === "budget_exhausted")
187
+ return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
188
+ if (state === "completed")
189
+ return "Completed workflow runs have no recovery action";
190
+ if (state === "stopped")
191
+ return "Stopped workflow runs cannot be retried; launch a new workflow";
192
+ if (state === "interrupted")
193
+ return "Interrupted workflow runs use /workflow resume, not workflow_retry";
194
+ return `Only failed workflow runs can be retried; source is ${state}`;
195
+ }
151
196
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
152
- name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow runs; invalid for registered function launches" })),
197
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
153
198
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
154
- script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
155
- workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
199
+ script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
200
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
156
201
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
157
- foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
158
- concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
159
- budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
160
- parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
202
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
203
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
204
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
205
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
161
206
  });
162
207
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
208
+ function phaseNames(source) {
209
+ const phases = source === undefined ? [] : Array.isArray(source) ? source : source.phases ?? [];
210
+ return phases.filter((phase) => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
211
+ }
212
+ function phaseAgentCounts(agents) {
213
+ const counts = { total: agents.length, completed: 0, running: 0, failed: 0, cancelled: 0, pending: 0 };
214
+ for (const agent of agents) {
215
+ if (agent.state === "completed")
216
+ counts.completed += 1;
217
+ else if (agent.state === "running")
218
+ counts.running += 1;
219
+ else if (agent.state === "failed")
220
+ counts.failed += 1;
221
+ else if (agent.state === "cancelled")
222
+ counts.cancelled += 1;
223
+ else
224
+ counts.pending += 1;
225
+ }
226
+ return counts;
227
+ }
228
+ function phaseState(runState, counts, isLatest) {
229
+ if (!isLatest)
230
+ return "completed";
231
+ if (runState === "failed")
232
+ return "failed";
233
+ if (runState === "stopped")
234
+ return "cancelled";
235
+ if (runState === "interrupted")
236
+ return "interrupted";
237
+ if (runState === "budget_exhausted")
238
+ return "budget_exhausted";
239
+ if (counts.failed > 0)
240
+ return "failed";
241
+ if (counts.cancelled > 0)
242
+ return "cancelled";
243
+ if (counts.running > 0 || counts.pending > 0)
244
+ return "running";
245
+ return runState === "completed" ? "completed" : "running";
246
+ }
247
+ export function buildWorkflowPhaseModel(run, source) {
248
+ const declaredPhases = phaseNames(source);
249
+ const rawHistory = Array.isArray(run.phaseHistory) ? run.phaseHistory : [];
250
+ const observed = [];
251
+ let boundary = 0;
252
+ for (const record of rawHistory) {
253
+ if (!object(record) || typeof record.phase !== "string" || !record.phase.trim() || typeof record.afterAgent !== "number" || !Number.isSafeInteger(record.afterAgent))
254
+ continue;
255
+ boundary = Math.max(boundary, Math.min(run.agents.length, Math.max(0, record.afterAgent)));
256
+ observed.push({ name: record.phase.trim(), afterAgent: boundary });
257
+ }
258
+ if (!observed.length && typeof run.phase === "string" && run.phase.trim())
259
+ observed.push({ name: run.phase.trim(), afterAgent: 0 });
260
+ const observedEntries = observed.map((entry, index) => ({ ...entry, index, agents: run.agents.slice(entry.afterAgent, observed[index + 1]?.afterAgent ?? run.agents.length) }));
261
+ const matchedDeclarations = new Set();
262
+ const declarationIndices = observedEntries.map((entry) => {
263
+ const index = declaredPhases.findIndex((name, candidate) => !matchedDeclarations.has(candidate) && name === entry.name);
264
+ if (index >= 0)
265
+ matchedDeclarations.add(index);
266
+ return index >= 0 ? index : undefined;
267
+ });
268
+ const entries = observedEntries.map((entry, index) => ({ name: entry.name, observedIndex: index, ...(declarationIndices[index] === undefined ? {} : { declarationIndex: declarationIndices[index] }) }));
269
+ for (const [declarationIndex, name] of declaredPhases.entries()) {
270
+ if (matchedDeclarations.has(declarationIndex))
271
+ continue;
272
+ const insertion = entries.findIndex((entry) => entry.declarationIndex !== undefined && entry.declarationIndex > declarationIndex);
273
+ const pending = { name };
274
+ if (insertion < 0)
275
+ entries.push(pending);
276
+ else
277
+ entries.splice(insertion, 0, pending);
278
+ }
279
+ const occurrences = new Map();
280
+ const phases = entries.map((entry) => {
281
+ const occurrence = (occurrences.get(entry.name) ?? 0) + 1;
282
+ occurrences.set(entry.name, occurrence);
283
+ const observation = entry.observedIndex === undefined ? undefined : observedEntries[entry.observedIndex];
284
+ const agents = observation?.agents ?? [];
285
+ const counts = phaseAgentCounts(agents);
286
+ const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
287
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
288
+ });
289
+ let currentPhaseIndex;
290
+ for (let index = phases.length - 1; index >= 0; index -= 1) {
291
+ if (phases[index]?.observed) {
292
+ currentPhaseIndex = index;
293
+ break;
294
+ }
295
+ }
296
+ const counts = {};
297
+ for (const phase of phases)
298
+ counts[phase.state] = (counts[phase.state] ?? 0) + 1;
299
+ const current = currentPhaseIndex === undefined ? undefined : phases[currentPhaseIndex];
300
+ const assigned = new Set(observedEntries.flatMap(({ agents }) => agents.map((agent) => agent.id)));
301
+ const unassignedAgents = run.agents.filter((agent) => !assigned.has(agent.id));
302
+ const result = { declaredPhases, phases, counts };
303
+ if (current !== undefined && currentPhaseIndex !== undefined) {
304
+ result.currentPhaseIndex = currentPhaseIndex;
305
+ result.currentPhaseId = current.id;
306
+ }
307
+ if (unassignedAgents.length)
308
+ result.unassignedAgents = unassignedAgents;
309
+ return result;
310
+ }
311
+ function workflowPhaseTreePath(kind, phaseId, operationPath, agentId) {
312
+ const root = `phase/${encodeURIComponent(phaseId)}`;
313
+ if (kind === "phase")
314
+ return root;
315
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
316
+ if (kind === "operation")
317
+ return `${root}/operation/${operation}`;
318
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
319
+ }
320
+ function workflowPhaseTreeAggregateState(states) {
321
+ if (!states.length || states.every((state) => state === "completed"))
322
+ return "completed";
323
+ if (states.some((state) => state === "failed"))
324
+ return "failed";
325
+ if (states.some((state) => state === "cancelled"))
326
+ return "cancelled";
327
+ if (states.some((state) => state === "running"))
328
+ return "running";
329
+ return "queued";
330
+ }
331
+ export function buildWorkflowPhaseTree(model) {
332
+ const drafts = new Map();
333
+ const roots = [];
334
+ const add = (node, parentId) => {
335
+ const existing = drafts.get(node.id);
336
+ if (existing)
337
+ return existing;
338
+ const draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
339
+ drafts.set(draft.id, draft);
340
+ if (parentId === undefined)
341
+ roots.push(draft.id);
342
+ else
343
+ drafts.get(parentId)?.children.push(draft.id);
344
+ return draft;
345
+ };
346
+ const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
347
+ const addPhase = (phaseId, label, agents, phase) => {
348
+ const phaseNode = add({ id: workflowPhaseTreePath("phase", phaseId, []), kind: "phase", label, depth: 0, phaseId, operationPath: [], state: phase?.state ?? workflowPhaseTreeAggregateState(agents.map((agent) => agent.state)), ...(phase ? { phase } : {}) });
349
+ const operationNodes = new Map();
350
+ const entries = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined, defaultParentId: phaseNode.id }));
351
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
352
+ const acceptedParents = new Map();
353
+ const wouldCycle = (childId, parentId) => {
354
+ const seen = new Set([childId]);
355
+ let current = parentId;
356
+ while (current) {
357
+ if (seen.has(current))
358
+ return true;
359
+ seen.add(current);
360
+ current = acceptedParents.get(current);
361
+ }
362
+ return false;
363
+ };
364
+ for (const entry of entries) {
365
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
366
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id))
367
+ acceptedParents.set(entry.agent.id, parent.agent.id);
368
+ }
369
+ const operationChain = (path, owner, startIndex = 0) => {
370
+ let parent = owner;
371
+ for (let index = startIndex; index < path.length; index += 1) {
372
+ const prefix = path.slice(0, index + 1);
373
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
374
+ const existing = operationNodes.get(key);
375
+ if (existing) {
376
+ parent = existing;
377
+ continue;
378
+ }
379
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
380
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
381
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
382
+ operationNodes.set(key, operation);
383
+ parent = operation;
384
+ }
385
+ return parent;
386
+ };
387
+ for (const entry of entries) {
388
+ if (!acceptedParents.has(entry.agent.id))
389
+ entry.defaultParentId = operationChain(entry.path, phaseNode).id;
390
+ }
391
+ for (const entry of entries) {
392
+ entry.node = add({ id: workflowPhaseTreePath("agent", phaseId, entry.path, entry.agent.id), kind: "agent", label: entry.agent.label ?? entry.agent.name, depth: 0, phaseId, operationPath: entry.path, state: entry.agent.state, agentId: entry.agent.id, agent: entry.agent }, phaseNode.id);
393
+ }
394
+ const attach = (entry, parentId) => {
395
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
396
+ if (previous)
397
+ previous.children = previous.children.filter((childId) => childId !== entry.node.id);
398
+ entry.node.parentId = parentId;
399
+ const parent = drafts.get(parentId);
400
+ if (parent && !parent.children.includes(entry.node.id))
401
+ parent.children.push(entry.node.id);
402
+ };
403
+ for (const entry of entries) {
404
+ const parentId = acceptedParents.get(entry.agent.id);
405
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
406
+ if (parent) {
407
+ if (samePath(entry.path, parent.path))
408
+ entry.defaultParentId = parent.node.id;
409
+ else {
410
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
411
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
412
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
413
+ }
414
+ }
415
+ attach(entry, entry.defaultParentId);
416
+ }
417
+ const setDepth = (node, depth, seen = new Set()) => {
418
+ if (seen.has(node.id))
419
+ return;
420
+ seen.add(node.id);
421
+ node.depth = depth;
422
+ for (const childId of node.children) {
423
+ const child = drafts.get(childId);
424
+ if (child)
425
+ setDepth(child, depth + 1, seen);
426
+ }
427
+ };
428
+ setDepth(phaseNode, 0);
429
+ const statesFor = (node, seen = new Set()) => {
430
+ if (seen.has(node.id))
431
+ return [];
432
+ const nextSeen = new Set(seen).add(node.id);
433
+ return node.children.flatMap((childId) => {
434
+ const child = drafts.get(childId);
435
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
436
+ });
437
+ };
438
+ for (const operation of operationNodes.values())
439
+ operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
440
+ };
441
+ for (const phase of model.phases)
442
+ addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
443
+ if (model.unassignedAgents?.length)
444
+ addPhase("unassigned", "Unassigned", model.unassignedAgents);
445
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
446
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
447
+ }
448
+ export function workflowPhaseTreeVisibleNodes(tree, expanded = new Set()) {
449
+ const visible = [];
450
+ const visit = (id) => {
451
+ const node = tree.byId.get(id);
452
+ if (!node)
453
+ return;
454
+ visible.push(node);
455
+ if (expanded.has(node.id))
456
+ for (const childId of node.children)
457
+ visit(childId);
458
+ };
459
+ for (const root of tree.roots)
460
+ visit(root);
461
+ return visible;
462
+ }
463
+ export function workflowPhaseTreeInitialExpanded(tree) {
464
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
465
+ }
466
+ export function preserveWorkflowPhaseTreeSelection(tree, selection) {
467
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
468
+ return node ? { nodeId: node.id } : {};
469
+ }
470
+ export function navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, direction) {
471
+ const expanded = new Set(expandedNodeIds);
472
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
473
+ if (!current)
474
+ return { expandedNodeIds: expanded };
475
+ if (direction === "left") {
476
+ if (current.children.length && expanded.delete(current.id))
477
+ return { nodeId: current.id, expandedNodeIds: expanded };
478
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
479
+ }
480
+ if (direction === "right") {
481
+ if (current.children.length && !expanded.has(current.id)) {
482
+ expanded.add(current.id);
483
+ return { nodeId: current.id, expandedNodeIds: expanded };
484
+ }
485
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
486
+ }
487
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
488
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
489
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
490
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
491
+ }
492
+ export function preserveWorkflowPhaseSelection(model, selection) {
493
+ const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
494
+ if (!phase)
495
+ return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
496
+ const tree = buildWorkflowPhaseTree(model);
497
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
498
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
499
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
500
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
501
+ return { phaseId: phase.id, ...(selection.agentId && phase.agents.some((agent) => agent.id === selection.agentId) ? { agentId: selection.agentId } : phase.agents[0] ? { agentId: phase.agents[0].id } : {}), ...(nodeId ? { nodeId } : {}), ...(selection.expandedNodeIds ? { expandedNodeIds: selection.expandedNodeIds } : {}) };
502
+ }
163
503
  function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
164
504
  function agentGroupLabel(agents) {
165
505
  const structural = agents[0]?.structuralPath ?? [];
@@ -171,8 +511,13 @@ function agentGroups(agents, allAgents = agents) {
171
511
  const groups = new Map();
172
512
  for (const [index, agent] of agents.entries()) {
173
513
  let depth = 0;
174
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
514
+ const seen = new Set([agent.id]);
515
+ for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) {
516
+ if (seen.has(parent))
517
+ break;
518
+ seen.add(parent);
175
519
  depth += 1;
520
+ }
176
521
  const key = agentGroupKey(agent);
177
522
  const group = groups.get(key) ?? { agents: [] };
178
523
  group.agents.push({ agent, index, depth });
@@ -188,26 +533,31 @@ function renderGroupedAgents(agents, render, allAgents = agents, groupLabel = (l
188
533
  ...group.entries.map((entry) => render(entry, grouped)),
189
534
  ]);
190
535
  }
191
- function progressStyleForState(state, styles) {
192
- if (state === "completed")
193
- return (text) => styles.success(text);
194
- if (state === "failed" || state === "cancelled")
195
- return (text) => styles.error(text);
196
- if (state === "running")
197
- return (text) => styles.accent(text);
198
- return (text) => styles.muted(text);
536
+ const RUN_STATE_GLYPH = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
537
+ const AGENT_STATE_GLYPH = { completed: "✓", failed: "✗", cancelled: "✗" };
538
+ function runStateGlyph(state, running) { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
539
+ function agentStateGlyph(state, running) { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? ""; }
540
+ const PROGRESS_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
541
+ const WORKFLOW_ICON_STYLE = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
542
+ const PHASE_STATE_STYLE = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
543
+ function styleForState(map, state, styles) {
544
+ const key = map[state] ?? "muted";
545
+ return (text) => styles[key](text);
199
546
  }
547
+ function progressStyleForState(state, styles) { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
548
+ function workflowIconStyle(state, styles) { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
549
+ function phaseStyleForState(state, styles) { return styleForState(PHASE_STATE_STYLE, state, styles); }
200
550
  export function formatWorkflowProgress(run, spinner = "◇", styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
201
551
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
202
- const workflowIcon = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆";
203
- const workflowIconStyle = run.state === "completed" ? (text) => styles.success(text) : run.state === "failed" || run.state === "stopped" ? (text) => styles.error(text) : run.state === "budget_exhausted" ? (text) => styles.warning(text) : run.state === "running" ? (text) => styles.accent(text) : (text) => styles.muted(text);
552
+ const workflowIcon = runStateGlyph(run.state, spinner);
553
+ const iconStyle = workflowIconStyle(run.state, styles);
204
554
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
205
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
555
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
206
556
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
207
557
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
208
558
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
209
559
  const renderAgents = (agents, offset, nested) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
210
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
560
+ const icon = agentStateGlyph(agent.state, spinner);
211
561
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
212
562
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
213
563
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
@@ -239,7 +589,200 @@ function textBlock(text) {
239
589
  invalidate() { },
240
590
  };
241
591
  }
242
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
592
+ function styledTextBlock(text) {
593
+ return {
594
+ render(width) {
595
+ return truncateWorkflowProgress(text, width);
596
+ },
597
+ invalidate() { },
598
+ };
599
+ }
600
+ function workflowCatalogBlock(text, expanded) {
601
+ return {
602
+ render(width) {
603
+ const safeWidth = Math.max(1, width);
604
+ if (!expanded)
605
+ return truncateWorkflowProgress(text, safeWidth);
606
+ return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines.map((line) => line.trimEnd());
607
+ },
608
+ invalidate() { },
609
+ };
610
+ }
611
+ function controlString(value) { return typeof value === "string" && value.trim() ? value : undefined; }
612
+ function controlValue(value) {
613
+ if (value === null)
614
+ return "removed";
615
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
616
+ return String(value);
617
+ const json = JSON.stringify(value);
618
+ return typeof json === "string" ? json : "unknown";
619
+ }
620
+ function controlTitle(name, theme) { return theme.fg("toolTitle", theme.bold(name)); }
621
+ function controlState(state, theme) {
622
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
623
+ return theme.fg(color, state);
624
+ }
625
+ function controlAction(action, theme) {
626
+ const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
627
+ return theme.fg(color, action);
628
+ }
629
+ function budgetPatchEntries(value) {
630
+ if (!object(value))
631
+ return value === undefined ? [] : [controlValue(value)];
632
+ return Object.entries(value).map(([dimension, limits]) => {
633
+ if (limits === null)
634
+ return `${dimension}=removed`;
635
+ if (!object(limits))
636
+ return `${dimension}=${controlValue(limits)}`;
637
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
638
+ return `${dimension} ${parts.join(" ")}`;
639
+ });
640
+ }
641
+ function budgetPatchSummary(value) {
642
+ const entries = budgetPatchEntries(value);
643
+ return entries.length ? entries.join(", ") : "unchanged";
644
+ }
645
+ function budgetPatchDetails(value, theme) {
646
+ const entries = budgetPatchEntries(value);
647
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
648
+ }
649
+ function workflowControlValue(result) { return catalogResultValue(result); }
650
+ function workflowControlCall(name, args, theme) {
651
+ const runId = controlString(args.runId) ?? "(missing run ID)";
652
+ if (name === "workflow_respond") {
653
+ const proposalId = controlString(args.proposalId);
654
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
655
+ const decision = args.approved === true ? "approve" : "reject";
656
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
657
+ }
658
+ if (name === "workflow_resume")
659
+ return args.budget === undefined ? `${controlTitle(name, theme)} ${theme.fg("accent", runId)}` : [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", "Budget")} ${theme.fg("toolOutput", budgetPatchSummary(args.budget))}`].join("\n");
660
+ if (name === "workflow_retry")
661
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
662
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
663
+ }
664
+ function workflowControlResult(name, args, result, expanded, theme, isError) {
665
+ if (isError) {
666
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
667
+ return theme.fg("error", text || `The ${name} tool failed.`);
668
+ }
669
+ const value = workflowControlValue(result);
670
+ if (!object(value))
671
+ return theme.fg("error", `The ${name} tool returned an invalid result.`);
672
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
673
+ const title = controlTitle(name, theme);
674
+ if (name === "workflow_stop") {
675
+ const state = controlString(value.state) ?? "unknown";
676
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
677
+ if (!expanded)
678
+ return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
679
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(controlString(value.reason) ? [`Reason: ${theme.fg("toolOutput", controlValue(value.reason))}`] : [])].join("\n");
680
+ }
681
+ if (name === "workflow_retry") {
682
+ const childRunId = controlString(value.runId) ?? "(unknown)";
683
+ const state = controlString(value.state) ?? "unknown";
684
+ if (!expanded)
685
+ return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
686
+ return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
687
+ }
688
+ if (name === "workflow_resume") {
689
+ const state = controlString(value.state) ?? "unknown";
690
+ const proposalId = controlString(value.proposalId);
691
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
692
+ if (!expanded)
693
+ return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
694
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal: ${theme.fg("accent", proposalId)}`] : []), ...budgetPatchDetails(args.budget, theme)].join("\n");
695
+ }
696
+ const proposalId = controlString(args.proposalId);
697
+ const checkpointName = controlString(args.name);
698
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
699
+ const accepted = value.accepted === true;
700
+ const approved = value.approved === true;
701
+ const reason = controlString(value.reason);
702
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
703
+ const state = controlString(value.state);
704
+ if (!expanded)
705
+ return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
706
+ return [title, `Run: ${theme.fg("accent", runId)}`, `Target: ${target}`, `Action: ${controlAction(action, theme)}`, ...(state ? [`State: ${controlState(state, theme)}`] : []), ...(reason ? [`Reason: ${theme.fg("toolOutput", reason)}`] : [])].join("\n");
707
+ }
708
+ function catalogText(value) { return value.replace(/\s+/g, " ").trim(); }
709
+ function catalogResultValue(result) {
710
+ if (result.details !== undefined)
711
+ return result.details;
712
+ const text = result.content?.find((entry) => entry.type === "text")?.text;
713
+ if (!text)
714
+ return undefined;
715
+ try {
716
+ return JSON.parse(text);
717
+ }
718
+ catch {
719
+ return text;
720
+ }
721
+ }
722
+ function isCatalogIndex(value) {
723
+ return object(value) && Array.isArray(value.functions) && Array.isArray(value.variables);
724
+ }
725
+ function isCatalogFunction(value) {
726
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.input) && object(value.output);
727
+ }
728
+ function isCatalogVariable(value) {
729
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.schema);
730
+ }
731
+ function isCatalogError(value) {
732
+ return object(value) && object(value.error) && typeof value.error.message === "string";
733
+ }
734
+ function catalogSectionTitle(label, count, theme) {
735
+ return theme.fg("accent", theme.bold(`${label} (${String(count)})`));
736
+ }
737
+ function catalogIndexEntries(entries, theme) {
738
+ const width = Math.max(0, ...entries.map((entry) => entry.name.length));
739
+ return entries.map((entry) => ` ${theme.fg("accent", entry.name.padEnd(width))} ${theme.fg("toolOutput", catalogText(entry.description))}`);
740
+ }
741
+ function formatCatalogIndex(catalog, theme) {
742
+ const aliases = Object.prototype.propertyIsEnumerable.call(catalog, "modelAliases") ? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static", provenance: "settings" })) : catalog.modelAliasEntries ?? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static", provenance: "settings" }));
743
+ const aliasWidth = Math.max(0, ...aliases.map(({ name }) => name.length));
744
+ const aliasLines = aliases.map(({ name, kind, provenance }) => ` ${theme.fg("accent", name.padEnd(aliasWidth))} ${theme.fg("toolOutput", `${kind} · ${provenance}`)}`);
745
+ return [
746
+ catalogSectionTitle("Functions", catalog.functions.length, theme),
747
+ ...catalogIndexEntries(catalog.functions, theme),
748
+ "",
749
+ catalogSectionTitle("Variables", catalog.variables.length, theme),
750
+ ...catalogIndexEntries(catalog.variables, theme),
751
+ "",
752
+ catalogSectionTitle("Model aliases", aliases.length, theme),
753
+ ...aliasLines,
754
+ ].join("\n");
755
+ }
756
+ function catalogSchemaLines(schema, theme) {
757
+ const json = JSON.stringify(schema, null, 2);
758
+ return json.split("\n").map((line) => ` ${theme.fg("toolOutput", line)}`);
759
+ }
760
+ function formatCatalogDetail(value, expanded, theme) {
761
+ if ("kind" in value)
762
+ return [theme.fg("accent", theme.bold("Model alias")), ` ${theme.fg("accent", value.name)} ${theme.fg("toolOutput", `${value.kind} · ${value.provenance}`)}`].join("\n");
763
+ const kind = "input" in value ? "Function" : "Variable";
764
+ if (!expanded)
765
+ return [theme.fg("accent", theme.bold(kind)), ` ${theme.fg("accent", value.name)} ${theme.fg("toolOutput", catalogText(value.description))}`, ` ${theme.fg("muted", "version")}: ${theme.fg("toolOutput", value.version)} ${theme.fg("muted", "headline")}: ${theme.fg("toolOutput", catalogText(value.headline))}`].join("\n");
766
+ const lines = [theme.fg("accent", theme.bold(`${kind}: ${value.name}`)), `${theme.fg("muted", "description")}: ${theme.fg("toolOutput", value.description)}`, "", theme.fg("accent", theme.bold("Extension")), ` ${theme.fg("muted", "version")}: ${theme.fg("toolOutput", value.version)}`, ` ${theme.fg("muted", "headline")}: ${theme.fg("toolOutput", value.headline)}`, ` ${theme.fg("muted", "description")}: ${theme.fg("toolOutput", value.extensionDescription)}`, "", theme.fg("accent", theme.bold("Schema"))];
767
+ if ("input" in value)
768
+ lines.push(theme.fg("muted", "Input schema"), ...catalogSchemaLines(value.input, theme), "", theme.fg("muted", "Output schema"), ...catalogSchemaLines(value.output, theme));
769
+ else
770
+ lines.push(theme.fg("muted", "Variable schema"), ...catalogSchemaLines(value.schema, theme));
771
+ return lines.join("\n");
772
+ }
773
+ function formatWorkflowCatalog(value, expanded, theme) {
774
+ if (isCatalogIndex(value))
775
+ return formatCatalogIndex(value, theme);
776
+ if (isCatalogFunction(value) || isCatalogVariable(value))
777
+ return formatCatalogDetail(value, expanded, theme);
778
+ if (object(value) && typeof value.name === "string" && (value.kind === "static" || value.kind === "dynamic"))
779
+ return formatCatalogDetail(value, expanded, theme);
780
+ if (isCatalogError(value))
781
+ return theme.fg("error", value.error.message);
782
+ return theme.fg("error", "The workflow catalog returned an invalid result.");
783
+ }
784
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
785
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
243
786
  export function truncateWorkflowProgress(text, width) {
244
787
  const safeWidth = Math.max(1, width);
245
788
  return text.split("\n").flatMap((line) => {
@@ -263,7 +806,7 @@ function themeWorkflowProgressStyles(theme) {
263
806
  warning: (text) => theme.fg("warning", text),
264
807
  muted: (text) => theme.fg("muted", text),
265
808
  dim: (text) => theme.fg("dim", text),
266
- bold: (text) => theme.bold(text),
809
+ bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
267
810
  };
268
811
  }
269
812
  function workflowProgressBlock(run, theme) {
@@ -310,39 +853,40 @@ function navigatorRunLabels(entries) {
310
853
  nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
311
854
  return entries.map(({ store, loaded: { run } }) => {
312
855
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
313
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
856
+ const glyph = runStateGlyph(run.state, "⠦");
314
857
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
315
858
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
316
859
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
317
860
  return `${glyph} ${run.workflowName}${suffix} ${run.state} ${run.phase ?? ""} ${String(done)}/${String(run.agents.length)} agents${costStr}`;
318
861
  });
319
862
  }
320
- function agentBreadcrumbParts(agent, byId) {
321
- const name = agent.label ?? agent.name;
322
- const parts = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
863
+ export function agentBreadcrumbParts(agent, byId, includeStructuralPath = false) {
864
+ const leaf = agent.label ?? agent.name;
865
+ const parts = includeStructuralPath && agent.structuralPath?.length ? [agent.structuralPath.join(" > ")] : [];
866
+ if (agent.parentBreadcrumb)
867
+ parts.push(agent.parentBreadcrumb);
868
+ const ancestors = [];
323
869
  const seen = new Set([agent.id]);
324
870
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
325
871
  if (seen.has(parentId))
326
- break; // ponytail: cycle guard for corrupt data
872
+ break;
327
873
  seen.add(parentId);
328
874
  const parent = byId.get(parentId);
329
- if (parent)
330
- parts.push(parent.label ?? parent.name);
331
- else
875
+ if (!parent)
332
876
  break;
877
+ ancestors.push(parent.label ?? parent.name);
333
878
  }
334
- parts.push(name);
879
+ parts.push(...ancestors.reverse(), leaf);
335
880
  return parts;
336
881
  }
337
- function agentBreadcrumb(agent, byId) {
338
- const parts = agentBreadcrumbParts(agent, byId);
339
- return parts.length > 1 ? parts.join(" > ") : parts[0] ?? "";
882
+ export function agentBreadcrumb(agent, byId, includeStructuralPath = false) {
883
+ return agentBreadcrumbParts(agent, byId, includeStructuralPath).join(" > ");
340
884
  }
341
885
  function styledAgentBreadcrumb(agent, byId, styles) {
342
886
  const parts = agentBreadcrumbParts(agent, byId);
343
887
  if (parts.length <= 1)
344
888
  return parts[0] ?? "";
345
- return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${parts[parts.length - 1] ?? ""}`;
889
+ return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
346
890
  }
347
891
  function formatAgentActivity(agent, spinner, styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
348
892
  const label = agent.activity?.kind === "reasoning" ? "reasoning" : agent.activity?.kind === "text" ? "responding" : agent.activity?.kind === "tool" ? agent.activity.text : [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running")?.name ?? "";
@@ -357,7 +901,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
357
901
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
358
902
  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 });
359
903
  const hasAccounting = run.agents.some((a) => a.accounting);
360
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
904
+ const glyph = runStateGlyph(run.state, "⠦");
361
905
  const header = `${glyph} ${run.workflowName}`;
362
906
  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(" · ");
363
907
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -368,7 +912,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
368
912
  lines.push("");
369
913
  const byId = new Map(run.agents.map((a) => [a.id, a]));
370
914
  const render = ({ agent, depth }, grouped) => {
371
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
915
+ const icon = agentStateGlyph(agent.state, "⠦");
372
916
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
373
917
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
374
918
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -391,7 +935,7 @@ export function formatNavigatorDashboard(run, checkpoints, worktrees) {
391
935
  }
392
936
  return lines.join("\n");
393
937
  }
394
- export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
938
+ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
395
939
  const { run, snapshot } = loaded;
396
940
  const lines = [
397
941
  `Workflow: ${run.workflowName}`,
@@ -401,6 +945,7 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
401
945
  `Launch cwd: ${run.cwd}`,
402
946
  ...formatCompactBudgetStatus(run),
403
947
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
948
+ `Settings: concurrency=${String(snapshot.settings.concurrency)}`,
404
949
  ];
405
950
  if (run.error)
406
951
  lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
@@ -409,6 +954,8 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
409
954
  const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
410
955
  if (aliases && Object.keys(aliases).length)
411
956
  lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
957
+ if (snapshot.settingsSources)
958
+ lines.push(`Settings sources: concurrency=${snapshot.settingsSources.concurrency}, modelAliases=${snapshot.settingsSources.modelAliases}, disabledAgentResources=${snapshot.settingsSources.disabledAgentResources}`);
412
959
  lines.push("Agents / ownership:");
413
960
  if (!run.agents.length)
414
961
  lines.push(" (none)");
@@ -431,10 +978,115 @@ export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
431
978
  lines.push(" (none)");
432
979
  for (const checkpoint of checkpoints)
433
980
  lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
434
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
981
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
435
982
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
436
983
  return lines.join("\n");
437
984
  }
985
+ export function formatWorkflowPhaseDashboard(run, snapshot, width, selection = {}, styles = PLAIN_WORKFLOW_PROGRESS_STYLES) {
986
+ const safeWidth = Math.max(1, width);
987
+ const model = buildWorkflowPhaseModel(run, snapshot);
988
+ const tree = buildWorkflowPhaseTree(model);
989
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
990
+ const wrap = (text, limit = safeWidth) => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
991
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
992
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
993
+ const visibleLength = (text) => text.replace(ansiPattern, "").length;
994
+ const padTo = (text, limit) => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
995
+ const phaseStyle = (state) => phaseStyleForState(state, styles);
996
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
997
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
998
+ const selectedNode = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? selectedByAgent ?? (phase ? tree.byId.get(workflowPhaseTreePath("phase", phase.id, [])) : undefined) ?? (model.currentPhaseId ? tree.byId.get(workflowPhaseTreePath("phase", model.currentPhaseId, [])) : undefined) ?? tree.nodes[0];
999
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
1000
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
1001
+ const nodeAgents = (node) => {
1002
+ const agents = [];
1003
+ const visit = (id) => {
1004
+ const child = tree.byId.get(id);
1005
+ if (!child)
1006
+ return;
1007
+ if (child.agent)
1008
+ agents.push(child.agent);
1009
+ else
1010
+ for (const childId of child.children)
1011
+ visit(childId);
1012
+ };
1013
+ if (node.agent)
1014
+ agents.push(node.agent);
1015
+ else
1016
+ for (const childId of node.children)
1017
+ visit(childId);
1018
+ return agents;
1019
+ };
1020
+ const nodeStatus = (node) => phaseStyle(node.state)(node.state);
1021
+ const nodeIcon = (node) => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
1022
+ const treeLine = (node) => {
1023
+ const selected = node.id === selectedNode?.id;
1024
+ const state = progressStyleForState(node.state, styles);
1025
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
1026
+ };
1027
+ const details = (node) => {
1028
+ if (!node)
1029
+ return [styles.muted("No workflow node is selected")];
1030
+ const agents = nodeAgents(node);
1031
+ if (node.kind === "phase") {
1032
+ const selected = node.phase;
1033
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
1034
+ return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`];
1035
+ }
1036
+ if (node.kind === "operation") {
1037
+ const states = phaseAgentCounts(agents);
1038
+ return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`];
1039
+ }
1040
+ const agent = node.agent;
1041
+ if (!agent)
1042
+ return [styles.muted("Agent details are unavailable")];
1043
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
1044
+ const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
1045
+ const error = agent.attemptDetails?.at(-1)?.error;
1046
+ if (error)
1047
+ result.push(styles.error(`Error: ${error.code}: ${error.message}`));
1048
+ if (agent.activity)
1049
+ result.push(`Activity: ${agent.activity.text}`);
1050
+ return result;
1051
+ };
1052
+ const stateNames = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
1053
+ const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
1054
+ const lines = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
1055
+ if (run.error)
1056
+ lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
1057
+ lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
1058
+ for (const event of run.events ?? [])
1059
+ lines.push(styles.warning(`Warning: ${event.message}`));
1060
+ lines.push(...formatCompactBudgetStatus(run));
1061
+ const renderTree = (limit) => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
1062
+ const actionRows = () => {
1063
+ const actions = selection.actions;
1064
+ if (!actions)
1065
+ return [];
1066
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
1067
+ };
1068
+ const detailRows = () => [...details(selectedNode), ...actionRows()];
1069
+ if (safeWidth >= 80) {
1070
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
1071
+ const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
1072
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
1073
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
1074
+ const rows = Math.max(sidebar.length, detail.length);
1075
+ for (let index = 0; index < rows; index += 1)
1076
+ lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
1077
+ }
1078
+ else if (selection.detailsOnly) {
1079
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
1080
+ }
1081
+ else {
1082
+ lines.push(...renderTree(safeWidth));
1083
+ if (!selection.treeOnly)
1084
+ lines.push("", ...detailRows().flatMap((line) => wrap(line)));
1085
+ }
1086
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned"))
1087
+ lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
1088
+ return lines.flatMap((line) => wrap(line));
1089
+ }
438
1090
  function formatCheckpointReview(checkpoint) {
439
1091
  const context = JSON.stringify(checkpoint.context, null, 2);
440
1092
  return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
@@ -920,42 +1572,70 @@ function projectTrusted(ctx) {
920
1572
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
921
1573
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
922
1574
  }
923
- function isEntryRenderer(value) { return typeof value === "function"; }
1575
+ function asFn(value) { return typeof value === "function" ? value : undefined; }
924
1576
  function isWorkflowEventSink(value) { return object(value) && typeof value.emit === "function"; }
925
1577
  function piHostCapabilities(pi) {
926
1578
  if (!object(pi))
927
1579
  return {};
928
- const registerEntryRenderer = pi.registerEntryRenderer;
1580
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer);
929
1581
  const events = pi.events;
930
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1582
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
931
1583
  }
932
- function isModelRegistryGetter(value) { return typeof value === "function"; }
933
1584
  function contextHostCapabilities(ctx) {
934
1585
  if (!object(ctx) || !object(ctx.modelRegistry))
935
1586
  return {};
936
1587
  const registry = ctx.modelRegistry;
937
- const getAll = registry.getAll;
938
- const getAvailable = registry.getAvailable;
939
- return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1588
+ const getAll = asFn(registry.getAll);
1589
+ const getAvailable = asFn(registry.getAvailable);
1590
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1591
+ }
1592
+ function modelInventory(root, registry) {
1593
+ const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
1594
+ const available = registry?.getAvailable?.() ?? registry?.getAll?.() ?? [];
1595
+ const knownModels = new Set(all.map((model) => `${model.provider}/${model.id}`));
1596
+ const availableModels = new Set(available.map((model) => `${model.provider}/${model.id}`));
1597
+ const rootName = root?.provider && root.model ? `${root.provider}/${root.model}` : undefined;
1598
+ if (rootName) {
1599
+ knownModels.add(rootName);
1600
+ availableModels.add(rootName);
1601
+ }
1602
+ return { knownModels, availableModels };
1603
+ }
1604
+ function resumeHostContext(ctx) {
1605
+ const model = object(ctx) && object(ctx.model) && typeof ctx.model.provider === "string" && typeof ctx.model.id === "string" ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
1606
+ return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry };
1607
+ }
1608
+ async function resolveLaunchAliases(registry, staticAliases, context, availableModels, knownModels, settingsPath) {
1609
+ const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
1610
+ const dynamicNames = Object.keys(dynamic);
1611
+ try {
1612
+ const aliases = validateModelAliases({ ...dynamic, ...staticAliases }, settingsPath);
1613
+ validateModelAliasAvailability(aliases, dynamicNames, availableModels, knownModels, settingsPath);
1614
+ return { aliases, dynamicNames };
1615
+ }
1616
+ catch (error) {
1617
+ const name = modelAliasErrorName(error);
1618
+ const descriptor = name && typeof registry.modelAliases === "function" ? registry.modelAliases().find((candidate) => candidate.name === name) : undefined;
1619
+ if (descriptor && errorCode(error) !== "CANCELLED")
1620
+ throw new WorkflowError(errorCode(error) ?? "CONFIG_ERROR", `${errorText(error)} (extension: ${descriptor.headline})`);
1621
+ throw error;
1622
+ }
940
1623
  }
941
- function isUiSelect(value) { return typeof value === "function"; }
942
- function isUiInput(value) { return typeof value === "function"; }
943
- function isUiSetStatus(value) { return typeof value === "function"; }
944
1624
  function uiHostCapabilities(ui) {
945
1625
  if (!object(ui))
946
1626
  return undefined;
947
- const select = ui.select;
948
- const input = ui.input;
949
- const setStatus = ui.setStatus;
950
- return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
1627
+ const select = asFn(ui.select);
1628
+ const input = asFn(ui.input);
1629
+ const setStatus = asFn(ui.setStatus);
1630
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
951
1631
  }
952
- function tuiHostCapabilities(tui) {
953
- if (!object(tui) || !object(tui.terminal))
954
- return {};
955
- return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
1632
+ function tuiRows(tui) {
1633
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1634
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
956
1635
  }
957
- function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
958
1636
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1637
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1638
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } };
959
1639
  function borderWorkflowOverlay(component, theme) {
960
1640
  return {
961
1641
  ...component,
@@ -965,13 +1645,10 @@ function borderWorkflowOverlay(component, theme) {
965
1645
  },
966
1646
  };
967
1647
  }
968
- function isKeybindingGetter(value) { return typeof value === "function"; }
969
- function keybindingsHostCapabilities(keybindings) {
970
- if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys))
971
- return {};
972
- return { getKeys: keybindings.getKeys };
1648
+ function keybindingKeys(keybindings, name) {
1649
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) : undefined;
1650
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
973
1651
  }
974
- function keybindingKeys(keybindings, name) { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
975
1652
  export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession, agentDir) {
976
1653
  beginWorkflowExtensionLoading();
977
1654
  const registry = loadingRegistry();
@@ -1084,11 +1761,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1084
1761
  };
1085
1762
  const phaseBridge = (store, metadata, lifecycle) => {
1086
1763
  let cursor = 0;
1087
- let executionPhase;
1088
1764
  return async (phase) => {
1089
- if (phase === executionPhase)
1090
- return;
1091
- executionPhase = phase;
1092
1765
  await scheduler.flush();
1093
1766
  await lifecycle.enter();
1094
1767
  try {
@@ -1234,7 +1907,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1234
1907
  catch {
1235
1908
  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 };
1236
1909
  }
1237
- 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 } : {}) };
1910
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1911
+ 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 ?? [])], ...(resultPath ? { resultPath } : {}), ...(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 } : {}) };
1238
1912
  });
1239
1913
  return { ...current, agents };
1240
1914
  });
@@ -1243,7 +1917,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1243
1917
  });
1244
1918
  const cleanupTerminalRun = async (runId) => {
1245
1919
  const run = runs.get(runId);
1246
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state))
1920
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state))
1247
1921
  return;
1248
1922
  await scheduler.cancelRun(runId);
1249
1923
  await scheduler.flush();
@@ -1293,7 +1967,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1293
1967
  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) });
1294
1968
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1295
1969
  };
1296
- const answerBudgetDecision = async (runId, proposalId, approved, silent = false) => {
1970
+ const answerBudgetDecision = async (runId, proposalId, approved, silent = false, context, signal) => {
1297
1971
  const run = runs.get(runId);
1298
1972
  if (!run)
1299
1973
  return undefined;
@@ -1301,7 +1975,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1301
1975
  if (!request)
1302
1976
  return undefined;
1303
1977
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1304
- const result = await applyBudgetDecision(request, approved);
1978
+ const result = await applyBudgetDecision(request, approved, context, signal);
1305
1979
  if (!silent)
1306
1980
  deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1307
1981
  return result;
@@ -1362,10 +2036,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1362
2036
  label: "Workflow Respond",
1363
2037
  description: "Approve or reject one pending workflow checkpoint or budget decision",
1364
2038
  parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
1365
- async execute(_id, params) {
2039
+ async execute(_id, params, signal, _onUpdate, ctx) {
1366
2040
  try {
1367
2041
  if (params.proposalId) {
1368
- const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
2042
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
1369
2043
  if (!result) {
1370
2044
  const denied = { state: "budget_exhausted", approved: false, reason: "proposal_not_pending" };
1371
2045
  return { content: [{ type: "text", text: JSON.stringify(denied) }], details: denied };
@@ -1381,6 +2055,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1381
2055
  throw mainAgentError(error);
1382
2056
  }
1383
2057
  },
2058
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
2059
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1384
2060
  });
1385
2061
  pi.registerTool({
1386
2062
  name: "workflow_stop",
@@ -1396,49 +2072,111 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1396
2072
  throw mainAgentError(error);
1397
2073
  }
1398
2074
  },
2075
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
2076
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1399
2077
  });
1400
2078
  let catalogRegistered = false;
1401
2079
  let sessionStarted = false;
1402
- const registerCatalog = () => {
2080
+ const registerCatalog = (cwd, trustedProject) => {
1403
2081
  if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
1404
2082
  return;
1405
- const catalog = registry.catalog();
1406
- const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
1407
- if (!catalog.functions.length && !catalog.variables.length && !hasAliases)
2083
+ const catalog = registry.catalog({ cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) });
2084
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0 || Boolean(catalog.modelAliasEntries?.length);
2085
+ const hasSettings = catalog.settings !== undefined && [catalog.settings.globalSettingsPath, catalog.settings.projectSettingsPath].some((path) => existsSync(path));
2086
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases && !hasSettings)
1408
2087
  return;
1409
2088
  pi.registerTool({
1410
2089
  name: "workflow_catalog",
1411
2090
  label: "Workflow Catalog",
1412
2091
  description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
1413
- parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or variable name for full detail" })) }, { additionalProperties: false }),
2092
+ parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
1414
2093
  async execute(_id, params = {}) {
1415
- const result = params.name === undefined ? registry.catalogIndex() : registry.catalogDetail(params.name);
2094
+ const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
2095
+ const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
1416
2096
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
1417
- }
2097
+ },
2098
+ renderCall(args, theme) {
2099
+ const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
2100
+ return styledTextBlock(args.name === undefined ? title : `${title} ${theme.fg("accent", args.name)}`);
2101
+ },
2102
+ renderResult(result, options, theme) {
2103
+ return workflowCatalogBlock(formatWorkflowCatalog(catalogResultValue(result), options.expanded, theme), options.expanded);
2104
+ },
1418
2105
  });
1419
2106
  catalogRegistered = true;
1420
2107
  };
1421
- const refreshPausedRunAliases = async (run, context) => {
1422
- const loaded = await run.store.load();
1423
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1424
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
2108
+ const createAgentExecutor = (root) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
2109
+ const activeSnapshotTools = (tools, active) => active === "session"
2110
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
2111
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
2112
+ const resumeLaunchPrologue = async (input) => {
2113
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
2114
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1425
2115
  if (missing)
1426
2116
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1427
2117
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1428
- const currentSettings = loadSettings(settingsPath);
1429
- resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
1430
- const currentAliases = currentSettings.modelAliases ?? {};
1431
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1432
- const modelRegistry = context?.modelRegistry;
1433
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1434
- if (context?.model)
1435
- knownModels.add(`${context.model.provider}/${context.model.id}`);
1436
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1437
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1438
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1439
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2118
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
2119
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
2120
+ const staticAliases = resolution.effective.modelAliases ?? {};
2121
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
2122
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
2123
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
2124
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
2125
+ const currentAliases = input.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: input.cwd, projectTrusted: input.trustedProject, rootModel: input.rootModel, knownModels, availableModels, signal: input.signal }, availableModels, knownModels, settingsPath)).aliases;
2126
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2127
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2128
+ let script;
2129
+ if (input.withPreflight) {
2130
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2131
+ script = launchScriptForSnapshot(input.snapshot, registry);
2132
+ preflight(script, { models: availableModels, tools: active, agentTypes: new Set(input.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, input.snapshot.schemas, input.snapshot.metadata, true);
2133
+ }
2134
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
2135
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2136
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
2137
+ };
2138
+ const workflowAgentHandler = (store, metadata, lifecycle, executor, cwd, runId) => async (prompt, options, agentSignal, identity) => {
2139
+ await lifecycle.enter();
2140
+ try {
2141
+ const path = agentIdentityPath(identity);
2142
+ const replayed = await store.replay(path);
2143
+ if (replayed) {
2144
+ return replayed.value;
2145
+ }
2146
+ const worktree = agentWorktree(identity);
2147
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
2148
+ const role = typeof options.role === "string" ? options.role : undefined;
2149
+ const model = typeof options.model === "string" ? options.model : undefined;
2150
+ const thinking = parseThinking(options.thinking);
2151
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
2152
+ const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
2153
+ const label = displayAgentName(requestedLabel, role, resolved.model);
2154
+ const tools = resolved.tools;
2155
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2156
+ const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd: agentCwd, 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 } : {}), agentOptions: options, agentIdentity: identity });
2157
+ const cancel = () => { scheduler.cancel(spawned.id); };
2158
+ if (agentSignal.aborted)
2159
+ cancel();
2160
+ else
2161
+ agentSignal.addEventListener("abort", cancel, { once: true });
2162
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2163
+ if (!outcome.ok)
2164
+ throw new WorkflowError(outcome.error.code, outcome.error.message);
2165
+ await store.complete(path, outcome.value);
2166
+ return outcome.value;
2167
+ }
2168
+ finally {
2169
+ await lifecycle.leave();
2170
+ }
2171
+ };
2172
+ const refreshPausedRunAliases = async (run, context) => {
2173
+ const loaded = await run.store.load();
2174
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
2175
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
2176
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: run.abortController.signal, withPreflight: false });
1440
2177
  await run.store.saveSnapshot(snapshot);
1441
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2178
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
2179
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, 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(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1442
2180
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1443
2181
  const drift = aliasDrift(previousAliases, currentAliases);
1444
2182
  if (drift.length)
@@ -1457,33 +2195,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1457
2195
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1458
2196
  if (missingRole)
1459
2197
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1460
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1461
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1462
- if (missing)
1463
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1464
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1465
- const currentSettings = loadSettings(settingsPath);
1466
- resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1467
- const currentAliases = currentSettings.modelAliases ?? {};
1468
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1469
- const modelRegistry = context?.modelRegistry;
1470
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1471
- if (context?.model)
1472
- knownModels.add(`${context.model.provider}/${context.model.id}`);
1473
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1474
- const resumeAliases = { ...previousAliases, ...currentAliases };
1475
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1476
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1477
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
1478
- preflight(script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
1479
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
2198
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
2199
+ const controller = new AbortController();
2200
+ if (context?.signal?.aborted)
2201
+ controller.abort();
2202
+ else {
2203
+ context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true });
2204
+ }
2205
+ run.abortController = controller;
2206
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: controller.signal, ...(context?.resolvedAliases ? { resolvedAliases: context.resolvedAliases } : {}), ...(context?.blockedAliases ? { blockedAliases: context.blockedAliases } : {}), ...(context?.blockedAliasTargets ? { blockedAliasTargets: context.blockedAliasTargets } : {}), withPreflight: true });
2207
+ if (!script)
2208
+ throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1480
2209
  await run.store.saveSnapshot(snapshot);
1481
- run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
2210
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
2211
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: rootModel, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, 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(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1482
2212
  const drift = aliasDrift(previousAliases, currentAliases);
1483
2213
  if (drift.length)
1484
2214
  await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1485
- const controller = new AbortController();
1486
- run.abortController = controller;
1487
2215
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
1488
2216
  run.executor.setRunContext(runContext);
1489
2217
  let variables;
@@ -1492,7 +2220,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1492
2220
  }
1493
2221
  catch (error) {
1494
2222
  const typed = asWorkflowError(error);
1495
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
2223
+ if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) {
1496
2224
  await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
1497
2225
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
1498
2226
  await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
@@ -1505,37 +2233,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1505
2233
  }
1506
2234
  await scheduler.cancelRun(run.store.runId);
1507
2235
  await run.lifecycle.resume();
1508
- const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (prompt, options, signal, identity) => {
1509
- await run.lifecycle.enter();
1510
- try {
1511
- const path = agentIdentityPath(identity);
1512
- const replayed = await run.store.replay(path);
1513
- if (replayed) {
1514
- return replayed.value;
1515
- }
1516
- const worktree = agentWorktree(identity);
1517
- const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
1518
- const role = typeof options.role === "string" ? options.role : undefined;
1519
- const model = typeof options.model === "string" ? options.model : undefined;
1520
- const thinking = parseThinking(options.thinking);
1521
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1522
- const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
1523
- const label = displayAgentName(requestedLabel, role, resolved.model);
1524
- const tools = resolved.tools;
1525
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1526
- 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 } : {}), agentOptions: options, agentIdentity: identity });
1527
- const cancel = () => { scheduler.cancel(spawned.id); };
1528
- signal.addEventListener("abort", cancel, { once: true });
1529
- const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
1530
- if (!outcome.ok)
1531
- throw new WorkflowError(outcome.error.code, outcome.error.message);
1532
- await run.store.complete(path, outcome.value);
1533
- return outcome.value;
1534
- }
1535
- finally {
1536
- await run.lifecycle.leave();
1537
- }
1538
- }, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
2236
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
1539
2237
  run.execution = execution;
1540
2238
  const completion = execution.result.then(async (value) => {
1541
2239
  await scheduler.flush();
@@ -1562,7 +2260,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1562
2260
  run.completion = completion;
1563
2261
  void completion;
1564
2262
  };
1565
- const applyBudgetDecision = async (request, approved) => {
2263
+ const applyBudgetDecision = async (request, approved, context, signal) => {
1566
2264
  const run = runs.get(request.runId);
1567
2265
  if (!run)
1568
2266
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
@@ -1576,16 +2274,31 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1576
2274
  next.budget = nextBudget;
1577
2275
  else
1578
2276
  delete next.budget; return next; });
1579
- await coldResumeRun(run, false, {}, true);
2277
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1580
2278
  return { state: "running", approved: true };
1581
2279
  };
1582
- const resumeWorkflowRun = async (runId, rawPatch) => {
2280
+ const resumeWorkflowRun = async (runId, rawPatch, context, signal) => {
1583
2281
  const run = runs.get(runId);
1584
- if (!run)
1585
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2282
+ if (!run) {
2283
+ const host = object(context) ? context : {};
2284
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2285
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2286
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2287
+ if (cwd && sessionId) {
2288
+ try {
2289
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2290
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2291
+ }
2292
+ catch (error) {
2293
+ if (error instanceof WorkflowError)
2294
+ throw error;
2295
+ }
2296
+ }
2297
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2298
+ }
1586
2299
  const loaded = await run.store.load();
1587
2300
  if (loaded.run.state !== "budget_exhausted")
1588
- throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2301
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1589
2302
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1590
2303
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1591
2304
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1610,11 +2323,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1610
2323
  else
1611
2324
  delete next.budget; return next; });
1612
2325
  }
1613
- await coldResumeRun(run, false, {}, true);
2326
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1614
2327
  return { state: "running" };
1615
2328
  };
1616
2329
  const retryReservations = new Set();
1617
- const retryWorkflowRun = async (runId, context) => {
2330
+ const retryWorkflowRun = async (runId, context, signal) => {
1618
2331
  if (typeof runId !== "string" || !runId.trim())
1619
2332
  throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
1620
2333
  const host = object(context) ? context : {};
@@ -1630,10 +2343,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1630
2343
  loaded = await sourceStore.load();
1631
2344
  }
1632
2345
  catch (error) {
1633
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`);
2346
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`);
1634
2347
  }
1635
2348
  if (loaded.run.state !== "failed")
1636
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
2349
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1637
2350
  if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string")))
1638
2351
  throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
1639
2352
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
@@ -1669,24 +2382,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1669
2382
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1670
2383
  if (missingRole)
1671
2384
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1672
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1673
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1674
- if (missing)
1675
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1676
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1677
- const currentSettings = loadSettings(settingsPath);
1678
- resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
1679
- const currentAliases = currentSettings.modelAliases ?? {};
1680
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1681
2385
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
1682
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1683
2386
  const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
1684
- if (hostModel.provider && hostModel.id)
1685
- knownModels.add(`${hostModel.provider}/${hostModel.id}`);
1686
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1687
- const resumeAliases = { ...previousAliases, ...currentAliases };
1688
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
1689
- preflight(script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
2387
+ const rootModel = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
2388
+ const { active, settingsPath, currentPolicy, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot: childBaseSnapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd, trustedProject, rootModel, ...(modelRegistry ? { modelRegistry } : {}), signal: signal ?? new AbortController().signal, withPreflight: true });
1690
2389
  await sourceStore.validateNamedWorktrees();
1691
2390
  for (const name of loaded.run.retry?.namedWorktrees ?? [])
1692
2391
  await sourceStore.resolveNamedWorktree(name);
@@ -1696,7 +2395,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1696
2395
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1697
2396
  const childRunId = randomUUID();
1698
2397
  const childStore = new RunStore(cwd, sessionId, childRunId, home);
1699
- const childSnapshot = createLaunchSnapshot(loaded.snapshot);
2398
+ const childSnapshot = childBaseSnapshot;
1700
2399
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
1701
2400
  const childInitialBudget = childBudget.snapshot();
1702
2401
  const retry = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -1705,13 +2404,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1705
2404
  const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
1706
2405
  const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
1707
2406
  const abortController = new AbortController();
1708
- const providerErrorRecovery = createProviderErrorRecovery(context, resumeModels, () => { abortController.abort(); });
2407
+ const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
1709
2408
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1710
- const childRun = { executor: new WorkflowAgentExecutor({ cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => active.has(tool) || tool === "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(cwd, projectTrusted(context), settingsPath) }, createSession), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
2409
+ const childRun = { executor: createAgentExecutor({ cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, active), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentResourcePolicy: frozenResourcePolicy(currentPolicy) }), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
1711
2410
  runs.set(childRunId, childRun);
1712
2411
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
1713
2412
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
1714
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry: modelRegistry ? { getAll: () => [...(modelRegistry.getAll?.() ?? [])], getAvailable: () => [...(modelRegistry.getAvailable?.() ?? [])] } : undefined });
2413
+ await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
1715
2414
  const completion = runs.get(childRunId)?.completion;
1716
2415
  if (completion) {
1717
2416
  childStarted = true;
@@ -1729,37 +2428,41 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1729
2428
  label: "Workflow Retry",
1730
2429
  description: "Retry a failed workflow run by replaying its completed structural operations",
1731
2430
  parameters: WORKFLOW_RETRY_PARAMETERS,
1732
- async execute(_id, params, _signal, _onUpdate, ctx) {
2431
+ async execute(_id, params, signal, _onUpdate, ctx) {
1733
2432
  try {
1734
- const result = await retryWorkflowRun(params.runId, ctx);
2433
+ const result = await retryWorkflowRun(params.runId, ctx, signal);
1735
2434
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
1736
2435
  }
1737
2436
  catch (error) {
1738
2437
  throw mainAgentError(error);
1739
2438
  }
1740
2439
  },
2440
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2441
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1741
2442
  });
1742
2443
  pi.registerTool({
1743
2444
  name: "workflow_resume",
1744
2445
  label: "Workflow Resume",
1745
2446
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
1746
2447
  parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
1747
- async execute(_id, params) {
2448
+ async execute(_id, params, signal, _onUpdate, ctx) {
1748
2449
  try {
1749
- const result = await resumeWorkflowRun(params.runId, params.budget);
2450
+ const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal);
1750
2451
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
1751
2452
  }
1752
2453
  catch (error) {
1753
2454
  throw mainAgentError(error);
1754
2455
  }
1755
2456
  },
2457
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2458
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1756
2459
  });
1757
2460
  pi.on("session_start", async (_event, ctx) => {
1758
2461
  if (sessionStarted)
1759
2462
  return;
1760
2463
  sessionStarted = true;
1761
2464
  registry.freeze();
1762
- registerCatalog();
2465
+ registerCatalog(ctx.cwd, projectTrusted(ctx));
1763
2466
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1764
2467
  try {
1765
2468
  for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
@@ -1795,7 +2498,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1795
2498
  const roleDefinitions = loaded.snapshot.roles ?? {};
1796
2499
  const abortController = new AbortController();
1797
2500
  const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
1798
- runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx), loaded.snapshot.settingsPath ?? workflowSettingsPath(extensionAgentDir)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
2501
+ runs.set(runId, { executor: createAgentExecutor({ cwd: ctx.cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, "session"), 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.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
1799
2502
  for (const checkpoint of await store.awaitingCheckpoints())
1800
2503
  deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
1801
2504
  for (const decision of await store.pendingWorkflowDecisions())
@@ -1832,7 +2535,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1832
2535
  pi.on("before_agent_start", (event, ctx) => {
1833
2536
  if (!pi.getActiveTools().includes("workflow"))
1834
2537
  return;
1835
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
2538
+ const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx), typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : undefined)).filter(([, definition]) => definition.description);
1836
2539
  if (!roles.length)
1837
2540
  return;
1838
2541
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
@@ -1848,32 +2551,33 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1848
2551
  try {
1849
2552
  const headless = object(ctx) && ctx.headless === true;
1850
2553
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1851
- const defaults = loadSettings(settingsPath);
1852
2554
  if (!ctx.model)
1853
2555
  throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
1854
- const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
1855
2556
  const budget = validateBudget(params.budget);
1856
2557
  const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
1857
2558
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
1858
2559
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
1859
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
1860
- knownModels.add(rootModelName);
1861
- const availableModels = knownModels;
1862
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2560
+ const inventory = modelInventory(rootModel, modelRegistry);
2561
+ const knownModels = inventory.knownModels;
2562
+ const availableModels = inventory.availableModels;
2563
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
1863
2564
  const trustedProject = projectTrusted(ctx);
1864
- if (typeof ctx.cwd === "string")
1865
- resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
1866
- const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
1867
- const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
1868
- await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1869
- const runId = randomUUID();
1870
- const args = (params.args ?? null);
1871
- encoded(args);
2565
+ const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
2566
+ const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
1872
2567
  const runController = new AbortController();
1873
2568
  if (signal?.aborted)
1874
2569
  runController.abort();
1875
2570
  else
1876
2571
  signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
2572
+ const resolvedAliases = await resolveLaunchAliases(registry, launch.settings.modelAliases ?? {}, { cwd: launchCwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: runController.signal }, availableModels, knownModels, settingsPath);
2573
+ const modelAliases = resolvedAliases.aliases;
2574
+ const settings = Object.freeze({ ...launch.settings, ...(Object.keys(modelAliases).length ? { modelAliases } : {}) });
2575
+ const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath }, registry);
2576
+ const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
2577
+ await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
2578
+ const runId = randomUUID();
2579
+ const args = (params.args ?? null);
2580
+ encoded(args);
1877
2581
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
1878
2582
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
1879
2583
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
@@ -1882,9 +2586,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1882
2586
  await store.validateParentRun(parentRunId);
1883
2587
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
1884
2588
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
1885
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2589
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
1886
2590
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
1887
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(functionName ? { launchKind: "function", functionName } : {}), ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2591
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function", functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
1888
2592
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
1889
2593
  const initialBudget = budgetRuntime.snapshot();
1890
2594
  await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
@@ -1893,45 +2597,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
1893
2597
  const providerPause = async () => { if (background)
1894
2598
  deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1895
2599
  const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
1896
- const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx), settingsPath), runContext }, createSession);
2600
+ const executor = createAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext });
1897
2601
  runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
1898
2602
  if (params.foreground && onUpdate)
1899
2603
  onUpdate(workflowToolUpdate((await store.load()).run));
1900
2604
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
1901
- const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (prompt, options, agentSignal, identity) => {
1902
- await lifecycle.enter();
1903
- try {
1904
- const path = agentIdentityPath(identity);
1905
- const replayed = await store.replay(path);
1906
- if (replayed) {
1907
- return replayed.value;
1908
- }
1909
- const worktree = agentWorktree(identity);
1910
- const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
1911
- const role = typeof options.role === "string" ? options.role : undefined;
1912
- const model = typeof options.model === "string" ? options.model : undefined;
1913
- const thinking = parseThinking(options.thinking);
1914
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1915
- const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
1916
- const label = displayAgentName(requestedLabel, role, resolved.model);
1917
- const tools = resolved.tools;
1918
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1919
- 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 } : {}), agentOptions: options, agentIdentity: identity });
1920
- const cancel = () => { scheduler.cancel(spawned.id); };
1921
- if (agentSignal.aborted)
1922
- cancel();
1923
- else
1924
- agentSignal.addEventListener("abort", cancel, { once: true });
1925
- const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
1926
- if (!outcome.ok)
1927
- throw new WorkflowError(outcome.error.code, outcome.error.message);
1928
- await store.complete(path, outcome.value);
1929
- return outcome.value;
1930
- }
1931
- finally {
1932
- await lifecycle.leave();
1933
- }
1934
- }, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2605
+ const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
1935
2606
  runs.get(runId).execution = execution;
1936
2607
  await eventPublisher.runStarted(store, checked.metadata);
1937
2608
  const finish = execution.result.then(async (value) => {
@@ -2027,7 +2698,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2027
2698
  return entries.filter((entry) => entry !== undefined);
2028
2699
  };
2029
2700
  let stores = await loadStores();
2030
- 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.";
2701
+ const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint-name]. Approve/reject are for checkpoints only; use workflow_respond with a proposalId or the navigator's budget controls for budget decisions. Use workflow_resume for budget patches.";
2031
2702
  const setWorkflowStatus = (text) => {
2032
2703
  const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
2033
2704
  setStatus?.call(ctx.ui, "workflow-stop", text);
@@ -2044,12 +2715,12 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2044
2715
  return keepContext ? "dashboard" : "done";
2045
2716
  }
2046
2717
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2047
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
2718
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
2048
2719
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2049
2720
  return keepContext ? "dashboard" : "done";
2050
2721
  }
2051
2722
  if (action === "delete" && stored) {
2052
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
2723
+ if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) {
2053
2724
  ctx.ui.notify("Stop the workflow before deleting it.", "warning");
2054
2725
  return keepContext ? "dashboard" : "done";
2055
2726
  }
@@ -2069,7 +2740,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2069
2740
  if (action === "resume" && run) {
2070
2741
  if (run.lifecycle.state === "budget_exhausted") {
2071
2742
  const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
2072
- const result = await resumeWorkflowRun(run.store.runId, patch);
2743
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
2073
2744
  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");
2074
2745
  }
2075
2746
  else {
@@ -2077,7 +2748,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2077
2748
  await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
2078
2749
  else {
2079
2750
  if (run.lifecycle.state === "paused")
2080
- await refreshPausedRunAliases(run, ctx);
2751
+ await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
2081
2752
  await run.lifecycle.resume();
2082
2753
  }
2083
2754
  ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
@@ -2088,7 +2759,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2088
2759
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
2089
2760
  if (input === undefined)
2090
2761
  return keepContext ? "dashboard" : "done";
2091
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2762
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
2092
2763
  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");
2093
2764
  return keepContext ? "dashboard" : "done";
2094
2765
  }
@@ -2123,6 +2794,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2123
2794
  };
2124
2795
  const manageAliases = async () => {
2125
2796
  const settingsPath = workflowSettingsPath(extensionAgentDir);
2797
+ let aliasSettingsPath = settingsPath;
2798
+ const trustedProject = projectTrusted(ctx);
2126
2799
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2127
2800
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
2128
2801
  const selectTarget = async (aliases) => {
@@ -2136,22 +2809,24 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2136
2809
  };
2137
2810
  const save = (aliases) => {
2138
2811
  try {
2139
- saveModelAliases(settingsPath, aliases);
2140
- ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info");
2812
+ saveModelAliases(aliasSettingsPath, aliases);
2813
+ ctx.ui.notify(`Saved model aliases to ${aliasSettingsPath}.`, "info");
2141
2814
  return true;
2142
2815
  }
2143
2816
  catch (error) {
2144
- ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2817
+ ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2145
2818
  return false;
2146
2819
  }
2147
2820
  };
2148
2821
  for (;;) {
2149
2822
  let aliases;
2150
2823
  try {
2151
- aliases = loadSettings(settingsPath).modelAliases ?? {};
2824
+ const resolution = resolveWorkflowSettings(ctx.cwd, trustedProject, settingsPath);
2825
+ aliases = resolution.effective.modelAliases ?? {};
2826
+ aliasSettingsPath = resolution.sources.modelAliases;
2152
2827
  }
2153
2828
  catch (error) {
2154
- ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2829
+ ctx.ui.notify(`${trustedProject ? workflowProjectSettingsPath(ctx.cwd) : settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2155
2830
  return;
2156
2831
  }
2157
2832
  const names = Object.keys(aliases).sort();
@@ -2173,13 +2848,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2173
2848
  continue;
2174
2849
  const next = { ...aliases, [name]: target };
2175
2850
  try {
2176
- validateModelAliases(next, settingsPath);
2851
+ validateModelAliases(next, aliasSettingsPath);
2177
2852
  }
2178
2853
  catch (error) {
2179
- ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2854
+ ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2180
2855
  continue;
2181
2856
  }
2182
- const parsed = resolveModelReference(target, next, new Set(available()), settingsPath);
2857
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
2183
2858
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
2184
2859
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
2185
2860
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
@@ -2195,13 +2870,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2195
2870
  continue;
2196
2871
  const next = { ...aliases, [edit[1]]: target };
2197
2872
  try {
2198
- validateModelAliases(next, settingsPath);
2873
+ validateModelAliases(next, aliasSettingsPath);
2199
2874
  }
2200
2875
  catch (error) {
2201
- ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2876
+ ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
2202
2877
  continue;
2203
2878
  }
2204
- const parsed = resolveModelReference(target, next, new Set(available()), settingsPath);
2879
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
2205
2880
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
2206
2881
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
2207
2882
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
@@ -2238,9 +2913,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2238
2913
  }
2239
2914
  const sorted = navigatorAttentionSort(stores);
2240
2915
  const labels = navigatorRunLabels(sorted);
2241
- const terminalStates = new Set(["completed", "failed", "stopped"]);
2916
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
2242
2917
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2243
- const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
2918
+ const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2919
+ const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
2244
2920
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
2245
2921
  if (!runChoice || runChoice === "Close")
2246
2922
  return;
@@ -2273,6 +2949,20 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2273
2949
  stores = await loadStores();
2274
2950
  continue;
2275
2951
  }
2952
+ if (runChoice === "Delete all failed") {
2953
+ if (!await ctx.ui.confirm("Delete failed runs?", "Delete all failed workflow runs and their artifacts? This cannot be undone."))
2954
+ continue;
2955
+ for (const entry of sorted) {
2956
+ if (entry.loaded.run.state === "failed") {
2957
+ await entry.store.delete(true);
2958
+ runs.delete(entry.store.runId);
2959
+ terminalRunStates.delete(entry.store.runId);
2960
+ }
2961
+ }
2962
+ ctx.ui.notify("Deleted all failed workflow runs.", "info");
2963
+ stores = await loadStores();
2964
+ continue;
2965
+ }
2276
2966
  const runIndex = labels.indexOf(runChoice);
2277
2967
  if (runIndex < 0)
2278
2968
  return;
@@ -2293,6 +2983,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2293
2983
  const loaded = await store.load();
2294
2984
  const checkpoints = await store.awaitingCheckpoints();
2295
2985
  const worktrees = await store.worktrees();
2986
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2987
+ const agentResults = new Map();
2988
+ for (const agent of loaded.run.agents) {
2989
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath)
2990
+ continue;
2991
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2992
+ if (operation)
2993
+ agentResults.set(agent.id, operation.value);
2994
+ }
2296
2995
  const actions = new Map();
2297
2996
  const copies = new Map();
2298
2997
  const reviews = new Map();
@@ -2327,8 +3026,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2327
3026
  if (ctx.mode !== "tui")
2328
3027
  actions.set("Refresh", "refresh");
2329
3028
  else
2330
- actions.set("View script", "view-script");
2331
- if (loaded.run.agents.length)
3029
+ actions.set("Open script in editor", "open-script");
3030
+ if (ctx.mode !== "tui" && loaded.run.agents.length)
2332
3031
  actions.set("Agents...", "agents");
2333
3032
  if (terminalStates.has(loaded.run.state))
2334
3033
  add("Delete", "delete");
@@ -2336,40 +3035,55 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2336
3035
  addCopy("Copy run path", store.directory, "run path");
2337
3036
  addCopy("Copy run ID", store.runId, "run ID");
2338
3037
  }
2339
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
3038
+ return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
2340
3039
  };
2341
- const selectAgent = async (dashboard) => {
2342
- const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2343
- const title = (agent) => {
2344
- const parents = [];
2345
- for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
2346
- const parent = byId.get(parentId);
2347
- if (!parent)
2348
- break;
2349
- parents.unshift(parent.label ?? parent.name);
2350
- }
2351
- return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
2352
- };
2353
- const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2354
- const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2355
- const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2356
- const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
2357
- if (!selected)
2358
- return;
2359
- const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2360
- const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
2361
- const actions = [
2362
- ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
3040
+ const agentWorktreeFor = (dashboard, agent) => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
3041
+ const agentActionLabels = (dashboard, agent) => {
3042
+ const worktree = agentWorktreeFor(dashboard, agent);
3043
+ return [
3044
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2363
3045
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
3046
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
2364
3047
  "Copy agent ID",
2365
3048
  "Back",
2366
3049
  ];
2367
- const chooseAttempt = async () => {
2368
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2369
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2370
- const index = choice ? choices.indexOf(choice) : -1;
2371
- return index >= 0 ? attempts[index] : undefined;
2372
- };
3050
+ };
3051
+ const forkAgentSession = async (dashboard, agent) => {
3052
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
3053
+ const worktree = agentWorktreeFor(dashboard, agent);
3054
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
3055
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
3056
+ const index = choice ? choices.indexOf(choice) : -1;
3057
+ const attempt = index >= 0 ? attempts[index] : undefined;
3058
+ if (!attempt)
3059
+ return;
3060
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
3061
+ if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?"))
3062
+ return;
3063
+ try {
3064
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
3065
+ ctx.ui.notify("Forked Pi session in pane.", "info");
3066
+ }
3067
+ catch (error) {
3068
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
3069
+ }
3070
+ };
3071
+ const selectAgent = async (dashboard, requestedAgentId) => {
3072
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
3073
+ const title = (agent) => agentBreadcrumb(agent, byId, true);
3074
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
3075
+ let selected;
3076
+ if (requestedAgentId)
3077
+ selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
3078
+ else {
3079
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
3080
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
3081
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
3082
+ }
3083
+ if (!selected)
3084
+ return;
3085
+ const worktree = agentWorktreeFor(dashboard, selected);
3086
+ const actions = agentActionLabels(dashboard, selected);
2373
3087
  for (;;) {
2374
3088
  const action = await ctx.ui.select(title(selected), actions);
2375
3089
  if (!action || action === "Back")
@@ -2386,55 +3100,87 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2386
3100
  await copyArtifact(worktree.path, "worktree path");
2387
3101
  continue;
2388
3102
  }
2389
- if (action === "Fork as Pi session in pane") {
2390
- const attempt = await chooseAttempt();
2391
- if (!attempt)
2392
- continue;
2393
- const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2394
- if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?"))
2395
- continue;
2396
- try {
2397
- await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2398
- ctx.ui.notify("Forked Pi session in pane.", "info");
2399
- }
2400
- catch (error) {
2401
- ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2402
- }
2403
- }
3103
+ if (action === "Fork as Pi session in pane")
3104
+ await forkAgentSession(dashboard, selected);
2404
3105
  }
2405
3106
  };
2406
3107
  for (;;) {
2407
3108
  let view = await loadDashboard();
2408
3109
  const actionChoice = ctx.mode === "tui"
2409
3110
  ? await ctx.ui.custom((tui, theme, keybindings, done) => {
2410
- let options = [...view.actions.keys(), "Close"];
2411
- let selectedIndex = 0;
2412
3111
  let dashboardOffset = 0;
2413
3112
  let refreshing = false;
2414
3113
  let disposed = false;
3114
+ let detailsMode = false;
3115
+ let actionMode = false;
3116
+ let actionIndex = 0;
2415
3117
  let stopRequested = false;
2416
3118
  let stopStatus;
3119
+ let selectionNeedsScroll = true;
3120
+ let renderedWidth = 80;
3121
+ let refreshGeneration = 0;
3122
+ const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
3123
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
3124
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
3125
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2417
3126
  const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2418
- const keyLabels = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3127
+ const keyLabels = { up: "↑", down: "↓", left: "", right: "", pageUp: "pgup", pageDown: "pgdn" };
2419
3128
  const keyLabel = (binding, fallback) => {
2420
3129
  const keys = keybindingKeys(keybindings, binding);
2421
3130
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2422
3131
  };
2423
- const dashboardLayout = () => {
2424
- const rows = terminalRows();
2425
- const hintRows = rows >= 4 ? 1 : 0;
2426
- const separatorRows = rows >= 8 ? 1 : 0;
2427
- const available = Math.max(1, rows - hintRows - separatorRows);
2428
- const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
2429
- return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
3132
+ const selectedAgentRecord = () => {
3133
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3134
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
3135
+ };
3136
+ const actionOptions = () => {
3137
+ const agent = selectedAgentRecord();
3138
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
3139
+ };
3140
+ let editorRunning = false;
3141
+ const openArtifact = async (artifact, label) => {
3142
+ if (editorRunning)
3143
+ return;
3144
+ editorRunning = true;
3145
+ try {
3146
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
3147
+ if (!command) {
3148
+ ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning");
3149
+ return;
3150
+ }
3151
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
3152
+ if (exitCode !== 0) {
3153
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
3154
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
3155
+ }
3156
+ }
3157
+ catch (error) {
3158
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
3159
+ }
3160
+ finally {
3161
+ editorRunning = false;
3162
+ tui.requestRender(true);
3163
+ }
2430
3164
  };
2431
- const updateDashboard = async (selectedOption) => {
3165
+ const updateDashboard = async () => {
3166
+ const generation = ++refreshGeneration;
3167
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2432
3168
  const next = await loadDashboard();
2433
- if (disposed)
3169
+ if (disposed || generation !== refreshGeneration)
2434
3170
  return;
3171
+ const previousNodeId = selectedNodeId;
3172
+ const previousExpanded = expandedNodeIds;
3173
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2435
3174
  view = next;
2436
- options = [...view.actions.keys(), "Close"];
2437
- selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
3175
+ tree = buildWorkflowPhaseTree(view.phaseModel);
3176
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
3177
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
3178
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0))
3179
+ expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
3180
+ const nextActions = actionOptions();
3181
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
3182
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
3183
+ selectionNeedsScroll = true;
2438
3184
  tui.requestRender();
2439
3185
  };
2440
3186
  const requestStop = () => {
@@ -2443,16 +3189,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2443
3189
  stopRequested = true;
2444
3190
  stopStatus = undefined;
2445
3191
  setWorkflowStatus(undefined);
2446
- const selectedOption = options[selectedIndex];
2447
3192
  void runAction(`stop ${store.runId}`, true, (status) => {
2448
3193
  stopStatus = status;
2449
3194
  setWorkflowStatus(status);
2450
3195
  if (!disposed)
2451
3196
  tui.requestRender();
2452
- }).then(() => updateDashboard(selectedOption)).catch((error) => {
3197
+ }).then(() => updateDashboard()).catch((error) => {
2453
3198
  if (disposed)
2454
3199
  return;
2455
3200
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
3201
+ setWorkflowStatus(stopStatus);
2456
3202
  tui.requestRender();
2457
3203
  }).finally(() => {
2458
3204
  stopRequested = false;
@@ -2464,59 +3210,181 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2464
3210
  if (refreshing || stopRequested)
2465
3211
  return;
2466
3212
  refreshing = true;
2467
- const selectedOption = options[selectedIndex];
2468
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
3213
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2469
3214
  }, 1000);
2470
3215
  timer.unref();
2471
3216
  return borderWorkflowOverlay({
2472
3217
  render(width) {
2473
- const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
2474
- const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
2475
- const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
2476
- const layout = dashboardLayout();
2477
- const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} navigate${dashboardLines.length > layout.dashboardViewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · ${keyLabel("tui.select.confirm", "enter")} select · ${keyLabel("tui.select.cancel", "esc")} close · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
2478
- const compact = [...dashboardLines, "", ...actionLines, "", hint];
2479
- if (compact.length <= layout.rows) {
2480
- dashboardOffset = 0;
2481
- return compact;
3218
+ renderedWidth = width;
3219
+ const narrow = width < 80;
3220
+ const styles = themeWorkflowProgressStyles(theme);
3221
+ const agent = selectedAgentRecord();
3222
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
3223
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
3224
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
3225
+ const content = [...statusLines, ...phaseLines];
3226
+ const rows = terminalRows();
3227
+ const hintRows = rows >= 3 ? 1 : 0;
3228
+ const viewport = Math.max(1, rows - hintRows);
3229
+ const maxOffset = Math.max(0, content.length - viewport);
3230
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
3231
+ if (actionMode) {
3232
+ const label = actions?.options[actionIndex];
3233
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
3234
+ if (actionRow >= 0) {
3235
+ if (actionRow < dashboardOffset)
3236
+ dashboardOffset = actionRow;
3237
+ else if (actionRow >= dashboardOffset + viewport)
3238
+ dashboardOffset = actionRow - viewport + 1;
3239
+ }
3240
+ }
3241
+ else if (!detailsMode && selectionNeedsScroll) {
3242
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
3243
+ if (selectedRow >= 0) {
3244
+ if (selectedRow < dashboardOffset)
3245
+ dashboardOffset = selectedRow;
3246
+ else if (selectedRow >= dashboardOffset + viewport)
3247
+ dashboardOffset = selectedRow - viewport + 1;
3248
+ }
3249
+ selectionNeedsScroll = false;
2482
3250
  }
2483
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
2484
3251
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2485
- const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
2486
- return [
2487
- ...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
2488
- ...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
2489
- ...actionLines.slice(actionStart, actionStart + layout.actionViewport),
2490
- ...(layout.hintRows ? [hint] : []),
2491
- ];
3252
+ const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "close"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3253
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2492
3254
  },
2493
3255
  invalidate() { },
2494
3256
  handleInput(data) {
2495
- if (stopRequested)
3257
+ if (stopRequested || editorRunning)
3258
+ return;
3259
+ const narrow = renderedWidth < 80;
3260
+ if (!actionMode && (data === "a" || data === "A")) {
3261
+ actionMode = true;
3262
+ actionIndex = 0;
3263
+ dashboardOffset = 0;
3264
+ tui.requestRender();
2496
3265
  return;
2497
- if (keybindings.matches(data, "tui.select.up"))
2498
- selectedIndex = (selectedIndex + options.length - 1) % options.length;
2499
- else if (keybindings.matches(data, "tui.select.down"))
2500
- selectedIndex = (selectedIndex + 1) % options.length;
2501
- else if (keybindings.matches(data, "tui.select.pageUp")) {
2502
- dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
2503
3266
  }
2504
- else if (keybindings.matches(data, "tui.select.pageDown")) {
2505
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
3267
+ if (actionMode) {
3268
+ const options = actionOptions();
3269
+ if (keybindings.matches(data, "tui.select.cancel")) {
3270
+ actionMode = false;
3271
+ dashboardOffset = 0;
3272
+ tui.requestRender();
3273
+ return;
3274
+ }
3275
+ if (keybindings.matches(data, "tui.select.up"))
3276
+ actionIndex = (actionIndex + options.length - 1) % options.length;
3277
+ else if (keybindings.matches(data, "tui.select.down"))
3278
+ actionIndex = (actionIndex + 1) % options.length;
3279
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3280
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3281
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3282
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3283
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3284
+ const action = options[actionIndex];
3285
+ const agent = selectedAgentRecord();
3286
+ if (!action || action === "Back") {
3287
+ actionMode = false;
3288
+ dashboardOffset = 0;
3289
+ }
3290
+ else if (agent) {
3291
+ const worktree = agentWorktreeFor(view, agent);
3292
+ if (action === "Open result in editor") {
3293
+ const result = view.agentResults.get(agent.id);
3294
+ if (result !== undefined)
3295
+ void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
3296
+ }
3297
+ else if (action === "Copy agent ID")
3298
+ void copyArtifact(agent.id, "agent ID");
3299
+ else if (action === "Copy branch" && worktree)
3300
+ void copyArtifact(worktree.branch, "branch");
3301
+ else if (action === "Copy worktree path" && worktree)
3302
+ void copyArtifact(worktree.path, "worktree path");
3303
+ else
3304
+ done(`__workflow_fork__:${agent.id}`);
3305
+ }
3306
+ else if (action === "Open script in editor")
3307
+ void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
3308
+ else if (action === "Stop")
3309
+ requestStop();
3310
+ else
3311
+ done(action);
3312
+ }
3313
+ tui.requestRender();
3314
+ return;
2506
3315
  }
2507
- else if (keybindings.matches(data, "tui.select.confirm")) {
2508
- if (options[selectedIndex] === "Stop")
2509
- requestStop();
3316
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
3317
+ if (keybindings.matches(data, "tui.select.cancel")) {
3318
+ if (narrow && detailsMode) {
3319
+ detailsMode = false;
3320
+ selectionNeedsScroll = true;
3321
+ }
2510
3322
  else
2511
- done(options[selectedIndex]);
3323
+ done(undefined);
3324
+ }
3325
+ else if (narrow && detailsMode) {
3326
+ if (keybindings.matches(data, "tui.select.pageUp"))
3327
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3328
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3329
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3330
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3331
+ if (current?.kind === "agent" && current.agentId) {
3332
+ actionMode = true;
3333
+ actionIndex = 0;
3334
+ }
3335
+ else if (current?.children.length) {
3336
+ if (expandedNodeIds.has(current.id))
3337
+ expandedNodeIds.delete(current.id);
3338
+ else
3339
+ expandedNodeIds.add(current.id);
3340
+ }
3341
+ }
3342
+ }
3343
+ else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
3344
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
3345
+ selectedNodeId = next.nodeId;
3346
+ expandedNodeIds = new Set(next.expandedNodeIds);
3347
+ selectionNeedsScroll = true;
3348
+ }
3349
+ else if (keybindings.matches(data, "tui.editor.cursorRight")) {
3350
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
3351
+ selectedNodeId = next.nodeId;
3352
+ expandedNodeIds = new Set(next.expandedNodeIds);
3353
+ selectionNeedsScroll = true;
3354
+ }
3355
+ else if (keybindings.matches(data, "tui.select.up")) {
3356
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
3357
+ selectedNodeId = next.nodeId;
3358
+ selectionNeedsScroll = true;
3359
+ }
3360
+ else if (keybindings.matches(data, "tui.select.down")) {
3361
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
3362
+ selectedNodeId = next.nodeId;
3363
+ selectionNeedsScroll = true;
3364
+ }
3365
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3366
+ dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3367
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3368
+ dashboardOffset += Math.max(1, terminalRows() - 1);
3369
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3370
+ if (narrow)
3371
+ detailsMode = true;
3372
+ else if (current?.kind === "agent" && current.agentId) {
3373
+ actionMode = true;
3374
+ actionIndex = 0;
3375
+ }
3376
+ else if (current?.children.length) {
3377
+ if (expandedNodeIds.has(current.id))
3378
+ expandedNodeIds.delete(current.id);
3379
+ else
3380
+ expandedNodeIds.add(current.id);
3381
+ }
2512
3382
  }
2513
- else if (keybindings.matches(data, "tui.select.cancel"))
2514
- done(undefined);
2515
3383
  tui.requestRender();
2516
3384
  },
2517
3385
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2518
3386
  }, theme);
2519
- }, { overlay: true })
3387
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
2520
3388
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2521
3389
  if (!actionChoice || actionChoice === "Close")
2522
3390
  return;
@@ -2524,48 +3392,19 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2524
3392
  await selectAgent(view);
2525
3393
  continue;
2526
3394
  }
2527
- if (actionChoice === "Refresh")
3395
+ if (actionChoice.startsWith("__workflow_agent__:")) {
3396
+ await selectAgent(view, actionChoice.slice("__workflow_agent__:".length));
2528
3397
  continue;
2529
- if (actionChoice === "View script") {
2530
- await ctx.ui.custom((tui, theme, keybindings, done) => {
2531
- const highlighted = highlightCode(view.script, "javascript");
2532
- let offset = 0;
2533
- let renderedLines = [];
2534
- const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
2535
- const move = (delta) => {
2536
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2537
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
2538
- };
2539
- return borderWorkflowOverlay({
2540
- render(width) {
2541
- renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2542
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2543
- offset = Math.min(offset, maxOffset);
2544
- return [
2545
- theme.fg("accent", "Workflow script"),
2546
- ...renderedLines.slice(offset, offset + viewport()),
2547
- "",
2548
- theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
2549
- ];
2550
- },
2551
- invalidate() { },
2552
- handleInput(data) {
2553
- if (keybindings.matches(data, "tui.select.up"))
2554
- move(-1);
2555
- else if (keybindings.matches(data, "tui.select.down"))
2556
- move(1);
2557
- else if (keybindings.matches(data, "tui.select.pageUp"))
2558
- move(-viewport());
2559
- else if (keybindings.matches(data, "tui.select.pageDown"))
2560
- move(viewport());
2561
- else if (keybindings.matches(data, "tui.select.cancel"))
2562
- done(undefined);
2563
- tui.requestRender();
2564
- },
2565
- }, theme);
2566
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3398
+ }
3399
+ if (actionChoice.startsWith("__workflow_fork__:")) {
3400
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
3401
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
3402
+ if (agent)
3403
+ await forkAgentSession(view, agent);
2567
3404
  continue;
2568
3405
  }
3406
+ if (actionChoice === "Refresh")
3407
+ continue;
2569
3408
  const copy = view.copies.get(actionChoice);
2570
3409
  if (copy) {
2571
3410
  await copyArtifact(copy.value, copy.artifact);
@@ -2629,7 +3468,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2629
3468
  tui.requestRender();
2630
3469
  },
2631
3470
  }, theme);
2632
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3471
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
2633
3472
  if (decision) {
2634
3473
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
2635
3474
  if (!accepted)
@@ -2656,16 +3495,13 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
2656
3495
  pi.on("session_shutdown", async () => {
2657
3496
  try {
2658
3497
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2659
- if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2660
- await run.completion?.catch(() => undefined);
2661
- return;
2662
- }
2663
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
3498
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
3499
+ if (!isTerminal) {
2664
3500
  try {
2665
3501
  await run.lifecycle.terminal("interrupted");
2666
3502
  }
2667
3503
  catch (error) {
2668
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
3504
+ if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state))
2669
3505
  throw error;
2670
3506
  }
2671
3507
  run.abortController.abort();