pi-extensible-workflows 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { Type } from "@earendil-works/pi-ai";
7
8
  import { Value } from "typebox/value";
8
- import { copyToClipboard, getAgentDir, highlightCode, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
9
+ import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
9
10
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
10
11
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
11
12
  import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
@@ -15,8 +16,12 @@ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCod
15
16
  import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
16
17
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
17
18
  import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
18
- import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentAttemptSummary, type AgentOptions, type AgentRecord, type 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
+ import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
20
+ import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCatalogFunction, type WorkflowCatalogIndex, type WorkflowCatalogVariable, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
19
21
  const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
22
+ const INTERNAL_WORKFLOW_TOOLS: readonly string[] = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
23
+ const HARD_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped"]);
24
+ const SHUTDOWN_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped", "budget_exhausted"]);
20
25
  export interface WorkflowProgressStyles {
21
26
  accent(text: string): string;
22
27
  success(text: string): string;
@@ -28,14 +33,14 @@ export interface WorkflowProgressStyles {
28
33
  }
29
34
  function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
30
35
  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: [] };
36
+ return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
32
37
  }
33
38
  const PLAIN_WORKFLOW_PROGRESS_STYLES: WorkflowProgressStyles = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
34
- type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy; modelSettingsPath: string };
39
+ type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy };
35
40
  function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
36
41
  const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
37
42
  const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
38
- return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath), modelSettingsPath: resolution.sources.modelAliases };
43
+ return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
39
44
  }
40
45
  function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
41
46
  function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
@@ -89,7 +94,6 @@ function mainAgentError(error: unknown): WorkflowError {
89
94
  const typed = asWorkflowError(error);
90
95
  const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
91
96
  Object.assign(presented, typed);
92
- presented.message = formatWorkflowFailure(typed);
93
97
  return presented;
94
98
  }
95
99
 
@@ -144,7 +148,7 @@ export class RunLifecycle {
144
148
  }
145
149
 
146
150
  async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
147
- if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
151
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
148
152
  await this.#set(state, reason ?? state);
149
153
  for (const resolve of this.#waiters.splice(0)) resolve();
150
154
  }
@@ -164,25 +168,39 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
164
168
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
165
169
  }
166
170
  export const WORKFLOW_TOOL_LABEL = "Workflow";
167
- export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
168
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Runs in the background by default; completion arrives as a follow-up message. Foreground results include the completed run ID. Use workflow_retry with an explicit failed run ID to replay completed structural operations; parentRunId only reuses named worktrees.";
171
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
172
+ export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
173
+ function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
174
+ if (action === "resume") {
175
+ if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
176
+ if (state === "completed") return "Completed workflow runs have no recovery action";
177
+ if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
178
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
179
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
180
+ }
181
+ if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
182
+ if (state === "completed") return "Completed workflow runs have no recovery action";
183
+ if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
184
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
185
+ return `Only failed workflow runs can be retried; source is ${state}`;
186
+ }
169
187
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
170
- name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow runs; invalid for registered function launches" })),
188
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
171
189
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
172
- script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
173
- workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
190
+ script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
191
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
174
192
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
175
- foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
176
- concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
177
- budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
178
- parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
193
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
194
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
195
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
196
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
179
197
  });
180
198
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
181
199
 
182
200
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
183
201
  export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
184
202
  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 }
203
+ export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
186
204
  export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
187
205
  type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
188
206
  function phaseNames(source: WorkflowPhaseSource): string[] {
@@ -244,7 +262,7 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
244
262
  const agents = observation?.agents ?? [];
245
263
  const counts = phaseAgentCounts(agents);
246
264
  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 };
265
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
248
266
  });
249
267
  let currentPhaseIndex: number | undefined;
250
268
  for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
@@ -258,12 +276,167 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
258
276
  if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
259
277
  return result;
260
278
  }
261
- export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
279
+ export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined; nodeId?: string | undefined; expandedNodeIds?: readonly string[] | undefined; treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined; actions?: { title: string; options: readonly string[]; index: number } | undefined }
280
+ export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
281
+ export interface WorkflowPhaseTreeNode { id: string; kind: WorkflowPhaseTreeNodeKind; label: string; depth: number; phaseId: string; operationPath: readonly string[]; parentId?: string; children: readonly string[]; state: WorkflowPhaseState | AgentRecord["state"]; agentId?: string; agent?: AgentRecord; phase?: WorkflowPhaseView }
282
+ export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
283
+ export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
284
+ export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
285
+ function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
286
+ const root = `phase/${encodeURIComponent(phaseId)}`;
287
+ if (kind === "phase") return root;
288
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
289
+ if (kind === "operation") return `${root}/operation/${operation}`;
290
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
291
+ }
292
+ function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
293
+ if (!states.length || states.every((state) => state === "completed")) return "completed";
294
+ if (states.some((state) => state === "failed")) return "failed";
295
+ if (states.some((state) => state === "cancelled")) return "cancelled";
296
+ if (states.some((state) => state === "running")) return "running";
297
+ return "queued";
298
+ }
299
+ export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
300
+ type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
301
+ type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
302
+ const drafts = new Map<string, Draft>();
303
+ const roots: string[] = [];
304
+ const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
305
+ const existing = drafts.get(node.id);
306
+ if (existing) return existing;
307
+ const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
308
+ drafts.set(draft.id, draft);
309
+ if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
310
+ return draft;
311
+ };
312
+ const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
313
+ const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
314
+ const phaseNode = add({ id: workflowPhaseTreePath("phase", phaseId, []), kind: "phase", label, depth: 0, phaseId, operationPath: [], state: phase?.state ?? workflowPhaseTreeAggregateState(agents.map((agent) => agent.state)), ...(phase ? { phase } : {}) });
315
+ const operationNodes = new Map<string, Draft>();
316
+ const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
317
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
318
+ const acceptedParents = new Map<string, string>();
319
+ const wouldCycle = (childId: string, parentId: string): boolean => {
320
+ const seen = new Set<string>([childId]);
321
+ let current: string | undefined = parentId;
322
+ while (current) {
323
+ if (seen.has(current)) return true;
324
+ seen.add(current);
325
+ current = acceptedParents.get(current);
326
+ }
327
+ return false;
328
+ };
329
+ for (const entry of entries) {
330
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
331
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
332
+ }
333
+ const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
334
+ let parent = owner;
335
+ for (let index = startIndex; index < path.length; index += 1) {
336
+ const prefix = path.slice(0, index + 1);
337
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
338
+ const existing = operationNodes.get(key);
339
+ if (existing) { parent = existing; continue; }
340
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
341
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
342
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
343
+ operationNodes.set(key, operation);
344
+ parent = operation;
345
+ }
346
+ return parent;
347
+ };
348
+ for (const entry of entries) {
349
+ if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
350
+ }
351
+ for (const entry of entries) {
352
+ entry.node = add({ id: workflowPhaseTreePath("agent", phaseId, entry.path, entry.agent.id), kind: "agent", label: entry.agent.label ?? entry.agent.name, depth: 0, phaseId, operationPath: entry.path, state: entry.agent.state, agentId: entry.agent.id, agent: entry.agent }, phaseNode.id);
353
+ }
354
+ const attach = (entry: AgentEntry, parentId: string): void => {
355
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
356
+ if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
357
+ entry.node.parentId = parentId;
358
+ const parent = drafts.get(parentId);
359
+ if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
360
+ };
361
+ for (const entry of entries) {
362
+ const parentId = acceptedParents.get(entry.agent.id);
363
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
364
+ if (parent) {
365
+ if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
366
+ else {
367
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
368
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
369
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
370
+ }
371
+ }
372
+ attach(entry, entry.defaultParentId);
373
+ }
374
+ const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
375
+ if (seen.has(node.id)) return;
376
+ seen.add(node.id);
377
+ node.depth = depth;
378
+ for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
379
+ };
380
+ setDepth(phaseNode, 0);
381
+ const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
382
+ if (seen.has(node.id)) return [];
383
+ const nextSeen = new Set(seen).add(node.id);
384
+ return node.children.flatMap((childId) => {
385
+ const child = drafts.get(childId);
386
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
387
+ });
388
+ };
389
+ for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
390
+ };
391
+ for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
392
+ if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
393
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
394
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
395
+ }
396
+ export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
397
+ const visible: WorkflowPhaseTreeNode[] = [];
398
+ const visit = (id: string): void => {
399
+ const node = tree.byId.get(id);
400
+ if (!node) return;
401
+ visible.push(node);
402
+ if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
403
+ };
404
+ for (const root of tree.roots) visit(root);
405
+ return visible;
406
+ }
407
+ export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
408
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
409
+ }
410
+ export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
411
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
412
+ return node ? { nodeId: node.id } : {};
413
+ }
414
+ export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
415
+ const expanded = new Set(expandedNodeIds);
416
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
417
+ if (!current) return { expandedNodeIds: expanded };
418
+ if (direction === "left") {
419
+ if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
420
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
421
+ }
422
+ if (direction === "right") {
423
+ if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
424
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
425
+ }
426
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
427
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
428
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
429
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
430
+ }
262
431
  export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
263
432
  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 }) };
433
+ if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
434
+ const tree = buildWorkflowPhaseTree(model);
435
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
436
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
437
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
438
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
439
+ return { phaseId: phase.id, ...(selection.agentId && phase.agents.some((agent) => agent.id === selection.agentId) ? { agentId: selection.agentId } : phase.agents[0] ? { agentId: phase.agents[0].id } : {}), ...(nodeId ? { nodeId } : {}), ...(selection.expandedNodeIds ? { expandedNodeIds: selection.expandedNodeIds } : {}) };
267
440
  }
268
441
  type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
269
442
  function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
@@ -294,23 +467,32 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
294
467
  ...group.entries.map((entry) => render(entry, grouped)),
295
468
  ]);
296
469
  }
297
- function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string {
298
- if (state === "completed") return (text) => styles.success(text);
299
- if (state === "failed" || state === "cancelled") return (text) => styles.error(text);
300
- if (state === "running") return (text) => styles.accent(text);
301
- return (text) => styles.muted(text);
302
- }
470
+ const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
471
+ const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
472
+ function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
473
+ function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
474
+ type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
475
+ const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
476
+ const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
477
+ const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
478
+ function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
479
+ const key = map[state] ?? "muted";
480
+ return (text) => styles[key](text);
481
+ }
482
+ function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
483
+ function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
484
+ function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
303
485
  export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
304
486
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
305
- const workflowIcon = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆";
306
- const workflowIconStyle = run.state === "completed" ? (text: string) => styles.success(text) : run.state === "failed" || run.state === "stopped" ? (text: string) => styles.error(text) : run.state === "budget_exhausted" ? (text: string) => styles.warning(text) : run.state === "running" ? (text: string) => styles.accent(text) : (text: string) => styles.muted(text);
487
+ const workflowIcon = runStateGlyph(run.state, spinner);
488
+ const iconStyle = workflowIconStyle(run.state, styles);
307
489
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
308
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
490
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
309
491
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
310
492
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
311
493
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
312
494
  const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
313
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
495
+ const icon = agentStateGlyph(agent.state, spinner);
314
496
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
315
497
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
316
498
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
@@ -364,6 +546,93 @@ function workflowCatalogBlock(text: string, expanded: boolean) {
364
546
  };
365
547
  }
366
548
 
549
+ type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
550
+ function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
551
+ function controlValue(value: unknown): string {
552
+ if (value === null) return "removed";
553
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
554
+ const json = JSON.stringify(value);
555
+ return typeof json === "string" ? json : "unknown";
556
+ }
557
+ function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
558
+ function controlState(state: string, theme: Theme): string {
559
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
560
+ return theme.fg(color, state);
561
+ }
562
+ function controlAction(action: string, theme: Theme): string {
563
+ const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
564
+ return theme.fg(color, action);
565
+ }
566
+ function budgetPatchEntries(value: unknown): string[] {
567
+ if (!object(value)) return value === undefined ? [] : [controlValue(value)];
568
+ return Object.entries(value).map(([dimension, limits]) => {
569
+ if (limits === null) return `${dimension}=removed`;
570
+ if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
571
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
572
+ return `${dimension} ${parts.join(" ")}`;
573
+ });
574
+ }
575
+ function budgetPatchSummary(value: unknown): string {
576
+ const entries = budgetPatchEntries(value);
577
+ return entries.length ? entries.join(", ") : "unchanged";
578
+ }
579
+ function budgetPatchDetails(value: unknown, theme: Theme): string[] {
580
+ const entries = budgetPatchEntries(value);
581
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
582
+ }
583
+ function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
584
+ function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
585
+ const runId = controlString(args.runId) ?? "(missing run ID)";
586
+ if (name === "workflow_respond") {
587
+ const proposalId = controlString(args.proposalId);
588
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
589
+ const decision = args.approved === true ? "approve" : "reject";
590
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
591
+ }
592
+ if (name === "workflow_resume") return args.budget === undefined ? `${controlTitle(name, theme)} ${theme.fg("accent", runId)}` : [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", "Budget")} ${theme.fg("toolOutput", budgetPatchSummary(args.budget))}`].join("\n");
593
+ if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
594
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
595
+ }
596
+ function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
597
+ if (isError) {
598
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
599
+ return theme.fg("error", text || `The ${name} tool failed.`);
600
+ }
601
+ const value = workflowControlValue(result);
602
+ if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
603
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
604
+ const title = controlTitle(name, theme);
605
+ if (name === "workflow_stop") {
606
+ const state = controlString(value.state) ?? "unknown";
607
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
608
+ if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
609
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(controlString(value.reason) ? [`Reason: ${theme.fg("toolOutput", controlValue(value.reason))}`] : [])].join("\n");
610
+ }
611
+ if (name === "workflow_retry") {
612
+ const childRunId = controlString(value.runId) ?? "(unknown)";
613
+ const state = controlString(value.state) ?? "unknown";
614
+ if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
615
+ return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
616
+ }
617
+ if (name === "workflow_resume") {
618
+ const state = controlString(value.state) ?? "unknown";
619
+ const proposalId = controlString(value.proposalId);
620
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
621
+ if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
622
+ return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal: ${theme.fg("accent", proposalId)}`] : []), ...budgetPatchDetails(args.budget, theme)].join("\n");
623
+ }
624
+ const proposalId = controlString(args.proposalId);
625
+ const checkpointName = controlString(args.name);
626
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
627
+ const accepted = value.accepted === true;
628
+ const approved = value.approved === true;
629
+ const reason = controlString(value.reason);
630
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
631
+ const state = controlString(value.state);
632
+ if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
633
+ return [title, `Run: ${theme.fg("accent", runId)}`, `Target: ${target}`, `Action: ${controlAction(action, theme)}`, ...(state ? [`State: ${controlState(state, theme)}`] : []), ...(reason ? [`Reason: ${theme.fg("toolOutput", reason)}`] : [])].join("\n");
634
+ }
635
+
367
636
  function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
368
637
 
369
638
  type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
@@ -439,7 +708,8 @@ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme):
439
708
  return theme.fg("error", "The workflow catalog returned an invalid result.");
440
709
  }
441
710
 
442
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
711
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
712
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
443
713
  export function truncateWorkflowProgress(text: string, width: number): string[] {
444
714
  const safeWidth = Math.max(1, width);
445
715
  return text.split("\n").flatMap((line) => {
@@ -506,7 +776,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
506
776
  for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
507
777
  return entries.map(({ store, loaded: { run } }) => {
508
778
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
509
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
779
+ const glyph = runStateGlyph(run.state, "⠦");
510
780
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
511
781
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
512
782
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -555,7 +825,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
555
825
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
556
826
  const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
557
827
  const hasAccounting = run.agents.some((a) => a.accounting);
558
- const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
828
+ const glyph = runStateGlyph(run.state, "⠦");
559
829
  const header = `${glyph} ${run.workflowName}`;
560
830
  const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
561
831
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -564,7 +834,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
564
834
  lines.push("");
565
835
  const byId = new Map(run.agents.map((a) => [a.id, a]));
566
836
  const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
567
- const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
837
+ const icon = agentStateGlyph(agent.state, "⠦");
568
838
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
569
839
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
570
840
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -582,7 +852,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
582
852
  return lines.join("\n");
583
853
  }
584
854
 
585
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
855
+ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
586
856
  const { run, snapshot } = loaded;
587
857
  const lines = [
588
858
  `Workflow: ${run.workflowName}`,
@@ -616,62 +886,92 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
616
886
  lines.push("Checkpoints:");
617
887
  if (!checkpoints.length) lines.push(" (none)");
618
888
  for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
619
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
889
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
620
890
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
621
891
  return lines.join("\n");
622
892
  }
623
893
  export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
624
894
  const safeWidth = Math.max(1, width);
625
895
  const model = buildWorkflowPhaseModel(run, snapshot);
896
+ const tree = buildWorkflowPhaseTree(model);
897
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
626
898
  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)}`;
899
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
900
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
901
+ const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
902
+ const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
903
+ const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
904
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
905
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
906
+ const selectedNode = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? selectedByAgent ?? (phase ? tree.byId.get(workflowPhaseTreePath("phase", phase.id, [])) : undefined) ?? (model.currentPhaseId ? tree.byId.get(workflowPhaseTreePath("phase", model.currentPhaseId, [])) : undefined) ?? tree.nodes[0];
907
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
908
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
909
+ const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
910
+ const agents: AgentRecord[] = [];
911
+ const visit = (id: string): void => {
912
+ const child = tree.byId.get(id);
913
+ if (!child) return;
914
+ if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
915
+ };
916
+ if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
917
+ return agents;
918
+ };
919
+ const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
920
+ const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
921
+ const treeLine = (node: WorkflowPhaseTreeNode): string => {
922
+ const selected = node.id === selectedNode?.id;
923
+ const state = progressStyleForState(node.state, styles);
924
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
925
+ };
926
+ const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
927
+ if (!node) return [styles.muted("No workflow node is selected")];
928
+ const agents = nodeAgents(node);
929
+ if (node.kind === "phase") {
930
+ const selected = node.phase;
931
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
932
+ return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`];
933
+ }
934
+ if (node.kind === "operation") {
935
+ const states = phaseAgentCounts(agents);
936
+ return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`];
937
+ }
938
+ const agent = node.agent;
939
+ if (!agent) return [styles.muted("Agent details are unavailable")];
940
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
941
+ const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
942
+ const error = agent.attemptDetails?.at(-1)?.error;
943
+ if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
944
+ if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
945
+ return result;
946
+ };
631
947
  const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
632
948
  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
949
  const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
637
950
  if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
638
951
  lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
639
952
  for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
640
953
  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;
954
+ const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
955
+ const actionRows = (): string[] => {
956
+ const actions = selection.actions;
957
+ if (!actions) return [];
958
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
656
959
  };
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];
960
+ const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
662
961
  if (safeWidth >= 80) {
663
- const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
962
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
664
963
  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")];
964
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
965
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
667
966
  const rows = Math.max(sidebar.length, detail.length);
668
- for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
967
+ for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
968
+ } else if (selection.detailsOnly) {
969
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
669
970
  } 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)));
971
+ lines.push(...renderTree(safeWidth));
972
+ if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
673
973
  }
674
- if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
974
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
675
975
  return lines.flatMap((line) => wrap(line));
676
976
  }
677
977
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
@@ -1094,26 +1394,25 @@ function projectTrusted(ctx: unknown): boolean {
1094
1394
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1095
1395
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1096
1396
  }
1397
+ function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
1097
1398
  type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
1098
- function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
1099
1399
  function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
1100
1400
  function piHostCapabilities(pi: unknown): PiHostCapabilities {
1101
1401
  if (!object(pi)) return {};
1102
- const registerEntryRenderer = pi.registerEntryRenderer;
1402
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
1103
1403
  const events = pi.events;
1104
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1404
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1105
1405
  }
1106
1406
  type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
1107
1407
  type ModelSummary = { provider: string; id: string };
1108
1408
  type ModelRegistryGetter = () => readonly ModelSummary[];
1109
1409
  type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
1110
- function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
1111
1410
  function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
1112
1411
  if (!object(ctx) || !object(ctx.modelRegistry)) return {};
1113
1412
  const registry = ctx.modelRegistry;
1114
- const getAll = registry.getAll;
1115
- const getAvailable = registry.getAvailable;
1116
- return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1413
+ const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
1414
+ const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
1415
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1117
1416
  }
1118
1417
  function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
1119
1418
  const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
@@ -1146,23 +1445,20 @@ type UiSelect = (title: string, options: string[]) => Promise<string | undefined
1146
1445
  type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
1147
1446
  type UiSetStatus = (key: string, text?: string) => void;
1148
1447
  type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
1149
- function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
1150
- function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
1151
- function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
1152
1448
  function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
1153
1449
  if (!object(ui)) return undefined;
1154
- const select = ui.select;
1155
- const input = ui.input;
1156
- const setStatus = ui.setStatus;
1157
- return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
1450
+ const select = (asFn(ui.select) as UiSelect | undefined);
1451
+ const input = (asFn(ui.input) as UiInput | undefined);
1452
+ const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
1453
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
1158
1454
  }
1159
- type TuiHostCapabilities = { terminal?: { rows?: unknown } };
1160
- function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
1161
- if (!object(tui) || !object(tui.terminal)) return {};
1162
- return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
1455
+ function tuiRows(tui: unknown): number {
1456
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1457
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1163
1458
  }
1164
- function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
1165
1459
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1460
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1461
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
1166
1462
  type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
1167
1463
  function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
1168
1464
  return {
@@ -1174,12 +1470,10 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
1174
1470
  };
1175
1471
  }
1176
1472
  type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
1177
- function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
1178
- function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
1179
- if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
1180
- return { getKeys: keybindings.getKeys };
1473
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
1474
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
1475
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
1181
1476
  }
1182
- function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
1183
1477
 
1184
1478
  export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
1185
1479
  beginWorkflowExtensionLoading();
@@ -1392,7 +1686,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1392
1686
  let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
1393
1687
  try { effective = run.executor.resolve(requested); }
1394
1688
  catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
1395
- return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
1689
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1690
+ return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(resultPath ? { resultPath } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
1396
1691
  });
1397
1692
  return { ...current, agents };
1398
1693
  });
@@ -1401,7 +1696,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1401
1696
  });
1402
1697
  const cleanupTerminalRun = async (runId: string): Promise<void> => {
1403
1698
  const run = runs.get(runId);
1404
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state)) return;
1699
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
1405
1700
  await scheduler.cancelRun(runId);
1406
1701
  await scheduler.flush();
1407
1702
  if (runs.get(runId) !== run) return;
@@ -1514,6 +1809,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1514
1809
  throw mainAgentError(error);
1515
1810
  }
1516
1811
  },
1812
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
1813
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1517
1814
  });
1518
1815
  pi.registerTool({
1519
1816
  name: "workflow_stop",
@@ -1528,6 +1825,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1528
1825
  throw mainAgentError(error);
1529
1826
  }
1530
1827
  },
1828
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
1829
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1531
1830
  });
1532
1831
  let catalogRegistered = false;
1533
1832
  let sessionStarted = false;
@@ -1557,29 +1856,81 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1557
1856
  });
1558
1857
  catalogRegistered = true;
1559
1858
  };
1560
- const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
1561
- const loaded = await run.store.load();
1562
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1563
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1859
+ const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
1860
+ const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
1861
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
1862
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
1863
+ const resumeLaunchPrologue = async (input: {
1864
+ snapshot: Readonly<LaunchSnapshot>;
1865
+ cwd: string;
1866
+ trustedProject: boolean;
1867
+ rootModel: ModelSpec;
1868
+ modelRegistry?: ModelRegistryCapability | undefined;
1869
+ signal: AbortSignal;
1870
+ resolvedAliases?: Readonly<Record<string, string>>;
1871
+ blockedAliases?: ReadonlySet<string>;
1872
+ blockedAliasTargets?: Readonly<Record<string, string>>;
1873
+ withPreflight: boolean;
1874
+ }) => {
1875
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
1876
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1564
1877
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1565
1878
  const settingsPath = workflowSettingsPath(extensionAgentDir);
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);
1879
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
1880
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
1569
1881
  const staticAliases = resolution.effective.modelAliases ?? {};
1570
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1882
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
1883
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
1884
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
1885
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
1886
+ const currentAliases = input.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: input.cwd, projectTrusted: input.trustedProject, rootModel: input.rootModel, knownModels, availableModels, signal: input.signal }, availableModels, knownModels, settingsPath)).aliases;
1887
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1888
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1889
+ let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
1890
+ if (input.withPreflight) {
1891
+ const resumeAliases = { ...previousAliases, ...currentAliases };
1892
+ script = launchScriptForSnapshot(input.snapshot, registry);
1893
+ preflight(script, { models: availableModels, tools: active, agentTypes: new Set(input.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, input.snapshot.schemas, input.snapshot.metadata, true);
1894
+ }
1895
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
1896
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1897
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
1898
+ };
1899
+ const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
1900
+ await lifecycle.enter();
1901
+ try {
1902
+ const path = agentIdentityPath(identity);
1903
+ const replayed = await store.replay(path);
1904
+ if (replayed) {
1905
+ return replayed.value;
1906
+ }
1907
+ const worktree = agentWorktree(identity);
1908
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
1909
+ const role = typeof options.role === "string" ? options.role : undefined;
1910
+ const model = typeof options.model === "string" ? options.model : undefined;
1911
+ const thinking = parseThinking(options.thinking);
1912
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1913
+ const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
1914
+ const label = displayAgentName(requestedLabel, role, resolved.model);
1915
+ const tools = resolved.tools;
1916
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1917
+ const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd: agentCwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
1918
+ const cancel = () => { scheduler.cancel(spawned.id); };
1919
+ if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
1920
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
1921
+ if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1922
+ await store.complete(path, outcome.value);
1923
+ return outcome.value;
1924
+ } finally { await lifecycle.leave(); }
1925
+ };
1926
+ const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
1927
+ const loaded = await run.store.load();
1928
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1571
1929
  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;
1576
- const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1577
- const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
1578
- const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1579
- const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1930
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: run.abortController.signal, withPreflight: false });
1580
1931
  await run.store.saveSnapshot(snapshot);
1581
1932
  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);
1933
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1583
1934
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1584
1935
  const drift = aliasDrift(previousAliases, currentAliases);
1585
1936
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
@@ -1593,32 +1944,15 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1593
1944
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1594
1945
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1595
1946
  if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1596
- const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1597
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1598
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1599
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1600
- const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
1601
- const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
1602
- const staticAliases = resolution.effective.modelAliases ?? {};
1603
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1604
1947
  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
1948
  const controller = new AbortController();
1609
1949
  if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
1610
1950
  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;
1612
- const resumeAliases = { ...previousAliases, ...currentAliases };
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)));
1615
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
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 });
1951
+ const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: controller.signal, ...(context?.resolvedAliases ? { resolvedAliases: context.resolvedAliases } : {}), ...(context?.blockedAliases ? { blockedAliases: context.blockedAliases } : {}), ...(context?.blockedAliasTargets ? { blockedAliasTargets: context.blockedAliasTargets } : {}), withPreflight: true });
1952
+ if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1619
1953
  await run.store.saveSnapshot(snapshot);
1620
1954
  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);
1955
+ run.executor = createAgentExecutor({ cwd: run.store.cwd, model: rootModel, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
1622
1956
  const drift = aliasDrift(previousAliases, currentAliases);
1623
1957
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1624
1958
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
@@ -1627,39 +1961,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1627
1961
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1628
1962
  catch (error) {
1629
1963
  const typed = asWorkflowError(error);
1630
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
1964
+ if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
1631
1965
  await cleanupTerminalRun(run.store.runId);
1632
1966
  throw typed;
1633
1967
  }
1634
1968
  await scheduler.cancelRun(run.store.runId);
1635
1969
  await run.lifecycle.resume();
1636
- const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (prompt, options, signal, identity) => {
1637
- await run.lifecycle.enter();
1638
- try {
1639
- const path = agentIdentityPath(identity);
1640
- const replayed = await run.store.replay(path);
1641
- if (replayed) {
1642
- return replayed.value;
1643
- }
1644
- const worktree = agentWorktree(identity);
1645
- const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
1646
- const role = typeof options.role === "string" ? options.role : undefined;
1647
- const model = typeof options.model === "string" ? options.model : undefined;
1648
- const thinking = parseThinking(options.thinking);
1649
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1650
- const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
1651
- const label = displayAgentName(requestedLabel, role, resolved.model);
1652
- const tools = resolved.tools;
1653
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1654
- const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
1655
- const cancel = () => { scheduler.cancel(spawned.id); };
1656
- signal.addEventListener("abort", cancel, { once: true });
1657
- const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
1658
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1659
- await run.store.complete(path, outcome.value);
1660
- return outcome.value;
1661
- } finally { await run.lifecycle.leave(); }
1662
- }, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
1970
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
1663
1971
  run.execution = execution;
1664
1972
  const completion = execution.result.then(async (value) => {
1665
1973
  await scheduler.flush();
@@ -1696,9 +2004,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1696
2004
  };
1697
2005
  const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
1698
2006
  const run = runs.get(runId);
1699
- if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2007
+ if (!run) {
2008
+ const host = object(context) ? context : {};
2009
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2010
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2011
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2012
+ if (cwd && sessionId) {
2013
+ try {
2014
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2015
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2016
+ } catch (error) {
2017
+ if (error instanceof WorkflowError) throw error;
2018
+ }
2019
+ }
2020
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2021
+ }
1700
2022
  const loaded = await run.store.load();
1701
- if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
2023
+ if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1702
2024
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1703
2025
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1704
2026
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1733,8 +2055,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1733
2055
  await ensureSessionLease(cwd, sessionId);
1734
2056
  const sourceStore = new RunStore(cwd, sessionId, runId, home);
1735
2057
  let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
1736
- try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`); }
1737
- if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
2058
+ try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`); }
2059
+ if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1738
2060
  if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string"))) throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
1739
2061
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
1740
2062
  if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
@@ -1760,27 +2082,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1760
2082
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1761
2083
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1762
2084
  if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
1763
- const active = new Set<string>(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
1764
- const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1765
- if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1766
- const settingsPath = workflowSettingsPath(extensionAgentDir);
1767
- const resolution = resolveWorkflowSettings(cwd, trustedProject, settingsPath);
1768
- const currentPolicy = resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
1769
- const staticAliases = resolution.effective.modelAliases ?? {};
1770
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
1771
2085
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
1772
2086
  const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
1773
2087
  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;
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)));
1782
- const script = launchScriptForSnapshot(loaded.snapshot, registry);
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);
2088
+ const { active, settingsPath, currentPolicy, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot: childBaseSnapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd, trustedProject, rootModel, ...(modelRegistry ? { modelRegistry } : {}), signal: signal ?? new AbortController().signal, withPreflight: true });
1784
2089
  await sourceStore.validateNamedWorktrees();
1785
2090
  for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
1786
2091
  const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
@@ -1789,8 +2094,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1789
2094
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1790
2095
  const childRunId = randomUUID();
1791
2096
  const childStore = new RunStore(cwd, sessionId, childRunId, home);
1792
- const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
1793
- const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2097
+ const childSnapshot = childBaseSnapshot;
1794
2098
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
1795
2099
  const childInitialBudget = childBudget.snapshot();
1796
2100
  const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -1801,7 +2105,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1801
2105
  const abortController = new AbortController();
1802
2106
  const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
1803
2107
  const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
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 } : {}) };
2108
+ const childRun = { executor: createAgentExecutor({ cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, active), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentResourcePolicy: frozenResourcePolicy(currentPolicy) }), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
1805
2109
  runs.set(childRunId, childRun);
1806
2110
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
1807
2111
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
@@ -1825,6 +2129,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1825
2129
  try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1826
2130
  catch (error) { throw mainAgentError(error); }
1827
2131
  },
2132
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2133
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1828
2134
  });
1829
2135
  pi.registerTool({
1830
2136
  name: "workflow_resume",
@@ -1835,6 +2141,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1835
2141
  try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1836
2142
  catch (error) { throw mainAgentError(error); }
1837
2143
  },
2144
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2145
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1838
2146
  });
1839
2147
  pi.on("session_start", async (_event, ctx) => {
1840
2148
  if (sessionStarted) return;
@@ -1865,7 +2173,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1865
2173
  const roleDefinitions = loaded.snapshot.roles ?? {};
1866
2174
  const abortController = new AbortController();
1867
2175
  const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
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 } : {}) });
2176
+ runs.set(runId, { executor: createAgentExecutor({ cwd: ctx.cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, "session"), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
1869
2177
  for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
1870
2178
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
1871
2179
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -1913,7 +2221,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1913
2221
  const inventory = modelInventory(rootModel, modelRegistry);
1914
2222
  const knownModels = inventory.knownModels;
1915
2223
  const availableModels = inventory.availableModels;
1916
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2224
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
1917
2225
  const trustedProject = projectTrusted(ctx);
1918
2226
  const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
1919
2227
  const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
@@ -1945,37 +2253,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1945
2253
  const background = !params.foreground;
1946
2254
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1947
2255
  const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
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);
2256
+ const executor = createAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext });
1949
2257
  runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
1950
2258
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
1951
2259
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
1952
- const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (prompt, options, agentSignal, identity) => {
1953
- await lifecycle.enter();
1954
- try {
1955
- const path = agentIdentityPath(identity);
1956
- const replayed = await store.replay(path);
1957
- if (replayed) {
1958
- return replayed.value;
1959
- }
1960
- const worktree = agentWorktree(identity);
1961
- const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
1962
- const role = typeof options.role === "string" ? options.role : undefined;
1963
- const model = typeof options.model === "string" ? options.model : undefined;
1964
- const thinking = parseThinking(options.thinking);
1965
- const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1966
- const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
1967
- const label = displayAgentName(requestedLabel, role, resolved.model);
1968
- const tools = resolved.tools;
1969
- const schema = object(options.outputSchema) ? options.outputSchema : undefined;
1970
- const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
1971
- const cancel = () => { scheduler.cancel(spawned.id); };
1972
- if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
1973
- const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
1974
- if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
1975
- await store.complete(path, outcome.value);
1976
- return outcome.value;
1977
- } finally { await lifecycle.leave(); }
1978
- }, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2260
+ const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
1979
2261
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
1980
2262
  await eventPublisher.runStarted(store, checked.metadata);
1981
2263
  const finish = execution.result.then(async (value) => {
@@ -2078,7 +2360,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2078
2360
  return keepContext ? "dashboard" : "done";
2079
2361
  }
2080
2362
  if (action === "delete" && stored) {
2081
- if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
2363
+ if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
2082
2364
  if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
2083
2365
  await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
2084
2366
  }
@@ -2201,7 +2483,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2201
2483
  }
2202
2484
  const sorted = navigatorAttentionSort(stores);
2203
2485
  const labels = navigatorRunLabels(sorted);
2204
- const terminalStates = new Set(["completed", "failed", "stopped"]);
2486
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
2205
2487
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2206
2488
  const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2207
2489
  const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
@@ -2248,6 +2530,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2248
2530
  const loaded = await store.load();
2249
2531
  const checkpoints = await store.awaitingCheckpoints();
2250
2532
  const worktrees = await store.worktrees();
2533
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2534
+ const agentResults = new Map<string, JsonValue>();
2535
+ for (const agent of loaded.run.agents) {
2536
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
2537
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2538
+ if (operation) agentResults.set(agent.id, operation.value);
2539
+ }
2251
2540
  const actions = new Map<string, string>();
2252
2541
  const copies = new Map<string, { value: string; artifact: string }>();
2253
2542
  const reviews = new Map<string, AwaitingCheckpoint>();
@@ -2273,96 +2562,135 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2273
2562
  }
2274
2563
  }
2275
2564
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
2276
- else actions.set("View script", "view-script");
2277
- if (loaded.run.agents.length) actions.set("Agents...", "agents");
2565
+ else actions.set("Open script in editor", "open-script");
2566
+ if (ctx.mode !== "tui" && loaded.run.agents.length) actions.set("Agents...", "agents");
2278
2567
  if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
2279
2568
  if (ctx.mode === "tui") {
2280
2569
  addCopy("Copy run path", store.directory, "run path");
2281
2570
  addCopy("Copy run ID", store.runId, "run ID");
2282
2571
  }
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 };
2572
+ return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
2284
2573
  };
2285
- const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
2286
- const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2287
- const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
2288
- const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2289
- const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2290
- const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2291
- const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
2292
- if (!selected) return;
2293
- const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2294
- const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
2295
- const actions = [
2296
- ...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2574
+ const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
2575
+ const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
2576
+ const worktree = agentWorktreeFor(dashboard, agent);
2577
+ return [
2578
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2297
2579
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
2580
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
2298
2581
  "Copy agent ID",
2299
2582
  "Back",
2300
2583
  ];
2301
- const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
2302
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2303
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2304
- const index = choice ? choices.indexOf(choice) : -1;
2305
- return index >= 0 ? attempts[index] : undefined;
2306
- };
2584
+ };
2585
+ const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
2586
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2587
+ const worktree = agentWorktreeFor(dashboard, agent);
2588
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2589
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2590
+ const index = choice ? choices.indexOf(choice) : -1;
2591
+ const attempt = index >= 0 ? attempts[index] : undefined;
2592
+ if (!attempt) return;
2593
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2594
+ if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) return;
2595
+ try {
2596
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2597
+ ctx.ui.notify("Forked Pi session in pane.", "info");
2598
+ } catch (error) {
2599
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2600
+ }
2601
+ };
2602
+ const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
2603
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2604
+ const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
2605
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2606
+ let selected: AgentRecord | undefined;
2607
+ if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
2608
+ else {
2609
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2610
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2611
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
2612
+ }
2613
+ if (!selected) return;
2614
+ const worktree = agentWorktreeFor(dashboard, selected);
2615
+ const actions = agentActionLabels(dashboard, selected);
2307
2616
  for (;;) {
2308
2617
  const action = await ctx.ui.select(title(selected), actions);
2309
2618
  if (!action || action === "Back") return;
2310
2619
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
2311
2620
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
2312
2621
  if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
2313
- if (action === "Fork as Pi session in pane") {
2314
- const attempt = await chooseAttempt();
2315
- if (!attempt) continue;
2316
- const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2317
- if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) continue;
2318
- try {
2319
- await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2320
- ctx.ui.notify("Forked Pi session in pane.", "info");
2321
- } catch (error) {
2322
- ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2323
- }
2324
- }
2622
+ if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
2325
2623
  }
2326
2624
  };
2327
2625
  for (;;) {
2328
2626
  let view = await loadDashboard();
2329
2627
  const actionChoice = ctx.mode === "tui"
2330
2628
  ? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2331
- let options = [...view.actions.keys(), "Close"];
2332
- let selectedIndex = 0;
2333
2629
  let dashboardOffset = 0;
2334
2630
  let refreshing = false;
2335
2631
  let disposed = false;
2632
+ let detailsMode = false;
2633
+ let actionMode = false;
2634
+ let actionIndex = 0;
2336
2635
  let stopRequested = false;
2337
2636
  let stopStatus: string | undefined;
2637
+ let selectionNeedsScroll = true;
2638
+ let renderedWidth = 80;
2639
+ let refreshGeneration = 0;
2338
2640
  const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2339
- let selectedPhaseId = initialSelection.phaseId;
2340
- let selectedAgentId = initialSelection.agentId;
2641
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
2642
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
2643
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2341
2644
  const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2342
- const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", tab: "tab", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
2645
+ const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
2343
2646
  const keyLabel = (binding: string, fallback: string) => {
2344
2647
  const keys = keybindingKeys(keybindings, binding);
2345
2648
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2346
2649
  };
2347
- const dashboardLayout = () => {
2348
- const rows = terminalRows();
2349
- const hintRows = rows >= 4 ? 1 : 0;
2350
- const separatorRows = rows >= 8 ? 1 : 0;
2351
- const available = Math.max(1, rows - hintRows - separatorRows);
2352
- const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
2353
- return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
2650
+ const selectedAgentRecord = (): AgentRecord | undefined => {
2651
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2652
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
2354
2653
  };
2355
- const updateDashboard = async (selectedOption: string | undefined) => {
2356
- const phaseId = selectedPhaseId;
2357
- const agentId = selectedAgentId;
2654
+ const actionOptions = () => {
2655
+ const agent = selectedAgentRecord();
2656
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
2657
+ };
2658
+ let editorRunning = false;
2659
+ const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
2660
+ if (editorRunning) return;
2661
+ editorRunning = true;
2662
+ try {
2663
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
2664
+ if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
2665
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
2666
+ if (exitCode !== 0) {
2667
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
2668
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
2669
+ }
2670
+ } catch (error) {
2671
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2672
+ } finally {
2673
+ editorRunning = false;
2674
+ tui.requestRender(true);
2675
+ }
2676
+ };
2677
+ const updateDashboard = async () => {
2678
+ const generation = ++refreshGeneration;
2679
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2358
2680
  const next = await loadDashboard();
2359
- if (disposed) return;
2681
+ if (disposed || generation !== refreshGeneration) return;
2682
+ const previousNodeId = selectedNodeId;
2683
+ const previousExpanded = expandedNodeIds;
2684
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2360
2685
  view = next;
2361
- options = [...view.actions.keys(), "Close"];
2362
- selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
2363
- const preserved = preserveWorkflowPhaseSelection(view.phaseModel, { phaseId, agentId });
2364
- selectedPhaseId = preserved.phaseId;
2365
- selectedAgentId = preserved.agentId;
2686
+ tree = buildWorkflowPhaseTree(view.phaseModel);
2687
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
2688
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
2689
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
2690
+ const nextActions = actionOptions();
2691
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
2692
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
2693
+ selectionNeedsScroll = true;
2366
2694
  tui.requestRender();
2367
2695
  };
2368
2696
  const requestStop = () => {
@@ -2370,14 +2698,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2370
2698
  stopRequested = true;
2371
2699
  stopStatus = undefined;
2372
2700
  setWorkflowStatus(undefined);
2373
- const selectedOption = options[selectedIndex];
2374
2701
  void runAction(`stop ${store.runId}`, true, (status) => {
2375
2702
  stopStatus = status;
2376
2703
  setWorkflowStatus(status);
2377
2704
  if (!disposed) tui.requestRender();
2378
- }).then(() => updateDashboard(selectedOption)).catch((error: unknown) => {
2705
+ }).then(() => updateDashboard()).catch((error: unknown) => {
2379
2706
  if (disposed) return;
2380
2707
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
2708
+ setWorkflowStatus(stopStatus);
2381
2709
  tui.requestRender();
2382
2710
  }).finally(() => {
2383
2711
  stopRequested = false;
@@ -2387,110 +2715,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2387
2715
  const timer = setInterval(() => {
2388
2716
  if (refreshing || stopRequested) return;
2389
2717
  refreshing = true;
2390
- const selectedOption = options[selectedIndex];
2391
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
2718
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2392
2719
  }, 1000);
2393
2720
  timer.unref();
2394
2721
  return borderWorkflowOverlay({
2395
2722
  render(width: number) {
2723
+ renderedWidth = width;
2724
+ const narrow = width < 80;
2396
2725
  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;
2399
- const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
2400
- const layout = dashboardLayout();
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] ?? "";
2402
- const compact = [...dashboardLines, "", ...actionLines, "", hint];
2403
- if (compact.length <= layout.rows) { dashboardOffset = 0; return compact; }
2404
- const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
2726
+ const agent = selectedAgentRecord();
2727
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
2728
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
2729
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
2730
+ const content = [...statusLines, ...phaseLines];
2731
+ const rows = terminalRows();
2732
+ const hintRows = rows >= 3 ? 1 : 0;
2733
+ const viewport = Math.max(1, rows - hintRows);
2734
+ const maxOffset = Math.max(0, content.length - viewport);
2735
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2736
+ if (actionMode) {
2737
+ const label = actions?.options[actionIndex];
2738
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
2739
+ if (actionRow >= 0) {
2740
+ if (actionRow < dashboardOffset) dashboardOffset = actionRow;
2741
+ else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
2742
+ }
2743
+ } else if (!detailsMode && selectionNeedsScroll) {
2744
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
2745
+ if (selectedRow >= 0) {
2746
+ if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
2747
+ else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
2748
+ }
2749
+ selectionNeedsScroll = false;
2750
+ }
2405
2751
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2406
- const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
2407
- return [
2408
- ...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
2409
- ...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
2410
- ...actionLines.slice(actionStart, actionStart + layout.actionViewport),
2411
- ...(layout.hintRows ? [hint] : []),
2412
- ];
2752
+ const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "close"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
2753
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2413
2754
  },
2414
2755
  invalidate() {},
2415
2756
  handleInput(data: string) {
2416
- if (stopRequested) return;
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;
2757
+ if (stopRequested || editorRunning) return;
2758
+ const narrow = renderedWidth < 80;
2759
+ if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
2760
+ if (actionMode) {
2761
+ const options = actionOptions();
2762
+ if (keybindings.matches(data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
2763
+ if (keybindings.matches(data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
2764
+ else if (keybindings.matches(data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
2765
+ else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2766
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2767
+ else if (keybindings.matches(data, "tui.select.confirm")) {
2768
+ const action = options[actionIndex];
2769
+ const agent = selectedAgentRecord();
2770
+ if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
2771
+ else if (agent) {
2772
+ const worktree = agentWorktreeFor(view, agent);
2773
+ if (action === "Open result in editor") {
2774
+ const result = view.agentResults.get(agent.id);
2775
+ if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
2776
+ }
2777
+ else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
2778
+ else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
2779
+ else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
2780
+ else done(`__workflow_fork__:${agent.id}`);
2781
+ }
2782
+ else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
2783
+ else if (action === "Stop") requestStop();
2784
+ else done(action);
2428
2785
  }
2786
+ tui.requestRender();
2787
+ return;
2429
2788
  }
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;
2789
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2790
+ if (keybindings.matches(data, "tui.select.cancel")) {
2791
+ if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done(undefined);
2792
+ } else if (narrow && detailsMode) {
2793
+ if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2794
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2795
+ else if (keybindings.matches(data, "tui.select.confirm")) {
2796
+ if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2797
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2435
2798
  }
2436
- }
2437
- else if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2438
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
2439
- else if (keybindings.matches(data, "tui.select.pageUp")) {
2440
- dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
2441
- }
2442
- else if (keybindings.matches(data, "tui.select.pageDown")) {
2443
- dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
2444
- }
2799
+ } else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
2800
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
2801
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2802
+ } else if (keybindings.matches(data, "tui.editor.cursorRight")) {
2803
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
2804
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2805
+ } else if (keybindings.matches(data, "tui.select.up")) {
2806
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
2807
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2808
+ } else if (keybindings.matches(data, "tui.select.down")) {
2809
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
2810
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2811
+ } else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2812
+ else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2445
2813
  else if (keybindings.matches(data, "tui.select.confirm")) {
2446
- if (options[selectedIndex] === "Stop") requestStop();
2447
- else done(options[selectedIndex]);
2814
+ if (narrow) detailsMode = true;
2815
+ else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2816
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2448
2817
  }
2449
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2450
2818
  tui.requestRender();
2451
2819
  },
2452
2820
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2453
2821
  }, theme);
2454
- }, { overlay: true })
2822
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
2455
2823
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2456
2824
  if (!actionChoice || actionChoice === "Close") return;
2457
2825
  if (actionChoice === "Agents...") { await selectAgent(view); continue; }
2458
- if (actionChoice === "Refresh") continue;
2459
- if (actionChoice === "View script") {
2460
- await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2461
- const highlighted = highlightCode(view.script, "javascript");
2462
- let offset = 0;
2463
- let renderedLines: string[] = [];
2464
- const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
2465
- const move = (delta: number) => {
2466
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2467
- offset = Math.max(0, Math.min(maxOffset, offset + delta));
2468
- };
2469
- return borderWorkflowOverlay({
2470
- render(width: number) {
2471
- renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
2472
- const maxOffset = Math.max(0, renderedLines.length - viewport());
2473
- offset = Math.min(offset, maxOffset);
2474
- return [
2475
- theme.fg("accent", "Workflow script"),
2476
- ...renderedLines.slice(offset, offset + viewport()),
2477
- "",
2478
- theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
2479
- ];
2480
- },
2481
- invalidate() {},
2482
- handleInput(data: string) {
2483
- if (keybindings.matches(data, "tui.select.up")) move(-1);
2484
- else if (keybindings.matches(data, "tui.select.down")) move(1);
2485
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
2486
- else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
2487
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2488
- tui.requestRender();
2489
- },
2490
- }, theme);
2491
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
2826
+ if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
2827
+ if (actionChoice.startsWith("__workflow_fork__:")) {
2828
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
2829
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
2830
+ if (agent) await forkAgentSession(view, agent);
2492
2831
  continue;
2493
2832
  }
2833
+ if (actionChoice === "Refresh") continue;
2494
2834
  const copy = view.copies.get(actionChoice);
2495
2835
  if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
2496
2836
  if (actionChoice.startsWith("Review ")) {
@@ -2544,7 +2884,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2544
2884
  tui.requestRender();
2545
2885
  },
2546
2886
  }, theme);
2547
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
2887
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
2548
2888
  if (decision) {
2549
2889
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
2550
2890
  if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
@@ -2564,9 +2904,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2564
2904
  pi.on("session_shutdown", async () => {
2565
2905
  try {
2566
2906
  await Promise.all([...runs.entries()].map(async ([runId, run]) => {
2567
- if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
2568
- if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
2569
- try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
2907
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
2908
+ if (!isTerminal) {
2909
+ try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
2570
2910
  run.abortController.abort();
2571
2911
  run.execution?.cancel();
2572
2912
  await scheduler.cancelRun(runId);