@marcoscale98/pi-extensible-workflows 5.14.1-fork.1 → 5.17.0-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 (71) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/src/agent-execution.js +36 -51
  3. package/dist/src/background-widget.d.ts +56 -0
  4. package/dist/src/background-widget.js +22 -15
  5. package/dist/src/decoders.js +2 -1
  6. package/dist/src/host-navigator.d.ts +18 -0
  7. package/dist/src/host-navigator.js +71 -13
  8. package/dist/src/host-view.d.ts +16 -1
  9. package/dist/src/host-view.js +109 -42
  10. package/dist/src/host.js +57 -24
  11. package/dist/src/index.d.ts +3 -1
  12. package/dist/src/index.js +867 -545
  13. package/dist/src/index.js.map +3 -3
  14. package/dist/src/registry.js +2 -1
  15. package/dist/src/roles.d.ts +70 -0
  16. package/dist/src/roles.js +325 -0
  17. package/dist/src/settings.d.ts +13 -0
  18. package/dist/src/settings.js +253 -0
  19. package/dist/src/trajectory.d.ts +29 -4
  20. package/dist/src/trajectory.js +58 -3
  21. package/dist/src/types.d.ts +0 -1
  22. package/dist/src/utils.d.ts +2 -1
  23. package/dist/src/utils.js +10 -0
  24. package/dist/src/validation.d.ts +5 -17
  25. package/dist/src/validation.js +9 -458
  26. package/dist/subagents/index.js +1132 -694
  27. package/dist/subagents/index.js.map +3 -3
  28. package/dist/subagents/src/contracts.d.ts +4 -0
  29. package/dist/subagents/src/index.d.ts +1 -1
  30. package/dist/subagents/src/index.js +2 -1
  31. package/dist/subagents/src/manager.js +29 -10
  32. package/dist/subagents/src/navigator.js +396 -247
  33. package/dist/subagents/src/view.d.ts +23 -2
  34. package/dist/subagents/src/view.js +96 -30
  35. package/dist/trajectory/assets/index.html +347 -231
  36. package/dist/trajectory/assets/prism.min.js +13 -0
  37. package/dist/trajectory/index.js +377 -257
  38. package/dist/trajectory/index.js.map +3 -3
  39. package/dist/trajectory/src/assets/index.html +347 -231
  40. package/dist/trajectory/src/assets/prism.min.js +13 -0
  41. package/dist/trajectory/src/export.js +4 -2
  42. package/dist/trajectory/src/index.d.ts +2 -0
  43. package/dist/trajectory/src/index.js +143 -42
  44. package/dist/trajectory/src/server.js +134 -9
  45. package/dist/trajectory/src/server.js.map +1 -1
  46. package/package.json +2 -1
  47. package/src/agent-execution.ts +36 -42
  48. package/src/background-widget.ts +25 -18
  49. package/src/decoders.ts +2 -1
  50. package/src/host-navigator.ts +58 -14
  51. package/src/host-view.ts +93 -37
  52. package/src/host.ts +51 -21
  53. package/src/index.ts +3 -1
  54. package/src/registry.ts +2 -1
  55. package/src/roles.ts +326 -0
  56. package/src/settings.ts +206 -0
  57. package/src/trajectory.ts +76 -7
  58. package/src/types.ts +1 -1
  59. package/src/utils.ts +8 -1
  60. package/src/validation.ts +12 -356
  61. package/subagents/README.md +1 -1
  62. package/subagents/src/contracts.ts +4 -0
  63. package/subagents/src/index.ts +3 -2
  64. package/subagents/src/manager.ts +28 -10
  65. package/subagents/src/navigator.ts +336 -180
  66. package/subagents/src/view.ts +97 -28
  67. package/trajectory/src/assets/index.html +347 -231
  68. package/trajectory/src/assets/prism.min.js +13 -0
  69. package/trajectory/src/export.ts +4 -2
  70. package/trajectory/src/index.ts +118 -38
  71. package/trajectory/src/server.ts +112 -12
@@ -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(" ")]
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);
@@ -96,7 +96,7 @@ function formatLogTimestamp(timestamp: number | undefined): string {
96
96
  if (Number.isNaN(date.getTime())) return "--:--:--";
97
97
  return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
98
98
  }
99
- function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, expanded: boolean, width?: number, showHint = false): string[] {
99
+ function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, expanded: boolean, width?: number, showHint = false, tail = 5): string[] {
100
100
  const events = (run.events ?? []).filter((event) => event.type === "log");
101
101
  if (!events.length) return [];
102
102
  const indent = " ".repeat(11);
@@ -106,12 +106,48 @@ function workflowLogLines(run: PersistedRun, styles: WorkflowProgressStyles, exp
106
106
  const chunks = truncateToVisualLines(message, Number.MAX_SAFE_INTEGER, Math.max(1, width - 11), 0).visualLines;
107
107
  return chunks.map((chunk, chunkIndex) => `${chunkIndex === 0 ? prefix : indent}${chunk}`);
108
108
  }));
109
- const visible = expanded ? visualLines : visualLines.slice(-5);
109
+ const visible = expanded ? visualLines : visualLines.slice(-tail);
110
110
  let hint = "";
111
111
  if (!expanded && showHint) { try { hint = ` (${keyHint("app.tools.expand", "to expand")})`; } catch { /* Theme is unavailable in non-interactive render tests. */ } }
112
112
  return [expanded ? ` ${styles.muted("Logs")}` : ` ${styles.muted(`Logs${hint}`)}`, ...visible];
113
113
  }
114
- export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now(), expanded = false, width?: number, showHint = false): string {
114
+ const ACTIVE_AGENT_STATES: ReadonlySet<string> = new Set(["running", "waiting_for_child", "retrying", "paused"]);
115
+ const COMPACT_RECENT_SETTLED = 3;
116
+ const COMPACT_LOG_LINES = 3;
117
+ function expandHint(): string { try { return ` (${keyHint("app.tools.expand", "to expand")})`; } catch { return ""; } }
118
+ function agentEnd(agent: AgentRecord): number { return (agent.startedAt ?? 0) + (agent.durationMs ?? 0); }
119
+ /** One line for a finished phase: outcome counts, wall time across its agents, tokens, and cost. */
120
+ function phaseSummary(agents: readonly AgentRecord[], styles: WorkflowProgressStyles): string {
121
+ const count = (state: string) => agents.filter((agent) => agent.state === state).length;
122
+ const starts = agents.map((agent) => agent.startedAt).filter((value): value is number => value !== undefined);
123
+ const span = starts.length ? Math.max(...agents.map(agentEnd)) - Math.min(...starts) : 0;
124
+ const accounting = sumAccounting(agents.map((agent) => agent.accounting ?? zeroAccounting()));
125
+ const failed = count("failed");
126
+ 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);
127
+ return styles.muted(parts.join(" · "));
128
+ }
129
+ /**
130
+ * Renders the run; with `maxLines`, a view taller than that limit switches to a compact one so the live block stays on screen.
131
+ * A block taller than the terminal pushes its changing header above the viewport, and pi-tui then clears and redraws the whole
132
+ * scrollback on every update, which snaps the reader back to the bottom.
133
+ */
134
+ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now(), expanded = false, width?: number, showHint = false, maxLines?: number): string {
135
+ const full = workflowProgressLines(run, spinner, styles, now, expanded, width, showHint);
136
+ if (expanded || maxLines === undefined || full.length <= maxLines) return full.join("\n");
137
+ // Finished phases collapse to a summary line; the current one keeps failed, active, and the latest settled agents, in that priority.
138
+ const phases = run.phaseHistory?.length ? run.phaseHistory : run.phase ? [{ phase: run.phase, afterAgent: 0 }] : [];
139
+ const currentStart = Math.min(run.agents.length, phases.at(-1)?.afterAgent ?? 0);
140
+ const current = run.agents.slice(currentStart);
141
+ 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);
142
+ const candidates = [...current.filter((agent) => agent.state === "failed"), ...current.filter((agent) => ACTIVE_AGENT_STATES.has(agent.state)), ...recent];
143
+ const render = (count: number) => workflowProgressLines(run, spinner, styles, now, false, width, showHint, new Set(candidates.slice(0, count)));
144
+ let low = 0;
145
+ let high = candidates.length;
146
+ while (low < high) { const middle = Math.ceil((low + high) / 2); if (render(middle).length <= maxLines) low = middle; else high = middle - 1; }
147
+ // NOTE: failures in finished phases always stay listed, so a run with more of them than maxLines still overflows.
148
+ return render(low).join("\n");
149
+ }
150
+ function workflowProgressLines(run: PersistedRun, spinner: string, styles: WorkflowProgressStyles, now: number, expanded: boolean, width: number | undefined, showHint: boolean, shown?: ReadonlySet<AgentRecord>): string[] {
115
151
  const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
116
152
  const workflowIcon = runStateGlyph(run.state, spinner);
117
153
  const iconStyle = workflowIconStyle(run.state, styles);
@@ -126,14 +162,15 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
126
162
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, spinner, styles, now);
127
163
  if (shellActivity) lines.push(` ${shellActivity}`);
128
164
  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) => {
165
+ const position = new Map(run.agents.map((agent, index) => [agent, index]));
166
+ const renderAgents = (agents: readonly AgentRecord[], nested: boolean) => renderGroupedAgents(agents, ({ agent, depth }, grouped) => {
130
167
  const icon = agentStateGlyph(agent.state, spinner);
131
168
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
132
169
  const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
133
170
  const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
134
171
  const state = progressStyleForState(agent.state, styles);
135
172
  const detail = expanded ? formatWorkflowAgentDetail(agent, now) : "";
136
- return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}${detail ? ` ${detail}` : ""}`;
173
+ return `${indent}#${String((position.get(agent) ?? 0) + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}${detail ? ` ${detail}` : ""}`;
137
174
  }, run.agents, (label) => styles.muted(label)).map((line) => nested ? ` ${line}` : line);
138
175
  const phases = run.phaseHistory?.length ? run.phaseHistory : run.phase ? [{ phase: run.phase, afterAgent: 0 }] : [];
139
176
  if (scopedShells) {
@@ -146,10 +183,23 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
146
183
  }
147
184
  let renderedAgents = 0;
148
185
  let nested = false;
186
+ // 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.
187
+ const segment = (agents: readonly AgentRecord[], closed: boolean) => {
188
+ if (!shown) return renderAgents(agents, nested);
189
+ const listed = agents.filter((agent) => closed ? agent.state === "failed" : shown.has(agent));
190
+ const hidden = closed ? [] : agents.filter((agent) => !shown.has(agent));
191
+ 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;
192
+ const summary = counts.filter(([, count]) => count > 0).map(([label, count]) => `+${String(count)} ${label}`).join(" · ");
193
+ return [...renderAgents(listed, nested), ...(summary ? [`${nested ? " " : " "}${styles.muted(`… ${summary}${showHint ? expandHint() : ""}`)}`] : [])];
194
+ };
149
195
  for (const [phaseIndex, phase] of phases.entries()) {
150
196
  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}]`)}`);
197
+ const before = run.agents.slice(renderedAgents, boundary);
198
+ if (shown && !nested && before.length) lines.push(` ${phaseSummary(before, styles)}`);
199
+ lines.push(...segment(before, true));
200
+ const next = phases[phaseIndex + 1];
201
+ const phaseAgents = next ? run.agents.slice(boundary, Math.max(boundary, Math.min(run.agents.length, next.afterAgent))) : [];
202
+ lines.push(` ${styles.muted(`[Phase: ${phase.phase}]`)}${shown && next ? ` ${phaseSummary(phaseAgents, styles)}` : ""}`);
153
203
  const phaseShell = scopedShells ? shellActivityFor(run, phaseIndex) : undefined;
154
204
  if (phaseShell) {
155
205
  const rendered = formatShellActivity(phaseShell.active, phaseShell.startedAt, spinner, styles, now);
@@ -158,9 +208,9 @@ export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", style
158
208
  renderedAgents = boundary;
159
209
  nested = true;
160
210
  }
161
- lines.push(...renderAgents(run.agents.slice(renderedAgents), renderedAgents, nested));
162
- lines.push(...workflowLogLines(run, styles, expanded, width, showHint));
163
- return lines.join("\n");
211
+ lines.push(...segment(run.agents.slice(renderedAgents), false));
212
+ lines.push(...workflowLogLines(run, styles, expanded, width, showHint, shown ? COMPACT_LOG_LINES : 5));
213
+ return lines;
164
214
  }
165
215
 
166
216
  const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -408,7 +458,11 @@ export function workflowProgressBlock(run: PersistedRun, theme: Theme, progress?
408
458
  }
409
459
  previousState = displayed.state;
410
460
  const frame = displayed.state === "running" ? workflowSpinner[Math.floor(now / 80) % workflowSpinner.length] ?? "◇" : "◇";
411
- const progressText = formatWorkflowProgress(displayed, frame, styles, now, expanded, width, true);
461
+ // pi-tui only patches rows that are still on screen; leave room below the block for the editor and footer.
462
+ // A finished or frozen block no longer changes, so it keeps the full list.
463
+ const rows = freezeAt === undefined && !terminal ? process.stdout.rows : undefined;
464
+ const maxLines = rows ? Math.max(6, rows - 10) - (prefix ? prefix.split("\n").length + 1 : 0) : undefined;
465
+ const progressText = formatWorkflowProgress(displayed, frame, styles, now, expanded, width, true, maxLines);
412
466
  return truncateWorkflowProgress(prefix ? `${prefix}\n\n${progressText}` : progressText, width);
413
467
  },
414
468
  setExpanded(value: boolean) { expanded = value; },
@@ -582,7 +636,7 @@ export interface AgentDetailPresentation {
582
636
  readonly accounting?: AgentAccounting;
583
637
  readonly error?: { readonly code: string; readonly message: string };
584
638
  }
585
- function formatAgentError(error: NonNullable<AgentDetailPresentation["error"]>, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
639
+ export function formatAgentError(error: NonNullable<AgentDetailPresentation["error"]>, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
586
640
  return styles.error(`Error: ${error.code}: ${error.message}`);
587
641
  }
588
642
 
@@ -731,11 +785,7 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
731
785
  const model = buildWorkflowPhaseModel(run, snapshot);
732
786
  const tree = buildWorkflowPhaseTree(model);
733
787
  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)))}`;
788
+ const wrap = (text: string): string[] => wrapNavigatorLine(text, safeWidth);
739
789
  const phaseStyle = (state: string): ((text: string) => string) => phaseStyleForState(state, styles);
740
790
  const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
741
791
  const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
@@ -799,28 +849,34 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
799
849
  const scopedShells = (run.activeShellsByPhase?.length ?? 0) > 0;
800
850
  const shellActivity = scopedShells ? undefined : formatShellActivity(run.activeShells, run.activeShellStartedAt, "⠦", styles, now);
801
851
  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)")])];
803
852
  const actionRows = (): string[] => {
804
853
  const actions = selection.actions;
805
854
  if (!actions) return [];
806
855
  return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
807
856
  };
808
- const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
857
+ const treeRows = [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.map(treeLine) : [styles.muted("(empty)")])];
858
+ lines.push(...formatNavigatorColumns(treeRows, [...details(selectedNode), ...actionRows()], safeWidth, selection));
859
+ if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
860
+ return lines.flatMap((line) => wrap(line));
861
+ }
862
+ function wrapNavigatorLine(text: string, width: number): string[] {
863
+ return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, width), 0).visualLines.map((line) => line.trimEnd());
864
+ }
865
+ /** The navigator layout: a sidebar beside details at 80 columns or wider, otherwise one of them (or both stacked). */
866
+ export function formatNavigatorColumns(sidebar: readonly string[], detail: readonly string[], width: number, layout: Readonly<{ treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined }> = {}): string[] {
867
+ const safeWidth = Math.max(1, width);
809
868
  if (safeWidth >= 80) {
810
869
  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)));
870
+ const left = sidebar.flatMap((line) => wrapNavigatorLine(line, sidebarWidth));
871
+ const right = detail.flatMap((line) => wrapNavigatorLine(line, safeWidth - sidebarWidth - 3));
872
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => {
873
+ const cell = left[index] ?? "";
874
+ return `${cell}${" ".repeat(Math.max(0, sidebarWidth - visibleWidth(cell)))} | ${right[index] ?? ""}`;
875
+ });
821
876
  }
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));
877
+ if (layout.detailsOnly) return detail.flatMap((line) => wrapNavigatorLine(line, safeWidth));
878
+ const left = sidebar.flatMap((line) => wrapNavigatorLine(line, safeWidth));
879
+ return layout.treeOnly ? left : [...left, ...detail.flatMap((line) => wrapNavigatorLine(line, safeWidth))];
824
880
  }
825
881
  export function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
826
882
  const context = JSON.stringify(checkpoint.context, null, 2);
package/src/host.ts CHANGED
@@ -14,7 +14,8 @@ import { retainTerminalRuns } from "./retention.js";
14
14
  import type { PersistedRun, WorktreeReference } from "./persistence.js";
15
15
  import { validateBudget, WorkflowBudgetRuntime } from "./budget.js";
16
16
  import { SerialLane, asWorkflowError, createLaunchSnapshot, errorCode, errorText, fail, isNodeError, jsonValue, mergeWorkflowExtensionSettings, modelAliasErrorName, modelCapability, object, parseModelReference, positiveInteger, sanitizeDisplayText, validateModelAliases } from "./utils.js";
17
- import { loadAgentDefinitions, loadProjectAgentDefinitions, loadSettings, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, validateCheckpoint, validateModelAliasAvailability, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowSettingsPath } from "./validation.js";
17
+ import { loadSettings, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, validateCheckpoint, validateModelAliasAvailability, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowSettingsPath } from "./validation.js";
18
+ import { loadAgentDefinitions, loadProjectAgentDefinitions } from "./roles.js";
18
19
  import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistryIfIdle, retainWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
19
20
  import { agentHandleTurnPath, agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
20
21
  import backgroundWidget, { type BackgroundWidgetAPI } from "./background-widget.js";
@@ -34,7 +35,6 @@ import {
34
35
  workflowControlCall,
35
36
  workflowControlResult,
36
37
  workflowProgressBlock,
37
- formatWorkflowProgress,
38
38
  type WorkflowProgressRenderState,
39
39
  } from "./host-view.js";
40
40
  import {
@@ -176,7 +176,9 @@ export const WORKFLOW_STATUS_PARAMETERS = Type.Object({ runId: Type.String({ des
176
176
  export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }), expectedState: Type.Optional(Type.String({ description: "Persisted source state observed before recovery" })), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) });
177
177
 
178
178
  function workflowToolUpdate(run: PersistedRun): WorkflowToolUpdate {
179
- return { content: [{ type: "text", text: formatWorkflowProgress(run) }], details: { runId: run.id, run } };
179
+ //NOTE: renderers read details.run; the partial text stays O(1) so high-frequency updates never format the whole tree.
180
+ const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
181
+ return { content: [{ type: "text", text: `Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done) [${run.state}]` }], details: { runId: run.id, run } };
180
182
  }
181
183
  function agentWithProgress(agent: AgentRecord, progress: AgentProgress): AgentRecord {
182
184
  const next = { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls };
@@ -200,6 +202,7 @@ function deliver(pi: WorkflowExtensionAPI, content: string): void {
200
202
  pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
201
203
  }
202
204
  const WORKFLOW_WARNING_ENTRY = "workflow-warning";
205
+ const PROGRESS_COALESCE_MS = 100;
203
206
  interface WorkflowWarningEntry { message: string }
204
207
  function deliverWarning(pi: WorkflowExtensionAPI, content: string): void {
205
208
  pi.appendEntry<WorkflowWarningEntry>(WORKFLOW_WARNING_ENTRY, { message: content });
@@ -415,7 +418,6 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
415
418
  const trusted = projectTrusted(context);
416
419
  const settings = resolveWorkflowSettings(cwd, trusted, workflowSettingsPath(extensionAgentDir)).effective.extensionSettings?.trajectory;
417
420
  const port = settings?.port;
418
- const themes = settings?.themes ?? false;
419
421
  const loadRuns = createTrajectoryRunLoader(cwd, sessionId, home, overlayLiveRun);
420
422
  const loadSubagents = createTrajectorySubagentLoader(cwd, sessionId, extensionAgentDir, overlayLiveSubagent);
421
423
  const loadRunsMetadata = createTrajectoryRunMetadataLoader(cwd, sessionId, home, overlayLiveRun);
@@ -424,7 +426,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
424
426
  const live = liveSubagents.get(subagentId);
425
427
  return live?.status.sessionId === sessionId ? live.status.attemptDetails?.at(-1)?.session : undefined;
426
428
  });
427
- return { cwd, sessionId, ...(port === undefined ? {} : { port }), themes, loadRuns, loadSubagents, loadMetadata: async () => ({ runs: await loadRunsMetadata(), subagents: await loadSubagentsMetadata() }), loadTranscript, handleAction: (request: Readonly<TrajectoryActionRequest>) => trajectoryAction(request, context) };
429
+ return { cwd, sessionId, ...(port === undefined ? {} : { port }), loadRuns, loadSubagents, loadMetadata: async () => ({ runs: await loadRunsMetadata(), subagents: await loadSubagentsMetadata() }), loadTranscript, handleAction: (request: Readonly<TrajectoryActionRequest>) => trajectoryAction(request, context) };
428
430
  };
429
431
  const withLiveActivities = (run: PersistedRun): PersistedRun => liveAgents.overlay(run);
430
432
  /** Overlays the in-memory lifecycle state and budget usage of an active run onto its persisted record. */
@@ -586,22 +588,49 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
586
588
  const scheduler = new FairAgentScheduler(async ({ id, runId, tuiIndex, parentId, prompt, options, signal, setSteer }) => {
587
589
  const run = runs.get(runId);
588
590
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
591
+ const applyProgress = async (progress: AgentProgress) => {
592
+ let runState: PersistedRun;
593
+ if (progress.persist) {
594
+ 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 ? agentWithProgress(agent, progress) : agent) } : current);
595
+ } else {
596
+ const loaded = await run.store.loadStatus();
597
+ if (!loaded.agents.some((agent) => agent.id === id)) return;
598
+ runState = { ...loaded, ...run.budget.snapshot(), agents: loaded.agents.map((agent) => agent.id === id ? agentWithProgress(agent, progress) : agent) };
599
+ }
600
+ if (!runState.agents.some((agent) => agent.id === id)) return;
601
+ liveAgents.setActivity(runId, id, progress.activity);
602
+ liveAgents.setEventTime(runId, id, progress.lastEventAt);
603
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
604
+ };
605
+ // Display-only progress bursts coalesce to one trailing apply per window; persisted progress always applies immediately.
606
+ let deferred: AgentProgress | undefined;
607
+ let deferredTimer: NodeJS.Timeout | undefined;
608
+ let appliedAt = 0;
609
+ const cancelDeferredProgress = () => {
610
+ if (deferredTimer) clearTimeout(deferredTimer);
611
+ deferredTimer = undefined;
612
+ deferred = undefined;
613
+ };
614
+ const onProgress = async (progress: AgentProgress) => {
615
+ const now = Date.now();
616
+ if (progress.persist || (!deferredTimer && now - appliedAt >= PROGRESS_COALESCE_MS)) {
617
+ cancelDeferredProgress();
618
+ appliedAt = now;
619
+ await applyProgress(progress);
620
+ return;
621
+ }
622
+ deferred = progress;
623
+ deferredTimer ??= setTimeout(() => {
624
+ deferredTimer = undefined;
625
+ const next = deferred;
626
+ deferred = undefined;
627
+ if (!next) return;
628
+ appliedAt = Date.now();
629
+ void applyProgress(next).catch(() => undefined);
630
+ }, Math.max(0, PROGRESS_COALESCE_MS - (now - appliedAt))).unref();
631
+ };
589
632
  try {
590
633
  const budget = run.budget.forAgent(id);
591
- const onProgress = async (progress: AgentProgress) => {
592
- let runState: PersistedRun;
593
- if (progress.persist) {
594
- 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 ? agentWithProgress(agent, progress) : agent) } : current);
595
- } else {
596
- const loaded = await run.store.load();
597
- if (!loaded.run.agents.some((agent) => agent.id === id)) return;
598
- runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? agentWithProgress(agent, progress) : agent) };
599
- }
600
- if (!runState.agents.some((agent) => agent.id === id)) return;
601
- liveAgents.setActivity(runId, id, progress.activity);
602
- liveAgents.setEventTime(runId, id, progress.lastEventAt);
603
- run.update?.(workflowToolUpdate(withLiveActivities(runState)));
604
- };
605
634
  const onAttempt = async (attempt: AgentAttempt) => {
606
635
  liveAgents.setSession(runId, id, attempt.liveSession);
607
636
  liveAgents.setHandoff(runId, id, attempt);
@@ -618,6 +647,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
618
647
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
619
648
  };
620
649
  const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, tuiIndex, tuiLabel: options.requestedLabel ?? options.label, agentNodeId: id, ...(options.extensionSettings === undefined ? {} : { inheritedExtensionSettings: options.extensionSettings }), 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.role ? { role: options.role } : {}), ...(options.contextFiles ? { contextFiles: options.contextFiles } : {}), tools: options.tools, ...(options.skills ? { skills: options.skills } : {}), ...(options.extensions ? { extensions: options.extensions } : {}), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.sessionPath ? { sessionPath: options.sessionPath } : {}), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, skills, extensions) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}), ...(skills !== undefined ? { skills } : {}), ...(extensions !== undefined ? { extensions } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
650
+ cancelDeferredProgress();
621
651
  const before = (await run.store.load()).run;
622
652
  await persistAgentAttempts(run.store, id, result.attempts);
623
653
  const completed = (await run.store.load()).run;
@@ -628,6 +658,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
628
658
  run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
629
659
  return result.value;
630
660
  } catch (error) {
661
+ cancelDeferredProgress();
631
662
  liveAgents.setSession(runId, id);
632
663
  const attempts = getAgentAttempts(error);
633
664
  if (attempts?.length) {
@@ -1365,8 +1396,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
1365
1396
  state.workflowProgressComponent = workflowProgressBlock(currentProgress?.run ?? incoming, theme, currentProgress, async () => {
1366
1397
  const active = runs.get(incoming.id);
1367
1398
  const store = active?.store ?? new RunStore(incoming.cwd, incoming.sessionId, incoming.id, home);
1368
- const loaded = await store.load();
1369
- return withLiveActivities(loaded.run);
1399
+ return withLiveActivities(await store.loadStatus());
1370
1400
  }, () => { if (state.workflowProgress === currentProgress) requestRender(); }, undefined, state.workflowProgressFrozenAt);
1371
1401
  }
1372
1402
  state.workflowProgressComponent.setExpanded(expanded);
package/src/index.ts CHANGED
@@ -6,12 +6,14 @@ export * from "./utils.js";
6
6
  export { canonicalPath, sameFilesystemPath } from "./paths.js";
7
7
  export * from "./budget.js";
8
8
  export * from "./validation.js";
9
+ export * from "./roles.js";
9
10
  export * from "./registry.js";
10
11
  export * from "./execution.js";
11
12
  export * from "./host.js";
12
13
  export * from "./workflow-artifacts.js";
13
14
  export { formatCost } from "./background-widget.js";
14
- export { agentActionLabels, formatAgentDetail, navigatorAttentionSortByState, themeWorkflowProgressStyles, visibleStandaloneAgentAttemptActions } from "./host-view.js";
15
+ export { agentActionLabels, formatAgentDetail, formatAgentError, formatNavigatorColumns, formatWorkflowRuntime, navigatorAttentionSortByState, PLAIN_WORKFLOW_PROGRESS_STYLES, progressStyleForState, runStateGlyph, themeWorkflowProgressStyles, visibleStandaloneAgentAttemptActions } from "./host-view.js";
16
+ export { createInlineConfirm, workflowKeyLabel, workflowKeyMatches } from "./host-navigator.js";
15
17
  export type { AgentDetailPresentation, WorkflowProgressStyles } from "./host-view.js";
16
18
  export { default } from "./host.js";
17
19
  export { acquireSessionLease, hasLiveSessionLease, listPersistedSessionIds, projectSessionsDirectory, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
package/src/registry.ts CHANGED
@@ -5,7 +5,8 @@ import { Value } from "typebox/value";
5
5
  import type { AgentAttemptAction, JsonSchema, JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogContext, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogModelAlias, WorkflowExtension, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowFunction, WorkflowFunctionContext, WorkflowFunctionSource, WorkflowJournal, WorkflowModelAlias, WorkflowModelAliasResolverContext, WorkflowRoleDirectoryRegistration } from "./types.js";
6
6
  import type { SubagentRunRequest, SubagentStatus } from "../subagents/src/contracts.js";
7
7
  import { byPriorityThenName, deepFreeze, errorCode, errorText, fail, jsonValue, MODEL_ALIAS_NAME, object, validWorkflowExtensionNamespace } from "./utils.js";
8
- import { loadSettings, resolveWorkflowSettings, validateSchema } from "./validation.js";
8
+ import { loadSettings, resolveWorkflowSettings } from "./settings.js";
9
+ import { validateSchema } from "./utils.js";
9
10
  import { canonicalPath } from "./paths.js";
10
11
  import { isCorePackageName } from "./package-identity.js";
11
12