pi-extensible-workflows 3.2.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.
package/src/host.ts CHANGED
@@ -17,7 +17,7 @@ import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentR
17
17
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
18
18
  import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
19
19
  import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
20
- import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCatalogFunction, type WorkflowCatalogIndex, type WorkflowCatalogVariable, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
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";
21
21
  const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
22
22
  const INTERNAL_WORKFLOW_TOOLS: readonly string[] = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
23
23
  const HARD_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped"]);
@@ -96,6 +96,8 @@ function mainAgentError(error: unknown): WorkflowError {
96
96
  Object.assign(presented, typed);
97
97
  return presented;
98
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 } : {}) }; }
99
101
 
100
102
  export class RunLifecycle {
101
103
  #state: RunState;
@@ -169,7 +171,7 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
169
171
  }
170
172
  export const WORKFLOW_TOOL_LABEL = "Workflow";
171
173
  export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
172
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
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."
173
175
  function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
174
176
  if (action === "resume") {
175
177
  if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
@@ -195,7 +197,7 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
195
197
  budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
196
198
  parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
197
199
  });
198
- 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" })) });
199
201
 
200
202
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
201
203
  export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
@@ -482,7 +484,7 @@ function styleForState(map: Record<string, ProgressStyleKey>, state: string, sty
482
484
  function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
483
485
  function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
484
486
  function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
485
- export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
487
+ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
486
488
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
487
489
  const workflowIcon = runStateGlyph(run.state, spinner);
488
490
  const iconStyle = workflowIconStyle(run.state, styles);
@@ -490,11 +492,13 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
490
492
  const lines = [`${iconStyle(workflowIcon)} ${header}`];
491
493
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
492
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)`)}`);
493
497
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
494
498
  const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
495
499
  const icon = agentStateGlyph(agent.state, spinner);
496
500
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
497
- 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);
498
502
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
499
503
  const state = progressStyleForState(agent.state, styles);
500
504
  return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
@@ -560,7 +564,7 @@ function controlState(state: string, theme: Theme): string {
560
564
  return theme.fg(color, state);
561
565
  }
562
566
  function controlAction(action: string, theme: Theme): string {
563
- const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
567
+ const color = /approved|completed|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
564
568
  return theme.fg(color, action);
565
569
  }
566
570
  function budgetPatchEntries(value: unknown): string[] {
@@ -611,13 +615,14 @@ function workflowControlResult(name: string, args: Record<string, unknown>, resu
611
615
  if (name === "workflow_retry") {
612
616
  const childRunId = controlString(value.runId) ?? "(unknown)";
613
617
  const state = controlString(value.state) ?? "unknown";
614
- if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
615
- return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
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");
616
621
  }
617
622
  if (name === "workflow_resume") {
618
623
  const state = controlString(value.state) ?? "unknown";
619
624
  const proposalId = controlString(value.proposalId);
620
- const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
625
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : state === "completed" ? "completed" : "no change";
621
626
  if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
622
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");
623
628
  }
@@ -809,18 +814,42 @@ function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord
809
814
  return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
810
815
  }
811
816
 
812
- 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 {
813
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 ?? "";
814
- 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();
815
840
  }
816
841
 
817
842
  function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
818
843
  const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
819
- return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
844
+ return `${formatAccountingValue(total)} tok`;
820
845
  }
821
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
+ }
822
851
 
823
- 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 {
824
853
  void worktrees;
825
854
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
826
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 });
@@ -843,7 +872,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
843
872
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
844
873
  if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
845
874
  }
846
- 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) : "";
847
876
  if (activity) result.push(`${indent} ${activity}`);
848
877
  return result.join("\n");
849
878
  };
@@ -852,7 +881,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
852
881
  return lines.join("\n");
853
882
  }
854
883
 
855
- 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 {
856
885
  const { run, snapshot } = loaded;
857
886
  const lines = [
858
887
  `Workflow: ${run.workflowName}`,
@@ -881,6 +910,8 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
881
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}`];
882
911
  for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
883
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}`);
884
915
  return result.join("\n");
885
916
  }));
886
917
  lines.push("Checkpoints:");
@@ -890,7 +921,7 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
890
921
  lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
891
922
  return lines.join("\n");
892
923
  }
893
- 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[] {
894
925
  const safeWidth = Math.max(1, width);
895
926
  const model = buildWorkflowPhaseModel(run, snapshot);
896
927
  const tree = buildWorkflowPhaseTree(model);
@@ -921,7 +952,8 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
921
952
  const treeLine = (node: WorkflowPhaseTreeNode): string => {
922
953
  const selected = node.id === selectedNode?.id;
923
954
  const state = progressStyleForState(node.state, styles);
924
- return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
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}` : ""}`;
925
957
  };
926
958
  const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
927
959
  if (!node) return [styles.muted("No workflow node is selected")];
@@ -938,10 +970,12 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
938
970
  const agent = node.agent;
939
971
  if (!agent) return [styles.muted("Agent details are unavailable")];
940
972
  const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
941
- const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
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")])];
942
974
  const error = agent.attemptDetails?.at(-1)?.error;
943
975
  if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
944
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)}`));
945
979
  return result;
946
980
  };
947
981
  const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
@@ -1127,16 +1161,35 @@ export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiag
1127
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}`] : [];
1128
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");
1129
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
+ }
1130
1182
 
1131
1183
  function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
1132
1184
  function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
1133
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);
1134
1186
  }
1135
1187
  function deliver(pi: ExtensionAPI, content: string): void {
1188
+ if (typeof pi.sendMessage !== "function") return;
1136
1189
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
1137
1190
  }
1138
1191
  function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
1139
- deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
1192
+ deliver(pi, formatWorkflowFailureDelivery(diagnostic));
1140
1193
  }
1141
1194
 
1142
1195
  type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -1456,6 +1509,7 @@ function tuiRows(tui: unknown): number {
1456
1509
  const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1457
1510
  return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1458
1511
  }
1512
+ const WORKFLOW_PANEL_FOOTER_ROWS = 2;
1459
1513
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1460
1514
  const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1461
1515
  const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
@@ -1474,8 +1528,17 @@ function keybindingKeys(keybindings: unknown, name: string): readonly string[] |
1474
1528
  const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
1475
1529
  return getKeys ? getKeys.call(keybindings, name) : undefined;
1476
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("/");
1539
+ }
1477
1540
 
1478
- 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[] = []) {
1479
1542
  beginWorkflowExtensionLoading();
1480
1543
  const registry = loadingRegistry();
1481
1544
  const extensionAgentDir = agentDir ?? getAgentDir();
@@ -1496,7 +1559,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1496
1559
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1497
1560
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1498
1561
  });
1499
- type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
1562
+ type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun };
1500
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 }>();
1501
1564
  let providerRecoveryQueue = Promise.resolve();
1502
1565
  const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
@@ -1520,14 +1583,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1520
1583
  });
1521
1584
  };
1522
1585
  const pendingFailureDiagnostics = new Map<string, WorkflowFailureDiagnostics>();
1523
- pi.on("tool_result", (event) => {
1524
- if (event.toolName !== "workflow" || !event.isError) return;
1525
- const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1526
- if (!diagnostic) return;
1527
- pendingFailureDiagnostics.delete(event.toolCallId);
1528
- return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1529
- });
1586
+ const foregroundDeliveries = new Map<string, { store: RunStore; inline: boolean; timer?: ReturnType<typeof setTimeout> }>();
1530
1587
  const liveActivities = new Map<string, Map<string, AgentActivity>>();
1588
+ const liveEventTimes = new Map<string, Map<string, number>>();
1531
1589
  const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
1532
1590
  const activities = liveActivities.get(runId);
1533
1591
  if (activity) {
@@ -1538,12 +1596,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1538
1596
  if (activities?.size === 0) liveActivities.delete(runId);
1539
1597
  }
1540
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
+ };
1541
1605
  const withLiveActivities = (run: PersistedRun): PersistedRun => {
1542
1606
  const activities = liveActivities.get(run.id);
1543
- return activities?.size ? { ...run, agents: run.agents.map((agent) => {
1544
- const activity = activities.get(agent.id);
1545
- return activity ? { ...agent, activity } : agent;
1546
- }) } : 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
+ }) };
1547
1615
  };
1548
1616
  const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
1549
1617
  let sessionLease: SessionLease | undefined;
@@ -1565,6 +1633,42 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1565
1633
  await eventPublisher.budget(store, metadata, persisted);
1566
1634
  return persisted;
1567
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
+ };
1568
1672
  const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
1569
1673
  let cursor = 0;
1570
1674
  return async (phase: string): Promise<void> => {
@@ -1605,17 +1709,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1605
1709
  const path = shellIdentityPath(identity);
1606
1710
  const replayed = await store.replay(path);
1607
1711
  if (replayed) return readShellResult(replayed.value);
1608
- const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1609
- const result = await executeShellCommand(command, options, signal, cwd);
1610
- await store.complete(path, result as unknown as JsonValue);
1611
- 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
+ }
1612
1729
  } finally { await lifecycle.leave(); }
1613
1730
  };
1614
1731
  const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
1615
1732
  if (next !== "pausing") budget.transition(next);
1616
1733
  const persisted = await persistRunState(store, metadata, (current) => {
1617
1734
  const nextRun = { ...current, state: next, ...budget.snapshot() };
1618
- if (next === "running" || next === "completed") delete nextRun.error;
1735
+ if (next === "running" || next === "completed") { delete nextRun.error; delete nextRun.failedAt; }
1619
1736
  return nextRun;
1620
1737
  });
1621
1738
  await eventPublisher.runState(store, metadata, previous, next, reason);
@@ -1629,25 +1746,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1629
1746
  const onProgress = async (progress: AgentProgress) => {
1630
1747
  let runState: PersistedRun;
1631
1748
  if (progress.persist) {
1632
- 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);
1633
1750
  } else {
1634
1751
  const loaded = await run.store.load();
1635
1752
  if (!loaded.run.agents.some((agent) => agent.id === id)) return;
1636
- 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) };
1637
1754
  }
1638
1755
  if (!runState.agents.some((agent) => agent.id === id)) return;
1639
1756
  setLiveActivity(runId, id, progress.activity);
1757
+ setLiveEventTime(runId, id, progress.lastEventAt);
1640
1758
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1641
1759
  };
1642
1760
  const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
1643
1761
  await scheduler.flush();
1644
1762
  scheduler.attemptStarted(id);
1763
+ const lastEventAt = Date.now();
1764
+ setLiveEventTime(runId, id, lastEventAt);
1645
1765
  await scheduler.flush();
1646
1766
  const before = (await run.store.load()).run;
1647
1767
  await persistActiveAgentAttempt(run.store, id, attempt);
1648
1768
  const active = (await run.store.load()).run;
1649
1769
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
1650
- 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) }));
1651
1771
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1652
1772
  };
1653
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); });
@@ -1687,7 +1807,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1687
1807
  try { effective = run.executor.resolve(requested); }
1688
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 }; }
1689
1809
  const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
1690
- return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(resultPath ? { resultPath } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
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 }) };
1691
1812
  });
1692
1813
  return { ...current, agents };
1693
1814
  });
@@ -1704,6 +1825,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1704
1825
  terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
1705
1826
  run.checkpointResolvers.clear();
1706
1827
  liveActivities.delete(runId);
1828
+ liveEventTimes.delete(runId);
1707
1829
  eventPublisher.removeRun(runId);
1708
1830
  runs.delete(runId);
1709
1831
  };
@@ -1739,13 +1861,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1739
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) });
1740
1862
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1741
1863
  };
1742
- 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> => {
1743
1865
  const run = runs.get(runId);
1744
1866
  if (!run) return undefined;
1745
1867
  const request = await run.store.answerWorkflowDecision(proposalId, approved);
1746
1868
  if (!request) return undefined;
1747
1869
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1748
- const result = await applyBudgetDecision(request, approved, context, signal);
1870
+ const result = await applyBudgetDecision(request, approved, context, signal, waitForCompletion);
1749
1871
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1750
1872
  return result;
1751
1873
  };
@@ -1856,7 +1978,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1856
1978
  });
1857
1979
  catalogRegistered = true;
1858
1980
  };
1859
- const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
1981
+ const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), agentSetupHooks: registry.agentSetupHooks() }, createSession);
1860
1982
  const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
1861
1983
  ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
1862
1984
  : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
@@ -1896,7 +2018,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1896
2018
  const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1897
2019
  return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
1898
2020
  };
1899
- const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
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) => {
1900
2022
  await lifecycle.enter();
1901
2023
  try {
1902
2024
  const path = agentIdentityPath(identity);
@@ -1911,6 +2033,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1911
2033
  const thinking = parseThinking(options.thinking);
1912
2034
  const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1913
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);
1914
2037
  const label = displayAgentName(requestedLabel, role, resolved.model);
1915
2038
  const tools = resolved.tools;
1916
2039
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
@@ -1935,8 +2058,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1935
2058
  const drift = aliasDrift(previousAliases, currentAliases);
1936
2059
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1937
2060
  };
1938
- const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }) => {
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> => {
1939
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
+ }
1940
2077
  await run.store.validateRetrySource();
1941
2078
  await run.store.validateBorrowedWorktrees();
1942
2079
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
@@ -1950,7 +2087,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1950
2087
  run.abortController = controller;
1951
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 });
1952
2089
  if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1953
- await run.store.saveSnapshot(snapshot);
2090
+ const persistedSnapshot = modeOverride === undefined ? snapshot : createLaunchSnapshot({ ...snapshot, launchMode: foreground ? "foreground" : "background" });
2091
+ await run.store.saveSnapshot(persistedSnapshot);
1954
2092
  scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1955
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) });
1956
2094
  const drift = aliasDrift(previousAliases, currentAliases);
@@ -1961,13 +2099,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1961
2099
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1962
2100
  catch (error) {
1963
2101
  const typed = asWorkflowError(error);
1964
- if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
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); }
1965
2103
  await cleanupTerminalRun(run.store.runId);
1966
2104
  throw typed;
1967
2105
  }
1968
2106
  await scheduler.cancelRun(run.store.runId);
1969
2107
  await run.lifecycle.resume();
1970
- const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
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);
1971
2109
  run.execution = execution;
1972
2110
  const completion = execution.result.then(async (value) => {
1973
2111
  await scheduler.flush();
@@ -1980,17 +2118,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1980
2118
  await scheduler.flush();
1981
2119
  const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
1982
2120
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
1983
- 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));
1984
2122
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
1985
2123
  if (state === "failed") retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
1986
2124
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
1987
2125
  run.update?.(workflowToolUpdate(persisted));
1988
- 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;
1989
2128
  }).finally(() => cleanupTerminalRun(run.store.runId));
1990
2129
  run.completion = completion;
1991
- 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;
1992
2141
  };
1993
- 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> => {
1994
2143
  const run = runs.get(request.runId);
1995
2144
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
1996
2145
  if (!approved) return { state: "budget_exhausted", approved: false };
@@ -1999,10 +2148,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1999
2148
  const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
2000
2149
  run.budget = runtime;
2001
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; });
2002
- 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 };
2003
2154
  return { state: "running", approved: true };
2004
2155
  };
2005
- 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>> => {
2006
2157
  const run = runs.get(runId);
2007
2158
  if (!run) {
2008
2159
  const host = object(context) ? context : {};
@@ -2041,11 +2192,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2041
2192
  run.budget = runtime;
2042
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; });
2043
2194
  }
2044
- 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 };
2045
2198
  return { state: "running" };
2046
2199
  };
2047
2200
  const retryReservations = new Set<string>();
2048
- 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 }> => {
2049
2202
  if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
2050
2203
  const host = object(context) ? context : {};
2051
2204
  const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
@@ -2109,12 +2262,17 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2109
2262
  runs.set(childRunId, childRun);
2110
2263
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
2111
2264
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
2112
- 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);
2113
2267
  const completion = runs.get(childRunId)?.completion;
2114
2268
  if (completion) {
2115
2269
  childStarted = true;
2116
2270
  void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
2271
+ } else if (completed) {
2272
+ childStarted = true;
2273
+ retryReservations.delete(lineageRootRunId);
2117
2274
  }
2275
+ if (completed) return { runId: childRunId, parentRunId: loaded.run.id, state: "completed", value: completed.value, run: (await childStore.load()).run };
2118
2276
  return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
2119
2277
  } finally {
2120
2278
  if (!childStarted) retryReservations.delete(lineageRootRunId);
@@ -2126,7 +2284,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2126
2284
  description: "Retry a failed workflow run by replaying its completed structural operations",
2127
2285
  parameters: WORKFLOW_RETRY_PARAMETERS,
2128
2286
  async execute(_id, params, signal, _onUpdate, ctx) {
2129
- try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2287
+ try { const result = await retryWorkflowRun(params.runId, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2130
2288
  catch (error) { throw mainAgentError(error); }
2131
2289
  },
2132
2290
  renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
@@ -2136,9 +2294,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2136
2294
  name: "workflow_resume",
2137
2295
  label: "Workflow Resume",
2138
2296
  description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
2139
- 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 }),
2140
2298
  async execute(_id, params, signal, _onUpdate, ctx) {
2141
- try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
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 }; }
2142
2300
  catch (error) { throw mainAgentError(error); }
2143
2301
  },
2144
2302
  renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
@@ -2159,10 +2317,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2159
2317
  if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
2160
2318
  if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
2161
2319
  const previousState = loaded.run.state;
2162
- 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
+ });
2163
2326
  loaded = { ...loaded, run: (await store.load()).run };
2164
2327
  await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
2165
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 };
2166
2337
  }
2167
2338
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
2168
2339
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
@@ -2188,7 +2359,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2188
2359
  if (choice && choice !== "Skip") {
2189
2360
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
2190
2361
  for (const run of toResume) {
2191
- 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"); }
2192
2363
  catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
2193
2364
  }
2194
2365
  }
@@ -2245,10 +2416,24 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2245
2416
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2246
2417
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
2247
2418
  const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
2248
- const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
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;
2249
2433
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
2250
2434
  const initialBudget = budgetRuntime.snapshot();
2251
- await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
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 });
2252
2437
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2253
2438
  const background = !params.foreground;
2254
2439
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
@@ -2257,7 +2442,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2257
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 } : {}) });
2258
2443
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
2259
2444
  scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
2260
- const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
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);
2261
2446
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
2262
2447
  await eventPublisher.runStarted(store, checked.metadata);
2263
2448
  const finish = execution.result.then(async (value) => {
@@ -2271,7 +2456,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2271
2456
  await scheduler.flush();
2272
2457
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2273
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);
2274
- 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));
2275
2460
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2276
2461
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
2277
2462
  const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
@@ -2281,16 +2466,37 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2281
2466
  });
2282
2467
  const completion = finish.finally(() => cleanupTerminalRun(runId));
2283
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
+ };
2284
2487
  if (background) {
2285
2488
  void completion.then(async ({ value, resultPath }) => {
2286
- deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2287
- }, (error: unknown) => {
2288
- const diagnostic = failureDiagnosticsFrom(error);
2289
- if (diagnostic) deliverFailure(pi, diagnostic);
2290
- 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));
2291
2492
  });
2292
2493
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2293
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
+ });
2294
2500
  const { value } = await completion;
2295
2501
  const run = (await store.load()).run;
2296
2502
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
@@ -2332,7 +2538,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2332
2538
  const loadStores = async () => {
2333
2539
  const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
2334
2540
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2335
- try { return { store, loaded: await store.load() }; }
2541
+ try { const loaded = await store.load(); return { store, loaded: { ...loaded, run: withLiveActivities(loaded.run) } }; }
2336
2542
  catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
2337
2543
  }));
2338
2544
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
@@ -2355,7 +2561,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2355
2561
  return keepContext ? "dashboard" : "done";
2356
2562
  }
2357
2563
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2358
- 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);
2359
2565
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2360
2566
  return keepContext ? "dashboard" : "done";
2361
2567
  }
@@ -2368,10 +2574,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2368
2574
  if (action === "resume" && run) {
2369
2575
  if (run.lifecycle.state === "budget_exhausted") {
2370
2576
  const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
2371
- const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
2372
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
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");
2373
2579
  } else {
2374
- 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);
2375
2581
  else {
2376
2582
  if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
2377
2583
  await run.lifecycle.resume();
@@ -2383,8 +2589,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2383
2589
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2384
2590
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2385
2591
  if (input === undefined) return keepContext ? "dashboard" : "done";
2386
- const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
2387
- ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
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");
2388
2594
  return keepContext ? "dashboard" : "done";
2389
2595
  }
2390
2596
  if (action === "stop" && run) {
@@ -2528,11 +2734,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2528
2734
  };
2529
2735
  const loadDashboard = async () => {
2530
2736
  const loaded = await store.load();
2737
+ const liveRun = withLiveActivities(loaded.run);
2531
2738
  const checkpoints = await store.awaitingCheckpoints();
2532
2739
  const worktrees = await store.worktrees();
2533
2740
  const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2534
2741
  const agentResults = new Map<string, JsonValue>();
2535
- for (const agent of loaded.run.agents) {
2742
+ for (const agent of liveRun.agents) {
2536
2743
  if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
2537
2744
  const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2538
2745
  if (operation) agentResults.set(agent.id, operation.value);
@@ -2542,15 +2749,15 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2542
2749
  const reviews = new Map<string, AwaitingCheckpoint>();
2543
2750
  const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
2544
2751
  const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
2545
- if (loaded.run.state === "running") add("Pause", "pause");
2546
- if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
2547
- 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}`); }
2548
2755
  for (const decision of await store.pendingWorkflowDecisions()) {
2549
2756
  const id = decision.proposalId.slice(0, 8);
2550
2757
  actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
2551
2758
  actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
2552
2759
  }
2553
- if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
2760
+ if (!terminalStates.has(liveRun.state)) add("Stop", "stop");
2554
2761
  for (const cp of checkpoints) {
2555
2762
  if (ctx.mode === "tui") {
2556
2763
  const label = `Review ${cp.name}`;
@@ -2563,13 +2770,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2563
2770
  }
2564
2771
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
2565
2772
  else actions.set("Open script in editor", "open-script");
2566
- if (ctx.mode !== "tui" && loaded.run.agents.length) actions.set("Agents...", "agents");
2567
- if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
2773
+ if (ctx.mode !== "tui" && liveRun.agents.length) actions.set("Agents...", "agents");
2774
+ if (terminalStates.has(liveRun.state)) add("Delete", "delete");
2568
2775
  if (ctx.mode === "tui") {
2569
2776
  addCopy("Copy run path", store.directory, "run path");
2570
2777
  addCopy("Copy run ID", store.runId, "run ID");
2571
2778
  }
2572
- return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
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 };
2573
2780
  };
2574
2781
  const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
2575
2782
  const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
@@ -2641,12 +2848,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2641
2848
  let tree = buildWorkflowPhaseTree(view.phaseModel);
2642
2849
  let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
2643
2850
  let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2644
- const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2851
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
2645
2852
  const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
2646
- const keyLabel = (binding: string, fallback: string) => {
2647
- const keys = keybindingKeys(keybindings, binding);
2648
- return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
2649
- };
2853
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
2650
2854
  const selectedAgentRecord = (): AgentRecord | undefined => {
2651
2855
  const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2652
2856
  return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
@@ -2671,7 +2875,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2671
2875
  ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2672
2876
  } finally {
2673
2877
  editorRunning = false;
2674
- tui.requestRender(true);
2675
2878
  }
2676
2879
  };
2677
2880
  const updateDashboard = async () => {
@@ -2718,7 +2921,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2718
2921
  void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2719
2922
  }, 1000);
2720
2923
  timer.unref();
2721
- return borderWorkflowOverlay({
2924
+ return {
2722
2925
  render(width: number) {
2723
2926
  renderedWidth = width;
2724
2927
  const narrow = width < 80;
@@ -2749,7 +2952,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2749
2952
  selectionNeedsScroll = false;
2750
2953
  }
2751
2954
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
2752
- const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "close"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
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] ?? "";
2753
2956
  return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2754
2957
  },
2755
2958
  invalidate() {},
@@ -2759,12 +2962,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2759
2962
  if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
2760
2963
  if (actionMode) {
2761
2964
  const options = actionOptions();
2762
- if (keybindings.matches(data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
2763
- if (keybindings.matches(data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
2764
- else if (keybindings.matches(data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
2765
- else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2766
- else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2767
- else if (keybindings.matches(data, "tui.select.confirm")) {
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")) {
2768
2971
  const action = options[actionIndex];
2769
2972
  const agent = selectedAgentRecord();
2770
2973
  if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
@@ -2787,30 +2990,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2787
2990
  return;
2788
2991
  }
2789
2992
  const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2790
- if (keybindings.matches(data, "tui.select.cancel")) {
2791
- if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done(undefined);
2993
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
2994
+ if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done("Back");
2792
2995
  } else if (narrow && detailsMode) {
2793
- if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2794
- else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2795
- else if (keybindings.matches(data, "tui.select.confirm")) {
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")) {
2796
2999
  if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2797
3000
  else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2798
3001
  }
2799
- } else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
3002
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) {
2800
3003
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
2801
3004
  selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2802
- } else if (keybindings.matches(data, "tui.editor.cursorRight")) {
3005
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorRight")) {
2803
3006
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
2804
3007
  selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2805
- } else if (keybindings.matches(data, "tui.select.up")) {
3008
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.up")) {
2806
3009
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
2807
3010
  selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2808
- } else if (keybindings.matches(data, "tui.select.down")) {
3011
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.down")) {
2809
3012
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
2810
3013
  selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2811
- } else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
2812
- else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
2813
- else if (keybindings.matches(data, "tui.select.confirm")) {
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")) {
2814
3017
  if (narrow) detailsMode = true;
2815
3018
  else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2816
3019
  else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
@@ -2818,10 +3021,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2818
3021
  tui.requestRender();
2819
3022
  },
2820
3023
  dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
2821
- }, theme);
2822
- }, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
2823
- : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
2824
- 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; }
2825
3028
  if (actionChoice === "Agents...") { await selectAgent(view); continue; }
2826
3029
  if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
2827
3030
  if (actionChoice.startsWith("__workflow_fork__:")) {
@@ -2861,7 +3064,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2861
3064
  const currentLayout = layout();
2862
3065
  const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
2863
3066
  offset = Math.min(offset, maxOffset);
2864
- 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] ?? "";
2865
3070
  const controls = currentLayout.compactControls
2866
3071
  ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
2867
3072
  : options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
@@ -2875,12 +3080,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2875
3080
  },
2876
3081
  invalidate() {},
2877
3082
  handleInput(data: string) {
2878
- if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
2879
- else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
2880
- else if (keybindings.matches(data, "tui.select.pageUp")) move(-layout().contentViewport);
2881
- else if (keybindings.matches(data, "tui.select.pageDown")) move(layout().contentViewport);
2882
- else if (keybindings.matches(data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
2883
- 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);
2884
3089
  tui.requestRender();
2885
3090
  },
2886
3091
  }, theme);