@marcoscale98/pi-extensible-workflows 5.14.1-fork.1 → 5.19.1-fork.1

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 (94) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/dist/src/agent-execution.js +114 -87
  3. package/dist/src/background-widget.d.ts +61 -0
  4. package/dist/src/background-widget.js +23 -16
  5. package/dist/src/decoders.js +28 -1
  6. package/dist/src/execution.d.ts +4 -2
  7. package/dist/src/execution.js +43 -2
  8. package/dist/src/host-delivery.js +1 -0
  9. package/dist/src/host-navigator.d.ts +18 -0
  10. package/dist/src/host-navigator.js +71 -13
  11. package/dist/src/host-recovery.d.ts +5 -2
  12. package/dist/src/host-recovery.js +3 -3
  13. package/dist/src/host-view.d.ts +21 -1
  14. package/dist/src/host-view.js +138 -47
  15. package/dist/src/host.d.ts +2 -2
  16. package/dist/src/host.js +246 -60
  17. package/dist/src/index.d.ts +3 -1
  18. package/dist/src/index.js +1346 -622
  19. package/dist/src/index.js.map +3 -3
  20. package/dist/src/paths.d.ts +2 -0
  21. package/dist/src/paths.js +2 -0
  22. package/dist/src/registry.js +3 -2
  23. package/dist/src/roles.d.ts +70 -0
  24. package/dist/src/roles.js +326 -0
  25. package/dist/src/script-tools.d.ts +27 -0
  26. package/dist/src/script-tools.js +87 -0
  27. package/dist/src/settings.d.ts +26 -0
  28. package/dist/src/settings.js +277 -0
  29. package/dist/src/trajectory.d.ts +39 -4
  30. package/dist/src/trajectory.js +82 -5
  31. package/dist/src/types.d.ts +30 -3
  32. package/dist/src/types.js +1 -1
  33. package/dist/src/utils.d.ts +21 -1
  34. package/dist/src/utils.js +40 -0
  35. package/dist/src/validation.d.ts +10 -17
  36. package/dist/src/validation.js +29 -458
  37. package/dist/starter/index.js +0 -2
  38. package/dist/starter/index.js.map +1 -1
  39. package/dist/starter/review-loop.d.ts +0 -13
  40. package/dist/starter/review-loop.js +0 -2
  41. package/dist/subagents/index.js +1344 -798
  42. package/dist/subagents/index.js.map +3 -3
  43. package/dist/subagents/src/contracts.d.ts +49 -29
  44. package/dist/subagents/src/contracts.js +18 -7
  45. package/dist/subagents/src/index.d.ts +1 -1
  46. package/dist/subagents/src/index.js +15 -7
  47. package/dist/subagents/src/manager.js +32 -13
  48. package/dist/subagents/src/navigator.js +396 -247
  49. package/dist/subagents/src/view.d.ts +24 -2
  50. package/dist/subagents/src/view.js +107 -80
  51. package/dist/trajectory/assets/index.html +370 -232
  52. package/dist/trajectory/assets/prism.min.js +13 -0
  53. package/dist/trajectory/index.js +445 -277
  54. package/dist/trajectory/index.js.map +3 -3
  55. package/dist/trajectory/src/assets/index.html +370 -232
  56. package/dist/trajectory/src/assets/prism.min.js +13 -0
  57. package/dist/trajectory/src/export.js +4 -2
  58. package/dist/trajectory/src/index.d.ts +2 -0
  59. package/dist/trajectory/src/index.js +151 -60
  60. package/dist/trajectory/src/server.js +138 -11
  61. package/dist/trajectory/src/server.js.map +2 -2
  62. package/package.json +7 -6
  63. package/skills/pi-extensible-workflows/SKILL.md +2 -0
  64. package/src/agent-execution.ts +110 -80
  65. package/src/background-widget.ts +26 -19
  66. package/src/decoders.ts +22 -1
  67. package/src/execution.ts +41 -4
  68. package/src/host-delivery.ts +1 -0
  69. package/src/host-navigator.ts +58 -14
  70. package/src/host-recovery.ts +8 -7
  71. package/src/host-view.ts +116 -42
  72. package/src/host.ts +223 -55
  73. package/src/index.ts +3 -1
  74. package/src/paths.ts +3 -0
  75. package/src/registry.ts +3 -2
  76. package/src/roles.ts +327 -0
  77. package/src/script-tools.ts +76 -0
  78. package/src/settings.ts +224 -0
  79. package/src/trajectory.ts +97 -9
  80. package/src/types.ts +12 -5
  81. package/src/utils.ts +36 -1
  82. package/src/validation.ts +30 -356
  83. package/starter/review-loop.ts +0 -2
  84. package/subagents/README.md +3 -3
  85. package/subagents/src/contracts.ts +22 -7
  86. package/subagents/src/index.ts +18 -7
  87. package/subagents/src/manager.ts +31 -13
  88. package/subagents/src/navigator.ts +336 -180
  89. package/subagents/src/view.ts +108 -76
  90. package/trajectory/src/assets/index.html +370 -232
  91. package/trajectory/src/assets/prism.min.js +13 -0
  92. package/trajectory/src/export.ts +4 -2
  93. package/trajectory/src/index.ts +124 -47
  94. package/trajectory/src/server.ts +114 -14
@@ -102,6 +102,7 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
102
102
  RUN_NOT_FOUND: (detail) => /^Unknown workflow run\b/.test(detail) ? "The workflow run was not found." : `The workflow run was not found: ${detail}.`,
103
103
  RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
104
104
  SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
105
+ TOOL_FAILED: (detail) => `The workflow tool call failed: ${detail}.`,
105
106
  AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
106
107
  AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
107
108
  AGENT_RESULT_COLLECTED: (detail) => `The nested agent result was already collected: ${detail}.`,
@@ -6,7 +6,7 @@ import { type WorkflowRegistryApi } from "./registry.js";
6
6
  import { deepFreeze, errorText, object, resolveModelReference, validateModelAliases } from "./utils.js";
7
7
  import { saveModelAliases, resolveWorkflowSettings, workflowProjectSettingsPath, workflowSettingsPath } from "./validation.js";
8
8
  import { openWorkflowArtifact, workflowPromptArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
9
- import { agentActionLabels, agentBreadcrumb, formatCheckpointReview, formatNavigatorRun, formatWorkflowPhaseDashboard, navigatorAttentionSort, navigatorRunLabels, SETTLED_AGENT_STATES, themeWorkflowProgressStyles, visibleAgentAttemptActions as visibleRegisteredAgentAttemptActions } from "./host-view.js";
9
+ import { agentActionLabels, agentBreadcrumb, formatCheckpointReview, formatNavigatorRun, formatWorkflowPhaseDashboard, navigatorAttentionSort, navigatorRunLabels, SETTLED_AGENT_STATES, themeWorkflowProgressStyles, visibleAgentAttemptActions as visibleRegisteredAgentAttemptActions, type WorkflowProgressStyles } from "./host-view.js";
10
10
  import { buildWorkflowPhaseModel, buildWorkflowPhaseTree, navigateWorkflowPhaseTree, preserveWorkflowPhaseTreeSelection, workflowPhaseTreeInitialExpanded } from "./host-phases.js";
11
11
  import { type WorkflowRecoveryContext, type createWorkflowRecovery } from "./host-recovery.js";
12
12
  import { runDependencyIds } from "./retention.js";
@@ -115,14 +115,52 @@ function keybindingKeys(keybindings: unknown, name: string): readonly string[] |
115
115
  }
116
116
  type WorkflowKeybindings = { matches(data: string, binding: string): boolean };
117
117
  const WORKFLOW_VIM_KEYS: Readonly<Record<string, string>> = { "tui.select.up": "k", "tui.select.down": "j", "tui.editor.cursorLeft": "h", "tui.editor.cursorRight": "l" };
118
- function workflowKeyMatches(keybindings: WorkflowKeybindings, data: string, binding: string): boolean { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
119
- function workflowKeyLabel(keybindings: unknown, binding: string, fallback: string, labels: Readonly<Record<string, string>>): string {
118
+ export function workflowKeyMatches(keybindings: WorkflowKeybindings, data: string, binding: string): boolean { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
119
+ const WORKFLOW_KEY_LABELS: Readonly<Record<string, string>> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
120
+ export function workflowKeyLabel(keybindings: unknown, binding: string, fallback: string, labels: Readonly<Record<string, string>> = WORKFLOW_KEY_LABELS): string {
120
121
  const keys = keybindingKeys(keybindings, binding);
121
122
  const configured = keys?.length ? keys.map((key) => labels[key] ?? key) : [fallback];
122
123
  const vim = WORKFLOW_VIM_KEYS[binding];
123
124
  return [...new Set(vim ? [...configured, vim] : configured)].join("/");
124
125
  }
125
126
 
127
+ /**
128
+ * A yes/no question drawn inside a navigator dashboard. Pi's confirm dialog replaces a custom component and afterwards
129
+ * restores the editor rather than the component, which leaves the dashboard's promise pending and the session waiting on it.
130
+ */
131
+ export function createInlineConfirm(requestRender: () => void) {
132
+ let pending: { readonly title: string; readonly message: string; yes: boolean; readonly resolve: (value: boolean) => void } | undefined;
133
+ const answer = (value: boolean): void => {
134
+ const current = pending;
135
+ pending = undefined;
136
+ current?.resolve(value);
137
+ requestRender();
138
+ };
139
+ return {
140
+ ask(title: string, message: string): Promise<boolean> {
141
+ answer(false);
142
+ return new Promise<boolean>((resolve) => { pending = { title, message, yes: true, resolve }; requestRender(); });
143
+ },
144
+ active(): boolean { return pending !== undefined; },
145
+ rows(width: number, styles: WorkflowProgressStyles): string[] {
146
+ if (!pending) return [];
147
+ const yes = pending.yes;
148
+ const message = truncateToVisualLines(pending.message, Number.MAX_SAFE_INTEGER, Math.max(1, width), 0).visualLines.map((line) => line.trimEnd());
149
+ return [styles.bold(pending.title), ...message, ...["Yes", "No"].map((option) => (option === "Yes") === yes ? `→ ${styles.accent(option)}` : ` ${option}`)];
150
+ },
151
+ hint(keybindings: unknown): string {
152
+ return `${workflowKeyLabel(keybindings, "tui.select.up", "↑")}/${workflowKeyLabel(keybindings, "tui.select.down", "↓")} select · ${workflowKeyLabel(keybindings, "tui.select.confirm", "enter")} confirm · ${workflowKeyLabel(keybindings, "tui.select.cancel", "esc")} cancel`;
153
+ },
154
+ handleInput(keybindings: WorkflowKeybindings, data: string): void {
155
+ if (!pending) return;
156
+ if (workflowKeyMatches(keybindings, data, "tui.select.up") || workflowKeyMatches(keybindings, data, "tui.select.down")) { pending.yes = !pending.yes; requestRender(); }
157
+ else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) answer(pending.yes);
158
+ else if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) answer(false);
159
+ },
160
+ cancel(): void { answer(false); },
161
+ };
162
+ }
163
+
126
164
  export type WorkflowNavigatorDependencies = {
127
165
  pi: Pick<ExtensionAPI, "registerCommand">;
128
166
  home: string | undefined;
@@ -189,7 +227,7 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
189
227
  const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
190
228
  setStatus?.call(ctx.ui, "workflow-stop", text);
191
229
  };
192
- const runAction = async (actionCommand: string, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "stopped"> => {
230
+ const runAction = async (actionCommand: string, status: (text: string | undefined) => void = setWorkflowStatus, confirm: (title: string, message: string) => Promise<boolean> = (title, message) => confirmWithBlocked(ctx.ui, reportBlocked, title, message)): Promise<"dashboard" | "picker" | "stopped"> => {
193
231
  const [action, runId, ...rest] = actionCommand.split(/\s+/);
194
232
  try {
195
233
  const run = runId ? runs.get(runId) : undefined;
@@ -263,7 +301,7 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
263
301
  }
264
302
  if (action === "stop" && run) {
265
303
  const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
266
- if (!await confirmWithBlocked(ctx.ui, reportBlocked, "Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
304
+ if (!await confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
267
305
  status(`Stopping workflow ${workflowName}...`);
268
306
  await stopWorkflowRun(run.store.runId);
269
307
  status(`Workflow ${run.store.runId} stopped.`);
@@ -554,8 +592,13 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
554
592
  let selectedNodeId = tree.nodes[0]?.id;
555
593
  let expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
556
594
  const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
557
- const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
558
- const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
595
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback);
596
+ const inlineConfirm = createInlineConfirm(() => { if (!disposed) tui.requestRender(); });
597
+ const confirmInDashboard = async (title: string, message: string): Promise<boolean> => {
598
+ reportBlocked?.(true, title);
599
+ try { return await inlineConfirm.ask(title, message); }
600
+ finally { reportBlocked?.(false); }
601
+ };
559
602
  const progressNow = () => {
560
603
  const now = Date.now();
561
604
  if (hardTerminalRunStates.has(view.run.state)) {
@@ -629,7 +672,7 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
629
672
  stopStatus = status;
630
673
  setWorkflowStatus(status);
631
674
  if (!disposed) tui.requestRender();
632
- }).then(async (outcome) => {
675
+ }, confirmInDashboard).then(async (outcome) => {
633
676
  if (outcome === "stopped") { done("__stopped__"); return; }
634
677
  await updateDashboard();
635
678
  }).catch((error: unknown) => {
@@ -662,7 +705,8 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
662
705
  const content = [...statusLines, ...phaseLines];
663
706
  const rows = terminalRows();
664
707
  const hintRows = rows >= 3 ? 1 : 0;
665
- const viewport = Math.max(1, rows - hintRows);
708
+ const confirmRows = inlineConfirm.rows(width, styles);
709
+ const viewport = Math.max(1, rows - hintRows - confirmRows.length);
666
710
  const maxOffset = Math.max(0, content.length - viewport);
667
711
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
668
712
  if (actionMode) {
@@ -683,11 +727,12 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
683
727
  dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
684
728
  const selectedNode = selectedNodeId ? tree.byId.get(selectedNodeId) : undefined;
685
729
  const enterAction = selectedNode?.kind === "workflow" ? "run actions" : selectedNode?.kind === "agent" ? "agent actions" : selectedNode?.children.length ? "expand/collapse" : narrow ? "inspect" : "focus details";
686
- 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")} ${enterAction} · 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] ?? "";
687
- return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
730
+ const hint = truncateToVisualLines(theme.fg("dim", inlineConfirm.active() ? inlineConfirm.hint(keybindings) : 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")} ${enterAction} · 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] ?? "";
731
+ return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...confirmRows, ...(hintRows ? [hint] : [])];
688
732
  },
689
733
  invalidate() {},
690
734
  handleInput(data: string) {
735
+ if (inlineConfirm.active()) { inlineConfirm.handleInput(keybindings, data); return; }
691
736
  if (stopRequested || editorRunning) return;
692
737
  const narrow = renderedWidth < 80;
693
738
  if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
@@ -776,7 +821,7 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
776
821
  }
777
822
  tui.requestRender();
778
823
  },
779
- dispose() { disposed = true; stopTimer(); setWorkflowStatus(undefined); },
824
+ dispose() { disposed = true; stopTimer(); setWorkflowStatus(undefined); inlineConfirm.cancel(); },
780
825
  };
781
826
  })
782
827
  : await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Back"]);
@@ -816,8 +861,7 @@ export function registerWorkflowNavigator(deps: WorkflowNavigatorDependencies):
816
861
  const currentLayout = layout();
817
862
  const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
818
863
  offset = Math.min(offset, maxOffset);
819
- const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
820
- const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
864
+ const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback);
821
865
  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] ?? "";
822
866
  const controls = currentLayout.compactControls
823
867
  ? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Api, Model } from "@earendil-works/pi-ai";
3
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { type ExtensionAPI, type ExtensionToolContext } from "@earendil-works/pi-coding-agent";
4
4
  import { FairAgentScheduler, WorkflowAgentExecutor, type AgentDefinition, type AgentExecutionRoot, type AgentProviderFailure, type AgentProviderRecovery } from "./agent-execution.js";
5
5
  import { listRunIds, RunStore, structuralPath as operationPath, type PersistedRun } from "./persistence.js";
6
6
  import { budgetUsage, budgetRelaxed, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
7
7
  import { aliasDrift, createLaunchSnapshot, errorCode, errorText, jsonValue, object } from "./utils.js";
8
- import { LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError, isExternallyEndedRunState, type AgentIdentity, type AgentResourcePolicy, type BudgetApprovalRequest, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowExtensionSettings, type WorkflowMetadata, type WorkflowRetryProvenance, type WorkflowWorktreeReference } from "./types.js";
8
+ import { LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError, isExternallyEndedRunState, type AgentIdentity, type AgentResourcePolicy, type BudgetApprovalRequest, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type ToolIdentity, type WorkflowExtensionSettings, type WorkflowMetadata, type WorkflowRetryProvenance, type WorkflowWorktreeReference } from "./types.js";
9
9
  import { type WorkflowRegistryApi } from "./registry.js";
10
10
  import { RunLifecycle, WorkflowEventPublisher, hostSessionContext, withWorkflowFunctions, withoutActiveShells, workflowRunContext, type WorkflowRunRecord, type WorkflowToolUpdate } from "./host-runtime.js";
11
11
  import { runWorkflow } from "./execution.js";
@@ -13,7 +13,7 @@ import { createWorkflowFailureDiagnostics, formatWorkflowFailureDelivery, format
13
13
 
14
14
  /** The subset of Pi's model registry the host relies on; every member is optional because older or headless hosts expose fewer of them. */
15
15
  export type ModelRegistryCapability = { getAll?: () => readonly Model<Api>[]; getAvailable?: () => readonly Model<Api>[]; find?: (provider: string, model: string) => Model<Api> | undefined; refresh?: () => Promise<void>; getError?: () => string | undefined };
16
- export type WorkflowRecoveryContext = { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext; signal?: AbortSignal; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> };
16
+ export type WorkflowRecoveryContext = { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext; toolContext?: ExtensionToolContext | undefined; signal?: AbortSignal; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> };
17
17
  export type WorkflowRecoveryDependencies = {
18
18
  pi: Pick<ExtensionAPI, "getThinkingLevel">;
19
19
  home: string | undefined;
@@ -32,6 +32,7 @@ export type WorkflowRecoveryDependencies = {
32
32
  resumeRoles: (store: RunStore, snapshot: Readonly<LaunchSnapshot>, cwd: string, trustedProject: boolean) => { definitions: Readonly<Record<string, AgentDefinition>>; capture: (role: string, model: ModelSpec) => Promise<void> };
33
33
  workflowAgentHandler: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string, captureRole?: (role: string, model: ModelSpec) => Promise<void>) => (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
34
34
  shellForRun: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity) => Promise<ShellResult>;
35
+ toolForRun: (store: RunStore, lifecycle: RunLifecycle, context: ExtensionToolContext | undefined, identifier: string, args: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: ToolIdentity) => Promise<JsonValue>;
35
36
  resolveWorktree: (store: RunStore, metadata: WorkflowMetadata, owner: string) => Promise<Readonly<WorkflowWorktreeReference>>;
36
37
  checkpointBridge: (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, headless?: boolean) => (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => Promise<boolean>;
37
38
  phaseBridge: (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => (phase: string) => Promise<void>;
@@ -74,7 +75,7 @@ function assertExpectedWorkflowState(expectedState: string | undefined, actualSt
74
75
  export function persistedFailure(run: PersistedRun, error: WorkflowError): PersistedRun { const failedAt = workflowFailedAt(error); return { ...run, error: { code: error.code, message: error.message, ...(failedAt ? { failedAt } : {}) }, ...(failedAt ? { failedAt } : {}) }; }
75
76
 
76
77
  export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
77
- const { pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver, deliverTerminal, workflowToolUpdate, registry, modelSpec } = deps;
78
+ const { pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, toolForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver, deliverTerminal, workflowToolUpdate, registry, modelSpec } = deps;
78
79
  const coordinateRunMutation = deps.coordinateRunMutation ?? (<T>(task: () => Promise<T>): Promise<T> => task());
79
80
  type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun; completion?: CompletionDeliveryResult };
80
81
  const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
@@ -111,7 +112,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
111
112
  return { hasUI: host?.hasUI === true, ui };
112
113
  };
113
114
  type ColdResumeResult = { value: JsonValue; resultPath: string; resultBytes: number; completion: CompletionDeliveryResult };
114
- const coldResumeRun = async (run: WorkflowRunRecord, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: WorkflowRecoveryContext["modelRegistry"]; deliveryContext: CompletionDeliveryContext; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
115
+ const coldResumeRun = async (run: WorkflowRunRecord, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: WorkflowRecoveryContext["modelRegistry"]; deliveryContext: CompletionDeliveryContext; toolContext?: ExtensionToolContext | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
115
116
  const loaded = await run.store.load();
116
117
  const foreground = modeOverride ?? (loaded.run.delivery?.mode === "foreground" || (loaded.run.delivery?.mode === "background" && loaded.run.delivery.toolCallId !== undefined) || (loaded.run.delivery === undefined && loaded.snapshot.launchMode === "foreground"));
117
118
  if (loaded.run.activeShells !== undefined || loaded.run.activeShellStartedAt !== undefined || loaded.run.activeShellsByPhase !== undefined) await persistRunState(run.store, run.metadata, withoutActiveShells);
@@ -137,7 +138,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
137
138
  run.executor.setRunContext(runContext);
138
139
  await scheduler.cancelRun(run.store.runId);
139
140
  await run.lifecycle.resume();
140
- 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, roles.capture), 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.store, run.lifecycle, run.metadata.name) }, run.store, runContext, registry, snapshot.settings.extensionSettings), controller.signal);
141
+ const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ tool: (identifier, args, signal, identity) => toolForRun(run.store, run.lifecycle, context?.toolContext, identifier, args, signal, identity), 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, roles.capture), 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.store, run.lifecycle, run.metadata.name) }, run.store, runContext, registry, snapshot.settings.extensionSettings), controller.signal);
141
142
  run.execution = execution;
142
143
  const completion = execution.result.then(async (value) => {
143
144
  await scheduler.flush(run.store.runId);
@@ -314,7 +315,7 @@ export function createWorkflowRecovery(deps: WorkflowRecoveryDependencies) {
314
315
  await eventPublisher.runStarted(prepared.childRun.store, prepared.childRun.metadata);
315
316
  const { hasUI, ui } = recoveryUi(context);
316
317
  const recoveryContext = resumeHostContext(context);
317
- const completed = await coldResumeRun(prepared.childRun, hasUI, ui, projectTrusted(context), { model: prepared.hostModel, modelRegistry: prepared.modelRegistry, deliveryContext: recoveryContext.deliveryContext, resolvedAliases: prepared.currentAliases, blockedAliases: prepared.blockedAliases, blockedAliasTargets: prepared.blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
318
+ const completed = await coldResumeRun(prepared.childRun, hasUI, ui, projectTrusted(context), { model: prepared.hostModel, modelRegistry: prepared.modelRegistry, deliveryContext: recoveryContext.deliveryContext, toolContext: recoveryContext.toolContext, resolvedAliases: prepared.currentAliases, blockedAliases: prepared.blockedAliases, blockedAliasTargets: prepared.blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
318
319
  const completion = runs.get(prepared.childRun.store.runId)?.completion;
319
320
  if (completion) {
320
321
  childStarted = true;
package/src/host-view.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { keyHint, truncateToVisualLines, type Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { type AwaitingCheckpoint, type PersistedRun, type RunStore, type WorktreeReference } from "./persistence.js";
4
4
  import { budgetUsage } from "./budget.js";
5
5
  import { formatCost, formatTokens } from "./background-widget.js";
6
- import { BUDGET_DIMENSIONS, HARD_TERMINAL_RUN_STATES, SETTLED_AGENT_STATES, WORKFLOW_AGENT_STALL_THRESHOLD_MS, sumAccounting, type AgentAccounting, type AgentAttemptAction, type AgentAttemptActionContext, type AgentRecord, type LaunchSnapshot, type StandaloneAgentAttemptActionContext, type WorkflowCatalogFunction, type WorkflowCatalogIndex } from "./types.js";
6
+ import { BUDGET_DIMENSIONS, HARD_TERMINAL_RUN_STATES, SETTLED_AGENT_STATES, WORKFLOW_AGENT_STALL_THRESHOLD_MS, sumAccounting, zeroAccounting, type AgentAccounting, type AgentAttemptAction, type AgentAttemptActionContext, type AgentRecord, type LaunchSnapshot, type StandaloneAgentAttemptActionContext, type WorkflowCatalogFunction, type WorkflowCatalogIndex } from "./types.js";
7
7
  import { object, sanitizeDisplayText } from "./utils.js";
8
8
  import {
9
9
  WORKFLOW_PHASE_STATES,
@@ -28,7 +28,7 @@ export interface WorkflowProgressStyles {
28
28
  dim(text: string): string;
29
29
  bold(text: string): string;
30
30
  }
31
- const PLAIN_WORKFLOW_PROGRESS_STYLES: WorkflowProgressStyles = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
31
+ export const PLAIN_WORKFLOW_PROGRESS_STYLES: WorkflowProgressStyles = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
32
32
  type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
33
33
  function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
34
34
  function agentGroupLabel(agents: readonly AgentRecord[]): string {
@@ -60,7 +60,7 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
60
60
  }
61
61
  const RUN_STATE_GLYPH: Record<string, string> = { "not started": "○", queued: "○", pausing: "⏸", paused: "⏸", completed: "✓", failed: "✗", stopped: "✗", interrupted: "↯", budget_exhausted: "!", awaiting_input: "?" };
62
62
  const AGENT_STATE_GLYPH: Record<string, string> = { queued: "○", waiting_for_child: "…", paused: "⏸", retrying: "↻", completed: "✓", failed: "✗", cancelled: "✗" };
63
- function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
63
+ export function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
64
64
  function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
65
65
  type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
66
66
  const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", paused: "warning", pausing: "warning", interrupted: "warning", retrying: "accent", budget_exhausted: "warning", awaiting_input: "warning" };
@@ -70,10 +70,10 @@ function styleForState(map: Record<string, ProgressStyleKey>, state: string, sty
70
70
  const key = map[state] ?? "muted";
71
71
  return (text) => styles[key](text);
72
72
  }
73
- function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
73
+ export function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
74
74
  function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
75
75
  function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
76
- function formatWorkflowRuntime(durationMs: number): string {
76
+ export function formatWorkflowRuntime(durationMs: number): string {
77
77
  const seconds = Math.max(0, Math.floor(durationMs / 1000));
78
78
  if (seconds < 60) return `${String(seconds)}s`;
79
79
  const minutes = Math.floor(seconds / 60);
@@ -90,13 +90,22 @@ function formatShellActivity(activeShells: number | undefined, startedAt: number
90
90
  const timing = started && startedAt !== undefined && !Number.isNaN(started.getTime()) ? ` ${styles.dim(`started=${started.toISOString()} elapsed=${formatWorkflowRuntime(Math.max(0, now - startedAt))}`)}` : "";
91
91
  return `${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(count)} active)`)}${timing}`;
92
92
  }
93
+ function formatToolActivity(run: Pick<PersistedRun, "activeTools">, spinner: string, styles: WorkflowProgressStyles, now: number): string | undefined {
94
+ const calls = run.activeTools ?? [];
95
+ if (!calls.length) return undefined;
96
+ const counts = new Map<string, number>();
97
+ for (const { name } of calls) counts.set(name, (counts.get(name) ?? 0) + 1);
98
+ const names = [...counts].map(([name, count]) => count > 1 ? `${name} ×${String(count)}` : name).join(", ");
99
+ const elapsed = formatWorkflowRuntime(Math.max(0, now - Math.min(...calls.map(({ startedAt }) => startedAt))));
100
+ return `${styles.accent(spinner)} tools ${styles.accent("[running]")} ${sanitizeDisplayText(names)} ${styles.dim(`elapsed=${elapsed}`)}`;
101
+ }
93
102
  function formatLogTimestamp(timestamp: number | undefined): string {
94
103
  if (timestamp === undefined || !Number.isFinite(timestamp)) return "--:--:--";
95
104
  const date = new Date(timestamp);
96
105
  if (Number.isNaN(date.getTime())) return "--:--:--";
97
106
  return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
98
107
  }
99
- function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, expanded: boolean, width?: number, showHint = false): string[] {
108
+ function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, expanded: boolean, width?: number, showHint = false, tail = 5): string[] {
100
109
  const events = (run.events ?? []).filter((event) => event.type === "log");
101
110
  if (!events.length) return [];
102
111
  const indent = " ".repeat(11);
@@ -106,12 +115,48 @@ function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, exp
106
115
  const chunks = truncateToVisualLines(message, Number.MAX_SAFE_INTEGER, Math.max(1, width - 11), 0).visualLines;
107
116
  return chunks.map((chunk, chunkIndex) => `${chunkIndex === 0 ? prefix : indent}${chunk}`);
108
117
  }));
109
- const visible = expanded ? visualLines : visualLines.slice(-5);
118
+ const visible = expanded ? visualLines : visualLines.slice(-tail);
110
119
  let hint = "";
111
120
  if (!expanded && showHint) { try { hint = ` (${keyHint("app.tools.expand", "to expand")})`; } catch { /* Theme is unavailable in non-interactive render tests. */ } }
112
121
  return [expanded ? ` ${styles.muted("Logs")}` : ` ${styles.muted(`Logs${hint}`)}`, ...visible];
113
122
  }
114
- export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now(), expanded = false, width?: number, showHint = false): string {
123
+ const ACTIVE_AGENT_STATES: ReadonlySet<string> = new Set(["running", "waiting_for_child", "retrying", "paused"]);
124
+ const COMPACT_RECENT_SETTLED = 3;
125
+ const COMPACT_LOG_LINES = 3;
126
+ function expandHint(): string { try { return ` (${keyHint("app.tools.expand", "to expand")})`; } catch { return ""; } }
127
+ function agentEnd(agent: AgentRecord): number { return (agent.startedAt ?? 0) + (agent.durationMs ?? 0); }
128
+ /** One line for a finished phase: outcome counts, wall time across its agents, tokens, and cost. */
129
+ function phaseSummary(agents: readonly AgentRecord[], styles: WorkflowProgressStyles): string {
130
+ const count = (state: string) => agents.filter((agent) => agent.state === state).length;
131
+ const starts = agents.map((agent) => agent.startedAt).filter((value): value is number => value !== undefined);
132
+ const span = starts.length ? Math.max(...agents.map(agentEnd)) - Math.min(...starts) : 0;
133
+ const accounting = sumAccounting(agents.map((agent) => agent.accounting ?? zeroAccounting()));
134
+ const failed = count("failed");
135
+ const parts = [`${String(count("completed"))} done`, ...(failed ? [styles.error(`${String(failed)} failed`)] : []), ...(count("cancelled") ? [`${String(count("cancelled"))} cancelled`] : []), ...(span > 0 ? [formatWorkflowRuntime(span)] : []), formatTokens(accounting.input + accounting.output), formatCost(accounting.cost)].filter(Boolean);
136
+ return styles.muted(parts.join(" · "));
137
+ }
138
+ /**
139
+ * Renders the run; with `maxLines`, a view taller than that limit switches to a compact one so the live block stays on screen.
140
+ * A block taller than the terminal pushes its changing header above the viewport, and pi-tui then clears and redraws the whole
141
+ * scrollback on every update, which snaps the reader back to the bottom.
142
+ */
143
+ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now(), expanded = false, width?: number, showHint = false, maxLines?: number): string {
144
+ const full = workflowProgressLines(run, spinner, styles, now, expanded, width, showHint);
145
+ if (expanded || maxLines === undefined || full.length <= maxLines) return full.join("\n");
146
+ // Finished phases collapse to a summary line; the current one keeps failed, active, and the latest settled agents, in that priority.
147
+ const phases = run.phaseHistory?.length ? run.phaseHistory : run.phase ? [{ phase: run.phase, afterAgent: 0 }] : [];
148
+ const currentStart = Math.min(run.agents.length, phases.at(-1)?.afterAgent ?? 0);
149
+ const current = run.agents.slice(currentStart);
150
+ const recent = current.filter((agent) => SETTLED_AGENT_STATES.has(agent.state) && agent.state !== "failed").sort((left, right) => agentEnd(right) - agentEnd(left)).slice(0, COMPACT_RECENT_SETTLED);
151
+ const candidates = [...current.filter((agent) => agent.state === "failed"), ...current.filter((agent) => ACTIVE_AGENT_STATES.has(agent.state)), ...recent];
152
+ const render = (count: number) => workflowProgressLines(run, spinner, styles, now, false, width, showHint, new Set(candidates.slice(0, count)));
153
+ let low = 0;
154
+ let high = candidates.length;
155
+ while (low < high) { const middle = Math.ceil((low + high) / 2); if (render(middle).length <= maxLines) low = middle; else high = middle - 1; }
156
+ // NOTE: failures in finished phases always stay listed, so a run with more of them than maxLines still overflows.
157
+ return render(low).join("\n");
158
+ }
159
+ function workflowProgressLines(run: PersistedRun, spinner: string, styles: WorkflowProgressStyles, now: number, expanded: boolean, width: number | undefined, showHint: boolean, shown?: ReadonlySet<AgentRecord>): string[] {
115
160
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
116
161
  const workflowIcon = runStateGlyph(run.state, spinner);
117
162
  const iconStyle = workflowIconStyle(run.state, styles);
@@ -125,15 +170,19 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
125
170
  const scopedShells = (run.activeShellsByPhase?.length ?? 0) > 0;
126
171
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, spinner, styles, now);
127
172
  if (shellActivity) lines.push(` ${shellActivity}`);
173
+ const toolActivity = formatToolActivity(run, spinner, styles, now);
174
+ if (toolActivity) lines.push(` ${toolActivity}`);
128
175
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
129
- const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
130
- const icon = agentStateGlyph(agent.state, spinner);
176
+ const position = new Map(run.agents.map((agent, index) => [agent, index]));
177
+ const renderAgents = (agents: readonly AgentRecord[], nested: boolean) => renderGroupedAgents(agents, ({ agent, depth }, grouped) => {
178
+ const icon = agentStateGlyph(agent.state, "●");
131
179
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
132
- const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
180
+ const idleSpinner = agent.state === "running" && !agentActivityLabel(agent) ? styles.accent(spinner) : "";
181
+ const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : [idleSpinner, formatAgentActivity(agent, spinner, styles, now)].filter(Boolean).join(" ");
133
182
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
134
183
  const state = progressStyleForState(agent.state, styles);
135
184
  const detail = expanded ? formatWorkflowAgentDetail(agent, now) : "";
136
- return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}${detail ? ` ${detail}` : ""}`;
185
+ return `${indent}#${String((position.get(agent) ?? 0) + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}${detail ? ` ${detail}` : ""}`;
137
186
  }, run.agents, (label) => styles.muted(label)).map((line) => nested ? ` ${line}` : line);
138
187
  const phases = run.phaseHistory?.length ? run.phaseHistory : run.phase ? [{ phase: run.phase, afterAgent: 0 }] : [];
139
188
  if (scopedShells) {
@@ -146,10 +195,23 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
146
195
  }
147
196
  let renderedAgents = 0;
148
197
  let nested = false;
198
+ // In compact mode (`shown` set) earlier segments list only their failures, and the current one only the shown agents plus a count of the rest.
199
+ const segment = (agents: readonly AgentRecord[], closed: boolean) => {
200
+ if (!shown) return renderAgents(agents, nested);
201
+ const listed = agents.filter((agent) => closed ? agent.state === "failed" : shown.has(agent));
202
+ const hidden = closed ? [] : agents.filter((agent) => !shown.has(agent));
203
+ const counts = [["queued", hidden.filter((agent) => !SETTLED_AGENT_STATES.has(agent.state) && !ACTIVE_AGENT_STATES.has(agent.state)).length], ["running", hidden.filter((agent) => ACTIVE_AGENT_STATES.has(agent.state)).length], ["done", hidden.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length]] as const;
204
+ const summary = counts.filter(([, count]) => count > 0).map(([label, count]) => `+${String(count)} ${label}`).join(" · ");
205
+ return [...renderAgents(listed, nested), ...(summary ? [`${nested ? " " : " "}${styles.muted(`… ${summary}${showHint ? expandHint() : ""}`)}`] : [])];
206
+ };
149
207
  for (const [phaseIndex, phase] of phases.entries()) {
150
208
  const boundary = Math.max(renderedAgents, Math.min(run.agents.length, phase.afterAgent));
151
- lines.push(...renderAgents(run.agents.slice(renderedAgents, boundary), renderedAgents, nested));
152
- lines.push(` ${styles.muted(`[Phase: ${phase.phase}]`)}`);
209
+ const before = run.agents.slice(renderedAgents, boundary);
210
+ if (shown && !nested && before.length) lines.push(` ${phaseSummary(before, styles)}`);
211
+ lines.push(...segment(before, true));
212
+ const next = phases[phaseIndex + 1];
213
+ const phaseAgents = next ? run.agents.slice(boundary, Math.max(boundary, Math.min(run.agents.length, next.afterAgent))) : [];
214
+ lines.push(` ${styles.muted(`[Phase: ${phase.phase}]`)}${shown && next ? ` ${phaseSummary(phaseAgents, styles)}` : ""}`);
153
215
  const phaseShell = scopedShells ? shellActivityFor(run, phaseIndex) : undefined;
154
216
  if (phaseShell) {
155
217
  const rendered = formatShellActivity(phaseShell.active, phaseShell.startedAt, spinner, styles, now);
@@ -158,9 +220,9 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
158
220
  renderedAgents = boundary;
159
221
  nested = true;
160
222
  }
161
- lines.push(...renderAgents(run.agents.slice(renderedAgents), renderedAgents, nested));
162
- lines.push(...workflowLogLines(run, styles, expanded, width, showHint));
163
- return lines.join("\n");
223
+ lines.push(...segment(run.agents.slice(renderedAgents), false));
224
+ lines.push(...workflowLogLines(run, styles, expanded, width, showHint, shown ? COMPACT_LOG_LINES : 5));
225
+ return lines;
164
226
  }
165
227
 
166
228
  const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -408,14 +470,18 @@ export function workflowProgressBlock(run: PersistedRun, theme: Theme, progress?
408
470
  }
409
471
  previousState = displayed.state;
410
472
  const frame = displayed.state === "running" ? workflowSpinner[Math.floor(now / 80) % workflowSpinner.length] ?? "◇" : "◇";
411
- const progressText = formatWorkflowProgress(displayed, frame, styles, now, expanded, width, true);
473
+ // pi-tui only patches rows that are still on screen; leave room below the block for the editor and footer.
474
+ // A finished or frozen block no longer changes, so it keeps the full list.
475
+ const rows = freezeAt === undefined && !terminal ? process.stdout.rows : undefined;
476
+ const maxLines = rows ? Math.max(6, rows - 10) - (prefix ? prefix.split("\n").length + 1 : 0) : undefined;
477
+ const progressText = formatWorkflowProgress(displayed, frame, styles, now, expanded, width, true, maxLines);
412
478
  return truncateWorkflowProgress(prefix ? `${prefix}\n\n${progressText}` : progressText, width);
413
479
  },
414
480
  setExpanded(value: boolean) { expanded = value; },
415
481
  invalidate() {
416
482
  const displayed = currentRun();
417
483
  const now = Date.now();
418
- if (!progress || !refresh || displayed.state !== "running" || (!displayed.agents.some((agent) => agent.state === "running") && (displayed.activeShells ?? 0) <= 0)) return;
484
+ if (!progress || !refresh || displayed.state !== "running" || (!displayed.agents.some((agent) => agent.state === "running") && (displayed.activeShells ?? 0) <= 0 && !displayed.activeTools?.length)) return;
419
485
  if (progress.refresh || now - progress.lastRefreshAt < WORKFLOW_PROGRESS_REFRESH_MS) return;
420
486
  progress.lastRefreshAt = now;
421
487
  const inputRun = progress.inputRun;
@@ -519,7 +585,7 @@ export function formatStalledDuration(durationMs: number): string {
519
585
  const remainingMinutes = minutes % 60;
520
586
  return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
521
587
  }
522
- function stalledDuration(agent: Pick<AgentDetailPresentation, "state" | "lastEventAt">, now: number): number | undefined {
588
+ export function stalledDuration(agent: Pick<AgentDetailPresentation, "state" | "lastEventAt">, now: number): number | undefined {
523
589
  if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt)) return undefined;
524
590
  const duration = now - agent.lastEventAt;
525
591
  return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
@@ -529,7 +595,7 @@ function elapsedDurationMs(agent: Pick<AgentDetailPresentation, "durationMs" | "
529
595
  if (agent.durationMs !== undefined && Number.isFinite(agent.durationMs)) return Math.max(0, agent.durationMs);
530
596
  return agent.startedAt === undefined || !Number.isFinite(agent.startedAt) ? undefined : Math.max(0, end - agent.startedAt);
531
597
  }
532
- function agentActivityLabel(agent: { readonly activity?: AgentRecord["activity"]; readonly toolCalls?: AgentRecord["toolCalls"] }): string {
598
+ export function agentActivityLabel(agent: { readonly activity?: AgentRecord["activity"]; readonly toolCalls?: AgentRecord["toolCalls"] }): string {
533
599
  const activity = agent.activity;
534
600
  if (activity?.kind === "reasoning" || activity?.kind === "text") {
535
601
  const name = activity.kind === "reasoning" ? "reasoning" : "responding";
@@ -582,7 +648,7 @@ export interface AgentDetailPresentation {
582
648
  readonly accounting?: AgentAccounting;
583
649
  readonly error?: { readonly code: string; readonly message: string };
584
650
  }
585
- function formatAgentError(error: NonNullable<AgentDetailPresentation["error"]>, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
651
+ export function formatAgentError(error: NonNullable<AgentDetailPresentation["error"]>, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
586
652
  return styles.error(`Error: ${error.code}: ${error.message}`);
587
653
  }
588
654
 
@@ -661,6 +727,8 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
661
727
  const lines = [header, meta, ...formatCompactBudgetStatus(run)];
662
728
  const shellActivity = formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
663
729
  if (shellActivity) lines.push(` ${shellActivity}`);
730
+ const toolActivity = formatToolActivity(run, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
731
+ if (toolActivity) lines.push(` ${toolActivity}`);
664
732
  if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
665
733
  if (run.events?.length) lines.push(...run.events.filter((event) => event.type === "warning").map((event) => `Warning: ${event.message}`));
666
734
  lines.push("");
@@ -698,6 +766,8 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
698
766
  ];
699
767
  const shellActivity = formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
700
768
  if (shellActivity) lines.push(` ${shellActivity}`);
769
+ const toolActivity = formatToolActivity(run, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now);
770
+ if (toolActivity) lines.push(` ${toolActivity}`);
701
771
  if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
702
772
  if (run.events?.length) lines.push(...run.events.filter((event) => event.type === "warning").map((event) => `Warning: ${event.message}`));
703
773
  const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
@@ -731,11 +801,7 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
731
801
  const model = buildWorkflowPhaseModel(run, snapshot);
732
802
  const tree = buildWorkflowPhaseTree(model);
733
803
  const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
734
- const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
735
- // ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
736
- const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
737
- const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
738
- const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
804
+ const wrap = (text: string): string[] => wrapNavigatorLine(text, safeWidth);
739
805
  const phaseStyle = (state: string): ((text: string) => string) => phaseStyleForState(state, styles);
740
806
  const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
741
807
  const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
@@ -799,28 +865,36 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
799
865
  const scopedShells = (run.activeShellsByPhase?.length ?? 0) > 0;
800
866
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", styles, now);
801
867
  if (shellActivity) lines.push(` ${shellActivity}`);
802
- const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
868
+ const toolActivity = formatToolActivity(run, "⠦", styles, now);
869
+ if (toolActivity) lines.push(` ${toolActivity}`);
803
870
  const actionRows = (): string[] => {
804
871
  const actions = selection.actions;
805
872
  if (!actions) return [];
806
873
  return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
807
874
  };
808
- const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
875
+ const treeRows = [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.map(treeLine) : [styles.muted("(empty)")])];
876
+ lines.push(...formatNavigatorColumns(treeRows, [...details(selectedNode), ...actionRows()], safeWidth, selection));
877
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
878
+ return lines.flatMap((line) => wrap(line));
879
+ }
880
+ function wrapNavigatorLine(text: string, width: number): string[] {
881
+ return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, width), 0).visualLines.map((line) => line.trimEnd());
882
+ }
883
+ /** The navigator layout: a sidebar beside details at 80 columns or wider, otherwise one of them (or both stacked). */
884
+ export function formatNavigatorColumns(sidebar: readonly string[], detail: readonly string[], width: number, layout: Readonly<{ treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined }> = {}): string[] {
885
+ const safeWidth = Math.max(1, width);
809
886
  if (safeWidth >= 80) {
810
887
  const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
811
- const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
812
- const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
813
- const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
814
- const rows = Math.max(sidebar.length, detail.length);
815
- for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
816
- } else if (selection.detailsOnly) {
817
- lines.push(...detailRows().flatMap((line) => wrap(line)));
818
- } else {
819
- lines.push(...renderTree(safeWidth));
820
- if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
888
+ const left = sidebar.flatMap((line) => wrapNavigatorLine(line, sidebarWidth));
889
+ const right = detail.flatMap((line) => wrapNavigatorLine(line, safeWidth - sidebarWidth - 3));
890
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => {
891
+ const cell = left[index] ?? "";
892
+ return `${cell}${" ".repeat(Math.max(0, sidebarWidth - visibleWidth(cell)))} | ${right[index] ?? ""}`;
893
+ });
821
894
  }
822
- if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
823
- return lines.flatMap((line) => wrap(line));
895
+ if (layout.detailsOnly) return detail.flatMap((line) => wrapNavigatorLine(line, safeWidth));
896
+ const left = sidebar.flatMap((line) => wrapNavigatorLine(line, safeWidth));
897
+ return layout.treeOnly ? left : [...left, ...detail.flatMap((line) => wrapNavigatorLine(line, safeWidth))];
824
898
  }
825
899
  export function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
826
900
  const context = JSON.stringify(checkpoint.context, null, 2);