@osolmaz/pi-workflows 0.5.3 → 0.6.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 (80) hide show
  1. package/README.md +10 -2
  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 +65 -13
  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 +2 -2
  15. package/dist/extension/widget.js +64 -5
  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-16-workflow-updates-plan.md +494 -0
  58. package/docs/run-bundles.md +10 -2
  59. package/docs/workflows.md +58 -17
  60. package/package.json +1 -1
  61. package/src/builtins/catalog.ts +1 -1
  62. package/src/builtins/monitor.workflow.ts +217 -148
  63. package/src/extension/executor.ts +36 -14
  64. package/src/extension/index.ts +95 -19
  65. package/src/extension/step-message.ts +145 -0
  66. package/src/extension/widget.ts +93 -4
  67. package/src/extension/workflow-tool.ts +22 -0
  68. package/src/host/rpc-bridge.ts +37 -14
  69. package/src/host/rpc-executor.ts +35 -14
  70. package/src/viewer/cli.ts +1 -1
  71. package/src/viewer/render.ts +27 -0
  72. package/src/viewer/tui.ts +1 -1
  73. package/src/workflows/engine.ts +117 -4
  74. package/src/workflows/index.ts +32 -0
  75. package/src/workflows/progress.ts +326 -0
  76. package/src/workflows/schema.ts +23 -1
  77. package/src/workflows/shell.ts +109 -26
  78. package/src/workflows/store.ts +67 -2
  79. package/src/workflows/types.ts +78 -1
  80. package/src/workflows/updates.ts +208 -0
@@ -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 = {
@@ -213,6 +227,8 @@ type WorkflowWidgetFactory = (_tui: unknown, theme: Theme) => WorkflowWidgetComp
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({
@@ -301,6 +320,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
301
320
  };
302
321
  let activeRun: ActiveRun | null = null;
303
322
  let systemTurnAbort: AgentStepContract | null = null;
323
+ let suppressWorkflowAssistantTail = false;
304
324
  let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
305
325
  let pendingToolLaunch: {
306
326
  ctx: ExtensionContext;
@@ -387,6 +407,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
387
407
  runHeld(),
388
408
  width,
389
409
  theme,
410
+ widgetSource.updateHistory,
390
411
  );
391
412
  widgetShownScroll = view.scroll;
392
413
  widgetMaxScroll = view.maxScroll;
@@ -411,13 +432,18 @@ export default function piWorkflows(pi: ExtensionAPI) {
411
432
  ctx: ExtensionContext,
412
433
  state: WorkflowRunState,
413
434
  snapshot: WorkflowDefinitionSnapshot,
435
+ updateHistory?: WorkflowUpdateRecord[],
414
436
  ) => {
415
437
  if (state.steps.length !== widgetStepCount) {
416
438
  widgetStepCount = state.steps.length;
417
439
  // The workflow moved on; resume following the active node.
418
440
  widgetScroll = null;
419
441
  }
420
- widgetSource = { state, snapshot };
442
+ widgetSource = {
443
+ state,
444
+ snapshot,
445
+ ...(updateHistory !== undefined ? { updateHistory: [...updateHistory] } : {}),
446
+ };
421
447
  renderWidget(ctx);
422
448
  };
423
449
 
@@ -505,6 +531,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
505
531
  return;
506
532
  }
507
533
  presentationPending = run.generation;
534
+ suppressWorkflowAssistantTail = false;
508
535
  pi.sendMessage(
509
536
  {
510
537
  customType: PRESENTATION_MESSAGE_TYPE,
@@ -596,7 +623,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
596
623
  void run.recorder?.stop();
597
624
  stopWidgetTicker();
598
625
  const { state } = result;
599
- updateWidget(ctx, state, run.snapshot);
626
+ updateWidget(ctx, state, run.snapshot, run.updateHistory);
600
627
  clearWidgetTimer();
601
628
  // A waiting run is parked at a checkpoint for a human; keep its widget up
602
629
  // until a new workflow replaces it. Terminal runs fade after a grace TTL.
@@ -734,8 +761,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
734
761
  }
735
762
 
736
763
  const executor = new ConversationStepExecutor({
737
- sendPrompt: ({ prompt, streaming }) => {
738
- pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
764
+ sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
765
+ const details: WorkflowAgentStepMessageDetails = {
766
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
767
+ kind,
768
+ contract,
769
+ ...(presentation !== undefined ? { presentation } : {}),
770
+ };
771
+ pi.sendMessage(
772
+ {
773
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
774
+ content: prompt,
775
+ display: true,
776
+ details,
777
+ },
778
+ {
779
+ triggerTurn: true,
780
+ deliverAs: streaming ? "steer" : "followUp",
781
+ },
782
+ );
739
783
  },
740
784
  onAbort: (contract, reason) => {
741
785
  lastExpiredAttempt = {
@@ -799,9 +843,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
799
843
  onRunFinishing: async () => {
800
844
  await run.recorder?.finish();
801
845
  },
802
- onEvent: (_event, state: WorkflowRunState) => {
846
+ onEvent: (event, state: WorkflowRunState) => {
803
847
  run.lastState = state;
804
- updateWidget(ctx, state, snapshot);
848
+ run.updateHistory = appendProgressHistory(
849
+ run.updateHistory,
850
+ progressRecordsFromTrace([event]),
851
+ );
852
+ updateWidget(ctx, state, snapshot, run.updateHistory);
805
853
  },
806
854
  });
807
855
  const run: ActiveRun = {
@@ -814,6 +862,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
814
862
  presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
815
863
  generation,
816
864
  lastState: null,
865
+ updateHistory: [],
817
866
  ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
818
867
  ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
819
868
  ...(claimToken !== undefined ? { claimToken } : {}),
@@ -1562,13 +1611,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1562
1611
  name: "workflow",
1563
1612
  label: "Workflow",
1564
1613
  description: [
1565
- "List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
1566
- "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.",
1567
- "Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1614
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
1615
+ "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.",
1616
+ "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1568
1617
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
1569
1618
  ].join(" "),
1570
1619
  parameters: WorkflowToolParameters,
1571
- async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1620
+ async execute(toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1572
1621
  let control: WorkflowControlResult;
1573
1622
  switch (params.action) {
1574
1623
  case "list":
@@ -1596,6 +1645,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1596
1645
  });
1597
1646
  break;
1598
1647
  }
1648
+ case "update": {
1649
+ if (!activeRun) throw new Error("No workflow step is active.");
1650
+ const receipt = await activeRun.engine.publishUpdate(
1651
+ params.step,
1652
+ params.attempt,
1653
+ params.update,
1654
+ toolCallId,
1655
+ );
1656
+ return {
1657
+ content: [
1658
+ { type: "text", text: "Workflow update published; the step remains active." },
1659
+ ],
1660
+ details: { action: "update", ...receipt },
1661
+ };
1662
+ }
1599
1663
  case "submit": {
1600
1664
  if (!activeRun) {
1601
1665
  if (
@@ -1620,6 +1684,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1620
1684
  if (!result.accepted) {
1621
1685
  throw new Error(result.message);
1622
1686
  }
1687
+ suppressWorkflowAssistantTail = true;
1623
1688
  return {
1624
1689
  content: [{ type: "text", text: result.message }],
1625
1690
  details: { action: "submit", step: params.step, accepted: true },
@@ -1683,6 +1748,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1683
1748
  });
1684
1749
 
1685
1750
  pi.on("agent_start", () => {
1751
+ suppressWorkflowAssistantTail = false;
1686
1752
  if (!activeRun && presentationPending === null && presentationAbort) {
1687
1753
  // A normal user turn started while an async presentation prompt was
1688
1754
  // still resolving. The user's new request supersedes that old result.
@@ -1693,6 +1759,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1693
1759
  });
1694
1760
 
1695
1761
  pi.on("agent_end", (event, ctx) => {
1762
+ suppressWorkflowAssistantTail = false;
1696
1763
  const aborted = event.messages.some(
1697
1764
  (message) =>
1698
1765
  typeof message === "object" &&
@@ -1740,6 +1807,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
1740
1807
  });
1741
1808
 
1742
1809
  pi.on("message_end", (event) => {
1810
+ if (suppressWorkflowAssistantTail && event.message.role === "assistant") {
1811
+ const message = {
1812
+ ...event.message,
1813
+ content: event.message.content.filter((part) => part.type !== "text"),
1814
+ };
1815
+ activeRun?.recorder?.handleMessageEnd({ ...event, message });
1816
+ return { message };
1817
+ }
1743
1818
  activeRun?.recorder?.handleMessageEnd(event);
1744
1819
  });
1745
1820
 
@@ -1785,6 +1860,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1785
1860
  pi.on("session_shutdown", async () => {
1786
1861
  sessionClosed = true;
1787
1862
  systemTurnAbort = null;
1863
+ suppressWorkflowAssistantTail = false;
1788
1864
  supersedePresentation();
1789
1865
  const run = activeRun;
1790
1866
  if (run !== null && run.claimToken !== undefined) {
@@ -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
+ }
@@ -2,11 +2,20 @@ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
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> = {
@@ -68,6 +77,7 @@ export function buildWidgetView(
68
77
  held = false,
69
78
  width = Number.POSITIVE_INFINITY,
70
79
  theme?: WidgetTheme,
80
+ updateHistory?: WorkflowUpdateRecord[],
71
81
  ): WidgetView {
72
82
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
73
83
  if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
@@ -93,13 +103,17 @@ export function buildWidgetView(
93
103
  );
94
104
  }
95
105
 
96
- 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;
97
108
  const nodes = displayNodeIds(snapshot).map((nodeId) =>
98
109
  compactNodeLine(state, snapshot, nodeId, now, paused, theme),
99
110
  );
100
- if (nodes.length === 0) {
111
+ if (nodes.length === 0 || budget <= 0) {
101
112
  return {
102
- lines: fitLines([header, ...footer], availableWidth),
113
+ lines: fitLines(
114
+ [header, ...progress, ...footer].slice(0, PI_MAX_WIDGET_LINES),
115
+ availableWidth,
116
+ ),
103
117
  scroll: 0,
104
118
  maxScroll: 0,
105
119
  };
@@ -110,7 +124,12 @@ export function buildWidgetView(
110
124
  const indentation = availableWidth >= 3 ? " " : "";
111
125
  return {
112
126
  lines: fitLines(
113
- [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
+ ],
114
133
  availableWidth,
115
134
  ),
116
135
  scroll: windowed.scroll,
@@ -118,6 +137,76 @@ export function buildWidgetView(
118
137
  };
119
138
  }
120
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
+
121
210
  function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
122
211
  const nodeIds = displayNodeIds(snapshot);
123
212
  const focused = state.currentNode ?? state.waitingOn;
@@ -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 };
@@ -15,26 +15,49 @@ export default function piWorkflowsRpcBridge(pi: ExtensionAPI) {
15
15
  name: "workflow",
16
16
  label: "Workflow",
17
17
  description: [
18
- "Submit the output for the pending workflow step with action submit.",
18
+ "Publish an update or submit the output for the pending workflow step.",
19
19
  "Only call this tool when a workflow step contract in the conversation asks you to.",
20
- "Pass the exact step id from the contract and your result as the output.",
20
+ "Pass the exact step and attempt ids from the contract.",
21
21
  ].join(" "),
22
- parameters: Type.Object(
23
- {
24
- action: StringEnum(["submit"] as const),
25
- step: Type.String({ description: "Step id from the workflow step contract" }),
26
- attempt: Type.String({ description: "Attempt id from the workflow step contract" }),
27
- output: Type.Unknown({ description: "Step output matching the expected shape" }),
28
- },
29
- { additionalProperties: false },
30
- ),
31
- async execute(_toolCallId, params) {
32
- process.stderr.write(`${RPC_SUBMISSION_PREFIX}${JSON.stringify(params)}\n`);
22
+ parameters: Type.Union([
23
+ Type.Object(
24
+ {
25
+ action: StringEnum(["update"] as const),
26
+ step: Type.String(),
27
+ attempt: Type.String(),
28
+ update: Type.Object(
29
+ {
30
+ type: Type.String(),
31
+ key: Type.String(),
32
+ data: Type.Record(Type.String(), Type.Unknown()),
33
+ },
34
+ { additionalProperties: false },
35
+ ),
36
+ },
37
+ { additionalProperties: false },
38
+ ),
39
+ Type.Object(
40
+ {
41
+ action: StringEnum(["submit"] as const),
42
+ step: Type.String({ description: "Step id from the workflow step contract" }),
43
+ attempt: Type.String({ description: "Attempt id from the workflow step contract" }),
44
+ output: Type.Unknown({ description: "Step output matching the expected shape" }),
45
+ },
46
+ { additionalProperties: false },
47
+ ),
48
+ ]),
49
+ async execute(toolCallId, params) {
50
+ process.stderr.write(
51
+ `${RPC_SUBMISSION_PREFIX}${JSON.stringify({ ...params, idempotencyKey: toolCallId })}\n`,
52
+ );
33
53
  return {
34
54
  content: [
35
55
  {
36
56
  type: "text",
37
- text: "Submission recorded. Continue only when the workflow sends the next step.",
57
+ text:
58
+ params.action === "update"
59
+ ? "Workflow update recorded; continue the current step."
60
+ : "Submission recorded. Continue only when the workflow sends the next step.",
38
61
  },
39
62
  ],
40
63
  details: {},