pi-extensible-workflows 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -31
  2. package/dist/src/agent-execution.d.ts +67 -9
  3. package/dist/src/agent-execution.js +385 -141
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +156 -22
  8. package/dist/src/doctor-cleanup.js +68 -31
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.d.ts +1 -1
  11. package/dist/src/execution.js +29 -13
  12. package/dist/src/host.d.ts +12 -9
  13. package/dist/src/host.js +525 -195
  14. package/dist/src/index.d.ts +5 -4
  15. package/dist/src/index.js +3 -2
  16. package/dist/src/persistence.d.ts +43 -8
  17. package/dist/src/persistence.js +54 -0
  18. package/dist/src/registry.d.ts +3 -2
  19. package/dist/src/registry.js +16 -4
  20. package/dist/src/session-inspector.d.ts +12 -2
  21. package/dist/src/session-inspector.js +50 -24
  22. package/dist/src/types.d.ts +141 -60
  23. package/dist/src/types.js +1 -0
  24. package/dist/src/validation.js +28 -7
  25. package/dist/src/workflow-artifacts.d.ts +1 -0
  26. package/dist/src/workflow-artifacts.js +1 -0
  27. package/dist/src/workflow-evals.d.ts +7 -1
  28. package/dist/src/workflow-evals.js +23 -3
  29. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  30. package/evals/cases/recovery-failed-run.yaml +14 -0
  31. package/package.json +1 -1
  32. package/skills/pi-extensible-workflows/SKILL.md +13 -5
  33. package/src/agent-execution.ts +302 -107
  34. package/src/bundles.ts +471 -0
  35. package/src/cli.ts +130 -22
  36. package/src/doctor-cleanup.ts +38 -4
  37. package/src/eval-capture-extension.ts +15 -1
  38. package/src/execution.ts +27 -15
  39. package/src/host.ts +454 -175
  40. package/src/index.ts +5 -4
  41. package/src/persistence.ts +58 -4
  42. package/src/registry.ts +14 -5
  43. package/src/session-inspector.ts +49 -26
  44. package/src/types.ts +55 -30
  45. package/src/validation.ts +19 -6
  46. package/src/workflow-artifacts.ts +1 -0
  47. package/src/workflow-evals.ts +24 -3
package/src/host.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { Type } from "@earendil-works/pi-ai";
8
8
  import { Value } from "typebox/value";
9
9
  import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
10
- import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
10
+ import { FairAgentScheduler, WorkflowAgentExecutor, localAgentTransport, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery } from "./agent-execution.js";
11
11
  import { herdrPaneId, openHerdrPane } from "./herdr.js";
12
12
  import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
13
13
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
@@ -16,8 +16,8 @@ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCod
16
16
  import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
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
- import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
20
- import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCatalogFunction, type WorkflowCatalogIndex, type WorkflowCatalogVariable, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
19
+ import { openWorkflowArtifact, workflowPromptArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
20
+ import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STALL_THRESHOLD_MS, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentAttemptActionContext, type AgentAttemptSummary, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type AgentTransport, 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;
@@ -163,13 +165,13 @@ export class RunLifecycle {
163
165
  export function formatWorkflowPreview(args: { script?: unknown; workflow?: unknown; name?: unknown; description?: unknown }): string {
164
166
  const explicitName = typeof args.name === "string" && args.name.trim() ? args.name.trim() : undefined;
165
167
  const registeredName = typeof args.workflow === "string" && args.workflow.trim() ? args.workflow.trim() : undefined;
166
- const name = registeredName ?? explicitName ?? "workflow";
167
- if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${registeredName ? "\nRegistered function" : ""}`;
168
+ const name = explicitName ?? registeredName ?? "workflow";
169
+ if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${registeredName ? `\nRegistered function${explicitName ? `: ${registeredName}` : ""}` : ""}`;
168
170
  return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
169
171
  }
170
172
  export const WORKFLOW_TOOL_LABEL = "Workflow";
171
- export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
172
- export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
173
+ export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline or file-backed parallel-to-summary path by default"
174
+ export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline and file-backed launches require a non-empty name; registered function launches may use name as an optional run label and otherwise 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 })";
@@ -185,17 +187,18 @@ function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState):
185
187
  return `Only failed workflow runs can be retried; source is ${state}`;
186
188
  }
187
189
  export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
188
- name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
190
+ name: Type.Optional(Type.String({ description: "Optional run label; required and non-empty for inline or file-backed launches, defaults to the registered function name when omitted" })),
189
191
  description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
190
192
  script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
191
- workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
193
+ scriptPath: Type.Optional(Type.String({ description: "Path to a JavaScript workflow file, read once at launch and persisted as the inline source" })),
194
+ workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name; name may optionally label the run" })),
192
195
  args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
193
196
  foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
194
197
  concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
195
198
  budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
196
199
  parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
197
200
  });
198
- export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
201
+ 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
202
 
200
203
  type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
201
204
  export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
@@ -482,19 +485,33 @@ function styleForState(map: Record<string, ProgressStyleKey>, state: string, sty
482
485
  function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
483
486
  function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
484
487
  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 {
488
+ function formatWorkflowRuntime(durationMs: number): string {
489
+ const seconds = Math.max(0, Math.floor(durationMs / 1000));
490
+ if (seconds < 60) return `${String(seconds)}s`;
491
+ const minutes = Math.floor(seconds / 60);
492
+ const remainingSeconds = seconds % 60;
493
+ if (minutes < 60) return `${String(minutes)}m${remainingSeconds ? ` ${String(remainingSeconds)}s` : ""}`;
494
+ const hours = Math.floor(minutes / 60);
495
+ const remainingMinutes = minutes % 60;
496
+ return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
497
+ }
498
+ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
486
499
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
487
500
  const workflowIcon = runStateGlyph(run.state, spinner);
488
501
  const iconStyle = workflowIconStyle(run.state, styles);
489
502
  const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
490
- const lines = [`${iconStyle(workflowIcon)} ${header}`];
503
+ const state = progressStyleForState(run.state, styles)(`[${run.state}]`);
504
+ const runtime = run.usage ? ` runtime=${formatWorkflowRuntime(run.usage.durationMs)}` : "";
505
+ const lines = [`${iconStyle(workflowIcon)} ${header} ${state}${runtime}`];
491
506
  const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
492
507
  lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
508
+ const activeShells = run.activeShells ?? 0;
509
+ if (activeShells > 0) lines.push(` ${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(activeShells)} active)`)}`);
493
510
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
494
511
  const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
495
512
  const icon = agentStateGlyph(agent.state, spinner);
496
513
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
497
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
514
+ const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
498
515
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
499
516
  const state = progressStyleForState(agent.state, styles);
500
517
  return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
@@ -518,6 +535,7 @@ function workflowToolUpdate(run: PersistedRun): WorkflowToolUpdate {
518
535
  }
519
536
 
520
537
  const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
538
+ const WORKFLOW_PROGRESS_REFRESH_MS = 1_000;
521
539
 
522
540
  function textBlock(text: string) {
523
541
  return {
@@ -560,7 +578,7 @@ function controlState(state: string, theme: Theme): string {
560
578
  return theme.fg(color, state);
561
579
  }
562
580
  function controlAction(action: string, theme: Theme): string {
563
- const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
581
+ const color = /approved|completed|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
564
582
  return theme.fg(color, action);
565
583
  }
566
584
  function budgetPatchEntries(value: unknown): string[] {
@@ -611,13 +629,14 @@ function workflowControlResult(name: string, args: Record<string, unknown>, resu
611
629
  if (name === "workflow_retry") {
612
630
  const childRunId = controlString(value.runId) ?? "(unknown)";
613
631
  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");
632
+ const action = state === "completed" ? "completed" : "started";
633
+ if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`].join("\n");
634
+ 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
635
  }
617
636
  if (name === "workflow_resume") {
618
637
  const state = controlString(value.state) ?? "unknown";
619
638
  const proposalId = controlString(value.proposalId);
620
- const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
639
+ const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : state === "completed" ? "completed" : "no change";
621
640
  if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
622
641
  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
642
  }
@@ -733,14 +752,38 @@ function themeWorkflowProgressStyles(theme: Theme): WorkflowProgressStyles {
733
752
  bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
734
753
  };
735
754
  }
736
- function workflowProgressBlock(run: PersistedRun, theme: Theme) {
755
+ type WorkflowProgressRefreshState = { runId: string; inputRun: PersistedRun; run: PersistedRun; lastRefreshAt: number; runtimeStartedAt: number; runtimeBaseMs: number; refresh?: Promise<void> };
756
+ function workflowProgressBlock(run: PersistedRun, theme: Theme, progress?: WorkflowProgressRefreshState, refresh?: () => Promise<PersistedRun | undefined>, invalidate?: () => void) {
737
757
  const styles = themeWorkflowProgressStyles(theme);
758
+ const currentRun = () => {
759
+ const displayed = progress?.run ?? run;
760
+ if (!progress || displayed.state !== "running") return displayed;
761
+ const durationMs = Math.max(displayed.usage?.durationMs ?? 0, progress.runtimeBaseMs + Date.now() - progress.runtimeStartedAt);
762
+ return { ...displayed, usage: { ...budgetUsage(displayed.usage), durationMs } };
763
+ };
738
764
  return {
739
765
  render(width: number) {
740
766
  const frame = workflowSpinner[Math.floor(Date.now() / 80) % workflowSpinner.length] ?? "◇";
741
- return truncateWorkflowProgress(formatWorkflowProgress(run, frame, styles), width);
767
+ return truncateWorkflowProgress(formatWorkflowProgress(currentRun(), frame, styles), width);
768
+ },
769
+ invalidate() {
770
+ const displayed = currentRun();
771
+ if (!progress || !refresh || displayed.state !== "running" || !displayed.agents.some((agent) => agent.state === "running")) return;
772
+ const now = Date.now();
773
+ if (progress.refresh || now - progress.lastRefreshAt < WORKFLOW_PROGRESS_REFRESH_MS) return;
774
+ progress.lastRefreshAt = now;
775
+ const inputRun = progress.inputRun;
776
+ const pending = refresh().then((next) => {
777
+ if (next && progress.inputRun === inputRun) {
778
+ progress.run = next;
779
+ invalidate?.();
780
+ }
781
+ }).catch(() => undefined);
782
+ progress.refresh = pending;
783
+ void pending.finally(() => {
784
+ if (progress.refresh === pending) delete progress.refresh;
785
+ });
742
786
  },
743
- invalidate() {},
744
787
  };
745
788
  }
746
789
  export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
@@ -809,18 +852,42 @@ function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord
809
852
  return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
810
853
  }
811
854
 
812
- function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
855
+ export function formatStalledDuration(durationMs: number): string {
856
+ const minutes = Math.max(0, Math.floor(durationMs / 60_000));
857
+ if (minutes < 60) return `${String(minutes)}m`;
858
+ const hours = Math.floor(minutes / 60);
859
+ const remainingMinutes = minutes % 60;
860
+ return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
861
+ }
862
+ function stalledDuration(agent: AgentRecord, now: number): number | undefined {
863
+ if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt)) return undefined;
864
+ const duration = now - agent.lastEventAt;
865
+ return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
866
+ }
867
+ function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
813
868
  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)}` : "";
869
+ const activity = label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
870
+ const stalled = stalledDuration(agent, now);
871
+ if (stalled === undefined) return activity;
872
+ const warning = `stalled? ${formatStalledDuration(stalled)}`;
873
+ return activity ? `${activity} ${styles.warning(`- ${warning}`)}` : styles.warning(warning);
874
+ }
875
+
876
+ function formatAccountingValue(value: number): string {
877
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value).toLowerCase();
815
878
  }
816
879
 
817
880
  function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
818
881
  const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
819
- return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
882
+ return `${formatAccountingValue(total)} tok`;
820
883
  }
821
884
 
885
+ function formatAgentAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string[] {
886
+ const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
887
+ 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)}`];
888
+ }
822
889
 
823
- export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
890
+ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
824
891
  void worktrees;
825
892
  const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
826
893
  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 +910,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
843
910
  const last = agent.attemptDetails[agent.attemptDetails.length - 1];
844
911
  if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
845
912
  }
846
- const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
913
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
847
914
  if (activity) result.push(`${indent} ${activity}`);
848
915
  return result.join("\n");
849
916
  };
@@ -852,7 +919,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
852
919
  return lines.join("\n");
853
920
  }
854
921
 
855
- export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
922
+ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
856
923
  const { run, snapshot } = loaded;
857
924
  const lines = [
858
925
  `Workflow: ${run.workflowName}`,
@@ -881,16 +948,18 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
881
948
  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
949
  for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
883
950
  for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
951
+ const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
952
+ if (activity) result.push(`${indent} ${activity}`);
884
953
  return result.join("\n");
885
954
  }));
886
955
  lines.push("Checkpoints:");
887
956
  if (!checkpoints.length) lines.push(" (none)");
888
957
  for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
889
958
  lines.push(`Worktrees: ${String(worktrees.length)}`);
890
- lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
959
+ lines.push(`Agent sessions: ${String(run.agentSessions.length)}`);
891
960
  return lines.join("\n");
892
961
  }
893
- export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
962
+ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string[] {
894
963
  const safeWidth = Math.max(1, width);
895
964
  const model = buildWorkflowPhaseModel(run, snapshot);
896
965
  const tree = buildWorkflowPhaseTree(model);
@@ -921,7 +990,8 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
921
990
  const treeLine = (node: WorkflowPhaseTreeNode): string => {
922
991
  const selected = node.id === selectedNode?.id;
923
992
  const state = progressStyleForState(node.state, styles);
924
- return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
993
+ const activity = node.agent && !SETTLED_AGENT_STATES.has(node.agent.state) ? formatAgentActivity(node.agent, "⠦", styles, now) : "";
994
+ return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}${activity ? ` ${activity}` : ""}`;
925
995
  };
926
996
  const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
927
997
  if (!node) return [styles.muted("No workflow node is selected")];
@@ -938,10 +1008,12 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
938
1008
  const agent = node.agent;
939
1009
  if (!agent) return [styles.muted("Agent details are unavailable")];
940
1010
  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")])];
1011
+ 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)"}`, ...(agent.systemPrompt === undefined ? [] : [`System prompt: ${agent.systemPrompt}`]), `Attempts: ${String(agent.attempts)}`, ...(agent.accounting ? formatAgentAccounting(agent.accounting) : []), ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
942
1012
  const error = agent.attemptDetails?.at(-1)?.error;
943
1013
  if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
944
1014
  if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
1015
+ const stalled = stalledDuration(agent, now);
1016
+ if (stalled !== undefined) result.push(styles.warning(`stalled? ${formatStalledDuration(stalled)}`));
945
1017
  return result;
946
1018
  };
947
1019
  const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
@@ -1018,8 +1090,8 @@ function boundedWorkflowFailureDiagnostics(value: WorkflowFailureDiagnostics): W
1018
1090
  ...(value.failedAgent.role ? { role: utf8Prefix(value.failedAgent.role, 128) } : {}),
1019
1091
  structuralPath: value.failedAgent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
1020
1092
  attempt: value.failedAgent.attempt,
1021
- ...(value.failedAgent.sessionId ? { sessionId: utf8Prefix(value.failedAgent.sessionId, 256) } : {}),
1022
- ...(value.failedAgent.sessionFile ? { sessionFile: utf8Prefix(value.failedAgent.sessionFile, 1024) } : {}),
1093
+ ...(value.failedAgent.transport ? { transport: utf8Prefix(value.failedAgent.transport, 128) } : {}),
1094
+ ...(value.failedAgent.session ? { session: structuredClone(value.failedAgent.session) } : {}),
1023
1095
  } } : {}),
1024
1096
  completedSiblingAgents: (value.completedSiblingAgents ?? []).slice(0, 16).map((agent) => ({
1025
1097
  id: utf8Prefix(agent.id, 128),
@@ -1045,8 +1117,6 @@ function boundedWorkflowFailureDiagnostics(value: WorkflowFailureDiagnostics): W
1045
1117
  bounded = { ...bounded, retry };
1046
1118
  continue;
1047
1119
  }
1048
- if (bounded.failedAgent?.sessionFile) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionFile; bounded = { ...bounded, failedAgent }; continue; }
1049
- if (bounded.failedAgent?.sessionId) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionId; bounded = { ...bounded, failedAgent }; continue; }
1050
1120
  if (Buffer.byteLength(bounded.artifacts.runDirectory) > 256) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, runDirectory: utf8Prefix(bounded.artifacts.runDirectory, 256) } }; continue; }
1051
1121
  if (Buffer.byteLength(bounded.error.message) > 256) { bounded = { ...bounded, error: { ...bounded.error, message: utf8Prefix(bounded.error.message, 256) } }; continue; }
1052
1122
  if (bounded.failedAt !== null && Buffer.byteLength(bounded.failedAt) > 256) { bounded = { ...bounded, failedAt: utf8Prefix(bounded.failedAt, 256) }; continue; }
@@ -1086,8 +1156,8 @@ async function createWorkflowFailureDiagnostics(store: RunStore, metadata: Workf
1086
1156
  ...(failedAgentRecord.role ? { role: failedAgentRecord.role } : {}),
1087
1157
  structuralPath: [...(failedAgentRecord.structuralPath ?? [])],
1088
1158
  attempt: Math.max(1, failedAttempt?.attempt ?? failedAgentRecord.attempts),
1089
- ...(failedAttempt?.sessionId ? { sessionId: failedAttempt.sessionId } : {}),
1090
- ...(failedAttempt?.sessionFile ? { sessionFile: failedAttempt.sessionFile } : {}),
1159
+ ...(failedAttempt?.transport ? { transport: failedAttempt.transport } : {}),
1160
+ ...(failedAttempt?.session ? { session: failedAttempt.session } : {}),
1091
1161
  } satisfies WorkflowFailureAgent : undefined;
1092
1162
  const completedSiblingAgents = run.agents.filter((agent) => {
1093
1163
  if (agent.state !== "completed" || agent.id === failedAgentRecord?.id) return false;
@@ -1121,22 +1191,41 @@ async function createWorkflowFailureDiagnostics(store: RunStore, metadata: Workf
1121
1191
  }
1122
1192
 
1123
1193
  export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string {
1124
- const failedAgent = diagnostic.failedAgent ? `${diagnostic.failedAgent.label ?? diagnostic.failedAgent.id}${diagnostic.failedAgent.role ? ` role=${diagnostic.failedAgent.role}` : ""} attempt=${String(diagnostic.failedAgent.attempt)} path=${diagnostic.failedAgent.structuralPath.join(" > ") || "(root)"}${diagnostic.failedAgent.sessionFile ? ` session=${diagnostic.failedAgent.sessionFile}` : ""}` : "(not persisted)";
1194
+ const failedAgent = diagnostic.failedAgent ? `${diagnostic.failedAgent.label ?? diagnostic.failedAgent.id}${diagnostic.failedAgent.role ? ` role=${diagnostic.failedAgent.role}` : ""} attempt=${String(diagnostic.failedAgent.attempt)} path=${diagnostic.failedAgent.structuralPath.join(" > ") || "(root)"}${diagnostic.failedAgent.session ? ` session=${diagnostic.failedAgent.session.transport}/${diagnostic.failedAgent.session.sessionId}` : ""}` : "(not persisted)";
1125
1195
  const siblingAgents = diagnostic.completedSiblingAgents;
1126
1196
  const siblings = siblingAgents ? siblingAgents.map((agent) => `${agent.label ?? agent.id}${agent.role ? ` role=${agent.role}` : ""} path=${agent.structuralPath.join(" > ") || "(root)"}`).join(", ") || "(none)" : diagnostic.completedSiblingPaths.map((path) => path.join(" > ") || "(root)").join(", ") || "(none)";
1127
1197
  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
1198
  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
1199
  }
1200
+ function deliveryPart(value: string, maxBytes: number): string { return utf8Prefix(value.replace(/\s+/g, " ").trim(), maxBytes) || "(unknown)"; }
1201
+ export function formatWorkflowFailureDelivery(diagnostic: WorkflowFailureDiagnostics): string {
1202
+ const name = deliveryPart(diagnostic.workflowName, 128);
1203
+ const runId = deliveryPart(diagnostic.runId, 128);
1204
+ const error = `${diagnostic.error.code}: ${deliveryPart(diagnostic.error.message, 768)}`;
1205
+ const failedPath = diagnostic.failedAt ? `; failed path=${deliveryPart(diagnostic.failedAt, 512)}` : "";
1206
+ const nextAction = diagnostic.retry ? `; next action: ${deliveryPart(diagnostic.retry.action, 256)}` : "";
1207
+ const artifacts = `; artifacts: runDirectory=${deliveryPart(diagnostic.artifacts.runDirectory, 512)} statePath=${deliveryPart(diagnostic.artifacts.statePath, 512)} journalPath=${deliveryPart(diagnostic.artifacts.journalPath, 512)}`;
1208
+ const line = `Workflow ${name} failed (runId=${runId}): error=${error}${failedPath}${nextAction}${artifacts}`;
1209
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1210
+ }
1211
+ function formatWorkflowFailureDeliveryFallback(workflowName: string, runId: string, runDirectory: string, error: unknown): string {
1212
+ const code = errorCode(error) ?? "INTERNAL_ERROR";
1213
+ const failedPath = workflowFailedAt(error);
1214
+ const nextAction = code === "BUDGET_EXHAUSTED" || code === "CANCELLED" ? "" : `; next action: workflow_retry({ runId: ${JSON.stringify(runId)} })`;
1215
+ 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)}`;
1216
+ return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
1217
+ }
1130
1218
 
1131
1219
  function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
1132
1220
  function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
1133
1221
  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
1222
  }
1135
1223
  function deliver(pi: ExtensionAPI, content: string): void {
1224
+ if (typeof pi.sendMessage !== "function") return;
1136
1225
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
1137
1226
  }
1138
1227
  function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
1139
- deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
1228
+ deliver(pi, formatWorkflowFailureDelivery(diagnostic));
1140
1229
  }
1141
1230
 
1142
1231
  type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -1456,6 +1545,7 @@ function tuiRows(tui: unknown): number {
1456
1545
  const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
1457
1546
  return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
1458
1547
  }
1548
+ const WORKFLOW_PANEL_FOOTER_ROWS = 2;
1459
1549
  const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
1460
1550
  const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
1461
1551
  const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
@@ -1474,8 +1564,17 @@ function keybindingKeys(keybindings: unknown, name: string): readonly string[] |
1474
1564
  const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
1475
1565
  return getKeys ? getKeys.call(keybindings, name) : undefined;
1476
1566
  }
1567
+ type WorkflowKeybindings = { matches(data: string, binding: string): boolean };
1568
+ const WORKFLOW_VIM_KEYS: Readonly<Record<string, string>> = { "tui.select.up": "k", "tui.select.down": "j", "tui.editor.cursorLeft": "h", "tui.editor.cursorRight": "l" };
1569
+ function workflowKeyMatches(keybindings: WorkflowKeybindings, data: string, binding: string): boolean { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
1570
+ function workflowKeyLabel(keybindings: unknown, binding: string, fallback: string, labels: Readonly<Record<string, string>>): string {
1571
+ const keys = keybindingKeys(keybindings, binding);
1572
+ const configured = keys?.length ? keys.map((key) => labels[key] ?? key) : [fallback];
1573
+ const vim = WORKFLOW_VIM_KEYS[binding];
1574
+ return [...new Set(vim ? [...configured, vim] : configured)].join("/");
1575
+ }
1477
1576
 
1478
- export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
1577
+ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, transport: AgentTransport = localAgentTransport, agentDir?: string, additionalSkillPaths: readonly string[] = []) {
1479
1578
  beginWorkflowExtensionLoading();
1480
1579
  const registry = loadingRegistry();
1481
1580
  const extensionAgentDir = agentDir ?? getAgentDir();
@@ -1496,7 +1595,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1496
1595
  const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
1497
1596
  return skillPath ? { skillPaths: [skillPath] } : undefined;
1498
1597
  });
1499
- type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
1598
+ type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun };
1500
1599
  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
1600
  let providerRecoveryQueue = Promise.resolve();
1502
1601
  const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
@@ -1520,14 +1619,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1520
1619
  });
1521
1620
  };
1522
1621
  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
- });
1622
+ const foregroundDeliveries = new Map<string, { store: RunStore; inline: boolean; timer?: ReturnType<typeof setTimeout> }>();
1530
1623
  const liveActivities = new Map<string, Map<string, AgentActivity>>();
1624
+ const liveEventTimes = new Map<string, Map<string, number>>();
1625
+ const liveAgentSessions = new Map<string, import("./types.js").WorkflowAgentSession>();
1626
+ const setLiveAgentSession = (runId: string, agentId: string, session?: import("./types.js").WorkflowAgentSession) => { const key = `${runId}:${agentId}`; if (session) liveAgentSessions.set(key, session); else liveAgentSessions.delete(key); };
1531
1627
  const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
1532
1628
  const activities = liveActivities.get(runId);
1533
1629
  if (activity) {
@@ -1538,12 +1634,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1538
1634
  if (activities?.size === 0) liveActivities.delete(runId);
1539
1635
  }
1540
1636
  };
1637
+ const setLiveEventTime = (runId: string, agentId: string, timestamp?: number) => {
1638
+ if (timestamp === undefined) return;
1639
+ const timestamps = liveEventTimes.get(runId);
1640
+ if (timestamps) timestamps.set(agentId, timestamp);
1641
+ else liveEventTimes.set(runId, new Map([[agentId, timestamp]]));
1642
+ };
1541
1643
  const withLiveActivities = (run: PersistedRun): PersistedRun => {
1542
1644
  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;
1645
+ const timestamps = liveEventTimes.get(run.id);
1646
+ if (!activities?.size && !timestamps?.size) return run;
1647
+ return { ...run, agents: run.agents.map((agent) => {
1648
+ const activity = activities?.get(agent.id);
1649
+ const lastEventAt = timestamps?.get(agent.id);
1650
+ if (activity === undefined && lastEventAt === undefined) return agent;
1651
+ return { ...agent, ...(activity === undefined ? {} : { activity }), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
1652
+ }) };
1547
1653
  };
1548
1654
  const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
1549
1655
  let sessionLease: SessionLease | undefined;
@@ -1565,6 +1671,42 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1565
1671
  await eventPublisher.budget(store, metadata, persisted);
1566
1672
  return persisted;
1567
1673
  };
1674
+ pi.on("tool_result", async (event) => {
1675
+ const delivery = event.toolName === "workflow" ? foregroundDeliveries.get(event.toolCallId) : undefined;
1676
+ if (delivery) {
1677
+ if (delivery.timer) clearTimeout(delivery.timer);
1678
+ delivery.inline = true;
1679
+ await delivery.store.updateState((current) => {
1680
+ if (current.delivery?.toolCallId !== event.toolCallId || current.delivery.state === "delivered") return current;
1681
+ return { ...current, delivery: { ...current.delivery, state: "delivered" } };
1682
+ });
1683
+ foregroundDeliveries.delete(event.toolCallId);
1684
+ }
1685
+ if (event.toolName !== "workflow" || !event.isError) return;
1686
+ const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
1687
+ if (!diagnostic) return;
1688
+ pendingFailureDiagnostics.delete(event.toolCallId);
1689
+ return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
1690
+ });
1691
+ const deliverTerminal = async (store: RunStore, content: string): Promise<void> => {
1692
+ let claimed: boolean | undefined;
1693
+ await store.updateState((current) => {
1694
+ if (current.delivery?.state === "delivered") return current;
1695
+ if (!current.delivery) { claimed = true; return current; }
1696
+ claimed = true;
1697
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "delivered" } };
1698
+ });
1699
+ if (claimed === true) deliver(pi, content);
1700
+ };
1701
+ const scheduleForegroundDelivery = (toolCallId: string, send: () => Promise<void>): void => {
1702
+ const delivery = foregroundDeliveries.get(toolCallId);
1703
+ if (!delivery || delivery.inline || typeof (pi as unknown as { sendMessage?: unknown }).sendMessage !== "function") return;
1704
+ //NOTE: Give Pi one event-loop turn to deliver an uninterrupted tool result before promoting.
1705
+ delivery.timer = setTimeout(() => {
1706
+ delete delivery.timer;
1707
+ void send().finally(() => foregroundDeliveries.delete(toolCallId));
1708
+ }, 0);
1709
+ };
1568
1710
  const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
1569
1711
  let cursor = 0;
1570
1712
  return async (phase: string): Promise<void> => {
@@ -1605,17 +1747,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1605
1747
  const path = shellIdentityPath(identity);
1606
1748
  const replayed = await store.replay(path);
1607
1749
  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;
1750
+ const started = await persistRunState(store, metadata, (current) => ({ ...current, activeShells: (current.activeShells ?? 0) + 1 }));
1751
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(started)));
1752
+ try {
1753
+ const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
1754
+ const result = await executeShellCommand(command, options, signal, cwd);
1755
+ await store.complete(path, result as unknown as JsonValue);
1756
+ return result;
1757
+ } finally {
1758
+ const stopped = await persistRunState(store, metadata, (current) => {
1759
+ const activeShells = Math.max(0, (current.activeShells ?? 0) - 1);
1760
+ if (activeShells > 0) return { ...current, activeShells };
1761
+ const next = { ...current };
1762
+ delete next.activeShells;
1763
+ return next;
1764
+ });
1765
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(stopped)));
1766
+ }
1612
1767
  } finally { await lifecycle.leave(); }
1613
1768
  };
1614
1769
  const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
1615
1770
  if (next !== "pausing") budget.transition(next);
1616
1771
  const persisted = await persistRunState(store, metadata, (current) => {
1617
1772
  const nextRun = { ...current, state: next, ...budget.snapshot() };
1618
- if (next === "running" || next === "completed") delete nextRun.error;
1773
+ if (next === "running" || next === "completed") { delete nextRun.error; delete nextRun.failedAt; }
1619
1774
  return nextRun;
1620
1775
  });
1621
1776
  await eventPublisher.runState(store, metadata, previous, next, reason);
@@ -1629,25 +1784,29 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1629
1784
  const onProgress = async (progress: AgentProgress) => {
1630
1785
  let runState: PersistedRun;
1631
1786
  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);
1787
+ 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, ...(progress.state ? { model: progress.state.model, tools: progress.state.tools, ...(progress.state.systemPrompt === undefined ? {} : { systemPrompt: progress.state.systemPrompt }) } : {}), activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) } : current);
1633
1788
  } else {
1634
1789
  const loaded = await run.store.load();
1635
1790
  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) };
1791
+ runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, ...(progress.state ? { model: progress.state.model, tools: progress.state.tools, ...(progress.state.systemPrompt === undefined ? {} : { systemPrompt: progress.state.systemPrompt }) } : {}), activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) };
1637
1792
  }
1638
1793
  if (!runState.agents.some((agent) => agent.id === id)) return;
1639
1794
  setLiveActivity(runId, id, progress.activity);
1795
+ setLiveEventTime(runId, id, progress.lastEventAt);
1640
1796
  run.update?.(workflowToolUpdate(withLiveActivities(runState)));
1641
1797
  };
1642
- const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
1798
+ const onAttempt = async (attempt: AgentAttempt) => {
1799
+ setLiveAgentSession(runId, id, attempt.liveSession);
1643
1800
  await scheduler.flush();
1644
1801
  scheduler.attemptStarted(id);
1802
+ const lastEventAt = Date.now();
1803
+ setLiveEventTime(runId, id, lastEventAt);
1645
1804
  await scheduler.flush();
1646
1805
  const before = (await run.store.load()).run;
1647
1806
  await persistActiveAgentAttempt(run.store, id, attempt);
1648
1807
  const active = (await run.store.load()).run;
1649
1808
  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() }));
1809
+ 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
1810
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1652
1811
  };
1653
1812
  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); });
@@ -1657,9 +1816,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1657
1816
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
1658
1817
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1659
1818
  setLiveActivity(runId, id);
1819
+ setLiveAgentSession(runId, id);
1660
1820
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
1661
1821
  return result.value;
1662
1822
  } catch (error) {
1823
+ setLiveAgentSession(runId, id);
1663
1824
  const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
1664
1825
  if (attempts?.length) {
1665
1826
  const before = (await run.store.load()).run;
@@ -1687,7 +1848,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1687
1848
  try { effective = run.executor.resolve(requested); }
1688
1849
  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
1850
  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 } : {}) };
1851
+ const lastEventAt = node.state === "running" ? previous?.state === "running" && previous.lastEventAt !== undefined ? previous.lastEventAt : Date.now() : previous?.lastEventAt;
1852
+ return { ...(previous?.systemPrompt === undefined ? {} : { systemPrompt: previous.systemPrompt }), ...(node.prompt !== undefined ? { prompt: node.prompt } : previous?.prompt !== undefined ? { prompt: previous.prompt } : {}), 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
1853
  });
1692
1854
  return { ...current, agents };
1693
1855
  });
@@ -1704,6 +1866,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1704
1866
  terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
1705
1867
  run.checkpointResolvers.clear();
1706
1868
  liveActivities.delete(runId);
1869
+ liveEventTimes.delete(runId);
1870
+ for (const key of liveAgentSessions.keys()) if (key.startsWith(`${runId}:`)) liveAgentSessions.delete(key);
1707
1871
  eventPublisher.removeRun(runId);
1708
1872
  runs.delete(runId);
1709
1873
  };
@@ -1739,13 +1903,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1739
1903
  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
1904
  await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
1741
1905
  };
1742
- const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
1906
+ const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult | undefined> => {
1743
1907
  const run = runs.get(runId);
1744
1908
  if (!run) return undefined;
1745
1909
  const request = await run.store.answerWorkflowDecision(proposalId, approved);
1746
1910
  if (!request) return undefined;
1747
1911
  await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
1748
- const result = await applyBudgetDecision(request, approved, context, signal);
1912
+ const result = await applyBudgetDecision(request, approved, context, signal, waitForCompletion);
1749
1913
  if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
1750
1914
  return result;
1751
1915
  };
@@ -1839,7 +2003,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1839
2003
  pi.registerTool({
1840
2004
  name: "workflow_catalog",
1841
2005
  label: "Workflow Catalog",
1842
- description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
2006
+ description: "List reusable workflow functions, variables, and model aliases; pass `name` to load one entry in full",
1843
2007
  parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
1844
2008
  async execute(_id, params = {}) {
1845
2009
  const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
@@ -1856,7 +2020,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1856
2020
  });
1857
2021
  catalogRegistered = true;
1858
2022
  };
1859
- const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
2023
+ const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), agentSetupHooks: registry.agentSetupHooks() }, transport);
1860
2024
  const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
1861
2025
  ? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
1862
2026
  : new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
@@ -1896,7 +2060,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1896
2060
  const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
1897
2061
  return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
1898
2062
  };
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) => {
2063
+ 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
2064
  await lifecycle.enter();
1901
2065
  try {
1902
2066
  const path = agentIdentityPath(identity);
@@ -1911,6 +2075,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1911
2075
  const thinking = parseThinking(options.thinking);
1912
2076
  const requestedLabel = typeof options.label === "string" ? options.label : undefined;
1913
2077
  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[] } : {}) });
2078
+ if (role) await captureRole?.(role, resolved.model);
1914
2079
  const label = displayAgentName(requestedLabel, role, resolved.model);
1915
2080
  const tools = resolved.tools;
1916
2081
  const schema = object(options.outputSchema) ? options.outputSchema : undefined;
@@ -1935,8 +2100,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1935
2100
  const drift = aliasDrift(previousAliases, currentAliases);
1936
2101
  if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
1937
2102
  };
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>> }) => {
2103
+ const recoveryUi = (context: unknown): { hasUI: boolean; ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> } } => {
2104
+ const host = object(context) ? context : undefined;
2105
+ const ui = host && object(host.ui) ? host.ui as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } : {};
2106
+ return { hasUI: host?.hasUI === true, ui };
2107
+ };
2108
+ type ColdResumeResult = { value: JsonValue; resultPath: string };
2109
+ 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
2110
  const loaded = await run.store.load();
2111
+ const foreground = modeOverride ?? loaded.snapshot.launchMode === "foreground";
2112
+ if (loaded.run.activeShells !== undefined) {
2113
+ await persistRunState(run.store, run.metadata, (current) => {
2114
+ const next = { ...current };
2115
+ delete next.activeShells;
2116
+ return next;
2117
+ });
2118
+ }
1940
2119
  await run.store.validateRetrySource();
1941
2120
  await run.store.validateBorrowedWorktrees();
1942
2121
  if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
@@ -1950,7 +2129,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1950
2129
  run.abortController = controller;
1951
2130
  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
2131
  if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
1953
- await run.store.saveSnapshot(snapshot);
2132
+ const persistedSnapshot = modeOverride === undefined ? snapshot : createLaunchSnapshot({ ...snapshot, launchMode: foreground ? "foreground" : "background" });
2133
+ await run.store.saveSnapshot(persistedSnapshot);
1954
2134
  scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
1955
2135
  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
2136
  const drift = aliasDrift(previousAliases, currentAliases);
@@ -1961,13 +2141,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1961
2141
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
1962
2142
  catch (error) {
1963
2143
  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); }
2144
+ 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
2145
  await cleanupTerminalRun(run.store.runId);
1966
2146
  throw typed;
1967
2147
  }
1968
2148
  await scheduler.cancelRun(run.store.runId);
1969
2149
  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);
2150
+ 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
2151
  run.execution = execution;
1972
2152
  const completion = execution.result.then(async (value) => {
1973
2153
  await scheduler.flush();
@@ -1980,17 +2160,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1980
2160
  await scheduler.flush();
1981
2161
  const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
1982
2162
  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 } }));
2163
+ const persisted = await persistRunState(run.store, run.metadata, (current) => persistedFailure({ ...current, ...run.budget.snapshot() }, typed));
1984
2164
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
1985
2165
  if (state === "failed") retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
1986
2166
  await eventPublisher.runFailed(run.store, run.metadata, typed, state);
1987
2167
  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);
2168
+ 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 }); }
2169
+ throw typed;
1989
2170
  }).finally(() => cleanupTerminalRun(run.store.runId));
1990
2171
  run.completion = completion;
1991
- void completion;
2172
+ if (!foreground || !waitForCompletion) {
2173
+ void completion.then(async ({ value, resultPath }) => {
2174
+ deliver(pi, completionDelivery(run.metadata.name, value, resultPath, await run.store.changedWorktrees()));
2175
+ }, (error: unknown) => {
2176
+ const diagnostic = failureDiagnosticsFrom(error);
2177
+ if (diagnostic) deliverFailure(pi, diagnostic);
2178
+ else deliver(pi, formatWorkflowFailureDeliveryFallback(run.metadata.name, run.store.runId, run.store.directory, error));
2179
+ });
2180
+ return undefined;
2181
+ }
2182
+ return completion;
1992
2183
  };
1993
- const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
2184
+ const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult> => {
1994
2185
  const run = runs.get(request.runId);
1995
2186
  if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
1996
2187
  if (!approved) return { state: "budget_exhausted", approved: false };
@@ -1999,10 +2190,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
1999
2190
  const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
2000
2191
  run.budget = runtime;
2001
2192
  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 } : {}) });
2193
+ const { hasUI, ui } = recoveryUi(context);
2194
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, undefined, waitForCompletion);
2195
+ if (completed) return { state: "completed", approved: true, value: completed.value, run: (await run.store.load()).run };
2003
2196
  return { state: "running", approved: true };
2004
2197
  };
2005
- const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
2198
+ const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal, modeOverride?: boolean, waitForCompletion = true): Promise<Record<string, JsonValue>> => {
2006
2199
  const run = runs.get(runId);
2007
2200
  if (!run) {
2008
2201
  const host = object(context) ? context : {};
@@ -2041,11 +2234,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2041
2234
  run.budget = runtime;
2042
2235
  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
2236
  }
2044
- await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
2237
+ const { hasUI, ui } = recoveryUi(context);
2238
+ const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, modeOverride, waitForCompletion);
2239
+ if (completed) return { state: "completed", runId, value: completed.value, run: (await run.store.load()).run as unknown as JsonValue };
2045
2240
  return { state: "running" };
2046
2241
  };
2047
2242
  const retryReservations = new Set<string>();
2048
- const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
2243
+ const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal, modeOverride?: boolean): Promise<{ runId: string; parentRunId: string; state: "running" | "completed"; value?: JsonValue; run?: PersistedRun }> => {
2049
2244
  if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
2050
2245
  const host = object(context) ? context : {};
2051
2246
  const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
@@ -2098,7 +2293,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2098
2293
  const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
2099
2294
  const childInitialBudget = childBudget.snapshot();
2100
2295
  const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
2101
- await childStore.create({ id: childRunId, workflowName: loaded.snapshot.metadata.name, cwd, sessionId, state: "interrupted", parentRunId: loaded.run.id, retry, agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: loaded.run.budgetVersion ?? 1, ...childInitialBudget }, childSnapshot);
2296
+ await childStore.create({ id: childRunId, workflowName: loaded.snapshot.metadata.name, cwd, sessionId, state: "interrupted", parentRunId: loaded.run.id, retry, agents: [], agentSessions: [], ...(budget ? { budget } : {}), budgetVersion: loaded.run.budgetVersion ?? 1, ...childInitialBudget }, childSnapshot);
2102
2297
  const fallbackModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
2103
2298
  const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
2104
2299
  const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
@@ -2109,12 +2304,17 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2109
2304
  runs.set(childRunId, childRun);
2110
2305
  scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
2111
2306
  await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
2112
- await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
2307
+ const { hasUI, ui } = recoveryUi(context);
2308
+ const completed = await coldResumeRun(childRun, hasUI, ui, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
2113
2309
  const completion = runs.get(childRunId)?.completion;
2114
2310
  if (completion) {
2115
2311
  childStarted = true;
2116
2312
  void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
2313
+ } else if (completed) {
2314
+ childStarted = true;
2315
+ retryReservations.delete(lineageRootRunId);
2117
2316
  }
2317
+ if (completed) return { runId: childRunId, parentRunId: loaded.run.id, state: "completed", value: completed.value, run: (await childStore.load()).run };
2118
2318
  return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
2119
2319
  } finally {
2120
2320
  if (!childStarted) retryReservations.delete(lineageRootRunId);
@@ -2126,7 +2326,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2126
2326
  description: "Retry a failed workflow run by replaying its completed structural operations",
2127
2327
  parameters: WORKFLOW_RETRY_PARAMETERS,
2128
2328
  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 }; }
2329
+ try { const result = await retryWorkflowRun(params.runId, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
2130
2330
  catch (error) { throw mainAgentError(error); }
2131
2331
  },
2132
2332
  renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
@@ -2136,9 +2336,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2136
2336
  name: "workflow_resume",
2137
2337
  label: "Workflow Resume",
2138
2338
  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 }),
2339
+ 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
2340
  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 }; }
2341
+ 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
2342
  catch (error) { throw mainAgentError(error); }
2143
2343
  },
2144
2344
  renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
@@ -2159,10 +2359,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2159
2359
  if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
2160
2360
  if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
2161
2361
  const previousState = loaded.run.state;
2162
- await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
2362
+ await store.updateState((current) => {
2363
+ if (["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state)) return current;
2364
+ const next = { ...current, state: "interrupted" as const };
2365
+ delete next.activeShells;
2366
+ return next;
2367
+ });
2163
2368
  loaded = { ...loaded, run: (await store.load()).run };
2164
2369
  await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
2165
2370
  loaded = { ...loaded, run: (await store.load()).run };
2371
+ } else if (loaded.run.activeShells !== undefined) {
2372
+ await store.updateState((current) => {
2373
+ if (["completed", "failed", "stopped"].includes(current.state)) return current;
2374
+ const next = { ...current };
2375
+ delete next.activeShells;
2376
+ return next;
2377
+ });
2378
+ loaded = { ...loaded, run: (await store.load()).run };
2166
2379
  }
2167
2380
  const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
2168
2381
  const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
@@ -2188,7 +2401,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2188
2401
  if (choice && choice !== "Skip") {
2189
2402
  const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
2190
2403
  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"); }
2404
+ try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx, undefined, false); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
2192
2405
  catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
2193
2406
  }
2194
2407
  }
@@ -2245,10 +2458,24 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2245
2458
  const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
2246
2459
  const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
2247
2460
  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 });
2461
+ 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 });
2462
+ let persistedSnapshot = snapshot;
2463
+ const captureFunctionRole = functionName ? async (role: string, model: ModelSpec): Promise<void> => {
2464
+ const definition = agentDefinitions[role];
2465
+ if (!definition) return;
2466
+ const modelName = `${model.provider}/${model.model}`;
2467
+ const hasProjectRole = projectAgentDefinitions[role] !== undefined;
2468
+ if (persistedSnapshot.roles?.[role] !== undefined && (!hasProjectRole || persistedSnapshot.projectRoles?.includes(role)) && persistedSnapshot.models.includes(modelName)) return;
2469
+ const roles = { ...(persistedSnapshot.roles ?? {}), [role]: definition };
2470
+ const projectRoles = hasProjectRole ? [...new Set([...(persistedSnapshot.projectRoles ?? []), role])] : persistedSnapshot.projectRoles ?? [];
2471
+ const models = [...new Set([...persistedSnapshot.models, modelName])];
2472
+ persistedSnapshot = createLaunchSnapshot({ ...persistedSnapshot, models, roles, projectRoles });
2473
+ await store.saveSnapshot(persistedSnapshot);
2474
+ } : undefined;
2249
2475
  const budgetRuntime = new WorkflowBudgetRuntime(budget);
2250
2476
  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);
2477
+ await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], agentSessions: [], delivery: params.foreground ? { mode: "foreground", state: "attached", toolCallId } : { mode: "background", state: "pending" }, ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
2478
+ if (params.foreground) foregroundDeliveries.set(toolCallId, { store, inline: false });
2252
2479
  const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
2253
2480
  const background = !params.foreground;
2254
2481
  const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
@@ -2257,7 +2484,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2257
2484
  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
2485
  if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
2259
2486
  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);
2487
+ 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
2488
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
2262
2489
  await eventPublisher.runStarted(store, checked.metadata);
2263
2490
  const finish = execution.result.then(async (value) => {
@@ -2271,7 +2498,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2271
2498
  await scheduler.flush();
2272
2499
  const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
2273
2500
  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 } }));
2501
+ const persisted = await persistRunState(store, checked.metadata, (current) => persistedFailure({ ...current, ...budgetRuntime.snapshot() }, typed));
2275
2502
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2276
2503
  await eventPublisher.runFailed(store, checked.metadata, typed, state);
2277
2504
  const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
@@ -2281,16 +2508,37 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2281
2508
  });
2282
2509
  const completion = finish.finally(() => cleanupTerminalRun(runId));
2283
2510
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
2511
+ const deliverFailureContent = (error: unknown): string => {
2512
+ const diagnostic = failureDiagnosticsFrom(error);
2513
+ return diagnostic ? formatWorkflowFailureDelivery(diagnostic) : formatWorkflowFailureDeliveryFallback(checked.metadata.name, runId, store.directory, error);
2514
+ };
2515
+ const queueForegroundDelivery = async (content: string): Promise<void> => {
2516
+ const delivery = foregroundDeliveries.get(toolCallId);
2517
+ if (!delivery) return;
2518
+ await store.updateState((current) => {
2519
+ if (!current.delivery || current.delivery.state === "delivered") return current;
2520
+ return { ...current, delivery: { ...current.delivery, mode: "background", state: "pending" } };
2521
+ });
2522
+ if (delivery.inline) return;
2523
+ scheduleForegroundDelivery(toolCallId, async () => {
2524
+ if (delivery.inline) return;
2525
+ pendingFailureDiagnostics.delete(toolCallId);
2526
+ await deliverTerminal(store, content);
2527
+ });
2528
+ };
2284
2529
  if (background) {
2285
2530
  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)}`);
2531
+ await deliverTerminal(store, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2532
+ }, async (error: unknown) => {
2533
+ await deliverTerminal(store, deliverFailureContent(error));
2291
2534
  });
2292
2535
  return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
2293
2536
  }
2537
+ void completion.then(async ({ value, resultPath }) => {
2538
+ await queueForegroundDelivery(completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
2539
+ }, async (error: unknown) => {
2540
+ await queueForegroundDelivery(deliverFailureContent(error));
2541
+ });
2294
2542
  const { value } = await completion;
2295
2543
  const run = (await store.load()).run;
2296
2544
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
@@ -2305,7 +2553,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2305
2553
  const details = result.details;
2306
2554
  if (isWorkflowFailureDiagnostics(details)) return textBlock(formatWorkflowFailureDiagnostics(details));
2307
2555
  const runDetails = details as { run?: PersistedRun; value?: JsonValue; preview?: string } | undefined;
2308
- const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval> };
2556
+ const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval>; workflowProgress?: WorkflowProgressRefreshState };
2309
2557
  if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
2310
2558
  state.workflowSpinner = setInterval(context.invalidate, 80);
2311
2559
  state.workflowSpinner.unref();
@@ -2313,7 +2561,31 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2313
2561
  clearInterval(state.workflowSpinner);
2314
2562
  delete state.workflowSpinner;
2315
2563
  }
2316
- if (runDetails?.run) return workflowProgressBlock(runDetails.run, theme);
2564
+ if (runDetails?.run) {
2565
+ const incoming = runDetails.run;
2566
+ let progress = state.workflowProgress;
2567
+ if (!isPartial || !progress || progress.runId !== incoming.id) {
2568
+ progress = undefined;
2569
+ delete state.workflowProgress;
2570
+ if (isPartial) {
2571
+ progress = { runId: incoming.id, inputRun: incoming, run: incoming, lastRefreshAt: 0, runtimeStartedAt: Date.now(), runtimeBaseMs: incoming.usage?.durationMs ?? 0 };
2572
+ state.workflowProgress = progress;
2573
+ }
2574
+ } else if (progress.inputRun !== incoming) {
2575
+ if (progress.run.state !== "running" && incoming.state === "running") {
2576
+ progress.runtimeBaseMs = incoming.usage?.durationMs ?? 0;
2577
+ progress.runtimeStartedAt = Date.now();
2578
+ }
2579
+ progress.inputRun = incoming;
2580
+ progress.run = incoming;
2581
+ }
2582
+ return workflowProgressBlock(progress?.run ?? incoming, theme, progress, async () => {
2583
+ const active = runs.get(incoming.id);
2584
+ const store = active?.store ?? new RunStore(incoming.cwd, incoming.sessionId, incoming.id, home);
2585
+ const loaded = await store.load();
2586
+ return withLiveActivities(loaded.run);
2587
+ }, () => { if (state.workflowProgress === progress) context.invalidate(); });
2588
+ }
2317
2589
  const content = result.content[0];
2318
2590
  return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
2319
2591
  },
@@ -2332,7 +2604,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2332
2604
  const loadStores = async () => {
2333
2605
  const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
2334
2606
  const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
2335
- try { return { store, loaded: await store.load() }; }
2607
+ try { const loaded = await store.load(); return { store, loaded: { ...loaded, run: withLiveActivities(loaded.run) } }; }
2336
2608
  catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
2337
2609
  }));
2338
2610
  return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
@@ -2355,7 +2627,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2355
2627
  return keepContext ? "dashboard" : "done";
2356
2628
  }
2357
2629
  if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
2358
- const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
2630
+ const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx, undefined, false);
2359
2631
  ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
2360
2632
  return keepContext ? "dashboard" : "done";
2361
2633
  }
@@ -2368,10 +2640,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2368
2640
  if (action === "resume" && run) {
2369
2641
  if (run.lifecycle.state === "budget_exhausted") {
2370
2642
  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");
2643
+ const result = await resumeWorkflowRun(run.store.runId, patch, ctx, undefined, undefined, false);
2644
+ 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
2645
  } else {
2374
- if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
2646
+ if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx, undefined, false);
2375
2647
  else {
2376
2648
  if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
2377
2649
  await run.lifecycle.resume();
@@ -2383,8 +2655,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2383
2655
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2384
2656
  const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2385
2657
  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");
2658
+ const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx, undefined, undefined, false);
2659
+ 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
2660
  return keepContext ? "dashboard" : "done";
2389
2661
  }
2390
2662
  if (action === "stop" && run) {
@@ -2528,11 +2800,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2528
2800
  };
2529
2801
  const loadDashboard = async () => {
2530
2802
  const loaded = await store.load();
2803
+ const liveRun = withLiveActivities(loaded.run);
2531
2804
  const checkpoints = await store.awaitingCheckpoints();
2532
2805
  const worktrees = await store.worktrees();
2533
2806
  const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
2534
2807
  const agentResults = new Map<string, JsonValue>();
2535
- for (const agent of loaded.run.agents) {
2808
+ for (const agent of liveRun.agents) {
2536
2809
  if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
2537
2810
  const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
2538
2811
  if (operation) agentResults.set(agent.id, operation.value);
@@ -2542,15 +2815,15 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2542
2815
  const reviews = new Map<string, AwaitingCheckpoint>();
2543
2816
  const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
2544
2817
  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}`); }
2818
+ if (liveRun.state === "running") add("Pause", "pause");
2819
+ if (["paused", "interrupted"].includes(liveRun.state)) add("Resume", "resume");
2820
+ if (liveRun.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
2548
2821
  for (const decision of await store.pendingWorkflowDecisions()) {
2549
2822
  const id = decision.proposalId.slice(0, 8);
2550
2823
  actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
2551
2824
  actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
2552
2825
  }
2553
- if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
2826
+ if (!terminalStates.has(liveRun.state)) add("Stop", "stop");
2554
2827
  for (const cp of checkpoints) {
2555
2828
  if (ctx.mode === "tui") {
2556
2829
  const label = `Review ${cp.name}`;
@@ -2563,42 +2836,41 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2563
2836
  }
2564
2837
  if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
2565
2838
  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");
2839
+ if (ctx.mode !== "tui" && liveRun.agents.length) actions.set("Agents...", "agents");
2840
+ if (terminalStates.has(liveRun.state)) add("Delete", "delete");
2568
2841
  if (ctx.mode === "tui") {
2569
2842
  addCopy("Copy run path", store.directory, "run path");
2570
2843
  addCopy("Copy run ID", store.runId, "run ID");
2571
2844
  }
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 };
2845
+ 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
2846
  };
2574
2847
  const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
2848
+ const agentAttemptActionContext = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): AgentAttemptActionContext | undefined => {
2849
+ const attempt = (agent.attemptDetails ?? []).reduce<AgentAttemptSummary | undefined>((latest, candidate) => !latest || candidate.attempt > latest.attempt ? candidate : latest, undefined);
2850
+ if (!attempt) return undefined;
2851
+ const liveCandidate = liveAgentSessions.get(`${dashboard.run.id}:${agent.id}`);
2852
+ const live = liveCandidate && attempt.session && liveCandidate.reference.transport === attempt.session.transport && liveCandidate.reference.sessionId === attempt.session.sessionId ? liveCandidate : undefined;
2853
+ const run = runs.get(dashboard.run.id);
2854
+ const ui = { notify: (message: string, level: "info" | "warning" | "error" = "info") => { ctx.ui.notify(message, level); }, confirm: (title: string, message: string) => ctx.ui.confirm(title, message), select: (title: string, options: readonly string[]) => { return ctx.ui.select(title, [...options]); }, input: (title: string, placeholder?: string) => ctx.ui.input(title, placeholder) };
2855
+ const attemptSnapshot = deepFreeze(structuredClone(attempt));
2856
+ return { run: deepFreeze(structuredClone(dashboard.run)), agent: deepFreeze(structuredClone(agent)), attempt: attemptSnapshot, ...(attemptSnapshot.session ? { session: attemptSnapshot.session } : {}), ...(live ? { liveSession: live } : {}), signal: run?.abortController.signal ?? new AbortController().signal, ui: Object.freeze(ui) };
2857
+ };
2858
+ const visibleAgentAttemptActions = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): readonly [string, import("./types.js").AgentAttemptAction][] => {
2859
+ const context = agentAttemptActionContext(dashboard, agent);
2860
+ if (!context) return [];
2861
+ return Object.entries(registry.agentAttemptActions()).filter(([, action]) => { try { return action.visible(context); } catch { return false; } });
2862
+ };
2575
2863
  const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
2576
2864
  const worktree = agentWorktreeFor(dashboard, agent);
2577
2865
  return [
2578
- ...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
2866
+ ...visibleAgentAttemptActions(dashboard, agent).map(([, action]) => action.label),
2579
2867
  ...(worktree ? ["Copy branch", "Copy worktree path"] : []),
2868
+ ...(ctx.mode === "tui" && agent.prompt !== undefined ? ["Open prompt in editor"] : []),
2580
2869
  ...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
2581
2870
  "Copy agent ID",
2582
2871
  "Back",
2583
2872
  ];
2584
2873
  };
2585
- const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
2586
- const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
2587
- const worktree = agentWorktreeFor(dashboard, agent);
2588
- const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
2589
- const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
2590
- const index = choice ? choices.indexOf(choice) : -1;
2591
- const attempt = index >= 0 ? attempts[index] : undefined;
2592
- if (!attempt) return;
2593
- const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
2594
- if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) return;
2595
- try {
2596
- await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
2597
- ctx.ui.notify("Forked Pi session in pane.", "info");
2598
- } catch (error) {
2599
- ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
2600
- }
2601
- };
2602
2874
  const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
2603
2875
  const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
2604
2876
  const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
@@ -2616,10 +2888,15 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2616
2888
  for (;;) {
2617
2889
  const action = await ctx.ui.select(title(selected), actions);
2618
2890
  if (!action || action === "Back") return;
2891
+ const extensionAction = visibleAgentAttemptActions(dashboard, selected).find(([, candidate]) => candidate.label === action);
2892
+ if (extensionAction) {
2893
+ const context = agentAttemptActionContext(dashboard, selected);
2894
+ if (context) { try { await extensionAction[1].run(context); } catch (error) { ctx.ui.notify(`Agent attempt action failed: ${error instanceof Error ? error.message : String(error)}`, "error"); } }
2895
+ return;
2896
+ }
2619
2897
  if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
2620
2898
  if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
2621
2899
  if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
2622
- if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
2623
2900
  }
2624
2901
  };
2625
2902
  for (;;) {
@@ -2641,12 +2918,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2641
2918
  let tree = buildWorkflowPhaseTree(view.phaseModel);
2642
2919
  let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
2643
2920
  let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
2644
- const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
2921
+ const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
2645
2922
  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
- };
2923
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
2650
2924
  const selectedAgentRecord = (): AgentRecord | undefined => {
2651
2925
  const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
2652
2926
  return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
@@ -2671,7 +2945,6 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2671
2945
  ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
2672
2946
  } finally {
2673
2947
  editorRunning = false;
2674
- tui.requestRender(true);
2675
2948
  }
2676
2949
  };
2677
2950
  const updateDashboard = async () => {
@@ -2718,7 +2991,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2718
2991
  void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
2719
2992
  }, 1000);
2720
2993
  timer.unref();
2721
- return borderWorkflowOverlay({
2994
+ return {
2722
2995
  render(width: number) {
2723
2996
  renderedWidth = width;
2724
2997
  const narrow = width < 80;
@@ -2749,7 +3022,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2749
3022
  selectionNeedsScroll = false;
2750
3023
  }
2751
3024
  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] ?? "";
3025
+ const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.editor.cursorLeft", "←")} tree · ${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
3026
  return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
2754
3027
  },
2755
3028
  invalidate() {},
@@ -2759,25 +3032,35 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2759
3032
  if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
2760
3033
  if (actionMode) {
2761
3034
  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")) {
3035
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel") || workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
3036
+ if (workflowKeyMatches(keybindings, data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
3037
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
3038
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3039
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
3040
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
2768
3041
  const action = options[actionIndex];
2769
3042
  const agent = selectedAgentRecord();
2770
3043
  if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
2771
3044
  else if (agent) {
2772
3045
  const worktree = agentWorktreeFor(view, agent);
2773
- if (action === "Open result in editor") {
3046
+ if (action === "Open prompt in editor") {
3047
+ if (agent.prompt !== undefined) void openArtifact(Promise.resolve(workflowPromptArtifact(agent.prompt)), "agent prompt");
3048
+ }
3049
+ else if (action === "Open result in editor") {
2774
3050
  const result = view.agentResults.get(agent.id);
2775
3051
  if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
2776
3052
  }
2777
3053
  else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
2778
3054
  else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
2779
3055
  else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
2780
- else done(`__workflow_fork__:${agent.id}`);
3056
+ else {
3057
+ const extensionAction = visibleAgentAttemptActions(view, agent).find(([, candidate]) => candidate.label === action);
3058
+ const actionContext = extensionAction ? agentAttemptActionContext(view, agent) : undefined;
3059
+ if (extensionAction && actionContext) {
3060
+ actionMode = false;
3061
+ void Promise.resolve(extensionAction[1].run(actionContext)).catch((error: unknown) => { ctx.ui.notify(`Agent attempt action failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }).finally(() => { void updateDashboard(); });
3062
+ }
3063
+ }
2781
3064
  }
2782
3065
  else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
2783
3066
  else if (action === "Stop") requestStop();
@@ -2787,30 +3070,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2787
3070
  return;
2788
3071
  }
2789
3072
  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);
3073
+ if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
3074
+ if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done("Back");
2792
3075
  } 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")) {
3076
+ if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3077
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
3078
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
2796
3079
  if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2797
3080
  else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
2798
3081
  }
2799
- } else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
3082
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) {
2800
3083
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
2801
3084
  selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2802
- } else if (keybindings.matches(data, "tui.editor.cursorRight")) {
3085
+ } else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorRight")) {
2803
3086
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
2804
3087
  selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
2805
- } else if (keybindings.matches(data, "tui.select.up")) {
3088
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.up")) {
2806
3089
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
2807
3090
  selectedNodeId = next.nodeId; selectionNeedsScroll = true;
2808
- } else if (keybindings.matches(data, "tui.select.down")) {
3091
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.down")) {
2809
3092
  const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
2810
3093
  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")) {
3094
+ } else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
3095
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
3096
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
2814
3097
  if (narrow) detailsMode = true;
2815
3098
  else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
2816
3099
  else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
@@ -2818,18 +3101,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2818
3101
  tui.requestRender();
2819
3102
  },
2820
3103
  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;
3104
+ };
3105
+ })
3106
+ : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Back"]);
3107
+ if (!actionChoice || actionChoice === "Back") { stores = await loadStores(); break; }
2825
3108
  if (actionChoice === "Agents...") { await selectAgent(view); continue; }
2826
3109
  if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
2827
- if (actionChoice.startsWith("__workflow_fork__:")) {
2828
- const agentId = actionChoice.slice("__workflow_fork__:".length);
2829
- const agent = view.agents.find((candidate) => candidate.id === agentId);
2830
- if (agent) await forkAgentSession(view, agent);
2831
- continue;
2832
- }
2833
3110
  if (actionChoice === "Refresh") continue;
2834
3111
  const copy = view.copies.get(actionChoice);
2835
3112
  if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
@@ -2861,7 +3138,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2861
3138
  const currentLayout = layout();
2862
3139
  const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
2863
3140
  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] ?? "";
3141
+ const keyLabels: Record<string, string> = { up: "", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
3142
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
3143
+ 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
3144
  const controls = currentLayout.compactControls
2866
3145
  ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
2867
3146
  : options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
@@ -2875,12 +3154,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2875
3154
  },
2876
3155
  invalidate() {},
2877
3156
  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);
3157
+ if (workflowKeyMatches(keybindings, data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
3158
+ else if (workflowKeyMatches(keybindings, data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
3159
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) move(-layout().contentViewport);
3160
+ else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) move(layout().contentViewport);
3161
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
3162
+ else if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) done(undefined);
2884
3163
  tui.requestRender();
2885
3164
  },
2886
3165
  }, theme);