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/src/host.ts CHANGED
@@ -1,22 +1,27 @@
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, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
9
- import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
9
+ import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
10
+ import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
10
11
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
11
12
  import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
12
13
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
13
14
  import { budgetRelaxed, budgetUsage, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
14
- import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
15
- import { launchScriptForSnapshot, loadAgentDefinitions, loadSettings, preflight, resolveAgentResourcePolicy, saveModelAliases, validateAgentOptions, validateCheckpoint, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowPrompt, workflowSettingsPath } from "./validation.js";
15
+ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelAliasErrorName, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
16
+ import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
16
17
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
17
18
  import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
18
- 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, type AgentAttemptSummary, type AgentOptions, type AgentRecord, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
19
+ import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
20
+ 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, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCatalogFunction, type WorkflowCatalogIndex, type WorkflowCatalogVariable, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
19
21
  const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
22
+ const INTERNAL_WORKFLOW_TOOLS: readonly string[] = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
23
+ const HARD_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped"]);
24
+ const SHUTDOWN_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped", "budget_exhausted"]);
20
25
  export interface WorkflowProgressStyles {
21
26
  accent(text: string): string;
22
27
  success(text: string): string;
@@ -26,7 +31,25 @@ export interface WorkflowProgressStyles {
26
31
  dim(text: string): string;
27
32
  bold(text: string): string;
28
33
  }
34
+ function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
35
+ const empty = { skills: [], extensions: [] };
36
+ return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
37
+ }
29
38
  const PLAIN_WORKFLOW_PROGRESS_STYLES: WorkflowProgressStyles = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
39
+ type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy };
40
+ function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
41
+ const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
42
+ const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
43
+ return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
44
+ }
45
+ function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
46
+ function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
47
+ const settings: WorkflowSettings = { ...snapshot.settings, concurrency: snapshot.settingsSources === undefined || snapshot.settingsSources.concurrency === "per-run options" ? snapshot.settings.concurrency : resolution.effective.concurrency, modelAliases };
48
+ if (resolution.effective.disabledAgentResources === undefined) delete settings.disabledAgentResources;
49
+ else settings.disabledAgentResources = resolution.effective.disabledAgentResources;
50
+ 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 };
51
+ return { settings, ...(settingsSources === undefined ? {} : { settingsSources }) };
52
+ }
30
53
  const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
31
54
 
32
55
  function workflowDetail(message: string): string {
@@ -71,7 +94,6 @@ function mainAgentError(error: unknown): WorkflowError {
71
94
  const typed = asWorkflowError(error);
72
95
  const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
73
96
  Object.assign(presented, typed);
74
- presented.message = formatWorkflowFailure(typed);
75
97
  return presented;
76
98
  }
77
99
 
@@ -126,7 +148,7 @@ export class RunLifecycle {
126
148
  }
127
149
 
128
150
  async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
129
- if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
151
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
130
152
  await this.#set(state, reason ?? state);
131
153
  for (const resolve of this.#waiters.splice(0)) resolve();
132
154
  }
@@ -146,23 +168,276 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
146
168
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
147
169
  }
148
170
  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.";
171
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
172
+ 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.";
173
+ function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
174
+ if (action === "resume") {
175
+ if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
176
+ if (state === "completed") return "Completed workflow runs have no recovery action";
177
+ if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
178
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
179
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
180
+ }
181
+ if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
182
+ if (state === "completed") return "Completed workflow runs have no recovery action";
183
+ if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
184
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
185
+ return `Only failed workflow runs can be retried; source is ${state}`;
186
+ }
151
187
  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" })),
188
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
153
189
  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" })),
190
+ 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(...)" })),
191
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
156
192
  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" })),
193
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
194
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
195
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
196
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
161
197
  });
162
198
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
163
199
 
164
200
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
165
-
201
+ export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
202
+ export interface WorkflowPhaseAgentCounts { total: number; completed: number; running: number; failed: number; cancelled: number; pending: number }
203
+ export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
204
+ export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
205
+ type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
206
+ function phaseNames(source: WorkflowPhaseSource): string[] {
207
+ const phases: readonly unknown[] = source === undefined ? [] : Array.isArray(source) ? source : (source as Pick<LaunchSnapshot, "phases">).phases ?? [];
208
+ return phases.filter((phase): phase is string => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
209
+ }
210
+ function phaseAgentCounts(agents: readonly AgentRecord[]): WorkflowPhaseAgentCounts {
211
+ const counts: WorkflowPhaseAgentCounts = { total: agents.length, completed: 0, running: 0, failed: 0, cancelled: 0, pending: 0 };
212
+ for (const agent of agents) {
213
+ if (agent.state === "completed") counts.completed += 1;
214
+ else if (agent.state === "running") counts.running += 1;
215
+ else if (agent.state === "failed") counts.failed += 1;
216
+ else if (agent.state === "cancelled") counts.cancelled += 1;
217
+ else counts.pending += 1;
218
+ }
219
+ return counts;
220
+ }
221
+ function phaseState(runState: RunState, counts: WorkflowPhaseAgentCounts, isLatest: boolean): WorkflowPhaseState {
222
+ if (!isLatest) return "completed";
223
+ if (runState === "failed") return "failed";
224
+ if (runState === "stopped") return "cancelled";
225
+ if (runState === "interrupted") return "interrupted";
226
+ if (runState === "budget_exhausted") return "budget_exhausted";
227
+ if (counts.failed > 0) return "failed";
228
+ if (counts.cancelled > 0) return "cancelled";
229
+ if (counts.running > 0 || counts.pending > 0) return "running";
230
+ return runState === "completed" ? "completed" : "running";
231
+ }
232
+ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase" | "phaseHistory" | "agents">, source?: WorkflowPhaseSource): WorkflowPhaseModel {
233
+ const declaredPhases = phaseNames(source);
234
+ const rawHistory: readonly unknown[] = Array.isArray(run.phaseHistory) ? run.phaseHistory : [];
235
+ const observed: Array<{ name: string; afterAgent: number }> = [];
236
+ let boundary = 0;
237
+ for (const record of rawHistory) {
238
+ if (!object(record) || typeof record.phase !== "string" || !record.phase.trim() || typeof record.afterAgent !== "number" || !Number.isSafeInteger(record.afterAgent)) continue;
239
+ boundary = Math.max(boundary, Math.min(run.agents.length, Math.max(0, record.afterAgent)));
240
+ observed.push({ name: record.phase.trim(), afterAgent: boundary });
241
+ }
242
+ if (!observed.length && typeof run.phase === "string" && run.phase.trim()) observed.push({ name: run.phase.trim(), afterAgent: 0 });
243
+ const observedEntries = observed.map((entry, index) => ({ ...entry, index, agents: run.agents.slice(entry.afterAgent, observed[index + 1]?.afterAgent ?? run.agents.length) }));
244
+ const matchedDeclarations = new Set<number>();
245
+ const declarationIndices = observedEntries.map((entry) => {
246
+ const index = declaredPhases.findIndex((name, candidate) => !matchedDeclarations.has(candidate) && name === entry.name);
247
+ if (index >= 0) matchedDeclarations.add(index);
248
+ return index >= 0 ? index : undefined;
249
+ });
250
+ const entries: Array<{ name: string; observedIndex?: number; declarationIndex?: number }> = observedEntries.map((entry, index) => ({ name: entry.name, observedIndex: index, ...(declarationIndices[index] === undefined ? {} : { declarationIndex: declarationIndices[index] }) }));
251
+ for (const [declarationIndex, name] of declaredPhases.entries()) {
252
+ if (matchedDeclarations.has(declarationIndex)) continue;
253
+ const insertion = entries.findIndex((entry) => entry.declarationIndex !== undefined && entry.declarationIndex > declarationIndex);
254
+ const pending = { name };
255
+ if (insertion < 0) entries.push(pending); else entries.splice(insertion, 0, pending);
256
+ }
257
+ const occurrences = new Map<string, number>();
258
+ const phases = entries.map((entry) => {
259
+ const occurrence = (occurrences.get(entry.name) ?? 0) + 1;
260
+ occurrences.set(entry.name, occurrence);
261
+ const observation = entry.observedIndex === undefined ? undefined : observedEntries[entry.observedIndex];
262
+ const agents = observation?.agents ?? [];
263
+ const counts = phaseAgentCounts(agents);
264
+ const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
265
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
266
+ });
267
+ let currentPhaseIndex: number | undefined;
268
+ for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
269
+ const counts: Partial<Record<WorkflowPhaseState, number>> = {};
270
+ for (const phase of phases) counts[phase.state] = (counts[phase.state] ?? 0) + 1;
271
+ const current = currentPhaseIndex === undefined ? undefined : phases[currentPhaseIndex];
272
+ const assigned = new Set(observedEntries.flatMap(({ agents }) => agents.map((agent) => agent.id)));
273
+ const unassignedAgents = run.agents.filter((agent) => !assigned.has(agent.id));
274
+ const result: WorkflowPhaseModel = { declaredPhases, phases, counts };
275
+ if (current !== undefined && currentPhaseIndex !== undefined) { result.currentPhaseIndex = currentPhaseIndex; result.currentPhaseId = current.id; }
276
+ if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
277
+ return result;
278
+ }
279
+ export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined; nodeId?: string | undefined; expandedNodeIds?: readonly string[] | undefined; treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined; actions?: { title: string; options: readonly string[]; index: number } | undefined }
280
+ export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
281
+ export interface WorkflowPhaseTreeNode { id: string; kind: WorkflowPhaseTreeNodeKind; label: string; depth: number; phaseId: string; operationPath: readonly string[]; parentId?: string; children: readonly string[]; state: WorkflowPhaseState | AgentRecord["state"]; agentId?: string; agent?: AgentRecord; phase?: WorkflowPhaseView }
282
+ export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
283
+ export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
284
+ export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
285
+ function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
286
+ const root = `phase/${encodeURIComponent(phaseId)}`;
287
+ if (kind === "phase") return root;
288
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
289
+ if (kind === "operation") return `${root}/operation/${operation}`;
290
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
291
+ }
292
+ function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
293
+ if (!states.length || states.every((state) => state === "completed")) return "completed";
294
+ if (states.some((state) => state === "failed")) return "failed";
295
+ if (states.some((state) => state === "cancelled")) return "cancelled";
296
+ if (states.some((state) => state === "running")) return "running";
297
+ return "queued";
298
+ }
299
+ export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
300
+ type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
301
+ type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
302
+ const drafts = new Map<string, Draft>();
303
+ const roots: string[] = [];
304
+ const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
305
+ const existing = drafts.get(node.id);
306
+ if (existing) return existing;
307
+ const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
308
+ drafts.set(draft.id, draft);
309
+ if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
310
+ return draft;
311
+ };
312
+ const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
313
+ const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
314
+ 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 } : {}) });
315
+ const operationNodes = new Map<string, Draft>();
316
+ const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
317
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
318
+ const acceptedParents = new Map<string, string>();
319
+ const wouldCycle = (childId: string, parentId: string): boolean => {
320
+ const seen = new Set<string>([childId]);
321
+ let current: string | undefined = parentId;
322
+ while (current) {
323
+ if (seen.has(current)) return true;
324
+ seen.add(current);
325
+ current = acceptedParents.get(current);
326
+ }
327
+ return false;
328
+ };
329
+ for (const entry of entries) {
330
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
331
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
332
+ }
333
+ const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
334
+ let parent = owner;
335
+ for (let index = startIndex; index < path.length; index += 1) {
336
+ const prefix = path.slice(0, index + 1);
337
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
338
+ const existing = operationNodes.get(key);
339
+ if (existing) { parent = existing; continue; }
340
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
341
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
342
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
343
+ operationNodes.set(key, operation);
344
+ parent = operation;
345
+ }
346
+ return parent;
347
+ };
348
+ for (const entry of entries) {
349
+ if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
350
+ }
351
+ for (const entry of entries) {
352
+ 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);
353
+ }
354
+ const attach = (entry: AgentEntry, parentId: string): void => {
355
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
356
+ if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
357
+ entry.node.parentId = parentId;
358
+ const parent = drafts.get(parentId);
359
+ if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
360
+ };
361
+ for (const entry of entries) {
362
+ const parentId = acceptedParents.get(entry.agent.id);
363
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
364
+ if (parent) {
365
+ if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
366
+ else {
367
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
368
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
369
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
370
+ }
371
+ }
372
+ attach(entry, entry.defaultParentId);
373
+ }
374
+ const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
375
+ if (seen.has(node.id)) return;
376
+ seen.add(node.id);
377
+ node.depth = depth;
378
+ for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
379
+ };
380
+ setDepth(phaseNode, 0);
381
+ const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
382
+ if (seen.has(node.id)) return [];
383
+ const nextSeen = new Set(seen).add(node.id);
384
+ return node.children.flatMap((childId) => {
385
+ const child = drafts.get(childId);
386
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
387
+ });
388
+ };
389
+ for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
390
+ };
391
+ for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
392
+ if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
393
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
394
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
395
+ }
396
+ export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
397
+ const visible: WorkflowPhaseTreeNode[] = [];
398
+ const visit = (id: string): void => {
399
+ const node = tree.byId.get(id);
400
+ if (!node) return;
401
+ visible.push(node);
402
+ if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
403
+ };
404
+ for (const root of tree.roots) visit(root);
405
+ return visible;
406
+ }
407
+ export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
408
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
409
+ }
410
+ export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
411
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
412
+ return node ? { nodeId: node.id } : {};
413
+ }
414
+ export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
415
+ const expanded = new Set(expandedNodeIds);
416
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
417
+ if (!current) return { expandedNodeIds: expanded };
418
+ if (direction === "left") {
419
+ if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
420
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
421
+ }
422
+ if (direction === "right") {
423
+ if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
424
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
425
+ }
426
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
427
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
428
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
429
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
430
+ }
431
+ export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
432
+ const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
433
+ if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
434
+ const tree = buildWorkflowPhaseTree(model);
435
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
436
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
437
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
438
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
439
+ 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 } : {}) };
440
+ }
166
441
  type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
167
442
  function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
168
443
  function agentGroupLabel(agents: readonly AgentRecord[]): string {
@@ -175,7 +450,8 @@ function agentGroups(agents: readonly AgentRecord[], allAgents: readonly AgentRe
175
450
  const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
176
451
  for (const [index, agent] of agents.entries()) {
177
452
  let depth = 0;
178
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
453
+ const seen = new Set<string>([agent.id]);
454
+ for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) { if (seen.has(parent)) break; seen.add(parent); depth += 1; }
179
455
  const key = agentGroupKey(agent);
180
456
  const group = groups.get(key) ?? { agents: [] };
181
457
  group.agents.push({ agent, index, depth });
@@ -191,23 +467,32 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
191
467
  ...group.entries.map((entry) => render(entry, grouped)),
192
468
  ]);
193
469
  }
194
- function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string {
195
- if (state === "completed") return (text) => styles.success(text);
196
- if (state === "failed" || state === "cancelled") return (text) => styles.error(text);
197
- if (state === "running") return (text) => styles.accent(text);
198
- return (text) => styles.muted(text);
470
+ const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
471
+ const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
472
+ function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
473
+ function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
474
+ type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
475
+ const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
476
+ const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
477
+ const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
478
+ function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
479
+ const key = map[state] ?? "muted";
480
+ return (text) => styles[key](text);
199
481
  }
482
+ function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
483
+ function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
484
+ function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
200
485
  export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
201
486
  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: string) => styles.success(text) : run.state === "failed" || run.state === "stopped" ? (text: string) => styles.error(text) : run.state === "budget_exhausted" ? (text: string) => styles.warning(text) : run.state === "running" ? (text: string) => styles.accent(text) : (text: string) => styles.muted(text);
487
+ const workflowIcon = runStateGlyph(run.state, spinner);
488
+ const iconStyle = workflowIconStyle(run.state, styles);
204
489
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
205
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
490
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
206
491
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
207
492
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
208
493
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
209
494
  const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
210
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
495
+ const icon = agentStateGlyph(agent.state, spinner);
211
496
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
212
497
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
213
498
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
@@ -242,8 +527,189 @@ function textBlock(text: string) {
242
527
  invalidate() {},
243
528
  };
244
529
  }
530
+ function styledTextBlock(text: string) {
531
+ return {
532
+ render(width: number) {
533
+ return truncateWorkflowProgress(text, width);
534
+ },
535
+ invalidate() {},
536
+ };
537
+ }
538
+ function workflowCatalogBlock(text: string, expanded: boolean) {
539
+ return {
540
+ render(width: number) {
541
+ const safeWidth = Math.max(1, width);
542
+ if (!expanded) return truncateWorkflowProgress(text, safeWidth);
543
+ return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines.map((line) => line.trimEnd());
544
+ },
545
+ invalidate() {},
546
+ };
547
+ }
548
+
549
+ type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
550
+ function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
551
+ function controlValue(value: unknown): string {
552
+ if (value === null) return "removed";
553
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
554
+ const json = JSON.stringify(value);
555
+ return typeof json === "string" ? json : "unknown";
556
+ }
557
+ function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
558
+ function controlState(state: string, theme: Theme): string {
559
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
560
+ return theme.fg(color, state);
561
+ }
562
+ function controlAction(action: string, theme: Theme): string {
563
+ const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
564
+ return theme.fg(color, action);
565
+ }
566
+ function budgetPatchEntries(value: unknown): string[] {
567
+ if (!object(value)) return value === undefined ? [] : [controlValue(value)];
568
+ return Object.entries(value).map(([dimension, limits]) => {
569
+ if (limits === null) return `${dimension}=removed`;
570
+ if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
571
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
572
+ return `${dimension} ${parts.join(" ")}`;
573
+ });
574
+ }
575
+ function budgetPatchSummary(value: unknown): string {
576
+ const entries = budgetPatchEntries(value);
577
+ return entries.length ? entries.join(", ") : "unchanged";
578
+ }
579
+ function budgetPatchDetails(value: unknown, theme: Theme): string[] {
580
+ const entries = budgetPatchEntries(value);
581
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
582
+ }
583
+ function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
584
+ function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
585
+ const runId = controlString(args.runId) ?? "(missing run ID)";
586
+ if (name === "workflow_respond") {
587
+ const proposalId = controlString(args.proposalId);
588
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
589
+ const decision = args.approved === true ? "approve" : "reject";
590
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
591
+ }
592
+ if (name === "workflow_resume") 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");
593
+ if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
594
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
595
+ }
596
+ function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
597
+ if (isError) {
598
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
599
+ return theme.fg("error", text || `The ${name} tool failed.`);
600
+ }
601
+ const value = workflowControlValue(result);
602
+ if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
603
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
604
+ const title = controlTitle(name, theme);
605
+ if (name === "workflow_stop") {
606
+ const state = controlString(value.state) ?? "unknown";
607
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
608
+ if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
609
+ 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");
610
+ }
611
+ if (name === "workflow_retry") {
612
+ const childRunId = controlString(value.runId) ?? "(unknown)";
613
+ const state = controlString(value.state) ?? "unknown";
614
+ if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
615
+ 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");
616
+ }
617
+ if (name === "workflow_resume") {
618
+ const state = controlString(value.state) ?? "unknown";
619
+ const proposalId = controlString(value.proposalId);
620
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
621
+ if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
622
+ 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");
623
+ }
624
+ const proposalId = controlString(args.proposalId);
625
+ const checkpointName = controlString(args.name);
626
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
627
+ const accepted = value.accepted === true;
628
+ const approved = value.approved === true;
629
+ const reason = controlString(value.reason);
630
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
631
+ const state = controlString(value.state);
632
+ if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
633
+ 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");
634
+ }
635
+
636
+ function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
637
+
638
+ type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
639
+
640
+ function catalogResultValue(result: CatalogToolResult): unknown {
641
+ if (result.details !== undefined) return result.details;
642
+ const text = result.content?.find((entry) => entry.type === "text")?.text;
643
+ if (!text) return undefined;
644
+ try { return JSON.parse(text) as unknown; } catch { return text; }
645
+ }
646
+
647
+ function isCatalogIndex(value: unknown): value is WorkflowCatalogIndex {
648
+ return object(value) && Array.isArray(value.functions) && Array.isArray(value.variables);
649
+ }
650
+
651
+ function isCatalogFunction(value: unknown): value is WorkflowCatalogFunction {
652
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.input) && object(value.output);
653
+ }
654
+
655
+ function isCatalogVariable(value: unknown): value is WorkflowCatalogVariable {
656
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.schema);
657
+ }
658
+
659
+ function isCatalogError(value: unknown): value is { error: { message: string } } {
660
+ return object(value) && object(value.error) && typeof value.error.message === "string";
661
+ }
662
+
663
+ function catalogSectionTitle(label: string, count: number, theme: Theme): string {
664
+ return theme.fg("accent", theme.bold(`${label} (${String(count)})`));
665
+ }
666
+
667
+ function catalogIndexEntries(entries: readonly { name: string; description: string }[], theme: Theme): string[] {
668
+ const width = Math.max(0, ...entries.map((entry) => entry.name.length));
669
+ return entries.map((entry) => ` ${theme.fg("accent", entry.name.padEnd(width))} ${theme.fg("toolOutput", catalogText(entry.description))}`);
670
+ }
671
+
672
+ function formatCatalogIndex(catalog: WorkflowCatalogIndex, theme: Theme): string {
673
+ const aliases = Object.prototype.propertyIsEnumerable.call(catalog, "modelAliases") ? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static" as const, provenance: "settings" })) : catalog.modelAliasEntries ?? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static" as const, provenance: "settings" }));
674
+ const aliasWidth = Math.max(0, ...aliases.map(({ name }) => name.length));
675
+ const aliasLines = aliases.map(({ name, kind, provenance }) => ` ${theme.fg("accent", name.padEnd(aliasWidth))} ${theme.fg("toolOutput", `${kind} · ${provenance}`)}`);
676
+ return [
677
+ catalogSectionTitle("Functions", catalog.functions.length, theme),
678
+ ...catalogIndexEntries(catalog.functions, theme),
679
+ "",
680
+ catalogSectionTitle("Variables", catalog.variables.length, theme),
681
+ ...catalogIndexEntries(catalog.variables, theme),
682
+ "",
683
+ catalogSectionTitle("Model aliases", aliases.length, theme),
684
+ ...aliasLines,
685
+ ].join("\n");
686
+ }
687
+
688
+ function catalogSchemaLines(schema: unknown, theme: Theme): string[] {
689
+ const json = JSON.stringify(schema, null, 2);
690
+ return json.split("\n").map((line) => ` ${theme.fg("toolOutput", line)}`);
691
+ }
692
+
693
+ function formatCatalogDetail(value: WorkflowCatalogFunction | WorkflowCatalogVariable | import("./types.js").WorkflowCatalogModelAlias, expanded: boolean, theme: Theme): string {
694
+ if ("kind" in value) return [theme.fg("accent", theme.bold("Model alias")), ` ${theme.fg("accent", value.name)} ${theme.fg("toolOutput", `${value.kind} · ${value.provenance}`)}`].join("\n");
695
+ const kind = "input" in value ? "Function" : "Variable";
696
+ if (!expanded) 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");
697
+ 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"))];
698
+ if ("input" in value) lines.push(theme.fg("muted", "Input schema"), ...catalogSchemaLines(value.input, theme), "", theme.fg("muted", "Output schema"), ...catalogSchemaLines(value.output, theme));
699
+ else lines.push(theme.fg("muted", "Variable schema"), ...catalogSchemaLines(value.schema, theme));
700
+ return lines.join("\n");
701
+ }
702
+
703
+ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme): string {
704
+ if (isCatalogIndex(value)) return formatCatalogIndex(value, theme);
705
+ if (isCatalogFunction(value) || isCatalogVariable(value)) return formatCatalogDetail(value, expanded, theme);
706
+ if (object(value) && typeof value.name === "string" && (value.kind === "static" || value.kind === "dynamic")) return formatCatalogDetail(value as unknown as import("./types.js").WorkflowCatalogModelAlias, expanded, theme);
707
+ if (isCatalogError(value)) return theme.fg("error", value.error.message);
708
+ return theme.fg("error", "The workflow catalog returned an invalid result.");
709
+ }
245
710
 
246
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
711
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
712
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
247
713
  export function truncateWorkflowProgress(text: string, width: number): string[] {
248
714
  const safeWidth = Math.max(1, width);
249
715
  return text.split("\n").flatMap((line) => {
@@ -264,7 +730,7 @@ function themeWorkflowProgressStyles(theme: Theme): WorkflowProgressStyles {
264
730
  warning: (text) => theme.fg("warning", text),
265
731
  muted: (text) => theme.fg("muted", text),
266
732
  dim: (text) => theme.fg("dim", text),
267
- bold: (text) => theme.bold(text),
733
+ bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
268
734
  };
269
735
  }
270
736
  function workflowProgressBlock(run: PersistedRun, theme: Theme) {
@@ -310,7 +776,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
310
776
  for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
311
777
  return entries.map(({ store, loaded: { run } }) => {
312
778
  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" ? "●" : "◆";
779
+ const glyph = runStateGlyph(run.state, "⠦");
314
780
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
315
781
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
316
782
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -318,28 +784,29 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
318
784
  });
319
785
  }
320
786
 
321
- function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord>): string[] {
322
- const name = agent.label ?? agent.name;
323
- const parts: string[] = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
787
+ export function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string[] {
788
+ const leaf = agent.label ?? agent.name;
789
+ const parts: string[] = includeStructuralPath && agent.structuralPath?.length ? [agent.structuralPath.join(" > ")] : [];
790
+ if (agent.parentBreadcrumb) parts.push(agent.parentBreadcrumb);
791
+ const ancestors: string[] = [];
324
792
  const seen = new Set<string>([agent.id]);
325
793
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
326
- if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
794
+ if (seen.has(parentId)) break;
327
795
  seen.add(parentId);
328
796
  const parent = byId.get(parentId);
329
- if (parent) parts.push(parent.label ?? parent.name);
330
- else break;
797
+ if (!parent) break;
798
+ ancestors.push(parent.label ?? parent.name);
331
799
  }
332
- parts.push(name);
800
+ parts.push(...ancestors.reverse(), leaf);
333
801
  return parts;
334
802
  }
335
- function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>): string {
336
- const parts = agentBreadcrumbParts(agent, byId);
337
- return parts.length > 1 ? parts.join(" > ") : parts[0] ?? "";
803
+ export function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string {
804
+ return agentBreadcrumbParts(agent, byId, includeStructuralPath).join(" > ");
338
805
  }
339
806
  function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, styles: WorkflowProgressStyles): string {
340
807
  const parts = agentBreadcrumbParts(agent, byId);
341
808
  if (parts.length <= 1) return parts[0] ?? "";
342
- return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${parts[parts.length - 1] ?? ""}`;
809
+ return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
343
810
  }
344
811
 
345
812
  function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
@@ -358,7 +825,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
358
825
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
359
826
  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 });
360
827
  const hasAccounting = run.agents.some((a) => a.accounting);
361
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
828
+ const glyph = runStateGlyph(run.state, "⠦");
362
829
  const header = `${glyph} ${run.workflowName}`;
363
830
  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(" · ");
364
831
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -367,7 +834,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
367
834
  lines.push("");
368
835
  const byId = new Map(run.agents.map((a) => [a.id, a]));
369
836
  const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
370
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
837
+ const icon = agentStateGlyph(agent.state, "⠦");
371
838
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
372
839
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
373
840
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -385,7 +852,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
385
852
  return lines.join("\n");
386
853
  }
387
854
 
388
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
855
+ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
389
856
  const { run, snapshot } = loaded;
390
857
  const lines = [
391
858
  `Workflow: ${run.workflowName}`,
@@ -395,11 +862,13 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
395
862
  `Launch cwd: ${run.cwd}`,
396
863
  ...formatCompactBudgetStatus(run),
397
864
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
865
+ `Settings: concurrency=${String(snapshot.settings.concurrency)}`,
398
866
  ];
399
867
  if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
400
868
  if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
401
869
  const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
402
870
  if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
871
+ if (snapshot.settingsSources) lines.push(`Settings sources: concurrency=${snapshot.settingsSources.concurrency}, modelAliases=${snapshot.settingsSources.modelAliases}, disabledAgentResources=${snapshot.settingsSources.disabledAgentResources}`);
403
872
  lines.push("Agents / ownership:");
404
873
  if (!run.agents.length) lines.push(" (none)");
405
874
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
@@ -417,10 +886,94 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
417
886
  lines.push("Checkpoints:");
418
887
  if (!checkpoints.length) lines.push(" (none)");
419
888
  for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
420
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
889
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
421
890
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
422
891
  return lines.join("\n");
423
892
  }
893
+ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
894
+ const safeWidth = Math.max(1, width);
895
+ const model = buildWorkflowPhaseModel(run, snapshot);
896
+ const tree = buildWorkflowPhaseTree(model);
897
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
898
+ const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
899
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
900
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
901
+ const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
902
+ const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
903
+ const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
904
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
905
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
906
+ 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];
907
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
908
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
909
+ const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
910
+ const agents: AgentRecord[] = [];
911
+ const visit = (id: string): void => {
912
+ const child = tree.byId.get(id);
913
+ if (!child) return;
914
+ if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
915
+ };
916
+ if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
917
+ return agents;
918
+ };
919
+ const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
920
+ const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
921
+ const treeLine = (node: WorkflowPhaseTreeNode): string => {
922
+ const selected = node.id === selectedNode?.id;
923
+ const state = progressStyleForState(node.state, styles);
924
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
925
+ };
926
+ const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
927
+ if (!node) return [styles.muted("No workflow node is selected")];
928
+ const agents = nodeAgents(node);
929
+ if (node.kind === "phase") {
930
+ const selected = node.phase;
931
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
932
+ 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)}`];
933
+ }
934
+ if (node.kind === "operation") {
935
+ const states = phaseAgentCounts(agents);
936
+ 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)}`];
937
+ }
938
+ const agent = node.agent;
939
+ if (!agent) return [styles.muted("Agent details are unavailable")];
940
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
941
+ 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")])];
942
+ const error = agent.attemptDetails?.at(-1)?.error;
943
+ if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
944
+ if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
945
+ return result;
946
+ };
947
+ const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
948
+ const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
949
+ const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
950
+ if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
951
+ lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
952
+ for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
953
+ lines.push(...formatCompactBudgetStatus(run));
954
+ const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
955
+ const actionRows = (): string[] => {
956
+ const actions = selection.actions;
957
+ if (!actions) return [];
958
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
959
+ };
960
+ const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
961
+ if (safeWidth >= 80) {
962
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
963
+ const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
964
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
965
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
966
+ const rows = Math.max(sidebar.length, detail.length);
967
+ for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
968
+ } else if (selection.detailsOnly) {
969
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
970
+ } else {
971
+ lines.push(...renderTree(safeWidth));
972
+ if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
973
+ }
974
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
975
+ return lines.flatMap((line) => wrap(line));
976
+ }
424
977
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
425
978
  const context = JSON.stringify(checkpoint.context, null, 2);
426
979
  return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
@@ -841,48 +1394,71 @@ function projectTrusted(ctx: unknown): boolean {
841
1394
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
842
1395
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
843
1396
  }
1397
+ function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
844
1398
  type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
845
- function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
846
1399
  function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
847
1400
  function piHostCapabilities(pi: unknown): PiHostCapabilities {
848
1401
  if (!object(pi)) return {};
849
- const registerEntryRenderer = pi.registerEntryRenderer;
1402
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
850
1403
  const events = pi.events;
851
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1404
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
852
1405
  }
853
1406
  type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
854
1407
  type ModelSummary = { provider: string; id: string };
855
1408
  type ModelRegistryGetter = () => readonly ModelSummary[];
856
1409
  type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
857
- function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
858
1410
  function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
859
1411
  if (!object(ctx) || !object(ctx.modelRegistry)) return {};
860
1412
  const registry = ctx.modelRegistry;
861
- const getAll = registry.getAll;
862
- const getAvailable = registry.getAvailable;
863
- return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1413
+ const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
1414
+ const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
1415
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1416
+ }
1417
+ function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
1418
+ const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
1419
+ const available = registry?.getAvailable?.() ?? registry?.getAll?.() ?? [];
1420
+ const knownModels = new Set(all.map((model) => `${model.provider}/${model.id}`));
1421
+ const availableModels = new Set(available.map((model) => `${model.provider}/${model.id}`));
1422
+ const rootName = root?.provider && root.model ? `${root.provider}/${root.model}` : undefined;
1423
+ if (rootName) { knownModels.add(rootName); availableModels.add(rootName); }
1424
+ return { knownModels, availableModels };
1425
+ }
1426
+ function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined } {
1427
+ 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;
1428
+ return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry };
1429
+ }
1430
+ async function resolveLaunchAliases(registry: WorkflowRegistryApi, staticAliases: Readonly<Record<string, string>>, context: Readonly<WorkflowModelAliasResolverContext>, availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath: string): Promise<{ aliases: Readonly<Record<string, string>>; dynamicNames: readonly string[] }> {
1431
+ const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
1432
+ const dynamicNames = Object.keys(dynamic);
1433
+ try {
1434
+ const aliases = validateModelAliases({ ...dynamic, ...staticAliases }, settingsPath);
1435
+ validateModelAliasAvailability(aliases, dynamicNames, availableModels, knownModels, settingsPath);
1436
+ return { aliases, dynamicNames };
1437
+ } catch (error) {
1438
+ const name = modelAliasErrorName(error);
1439
+ const descriptor = name && typeof registry.modelAliases === "function" ? registry.modelAliases().find((candidate) => candidate.name === name) : undefined;
1440
+ if (descriptor && errorCode(error) !== "CANCELLED") throw new WorkflowError(errorCode(error) ?? "CONFIG_ERROR", `${errorText(error)} (extension: ${descriptor.headline})`);
1441
+ throw error;
1442
+ }
864
1443
  }
865
1444
  type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
866
1445
  type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
867
1446
  type UiSetStatus = (key: string, text?: string) => void;
868
1447
  type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
869
- function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
870
- function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
871
- function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
872
1448
  function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
873
1449
  if (!object(ui)) return undefined;
874
- const select = ui.select;
875
- const input = ui.input;
876
- const setStatus = ui.setStatus;
877
- return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
1450
+ const select = (asFn(ui.select) as UiSelect | undefined);
1451
+ const input = (asFn(ui.input) as UiInput | undefined);
1452
+ const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
1453
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
878
1454
  }
879
- type TuiHostCapabilities = { terminal?: { rows?: unknown } };
880
- function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
881
- if (!object(tui) || !object(tui.terminal)) return {};
882
- return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
1455
+ function tuiRows(tui: unknown): number {
1456
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1457
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
883
1458
  }
884
- function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
885
1459
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1460
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1461
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
886
1462
  type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
887
1463
  function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
888
1464
  return {
@@ -894,12 +1470,10 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
894
1470
  };
895
1471
  }
896
1472
  type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
897
- function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
898
- function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
899
- if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
900
- return { getKeys: keybindings.getKeys };
1473
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
1474
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
1475
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
901
1476
  }
902
- function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
903
1477
 
904
1478
  export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
905
1479
  beginWorkflowExtensionLoading();
@@ -993,10 +1567,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
993
1567
  };
994
1568
  const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
995
1569
  let cursor = 0;
996
- let executionPhase: string | undefined;
997
1570
  return async (phase: string): Promise<void> => {
998
- if (phase === executionPhase) return;
999
- executionPhase = phase;
1000
1571
  await scheduler.flush();
1001
1572
  await lifecycle.enter();
1002
1573
  try {
@@ -1115,7 +1686,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1115
1686
  let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
1116
1687
  try { effective = run.executor.resolve(requested); }
1117
1688
  catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
1118
- 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 } : {}) };
1689
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1690
+ 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 } : {}) };
1119
1691
  });
1120
1692
  return { ...current, agents };
1121
1693
  });
@@ -1124,7 +1696,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1124
1696
  });
1125
1697
  const cleanupTerminalRun = async (runId: string): Promise<void> => {
1126
1698
  const run = runs.get(runId);
1127
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state)) return;
1699
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
1128
1700
  await scheduler.cancelRun(runId);
1129
1701
  await scheduler.flush();
1130
1702
  if (runs.get(runId) !== run) return;
@@ -1167,13 +1739,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1167
1739
  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) });
1168
1740
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1169
1741
  };
1170
- const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
1742
+ const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
1171
1743
  const run = runs.get(runId);
1172
1744
  if (!run) return undefined;
1173
1745
  const request = await run.store.answerWorkflowDecision(proposalId, approved);
1174
1746
  if (!request) return undefined;
1175
1747
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1176
- const result = await applyBudgetDecision(request, approved);
1748
+ const result = await applyBudgetDecision(request, approved, context, signal);
1177
1749
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1178
1750
  return result;
1179
1751
  };
@@ -1223,20 +1795,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1223
1795
  label: "Workflow Respond",
1224
1796
  description: "Approve or reject one pending workflow checkpoint or budget decision",
1225
1797
  parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
1226
- async execute(_id, params) {
1798
+ async execute(_id, params, signal, _onUpdate, ctx) {
1227
1799
  try {
1228
1800
  if (params.proposalId) {
1229
- const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
1801
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
1230
1802
  if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { content: [{ type: "text" as const, text: JSON.stringify(denied) }], details: denied }; }
1231
1803
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
1232
1804
  }
1233
1805
  if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
1234
1806
  const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
1235
- return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } as never };
1807
+ return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } };
1236
1808
  } catch (error) {
1237
1809
  throw mainAgentError(error);
1238
1810
  }
1239
1811
  },
1812
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
1813
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1240
1814
  });
1241
1815
  pi.registerTool({
1242
1816
  name: "workflow_stop",
@@ -1251,50 +1825,117 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1251
1825
  throw mainAgentError(error);
1252
1826
  }
1253
1827
  },
1828
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
1829
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1254
1830
  });
1255
1831
  let catalogRegistered = false;
1256
1832
  let sessionStarted = false;
1257
- const registerCatalog = () => {
1833
+ const registerCatalog = (cwd: string, trustedProject: boolean) => {
1258
1834
  if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
1259
- const catalog = registry.catalog();
1260
- const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
1261
- if (!catalog.functions.length && !catalog.variables.length && !hasAliases) return;
1835
+ const catalog = registry.catalog({ cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) });
1836
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0 || Boolean(catalog.modelAliasEntries?.length);
1837
+ const hasSettings = catalog.settings !== undefined && [catalog.settings.globalSettingsPath, catalog.settings.projectSettingsPath].some((path) => existsSync(path));
1838
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases && !hasSettings) return;
1262
1839
  pi.registerTool({
1263
1840
  name: "workflow_catalog",
1264
1841
  label: "Workflow Catalog",
1265
1842
  description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
1266
- parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or variable name for full detail" })) }, { additionalProperties: false }),
1843
+ parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
1267
1844
  async execute(_id, params = {}) {
1268
- const result = params.name === undefined ? registry.catalogIndex() : registry.catalogDetail(params.name);
1845
+ const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
1846
+ const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
1269
1847
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
1270
- }
1848
+ },
1849
+ renderCall(args, theme) {
1850
+ const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
1851
+ return styledTextBlock(args.name === undefined ? title : `${title} ${theme.fg("accent", args.name)}`);
1852
+ },
1853
+ renderResult(result, options, theme) {
1854
+ return workflowCatalogBlock(formatWorkflowCatalog(catalogResultValue(result), options.expanded, theme), options.expanded);
1855
+ },
1271
1856
  });
1272
1857
  catalogRegistered = true;
1273
1858
  };
1274
- const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
1275
- const loaded = await run.store.load();
1276
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1277
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1859
+ const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
1860
+ const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
1861
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
1862
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
1863
+ const resumeLaunchPrologue = async (input: {
1864
+ snapshot: Readonly<LaunchSnapshot>;
1865
+ cwd: string;
1866
+ trustedProject: boolean;
1867
+ rootModel: ModelSpec;
1868
+ modelRegistry?: ModelRegistryCapability | undefined;
1869
+ signal: AbortSignal;
1870
+ resolvedAliases?: Readonly<Record<string, string>>;
1871
+ blockedAliases?: ReadonlySet<string>;
1872
+ blockedAliasTargets?: Readonly<Record<string, string>>;
1873
+ withPreflight: boolean;
1874
+ }) => {
1875
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
1876
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1278
1877
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1279
1878
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1280
- const currentSettings = loadSettings(settingsPath);
1281
- resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
1282
- const currentAliases = currentSettings.modelAliases ?? {};
1283
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1284
- const modelRegistry = context?.modelRegistry;
1285
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1286
- if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
1287
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1288
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1289
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1290
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
1879
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
1880
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
1881
+ const staticAliases = resolution.effective.modelAliases ?? {};
1882
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
1883
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
1884
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
1885
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
1886
+ 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;
1887
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1888
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1889
+ let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
1890
+ if (input.withPreflight) {
1891
+ const resumeAliases = { ...previousAliases, ...currentAliases };
1892
+ script = launchScriptForSnapshot(input.snapshot, registry);
1893
+ 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);
1894
+ }
1895
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
1896
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1897
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
1898
+ };
1899
+ const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
1900
+ await lifecycle.enter();
1901
+ try {
1902
+ const path = agentIdentityPath(identity);
1903
+ const replayed = await store.replay(path);
1904
+ if (replayed) {
1905
+ return replayed.value;
1906
+ }
1907
+ const worktree = agentWorktree(identity);
1908
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
1909
+ const role = typeof options.role === "string" ? options.role : undefined;
1910
+ const model = typeof options.model === "string" ? options.model : undefined;
1911
+ const thinking = parseThinking(options.thinking);
1912
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1913
+ const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
1914
+ const label = displayAgentName(requestedLabel, role, resolved.model);
1915
+ const tools = resolved.tools;
1916
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1917
+ 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 });
1918
+ const cancel = () => { scheduler.cancel(spawned.id); };
1919
+ if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
1920
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
1921
+ if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1922
+ await store.complete(path, outcome.value);
1923
+ return outcome.value;
1924
+ } finally { await lifecycle.leave(); }
1925
+ };
1926
+ const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
1927
+ const loaded = await run.store.load();
1928
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1929
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1930
+ 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 });
1291
1931
  await run.store.saveSnapshot(snapshot);
1292
- 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() as unknown as readonly RegisteredAgentSetupHook[], agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
1932
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1933
+ 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) });
1293
1934
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1294
1935
  const drift = aliasDrift(previousAliases, currentAliases);
1295
1936
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1296
1937
  };
1297
- const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
1938
+ const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }) => {
1298
1939
  const loaded = await run.store.load();
1299
1940
  await run.store.validateRetrySource();
1300
1941
  await run.store.validateBorrowedWorktrees();
@@ -1303,69 +1944,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1303
1944
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1304
1945
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1305
1946
  if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1306
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1307
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1308
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1309
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1310
- const currentSettings = loadSettings(settingsPath);
1311
- resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1312
- const currentAliases = currentSettings.modelAliases ?? {};
1313
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1314
- const modelRegistry = context?.modelRegistry;
1315
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1316
- if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
1317
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1318
- const resumeAliases = { ...previousAliases, ...currentAliases };
1319
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1320
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1321
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
1322
- 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);
1323
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
1947
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1948
+ const controller = new AbortController();
1949
+ if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
1950
+ run.abortController = controller;
1951
+ 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 });
1952
+ if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1324
1953
  await run.store.saveSnapshot(snapshot);
1325
- 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() as unknown as readonly RegisteredAgentSetupHook[], agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
1954
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1955
+ 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) });
1326
1956
  const drift = aliasDrift(previousAliases, currentAliases);
1327
1957
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1328
- const controller = new AbortController();
1329
- run.abortController = controller;
1330
1958
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
1331
1959
  run.executor.setRunContext(runContext);
1332
1960
  let variables: Readonly<Record<string, JsonValue>>;
1333
1961
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1334
1962
  catch (error) {
1335
1963
  const typed = asWorkflowError(error);
1336
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
1964
+ if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
1337
1965
  await cleanupTerminalRun(run.store.runId);
1338
1966
  throw typed;
1339
1967
  }
1340
1968
  await scheduler.cancelRun(run.store.runId);
1341
1969
  await run.lifecycle.resume();
1342
- 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) => {
1343
- await run.lifecycle.enter();
1344
- try {
1345
- const path = agentIdentityPath(identity);
1346
- const replayed = await run.store.replay(path);
1347
- if (replayed) {
1348
- return replayed.value;
1349
- }
1350
- const worktree = agentWorktree(identity);
1351
- const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
1352
- const role = typeof options.role === "string" ? options.role : undefined;
1353
- const model = typeof options.model === "string" ? options.model : undefined;
1354
- const thinking = parseThinking(options.thinking);
1355
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1356
- 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 as string[] } : {}) });
1357
- const label = displayAgentName(requestedLabel, role, resolved.model);
1358
- const tools = resolved.tools;
1359
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1360
- 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 });
1361
- const cancel = () => { scheduler.cancel(spawned.id); };
1362
- signal.addEventListener("abort", cancel, { once: true });
1363
- const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
1364
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1365
- await run.store.complete(path, outcome.value);
1366
- return outcome.value;
1367
- } finally { await run.lifecycle.leave(); }
1368
- }, 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);
1970
+ 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);
1369
1971
  run.execution = execution;
1370
1972
  const completion = execution.result.then(async (value) => {
1371
1973
  await scheduler.flush();
@@ -1388,7 +1990,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1388
1990
  run.completion = completion;
1389
1991
  void completion;
1390
1992
  };
1391
- const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
1993
+ const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
1392
1994
  const run = runs.get(request.runId);
1393
1995
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
1394
1996
  if (!approved) return { state: "budget_exhausted", approved: false };
@@ -1397,14 +1999,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1397
1999
  const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
1398
2000
  run.budget = runtime;
1399
2001
  await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
1400
- await coldResumeRun(run, false, {}, true);
2002
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1401
2003
  return { state: "running", approved: true };
1402
2004
  };
1403
- const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
2005
+ const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
1404
2006
  const run = runs.get(runId);
1405
- if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2007
+ if (!run) {
2008
+ const host = object(context) ? context : {};
2009
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2010
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2011
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2012
+ if (cwd && sessionId) {
2013
+ try {
2014
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2015
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2016
+ } catch (error) {
2017
+ if (error instanceof WorkflowError) throw error;
2018
+ }
2019
+ }
2020
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2021
+ }
1406
2022
  const loaded = await run.store.load();
1407
- if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2023
+ if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1408
2024
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1409
2025
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1410
2026
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1425,11 +2041,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1425
2041
  run.budget = runtime;
1426
2042
  await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
1427
2043
  }
1428
- await coldResumeRun(run, false, {}, true);
2044
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1429
2045
  return { state: "running" };
1430
2046
  };
1431
2047
  const retryReservations = new Set<string>();
1432
- const retryWorkflowRun = async (runId: string, context: unknown): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
2048
+ const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
1433
2049
  if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
1434
2050
  const host = object(context) ? context : {};
1435
2051
  const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
@@ -1439,8 +2055,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1439
2055
  await ensureSessionLease(cwd, sessionId);
1440
2056
  const sourceStore = new RunStore(cwd, sessionId, runId, home);
1441
2057
  let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
1442
- try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`); }
1443
- if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
2058
+ try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`); }
2059
+ if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1444
2060
  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"))) throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
1445
2061
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
1446
2062
  if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
@@ -1466,22 +2082,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1466
2082
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1467
2083
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1468
2084
  if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1469
- const active = new Set<string>(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1470
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1471
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1472
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1473
- const currentSettings = loadSettings(settingsPath);
1474
- resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
1475
- const currentAliases = currentSettings.modelAliases ?? {};
1476
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1477
2085
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
1478
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1479
2086
  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: "" };
1480
- if (hostModel.provider && hostModel.id) knownModels.add(`${hostModel.provider}/${hostModel.id}`);
1481
- const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
1482
- const resumeAliases = { ...previousAliases, ...currentAliases };
1483
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
1484
- 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);
2087
+ const rootModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
2088
+ 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 });
1485
2089
  await sourceStore.validateNamedWorktrees();
1486
2090
  for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
1487
2091
  const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
@@ -1490,7 +2094,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1490
2094
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1491
2095
  const childRunId = randomUUID();
1492
2096
  const childStore = new RunStore(cwd, sessionId, childRunId, home);
1493
- const childSnapshot = createLaunchSnapshot(loaded.snapshot);
2097
+ const childSnapshot = childBaseSnapshot;
1494
2098
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
1495
2099
  const childInitialBudget = childBudget.snapshot();
1496
2100
  const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -1499,13 +2103,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1499
2103
  const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
1500
2104
  const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
1501
2105
  const abortController = new AbortController();
1502
- const providerErrorRecovery = createProviderErrorRecovery(context, resumeModels, () => { abortController.abort(); });
2106
+ const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
1503
2107
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1504
- 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() as unknown as readonly RegisteredAgentSetupHook[], 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 } : {}) };
2108
+ 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 } : {}) };
1505
2109
  runs.set(childRunId, childRun);
1506
2110
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
1507
2111
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
1508
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry: modelRegistry ? { getAll: () => [...(modelRegistry.getAll?.() ?? [])], getAvailable: () => [...(modelRegistry.getAvailable?.() ?? [])] } : undefined });
2112
+ await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
1509
2113
  const completion = runs.get(childRunId)?.completion;
1510
2114
  if (completion) {
1511
2115
  childStarted = true;
@@ -1521,26 +2125,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1521
2125
  label: "Workflow Retry",
1522
2126
  description: "Retry a failed workflow run by replaying its completed structural operations",
1523
2127
  parameters: WORKFLOW_RETRY_PARAMETERS,
1524
- async execute(_id, params, _signal, _onUpdate, ctx) {
1525
- try { const result = await retryWorkflowRun(params.runId, ctx); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2128
+ async execute(_id, params, signal, _onUpdate, ctx) {
2129
+ try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1526
2130
  catch (error) { throw mainAgentError(error); }
1527
2131
  },
2132
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2133
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1528
2134
  });
1529
2135
  pi.registerTool({
1530
2136
  name: "workflow_resume",
1531
2137
  label: "Workflow Resume",
1532
2138
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
1533
2139
  parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
1534
- async execute(_id, params) {
1535
- try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2140
+ async execute(_id, params, signal, _onUpdate, ctx) {
2141
+ try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1536
2142
  catch (error) { throw mainAgentError(error); }
1537
2143
  },
2144
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2145
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1538
2146
  });
1539
2147
  pi.on("session_start", async (_event, ctx) => {
1540
2148
  if (sessionStarted) return;
1541
2149
  sessionStarted = true;
1542
2150
  registry.freeze();
1543
- registerCatalog();
2151
+ registerCatalog(ctx.cwd, projectTrusted(ctx));
1544
2152
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1545
2153
  try {
1546
2154
  for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
@@ -1565,7 +2173,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1565
2173
  const roleDefinitions = loaded.snapshot.roles ?? {};
1566
2174
  const abortController = new AbortController();
1567
2175
  const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
1568
- 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() as unknown as readonly RegisteredAgentSetupHook[], 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 } : {}) });
2176
+ 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 } : {}) });
1569
2177
  for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
1570
2178
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
1571
2179
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -1590,7 +2198,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1590
2198
  });
1591
2199
  pi.on("before_agent_start", (event, ctx) => {
1592
2200
  if (!pi.getActiveTools().includes("workflow")) return;
1593
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
2201
+ const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx), typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : undefined)).filter(([, definition]) => definition.description);
1594
2202
  if (!roles.length) return;
1595
2203
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
1596
2204
  return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
@@ -1605,27 +2213,29 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1605
2213
  try {
1606
2214
  const headless = object(ctx) && ctx.headless === true;
1607
2215
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1608
- const defaults = loadSettings(settingsPath);
1609
2216
  if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
1610
- const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
1611
2217
  const budget = validateBudget(params.budget);
1612
2218
  const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
1613
2219
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
1614
2220
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
1615
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
1616
- knownModels.add(rootModelName);
1617
- const availableModels = knownModels;
1618
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2221
+ const inventory = modelInventory(rootModel, modelRegistry);
2222
+ const knownModels = inventory.knownModels;
2223
+ const availableModels = inventory.availableModels;
2224
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
1619
2225
  const trustedProject = projectTrusted(ctx);
1620
- if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
1621
- 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);
2226
+ const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
2227
+ const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
2228
+ const runController = new AbortController();
2229
+ if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
2230
+ const resolvedAliases = await resolveLaunchAliases(registry, launch.settings.modelAliases ?? {}, { cwd: launchCwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: runController.signal }, availableModels, knownModels, settingsPath);
2231
+ const modelAliases = resolvedAliases.aliases;
2232
+ const settings = Object.freeze({ ...launch.settings, ...(Object.keys(modelAliases).length ? { modelAliases } : {}) });
2233
+ const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath }, registry);
1622
2234
  const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
1623
2235
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1624
2236
  const runId = randomUUID();
1625
2237
  const args = (params.args ?? null) as JsonValue;
1626
2238
  encoded(args);
1627
- const runController = new AbortController();
1628
- if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
1629
2239
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
1630
2240
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
1631
2241
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
@@ -1633,9 +2243,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1633
2243
  if (parentRunId !== undefined) await store.validateParentRun(parentRunId);
1634
2244
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
1635
2245
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
1636
- const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
2246
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
1637
2247
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
1638
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2248
+ 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" as const, 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 });
1639
2249
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
1640
2250
  const initialBudget = budgetRuntime.snapshot();
1641
2251
  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);
@@ -1643,37 +2253,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1643
2253
  const background = !params.foreground;
1644
2254
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1645
2255
  const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
1646
- 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() as unknown as readonly RegisteredAgentSetupHook[], agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx), settingsPath), runContext }, createSession);
2256
+ 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 });
1647
2257
  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 } : {}) });
1648
2258
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
1649
2259
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
1650
- 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) => {
1651
- await lifecycle.enter();
1652
- try {
1653
- const path = agentIdentityPath(identity);
1654
- const replayed = await store.replay(path);
1655
- if (replayed) {
1656
- return replayed.value;
1657
- }
1658
- const worktree = agentWorktree(identity);
1659
- const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
1660
- const role = typeof options.role === "string" ? options.role : undefined;
1661
- const model = typeof options.model === "string" ? options.model : undefined;
1662
- const thinking = parseThinking(options.thinking);
1663
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1664
- 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 as string[] } : {}) });
1665
- const label = displayAgentName(requestedLabel, role, resolved.model);
1666
- const tools = resolved.tools;
1667
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1668
- 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 });
1669
- const cancel = () => { scheduler.cancel(spawned.id); };
1670
- if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
1671
- const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
1672
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1673
- await store.complete(path, outcome.value);
1674
- return outcome.value;
1675
- } finally { await lifecycle.leave(); }
1676
- }, 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);
2260
+ 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);
1677
2261
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
1678
2262
  await eventPublisher.runStarted(store, checked.metadata);
1679
2263
  const finish = execution.result.then(async (value) => {
@@ -1754,7 +2338,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1754
2338
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
1755
2339
  };
1756
2340
  let stores = await loadStores();
1757
- 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."
2341
+ 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."
1758
2342
  const setWorkflowStatus = (text: string | undefined) => {
1759
2343
  const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
1760
2344
  setStatus?.call(ctx.ui, "workflow-stop", text);
@@ -1771,12 +2355,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1771
2355
  return keepContext ? "dashboard" : "done";
1772
2356
  }
1773
2357
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
1774
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
2358
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
1775
2359
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
1776
2360
  return keepContext ? "dashboard" : "done";
1777
2361
  }
1778
2362
  if (action === "delete" && stored) {
1779
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
2363
+ if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
1780
2364
  if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
1781
2365
  await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
1782
2366
  }
@@ -1784,12 +2368,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1784
2368
  if (action === "resume" && run) {
1785
2369
  if (run.lifecycle.state === "budget_exhausted") {
1786
2370
  const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
1787
- const result = await resumeWorkflowRun(run.store.runId, patch);
2371
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
1788
2372
  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");
1789
2373
  } else {
1790
2374
  if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
1791
2375
  else {
1792
- if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
2376
+ if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
1793
2377
  await run.lifecycle.resume();
1794
2378
  }
1795
2379
  ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
@@ -1799,7 +2383,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1799
2383
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
1800
2384
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
1801
2385
  if (input === undefined) return keepContext ? "dashboard" : "done";
1802
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2386
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
1803
2387
  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");
1804
2388
  return keepContext ? "dashboard" : "done";
1805
2389
  }
@@ -1824,6 +2408,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1824
2408
  };
1825
2409
  const manageAliases = async (): Promise<void> => {
1826
2410
  const settingsPath = workflowSettingsPath(extensionAgentDir);
2411
+ let aliasSettingsPath = settingsPath;
2412
+ const trustedProject = projectTrusted(ctx);
1827
2413
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
1828
2414
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
1829
2415
  const selectTarget = async (aliases: Readonly<Record<string, string>>): Promise<string | undefined> => {
@@ -1834,13 +2420,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1834
2420
  return (await ctx.ui.input("Manual model ID", "provider/model[:thinking] or alias[:thinking]"))?.trim() || undefined;
1835
2421
  };
1836
2422
  const save = (aliases: Readonly<Record<string, string>>): boolean => {
1837
- try { saveModelAliases(settingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info"); return true; }
1838
- catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
2423
+ try { saveModelAliases(aliasSettingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${aliasSettingsPath}.`, "info"); return true; }
2424
+ catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
1839
2425
  };
1840
2426
  for (;;) {
1841
2427
  let aliases: Readonly<Record<string, string>>;
1842
- try { aliases = loadSettings(settingsPath).modelAliases ?? {}; }
1843
- catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
2428
+ try { const resolution = resolveWorkflowSettings(ctx.cwd, trustedProject, settingsPath); aliases = resolution.effective.modelAliases ?? {}; aliasSettingsPath = resolution.sources.modelAliases; }
2429
+ catch (error) { ctx.ui.notify(`${trustedProject ? workflowProjectSettingsPath(ctx.cwd) : settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
1844
2430
  const names = Object.keys(aliases).sort();
1845
2431
  const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
1846
2432
  const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
@@ -1853,8 +2439,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1853
2439
  const target = await selectTarget(aliases);
1854
2440
  if (!target) continue;
1855
2441
  const next = { ...aliases, [name]: target };
1856
- try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
1857
- const parsed = resolveModelReference(target, next, new Set(available()), settingsPath);
2442
+ try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
2443
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
1858
2444
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
1859
2445
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
1860
2446
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
@@ -1867,8 +2453,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1867
2453
  const target = await selectTarget(aliases);
1868
2454
  if (!target) continue;
1869
2455
  const next = { ...aliases, [edit[1]]: target };
1870
- try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
1871
- const parsed = resolveModelReference(target, next, new Set(available()), settingsPath);
2456
+ try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
2457
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
1872
2458
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
1873
2459
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
1874
2460
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
@@ -1897,9 +2483,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1897
2483
  }
1898
2484
  const sorted = navigatorAttentionSort(stores);
1899
2485
  const labels = navigatorRunLabels(sorted);
1900
- const terminalStates = new Set(["completed", "failed", "stopped"]);
2486
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
1901
2487
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
1902
- const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
2488
+ const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2489
+ const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
1903
2490
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
1904
2491
  if (!runChoice || runChoice === "Close") return;
1905
2492
  if (runChoice === "Inspect session in pane") {
@@ -1919,6 +2506,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1919
2506
  }
1920
2507
  ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
1921
2508
  }
2509
+ if (runChoice === "Delete all failed") {
2510
+ if (!await ctx.ui.confirm("Delete failed runs?", "Delete all failed workflow runs and their artifacts? This cannot be undone.")) continue;
2511
+ for (const entry of sorted) {
2512
+ if (entry.loaded.run.state === "failed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
2513
+ }
2514
+ ctx.ui.notify("Deleted all failed workflow runs.", "info"); stores = await loadStores(); continue;
2515
+ }
1922
2516
  const runIndex = labels.indexOf(runChoice);
1923
2517
  if (runIndex < 0) return;
1924
2518
  const selected = sorted[runIndex];
@@ -1936,6 +2530,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1936
2530
  const loaded = await store.load();
1937
2531
  const checkpoints = await store.awaitingCheckpoints();
1938
2532
  const worktrees = await store.worktrees();
2533
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2534
+ const agentResults = new Map<string, JsonValue>();
2535
+ for (const agent of loaded.run.agents) {
2536
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
2537
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2538
+ if (operation) agentResults.set(agent.id, operation.value);
2539
+ }
1939
2540
  const actions = new Map<string, string>();
1940
2541
  const copies = new Map<string, { value: string; artifact: string }>();
1941
2542
  const reviews = new Map<string, AwaitingCheckpoint>();
@@ -1961,96 +2562,135 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1961
2562
  }
1962
2563
  }
1963
2564
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
1964
- else actions.set("View script", "view-script");
1965
- if (loaded.run.agents.length) actions.set("Agents...", "agents");
2565
+ else actions.set("Open script in editor", "open-script");
2566
+ if (ctx.mode !== "tui" && loaded.run.agents.length) actions.set("Agents...", "agents");
1966
2567
  if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
1967
2568
  if (ctx.mode === "tui") {
1968
2569
  addCopy("Copy run path", store.directory, "run path");
1969
2570
  addCopy("Copy run ID", store.runId, "run ID");
1970
2571
  }
1971
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
2572
+ 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 };
1972
2573
  };
1973
- const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
1974
- const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
1975
- const title = (agent: AgentRecord): string => {
1976
- const parents: string[] = [];
1977
- for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
1978
- const parent = byId.get(parentId);
1979
- if (!parent) break;
1980
- parents.unshift(parent.label ?? parent.name);
1981
- }
1982
- return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
1983
- };
1984
- const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
1985
- const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
1986
- const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
1987
- const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
1988
- if (!selected) return;
1989
- const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
1990
- const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
1991
- const actions = [
1992
- ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2574
+ const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
2575
+ const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
2576
+ const worktree = agentWorktreeFor(dashboard, agent);
2577
+ return [
2578
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
1993
2579
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
2580
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
1994
2581
  "Copy agent ID",
1995
2582
  "Back",
1996
2583
  ];
1997
- const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
1998
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
1999
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2000
- const index = choice ? choices.indexOf(choice) : -1;
2001
- return index >= 0 ? attempts[index] : undefined;
2002
- };
2584
+ };
2585
+ const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
2586
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2587
+ const worktree = agentWorktreeFor(dashboard, agent);
2588
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2589
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2590
+ const index = choice ? choices.indexOf(choice) : -1;
2591
+ const attempt = index >= 0 ? attempts[index] : undefined;
2592
+ if (!attempt) return;
2593
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2594
+ 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?")) return;
2595
+ try {
2596
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2597
+ ctx.ui.notify("Forked Pi session in pane.", "info");
2598
+ } catch (error) {
2599
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2600
+ }
2601
+ };
2602
+ const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
2603
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2604
+ const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
2605
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2606
+ let selected: AgentRecord | undefined;
2607
+ if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
2608
+ else {
2609
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2610
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2611
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
2612
+ }
2613
+ if (!selected) return;
2614
+ const worktree = agentWorktreeFor(dashboard, selected);
2615
+ const actions = agentActionLabels(dashboard, selected);
2003
2616
  for (;;) {
2004
2617
  const action = await ctx.ui.select(title(selected), actions);
2005
2618
  if (!action || action === "Back") return;
2006
2619
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
2007
2620
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
2008
2621
  if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
2009
- if (action === "Fork as Pi session in pane") {
2010
- const attempt = await chooseAttempt();
2011
- if (!attempt) continue;
2012
- const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2013
- 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?")) continue;
2014
- try {
2015
- await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2016
- ctx.ui.notify("Forked Pi session in pane.", "info");
2017
- } catch (error) {
2018
- ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2019
- }
2020
- }
2622
+ if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
2021
2623
  }
2022
2624
  };
2023
2625
  for (;;) {
2024
2626
  let view = await loadDashboard();
2025
2627
  const actionChoice = ctx.mode === "tui"
2026
2628
  ? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2027
- let options = [...view.actions.keys(), "Close"];
2028
- let selectedIndex = 0;
2029
2629
  let dashboardOffset = 0;
2030
2630
  let refreshing = false;
2031
2631
  let disposed = false;
2632
+ let detailsMode = false;
2633
+ let actionMode = false;
2634
+ let actionIndex = 0;
2032
2635
  let stopRequested = false;
2033
2636
  let stopStatus: string | undefined;
2637
+ let selectionNeedsScroll = true;
2638
+ let renderedWidth = 80;
2639
+ let refreshGeneration = 0;
2640
+ const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2641
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
2642
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
2643
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2034
2644
  const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2035
- const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2645
+ const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "", right: "", pageUp: "pgup", pageDown: "pgdn" };
2036
2646
  const keyLabel = (binding: string, fallback: string) => {
2037
2647
  const keys = keybindingKeys(keybindings, binding);
2038
2648
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2039
2649
  };
2040
- const dashboardLayout = () => {
2041
- const rows = terminalRows();
2042
- const hintRows = rows >= 4 ? 1 : 0;
2043
- const separatorRows = rows >= 8 ? 1 : 0;
2044
- const available = Math.max(1, rows - hintRows - separatorRows);
2045
- const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
2046
- return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
2650
+ const selectedAgentRecord = (): AgentRecord | undefined => {
2651
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2652
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
2653
+ };
2654
+ const actionOptions = () => {
2655
+ const agent = selectedAgentRecord();
2656
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
2047
2657
  };
2048
- const updateDashboard = async (selectedOption: string | undefined) => {
2658
+ let editorRunning = false;
2659
+ const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
2660
+ if (editorRunning) return;
2661
+ editorRunning = true;
2662
+ try {
2663
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
2664
+ if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
2665
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
2666
+ if (exitCode !== 0) {
2667
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
2668
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
2669
+ }
2670
+ } catch (error) {
2671
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2672
+ } finally {
2673
+ editorRunning = false;
2674
+ tui.requestRender(true);
2675
+ }
2676
+ };
2677
+ const updateDashboard = async () => {
2678
+ const generation = ++refreshGeneration;
2679
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2049
2680
  const next = await loadDashboard();
2050
- if (disposed) return;
2681
+ if (disposed || generation !== refreshGeneration) return;
2682
+ const previousNodeId = selectedNodeId;
2683
+ const previousExpanded = expandedNodeIds;
2684
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2051
2685
  view = next;
2052
- options = [...view.actions.keys(), "Close"];
2053
- selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
2686
+ tree = buildWorkflowPhaseTree(view.phaseModel);
2687
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
2688
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
2689
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
2690
+ const nextActions = actionOptions();
2691
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
2692
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
2693
+ selectionNeedsScroll = true;
2054
2694
  tui.requestRender();
2055
2695
  };
2056
2696
  const requestStop = () => {
@@ -2058,14 +2698,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2058
2698
  stopRequested = true;
2059
2699
  stopStatus = undefined;
2060
2700
  setWorkflowStatus(undefined);
2061
- const selectedOption = options[selectedIndex];
2062
2701
  void runAction(`stop ${store.runId}`, true, (status) => {
2063
2702
  stopStatus = status;
2064
2703
  setWorkflowStatus(status);
2065
2704
  if (!disposed) tui.requestRender();
2066
- }).then(() => updateDashboard(selectedOption)).catch((error: unknown) => {
2705
+ }).then(() => updateDashboard()).catch((error: unknown) => {
2067
2706
  if (disposed) return;
2068
2707
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
2708
+ setWorkflowStatus(stopStatus);
2069
2709
  tui.requestRender();
2070
2710
  }).finally(() => {
2071
2711
  stopRequested = false;
@@ -2075,89 +2715,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2075
2715
  const timer = setInterval(() => {
2076
2716
  if (refreshing || stopRequested) return;
2077
2717
  refreshing = true;
2078
- const selectedOption = options[selectedIndex];
2079
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
2718
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2080
2719
  }, 1000);
2081
2720
  timer.unref();
2082
2721
  return borderWorkflowOverlay({
2083
2722
  render(width: number) {
2084
- const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
2085
- const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
2086
- const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
2087
- const layout = dashboardLayout();
2088
- 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] ?? "";
2089
- const compact = [...dashboardLines, "", ...actionLines, "", hint];
2090
- if (compact.length <= layout.rows) { dashboardOffset = 0; return compact; }
2091
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
2723
+ renderedWidth = width;
2724
+ const narrow = width < 80;
2725
+ const styles = themeWorkflowProgressStyles(theme);
2726
+ const agent = selectedAgentRecord();
2727
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
2728
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
2729
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
2730
+ const content = [...statusLines, ...phaseLines];
2731
+ const rows = terminalRows();
2732
+ const hintRows = rows >= 3 ? 1 : 0;
2733
+ const viewport = Math.max(1, rows - hintRows);
2734
+ const maxOffset = Math.max(0, content.length - viewport);
2735
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2736
+ if (actionMode) {
2737
+ const label = actions?.options[actionIndex];
2738
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
2739
+ if (actionRow >= 0) {
2740
+ if (actionRow < dashboardOffset) dashboardOffset = actionRow;
2741
+ else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
2742
+ }
2743
+ } else if (!detailsMode && selectionNeedsScroll) {
2744
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
2745
+ if (selectedRow >= 0) {
2746
+ if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
2747
+ else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
2748
+ }
2749
+ selectionNeedsScroll = false;
2750
+ }
2092
2751
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2093
- const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
2094
- return [
2095
- ...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
2096
- ...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
2097
- ...actionLines.slice(actionStart, actionStart + layout.actionViewport),
2098
- ...(layout.hintRows ? [hint] : []),
2099
- ];
2752
+ 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] ?? "";
2753
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2100
2754
  },
2101
2755
  invalidate() {},
2102
2756
  handleInput(data: string) {
2103
- if (stopRequested) return;
2104
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2105
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
2106
- else if (keybindings.matches(data, "tui.select.pageUp")) {
2107
- dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
2108
- }
2109
- else if (keybindings.matches(data, "tui.select.pageDown")) {
2110
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
2757
+ if (stopRequested || editorRunning) return;
2758
+ const narrow = renderedWidth < 80;
2759
+ if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
2760
+ if (actionMode) {
2761
+ const options = actionOptions();
2762
+ if (keybindings.matches(data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
2763
+ if (keybindings.matches(data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
2764
+ else if (keybindings.matches(data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
2765
+ else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2766
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2767
+ else if (keybindings.matches(data, "tui.select.confirm")) {
2768
+ const action = options[actionIndex];
2769
+ const agent = selectedAgentRecord();
2770
+ if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
2771
+ else if (agent) {
2772
+ const worktree = agentWorktreeFor(view, agent);
2773
+ if (action === "Open result in editor") {
2774
+ const result = view.agentResults.get(agent.id);
2775
+ if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
2776
+ }
2777
+ else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
2778
+ else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
2779
+ else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
2780
+ else done(`__workflow_fork__:${agent.id}`);
2781
+ }
2782
+ else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
2783
+ else if (action === "Stop") requestStop();
2784
+ else done(action);
2785
+ }
2786
+ tui.requestRender();
2787
+ return;
2111
2788
  }
2789
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2790
+ if (keybindings.matches(data, "tui.select.cancel")) {
2791
+ if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done(undefined);
2792
+ } else if (narrow && detailsMode) {
2793
+ if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2794
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2795
+ else if (keybindings.matches(data, "tui.select.confirm")) {
2796
+ if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2797
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2798
+ }
2799
+ } else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
2800
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
2801
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2802
+ } else if (keybindings.matches(data, "tui.editor.cursorRight")) {
2803
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
2804
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2805
+ } else if (keybindings.matches(data, "tui.select.up")) {
2806
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
2807
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2808
+ } else if (keybindings.matches(data, "tui.select.down")) {
2809
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
2810
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2811
+ } else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2812
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2112
2813
  else if (keybindings.matches(data, "tui.select.confirm")) {
2113
- if (options[selectedIndex] === "Stop") requestStop();
2114
- else done(options[selectedIndex]);
2814
+ if (narrow) detailsMode = true;
2815
+ else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2816
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2115
2817
  }
2116
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2117
2818
  tui.requestRender();
2118
2819
  },
2119
2820
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2120
2821
  }, theme);
2121
- }, { overlay: true })
2822
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
2122
2823
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2123
2824
  if (!actionChoice || actionChoice === "Close") return;
2124
2825
  if (actionChoice === "Agents...") { await selectAgent(view); continue; }
2125
- if (actionChoice === "Refresh") continue;
2126
- if (actionChoice === "View script") {
2127
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2128
- const highlighted = highlightCode(view.script, "javascript");
2129
- let offset = 0;
2130
- let renderedLines: string[] = [];
2131
- const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
2132
- const move = (delta: number) => {
2133
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2134
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
2135
- };
2136
- return borderWorkflowOverlay({
2137
- render(width: number) {
2138
- renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2139
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2140
- offset = Math.min(offset, maxOffset);
2141
- return [
2142
- theme.fg("accent", "Workflow script"),
2143
- ...renderedLines.slice(offset, offset + viewport()),
2144
- "",
2145
- theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
2146
- ];
2147
- },
2148
- invalidate() {},
2149
- handleInput(data: string) {
2150
- if (keybindings.matches(data, "tui.select.up")) move(-1);
2151
- else if (keybindings.matches(data, "tui.select.down")) move(1);
2152
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
2153
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
2154
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2155
- tui.requestRender();
2156
- },
2157
- }, theme);
2158
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
2826
+ if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
2827
+ if (actionChoice.startsWith("__workflow_fork__:")) {
2828
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
2829
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
2830
+ if (agent) await forkAgentSession(view, agent);
2159
2831
  continue;
2160
2832
  }
2833
+ if (actionChoice === "Refresh") continue;
2161
2834
  const copy = view.copies.get(actionChoice);
2162
2835
  if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
2163
2836
  if (actionChoice.startsWith("Review ")) {
@@ -2211,7 +2884,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2211
2884
  tui.requestRender();
2212
2885
  },
2213
2886
  }, theme);
2214
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
2887
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
2215
2888
  if (decision) {
2216
2889
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
2217
2890
  if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
@@ -2231,9 +2904,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2231
2904
  pi.on("session_shutdown", async () => {
2232
2905
  try {
2233
2906
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2234
- if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
2235
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2236
- try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
2907
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
2908
+ if (!isTerminal) {
2909
+ try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
2237
2910
  run.abortController.abort();
2238
2911
  run.execution?.cancel();
2239
2912
  await scheduler.cancelRun(runId);