@osolmaz/pi-workflows 0.16.0 → 0.16.2

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 (175) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +48 -16
  22. package/dist/client/view.js +1 -0
  23. package/dist/client/view.js.map +1 -1
  24. package/dist/controllers/index.d.ts +1 -1
  25. package/dist/controllers/index.js.map +1 -1
  26. package/dist/controllers/sqlite.d.ts +20 -165
  27. package/dist/controllers/sqlite.js +178 -436
  28. package/dist/controllers/sqlite.js.map +1 -1
  29. package/dist/extension/index.d.ts +23 -2
  30. package/dist/extension/index.js +637 -534
  31. package/dist/extension/index.js.map +1 -1
  32. package/dist/extension/recorder.d.ts +95 -0
  33. package/dist/extension/recorder.js +530 -0
  34. package/dist/extension/recorder.js.map +1 -0
  35. package/dist/extension/remote-recorder-store.d.ts +25 -0
  36. package/dist/extension/remote-recorder-store.js +81 -0
  37. package/dist/extension/remote-recorder-store.js.map +1 -0
  38. package/dist/extension/session-events.d.ts +134 -0
  39. package/dist/extension/session-events.js +60 -0
  40. package/dist/extension/session-events.js.map +1 -0
  41. package/dist/extension/session-view.d.ts +2 -0
  42. package/dist/extension/session-view.js +60 -2
  43. package/dist/extension/session-view.js.map +1 -1
  44. package/dist/extension/step-message.d.ts +6 -4
  45. package/dist/extension/step-message.js +12 -6
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.d.ts +2 -2
  48. package/dist/extension/widget.js +28 -18
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/extension/workflow-message-coordinator.d.ts +28 -0
  51. package/dist/extension/workflow-message-coordinator.js +346 -0
  52. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  53. package/dist/host/channel-effects.d.ts +72 -0
  54. package/dist/host/channel-effects.js +271 -0
  55. package/dist/host/channel-effects.js.map +1 -0
  56. package/dist/host/channel-supervisor.d.ts +21 -0
  57. package/dist/host/channel-supervisor.js +54 -0
  58. package/dist/host/channel-supervisor.js.map +1 -0
  59. package/dist/host/runner.d.ts +50 -8
  60. package/dist/host/runner.js +1632 -341
  61. package/dist/host/runner.js.map +1 -1
  62. package/dist/host/state.d.ts +19 -16
  63. package/dist/host/state.js +251 -63
  64. package/dist/host/state.js.map +1 -1
  65. package/dist/host/view.d.ts +19 -9
  66. package/dist/host/view.js +227 -119
  67. package/dist/host/view.js.map +1 -1
  68. package/dist/host/worker-entry.d.ts +4 -1
  69. package/dist/host/worker-entry.js +23 -24
  70. package/dist/host/worker-entry.js.map +1 -1
  71. package/dist/host/worker-protocol.d.ts +15 -0
  72. package/dist/host/worker-protocol.js.map +1 -1
  73. package/dist/state/prune.js +3 -25
  74. package/dist/state/prune.js.map +1 -1
  75. package/dist/state/schema.js +76 -97
  76. package/dist/state/schema.js.map +1 -1
  77. package/dist/state/workflow-messages.d.ts +98 -0
  78. package/dist/state/workflow-messages.js +366 -0
  79. package/dist/state/workflow-messages.js.map +1 -0
  80. package/dist/viewer/render.js +1 -1
  81. package/dist/viewer/render.js.map +1 -1
  82. package/dist/viewer/tui.d.ts +1 -1
  83. package/dist/viewer/tui.js +65 -8
  84. package/dist/viewer/tui.js.map +1 -1
  85. package/dist/workflows/composition.js +0 -4
  86. package/dist/workflows/composition.js.map +1 -1
  87. package/dist/workflows/definition.js +0 -8
  88. package/dist/workflows/definition.js.map +1 -1
  89. package/dist/workflows/engine.js +4 -5
  90. package/dist/workflows/engine.js.map +1 -1
  91. package/dist/workflows/human-decision.d.ts +2 -0
  92. package/dist/workflows/human-decision.js +8 -2
  93. package/dist/workflows/human-decision.js.map +1 -1
  94. package/dist/workflows/index.d.ts +1 -1
  95. package/dist/workflows/index.js.map +1 -1
  96. package/dist/workflows/schema.js +0 -4
  97. package/dist/workflows/schema.js.map +1 -1
  98. package/dist/workflows/settings.d.ts +1 -8
  99. package/dist/workflows/settings.js.map +1 -1
  100. package/dist/workflows/store.d.ts +20 -19
  101. package/dist/workflows/store.js +163 -354
  102. package/dist/workflows/store.js.map +1 -1
  103. package/dist/workflows/tool-input.d.ts +21 -0
  104. package/dist/workflows/tool-input.js +23 -1
  105. package/dist/workflows/tool-input.js.map +1 -1
  106. package/dist/workflows/types.d.ts +6 -8
  107. package/dist/workflows/workflow-message-content.d.ts +38 -0
  108. package/dist/workflows/workflow-message-content.js +157 -0
  109. package/dist/workflows/workflow-message-content.js.map +1 -0
  110. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  111. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  112. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  113. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  114. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  115. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  116. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  117. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  118. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  119. package/docs/DEFERRED_TURNS.md +66 -294
  120. package/docs/HUMAN_DECISIONS.md +29 -30
  121. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  122. package/docs/SQLITE_STATE.md +25 -17
  123. package/docs/WORKFLOW_HOST.md +105 -31
  124. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  125. package/docs/live-replay-protocol.md +8 -4
  126. package/docs/tui-viewer.md +3 -1
  127. package/docs/workflows.md +44 -28
  128. package/herdr-plugin.toml +1 -1
  129. package/package.json +1 -1
  130. package/plugins/herdr/viewer.mjs +14 -5
  131. package/src/channels/adapter-entry.ts +220 -0
  132. package/src/channels/config.ts +296 -0
  133. package/src/channels/protocol.ts +333 -0
  134. package/src/channels/telegram.ts +335 -0
  135. package/src/client/client.ts +6 -2
  136. package/src/client/index.ts +2 -1
  137. package/src/client/protocol.ts +11 -5
  138. package/src/client/view.ts +51 -18
  139. package/src/controllers/index.ts +0 -1
  140. package/src/controllers/sqlite.ts +247 -845
  141. package/src/extension/index.ts +712 -647
  142. package/src/extension/recorder.ts +687 -0
  143. package/src/extension/remote-recorder-store.ts +126 -0
  144. package/src/extension/session-events.ts +119 -0
  145. package/src/extension/session-view.ts +86 -3
  146. package/src/extension/step-message.ts +19 -9
  147. package/src/extension/widget.ts +37 -17
  148. package/src/extension/workflow-message-coordinator.ts +398 -0
  149. package/src/host/channel-effects.ts +465 -0
  150. package/src/host/channel-supervisor.ts +73 -0
  151. package/src/host/runner.ts +2163 -525
  152. package/src/host/state.ts +333 -94
  153. package/src/host/view.ts +254 -122
  154. package/src/host/worker-entry.ts +38 -30
  155. package/src/host/worker-protocol.ts +11 -0
  156. package/src/state/prune.ts +3 -31
  157. package/src/state/schema.ts +76 -97
  158. package/src/state/workflow-messages.ts +599 -0
  159. package/src/viewer/render.ts +1 -5
  160. package/src/viewer/tui.ts +71 -6
  161. package/src/workflows/composition.ts +0 -5
  162. package/src/workflows/definition.ts +0 -8
  163. package/src/workflows/engine.ts +4 -6
  164. package/src/workflows/human-decision.ts +18 -2
  165. package/src/workflows/index.ts +0 -1
  166. package/src/workflows/schema.ts +0 -6
  167. package/src/workflows/settings.ts +1 -20
  168. package/src/workflows/store.ts +237 -489
  169. package/src/workflows/tool-input.ts +36 -1
  170. package/src/workflows/types.ts +6 -8
  171. package/src/workflows/workflow-message-content.ts +197 -0
  172. package/dist/extension/decision-channels.d.ts +0 -134
  173. package/dist/extension/decision-channels.js +0 -1307
  174. package/dist/extension/decision-channels.js.map +0 -1
  175. package/src/extension/decision-channels.ts +0 -1826
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { WorkflowClient } from "../client/client.js";
3
+ import { canonicalJson, type JsonValue } from "../state/json.js";
4
+ import type {
5
+ WorkflowSessionBinding,
6
+ WorkflowSessionCapture,
7
+ WorkflowSessionEventRecord,
8
+ } from "../workflows/types.js";
9
+ import type { SessionRecordingStore } from "./recorder.js";
10
+
11
+ type SessionCommandContext = {
12
+ targetSessionId: string;
13
+ coordinatorEpoch: string;
14
+ };
15
+
16
+ /** Sends bounded session recording batches to the single workflow host writer. */
17
+ export class RemoteSessionRecordingStore implements SessionRecordingStore {
18
+ constructor(
19
+ private readonly client: WorkflowClient,
20
+ private readonly context: () => SessionCommandContext,
21
+ ) {}
22
+
23
+ async hasSessionBinding(runId: string): Promise<boolean> {
24
+ const receipt = await this.request(runId, "status");
25
+ if (typeof receipt.bound !== "boolean") throw new Error("Session recording status is invalid");
26
+ return receipt.bound;
27
+ }
28
+
29
+ async writeSessionBinding(
30
+ runId: string,
31
+ binding: WorkflowSessionBinding,
32
+ attemptId?: string,
33
+ ): Promise<void> {
34
+ await this.request(runId, "bind", {
35
+ binding: toJson(binding),
36
+ ...(attemptId === undefined ? {} : { attemptId }),
37
+ });
38
+ }
39
+
40
+ async writeSessionCapture(
41
+ runId: string,
42
+ capture: WorkflowSessionCapture,
43
+ attemptId?: string,
44
+ ): Promise<void> {
45
+ await this.request(runId, "capture", {
46
+ capture: toJson(capture),
47
+ ...(attemptId === undefined ? {} : { attemptId }),
48
+ });
49
+ }
50
+
51
+ async appendSessionEntry(
52
+ runId: string,
53
+ entry: Record<string, unknown>,
54
+ attemptId?: string,
55
+ ): Promise<number> {
56
+ const receipt = await this.request(runId, "entries", {
57
+ entries: [toJson(entry)],
58
+ ...(attemptId === undefined ? {} : { attemptId }),
59
+ });
60
+ if (!Number.isSafeInteger(receipt.entrySequence) || (receipt.entrySequence as number) <= 0) {
61
+ throw new Error("Session recording entry receipt is invalid");
62
+ }
63
+ return receipt.entrySequence as number;
64
+ }
65
+
66
+ async appendSessionEventBatch(
67
+ runId: string,
68
+ events: WorkflowSessionEventRecord[],
69
+ attemptId?: string,
70
+ ): Promise<void> {
71
+ await this.request(runId, "events", {
72
+ events: toJson(events),
73
+ ...(attemptId === undefined ? {} : { attemptId }),
74
+ });
75
+ }
76
+
77
+ async sessionCounts(
78
+ runId: string,
79
+ attemptId?: string,
80
+ ): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
81
+ const receipt = await this.request(
82
+ runId,
83
+ "status",
84
+ attemptId === undefined ? {} : { attemptId },
85
+ );
86
+ const eventCount = positiveOrZero(receipt.eventCount, "eventCount");
87
+ const entryCount = positiveOrZero(receipt.entryCount, "entryCount");
88
+ const lastEventSeq = positiveOrZero(receipt.lastEventSeq, "lastEventSeq");
89
+ return { eventCount, entryCount, lastEventSeq };
90
+ }
91
+
92
+ private async request(
93
+ runId: string,
94
+ action: "status" | "bind" | "entries" | "events" | "capture",
95
+ extra: Record<string, JsonValue> = {},
96
+ ): Promise<Record<string, unknown>> {
97
+ const idempotencyKey = `session-record-${randomUUID()}`;
98
+ const response = await this.client.requestDurable({
99
+ operation: "session.record",
100
+ requestId: idempotencyKey,
101
+ idempotencyKey,
102
+ runId,
103
+ payload: { ...this.context(), action, ...extra },
104
+ });
105
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
106
+ throw new Error(response.error ?? "Workflow host rejected the session recording batch");
107
+ }
108
+ if (!isRecord(response.receipt)) throw new Error("Session recording receipt is invalid");
109
+ return response.receipt;
110
+ }
111
+ }
112
+
113
+ function toJson(value: unknown): JsonValue {
114
+ return JSON.parse(canonicalJson(value)) as JsonValue;
115
+ }
116
+
117
+ function positiveOrZero(value: unknown, name: string): number {
118
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
119
+ throw new Error(`Session recording ${name} is invalid`);
120
+ }
121
+ return value as number;
122
+ }
123
+
124
+ function isRecord(value: unknown): value is Record<string, unknown> {
125
+ return typeof value === "object" && value !== null && !Array.isArray(value);
126
+ }
@@ -0,0 +1,119 @@
1
+ export type PublicAssistantMessageEvent =
2
+ | { type: "start"; partial: unknown }
3
+ | { type: "text_start"; contentIndex: number; partial: unknown }
4
+ | { type: "text_delta"; contentIndex: number; delta: string; partial: unknown }
5
+ | { type: "text_end"; contentIndex: number; content: string; partial: unknown }
6
+ | { type: "thinking_start"; contentIndex: number; partial: unknown }
7
+ | { type: "thinking_delta"; contentIndex: number; delta: string; partial: unknown }
8
+ | { type: "thinking_end"; contentIndex: number; content: string; partial: unknown }
9
+ | { type: "toolcall_start"; contentIndex: number; partial: unknown }
10
+ | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: unknown }
11
+ | { type: "toolcall_end"; contentIndex: number; toolCall: unknown; partial: unknown }
12
+ | { type: "done"; reason: "stop" | "length" | "toolUse" | "deferred"; message: unknown }
13
+ | { type: "error"; reason: "aborted" | "error"; error: unknown };
14
+
15
+ export type TurnStartEventLike = { turnIndex: number };
16
+ export type TurnEndEventLike = { turnIndex: number; message: unknown };
17
+ export type MessageStartEventLike = { message: unknown };
18
+ export type MessageUpdateEventLike = {
19
+ message: unknown;
20
+ assistantMessageEvent: PublicAssistantMessageEvent;
21
+ };
22
+ export type MessageEndEventLike = { message: unknown };
23
+ export type ToolExecutionStartEventLike = {
24
+ toolCallId: string;
25
+ toolName: string;
26
+ args: unknown;
27
+ };
28
+ export type ToolExecutionUpdateEventLike = { toolCallId: string };
29
+ export type ToolExecutionEndEventLike = {
30
+ toolCallId: string;
31
+ toolName: string;
32
+ result: unknown;
33
+ isError: boolean;
34
+ };
35
+
36
+ export type NormalizedAssistantEvent =
37
+ | { type: "start" }
38
+ | { type: "text_start"; contentIndex: number }
39
+ | { type: "text_delta"; contentIndex: number; delta: string }
40
+ | { type: "text_end"; contentIndex: number; content: string }
41
+ | { type: "thinking_start"; contentIndex: number }
42
+ | { type: "thinking_delta"; contentIndex: number; delta: string }
43
+ | { type: "thinking_end"; contentIndex: number; content: string }
44
+ | { type: "toolcall_start"; contentIndex: number }
45
+ | { type: "toolcall_delta"; contentIndex: number; delta: string }
46
+ | { type: "toolcall_end"; contentIndex: number; toolCall: unknown }
47
+ | { type: "done"; reason: "stop" | "length" | "toolUse" | "deferred" }
48
+ | { type: "error"; reason: "aborted" | "error" };
49
+
50
+ /** Remove cumulative snapshots while preserving every semantic stream event. */
51
+ export function normalizeAssistantEvent(
52
+ event: PublicAssistantMessageEvent,
53
+ ): NormalizedAssistantEvent {
54
+ switch (event.type) {
55
+ case "start":
56
+ return { type: event.type };
57
+ case "text_start":
58
+ case "thinking_start":
59
+ case "toolcall_start":
60
+ return { type: event.type, contentIndex: event.contentIndex };
61
+ case "text_delta":
62
+ case "thinking_delta":
63
+ case "toolcall_delta":
64
+ return { type: event.type, contentIndex: event.contentIndex, delta: event.delta };
65
+ case "text_end":
66
+ case "thinking_end":
67
+ return { type: event.type, contentIndex: event.contentIndex, content: event.content };
68
+ case "toolcall_end":
69
+ return {
70
+ type: event.type,
71
+ contentIndex: event.contentIndex,
72
+ toolCall: event.toolCall,
73
+ };
74
+ case "done":
75
+ return { type: event.type, reason: event.reason };
76
+ case "error":
77
+ return { type: event.type, reason: event.reason };
78
+ default: {
79
+ const exhaustive: never = event;
80
+ return exhaustive;
81
+ }
82
+ }
83
+ }
84
+
85
+ export function messageRole(message: unknown): string {
86
+ if (typeof message !== "object" || message === null || !("role" in message)) {
87
+ return "unknown";
88
+ }
89
+ const role = (message as { role?: unknown }).role;
90
+ return typeof role === "string" && role.length > 0 ? role : "unknown";
91
+ }
92
+
93
+ export function toolCallIdFromAssistantEvent(event: NormalizedAssistantEvent): string | null {
94
+ if (event.type !== "toolcall_end" || typeof event.toolCall !== "object" || !event.toolCall) {
95
+ return null;
96
+ }
97
+ const id = (event.toolCall as { id?: unknown }).id;
98
+ return typeof id === "string" && id.length > 0 ? id : null;
99
+ }
100
+
101
+ export function toolStartedPayload(event: ToolExecutionStartEventLike): Record<string, unknown> {
102
+ return { toolName: event.toolName, args: event.args };
103
+ }
104
+
105
+ export function toolFinishedPayload(event: ToolExecutionEndEventLike): Record<string, unknown> {
106
+ return { toolName: event.toolName, isError: event.isError, result: event.result };
107
+ }
108
+
109
+ export function turnFinishedPayload(
110
+ event: TurnEndEventLike,
111
+ messageId: string | undefined,
112
+ toolCallIds: string[],
113
+ ): Record<string, unknown> {
114
+ return {
115
+ turnIndex: event.turnIndex,
116
+ ...(messageId === undefined ? {} : { messageId }),
117
+ toolCallIds,
118
+ };
119
+ }
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { WorkflowSessionView } from "../client/view.js";
3
- import type { WorkflowDefinitionSnapshot, WorkflowRunState } from "../workflows/types.js";
3
+ import type {
4
+ WorkflowDefinitionSnapshot,
5
+ WorkflowRunState,
6
+ WorkflowUpdateRecord,
7
+ } from "../workflows/types.js";
4
8
  import { buildWidgetView } from "./widget.js";
5
9
 
6
10
  const WIDGET_KEY = "pi-workflows";
@@ -15,6 +19,7 @@ export class SessionWorkflowView {
15
19
  private stepCount = 0;
16
20
  private visible = false;
17
21
  private actionHint: string | undefined;
22
+ private lastNoticeKey: string | null = null;
18
23
 
19
24
  update(session: WorkflowSessionView, ctx: ExtensionContext): void {
20
25
  const run = session.run;
@@ -33,6 +38,7 @@ export class SessionWorkflowView {
33
38
  this.stepCount = run.state.steps.length;
34
39
  }
35
40
  this.session = session;
41
+ this.notifyTransition(previousRun, session, ctx);
36
42
  this.render(ctx);
37
43
  }
38
44
 
@@ -63,6 +69,7 @@ export class SessionWorkflowView {
63
69
  this.shownScroll = 0;
64
70
  this.maxScroll = 0;
65
71
  this.stepCount = 0;
72
+ this.lastNoticeKey = null;
66
73
  this.clearWidget(ctx);
67
74
  }
68
75
 
@@ -100,9 +107,11 @@ export class SessionWorkflowView {
100
107
  run.display.status === "paused",
101
108
  width,
102
109
  theme,
103
- undefined,
110
+ workflowUpdates(run.updates),
104
111
  this.actionHint,
105
112
  run.display.status,
113
+ run.display.reason,
114
+ run.display.controls,
106
115
  );
107
116
  this.shownScroll = view.scroll;
108
117
  this.maxScroll = view.maxScroll;
@@ -118,10 +127,51 @@ export class SessionWorkflowView {
118
127
  } else {
119
128
  ctx.ui.setWidget(WIDGET_KEY, render());
120
129
  }
121
- ctx.ui.setStatus(WIDGET_KEY, `${state.workflowName} [${run.display.status}]`);
130
+ const focus = state.currentNode ?? state.waitingOn;
131
+ ctx.ui.setStatus(
132
+ WIDGET_KEY,
133
+ `${state.workflowName} [${run.display.status}]${focus === undefined ? "" : ` ${focus}`}`,
134
+ );
122
135
  this.visible = true;
123
136
  });
124
137
  }
138
+
139
+ private notifyTransition(
140
+ previousRun: WorkflowSessionView["run"] | undefined,
141
+ session: WorkflowSessionView,
142
+ ctx: ExtensionContext,
143
+ ): void {
144
+ const run = session.run;
145
+ if (run === null || !isWorkflowRunState(run.state)) return;
146
+ const status = run.display.status;
147
+ const decision = session.pendingInteractions.some(
148
+ (value) => isRecord(value) && value.kind === "decision" && value.status === "pending",
149
+ );
150
+ const shouldNotify =
151
+ isTerminalStatus(status) ||
152
+ (status === "waiting" && (decision || previousRun?.display.status !== "waiting")) ||
153
+ (status === "paused" && previousRun?.display.status !== "paused");
154
+ if (!shouldNotify) return;
155
+ const key = `${run.runId}:${status}:${decision ? "decision" : "workflow"}`;
156
+ if (key === this.lastNoticeKey) return;
157
+ this.lastNoticeKey = key;
158
+ const reason = run.display.reason?.trim();
159
+ const message = decision
160
+ ? `Workflow ${run.state.workflowName} needs a human decision.`
161
+ : reason && reason.length > 0
162
+ ? reason
163
+ : `Workflow ${run.state.workflowName} ${status.replace("_", " ")}.`;
164
+ safelyUpdateUi(ctx, () => {
165
+ ctx.ui.notify(
166
+ message,
167
+ status === "failed" || status === "timed_out" || status === "ambiguous"
168
+ ? "error"
169
+ : status === "waiting" || status === "paused"
170
+ ? "warning"
171
+ : "info",
172
+ );
173
+ });
174
+ }
125
175
  }
126
176
 
127
177
  function isWorkflowRunState(value: unknown): value is WorkflowRunState {
@@ -135,6 +185,25 @@ function isWorkflowRunState(value: unknown): value is WorkflowRunState {
135
185
  );
136
186
  }
137
187
 
188
+ function workflowUpdates(values: readonly unknown[]): WorkflowUpdateRecord[] {
189
+ return values.filter(isWorkflowUpdateRecord);
190
+ }
191
+
192
+ function isWorkflowUpdateRecord(value: unknown): value is WorkflowUpdateRecord {
193
+ return (
194
+ isRecord(value) &&
195
+ typeof value.updateId === "string" &&
196
+ typeof value.seq === "number" &&
197
+ typeof value.at === "string" &&
198
+ typeof value.runId === "string" &&
199
+ typeof value.nodeId === "string" &&
200
+ typeof value.attemptId === "string" &&
201
+ typeof value.type === "string" &&
202
+ typeof value.key === "string" &&
203
+ isRecord(value.data)
204
+ );
205
+ }
206
+
138
207
  function isWorkflowDefinitionSnapshot(value: unknown): value is WorkflowDefinitionSnapshot {
139
208
  return (
140
209
  typeof value === "object" &&
@@ -145,6 +214,20 @@ function isWorkflowDefinitionSnapshot(value: unknown): value is WorkflowDefiniti
145
214
  );
146
215
  }
147
216
 
217
+ function isTerminalStatus(status: string): boolean {
218
+ return (
219
+ status === "completed" ||
220
+ status === "failed" ||
221
+ status === "timed_out" ||
222
+ status === "cancelled" ||
223
+ status === "ambiguous"
224
+ );
225
+ }
226
+
227
+ function isRecord(value: unknown): value is Record<string, unknown> {
228
+ return typeof value === "object" && value !== null && !Array.isArray(value);
229
+ }
230
+
148
231
  function safelyUpdateUi(ctx: ExtensionContext, update: () => void): void {
149
232
  try {
150
233
  if (ctx.hasUI) update();
@@ -6,6 +6,7 @@ import type {
6
6
  AgentStepPresentation,
7
7
  AgentStepSubmission,
8
8
  } from "../workflows/types.js";
9
+ import { WORKFLOW_STEP_MESSAGE_TYPE } from "../workflows/workflow-message-content.js";
9
10
  import {
10
11
  cleanOptionalSingleLine,
11
12
  cleanSingleLine,
@@ -15,17 +16,19 @@ import {
15
16
  type MessageCardView,
16
17
  } from "./message-card.js";
17
18
 
18
- export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = "pi-workflows-agent-step";
19
+ export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = WORKFLOW_STEP_MESSAGE_TYPE;
19
20
  export const WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA = "pi-workflows.agent-step-message.v1";
20
21
 
21
- type PromptDeliveryKind = "step" | "reminder" | "resume";
22
+ type PromptDeliveryReason = "initial" | "reminder" | "resumed";
22
23
 
23
24
  export type WorkflowAgentStepMessageDetails = {
24
25
  schema: typeof WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA;
25
- kind: PromptDeliveryKind;
26
+ kind: "step";
27
+ reason: PromptDeliveryReason;
26
28
  contract: AgentStepContract;
27
29
  presentation?: AgentStepPresentation;
28
- turnIntentId?: string;
30
+ requestId: string;
31
+ workflowMessageId: string;
29
32
  };
30
33
 
31
34
  type WorkflowAgentStepMessage = {
@@ -51,14 +54,14 @@ export function buildWorkflowAgentStepView(
51
54
  ): WorkflowAgentStepView {
52
55
  const details = parseDetails(message.details);
53
56
  const contract = details?.contract;
54
- const kind = details?.kind ?? "step";
57
+ const reason = details?.reason ?? "initial";
55
58
  const workflowName = cleanSingleLine(contract?.workflowName ?? "Workflow");
56
59
  const nodeId = cleanSingleLine(contract?.nodeId ?? "step");
57
60
  const runTitle = cleanOptionalSingleLine(details?.presentation?.runTitle);
58
61
  const statusDetail = cleanOptionalSingleLine(details?.presentation?.statusDetail);
59
62
  const label = runTitle ?? workflowName;
60
- const suffix = kind === "step" ? "" : ` · ${kind}`;
61
- const glyph = kind === "step" ? "▶" : "↻";
63
+ const suffix = reason === "initial" ? "" : ` · ${reason}`;
64
+ const glyph = reason === "initial" ? "▶" : "↻";
62
65
  const title = paintMessageCard(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
63
66
 
64
67
  if (!expanded) {
@@ -80,7 +83,7 @@ export function buildWorkflowAgentStepView(
80
83
  `Run id: ${cleanSingleLine(contract?.runId ?? "unknown")}`,
81
84
  `Node id: ${nodeId}`,
82
85
  `Attempt id: ${cleanSingleLine(contract?.attemptId ?? "unknown")}`,
83
- `Delivery: ${kind}`,
86
+ `Reason: ${reason}`,
84
87
  `Completion: ${assistant ? "assistant response" : "workflow submission"}`,
85
88
  `Expected output: ${cleanSingleLine(expectedOutput)}`,
86
89
  ];
@@ -220,7 +223,14 @@ function parseDetails(value: unknown): WorkflowAgentStepMessageDetails | undefin
220
223
  if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
221
224
  const candidate = value as Partial<WorkflowAgentStepMessageDetails>;
222
225
  if (candidate.schema !== WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA) return undefined;
223
- if (candidate.kind !== "step" && candidate.kind !== "reminder" && candidate.kind !== "resume") {
226
+ if (
227
+ candidate.kind !== "step" ||
228
+ (candidate.reason !== "initial" &&
229
+ candidate.reason !== "reminder" &&
230
+ candidate.reason !== "resumed") ||
231
+ typeof candidate.requestId !== "string" ||
232
+ typeof candidate.workflowMessageId !== "string"
233
+ ) {
224
234
  return undefined;
225
235
  }
226
236
  const contract = candidate.contract;
@@ -38,17 +38,28 @@ const STATUS_GLYPHS: Record<WorkflowDisplayStatus, string> = {
38
38
  const PI_MAX_WIDGET_LINES = 10;
39
39
  const MAX_NODE_ERROR_CHARS = 120;
40
40
 
41
- export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
42
- if (state.currentNode === nodeId) {
41
+ function displayedRunningNode(
42
+ state: WorkflowRunState,
43
+ displayStatus: WorkflowDisplayStatus | undefined,
44
+ ): string | undefined {
45
+ return state.currentNode ?? (displayStatus === "running" ? state.waitingOn : undefined);
46
+ }
47
+
48
+ export function nodeGlyph(
49
+ state: WorkflowRunState,
50
+ nodeId: string,
51
+ displayStatus?: WorkflowDisplayStatus,
52
+ ): string {
53
+ if (displayedRunningNode(state, displayStatus) === nodeId) {
43
54
  return "◐";
44
55
  }
56
+ if (state.waitingOn === nodeId) {
57
+ return "⏸";
58
+ }
45
59
  const result = state.results[nodeId];
46
60
  if (!result) {
47
61
  return "·";
48
62
  }
49
- if (state.waitingOn === nodeId) {
50
- return "⏸";
51
- }
52
63
  return result.outcome === "ok" ? "✓" : "✗";
53
64
  }
54
65
 
@@ -83,6 +94,8 @@ export function buildWidgetView(
83
94
  updateHistory?: WorkflowUpdateRecord[],
84
95
  actionHint?: string,
85
96
  displayStatus?: WorkflowDisplayStatus,
97
+ displayReason?: string | null,
98
+ controls?: readonly string[],
86
99
  ): WidgetView {
87
100
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
88
101
  if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
@@ -100,19 +113,23 @@ export function buildWidgetView(
100
113
  const header = `${paint(theme, headerTone, glyph)} workflow ${sanitizeText(state.workflowName)}${title} ${paint(theme, headerTone, `[${statusText}]`)}`;
101
114
 
102
115
  const footer: string[] = [];
116
+ if (displayReason) {
117
+ footer.push(paint(theme, "dim", ` ${sanitizeText(displayReason)}`));
118
+ }
119
+ if (controls !== undefined && controls.length > 0) {
120
+ footer.push(paint(theme, "dim", ` controls: ${controls.join(", ")}`));
121
+ }
103
122
  if (state.error) {
104
123
  footer.push(paint(theme, "error", ` error: ${truncate(sanitizeText(state.error), 120)}`));
105
124
  }
106
125
  if (status === "waiting" && state.waitingOn) {
107
- footer.push(
108
- paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
109
- );
126
+ footer.push(paint(theme, "warning", ` waiting on step: ${sanitizeText(state.waitingOn)}`));
110
127
  }
111
128
  const hint = actionHint?.trim() ? sanitizeText(actionHint) : undefined;
112
129
  const progress = progressLines(state, now, updateHistory).slice(0, 4);
113
130
  const baseBudget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
114
131
  const nodes = displayNodeIds(snapshot).map((nodeId) =>
115
- compactNodeLine(state, snapshot, nodeId, now, paused, theme),
132
+ compactNodeLine(state, snapshot, nodeId, now, paused, status, theme),
116
133
  );
117
134
  const combineHintWithWindow =
118
135
  hint !== undefined && nodes.length > 0 && nodes.length + 1 > baseBudget;
@@ -260,15 +277,17 @@ function compactNodeLine(
260
277
  nodeId: string,
261
278
  now: Date,
262
279
  paused: boolean,
280
+ displayStatus: WorkflowDisplayStatus,
263
281
  theme?: WidgetTheme,
264
282
  ): string {
265
283
  const node = snapshot.nodes[nodeId];
266
- const glyph = nodeGlyph(state, nodeId);
284
+ const runningNode = displayedRunningNode(state, displayStatus);
285
+ const isCurrent = runningNode === nodeId;
286
+ const isWaiting = state.waitingOn === nodeId && !isCurrent;
287
+ const glyph = nodeGlyph(state, nodeId, displayStatus);
267
288
  const typeGlyph = node ? nodeTypeGlyph(node.nodeType, node.actionExecution) : "?";
268
289
  const name = sanitizeText(nodeId);
269
- const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
270
- const isCurrent = state.currentNode === nodeId;
271
- const isWaiting = state.waitingOn === nodeId;
290
+ const segments = nodeRuntimeSegments(state, snapshot, nodeId, now, isCurrent);
272
291
 
273
292
  if (isCurrent || isWaiting) {
274
293
  const tone = paused || isWaiting ? "warning" : "accent";
@@ -301,6 +320,7 @@ function nodeRuntimeSegments(
301
320
  snapshot: WorkflowDefinitionSnapshot,
302
321
  nodeId: string,
303
322
  now: Date,
323
+ isCurrent: boolean,
304
324
  ): string[] {
305
325
  const segments: string[] = [];
306
326
  const node = snapshot.nodes[nodeId];
@@ -312,11 +332,11 @@ function nodeRuntimeSegments(
312
332
  segments.push("assistant response");
313
333
  }
314
334
  const completedAttempts = state.steps.filter((step) => step.nodeId === nodeId).length;
315
- const attempts = completedAttempts + (state.currentNode === nodeId ? 1 : 0);
335
+ const attempts = completedAttempts + (isCurrent ? 1 : 0);
316
336
  if (attempts > 1) {
317
337
  segments.push(`↻${attempts}`);
318
338
  }
319
- if (state.currentNode === nodeId && state.currentSettingsChangeNumber !== undefined) {
339
+ if (isCurrent && state.currentSettingsChangeNumber !== undefined) {
320
340
  segments.push(`settings ${state.currentSettingsChangeNumber}`);
321
341
  } else {
322
342
  const latest = state.steps.findLast((step) => step.nodeId === nodeId);
@@ -326,8 +346,8 @@ function nodeRuntimeSegments(
326
346
  }
327
347
 
328
348
  const result = state.results[nodeId];
329
- if (state.currentNode === nodeId) {
330
- if (state.statusDetail) {
349
+ if (isCurrent) {
350
+ if (state.currentNode === nodeId && state.statusDetail) {
331
351
  segments.push(sanitizeText(state.statusDetail));
332
352
  }
333
353
  const elapsed = elapsedSince(state.currentNodeStartedAt, now);