@osolmaz/pi-workflows 0.5.3 → 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 (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 +51 -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 +57 -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 +81 -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({
@@ -387,6 +406,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
387
406
  runHeld(),
388
407
  width,
389
408
  theme,
409
+ widgetSource.updateHistory,
390
410
  );
391
411
  widgetShownScroll = view.scroll;
392
412
  widgetMaxScroll = view.maxScroll;
@@ -411,13 +431,18 @@ export default function piWorkflows(pi: ExtensionAPI) {
411
431
  ctx: ExtensionContext,
412
432
  state: WorkflowRunState,
413
433
  snapshot: WorkflowDefinitionSnapshot,
434
+ updateHistory?: WorkflowUpdateRecord[],
414
435
  ) => {
415
436
  if (state.steps.length !== widgetStepCount) {
416
437
  widgetStepCount = state.steps.length;
417
438
  // The workflow moved on; resume following the active node.
418
439
  widgetScroll = null;
419
440
  }
420
- widgetSource = { state, snapshot };
441
+ widgetSource = {
442
+ state,
443
+ snapshot,
444
+ ...(updateHistory !== undefined ? { updateHistory: [...updateHistory] } : {}),
445
+ };
421
446
  renderWidget(ctx);
422
447
  };
423
448
 
@@ -596,7 +621,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
596
621
  void run.recorder?.stop();
597
622
  stopWidgetTicker();
598
623
  const { state } = result;
599
- updateWidget(ctx, state, run.snapshot);
624
+ updateWidget(ctx, state, run.snapshot, run.updateHistory);
600
625
  clearWidgetTimer();
601
626
  // A waiting run is parked at a checkpoint for a human; keep its widget up
602
627
  // until a new workflow replaces it. Terminal runs fade after a grace TTL.
@@ -734,8 +759,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
734
759
  }
735
760
 
736
761
  const executor = new ConversationStepExecutor({
737
- sendPrompt: ({ prompt, streaming }) => {
738
- 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
+ );
739
781
  },
740
782
  onAbort: (contract, reason) => {
741
783
  lastExpiredAttempt = {
@@ -799,9 +841,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
799
841
  onRunFinishing: async () => {
800
842
  await run.recorder?.finish();
801
843
  },
802
- onEvent: (_event, state: WorkflowRunState) => {
844
+ onEvent: (event, state: WorkflowRunState) => {
803
845
  run.lastState = state;
804
- updateWidget(ctx, state, snapshot);
846
+ run.updateHistory = appendProgressHistory(
847
+ run.updateHistory,
848
+ progressRecordsFromTrace([event]),
849
+ );
850
+ updateWidget(ctx, state, snapshot, run.updateHistory);
805
851
  },
806
852
  });
807
853
  const run: ActiveRun = {
@@ -814,6 +860,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
814
860
  presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
815
861
  generation,
816
862
  lastState: null,
863
+ updateHistory: [],
817
864
  ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
818
865
  ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
819
866
  ...(claimToken !== undefined ? { claimToken } : {}),
@@ -1562,13 +1609,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1562
1609
  name: "workflow",
1563
1610
  label: "Workflow",
1564
1611
  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.",
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.",
1568
1615
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
1569
1616
  ].join(" "),
1570
1617
  parameters: WorkflowToolParameters,
1571
- async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1618
+ async execute(toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1572
1619
  let control: WorkflowControlResult;
1573
1620
  switch (params.action) {
1574
1621
  case "list":
@@ -1596,6 +1643,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
1596
1643
  });
1597
1644
  break;
1598
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
+ }
1599
1661
  case "submit": {
1600
1662
  if (!activeRun) {
1601
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
+ }
@@ -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: {},