pi-extensible-workflows 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -47
  2. package/dist/src/agent-execution.d.ts +4 -0
  3. package/dist/src/agent-execution.js +146 -60
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +146 -21
  8. package/dist/src/doctor-cleanup.js +4 -2
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +51 -8
  12. package/dist/src/host.js +1181 -487
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +40 -1
  16. package/dist/src/persistence.js +51 -0
  17. package/dist/src/session-inspector.d.ts +12 -2
  18. package/dist/src/session-inspector.js +36 -2
  19. package/dist/src/types.d.ts +19 -0
  20. package/dist/src/types.js +1 -0
  21. package/dist/src/validation.js +42 -2
  22. package/dist/src/workflow-artifacts.d.ts +13 -0
  23. package/dist/src/workflow-artifacts.js +39 -0
  24. package/dist/src/workflow-evals.d.ts +7 -1
  25. package/dist/src/workflow-evals.js +23 -3
  26. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  27. package/evals/cases/recovery-failed-run.yaml +14 -0
  28. package/examples/workflow-extension-template/README.md +37 -0
  29. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  30. package/examples/workflow-extension-template/index.js +51 -0
  31. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  32. package/package.json +3 -2
  33. package/skills/pi-extensible-workflows/SKILL.md +21 -28
  34. package/src/agent-execution.ts +102 -30
  35. package/src/bundles.ts +471 -0
  36. package/src/cli.ts +118 -21
  37. package/src/doctor-cleanup.ts +3 -2
  38. package/src/eval-capture-extension.ts +15 -1
  39. package/src/execution.ts +13 -4
  40. package/src/host.ts +992 -447
  41. package/src/index.ts +4 -2
  42. package/src/persistence.ts +53 -1
  43. package/src/session-inspector.ts +36 -4
  44. package/src/types.ts +12 -5
  45. package/src/validation.ts +33 -2
  46. package/src/workflow-artifacts.ts +34 -0
  47. package/src/workflow-evals.ts +24 -3
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_STALL_THRESHOLD_MS, 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,9 +94,10 @@ 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
  }
99
+ function workflowFailedAt(error: unknown): string | undefined { return object(error) && typeof error.failedAt === "string" && error.failedAt ? error.failedAt : undefined; }
100
+ function persistedFailure(run: PersistedRun, error: WorkflowError): PersistedRun { const failedAt = workflowFailedAt(error); return { ...run, error: { code: error.code, message: error.message, ...(failedAt ? { failedAt } : {}) }, ...(failedAt ? { failedAt } : {}) }; }
95
101
 
96
102
  export class RunLifecycle {
97
103
  #state: RunState;
@@ -144,7 +150,7 @@ export class RunLifecycle {
144
150
  }
145
151
 
146
152
  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`);
153
+ if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
148
154
  await this.#set(state, reason ?? state);
149
155
  for (const resolve of this.#waiters.splice(0)) resolve();
150
156
  }
@@ -164,25 +170,39 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
164
170
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
165
171
  }
166
172
  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.";
173
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
174
+ 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. If a foreground call detaches before its result is accepted, its terminal success or failure is promoted to one follow-up message. Foreground results include the completed run ID. Recovery inherits the source launch mode; legacy snapshots without launchMode recover in the background. Set foreground: true or false on workflow_resume/workflow_retry to override it; foreground recovery waits for terminal value and run details, while background recovery returns immediately with a follow-up. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId, foreground? }) replays a failed run into a child; workflow_resume({ runId, budget?, foreground? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes."
175
+ function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
176
+ if (action === "resume") {
177
+ if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
178
+ if (state === "completed") return "Completed workflow runs have no recovery action";
179
+ if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
180
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
181
+ return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
182
+ }
183
+ if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
184
+ if (state === "completed") return "Completed workflow runs have no recovery action";
185
+ if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
186
+ if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
187
+ return `Only failed workflow runs can be retried; source is ${state}`;
188
+ }
169
189
  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" })),
190
+ name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
171
191
  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" })),
192
+ 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(...)" })),
193
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
174
194
  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" })),
195
+ foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
196
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
197
+ budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
198
+ parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
179
199
  });
180
- export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
200
+ export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) });
181
201
 
182
202
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
183
203
  export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
184
204
  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 }
205
+ export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
186
206
  export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
187
207
  type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
188
208
  function phaseNames(source: WorkflowPhaseSource): string[] {
@@ -244,7 +264,7 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
244
264
  const agents = observation?.agents ?? [];
245
265
  const counts = phaseAgentCounts(agents);
246
266
  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 };
267
+ return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
248
268
  });
249
269
  let currentPhaseIndex: number | undefined;
250
270
  for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
@@ -258,12 +278,167 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
258
278
  if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
259
279
  return result;
260
280
  }
261
- export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
281
+ 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 }
282
+ export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
283
+ 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 }
284
+ export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
285
+ export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
286
+ export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
287
+ function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
288
+ const root = `phase/${encodeURIComponent(phaseId)}`;
289
+ if (kind === "phase") return root;
290
+ const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
291
+ if (kind === "operation") return `${root}/operation/${operation}`;
292
+ return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
293
+ }
294
+ function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
295
+ if (!states.length || states.every((state) => state === "completed")) return "completed";
296
+ if (states.some((state) => state === "failed")) return "failed";
297
+ if (states.some((state) => state === "cancelled")) return "cancelled";
298
+ if (states.some((state) => state === "running")) return "running";
299
+ return "queued";
300
+ }
301
+ export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
302
+ type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
303
+ type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
304
+ const drafts = new Map<string, Draft>();
305
+ const roots: string[] = [];
306
+ const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
307
+ const existing = drafts.get(node.id);
308
+ if (existing) return existing;
309
+ const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
310
+ drafts.set(draft.id, draft);
311
+ if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
312
+ return draft;
313
+ };
314
+ const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
315
+ const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
316
+ 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 } : {}) });
317
+ const operationNodes = new Map<string, Draft>();
318
+ const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
319
+ const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
320
+ const acceptedParents = new Map<string, string>();
321
+ const wouldCycle = (childId: string, parentId: string): boolean => {
322
+ const seen = new Set<string>([childId]);
323
+ let current: string | undefined = parentId;
324
+ while (current) {
325
+ if (seen.has(current)) return true;
326
+ seen.add(current);
327
+ current = acceptedParents.get(current);
328
+ }
329
+ return false;
330
+ };
331
+ for (const entry of entries) {
332
+ const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
333
+ if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
334
+ }
335
+ const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
336
+ let parent = owner;
337
+ for (let index = startIndex; index < path.length; index += 1) {
338
+ const prefix = path.slice(0, index + 1);
339
+ const key = `${owner.id}:${JSON.stringify(prefix)}`;
340
+ const existing = operationNodes.get(key);
341
+ if (existing) { parent = existing; continue; }
342
+ const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
343
+ const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
344
+ const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
345
+ operationNodes.set(key, operation);
346
+ parent = operation;
347
+ }
348
+ return parent;
349
+ };
350
+ for (const entry of entries) {
351
+ if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
352
+ }
353
+ for (const entry of entries) {
354
+ 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);
355
+ }
356
+ const attach = (entry: AgentEntry, parentId: string): void => {
357
+ const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
358
+ if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
359
+ entry.node.parentId = parentId;
360
+ const parent = drafts.get(parentId);
361
+ if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
362
+ };
363
+ for (const entry of entries) {
364
+ const parentId = acceptedParents.get(entry.agent.id);
365
+ const parent = parentId ? agentEntries.get(parentId) : undefined;
366
+ if (parent) {
367
+ if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
368
+ else {
369
+ const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
370
+ const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
371
+ entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
372
+ }
373
+ }
374
+ attach(entry, entry.defaultParentId);
375
+ }
376
+ const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
377
+ if (seen.has(node.id)) return;
378
+ seen.add(node.id);
379
+ node.depth = depth;
380
+ for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
381
+ };
382
+ setDepth(phaseNode, 0);
383
+ const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
384
+ if (seen.has(node.id)) return [];
385
+ const nextSeen = new Set(seen).add(node.id);
386
+ return node.children.flatMap((childId) => {
387
+ const child = drafts.get(childId);
388
+ return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
389
+ });
390
+ };
391
+ for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
392
+ };
393
+ for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
394
+ if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
395
+ const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
396
+ return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
397
+ }
398
+ export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
399
+ const visible: WorkflowPhaseTreeNode[] = [];
400
+ const visit = (id: string): void => {
401
+ const node = tree.byId.get(id);
402
+ if (!node) return;
403
+ visible.push(node);
404
+ if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
405
+ };
406
+ for (const root of tree.roots) visit(root);
407
+ return visible;
408
+ }
409
+ export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
410
+ return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
411
+ }
412
+ export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
413
+ const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
414
+ return node ? { nodeId: node.id } : {};
415
+ }
416
+ export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
417
+ const expanded = new Set(expandedNodeIds);
418
+ const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
419
+ if (!current) return { expandedNodeIds: expanded };
420
+ if (direction === "left") {
421
+ if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
422
+ return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
423
+ }
424
+ if (direction === "right") {
425
+ if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
426
+ return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
427
+ }
428
+ const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
429
+ const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
430
+ const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
431
+ return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
432
+ }
262
433
  export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
263
434
  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 }) };
435
+ if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
436
+ const tree = buildWorkflowPhaseTree(model);
437
+ const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
438
+ const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
439
+ const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
440
+ const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
441
+ 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
442
  }
268
443
  type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
269
444
  function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
@@ -294,25 +469,36 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
294
469
  ...group.entries.map((entry) => render(entry, grouped)),
295
470
  ]);
296
471
  }
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
- }
303
- export function formatWorkflowProgress(run: PersistedRun, spinner = "", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
472
+ const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
473
+ const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
474
+ function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
475
+ function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
476
+ type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
477
+ const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
478
+ const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
479
+ const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
480
+ function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
481
+ const key = map[state] ?? "muted";
482
+ return (text) => styles[key](text);
483
+ }
484
+ function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
485
+ function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
486
+ function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
487
+ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
304
488
  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);
489
+ const workflowIcon = runStateGlyph(run.state, spinner);
490
+ const iconStyle = workflowIconStyle(run.state, styles);
307
491
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
308
- const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
492
+ const lines = [`${iconStyle(workflowIcon)} ${header}`];
309
493
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
310
494
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
495
+ const activeShells = run.activeShells ?? 0;
496
+ if (activeShells > 0) lines.push(` ${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(activeShells)} active)`)}`);
311
497
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
312
498
  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 : "○";
499
+ const icon = agentStateGlyph(agent.state, spinner);
314
500
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
315
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
501
+ const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
316
502
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
317
503
  const state = progressStyleForState(agent.state, styles);
318
504
  return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
@@ -364,6 +550,94 @@ function workflowCatalogBlock(text: string, expanded: boolean) {
364
550
  };
365
551
  }
366
552
 
553
+ type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
554
+ function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
555
+ function controlValue(value: unknown): string {
556
+ if (value === null) return "removed";
557
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
558
+ const json = JSON.stringify(value);
559
+ return typeof json === "string" ? json : "unknown";
560
+ }
561
+ function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
562
+ function controlState(state: string, theme: Theme): string {
563
+ const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
564
+ return theme.fg(color, state);
565
+ }
566
+ function controlAction(action: string, theme: Theme): string {
567
+ const color = /approved|completed|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
568
+ return theme.fg(color, action);
569
+ }
570
+ function budgetPatchEntries(value: unknown): string[] {
571
+ if (!object(value)) return value === undefined ? [] : [controlValue(value)];
572
+ return Object.entries(value).map(([dimension, limits]) => {
573
+ if (limits === null) return `${dimension}=removed`;
574
+ if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
575
+ const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
576
+ return `${dimension} ${parts.join(" ")}`;
577
+ });
578
+ }
579
+ function budgetPatchSummary(value: unknown): string {
580
+ const entries = budgetPatchEntries(value);
581
+ return entries.length ? entries.join(", ") : "unchanged";
582
+ }
583
+ function budgetPatchDetails(value: unknown, theme: Theme): string[] {
584
+ const entries = budgetPatchEntries(value);
585
+ return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
586
+ }
587
+ function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
588
+ function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
589
+ const runId = controlString(args.runId) ?? "(missing run ID)";
590
+ if (name === "workflow_respond") {
591
+ const proposalId = controlString(args.proposalId);
592
+ const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
593
+ const decision = args.approved === true ? "approve" : "reject";
594
+ return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
595
+ }
596
+ 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");
597
+ if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
598
+ return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
599
+ }
600
+ function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
601
+ if (isError) {
602
+ const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
603
+ return theme.fg("error", text || `The ${name} tool failed.`);
604
+ }
605
+ const value = workflowControlValue(result);
606
+ if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
607
+ const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
608
+ const title = controlTitle(name, theme);
609
+ if (name === "workflow_stop") {
610
+ const state = controlString(value.state) ?? "unknown";
611
+ const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
612
+ if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
613
+ 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");
614
+ }
615
+ if (name === "workflow_retry") {
616
+ const childRunId = controlString(value.runId) ?? "(unknown)";
617
+ const state = controlString(value.state) ?? "unknown";
618
+ const action = state === "completed" ? "completed" : "started";
619
+ if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`].join("\n");
620
+ return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action === "completed" ? "completed" : "started; completed work will be replayed", theme)}`].join("\n");
621
+ }
622
+ if (name === "workflow_resume") {
623
+ const state = controlString(value.state) ?? "unknown";
624
+ const proposalId = controlString(value.proposalId);
625
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : state === "completed" ? "completed" : "no change";
626
+ if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
627
+ 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");
628
+ }
629
+ const proposalId = controlString(args.proposalId);
630
+ const checkpointName = controlString(args.name);
631
+ const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
632
+ const accepted = value.accepted === true;
633
+ const approved = value.approved === true;
634
+ const reason = controlString(value.reason);
635
+ const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
636
+ const state = controlString(value.state);
637
+ if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
638
+ 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");
639
+ }
640
+
367
641
  function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
368
642
 
369
643
  type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
@@ -439,7 +713,8 @@ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme):
439
713
  return theme.fg("error", "The workflow catalog returned an invalid result.");
440
714
  }
441
715
 
442
- const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
716
+ const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
717
+ const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
443
718
  export function truncateWorkflowProgress(text: string, width: number): string[] {
444
719
  const safeWidth = Math.max(1, width);
445
720
  return text.split("\n").flatMap((line) => {
@@ -506,7 +781,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
506
781
  for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
507
782
  return entries.map(({ store, loaded: { run } }) => {
508
783
  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" ? "●" : "◆";
784
+ const glyph = runStateGlyph(run.state, "⠦");
510
785
  const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
511
786
  const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
512
787
  const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
@@ -539,23 +814,47 @@ function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord
539
814
  return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
540
815
  }
541
816
 
542
- function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
817
+ export function formatStalledDuration(durationMs: number): string {
818
+ const minutes = Math.max(0, Math.floor(durationMs / 60_000));
819
+ if (minutes < 60) return `${String(minutes)}m`;
820
+ const hours = Math.floor(minutes / 60);
821
+ const remainingMinutes = minutes % 60;
822
+ return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
823
+ }
824
+ function stalledDuration(agent: AgentRecord, now: number): number | undefined {
825
+ if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt)) return undefined;
826
+ const duration = now - agent.lastEventAt;
827
+ return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
828
+ }
829
+ function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
543
830
  const label = agent.activity?.kind === "reasoning" ? "reasoning" : agent.activity?.kind === "text" ? "responding" : agent.activity?.kind === "tool" ? agent.activity.text : [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running")?.name ?? "";
544
- return label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
831
+ const activity = label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
832
+ const stalled = stalledDuration(agent, now);
833
+ if (stalled === undefined) return activity;
834
+ const warning = `stalled? ${formatStalledDuration(stalled)}`;
835
+ return activity ? `${activity} ${styles.warning(`- ${warning}`)}` : styles.warning(warning);
836
+ }
837
+
838
+ function formatAccountingValue(value: number): string {
839
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value).toLowerCase();
545
840
  }
546
841
 
547
842
  function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
548
843
  const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
549
- return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
844
+ return `${formatAccountingValue(total)} tok`;
550
845
  }
551
846
 
847
+ function formatAgentAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string[] {
848
+ const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
849
+ return [`Tokens: ${formatAccountingValue(total)} (in=${formatAccountingValue(accounting.input)} out=${formatAccountingValue(accounting.output)} cache-read=${formatAccountingValue(accounting.cacheRead)} cache-write=${formatAccountingValue(accounting.cacheWrite)})`, `Cost: $${accounting.cost.toFixed(2)}`];
850
+ }
552
851
 
553
- export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
852
+ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
554
853
  void worktrees;
555
854
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
556
855
  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
856
  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" ? "●" : "◆";
857
+ const glyph = runStateGlyph(run.state, "⠦");
559
858
  const header = `${glyph} ${run.workflowName}`;
560
859
  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
860
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
@@ -564,7 +863,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
564
863
  lines.push("");
565
864
  const byId = new Map(run.agents.map((a) => [a.id, a]));
566
865
  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" ? "⠦" : "○";
866
+ const icon = agentStateGlyph(agent.state, "⠦");
568
867
  const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
569
868
  const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
570
869
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
@@ -573,7 +872,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
573
872
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
574
873
  if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
575
874
  }
576
- const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
875
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
577
876
  if (activity) result.push(`${indent} ${activity}`);
578
877
  return result.join("\n");
579
878
  };
@@ -582,7 +881,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
582
881
  return lines.join("\n");
583
882
  }
584
883
 
585
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
884
+ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
586
885
  const { run, snapshot } = loaded;
587
886
  const lines = [
588
887
  `Workflow: ${run.workflowName}`,
@@ -611,67 +910,102 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
611
910
  const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
612
911
  for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
613
912
  for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
913
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
914
+ if (activity) result.push(`${indent} ${activity}`);
614
915
  return result.join("\n");
615
916
  }));
616
917
  lines.push("Checkpoints:");
617
918
  if (!checkpoints.length) lines.push(" (none)");
618
919
  for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
619
- lines.push(`Worktrees: ${String(_worktrees.length)}`);
920
+ lines.push(`Worktrees: ${String(worktrees.length)}`);
620
921
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
621
922
  return lines.join("\n");
622
923
  }
623
- export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
924
+ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string[] {
624
925
  const safeWidth = Math.max(1, width);
625
926
  const model = buildWorkflowPhaseModel(run, snapshot);
927
+ const tree = buildWorkflowPhaseTree(model);
928
+ const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
626
929
  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)}`;
930
+ // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
931
+ const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
932
+ const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
933
+ const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
934
+ const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
935
+ const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
936
+ const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
937
+ 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];
938
+ const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
939
+ const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
940
+ const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
941
+ const agents: AgentRecord[] = [];
942
+ const visit = (id: string): void => {
943
+ const child = tree.byId.get(id);
944
+ if (!child) return;
945
+ if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
946
+ };
947
+ if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
948
+ return agents;
949
+ };
950
+ const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
951
+ const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
952
+ const treeLine = (node: WorkflowPhaseTreeNode): string => {
953
+ const selected = node.id === selectedNode?.id;
954
+ const state = progressStyleForState(node.state, styles);
955
+ const activity = node.agent && !SETTLED_AGENT_STATES.has(node.agent.state) ? formatAgentActivity(node.agent, "⠦", styles, now) : "";
956
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}${activity ? ` ${activity}` : ""}`;
957
+ };
958
+ const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
959
+ if (!node) return [styles.muted("No workflow node is selected")];
960
+ const agents = nodeAgents(node);
961
+ if (node.kind === "phase") {
962
+ const selected = node.phase;
963
+ const counts = selected?.counts ?? phaseAgentCounts(agents);
964
+ 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)}`];
965
+ }
966
+ if (node.kind === "operation") {
967
+ const states = phaseAgentCounts(agents);
968
+ 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)}`];
969
+ }
970
+ const agent = node.agent;
971
+ if (!agent) return [styles.muted("Agent details are unavailable")];
972
+ const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
973
+ 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)}`, ...(agent.accounting ? formatAgentAccounting(agent.accounting) : []), ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
974
+ const error = agent.attemptDetails?.at(-1)?.error;
975
+ if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
976
+ if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
977
+ const stalled = stalledDuration(agent, now);
978
+ if (stalled !== undefined) result.push(styles.warning(`stalled? ${formatStalledDuration(stalled)}`));
979
+ return result;
980
+ };
631
981
  const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
632
982
  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
983
  const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
637
984
  if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
638
985
  lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
639
986
  for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
640
987
  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;
988
+ const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
989
+ const actionRows = (): string[] => {
990
+ const actions = selection.actions;
991
+ if (!actions) return [];
992
+ return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
656
993
  };
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];
994
+ const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
662
995
  if (safeWidth >= 80) {
663
- const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
996
+ const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
664
997
  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")];
998
+ const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
999
+ const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
667
1000
  const rows = Math.max(sidebar.length, detail.length);
668
- for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
1001
+ for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
1002
+ } else if (selection.detailsOnly) {
1003
+ lines.push(...detailRows().flatMap((line) => wrap(line)));
669
1004
  } 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)));
1005
+ lines.push(...renderTree(safeWidth));
1006
+ if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
673
1007
  }
674
- if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
1008
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
675
1009
  return lines.flatMap((line) => wrap(line));
676
1010
  }
677
1011
  function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
@@ -827,16 +1161,35 @@ export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiag
827
1161
  const retry = diagnostic.retry ? [` Retry: ${diagnostic.retry.action}`, ` Replayable completed paths: ${diagnostic.retry.completedPaths.join(", ") || "(none)"}`, ` Incomplete paths: ${diagnostic.retry.incompletePaths.join(", ") || "(unknown)"}`, ` Named worktrees: ${diagnostic.retry.namedWorktrees.join(", ") || "(none)"}`, ` Warning: ${diagnostic.retry.warning}`] : [];
828
1162
  return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ...retry, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
829
1163
  }
1164
+ function deliveryPart(value: string, maxBytes: number): string { return utf8Prefix(value.replace(/\s+/g, " ").trim(), maxBytes) || "(unknown)"; }
1165
+ export function formatWorkflowFailureDelivery(diagnostic: WorkflowFailureDiagnostics): string {
1166
+ const name = deliveryPart(diagnostic.workflowName, 128);
1167
+ const runId = deliveryPart(diagnostic.runId, 128);
1168
+ const error = `${diagnostic.error.code}: ${deliveryPart(diagnostic.error.message, 768)}`;
1169
+ const failedPath = diagnostic.failedAt ? `; failed path=${deliveryPart(diagnostic.failedAt, 512)}` : "";
1170
+ const nextAction = diagnostic.retry ? `; next action: ${deliveryPart(diagnostic.retry.action, 256)}` : "";
1171
+ const artifacts = `; artifacts: runDirectory=${deliveryPart(diagnostic.artifacts.runDirectory, 512)} statePath=${deliveryPart(diagnostic.artifacts.statePath, 512)} journalPath=${deliveryPart(diagnostic.artifacts.journalPath, 512)}`;
1172
+ const line = `Workflow ${name} failed (runId=${runId}): error=${error}${failedPath}${nextAction}${artifacts}`;
1173
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1174
+ }
1175
+ function formatWorkflowFailureDeliveryFallback(workflowName: string, runId: string, runDirectory: string, error: unknown): string {
1176
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
1177
+ const failedPath = workflowFailedAt(error);
1178
+ const nextAction = code === "BUDGET_EXHAUSTED" || code === "CANCELLED" ? "" : `; next action: workflow_retry({ runId: ${JSON.stringify(runId)} })`;
1179
+ const line = `Workflow ${deliveryPart(workflowName, 128)} failed (runId=${deliveryPart(runId, 128)}): error=${code}: ${deliveryPart(formatWorkflowFailure(error), 768)}${failedPath ? `; failed path=${deliveryPart(failedPath, 512)}` : ""}${nextAction}; artifacts: runDirectory=${deliveryPart(runDirectory, 512)} statePath=${deliveryPart(join(runDirectory, "state.json"), 512)} journalPath=${deliveryPart(join(runDirectory, "journal.json"), 512)}`;
1180
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1181
+ }
830
1182
 
831
1183
  function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
832
1184
  function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
833
1185
  return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
834
1186
  }
835
1187
  function deliver(pi: ExtensionAPI, content: string): void {
1188
+ if (typeof pi.sendMessage !== "function") return;
836
1189
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
837
1190
  }
838
1191
  function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
839
- deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
1192
+ deliver(pi, formatWorkflowFailureDelivery(diagnostic));
840
1193
  }
841
1194
 
842
1195
  type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -1094,26 +1447,25 @@ function projectTrusted(ctx: unknown): boolean {
1094
1447
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
1095
1448
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
1096
1449
  }
1450
+ function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
1097
1451
  type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
1098
- function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
1099
1452
  function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
1100
1453
  function piHostCapabilities(pi: unknown): PiHostCapabilities {
1101
1454
  if (!object(pi)) return {};
1102
- const registerEntryRenderer = pi.registerEntryRenderer;
1455
+ const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
1103
1456
  const events = pi.events;
1104
- return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1457
+ return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
1105
1458
  }
1106
1459
  type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
1107
1460
  type ModelSummary = { provider: string; id: string };
1108
1461
  type ModelRegistryGetter = () => readonly ModelSummary[];
1109
1462
  type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
1110
- function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
1111
1463
  function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
1112
1464
  if (!object(ctx) || !object(ctx.modelRegistry)) return {};
1113
1465
  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) } : {}) } };
1466
+ const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
1467
+ const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
1468
+ return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
1117
1469
  }
1118
1470
  function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
1119
1471
  const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
@@ -1146,23 +1498,21 @@ type UiSelect = (title: string, options: string[]) => Promise<string | undefined
1146
1498
  type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
1147
1499
  type UiSetStatus = (key: string, text?: string) => void;
1148
1500
  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
1501
  function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
1153
1502
  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 } : {}) };
1503
+ const select = (asFn(ui.select) as UiSelect | undefined);
1504
+ const input = (asFn(ui.input) as UiInput | undefined);
1505
+ const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
1506
+ return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
1158
1507
  }
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 }) } };
1508
+ function tuiRows(tui: unknown): number {
1509
+ const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1510
+ return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1163
1511
  }
1164
- function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
1512
+ const WORKFLOW_PANEL_FOOTER_ROWS = 2;
1165
1513
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1514
+ const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1515
+ const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
1166
1516
  type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
1167
1517
  function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
1168
1518
  return {
@@ -1174,14 +1524,21 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
1174
1524
  };
1175
1525
  }
1176
1526
  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 };
1527
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
1528
+ const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
1529
+ return getKeys ? getKeys.call(keybindings, name) : undefined;
1530
+ }
1531
+ type WorkflowKeybindings = { matches(data: string, binding: string): boolean };
1532
+ const WORKFLOW_VIM_KEYS: Readonly<Record<string, string>> = { "tui.select.up": "k", "tui.select.down": "j", "tui.editor.cursorLeft": "h", "tui.editor.cursorRight": "l" };
1533
+ function workflowKeyMatches(keybindings: WorkflowKeybindings, data: string, binding: string): boolean { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
1534
+ function workflowKeyLabel(keybindings: unknown, binding: string, fallback: string, labels: Readonly<Record<string, string>>): string {
1535
+ const keys = keybindingKeys(keybindings, binding);
1536
+ const configured = keys?.length ? keys.map((key) => labels[key] ?? key) : [fallback];
1537
+ const vim = WORKFLOW_VIM_KEYS[binding];
1538
+ return [...new Set(vim ? [...configured, vim] : configured)].join("/");
1181
1539
  }
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
1540
 
1184
- export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
1541
+ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string, additionalSkillPaths: readonly string[] = []) {
1185
1542
  beginWorkflowExtensionLoading();
1186
1543
  const registry = loadingRegistry();
1187
1544
  const extensionAgentDir = agentDir ?? getAgentDir();
@@ -1202,7 +1559,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1202
1559
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1203
1560
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1204
1561
  });
1205
- type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
1562
+ type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun };
1206
1563
  const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
1207
1564
  let providerRecoveryQueue = Promise.resolve();
1208
1565
  const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
@@ -1226,14 +1583,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1226
1583
  });
1227
1584
  };
1228
1585
  const pendingFailureDiagnostics = new Map<string, WorkflowFailureDiagnostics>();
1229
- pi.on("tool_result", (event) => {
1230
- if (event.toolName !== "workflow" || !event.isError) return;
1231
- const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1232
- if (!diagnostic) return;
1233
- pendingFailureDiagnostics.delete(event.toolCallId);
1234
- return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1235
- });
1586
+ const foregroundDeliveries = new Map<string, { store: RunStore; inline: boolean; timer?: ReturnType<typeof setTimeout> }>();
1236
1587
  const liveActivities = new Map<string, Map<string, AgentActivity>>();
1588
+ const liveEventTimes = new Map<string, Map<string, number>>();
1237
1589
  const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
1238
1590
  const activities = liveActivities.get(runId);
1239
1591
  if (activity) {
@@ -1244,12 +1596,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1244
1596
  if (activities?.size === 0) liveActivities.delete(runId);
1245
1597
  }
1246
1598
  };
1599
+ const setLiveEventTime = (runId: string, agentId: string, timestamp?: number) => {
1600
+ if (timestamp === undefined) return;
1601
+ const timestamps = liveEventTimes.get(runId);
1602
+ if (timestamps) timestamps.set(agentId, timestamp);
1603
+ else liveEventTimes.set(runId, new Map([[agentId, timestamp]]));
1604
+ };
1247
1605
  const withLiveActivities = (run: PersistedRun): PersistedRun => {
1248
1606
  const activities = liveActivities.get(run.id);
1249
- return activities?.size ? { ...run, agents: run.agents.map((agent) => {
1250
- const activity = activities.get(agent.id);
1251
- return activity ? { ...agent, activity } : agent;
1252
- }) } : run;
1607
+ const timestamps = liveEventTimes.get(run.id);
1608
+ if (!activities?.size && !timestamps?.size) return run;
1609
+ return { ...run, agents: run.agents.map((agent) => {
1610
+ const activity = activities?.get(agent.id);
1611
+ const lastEventAt = timestamps?.get(agent.id);
1612
+ if (activity === undefined && lastEventAt === undefined) return agent;
1613
+ return { ...agent, ...(activity === undefined ? {} : { activity }), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
1614
+ }) };
1253
1615
  };
1254
1616
  const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
1255
1617
  let sessionLease: SessionLease | undefined;
@@ -1271,6 +1633,42 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1271
1633
  await eventPublisher.budget(store, metadata, persisted);
1272
1634
  return persisted;
1273
1635
  };
1636
+ pi.on("tool_result", async (event) => {
1637
+ const delivery = event.toolName === "workflow" ? foregroundDeliveries.get(event.toolCallId) : undefined;
1638
+ if (delivery) {
1639
+ if (delivery.timer) clearTimeout(delivery.timer);
1640
+ delivery.inline = true;
1641
+ await delivery.store.updateState((current) => {
1642
+ if (current.delivery?.toolCallId !== event.toolCallId || current.delivery.state === "delivered") return current;
1643
+ return { ...current, delivery: { ...current.delivery, state: "delivered" } };
1644
+ });
1645
+ foregroundDeliveries.delete(event.toolCallId);
1646
+ }
1647
+ if (event.toolName !== "workflow" || !event.isError) return;
1648
+ const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1649
+ if (!diagnostic) return;
1650
+ pendingFailureDiagnostics.delete(event.toolCallId);
1651
+ return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1652
+ });
1653
+ const deliverTerminal = async (store: RunStore, content: string): Promise<void> => {
1654
+ let claimed: boolean | undefined;
1655
+ await store.updateState((current) => {
1656
+ if (current.delivery?.state === "delivered") return current;
1657
+ if (!current.delivery) { claimed = true; return current; }
1658
+ claimed = true;
1659
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "delivered" } };
1660
+ });
1661
+ if (claimed === true) deliver(pi, content);
1662
+ };
1663
+ const scheduleForegroundDelivery = (toolCallId: string, send: () => Promise<void>): void => {
1664
+ const delivery = foregroundDeliveries.get(toolCallId);
1665
+ if (!delivery || delivery.inline || typeof (pi as unknown as { sendMessage?: unknown }).sendMessage !== "function") return;
1666
+ //NOTE: Give Pi one event-loop turn to deliver an uninterrupted tool result before promoting.
1667
+ delivery.timer = setTimeout(() => {
1668
+ delete delivery.timer;
1669
+ void send().finally(() => foregroundDeliveries.delete(toolCallId));
1670
+ }, 0);
1671
+ };
1274
1672
  const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
1275
1673
  let cursor = 0;
1276
1674
  return async (phase: string): Promise<void> => {
@@ -1311,17 +1709,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1311
1709
  const path = shellIdentityPath(identity);
1312
1710
  const replayed = await store.replay(path);
1313
1711
  if (replayed) return readShellResult(replayed.value);
1314
- const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1315
- const result = await executeShellCommand(command, options, signal, cwd);
1316
- await store.complete(path, result as unknown as JsonValue);
1317
- return result;
1712
+ const started = await persistRunState(store, metadata, (current) => ({ ...current, activeShells: (current.activeShells ?? 0) + 1 }));
1713
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(started)));
1714
+ try {
1715
+ const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1716
+ const result = await executeShellCommand(command, options, signal, cwd);
1717
+ await store.complete(path, result as unknown as JsonValue);
1718
+ return result;
1719
+ } finally {
1720
+ const stopped = await persistRunState(store, metadata, (current) => {
1721
+ const activeShells = Math.max(0, (current.activeShells ?? 0) - 1);
1722
+ if (activeShells > 0) return { ...current, activeShells };
1723
+ const next = { ...current };
1724
+ delete next.activeShells;
1725
+ return next;
1726
+ });
1727
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(stopped)));
1728
+ }
1318
1729
  } finally { await lifecycle.leave(); }
1319
1730
  };
1320
1731
  const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
1321
1732
  if (next !== "pausing") budget.transition(next);
1322
1733
  const persisted = await persistRunState(store, metadata, (current) => {
1323
1734
  const nextRun = { ...current, state: next, ...budget.snapshot() };
1324
- if (next === "running" || next === "completed") delete nextRun.error;
1735
+ if (next === "running" || next === "completed") { delete nextRun.error; delete nextRun.failedAt; }
1325
1736
  return nextRun;
1326
1737
  });
1327
1738
  await eventPublisher.runState(store, metadata, previous, next, reason);
@@ -1335,25 +1746,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1335
1746
  const onProgress = async (progress: AgentProgress) => {
1336
1747
  let runState: PersistedRun;
1337
1748
  if (progress.persist) {
1338
- runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
1749
+ runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) } : current);
1339
1750
  } else {
1340
1751
  const loaded = await run.store.load();
1341
1752
  if (!loaded.run.agents.some((agent) => agent.id === id)) return;
1342
- runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
1753
+ runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) };
1343
1754
  }
1344
1755
  if (!runState.agents.some((agent) => agent.id === id)) return;
1345
1756
  setLiveActivity(runId, id, progress.activity);
1757
+ setLiveEventTime(runId, id, progress.lastEventAt);
1346
1758
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1347
1759
  };
1348
1760
  const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
1349
1761
  await scheduler.flush();
1350
1762
  scheduler.attemptStarted(id);
1763
+ const lastEventAt = Date.now();
1764
+ setLiveEventTime(runId, id, lastEventAt);
1351
1765
  await scheduler.flush();
1352
1766
  const before = (await run.store.load()).run;
1353
1767
  await persistActiveAgentAttempt(run.store, id, attempt);
1354
1768
  const active = (await run.store.load()).run;
1355
1769
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
1356
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1770
+ const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, lastEventAt } : agent) }));
1357
1771
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1358
1772
  };
1359
1773
  const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(run.providerErrorRecovery ? { providerErrorRecovery: run.providerErrorRecovery } : {}), ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
@@ -1392,7 +1806,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1392
1806
  let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
1393
1807
  try { effective = run.executor.resolve(requested); }
1394
1808
  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 } : {}) };
1809
+ const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1810
+ const lastEventAt = node.state === "running" ? previous?.state === "running" && previous.lastEventAt !== undefined ? previous.lastEventAt : Date.now() : previous?.lastEventAt;
1811
+ 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 } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
1396
1812
  });
1397
1813
  return { ...current, agents };
1398
1814
  });
@@ -1401,7 +1817,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1401
1817
  });
1402
1818
  const cleanupTerminalRun = async (runId: string): Promise<void> => {
1403
1819
  const run = runs.get(runId);
1404
- if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state)) return;
1820
+ if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
1405
1821
  await scheduler.cancelRun(runId);
1406
1822
  await scheduler.flush();
1407
1823
  if (runs.get(runId) !== run) return;
@@ -1409,6 +1825,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1409
1825
  terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
1410
1826
  run.checkpointResolvers.clear();
1411
1827
  liveActivities.delete(runId);
1828
+ liveEventTimes.delete(runId);
1412
1829
  eventPublisher.removeRun(runId);
1413
1830
  runs.delete(runId);
1414
1831
  };
@@ -1444,13 +1861,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1444
1861
  run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
1445
1862
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1446
1863
  };
1447
- const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
1864
+ const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult | undefined> => {
1448
1865
  const run = runs.get(runId);
1449
1866
  if (!run) return undefined;
1450
1867
  const request = await run.store.answerWorkflowDecision(proposalId, approved);
1451
1868
  if (!request) return undefined;
1452
1869
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1453
- const result = await applyBudgetDecision(request, approved, context, signal);
1870
+ const result = await applyBudgetDecision(request, approved, context, signal, waitForCompletion);
1454
1871
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1455
1872
  return result;
1456
1873
  };
@@ -1514,6 +1931,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1514
1931
  throw mainAgentError(error);
1515
1932
  }
1516
1933
  },
1934
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
1935
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1517
1936
  });
1518
1937
  pi.registerTool({
1519
1938
  name: "workflow_stop",
@@ -1528,6 +1947,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1528
1947
  throw mainAgentError(error);
1529
1948
  }
1530
1949
  },
1950
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
1951
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1531
1952
  });
1532
1953
  let catalogRegistered = false;
1533
1954
  let sessionStarted = false;
@@ -1557,35 +1978,102 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1557
1978
  });
1558
1979
  catalogRegistered = true;
1559
1980
  };
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));
1981
+ const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), agentSetupHooks: registry.agentSetupHooks() }, createSession);
1982
+ const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
1983
+ ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
1984
+ : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
1985
+ const resumeLaunchPrologue = async (input: {
1986
+ snapshot: Readonly<LaunchSnapshot>;
1987
+ cwd: string;
1988
+ trustedProject: boolean;
1989
+ rootModel: ModelSpec;
1990
+ modelRegistry?: ModelRegistryCapability | undefined;
1991
+ signal: AbortSignal;
1992
+ resolvedAliases?: Readonly<Record<string, string>>;
1993
+ blockedAliases?: ReadonlySet<string>;
1994
+ blockedAliasTargets?: Readonly<Record<string, string>>;
1995
+ withPreflight: boolean;
1996
+ }) => {
1997
+ const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
1998
+ const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
1564
1999
  if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
1565
2000
  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);
2001
+ const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
2002
+ const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
1569
2003
  const staticAliases = resolution.effective.modelAliases ?? {};
1570
- const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
2004
+ const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
2005
+ const inventory = modelInventory(input.rootModel, input.modelRegistry);
2006
+ const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
2007
+ const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
2008
+ 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;
2009
+ const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2010
+ const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
2011
+ let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
2012
+ if (input.withPreflight) {
2013
+ const resumeAliases = { ...previousAliases, ...currentAliases };
2014
+ script = launchScriptForSnapshot(input.snapshot, registry);
2015
+ 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);
2016
+ }
2017
+ const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
2018
+ const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
2019
+ return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
2020
+ };
2021
+ const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string, captureRole?: (role: string, model: ModelSpec) => Promise<void>) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
2022
+ await lifecycle.enter();
2023
+ try {
2024
+ const path = agentIdentityPath(identity);
2025
+ const replayed = await store.replay(path);
2026
+ if (replayed) {
2027
+ return replayed.value;
2028
+ }
2029
+ const worktree = agentWorktree(identity);
2030
+ const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
2031
+ const role = typeof options.role === "string" ? options.role : undefined;
2032
+ const model = typeof options.model === "string" ? options.model : undefined;
2033
+ const thinking = parseThinking(options.thinking);
2034
+ const requestedLabel = typeof options.label === "string" ? options.label : undefined;
2035
+ 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[] } : {}) });
2036
+ if (role) await captureRole?.(role, resolved.model);
2037
+ const label = displayAgentName(requestedLabel, role, resolved.model);
2038
+ const tools = resolved.tools;
2039
+ const schema = object(options.outputSchema) ? options.outputSchema : undefined;
2040
+ 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 });
2041
+ const cancel = () => { scheduler.cancel(spawned.id); };
2042
+ if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
2043
+ const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
2044
+ if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
2045
+ await store.complete(path, outcome.value);
2046
+ return outcome.value;
2047
+ } finally { await lifecycle.leave(); }
2048
+ };
2049
+ const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
2050
+ const loaded = await run.store.load();
2051
+ const trustedProject = context?.projectTrusted ?? run.projectTrusted();
1571
2052
  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 });
2053
+ 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
2054
  await run.store.saveSnapshot(snapshot);
1581
2055
  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);
2056
+ 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
2057
  run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
1584
2058
  const drift = aliasDrift(previousAliases, currentAliases);
1585
2059
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1586
2060
  };
1587
- const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }) => {
2061
+ const recoveryUi = (context: unknown): { hasUI: boolean; ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> } } => {
2062
+ const host = object(context) ? context : undefined;
2063
+ const ui = host && object(host.ui) ? host.ui as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } : {};
2064
+ return { hasUI: host?.hasUI === true, ui };
2065
+ };
2066
+ type ColdResumeResult = { value: JsonValue; resultPath: string };
2067
+ const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
1588
2068
  const loaded = await run.store.load();
2069
+ const foreground = modeOverride ?? loaded.snapshot.launchMode === "foreground";
2070
+ if (loaded.run.activeShells !== undefined) {
2071
+ await persistRunState(run.store, run.metadata, (current) => {
2072
+ const next = { ...current };
2073
+ delete next.activeShells;
2074
+ return next;
2075
+ });
2076
+ }
1589
2077
  await run.store.validateRetrySource();
1590
2078
  await run.store.validateBorrowedWorktrees();
1591
2079
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
@@ -1593,32 +2081,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1593
2081
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1594
2082
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1595
2083
  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
2084
  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
2085
  const controller = new AbortController();
1609
2086
  if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
1610
2087
  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 });
1619
- await run.store.saveSnapshot(snapshot);
2088
+ 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 });
2089
+ if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
2090
+ const persistedSnapshot = modeOverride === undefined ? snapshot : createLaunchSnapshot({ ...snapshot, launchMode: foreground ? "foreground" : "background" });
2091
+ await run.store.saveSnapshot(persistedSnapshot);
1620
2092
  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);
2093
+ 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
2094
  const drift = aliasDrift(previousAliases, currentAliases);
1623
2095
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1624
2096
  const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
@@ -1627,39 +2099,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1627
2099
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1628
2100
  catch (error) {
1629
2101
  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); }
2102
+ 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) => persistedFailure({ ...current }, typed)); 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
2103
  await cleanupTerminalRun(run.store.runId);
1632
2104
  throw typed;
1633
2105
  }
1634
2106
  await scheduler.cancelRun(run.store.runId);
1635
2107
  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);
2108
+ 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, foreground, 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
2109
  run.execution = execution;
1664
2110
  const completion = execution.result.then(async (value) => {
1665
2111
  await scheduler.flush();
@@ -1672,17 +2118,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1672
2118
  await scheduler.flush();
1673
2119
  const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
1674
2120
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
1675
- const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2121
+ const persisted = await persistRunState(run.store, run.metadata, (current) => persistedFailure({ ...current, ...run.budget.snapshot() }, typed));
1676
2122
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
1677
2123
  if (state === "failed") retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
1678
2124
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
1679
2125
  run.update?.(workflowToolUpdate(persisted));
1680
- if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined);
2126
+ if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) { const diagnostic = await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted); Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic }); }
2127
+ throw typed;
1681
2128
  }).finally(() => cleanupTerminalRun(run.store.runId));
1682
2129
  run.completion = completion;
1683
- void completion;
2130
+ if (!foreground || !waitForCompletion) {
2131
+ void completion.then(async ({ value, resultPath }) => {
2132
+ deliver(pi, completionDelivery(run.metadata.name, value, resultPath, await run.store.changedWorktrees()));
2133
+ }, (error: unknown) => {
2134
+ const diagnostic = failureDiagnosticsFrom(error);
2135
+ if (diagnostic) deliverFailure(pi, diagnostic);
2136
+ else deliver(pi, formatWorkflowFailureDeliveryFallback(run.metadata.name, run.store.runId, run.store.directory, error));
2137
+ });
2138
+ return undefined;
2139
+ }
2140
+ return completion;
1684
2141
  };
1685
- const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
2142
+ const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult> => {
1686
2143
  const run = runs.get(request.runId);
1687
2144
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
1688
2145
  if (!approved) return { state: "budget_exhausted", approved: false };
@@ -1691,14 +2148,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1691
2148
  const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
1692
2149
  run.budget = runtime;
1693
2150
  await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
1694
- await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
2151
+ const { hasUI, ui } = recoveryUi(context);
2152
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, undefined, waitForCompletion);
2153
+ if (completed) return { state: "completed", approved: true, value: completed.value, run: (await run.store.load()).run };
1695
2154
  return { state: "running", approved: true };
1696
2155
  };
1697
- const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
2156
+ const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal, modeOverride?: boolean, waitForCompletion = true): Promise<Record<string, JsonValue>> => {
1698
2157
  const run = runs.get(runId);
1699
- if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
2158
+ if (!run) {
2159
+ const host = object(context) ? context : {};
2160
+ const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
2161
+ const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
2162
+ const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
2163
+ if (cwd && sessionId) {
2164
+ try {
2165
+ const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
2166
+ throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
2167
+ } catch (error) {
2168
+ if (error instanceof WorkflowError) throw error;
2169
+ }
2170
+ }
2171
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
2172
+ }
1700
2173
  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");
2174
+ if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
1702
2175
  const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1703
2176
  const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
1704
2177
  const nextBudget = mergeBudget(currentBudget, patch);
@@ -1719,11 +2192,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1719
2192
  run.budget = runtime;
1720
2193
  await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
1721
2194
  }
1722
- await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
2195
+ const { hasUI, ui } = recoveryUi(context);
2196
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, modeOverride, waitForCompletion);
2197
+ if (completed) return { state: "completed", runId, value: completed.value, run: (await run.store.load()).run as unknown as JsonValue };
1723
2198
  return { state: "running" };
1724
2199
  };
1725
2200
  const retryReservations = new Set<string>();
1726
- const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
2201
+ const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal, modeOverride?: boolean): Promise<{ runId: string; parentRunId: string; state: "running" | "completed"; value?: JsonValue; run?: PersistedRun }> => {
1727
2202
  if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
1728
2203
  const host = object(context) ? context : {};
1729
2204
  const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
@@ -1733,8 +2208,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1733
2208
  await ensureSessionLease(cwd, sessionId);
1734
2209
  const sourceStore = new RunStore(cwd, sessionId, runId, home);
1735
2210
  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}`);
2211
+ 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)}`); }
2212
+ if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
1738
2213
  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
2214
  const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
1740
2215
  if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
@@ -1760,27 +2235,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1760
2235
  if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
1761
2236
  const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
1762
2237
  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
2238
  const modelRegistry = contextHostCapabilities(context).modelRegistry;
1772
2239
  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
2240
  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);
2241
+ 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
2242
  await sourceStore.validateNamedWorktrees();
1785
2243
  for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
1786
2244
  const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
@@ -1789,8 +2247,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1789
2247
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
1790
2248
  const childRunId = randomUUID();
1791
2249
  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 });
2250
+ const childSnapshot = childBaseSnapshot;
1794
2251
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
1795
2252
  const childInitialBudget = childBudget.snapshot();
1796
2253
  const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
@@ -1801,16 +2258,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1801
2258
  const abortController = new AbortController();
1802
2259
  const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
1803
2260
  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 } : {}) };
2261
+ 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
2262
  runs.set(childRunId, childRun);
1806
2263
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
1807
2264
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
1808
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
2265
+ const { hasUI, ui } = recoveryUi(context);
2266
+ const completed = await coldResumeRun(childRun, hasUI, ui, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
1809
2267
  const completion = runs.get(childRunId)?.completion;
1810
2268
  if (completion) {
1811
2269
  childStarted = true;
1812
2270
  void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
2271
+ } else if (completed) {
2272
+ childStarted = true;
2273
+ retryReservations.delete(lineageRootRunId);
1813
2274
  }
2275
+ if (completed) return { runId: childRunId, parentRunId: loaded.run.id, state: "completed", value: completed.value, run: (await childStore.load()).run };
1814
2276
  return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
1815
2277
  } finally {
1816
2278
  if (!childStarted) retryReservations.delete(lineageRootRunId);
@@ -1822,19 +2284,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1822
2284
  description: "Retry a failed workflow run by replaying its completed structural operations",
1823
2285
  parameters: WORKFLOW_RETRY_PARAMETERS,
1824
2286
  async execute(_id, params, signal, _onUpdate, ctx) {
1825
- try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2287
+ try { const result = await retryWorkflowRun(params.runId, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1826
2288
  catch (error) { throw mainAgentError(error); }
1827
2289
  },
2290
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
2291
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1828
2292
  });
1829
2293
  pi.registerTool({
1830
2294
  name: "workflow_resume",
1831
2295
  label: "Workflow Resume",
1832
2296
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
1833
- parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
2297
+ parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) }, { additionalProperties: false }),
1834
2298
  async execute(_id, params, signal, _onUpdate, ctx) {
1835
- try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2299
+ try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
1836
2300
  catch (error) { throw mainAgentError(error); }
1837
2301
  },
2302
+ renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
2303
+ renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
1838
2304
  });
1839
2305
  pi.on("session_start", async (_event, ctx) => {
1840
2306
  if (sessionStarted) return;
@@ -1851,10 +2317,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1851
2317
  if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
1852
2318
  if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
1853
2319
  const previousState = loaded.run.state;
1854
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2320
+ await store.updateState((current) => {
2321
+ if (["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state)) return current;
2322
+ const next = { ...current, state: "interrupted" as const };
2323
+ delete next.activeShells;
2324
+ return next;
2325
+ });
1855
2326
  loaded = { ...loaded, run: (await store.load()).run };
1856
2327
  await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
1857
2328
  loaded = { ...loaded, run: (await store.load()).run };
2329
+ } else if (loaded.run.activeShells !== undefined) {
2330
+ await store.updateState((current) => {
2331
+ if (["completed", "failed", "stopped"].includes(current.state)) return current;
2332
+ const next = { ...current };
2333
+ delete next.activeShells;
2334
+ return next;
2335
+ });
2336
+ loaded = { ...loaded, run: (await store.load()).run };
1858
2337
  }
1859
2338
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
1860
2339
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
@@ -1865,7 +2344,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1865
2344
  const roleDefinitions = loaded.snapshot.roles ?? {};
1866
2345
  const abortController = new AbortController();
1867
2346
  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 } : {}) });
2347
+ 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
2348
  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
2349
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
1871
2350
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
@@ -1880,7 +2359,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1880
2359
  if (choice && choice !== "Skip") {
1881
2360
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
1882
2361
  for (const run of toResume) {
1883
- try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
2362
+ try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx, undefined, false); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
1884
2363
  catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
1885
2364
  }
1886
2365
  }
@@ -1913,7 +2392,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1913
2392
  const inventory = modelInventory(rootModel, modelRegistry);
1914
2393
  const knownModels = inventory.knownModels;
1915
2394
  const availableModels = inventory.availableModels;
1916
- const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
2395
+ const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
1917
2396
  const trustedProject = projectTrusted(ctx);
1918
2397
  const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
1919
2398
  const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
@@ -1937,45 +2416,33 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1937
2416
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
1938
2417
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
1939
2418
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
1940
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2419
+ const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, launchMode: params.foreground ? "foreground" : "background", settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
2420
+ let persistedSnapshot = snapshot;
2421
+ const captureFunctionRole = functionName ? async (role: string, model: ModelSpec): Promise<void> => {
2422
+ const definition = agentDefinitions[role];
2423
+ if (!definition) return;
2424
+ const modelName = `${model.provider}/${model.model}`;
2425
+ const hasProjectRole = projectAgentDefinitions[role] !== undefined;
2426
+ if (persistedSnapshot.roles?.[role] !== undefined && (!hasProjectRole || persistedSnapshot.projectRoles?.includes(role)) && persistedSnapshot.models.includes(modelName)) return;
2427
+ const roles = { ...(persistedSnapshot.roles ?? {}), [role]: definition };
2428
+ const projectRoles = hasProjectRole ? [...new Set([...(persistedSnapshot.projectRoles ?? []), role])] : persistedSnapshot.projectRoles ?? [];
2429
+ const models = [...new Set([...persistedSnapshot.models, modelName])];
2430
+ persistedSnapshot = createLaunchSnapshot({ ...persistedSnapshot, models, roles, projectRoles });
2431
+ await store.saveSnapshot(persistedSnapshot);
2432
+ } : undefined;
1941
2433
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
1942
2434
  const initialBudget = budgetRuntime.snapshot();
1943
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2435
+ await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], delivery: params.foreground ? { mode: "foreground", state: "attached", toolCallId } : { mode: "background", state: "pending" }, ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2436
+ if (params.foreground) foregroundDeliveries.set(toolCallId, { store, inline: false });
1944
2437
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
1945
2438
  const background = !params.foreground;
1946
2439
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
1947
2440
  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);
2441
+ 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
2442
  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
2443
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
1951
2444
  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);
2445
+ 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, captureFunctionRole), 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
2446
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
1980
2447
  await eventPublisher.runStarted(store, checked.metadata);
1981
2448
  const finish = execution.result.then(async (value) => {
@@ -1989,7 +2456,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1989
2456
  await scheduler.flush();
1990
2457
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
1991
2458
  if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
1992
- const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
2459
+ const persisted = await persistRunState(store, checked.metadata, (current) => persistedFailure({ ...current, ...budgetRuntime.snapshot() }, typed));
1993
2460
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
1994
2461
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
1995
2462
  const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
@@ -1999,16 +2466,37 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1999
2466
  });
2000
2467
  const completion = finish.finally(() => cleanupTerminalRun(runId));
2001
2468
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
2469
+ const deliverFailureContent = (error: unknown): string => {
2470
+ const diagnostic = failureDiagnosticsFrom(error);
2471
+ return diagnostic ? formatWorkflowFailureDelivery(diagnostic) : formatWorkflowFailureDeliveryFallback(checked.metadata.name, runId, store.directory, error);
2472
+ };
2473
+ const queueForegroundDelivery = async (content: string): Promise<void> => {
2474
+ const delivery = foregroundDeliveries.get(toolCallId);
2475
+ if (!delivery) return;
2476
+ await store.updateState((current) => {
2477
+ if (!current.delivery || current.delivery.state === "delivered") return current;
2478
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "pending" } };
2479
+ });
2480
+ if (delivery.inline) return;
2481
+ scheduleForegroundDelivery(toolCallId, async () => {
2482
+ if (delivery.inline) return;
2483
+ pendingFailureDiagnostics.delete(toolCallId);
2484
+ await deliverTerminal(store, content);
2485
+ });
2486
+ };
2002
2487
  if (background) {
2003
2488
  void completion.then(async ({ value, resultPath }) => {
2004
- deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2005
- }, (error: unknown) => {
2006
- const diagnostic = failureDiagnosticsFrom(error);
2007
- if (diagnostic) deliverFailure(pi, diagnostic);
2008
- else deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
2489
+ await deliverTerminal(store, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2490
+ }, async (error: unknown) => {
2491
+ await deliverTerminal(store, deliverFailureContent(error));
2009
2492
  });
2010
2493
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2011
2494
  }
2495
+ void completion.then(async ({ value, resultPath }) => {
2496
+ await queueForegroundDelivery(completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2497
+ }, async (error: unknown) => {
2498
+ await queueForegroundDelivery(deliverFailureContent(error));
2499
+ });
2012
2500
  const { value } = await completion;
2013
2501
  const run = (await store.load()).run;
2014
2502
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
@@ -2050,7 +2538,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2050
2538
  const loadStores = async () => {
2051
2539
  const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
2052
2540
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2053
- try { return { store, loaded: await store.load() }; }
2541
+ try { const loaded = await store.load(); return { store, loaded: { ...loaded, run: withLiveActivities(loaded.run) } }; }
2054
2542
  catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
2055
2543
  }));
2056
2544
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
@@ -2073,12 +2561,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2073
2561
  return keepContext ? "dashboard" : "done";
2074
2562
  }
2075
2563
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2076
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
2564
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx, undefined, false);
2077
2565
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2078
2566
  return keepContext ? "dashboard" : "done";
2079
2567
  }
2080
2568
  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"; }
2569
+ 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
2570
  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
2571
  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
2572
  }
@@ -2086,10 +2574,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2086
2574
  if (action === "resume" && run) {
2087
2575
  if (run.lifecycle.state === "budget_exhausted") {
2088
2576
  const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
2089
- const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
2090
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
2577
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx, undefined, undefined, false);
2578
+ ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
2091
2579
  } else {
2092
- if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
2580
+ if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx, undefined, false);
2093
2581
  else {
2094
2582
  if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
2095
2583
  await run.lifecycle.resume();
@@ -2101,8 +2589,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2101
2589
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2102
2590
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2103
2591
  if (input === undefined) return keepContext ? "dashboard" : "done";
2104
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
2105
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
2592
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx, undefined, undefined, false);
2593
+ ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
2106
2594
  return keepContext ? "dashboard" : "done";
2107
2595
  }
2108
2596
  if (action === "stop" && run) {
@@ -2201,7 +2689,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2201
2689
  }
2202
2690
  const sorted = navigatorAttentionSort(stores);
2203
2691
  const labels = navigatorRunLabels(sorted);
2204
- const terminalStates = new Set(["completed", "failed", "stopped"]);
2692
+ const terminalStates = HARD_TERMINAL_RUN_STATES;
2205
2693
  const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
2206
2694
  const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
2207
2695
  const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
@@ -2246,22 +2734,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2246
2734
  };
2247
2735
  const loadDashboard = async () => {
2248
2736
  const loaded = await store.load();
2737
+ const liveRun = withLiveActivities(loaded.run);
2249
2738
  const checkpoints = await store.awaitingCheckpoints();
2250
2739
  const worktrees = await store.worktrees();
2740
+ const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2741
+ const agentResults = new Map<string, JsonValue>();
2742
+ for (const agent of liveRun.agents) {
2743
+ if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
2744
+ const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2745
+ if (operation) agentResults.set(agent.id, operation.value);
2746
+ }
2251
2747
  const actions = new Map<string, string>();
2252
2748
  const copies = new Map<string, { value: string; artifact: string }>();
2253
2749
  const reviews = new Map<string, AwaitingCheckpoint>();
2254
2750
  const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
2255
2751
  const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
2256
- if (loaded.run.state === "running") add("Pause", "pause");
2257
- if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
2258
- if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
2752
+ if (liveRun.state === "running") add("Pause", "pause");
2753
+ if (["paused", "interrupted"].includes(liveRun.state)) add("Resume", "resume");
2754
+ if (liveRun.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
2259
2755
  for (const decision of await store.pendingWorkflowDecisions()) {
2260
2756
  const id = decision.proposalId.slice(0, 8);
2261
2757
  actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
2262
2758
  actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
2263
2759
  }
2264
- if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
2760
+ if (!terminalStates.has(liveRun.state)) add("Stop", "stop");
2265
2761
  for (const cp of checkpoints) {
2266
2762
  if (ctx.mode === "tui") {
2267
2763
  const label = `Review ${cp.name}`;
@@ -2273,96 +2769,131 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2273
2769
  }
2274
2770
  }
2275
2771
  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");
2278
- if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
2772
+ else actions.set("Open script in editor", "open-script");
2773
+ if (ctx.mode !== "tui" && liveRun.agents.length) actions.set("Agents...", "agents");
2774
+ if (terminalStates.has(liveRun.state)) add("Delete", "delete");
2279
2775
  if (ctx.mode === "tui") {
2280
2776
  addCopy("Copy run path", store.directory, "run path");
2281
2777
  addCopy("Copy run ID", store.runId, "run ID");
2282
2778
  }
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 };
2779
+ return { dashboard: formatWorkflowPhaseDashboard(liveRun, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(liveRun, loaded.snapshot), run: liveRun, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: liveRun.agents, worktrees, cwd: liveRun.cwd };
2284
2780
  };
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"] : []),
2781
+ const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
2782
+ const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
2783
+ const worktree = agentWorktreeFor(dashboard, agent);
2784
+ return [
2785
+ ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2297
2786
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
2787
+ ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
2298
2788
  "Copy agent ID",
2299
2789
  "Back",
2300
2790
  ];
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
- };
2791
+ };
2792
+ const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
2793
+ const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2794
+ const worktree = agentWorktreeFor(dashboard, agent);
2795
+ const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2796
+ const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2797
+ const index = choice ? choices.indexOf(choice) : -1;
2798
+ const attempt = index >= 0 ? attempts[index] : undefined;
2799
+ if (!attempt) return;
2800
+ const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2801
+ 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;
2802
+ try {
2803
+ await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2804
+ ctx.ui.notify("Forked Pi session in pane.", "info");
2805
+ } catch (error) {
2806
+ ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2807
+ }
2808
+ };
2809
+ const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
2810
+ const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2811
+ const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
2812
+ const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
2813
+ let selected: AgentRecord | undefined;
2814
+ if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
2815
+ else {
2816
+ const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
2817
+ const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
2818
+ selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
2819
+ }
2820
+ if (!selected) return;
2821
+ const worktree = agentWorktreeFor(dashboard, selected);
2822
+ const actions = agentActionLabels(dashboard, selected);
2307
2823
  for (;;) {
2308
2824
  const action = await ctx.ui.select(title(selected), actions);
2309
2825
  if (!action || action === "Back") return;
2310
2826
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
2311
2827
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
2312
2828
  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
- }
2829
+ if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
2325
2830
  }
2326
2831
  };
2327
2832
  for (;;) {
2328
2833
  let view = await loadDashboard();
2329
2834
  const actionChoice = ctx.mode === "tui"
2330
2835
  ? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
2331
- let options = [...view.actions.keys(), "Close"];
2332
- let selectedIndex = 0;
2333
2836
  let dashboardOffset = 0;
2334
2837
  let refreshing = false;
2335
2838
  let disposed = false;
2839
+ let detailsMode = false;
2840
+ let actionMode = false;
2841
+ let actionIndex = 0;
2336
2842
  let stopRequested = false;
2337
2843
  let stopStatus: string | undefined;
2844
+ let selectionNeedsScroll = true;
2845
+ let renderedWidth = 80;
2846
+ let refreshGeneration = 0;
2338
2847
  const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
2339
- let selectedPhaseId = initialSelection.phaseId;
2340
- let selectedAgentId = initialSelection.agentId;
2341
- 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" };
2343
- const keyLabel = (binding: string, fallback: string) => {
2344
- const keys = keybindingKeys(keybindings, binding);
2345
- return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2848
+ let tree = buildWorkflowPhaseTree(view.phaseModel);
2849
+ let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
2850
+ let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2851
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
2852
+ const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
2853
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
2854
+ const selectedAgentRecord = (): AgentRecord | undefined => {
2855
+ const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2856
+ return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
2346
2857
  };
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 };
2858
+ const actionOptions = () => {
2859
+ const agent = selectedAgentRecord();
2860
+ return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
2354
2861
  };
2355
- const updateDashboard = async (selectedOption: string | undefined) => {
2356
- const phaseId = selectedPhaseId;
2357
- const agentId = selectedAgentId;
2862
+ let editorRunning = false;
2863
+ const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
2864
+ if (editorRunning) return;
2865
+ editorRunning = true;
2866
+ try {
2867
+ const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
2868
+ if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
2869
+ const exitCode = await openWorkflowArtifact(tui, command, await artifact);
2870
+ if (exitCode !== 0) {
2871
+ const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
2872
+ ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
2873
+ }
2874
+ } catch (error) {
2875
+ ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2876
+ } finally {
2877
+ editorRunning = false;
2878
+ }
2879
+ };
2880
+ const updateDashboard = async () => {
2881
+ const generation = ++refreshGeneration;
2882
+ const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
2358
2883
  const next = await loadDashboard();
2359
- if (disposed) return;
2884
+ if (disposed || generation !== refreshGeneration) return;
2885
+ const previousNodeId = selectedNodeId;
2886
+ const previousExpanded = expandedNodeIds;
2887
+ const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
2360
2888
  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;
2889
+ tree = buildWorkflowPhaseTree(view.phaseModel);
2890
+ selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
2891
+ expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
2892
+ if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
2893
+ const nextActions = actionOptions();
2894
+ const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
2895
+ actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
2896
+ selectionNeedsScroll = true;
2366
2897
  tui.requestRender();
2367
2898
  };
2368
2899
  const requestStop = () => {
@@ -2370,14 +2901,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2370
2901
  stopRequested = true;
2371
2902
  stopStatus = undefined;
2372
2903
  setWorkflowStatus(undefined);
2373
- const selectedOption = options[selectedIndex];
2374
2904
  void runAction(`stop ${store.runId}`, true, (status) => {
2375
2905
  stopStatus = status;
2376
2906
  setWorkflowStatus(status);
2377
2907
  if (!disposed) tui.requestRender();
2378
- }).then(() => updateDashboard(selectedOption)).catch((error: unknown) => {
2908
+ }).then(() => updateDashboard()).catch((error: unknown) => {
2379
2909
  if (disposed) return;
2380
2910
  stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
2911
+ setWorkflowStatus(stopStatus);
2381
2912
  tui.requestRender();
2382
2913
  }).finally(() => {
2383
2914
  stopRequested = false;
@@ -2387,110 +2918,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2387
2918
  const timer = setInterval(() => {
2388
2919
  if (refreshing || stopRequested) return;
2389
2920
  refreshing = true;
2390
- const selectedOption = options[selectedIndex];
2391
- void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
2921
+ void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2392
2922
  }, 1000);
2393
2923
  timer.unref();
2394
- return borderWorkflowOverlay({
2924
+ return {
2395
2925
  render(width: number) {
2926
+ renderedWidth = width;
2927
+ const narrow = width < 80;
2396
2928
  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);
2929
+ const agent = selectedAgentRecord();
2930
+ const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
2931
+ const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
2932
+ const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
2933
+ const content = [...statusLines, ...phaseLines];
2934
+ const rows = terminalRows();
2935
+ const hintRows = rows >= 3 ? 1 : 0;
2936
+ const viewport = Math.max(1, rows - hintRows);
2937
+ const maxOffset = Math.max(0, content.length - viewport);
2938
+ dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2939
+ if (actionMode) {
2940
+ const label = actions?.options[actionIndex];
2941
+ const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
2942
+ if (actionRow >= 0) {
2943
+ if (actionRow < dashboardOffset) dashboardOffset = actionRow;
2944
+ else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
2945
+ }
2946
+ } else if (!detailsMode && selectionNeedsScroll) {
2947
+ const selectedRow = content.findIndex((line) => line.startsWith("→"));
2948
+ if (selectedRow >= 0) {
2949
+ if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
2950
+ else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
2951
+ }
2952
+ selectionNeedsScroll = false;
2953
+ }
2405
2954
  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
- ];
2955
+ 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" : "back"}${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] ?? "";
2956
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2413
2957
  },
2414
2958
  invalidate() {},
2415
2959
  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;
2960
+ if (stopRequested || editorRunning) return;
2961
+ const narrow = renderedWidth < 80;
2962
+ if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
2963
+ if (actionMode) {
2964
+ const options = actionOptions();
2965
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
2966
+ if (workflowKeyMatches(keybindings, data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
2967
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
2968
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2969
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2970
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
2971
+ const action = options[actionIndex];
2972
+ const agent = selectedAgentRecord();
2973
+ if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
2974
+ else if (agent) {
2975
+ const worktree = agentWorktreeFor(view, agent);
2976
+ if (action === "Open result in editor") {
2977
+ const result = view.agentResults.get(agent.id);
2978
+ if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
2979
+ }
2980
+ else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
2981
+ else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
2982
+ else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
2983
+ else done(`__workflow_fork__:${agent.id}`);
2984
+ }
2985
+ else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
2986
+ else if (action === "Stop") requestStop();
2987
+ else done(action);
2428
2988
  }
2989
+ tui.requestRender();
2990
+ return;
2429
2991
  }
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;
2992
+ const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2993
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
2994
+ if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done("Back");
2995
+ } else if (narrow && detailsMode) {
2996
+ if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2997
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2998
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
2999
+ if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
3000
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2435
3001
  }
3002
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) {
3003
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
3004
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
3005
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorRight")) {
3006
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
3007
+ selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
3008
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.up")) {
3009
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
3010
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
3011
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.down")) {
3012
+ const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
3013
+ selectedNodeId = next.nodeId; selectionNeedsScroll = true;
3014
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3015
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
3016
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
3017
+ if (narrow) detailsMode = true;
3018
+ else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
3019
+ else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2436
3020
  }
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
- }
2445
- else if (keybindings.matches(data, "tui.select.confirm")) {
2446
- if (options[selectedIndex] === "Stop") requestStop();
2447
- else done(options[selectedIndex]);
2448
- }
2449
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
2450
3021
  tui.requestRender();
2451
3022
  },
2452
3023
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2453
- }, theme);
2454
- }, { overlay: true })
2455
- : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2456
- if (!actionChoice || actionChoice === "Close") return;
3024
+ };
3025
+ })
3026
+ : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Back"]);
3027
+ if (!actionChoice || actionChoice === "Back") { stores = await loadStores(); break; }
2457
3028
  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%" } });
3029
+ if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
3030
+ if (actionChoice.startsWith("__workflow_fork__:")) {
3031
+ const agentId = actionChoice.slice("__workflow_fork__:".length);
3032
+ const agent = view.agents.find((candidate) => candidate.id === agentId);
3033
+ if (agent) await forkAgentSession(view, agent);
2492
3034
  continue;
2493
3035
  }
3036
+ if (actionChoice === "Refresh") continue;
2494
3037
  const copy = view.copies.get(actionChoice);
2495
3038
  if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
2496
3039
  if (actionChoice.startsWith("Review ")) {
@@ -2521,7 +3064,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2521
3064
  const currentLayout = layout();
2522
3065
  const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
2523
3066
  offset = Math.min(offset, maxOffset);
2524
- const hint = truncateToVisualLines(theme.fg("dim", "↑↓/pgup/pgdn scroll · enter select · esc cancel"), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
3067
+ const keyLabels: Record<string, string> = { up: "", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
3068
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
3069
+ const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")}/pgup/pgdn scroll · enter select · esc cancel`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
2525
3070
  const controls = currentLayout.compactControls
2526
3071
  ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
2527
3072
  : options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
@@ -2535,16 +3080,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2535
3080
  },
2536
3081
  invalidate() {},
2537
3082
  handleInput(data: string) {
2538
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2539
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
2540
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-layout().contentViewport);
2541
- else if (keybindings.matches(data, "tui.select.pageDown")) move(layout().contentViewport);
2542
- else if (keybindings.matches(data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
2543
- else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
3083
+ if (workflowKeyMatches(keybindings, data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
3084
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
3085
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) move(-layout().contentViewport);
3086
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) move(layout().contentViewport);
3087
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
3088
+ else if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) done(undefined);
2544
3089
  tui.requestRender();
2545
3090
  },
2546
3091
  }, theme);
2547
- }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
3092
+ }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
2548
3093
  if (decision) {
2549
3094
  const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
2550
3095
  if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
@@ -2564,9 +3109,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2564
3109
  pi.on("session_shutdown", async () => {
2565
3110
  try {
2566
3111
  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; }
3112
+ const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
3113
+ if (!isTerminal) {
3114
+ try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
2570
3115
  run.abortController.abort();
2571
3116
  run.execution?.cancel();
2572
3117
  await scheduler.cancelRun(runId);