pi-extensible-workflows 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts CHANGED
@@ -6,16 +6,16 @@ import { fileURLToPath } from "node:url";
6
6
  import { Type } from "@earendil-works/pi-ai";
7
7
  import { Value } from "typebox/value";
8
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 { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
10
10
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
11
11
  import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
12
12
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
13
13
  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";
14
+ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelAliasErrorName, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
15
+ import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
16
16
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
17
17
  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";
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 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
19
  const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
20
20
  export interface WorkflowProgressStyles {
21
21
  accent(text: string): string;
@@ -26,7 +26,25 @@ export interface WorkflowProgressStyles {
26
26
  dim(text: string): string;
27
27
  bold(text: string): string;
28
28
  }
29
+ function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
30
+ const empty = { skills: [], extensions: [] };
31
+ return { globalSettingsPath, projectSettingsPath: join(cwd, ".pi", "pi-extensible-workflows", "settings.json"), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
32
+ }
29
33
  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 };
34
+ type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy; modelSettingsPath: string };
35
+ function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
36
+ const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
37
+ const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
38
+ return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath), modelSettingsPath: resolution.sources.modelAliases };
39
+ }
40
+ function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
41
+ function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
42
+ const settings: WorkflowSettings = { ...snapshot.settings, concurrency: snapshot.settingsSources === undefined || snapshot.settingsSources.concurrency === "per-run options" ? snapshot.settings.concurrency : resolution.effective.concurrency, modelAliases };
43
+ if (resolution.effective.disabledAgentResources === undefined) delete settings.disabledAgentResources;
44
+ else settings.disabledAgentResources = resolution.effective.disabledAgentResources;
45
+ 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 };
46
+ return { settings, ...(settingsSources === undefined ? {} : { settingsSources }) };
47
+ }
30
48
  const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
31
49
 
32
50
  function workflowDetail(message: string): string {
@@ -162,7 +180,91 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
162
180
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
163
181
 
164
182
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
165
-
183
+ export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
184
+ export interface WorkflowPhaseAgentCounts { total: number; completed: number; running: number; failed: number; cancelled: number; pending: number }
185
+ export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; status: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
186
+ export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
187
+ type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
188
+ function phaseNames(source: WorkflowPhaseSource): string[] {
189
+ const phases: readonly unknown[] = source === undefined ? [] : Array.isArray(source) ? source : (source as Pick<LaunchSnapshot, "phases">).phases ?? [];
190
+ return phases.filter((phase): phase is string => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
191
+ }
192
+ function phaseAgentCounts(agents: readonly AgentRecord[]): WorkflowPhaseAgentCounts {
193
+ const counts: WorkflowPhaseAgentCounts = { total: agents.length, completed: 0, running: 0, failed: 0, cancelled: 0, pending: 0 };
194
+ for (const agent of agents) {
195
+ if (agent.state === "completed") counts.completed += 1;
196
+ else if (agent.state === "running") counts.running += 1;
197
+ else if (agent.state === "failed") counts.failed += 1;
198
+ else if (agent.state === "cancelled") counts.cancelled += 1;
199
+ else counts.pending += 1;
200
+ }
201
+ return counts;
202
+ }
203
+ function phaseState(runState: RunState, counts: WorkflowPhaseAgentCounts, isLatest: boolean): WorkflowPhaseState {
204
+ if (!isLatest) return "completed";
205
+ if (runState === "failed") return "failed";
206
+ if (runState === "stopped") return "cancelled";
207
+ if (runState === "interrupted") return "interrupted";
208
+ if (runState === "budget_exhausted") return "budget_exhausted";
209
+ if (counts.failed > 0) return "failed";
210
+ if (counts.cancelled > 0) return "cancelled";
211
+ if (counts.running > 0 || counts.pending > 0) return "running";
212
+ return runState === "completed" ? "completed" : "running";
213
+ }
214
+ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase" | "phaseHistory" | "agents">, source?: WorkflowPhaseSource): WorkflowPhaseModel {
215
+ const declaredPhases = phaseNames(source);
216
+ const rawHistory: readonly unknown[] = Array.isArray(run.phaseHistory) ? run.phaseHistory : [];
217
+ const observed: Array<{ name: string; afterAgent: number }> = [];
218
+ let boundary = 0;
219
+ for (const record of rawHistory) {
220
+ if (!object(record) || typeof record.phase !== "string" || !record.phase.trim() || typeof record.afterAgent !== "number" || !Number.isSafeInteger(record.afterAgent)) continue;
221
+ boundary = Math.max(boundary, Math.min(run.agents.length, Math.max(0, record.afterAgent)));
222
+ observed.push({ name: record.phase.trim(), afterAgent: boundary });
223
+ }
224
+ if (!observed.length && typeof run.phase === "string" && run.phase.trim()) observed.push({ name: run.phase.trim(), afterAgent: 0 });
225
+ const observedEntries = observed.map((entry, index) => ({ ...entry, index, agents: run.agents.slice(entry.afterAgent, observed[index + 1]?.afterAgent ?? run.agents.length) }));
226
+ const matchedDeclarations = new Set<number>();
227
+ const declarationIndices = observedEntries.map((entry) => {
228
+ const index = declaredPhases.findIndex((name, candidate) => !matchedDeclarations.has(candidate) && name === entry.name);
229
+ if (index >= 0) matchedDeclarations.add(index);
230
+ return index >= 0 ? index : undefined;
231
+ });
232
+ const entries: Array<{ name: string; observedIndex?: number; declarationIndex?: number }> = observedEntries.map((entry, index) => ({ name: entry.name, observedIndex: index, ...(declarationIndices[index] === undefined ? {} : { declarationIndex: declarationIndices[index] }) }));
233
+ for (const [declarationIndex, name] of declaredPhases.entries()) {
234
+ if (matchedDeclarations.has(declarationIndex)) continue;
235
+ const insertion = entries.findIndex((entry) => entry.declarationIndex !== undefined && entry.declarationIndex > declarationIndex);
236
+ const pending = { name };
237
+ if (insertion < 0) entries.push(pending); else entries.splice(insertion, 0, pending);
238
+ }
239
+ const occurrences = new Map<string, number>();
240
+ const phases = entries.map((entry) => {
241
+ const occurrence = (occurrences.get(entry.name) ?? 0) + 1;
242
+ occurrences.set(entry.name, occurrence);
243
+ const observation = entry.observedIndex === undefined ? undefined : observedEntries[entry.observedIndex];
244
+ const agents = observation?.agents ?? [];
245
+ const counts = phaseAgentCounts(agents);
246
+ const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
247
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, status: state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
248
+ });
249
+ let currentPhaseIndex: number | undefined;
250
+ for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
251
+ const counts: Partial<Record<WorkflowPhaseState, number>> = {};
252
+ for (const phase of phases) counts[phase.state] = (counts[phase.state] ?? 0) + 1;
253
+ const current = currentPhaseIndex === undefined ? undefined : phases[currentPhaseIndex];
254
+ const assigned = new Set(observedEntries.flatMap(({ agents }) => agents.map((agent) => agent.id)));
255
+ const unassignedAgents = run.agents.filter((agent) => !assigned.has(agent.id));
256
+ const result: WorkflowPhaseModel = { declaredPhases, phases, counts };
257
+ if (current !== undefined && currentPhaseIndex !== undefined) { result.currentPhaseIndex = currentPhaseIndex; result.currentPhaseId = current.id; }
258
+ if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
259
+ return result;
260
+ }
261
+ export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
262
+ export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
263
+ const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
264
+ if (!phase) return {};
265
+ const agentId = phase.agents.some((agent) => agent.id === selection.agentId) ? selection.agentId : phase.agents[0]?.id;
266
+ return { phaseId: phase.id, ...(agentId === undefined ? {} : { agentId }) };
267
+ }
166
268
  type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
167
269
  function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
168
270
  function agentGroupLabel(agents: readonly AgentRecord[]): string {
@@ -175,7 +277,8 @@ function agentGroups(agents: readonly AgentRecord[], allAgents: readonly AgentRe
175
277
  const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
176
278
  for (const [index, agent] of agents.entries()) {
177
279
  let depth = 0;
178
- for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
280
+ const seen = new Set<string>([agent.id]);
281
+ 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
282
  const key = agentGroupKey(agent);
180
283
  const group = groups.get(key) ?? { agents: [] };
181
284
  group.agents.push({ agent, index, depth });
@@ -242,6 +345,99 @@ function textBlock(text: string) {
242
345
  invalidate() {},
243
346
  };
244
347
  }
348
+ function styledTextBlock(text: string) {
349
+ return {
350
+ render(width: number) {
351
+ return truncateWorkflowProgress(text, width);
352
+ },
353
+ invalidate() {},
354
+ };
355
+ }
356
+ function workflowCatalogBlock(text: string, expanded: boolean) {
357
+ return {
358
+ render(width: number) {
359
+ const safeWidth = Math.max(1, width);
360
+ if (!expanded) return truncateWorkflowProgress(text, safeWidth);
361
+ return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines.map((line) => line.trimEnd());
362
+ },
363
+ invalidate() {},
364
+ };
365
+ }
366
+
367
+ function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
368
+
369
+ type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
370
+
371
+ function catalogResultValue(result: CatalogToolResult): unknown {
372
+ if (result.details !== undefined) return result.details;
373
+ const text = result.content?.find((entry) => entry.type === "text")?.text;
374
+ if (!text) return undefined;
375
+ try { return JSON.parse(text) as unknown; } catch { return text; }
376
+ }
377
+
378
+ function isCatalogIndex(value: unknown): value is WorkflowCatalogIndex {
379
+ return object(value) && Array.isArray(value.functions) && Array.isArray(value.variables);
380
+ }
381
+
382
+ function isCatalogFunction(value: unknown): value is WorkflowCatalogFunction {
383
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.input) && object(value.output);
384
+ }
385
+
386
+ function isCatalogVariable(value: unknown): value is WorkflowCatalogVariable {
387
+ return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.schema);
388
+ }
389
+
390
+ function isCatalogError(value: unknown): value is { error: { message: string } } {
391
+ return object(value) && object(value.error) && typeof value.error.message === "string";
392
+ }
393
+
394
+ function catalogSectionTitle(label: string, count: number, theme: Theme): string {
395
+ return theme.fg("accent", theme.bold(`${label} (${String(count)})`));
396
+ }
397
+
398
+ function catalogIndexEntries(entries: readonly { name: string; description: string }[], theme: Theme): string[] {
399
+ const width = Math.max(0, ...entries.map((entry) => entry.name.length));
400
+ return entries.map((entry) => ` ${theme.fg("accent", entry.name.padEnd(width))} ${theme.fg("toolOutput", catalogText(entry.description))}`);
401
+ }
402
+
403
+ function formatCatalogIndex(catalog: WorkflowCatalogIndex, theme: Theme): string {
404
+ 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" }));
405
+ const aliasWidth = Math.max(0, ...aliases.map(({ name }) => name.length));
406
+ const aliasLines = aliases.map(({ name, kind, provenance }) => ` ${theme.fg("accent", name.padEnd(aliasWidth))} ${theme.fg("toolOutput", `${kind} · ${provenance}`)}`);
407
+ return [
408
+ catalogSectionTitle("Functions", catalog.functions.length, theme),
409
+ ...catalogIndexEntries(catalog.functions, theme),
410
+ "",
411
+ catalogSectionTitle("Variables", catalog.variables.length, theme),
412
+ ...catalogIndexEntries(catalog.variables, theme),
413
+ "",
414
+ catalogSectionTitle("Model aliases", aliases.length, theme),
415
+ ...aliasLines,
416
+ ].join("\n");
417
+ }
418
+
419
+ function catalogSchemaLines(schema: unknown, theme: Theme): string[] {
420
+ const json = JSON.stringify(schema, null, 2);
421
+ return json.split("\n").map((line) => ` ${theme.fg("toolOutput", line)}`);
422
+ }
423
+
424
+ function formatCatalogDetail(value: WorkflowCatalogFunction | WorkflowCatalogVariable | import("./types.js").WorkflowCatalogModelAlias, expanded: boolean, theme: Theme): string {
425
+ 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");
426
+ const kind = "input" in value ? "Function" : "Variable";
427
+ 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");
428
+ 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"))];
429
+ if ("input" in value) lines.push(theme.fg("muted", "Input schema"), ...catalogSchemaLines(value.input, theme), "", theme.fg("muted", "Output schema"), ...catalogSchemaLines(value.output, theme));
430
+ else lines.push(theme.fg("muted", "Variable schema"), ...catalogSchemaLines(value.schema, theme));
431
+ return lines.join("\n");
432
+ }
433
+
434
+ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme): string {
435
+ if (isCatalogIndex(value)) return formatCatalogIndex(value, theme);
436
+ if (isCatalogFunction(value) || isCatalogVariable(value)) return formatCatalogDetail(value, expanded, theme);
437
+ 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);
438
+ if (isCatalogError(value)) return theme.fg("error", value.error.message);
439
+ return theme.fg("error", "The workflow catalog returned an invalid result.");
440
+ }
245
441
 
246
442
  const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
247
443
  export function truncateWorkflowProgress(text: string, width: number): string[] {
@@ -264,7 +460,7 @@ function themeWorkflowProgressStyles(theme: Theme): WorkflowProgressStyles {
264
460
  warning: (text) => theme.fg("warning", text),
265
461
  muted: (text) => theme.fg("muted", text),
266
462
  dim: (text) => theme.fg("dim", text),
267
- bold: (text) => theme.bold(text),
463
+ bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
268
464
  };
269
465
  }
270
466
  function workflowProgressBlock(run: PersistedRun, theme: Theme) {
@@ -318,28 +514,29 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
318
514
  });
319
515
  }
320
516
 
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] : [];
517
+ export function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string[] {
518
+ const leaf = agent.label ?? agent.name;
519
+ const parts: string[] = includeStructuralPath && agent.structuralPath?.length ? [agent.structuralPath.join(" > ")] : [];
520
+ if (agent.parentBreadcrumb) parts.push(agent.parentBreadcrumb);
521
+ const ancestors: string[] = [];
324
522
  const seen = new Set<string>([agent.id]);
325
523
  for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
326
- if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
524
+ if (seen.has(parentId)) break;
327
525
  seen.add(parentId);
328
526
  const parent = byId.get(parentId);
329
- if (parent) parts.push(parent.label ?? parent.name);
330
- else break;
527
+ if (!parent) break;
528
+ ancestors.push(parent.label ?? parent.name);
331
529
  }
332
- parts.push(name);
530
+ parts.push(...ancestors.reverse(), leaf);
333
531
  return parts;
334
532
  }
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] ?? "";
533
+ export function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string {
534
+ return agentBreadcrumbParts(agent, byId, includeStructuralPath).join(" > ");
338
535
  }
339
536
  function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, styles: WorkflowProgressStyles): string {
340
537
  const parts = agentBreadcrumbParts(agent, byId);
341
538
  if (parts.length <= 1) return parts[0] ?? "";
342
- return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${parts[parts.length - 1] ?? ""}`;
539
+ return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
343
540
  }
344
541
 
345
542
  function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
@@ -395,11 +592,13 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
395
592
  `Launch cwd: ${run.cwd}`,
396
593
  ...formatCompactBudgetStatus(run),
397
594
  `Launch models: ${snapshot.models.join(", ") || "(none)"}`,
595
+ `Settings: concurrency=${String(snapshot.settings.concurrency)}`,
398
596
  ];
399
597
  if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
400
598
  if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
401
599
  const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
402
600
  if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
601
+ if (snapshot.settingsSources) lines.push(`Settings sources: concurrency=${snapshot.settingsSources.concurrency}, modelAliases=${snapshot.settingsSources.modelAliases}, disabledAgentResources=${snapshot.settingsSources.disabledAgentResources}`);
403
602
  lines.push("Agents / ownership:");
404
603
  if (!run.agents.length) lines.push(" (none)");
405
604
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
@@ -421,6 +620,60 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
421
620
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
422
621
  return lines.join("\n");
423
622
  }
623
+ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
624
+ const safeWidth = Math.max(1, width);
625
+ const model = buildWorkflowPhaseModel(run, snapshot);
626
+ const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
627
+ const phaseStyle = (state: WorkflowPhaseState): ((text: string) => string) => state === "completed" ? (text) => styles.success(text) : state === "failed" || state === "cancelled" ? (text) => styles.error(text) : state === "running" ? (text) => styles.accent(text) : state === "interrupted" || state === "budget_exhausted" ? (text) => styles.warning(text) : (text) => styles.muted(text);
628
+ const phaseName = (phase: WorkflowPhaseView): string => `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`;
629
+ const phaseCounts = (phase: WorkflowPhaseView): string => `agents completed=${String(phase.counts.completed)} running=${String(phase.counts.running)} failed=${String(phase.counts.failed)} cancelled=${String(phase.counts.cancelled)} pending=${String(phase.counts.pending)}`;
630
+ const phaseLine = (phase: WorkflowPhaseView, selected: boolean): string => `${selected ? "→" : " "} ${phaseName(phase)} · ${phaseStyle(phase.state)(phase.state)} · ${phaseCounts(phase)}`;
631
+ const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
632
+ const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
633
+ const selectedIndex = selection.phaseId ? model.phases.findIndex((phase) => phase.id === selection.phaseId) : -1;
634
+ const activeIndex = selectedIndex >= 0 ? selectedIndex : model.currentPhaseIndex ?? (model.phases.length ? 0 : -1);
635
+ const selectedPhase = activeIndex >= 0 ? model.phases[activeIndex] : undefined;
636
+ const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
637
+ if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
638
+ lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
639
+ for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
640
+ lines.push(...formatCompactBudgetStatus(run));
641
+ const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
642
+ const renderAgentLines = (agents: readonly AgentRecord[], selectedAgentId: string | undefined): string[] => {
643
+ if (!agents.length) return [styles.muted("Agents: none")];
644
+ const rendered: string[] = [];
645
+ for (const agent of agents) {
646
+ const selected = agent.id === selectedAgentId;
647
+ const stateStyle = progressStyleForState(agent.state, styles);
648
+ const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
649
+ rendered.push(`${selected ? "→" : " "} ${stateStyle(icon)} ${styledAgentBreadcrumb(agent, byId, styles)} · ${stateStyle(agent.state)}`);
650
+ if (agent.state === "failed") {
651
+ const error = agent.attemptDetails?.at(-1)?.error;
652
+ if (error) rendered.push(styles.error(` error: ${error.code}: ${error.message}`));
653
+ }
654
+ }
655
+ return rendered;
656
+ };
657
+ if (!model.phases.length) {
658
+ lines.push(styles.muted("Phases: no declared or observed phases (legacy snapshot)"), styles.bold("Agents"), ...renderAgentLines(run.agents, selection.agentId));
659
+ return lines.flatMap((line) => wrap(line));
660
+ }
661
+ const selectedAgent = selectedPhase?.agents.find((agent) => agent.id === selection.agentId) ?? selectedPhase?.agents[0];
662
+ if (safeWidth >= 80) {
663
+ const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
664
+ const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
665
+ const sidebar = [styles.bold("Phases"), ...model.phases.flatMap((phase, index) => wrap(phaseLine(phase, index === activeIndex), sidebarWidth))];
666
+ const detail = selectedPhase ? [styles.bold(`Phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`, detailWidth), ...wrap(phaseCounts(selectedPhase), detailWidth), "Agents", ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line, detailWidth))] : [styles.muted("No phase is selected")];
667
+ const rows = Math.max(sidebar.length, detail.length);
668
+ for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
669
+ } else {
670
+ lines.push(styles.bold("Phases"));
671
+ for (const [index, phase] of model.phases.entries()) lines.push(...wrap(phaseLine(phase, index === activeIndex)));
672
+ if (selectedPhase) lines.push("", styles.bold(`Selected phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`), ...wrap(phaseCounts(selectedPhase)), styles.bold("Agents"), ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line)));
673
+ }
674
+ if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
675
+ return lines.flatMap((line) => wrap(line));
676
+ }
424
677
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
425
678
  const context = JSON.stringify(checkpoint.context, null, 2);
426
679
  return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
@@ -862,6 +1115,33 @@ function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
862
1115
  const getAvailable = registry.getAvailable;
863
1116
  return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
864
1117
  }
1118
+ function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
1119
+ const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
1120
+ const available = registry?.getAvailable?.() ?? registry?.getAll?.() ?? [];
1121
+ const knownModels = new Set(all.map((model) => `${model.provider}/${model.id}`));
1122
+ const availableModels = new Set(available.map((model) => `${model.provider}/${model.id}`));
1123
+ const rootName = root?.provider && root.model ? `${root.provider}/${root.model}` : undefined;
1124
+ if (rootName) { knownModels.add(rootName); availableModels.add(rootName); }
1125
+ return { knownModels, availableModels };
1126
+ }
1127
+ function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined } {
1128
+ 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;
1129
+ return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry };
1130
+ }
1131
+ 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[] }> {
1132
+ const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
1133
+ const dynamicNames = Object.keys(dynamic);
1134
+ try {
1135
+ const aliases = validateModelAliases({ ...dynamic, ...staticAliases }, settingsPath);
1136
+ validateModelAliasAvailability(aliases, dynamicNames, availableModels, knownModels, settingsPath);
1137
+ return { aliases, dynamicNames };
1138
+ } catch (error) {
1139
+ const name = modelAliasErrorName(error);
1140
+ const descriptor = name && typeof registry.modelAliases === "function" ? registry.modelAliases().find((candidate) => candidate.name === name) : undefined;
1141
+ if (descriptor && errorCode(error) !== "CANCELLED") throw new WorkflowError(errorCode(error) ?? "CONFIG_ERROR", `${errorText(error)} (extension: ${descriptor.headline})`);
1142
+ throw error;
1143
+ }
1144
+ }
865
1145
  type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
866
1146
  type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
867
1147
  type UiSetStatus = (key: string, text?: string) => void;
@@ -993,10 +1273,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
993
1273
  };
994
1274
  const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
995
1275
  let cursor = 0;
996
- let executionPhase: string | undefined;
997
1276
  return async (phase: string): Promise<void> => {
998
- if (phase === executionPhase) return;
999
- executionPhase = phase;
1000
1277
  await scheduler.flush();
1001
1278
  await lifecycle.enter();
1002
1279
  try {
@@ -1167,13 +1444,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1167
1444
  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
1445
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1169
1446
  };
1170
- const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
1447
+ const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
1171
1448
  const run = runs.get(runId);
1172
1449
  if (!run) return undefined;
1173
1450
  const request = await run.store.answerWorkflowDecision(proposalId, approved);
1174
1451
  if (!request) return undefined;
1175
1452
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1176
- const result = await applyBudgetDecision(request, approved);
1453
+ const result = await applyBudgetDecision(request, approved, context, signal);
1177
1454
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1178
1455
  return result;
1179
1456
  };
@@ -1223,16 +1500,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1223
1500
  label: "Workflow Respond",
1224
1501
  description: "Approve or reject one pending workflow checkpoint or budget decision",
1225
1502
  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) {
1503
+ async execute(_id, params, signal, _onUpdate, ctx) {
1227
1504
  try {
1228
1505
  if (params.proposalId) {
1229
- const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
1506
+ const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
1230
1507
  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
1508
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
1232
1509
  }
1233
1510
  if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
1234
1511
  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 };
1512
+ 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
1513
  } catch (error) {
1237
1514
  throw mainAgentError(error);
1238
1515
  }
@@ -1254,47 +1531,60 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1254
1531
  });
1255
1532
  let catalogRegistered = false;
1256
1533
  let sessionStarted = false;
1257
- const registerCatalog = () => {
1534
+ const registerCatalog = (cwd: string, trustedProject: boolean) => {
1258
1535
  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;
1536
+ const catalog = registry.catalog({ cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) });
1537
+ const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0 || Boolean(catalog.modelAliasEntries?.length);
1538
+ const hasSettings = catalog.settings !== undefined && [catalog.settings.globalSettingsPath, catalog.settings.projectSettingsPath].some((path) => existsSync(path));
1539
+ if (!catalog.functions.length && !catalog.variables.length && !hasAliases && !hasSettings) return;
1262
1540
  pi.registerTool({
1263
1541
  name: "workflow_catalog",
1264
1542
  label: "Workflow Catalog",
1265
1543
  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 }),
1544
+ parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
1267
1545
  async execute(_id, params = {}) {
1268
- const result = params.name === undefined ? registry.catalogIndex() : registry.catalogDetail(params.name);
1546
+ const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
1547
+ const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
1269
1548
  return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
1270
- }
1549
+ },
1550
+ renderCall(args, theme) {
1551
+ const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
1552
+ return styledTextBlock(args.name === undefined ? title : `${title} ${theme.fg("accent", args.name)}`);
1553
+ },
1554
+ renderResult(result, options, theme) {
1555
+ return workflowCatalogBlock(formatWorkflowCatalog(catalogResultValue(result), options.expanded, theme), options.expanded);
1556
+ },
1271
1557
  });
1272
1558
  catalogRegistered = true;
1273
1559
  };
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 }) => {
1560
+ const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
1275
1561
  const loaded = await run.store.load();
1276
1562
  const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1277
1563
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1278
1564
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1279
1565
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1280
- const currentSettings = loadSettings(settingsPath);
1281
- resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
1282
- const currentAliases = currentSettings.modelAliases ?? {};
1566
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1567
+ const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
1568
+ const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1569
+ const staticAliases = resolution.effective.modelAliases ?? {};
1283
1570
  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]);
1571
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1572
+ const inventory = modelInventory(rootModel, context?.modelRegistry);
1573
+ const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
1574
+ const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
1575
+ const currentAliases = (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: run.abortController.signal }, availableModels, knownModels, settingsPath)).aliases;
1288
1576
  const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1289
1577
  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 });
1578
+ const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1579
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1291
1580
  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);
1581
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1582
+ 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, 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(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
1293
1583
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1294
1584
  const drift = aliasDrift(previousAliases, currentAliases);
1295
1585
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1296
1586
  };
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 }) => {
1587
+ 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
1588
  const loaded = await run.store.load();
1299
1589
  await run.store.validateRetrySource();
1300
1590
  await run.store.validateBorrowedWorktrees();
@@ -1307,26 +1597,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1307
1597
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1308
1598
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1309
1599
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1310
- const currentSettings = loadSettings(settingsPath);
1311
- resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1312
- const currentAliases = currentSettings.modelAliases ?? {};
1600
+ const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
1601
+ const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1602
+ const staticAliases = resolution.effective.modelAliases ?? {};
1313
1603
  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]);
1604
+ const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
1605
+ const inventory = modelInventory(rootModel, context?.modelRegistry);
1606
+ const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
1607
+ const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
1608
+ const controller = new AbortController();
1609
+ if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
1610
+ run.abortController = controller;
1611
+ const currentAliases = context?.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: controller.signal }, availableModels, knownModels, settingsPath)).aliases;
1318
1612
  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)));
1613
+ const blockedAliases = context?.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1614
+ const blockedAliasTargets = context?.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1321
1615
  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 });
1616
+ preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
1617
+ const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1618
+ const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1324
1619
  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);
1620
+ scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1621
+ run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), 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(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
1326
1622
  const drift = aliasDrift(previousAliases, currentAliases);
1327
1623
  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
1624
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
1331
1625
  run.executor.setRunContext(runContext);
1332
1626
  let variables: Readonly<Record<string, JsonValue>>;
@@ -1388,7 +1682,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1388
1682
  run.completion = completion;
1389
1683
  void completion;
1390
1684
  };
1391
- const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
1685
+ const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
1392
1686
  const run = runs.get(request.runId);
1393
1687
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
1394
1688
  if (!approved) return { state: "budget_exhausted", approved: false };
@@ -1397,10 +1691,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1397
1691
  const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
1398
1692
  run.budget = runtime;
1399
1693
  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);
1694
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1401
1695
  return { state: "running", approved: true };
1402
1696
  };
1403
- const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
1697
+ const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
1404
1698
  const run = runs.get(runId);
1405
1699
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
1406
1700
  const loaded = await run.store.load();
@@ -1425,11 +1719,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1425
1719
  run.budget = runtime;
1426
1720
  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
1721
  }
1428
- await coldResumeRun(run, false, {}, true);
1722
+ await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
1429
1723
  return { state: "running" };
1430
1724
  };
1431
1725
  const retryReservations = new Set<string>();
1432
- const retryWorkflowRun = async (runId: string, context: unknown): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
1726
+ const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
1433
1727
  if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
1434
1728
  const host = object(context) ? context : {};
1435
1729
  const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
@@ -1470,18 +1764,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1470
1764
  const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1471
1765
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1472
1766
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1473
- const currentSettings = loadSettings(settingsPath);
1474
- resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
1475
- const currentAliases = currentSettings.modelAliases ?? {};
1767
+ const resolution = resolveWorkflowSettings(cwd, trustedProject, settingsPath);
1768
+ const currentPolicy = resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
1769
+ const staticAliases = resolution.effective.modelAliases ?? {};
1476
1770
  const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1477
1771
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
1478
- const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
1479
1772
  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]);
1773
+ const rootModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
1774
+ const inventory = modelInventory(rootModel, modelRegistry);
1775
+ const knownModels = modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
1776
+ const availableModels = modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
1777
+ const resolvedAliases = await resolveLaunchAliases(registry, staticAliases, { cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: signal ?? new AbortController().signal }, availableModels, knownModels, settingsPath);
1778
+ const currentAliases = resolvedAliases.aliases;
1482
1779
  const resumeAliases = { ...previousAliases, ...currentAliases };
1780
+ const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1781
+ const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1483
1782
  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);
1783
+ preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
1485
1784
  await sourceStore.validateNamedWorktrees();
1486
1785
  for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
1487
1786
  const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
@@ -1490,7 +1789,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1490
1789
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1491
1790
  const childRunId = randomUUID();
1492
1791
  const childStore = new RunStore(cwd, sessionId, childRunId, home);
1493
- const childSnapshot = createLaunchSnapshot(loaded.snapshot);
1792
+ const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1793
+ const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1494
1794
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
1495
1795
  const childInitialBudget = childBudget.snapshot();
1496
1796
  const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -1499,13 +1799,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1499
1799
  const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
1500
1800
  const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
1501
1801
  const abortController = new AbortController();
1502
- const providerErrorRecovery = createProviderErrorRecovery(context, resumeModels, () => { abortController.abort(); });
1802
+ const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
1503
1803
  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 } : {}) };
1804
+ const childRun = { executor: new WorkflowAgentExecutor({ cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => active.has(tool) || tool === "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
1505
1805
  runs.set(childRunId, childRun);
1506
1806
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
1507
1807
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
1508
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry: modelRegistry ? { getAll: () => [...(modelRegistry.getAll?.() ?? [])], getAvailable: () => [...(modelRegistry.getAvailable?.() ?? [])] } : undefined });
1808
+ await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
1509
1809
  const completion = runs.get(childRunId)?.completion;
1510
1810
  if (completion) {
1511
1811
  childStarted = true;
@@ -1521,8 +1821,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1521
1821
  label: "Workflow Retry",
1522
1822
  description: "Retry a failed workflow run by replaying its completed structural operations",
1523
1823
  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 }; }
1824
+ async execute(_id, params, signal, _onUpdate, ctx) {
1825
+ try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1526
1826
  catch (error) { throw mainAgentError(error); }
1527
1827
  },
1528
1828
  });
@@ -1531,8 +1831,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1531
1831
  label: "Workflow Resume",
1532
1832
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
1533
1833
  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 }; }
1834
+ async execute(_id, params, signal, _onUpdate, ctx) {
1835
+ try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1536
1836
  catch (error) { throw mainAgentError(error); }
1537
1837
  },
1538
1838
  });
@@ -1540,7 +1840,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1540
1840
  if (sessionStarted) return;
1541
1841
  sessionStarted = true;
1542
1842
  registry.freeze();
1543
- registerCatalog();
1843
+ registerCatalog(ctx.cwd, projectTrusted(ctx));
1544
1844
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1545
1845
  try {
1546
1846
  for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
@@ -1565,7 +1865,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1565
1865
  const roleDefinitions = loaded.snapshot.roles ?? {};
1566
1866
  const abortController = new AbortController();
1567
1867
  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 } : {}) });
1868
+ 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.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
1569
1869
  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
1870
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
1571
1871
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -1590,7 +1890,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1590
1890
  });
1591
1891
  pi.on("before_agent_start", (event, ctx) => {
1592
1892
  if (!pi.getActiveTools().includes("workflow")) return;
1593
- const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
1893
+ const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx), typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : undefined)).filter(([, definition]) => definition.description);
1594
1894
  if (!roles.length) return;
1595
1895
  const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
1596
1896
  return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
@@ -1605,27 +1905,29 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1605
1905
  try {
1606
1906
  const headless = object(ctx) && ctx.headless === true;
1607
1907
  const settingsPath = workflowSettingsPath(extensionAgentDir);
1608
- const defaults = loadSettings(settingsPath);
1609
1908
  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
1909
  const budget = validateBudget(params.budget);
1612
1910
  const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
1613
1911
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
1614
1912
  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;
1913
+ const inventory = modelInventory(rootModel, modelRegistry);
1914
+ const knownModels = inventory.knownModels;
1915
+ const availableModels = inventory.availableModels;
1618
1916
  const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
1619
1917
  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);
1918
+ const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
1919
+ const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
1920
+ const runController = new AbortController();
1921
+ if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
1922
+ const resolvedAliases = await resolveLaunchAliases(registry, launch.settings.modelAliases ?? {}, { cwd: launchCwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: runController.signal }, availableModels, knownModels, settingsPath);
1923
+ const modelAliases = resolvedAliases.aliases;
1924
+ const settings = Object.freeze({ ...launch.settings, ...(Object.keys(modelAliases).length ? { modelAliases } : {}) });
1925
+ const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath }, registry);
1622
1926
  const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
1623
1927
  await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
1624
1928
  const runId = randomUUID();
1625
1929
  const args = (params.args ?? null) as JsonValue;
1626
1930
  encoded(args);
1627
- const runController = new AbortController();
1628
- if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
1629
1931
  const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
1630
1932
  const variables = await resolveWorkflowVariables(runContext, runController, registry);
1631
1933
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
@@ -1633,9 +1935,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1633
1935
  if (parentRunId !== undefined) await store.validateParentRun(parentRunId);
1634
1936
  const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
1635
1937
  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)] : []; });
1938
+ const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
1637
1939
  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 });
1940
+ 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
1941
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
1640
1942
  const initialBudget = budgetRuntime.snapshot();
1641
1943
  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,7 +1945,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1643
1945
  const background = !params.foreground;
1644
1946
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1645
1947
  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);
1948
+ const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext }, createSession);
1647
1949
  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
1950
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
1649
1951
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -1754,7 +2056,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1754
2056
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
1755
2057
  };
1756
2058
  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."
2059
+ 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
2060
  const setWorkflowStatus = (text: string | undefined) => {
1759
2061
  const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
1760
2062
  setStatus?.call(ctx.ui, "workflow-stop", text);
@@ -1771,7 +2073,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1771
2073
  return keepContext ? "dashboard" : "done";
1772
2074
  }
1773
2075
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
1774
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
2076
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
1775
2077
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
1776
2078
  return keepContext ? "dashboard" : "done";
1777
2079
  }
@@ -1784,12 +2086,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1784
2086
  if (action === "resume" && run) {
1785
2087
  if (run.lifecycle.state === "budget_exhausted") {
1786
2088
  const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
1787
- const result = await resumeWorkflowRun(run.store.runId, patch);
2089
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
1788
2090
  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
2091
  } else {
1790
2092
  if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
1791
2093
  else {
1792
- if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
2094
+ if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
1793
2095
  await run.lifecycle.resume();
1794
2096
  }
1795
2097
  ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
@@ -1799,7 +2101,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1799
2101
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
1800
2102
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
1801
2103
  if (input === undefined) return keepContext ? "dashboard" : "done";
1802
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2104
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
1803
2105
  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
2106
  return keepContext ? "dashboard" : "done";
1805
2107
  }
@@ -1824,6 +2126,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1824
2126
  };
1825
2127
  const manageAliases = async (): Promise<void> => {
1826
2128
  const settingsPath = workflowSettingsPath(extensionAgentDir);
2129
+ let aliasSettingsPath = settingsPath;
2130
+ const trustedProject = projectTrusted(ctx);
1827
2131
  const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
1828
2132
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
1829
2133
  const selectTarget = async (aliases: Readonly<Record<string, string>>): Promise<string | undefined> => {
@@ -1834,13 +2138,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1834
2138
  return (await ctx.ui.input("Manual model ID", "provider/model[:thinking] or alias[:thinking]"))?.trim() || undefined;
1835
2139
  };
1836
2140
  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; }
2141
+ try { saveModelAliases(aliasSettingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${aliasSettingsPath}.`, "info"); return true; }
2142
+ catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
1839
2143
  };
1840
2144
  for (;;) {
1841
2145
  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; }
2146
+ try { const resolution = resolveWorkflowSettings(ctx.cwd, trustedProject, settingsPath); aliases = resolution.effective.modelAliases ?? {}; aliasSettingsPath = resolution.sources.modelAliases; }
2147
+ catch (error) { ctx.ui.notify(`${trustedProject ? workflowProjectSettingsPath(ctx.cwd) : settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
1844
2148
  const names = Object.keys(aliases).sort();
1845
2149
  const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
1846
2150
  const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
@@ -1853,8 +2157,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1853
2157
  const target = await selectTarget(aliases);
1854
2158
  if (!target) continue;
1855
2159
  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);
2160
+ try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
2161
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
1858
2162
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
1859
2163
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
1860
2164
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
@@ -1867,8 +2171,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1867
2171
  const target = await selectTarget(aliases);
1868
2172
  if (!target) continue;
1869
2173
  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);
2174
+ try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
2175
+ const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
1872
2176
  if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
1873
2177
  ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
1874
2178
  if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
@@ -1899,7 +2203,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1899
2203
  const labels = navigatorRunLabels(sorted);
1900
2204
  const terminalStates = new Set(["completed", "failed", "stopped"]);
1901
2205
  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"] : [])];
2206
+ const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2207
+ const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
1903
2208
  const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
1904
2209
  if (!runChoice || runChoice === "Close") return;
1905
2210
  if (runChoice === "Inspect session in pane") {
@@ -1919,6 +2224,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1919
2224
  }
1920
2225
  ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
1921
2226
  }
2227
+ if (runChoice === "Delete all failed") {
2228
+ if (!await ctx.ui.confirm("Delete failed runs?", "Delete all failed workflow runs and their artifacts? This cannot be undone.")) continue;
2229
+ for (const entry of sorted) {
2230
+ if (entry.loaded.run.state === "failed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
2231
+ }
2232
+ ctx.ui.notify("Deleted all failed workflow runs.", "info"); stores = await loadStores(); continue;
2233
+ }
1922
2234
  const runIndex = labels.indexOf(runChoice);
1923
2235
  if (runIndex < 0) return;
1924
2236
  const selected = sorted[runIndex];
@@ -1968,19 +2280,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1968
2280
  addCopy("Copy run path", store.directory, "run path");
1969
2281
  addCopy("Copy run ID", store.runId, "run ID");
1970
2282
  }
1971
- return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
2283
+ 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, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
1972
2284
  };
1973
2285
  const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
1974
2286
  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
- };
2287
+ const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
1984
2288
  const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
1985
2289
  const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
1986
2290
  const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
@@ -2031,8 +2335,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2031
2335
  let disposed = false;
2032
2336
  let stopRequested = false;
2033
2337
  let stopStatus: string | undefined;
2338
+ const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2339
+ let selectedPhaseId = initialSelection.phaseId;
2340
+ let selectedAgentId = initialSelection.agentId;
2034
2341
  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" };
2342
+ const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", tab: "tab", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2036
2343
  const keyLabel = (binding: string, fallback: string) => {
2037
2344
  const keys = keybindingKeys(keybindings, binding);
2038
2345
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
@@ -2046,11 +2353,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2046
2353
  return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
2047
2354
  };
2048
2355
  const updateDashboard = async (selectedOption: string | undefined) => {
2356
+ const phaseId = selectedPhaseId;
2357
+ const agentId = selectedAgentId;
2049
2358
  const next = await loadDashboard();
2050
2359
  if (disposed) return;
2051
2360
  view = next;
2052
2361
  options = [...view.actions.keys(), "Close"];
2053
2362
  selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
2363
+ const preserved = preserveWorkflowPhaseSelection(view.phaseModel, { phaseId, agentId });
2364
+ selectedPhaseId = preserved.phaseId;
2365
+ selectedAgentId = preserved.agentId;
2054
2366
  tui.requestRender();
2055
2367
  };
2056
2368
  const requestStop = () => {
@@ -2081,11 +2393,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2081
2393
  timer.unref();
2082
2394
  return borderWorkflowOverlay({
2083
2395
  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;
2396
+ const styles = themeWorkflowProgressStyles(theme);
2397
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { phaseId: selectedPhaseId, agentId: selectedAgentId }, styles);
2398
+ const dashboardLines = stopStatus ? [styles.error(stopStatus), ...phaseLines] : phaseLines;
2086
2399
  const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
2087
2400
  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] ?? "";
2401
+ const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} phases · ${keyLabel("tui.input.tab", "tab")} agents${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
2402
  const compact = [...dashboardLines, "", ...actionLines, "", hint];
2090
2403
  if (compact.length <= layout.rows) { dashboardOffset = 0; return compact; }
2091
2404
  const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
@@ -2101,7 +2414,27 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2101
2414
  invalidate() {},
2102
2415
  handleInput(data: string) {
2103
2416
  if (stopRequested) return;
2104
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2417
+ const currentPhase = () => view.phaseModel.phases.find((phase) => phase.id === selectedPhaseId);
2418
+ const left = keybindings.matches(data, "tui.editor.cursorLeft");
2419
+ const right = keybindings.matches(data, "tui.editor.cursorRight");
2420
+ if (left || right) {
2421
+ const phases = view.phaseModel.phases;
2422
+ if (phases.length) {
2423
+ const currentIndex = Math.max(0, phases.findIndex((phase) => phase.id === selectedPhaseId));
2424
+ const delta = left ? -1 : 1;
2425
+ const nextPhase = phases[(currentIndex + delta + phases.length) % phases.length];
2426
+ selectedPhaseId = nextPhase?.id;
2427
+ selectedAgentId = nextPhase?.agents[0]?.id;
2428
+ }
2429
+ }
2430
+ else if (keybindings.matches(data, "tui.input.tab")) {
2431
+ const agents = currentPhase()?.agents ?? [];
2432
+ if (agents.length) {
2433
+ const currentIndex = Math.max(0, agents.findIndex((agent) => agent.id === selectedAgentId));
2434
+ selectedAgentId = agents[(currentIndex + 1) % agents.length]?.id;
2435
+ }
2436
+ }
2437
+ else if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2105
2438
  else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
2106
2439
  else if (keybindings.matches(data, "tui.select.pageUp")) {
2107
2440
  dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));