@osolmaz/pi-workflows 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +13 -3
  2. package/dist/builtins/catalog.js +1 -1
  3. package/dist/builtins/monitor.workflow.d.ts +25 -69
  4. package/dist/builtins/monitor.workflow.js +194 -123
  5. package/dist/builtins/monitor.workflow.js.map +1 -1
  6. package/dist/extension/executor.d.ts +7 -2
  7. package/dist/extension/executor.js +20 -14
  8. package/dist/extension/executor.js.map +1 -1
  9. package/dist/extension/index.js +56 -15
  10. package/dist/extension/index.js.map +1 -1
  11. package/dist/extension/step-message.d.ts +24 -0
  12. package/dist/extension/step-message.js +106 -0
  13. package/dist/extension/step-message.js.map +1 -0
  14. package/dist/extension/widget.d.ts +4 -2
  15. package/dist/extension/widget.js +111 -17
  16. package/dist/extension/widget.js.map +1 -1
  17. package/dist/extension/workflow-tool.d.ts +9 -0
  18. package/dist/extension/workflow-tool.js +10 -0
  19. package/dist/extension/workflow-tool.js.map +1 -1
  20. package/dist/host/rpc-bridge.js +25 -11
  21. package/dist/host/rpc-bridge.js.map +1 -1
  22. package/dist/host/rpc-executor.d.ts +2 -2
  23. package/dist/host/rpc-executor.js +23 -12
  24. package/dist/host/rpc-executor.js.map +1 -1
  25. package/dist/viewer/cli.js +1 -1
  26. package/dist/viewer/cli.js.map +1 -1
  27. package/dist/viewer/render.js +14 -0
  28. package/dist/viewer/render.js.map +1 -1
  29. package/dist/viewer/tui.js +1 -1
  30. package/dist/viewer/tui.js.map +1 -1
  31. package/dist/workflows/engine.d.ts +6 -1
  32. package/dist/workflows/engine.js +88 -6
  33. package/dist/workflows/engine.js.map +1 -1
  34. package/dist/workflows/index.d.ts +4 -2
  35. package/dist/workflows/index.js +2 -0
  36. package/dist/workflows/index.js.map +1 -1
  37. package/dist/workflows/progress.d.ts +34 -0
  38. package/dist/workflows/progress.js +268 -0
  39. package/dist/workflows/progress.js.map +1 -0
  40. package/dist/workflows/schema.js +21 -1
  41. package/dist/workflows/schema.js.map +1 -1
  42. package/dist/workflows/shell.d.ts +2 -2
  43. package/dist/workflows/shell.js +103 -25
  44. package/dist/workflows/shell.js.map +1 -1
  45. package/dist/workflows/store.d.ts +15 -2
  46. package/dist/workflows/store.js +44 -2
  47. package/dist/workflows/store.js.map +1 -1
  48. package/dist/workflows/types.d.ts +52 -1
  49. package/dist/workflows/updates.d.ts +15 -0
  50. package/dist/workflows/updates.js +188 -0
  51. package/dist/workflows/updates.js.map +1 -0
  52. package/docs/DESIGN_PHILOSOPHY.md +51 -0
  53. package/docs/MONITOR.md +282 -0
  54. package/docs/WORKFLOW_STEP_MESSAGES.md +141 -0
  55. package/docs/WORKFLOW_UPDATES.md +416 -0
  56. package/docs/development.md +7 -3
  57. package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +11 -3
  58. package/docs/plans/2026-08-16-workflow-updates-plan.md +494 -0
  59. package/docs/run-bundles.md +10 -2
  60. package/docs/workflows.md +57 -17
  61. package/package.json +1 -1
  62. package/src/builtins/catalog.ts +1 -1
  63. package/src/builtins/monitor.workflow.ts +217 -148
  64. package/src/extension/executor.ts +36 -14
  65. package/src/extension/index.ts +89 -23
  66. package/src/extension/step-message.ts +145 -0
  67. package/src/extension/widget.ts +158 -14
  68. package/src/extension/workflow-tool.ts +22 -0
  69. package/src/host/rpc-bridge.ts +37 -14
  70. package/src/host/rpc-executor.ts +35 -14
  71. package/src/viewer/cli.ts +1 -1
  72. package/src/viewer/render.ts +27 -0
  73. package/src/viewer/tui.ts +1 -1
  74. package/src/workflows/engine.ts +117 -4
  75. package/src/workflows/index.ts +32 -0
  76. package/src/workflows/progress.ts +326 -0
  77. package/src/workflows/schema.ts +23 -1
  78. package/src/workflows/shell.ts +109 -26
  79. package/src/workflows/store.ts +67 -2
  80. package/src/workflows/types.ts +78 -1
  81. package/src/workflows/updates.ts +208 -0
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
5
  import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
6
6
  import type { JsonObject } from "../controllers/types.js";
@@ -14,6 +14,7 @@ import {
14
14
  } from "../workflows/errors.js";
15
15
  import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
16
16
  import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
17
+ import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/progress.js";
17
18
  import {
18
19
  createRunId,
19
20
  listRunBundles,
@@ -28,6 +29,7 @@ import type {
28
29
  WorkflowDefinitionSnapshot,
29
30
  WorkflowRunResult,
30
31
  WorkflowRunState,
32
+ WorkflowUpdateRecord,
31
33
  } from "../workflows/types.js";
32
34
  import {
33
35
  PiControllerHost,
@@ -36,6 +38,12 @@ import {
36
38
  } from "./controller-host.js";
37
39
  import { ConversationStepExecutor } from "./executor.js";
38
40
  import { SessionRecorder } from "./recorder.js";
41
+ import {
42
+ registerWorkflowAgentStepMessageRenderer,
43
+ WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
44
+ WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
45
+ type WorkflowAgentStepMessageDetails,
46
+ } from "./step-message.js";
39
47
  import { buildWidgetView } from "./widget.js";
40
48
  import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
41
49
 
@@ -71,6 +79,7 @@ type ActiveRun = {
71
79
  presentationPrompt: WorkflowDefinition["presentationPrompt"];
72
80
  generation: number;
73
81
  lastState: WorkflowRunState | null;
82
+ updateHistory: WorkflowUpdateRecord[];
74
83
  childKey?: string;
75
84
  onFinish?: (result: WorkflowSchedulerResult) => void;
76
85
  completion?: Promise<void>;
@@ -191,6 +200,10 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
191
200
  workflowName: state.workflowName,
192
201
  status: state.status,
193
202
  steps: state.steps.length,
203
+ updates: (state.updates ?? []).map(({ data, ...record }) => ({
204
+ ...record,
205
+ data: data as JsonObject,
206
+ })),
194
207
  ...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
195
208
  ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
196
209
  ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
@@ -202,6 +215,7 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
202
215
  type WidgetSource = {
203
216
  state: WorkflowRunState;
204
217
  snapshot: WorkflowDefinitionSnapshot;
218
+ updateHistory?: WorkflowUpdateRecord[];
205
219
  };
206
220
 
207
221
  type WorkflowWidgetComponent = {
@@ -209,10 +223,12 @@ type WorkflowWidgetComponent = {
209
223
  invalidate: () => void;
210
224
  };
211
225
 
212
- type WorkflowWidgetFactory = () => WorkflowWidgetComponent;
226
+ type WorkflowWidgetFactory = (_tui: unknown, theme: Theme) => WorkflowWidgetComponent;
213
227
  type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
214
228
 
215
229
  export default function piWorkflows(pi: ExtensionAPI) {
230
+ registerWorkflowAgentStepMessageRenderer(pi);
231
+
216
232
  // One runner identity per session; it names this session in run claims.
217
233
  const runnerId = randomUUID();
218
234
  let runQueueStore: SqliteControllerStore | null = null;
@@ -267,16 +283,19 @@ export default function piWorkflows(pi: ExtensionAPI) {
267
283
  leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
268
284
  })) {
269
285
  if (!alreadyDelivered.has(notification.notificationId)) {
270
- pi.sendMessage({
271
- customType: "pi-workflows-notification",
272
- content: notification.content,
273
- display: true,
274
- details: {
275
- notificationId: notification.notificationId,
276
- runId: notification.runId,
277
- kind: notification.kind,
286
+ pi.sendMessage(
287
+ {
288
+ customType: "pi-workflows-notification",
289
+ content: notification.content,
290
+ display: true,
291
+ details: {
292
+ notificationId: notification.notificationId,
293
+ runId: notification.runId,
294
+ kind: notification.kind,
295
+ },
278
296
  },
279
- });
297
+ { triggerTurn: false },
298
+ );
280
299
  alreadyDelivered.add(notification.notificationId);
281
300
  }
282
301
  runQueueStore.markWorkflowNotificationDelivered({
@@ -377,7 +396,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
377
396
  if (!widgetSource) {
378
397
  return;
379
398
  }
380
- const render = (width = Number.POSITIVE_INFINITY): string[] => {
399
+ const render = (width = Number.POSITIVE_INFINITY, theme?: Theme): string[] => {
381
400
  if (!widgetSource) return [];
382
401
  const view = buildWidgetView(
383
402
  widgetSource.state,
@@ -386,6 +405,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
386
405
  widgetScroll,
387
406
  runHeld(),
388
407
  width,
408
+ theme,
409
+ widgetSource.updateHistory,
389
410
  );
390
411
  widgetShownScroll = view.scroll;
391
412
  widgetMaxScroll = view.maxScroll;
@@ -395,7 +416,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
395
416
  return view.lines;
396
417
  };
397
418
  if (ctx.mode === "tui") {
398
- setWidget(ctx, () => ({ render, invalidate() {} }));
419
+ setWidget(ctx, (_tui, theme) => ({
420
+ render: (width) => render(width, theme),
421
+ invalidate() {},
422
+ }));
399
423
  } else {
400
424
  // RPC transports string widgets but cannot serialize TUI component factories.
401
425
  setWidget(ctx, render());
@@ -407,13 +431,18 @@ export default function piWorkflows(pi: ExtensionAPI) {
407
431
  ctx: ExtensionContext,
408
432
  state: WorkflowRunState,
409
433
  snapshot: WorkflowDefinitionSnapshot,
434
+ updateHistory?: WorkflowUpdateRecord[],
410
435
  ) => {
411
436
  if (state.steps.length !== widgetStepCount) {
412
437
  widgetStepCount = state.steps.length;
413
438
  // The workflow moved on; resume following the active node.
414
439
  widgetScroll = null;
415
440
  }
416
- widgetSource = { state, snapshot };
441
+ widgetSource = {
442
+ state,
443
+ snapshot,
444
+ ...(updateHistory !== undefined ? { updateHistory: [...updateHistory] } : {}),
445
+ };
417
446
  renderWidget(ctx);
418
447
  };
419
448
 
@@ -592,7 +621,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
592
621
  void run.recorder?.stop();
593
622
  stopWidgetTicker();
594
623
  const { state } = result;
595
- updateWidget(ctx, state, run.snapshot);
624
+ updateWidget(ctx, state, run.snapshot, run.updateHistory);
596
625
  clearWidgetTimer();
597
626
  // A waiting run is parked at a checkpoint for a human; keep its widget up
598
627
  // until a new workflow replaces it. Terminal runs fade after a grace TTL.
@@ -730,8 +759,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
730
759
  }
731
760
 
732
761
  const executor = new ConversationStepExecutor({
733
- sendPrompt: ({ prompt, streaming }) => {
734
- pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
762
+ sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
763
+ const details: WorkflowAgentStepMessageDetails = {
764
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
765
+ kind,
766
+ contract,
767
+ ...(presentation !== undefined ? { presentation } : {}),
768
+ };
769
+ pi.sendMessage(
770
+ {
771
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
772
+ content: prompt,
773
+ display: true,
774
+ details,
775
+ },
776
+ {
777
+ triggerTurn: true,
778
+ deliverAs: streaming ? "steer" : "followUp",
779
+ },
780
+ );
735
781
  },
736
782
  onAbort: (contract, reason) => {
737
783
  lastExpiredAttempt = {
@@ -795,9 +841,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
795
841
  onRunFinishing: async () => {
796
842
  await run.recorder?.finish();
797
843
  },
798
- onEvent: (_event, state: WorkflowRunState) => {
844
+ onEvent: (event, state: WorkflowRunState) => {
799
845
  run.lastState = state;
800
- updateWidget(ctx, state, snapshot);
846
+ run.updateHistory = appendProgressHistory(
847
+ run.updateHistory,
848
+ progressRecordsFromTrace([event]),
849
+ );
850
+ updateWidget(ctx, state, snapshot, run.updateHistory);
801
851
  },
802
852
  });
803
853
  const run: ActiveRun = {
@@ -810,6 +860,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
810
860
  presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
811
861
  generation,
812
862
  lastState: null,
863
+ updateHistory: [],
813
864
  ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
814
865
  ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
815
866
  ...(claimToken !== undefined ? { claimToken } : {}),
@@ -1558,13 +1609,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1558
1609
  name: "workflow",
1559
1610
  label: "Workflow",
1560
1611
  description: [
1561
- "List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
1562
- "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, reportWhen, stopWhen, and optional maxChecks.",
1563
- "Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1612
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
1613
+ "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, stopWhen, and optional maxChecks.",
1614
+ "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1564
1615
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
1565
1616
  ].join(" "),
1566
1617
  parameters: WorkflowToolParameters,
1567
- async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1618
+ async execute(toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1568
1619
  let control: WorkflowControlResult;
1569
1620
  switch (params.action) {
1570
1621
  case "list":
@@ -1592,6 +1643,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1592
1643
  });
1593
1644
  break;
1594
1645
  }
1646
+ case "update": {
1647
+ if (!activeRun) throw new Error("No workflow step is active.");
1648
+ const receipt = await activeRun.engine.publishUpdate(
1649
+ params.step,
1650
+ params.attempt,
1651
+ params.update,
1652
+ toolCallId,
1653
+ );
1654
+ return {
1655
+ content: [
1656
+ { type: "text", text: "Workflow update published; the step remains active." },
1657
+ ],
1658
+ details: { action: "update", ...receipt },
1659
+ };
1660
+ }
1595
1661
  case "submit": {
1596
1662
  if (!activeRun) {
1597
1663
  if (
@@ -0,0 +1,145 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
3
+ import { sanitizeText } from "../workflows/text.js";
4
+ import type { AgentStepContract, AgentStepPresentation } from "../workflows/types.js";
5
+ import type { PromptDeliveryKind } from "./executor.js";
6
+
7
+ export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = "pi-workflows-agent-step";
8
+ export const WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA = "pi-workflows.agent-step-message.v1";
9
+
10
+ export type WorkflowAgentStepMessageDetails = {
11
+ schema: typeof WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA;
12
+ kind: PromptDeliveryKind;
13
+ contract: AgentStepContract;
14
+ presentation?: AgentStepPresentation;
15
+ };
16
+
17
+ type WorkflowAgentStepMessage = {
18
+ content: unknown;
19
+ details?: unknown;
20
+ };
21
+
22
+ type WorkflowAgentStepView = {
23
+ title: string;
24
+ status?: string;
25
+ expandedText?: string;
26
+ };
27
+
28
+ export function registerWorkflowAgentStepMessageRenderer(pi: ExtensionAPI): void {
29
+ pi.registerMessageRenderer<WorkflowAgentStepMessageDetails>(
30
+ WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
31
+ (message, { expanded }, theme) => {
32
+ const view = buildWorkflowAgentStepView(message, expanded, theme);
33
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
34
+ box.addChild(new TruncatedText(view.title));
35
+ if (view.status !== undefined) {
36
+ box.addChild(new TruncatedText(view.status));
37
+ }
38
+ if (view.expandedText !== undefined) {
39
+ box.addChild(new Text(`\n${view.expandedText}`));
40
+ }
41
+ return box;
42
+ },
43
+ );
44
+ }
45
+
46
+ /** Build renderer text without depending on Pi's session or TUI state. */
47
+ export function buildWorkflowAgentStepView(
48
+ message: WorkflowAgentStepMessage,
49
+ expanded: boolean,
50
+ theme?: Pick<Theme, "fg">,
51
+ ): WorkflowAgentStepView {
52
+ const details = parseDetails(message.details);
53
+ const contract = details?.contract;
54
+ const kind = details?.kind ?? "step";
55
+ const workflowName = cleanSingleLine(contract?.workflowName ?? "Workflow");
56
+ const nodeId = cleanSingleLine(contract?.nodeId ?? "step");
57
+ const runTitle = cleanOptionalSingleLine(details?.presentation?.runTitle);
58
+ const statusDetail = cleanOptionalSingleLine(details?.presentation?.statusDetail);
59
+ const label = runTitle ?? workflowName;
60
+ const suffix = kind === "step" ? "" : ` · ${kind}`;
61
+ const glyph = kind === "step" ? "▶" : "↻";
62
+ const title = paint(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
63
+
64
+ if (!expanded) {
65
+ return {
66
+ title,
67
+ ...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
68
+ };
69
+ }
70
+
71
+ const metadata = [
72
+ `Workflow: ${workflowName}`,
73
+ ...(runTitle !== undefined ? [`Run title: ${runTitle}`] : []),
74
+ `Run id: ${cleanSingleLine(contract?.runId ?? "unknown")}`,
75
+ `Node id: ${nodeId}`,
76
+ `Attempt id: ${cleanSingleLine(contract?.attemptId ?? "unknown")}`,
77
+ `Delivery: ${kind}`,
78
+ `Expected output: ${cleanSingleLine(contract?.expectedOutput ?? "a JSON object with your result")}`,
79
+ ];
80
+ const prompt = contentText(message.content);
81
+ return {
82
+ title,
83
+ ...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
84
+ expandedText: `${metadata.map((line) => paint(theme, "dim", line)).join("\n")}\n\n${prompt}`,
85
+ };
86
+ }
87
+
88
+ function parseDetails(value: unknown): WorkflowAgentStepMessageDetails | undefined {
89
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
90
+ const candidate = value as Partial<WorkflowAgentStepMessageDetails>;
91
+ if (candidate.schema !== WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA) return undefined;
92
+ if (candidate.kind !== "step" && candidate.kind !== "reminder" && candidate.kind !== "resume") {
93
+ return undefined;
94
+ }
95
+ const contract = candidate.contract;
96
+ if (
97
+ contract === null ||
98
+ typeof contract !== "object" ||
99
+ typeof contract.runId !== "string" ||
100
+ typeof contract.workflowName !== "string" ||
101
+ typeof contract.nodeId !== "string" ||
102
+ typeof contract.attemptId !== "string"
103
+ ) {
104
+ return undefined;
105
+ }
106
+ return candidate as WorkflowAgentStepMessageDetails;
107
+ }
108
+
109
+ function contentText(content: unknown): string {
110
+ if (typeof content === "string") return cleanMultiline(content);
111
+ if (!Array.isArray(content)) return "";
112
+ return content
113
+ .map((part) => {
114
+ if (part === null || typeof part !== "object" || !("text" in part)) return "";
115
+ const text = (part as { text?: unknown }).text;
116
+ return typeof text === "string" ? cleanMultiline(text) : "";
117
+ })
118
+ .filter(Boolean)
119
+ .join("\n");
120
+ }
121
+
122
+ function cleanOptionalSingleLine(value: unknown): string | undefined {
123
+ return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
124
+ }
125
+
126
+ function cleanSingleLine(value: string): string {
127
+ return sanitizeText(value);
128
+ }
129
+
130
+ function cleanMultiline(value: string): string {
131
+ return value
132
+ .replaceAll("\r\n", "\n")
133
+ .replaceAll("\r", "\n")
134
+ .split("\n")
135
+ .map((line) => sanitizeText(line))
136
+ .join("\n");
137
+ }
138
+
139
+ function paint(
140
+ theme: Pick<Theme, "fg"> | undefined,
141
+ color: "accent" | "dim",
142
+ text: string,
143
+ ): string {
144
+ return theme?.fg(color, text) ?? text;
145
+ }
@@ -1,12 +1,21 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
1
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { ansi } from "../render/ansi.js";
3
3
  import { formatDuration } from "../render/format.js";
4
4
  import { nodeTypeGlyph } from "../render/node-type.js";
5
+ import {
6
+ estimateProgress,
7
+ formatProgressLine,
8
+ formatRemaining,
9
+ prioritizeProgressEstimates,
10
+ progressTracksFromRecords,
11
+ type ProgressEstimate,
12
+ } from "../workflows/progress.js";
5
13
  import { sanitizeText } from "../workflows/text.js";
6
14
  import type {
7
15
  WorkflowDefinitionSnapshot,
8
16
  WorkflowRunState,
9
17
  WorkflowRunStatus,
18
+ WorkflowUpdateRecord,
10
19
  } from "../workflows/types.js";
11
20
 
12
21
  const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
@@ -45,6 +54,8 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
45
54
  return Object.keys(snapshot.nodes);
46
55
  }
47
56
 
57
+ export type WidgetTheme = Pick<Theme, "bold" | "fg">;
58
+
48
59
  export type WidgetView = {
49
60
  lines: string[];
50
61
  /** The clamped first visible node row. */
@@ -65,6 +76,8 @@ export function buildWidgetView(
65
76
  scroll: number | null = null,
66
77
  held = false,
67
78
  width = Number.POSITIVE_INFINITY,
79
+ theme?: WidgetTheme,
80
+ updateHistory?: WorkflowUpdateRecord[],
68
81
  ): WidgetView {
69
82
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
70
83
  if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
@@ -77,34 +90,46 @@ export function buildWidgetView(
77
90
  // Titles, status details, and errors can carry model- or shell-controlled
78
91
  // text; never let escape sequences or newlines reach the terminal.
79
92
  const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
80
- const header = `${glyph} workflow ${sanitizeText(state.workflowName)}${title} [${statusText}]`;
93
+ const headerTone = statusTone(paused ? "waiting" : state.status);
94
+ const header = `${paint(theme, headerTone, glyph)} workflow ${sanitizeText(state.workflowName)}${title} ${paint(theme, headerTone, `[${statusText}]`)}`;
81
95
 
82
96
  const footer: string[] = [];
83
97
  if (state.error) {
84
- footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
98
+ footer.push(paint(theme, "error", ` error: ${truncate(sanitizeText(state.error), 120)}`));
85
99
  }
86
100
  if (state.status === "waiting" && state.waitingOn) {
87
- footer.push(` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`);
101
+ footer.push(
102
+ paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
103
+ );
88
104
  }
89
105
 
90
- const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
106
+ const progress = progressLines(state, now, updateHistory).slice(0, 4);
107
+ const budget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
91
108
  const nodes = displayNodeIds(snapshot).map((nodeId) =>
92
- compactNodeLine(state, snapshot, nodeId, now),
109
+ compactNodeLine(state, snapshot, nodeId, now, paused, theme),
93
110
  );
94
- if (nodes.length === 0) {
111
+ if (nodes.length === 0 || budget <= 0) {
95
112
  return {
96
- lines: fitLines([header, ...footer], availableWidth),
113
+ lines: fitLines(
114
+ [header, ...progress, ...footer].slice(0, PI_MAX_WIDGET_LINES),
115
+ availableWidth,
116
+ ),
97
117
  scroll: 0,
98
118
  maxScroll: 0,
99
119
  };
100
120
  }
101
121
 
102
122
  const anchor = scroll ?? compactFocusIndex(state, snapshot);
103
- const windowed = windowLines(nodes, budget, anchor, scroll !== null);
123
+ const windowed = windowLines(nodes, budget, anchor, scroll !== null, theme);
104
124
  const indentation = availableWidth >= 3 ? " " : "";
105
125
  return {
106
126
  lines: fitLines(
107
- [header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
127
+ [
128
+ header,
129
+ ...windowed.lines.map((line) => `${indentation}${line}`),
130
+ ...progress.map((line) => `${indentation}${line}`),
131
+ ...footer,
132
+ ],
108
133
  availableWidth,
109
134
  ),
110
135
  scroll: windowed.scroll,
@@ -112,6 +137,76 @@ export function buildWidgetView(
112
137
  };
113
138
  }
114
139
 
140
+ function progressLines(
141
+ state: WorkflowRunState,
142
+ now: Date,
143
+ updateHistory?: WorkflowUpdateRecord[],
144
+ ): string[] {
145
+ const measured =
146
+ updateHistory === undefined
147
+ ? undefined
148
+ : progressTracksFromRecords(updateHistory, now).map((track) => track.estimate);
149
+ const projected = mergeProgressEstimates(latestProgressEstimates(state, now), measured ?? []);
150
+ const estimates = mergeProgressEstimates(projected, monitorEstimates(state) ?? []);
151
+ const lines = prioritizeProgressEstimates(estimates).map((estimate) =>
152
+ formatProgressLine(estimate, now),
153
+ );
154
+ const schedule = (state.updates ?? []).find(
155
+ (record) => record.type === "monitor.schedule" && record.key === "next-check",
156
+ );
157
+ if (schedule !== undefined && typeof schedule.data.nextCheckAt === "string") {
158
+ const next = Date.parse(schedule.data.nextCheckAt);
159
+ if (Number.isFinite(next)) {
160
+ const age = Math.max(0, now.getTime() - Date.parse(schedule.at));
161
+ lines.push(
162
+ `Last update ${formatRemaining(age)} ago next check ${formatRemaining(next - now.getTime())}`,
163
+ );
164
+ }
165
+ }
166
+ return lines;
167
+ }
168
+
169
+ function monitorEstimates(state: WorkflowRunState): ProgressEstimate[] | undefined {
170
+ const output = state.outputs.estimate;
171
+ if (output === null || typeof output !== "object" || Array.isArray(output)) return undefined;
172
+ const tracks = (output as { tracks?: unknown }).tracks;
173
+ if (!Array.isArray(tracks)) return undefined;
174
+ const estimates = tracks
175
+ .map((track) =>
176
+ track !== null && typeof track === "object" && !Array.isArray(track)
177
+ ? (track as { estimate?: ProgressEstimate }).estimate
178
+ : undefined,
179
+ )
180
+ .filter((estimate): estimate is ProgressEstimate => estimate !== undefined);
181
+ return estimates.length > 0 ? estimates : undefined;
182
+ }
183
+
184
+ function mergeProgressEstimates(
185
+ latest: ProgressEstimate[],
186
+ measured: ProgressEstimate[],
187
+ ): ProgressEstimate[] {
188
+ const measuredByKey = new Map(measured.map((estimate) => [estimate.key, estimate]));
189
+ const merged = latest.map((estimate) => measuredByKey.get(estimate.key) ?? estimate);
190
+ const latestKeys = new Set(latest.map((estimate) => estimate.key));
191
+ merged.push(...measured.filter((estimate) => !latestKeys.has(estimate.key)));
192
+ return merged;
193
+ }
194
+
195
+ function latestProgressEstimates(state: WorkflowRunState, now: Date): ProgressEstimate[] {
196
+ const estimates: ProgressEstimate[] = [];
197
+ for (const record of state.updates ?? []) {
198
+ if (record.type !== "progress") continue;
199
+ try {
200
+ estimates.push(
201
+ estimateProgress(record.key, [{ at: record.at, data: record.data as never }], now),
202
+ );
203
+ } catch {
204
+ // A malformed historical update must not break the workflow widget.
205
+ }
206
+ }
207
+ return estimates;
208
+ }
209
+
115
210
  function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
116
211
  const nodeIds = displayNodeIds(snapshot);
117
212
  const focused = state.currentNode ?? state.waitingOn;
@@ -125,12 +220,41 @@ function compactNodeLine(
125
220
  snapshot: WorkflowDefinitionSnapshot,
126
221
  nodeId: string,
127
222
  now: Date,
223
+ paused: boolean,
224
+ theme?: WidgetTheme,
128
225
  ): string {
129
226
  const node = snapshot.nodes[nodeId];
130
- const type = node ? ansi.dim(nodeTypeGlyph(node.nodeType, node.actionExecution)) : "?";
227
+ const glyph = nodeGlyph(state, nodeId);
228
+ const typeGlyph = node ? nodeTypeGlyph(node.nodeType, node.actionExecution) : "?";
229
+ const name = sanitizeText(nodeId);
131
230
  const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
132
- const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
133
- return `${nodeGlyph(state, nodeId)} ${type} ${sanitizeText(nodeId)}${detail}`;
231
+ const isCurrent = state.currentNode === nodeId;
232
+ const isWaiting = state.waitingOn === nodeId;
233
+
234
+ if (isCurrent || isWaiting) {
235
+ const tone = paused || isWaiting ? "warning" : "accent";
236
+ const focusedName = theme ? theme.bold(name) : name;
237
+ const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
238
+ return paint(theme, tone, `${glyph} ${typeGlyph} ${focusedName}${detail}`);
239
+ }
240
+
241
+ const result = state.results[nodeId];
242
+ if (!result) {
243
+ const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
244
+ return paint(theme, "dim", `${glyph} ${typeGlyph} ${name}${detail}`);
245
+ }
246
+
247
+ const styledGlyph = paint(theme, result.outcome === "ok" ? "success" : "error", glyph);
248
+ const styledType = paint(theme, "dim", typeGlyph);
249
+ const styledSegments = segments.map((segment, index) =>
250
+ paint(
251
+ theme,
252
+ result.outcome !== "ok" && index === segments.length - 1 ? "error" : "dim",
253
+ segment,
254
+ ),
255
+ );
256
+ const detail = styledSegments.length > 0 ? ` · ${styledSegments.join(" · ")}` : "";
257
+ return `${styledGlyph} ${styledType} ${name}${detail}`;
134
258
  }
135
259
 
136
260
  function nodeRuntimeSegments(
@@ -188,6 +312,25 @@ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
188
312
  return formatDuration(Math.max(0, now.getTime() - started));
189
313
  }
190
314
 
315
+ function statusTone(status: WorkflowRunStatus): ThemeColor {
316
+ switch (status) {
317
+ case "completed":
318
+ return "success";
319
+ case "failed":
320
+ case "timed_out":
321
+ case "cancelled":
322
+ return "error";
323
+ case "waiting":
324
+ return "warning";
325
+ case "running":
326
+ return "accent";
327
+ }
328
+ }
329
+
330
+ function paint(theme: WidgetTheme | undefined, color: ThemeColor, text: string): string {
331
+ return theme ? theme.fg(color, text) : text;
332
+ }
333
+
191
334
  function fitLines(lines: string[], width: number): string[] {
192
335
  if (!Number.isFinite(width)) return lines;
193
336
  return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
@@ -212,6 +355,7 @@ function windowLines(
212
355
  budget: number,
213
356
  anchor: number,
214
357
  anchorIsStart: boolean,
358
+ theme?: WidgetTheme,
215
359
  ): { lines: string[]; scroll: number; maxScroll: number } {
216
360
  if (lines.length <= budget) {
217
361
  return { lines, scroll: 0, maxScroll: 0 };
@@ -225,7 +369,7 @@ function windowLines(
225
369
  const directions = [above > 0 ? `↑ ${above}` : "", below > 0 ? `↓ ${below}` : ""]
226
370
  .filter(Boolean)
227
371
  .join(" · ");
228
- out.push(ansi.dim(`${directions} more · shift+↑/↓ scroll`));
372
+ out.push(paint(theme, "dim", `${directions} more · shift+↑/↓ scroll`));
229
373
  return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
230
374
  }
231
375
 
@@ -37,6 +37,22 @@ export const WorkflowToolParameters: TSchema = Type.Union([
37
37
  },
38
38
  noExtraProperties,
39
39
  ),
40
+ Type.Object(
41
+ {
42
+ action: StringEnum(["update"] as const),
43
+ step: Type.String({ description: "Active step id" }),
44
+ attempt: Type.String({ description: "Active attempt id" }),
45
+ update: Type.Object(
46
+ {
47
+ type: Type.String(),
48
+ key: Type.String(),
49
+ data: Type.Record(Type.String(), Type.Unknown()),
50
+ },
51
+ noExtraProperties,
52
+ ),
53
+ },
54
+ noExtraProperties,
55
+ ),
40
56
  Type.Object(
41
57
  {
42
58
  action: StringEnum(["submit"] as const),
@@ -56,4 +72,10 @@ export type WorkflowToolInput =
56
72
  | { action: "resume" }
57
73
  | { action: "cancel" }
58
74
  | { action: "answer"; input: unknown; runId?: string }
75
+ | {
76
+ action: "update";
77
+ step: string;
78
+ attempt: string;
79
+ update: { type: string; key: string; data: Record<string, unknown> };
80
+ }
59
81
  | { action: "submit"; step: string; attempt: string; output: unknown };