@osolmaz/pi-workflows 0.15.3 → 0.16.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 (207) hide show
  1. package/README.md +24 -8
  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/activity.d.ts +2 -0
  15. package/dist/client/activity.js +6 -0
  16. package/dist/client/activity.js.map +1 -0
  17. package/dist/client/client.d.ts +102 -0
  18. package/dist/client/client.js +737 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/index.d.ts +3 -0
  21. package/dist/client/index.js +3 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client/materialize.d.ts +7 -0
  24. package/dist/client/materialize.js +177 -0
  25. package/dist/client/materialize.js.map +1 -0
  26. package/dist/client/protocol.d.ts +60 -0
  27. package/dist/client/protocol.js +275 -0
  28. package/dist/client/protocol.js.map +1 -0
  29. package/dist/client/resolver.d.ts +23 -0
  30. package/dist/client/resolver.js +2 -0
  31. package/dist/client/resolver.js.map +1 -0
  32. package/dist/client/view.d.ts +144 -0
  33. package/dist/client/view.js +3 -0
  34. package/dist/client/view.js.map +1 -0
  35. package/dist/controllers/index.d.ts +1 -1
  36. package/dist/controllers/index.js.map +1 -1
  37. package/dist/controllers/sqlite.d.ts +47 -157
  38. package/dist/controllers/sqlite.js +230 -406
  39. package/dist/controllers/sqlite.js.map +1 -1
  40. package/dist/extension/index.d.ts +23 -1
  41. package/dist/extension/index.js +734 -555
  42. package/dist/extension/index.js.map +1 -1
  43. package/dist/extension/recorder.d.ts +95 -0
  44. package/dist/extension/recorder.js +530 -0
  45. package/dist/extension/recorder.js.map +1 -0
  46. package/dist/extension/remote-recorder-store.d.ts +25 -0
  47. package/dist/extension/remote-recorder-store.js +81 -0
  48. package/dist/extension/remote-recorder-store.js.map +1 -0
  49. package/dist/extension/session-events.d.ts +134 -0
  50. package/dist/extension/session-events.js +60 -0
  51. package/dist/extension/session-events.js.map +1 -0
  52. package/dist/extension/session-view.d.ts +9 -2
  53. package/dist/extension/session-view.js +117 -51
  54. package/dist/extension/session-view.js.map +1 -1
  55. package/dist/extension/step-message.d.ts +6 -4
  56. package/dist/extension/step-message.js +12 -6
  57. package/dist/extension/step-message.js.map +1 -1
  58. package/dist/extension/widget.d.ts +3 -2
  59. package/dist/extension/widget.js +41 -24
  60. package/dist/extension/widget.js.map +1 -1
  61. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  62. package/dist/extension/workflow-message-coordinator.js +294 -0
  63. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  64. package/dist/host/channel-effects.d.ts +72 -0
  65. package/dist/host/channel-effects.js +271 -0
  66. package/dist/host/channel-effects.js.map +1 -0
  67. package/dist/host/channel-supervisor.d.ts +21 -0
  68. package/dist/host/channel-supervisor.js +54 -0
  69. package/dist/host/channel-supervisor.js.map +1 -0
  70. package/dist/host/child-worker-supervisor.js +1 -1
  71. package/dist/host/child-worker-supervisor.js.map +1 -1
  72. package/dist/host/resolver-entry.d.ts +2 -23
  73. package/dist/host/resolver-entry.js +1 -1
  74. package/dist/host/resolver-entry.js.map +1 -1
  75. package/dist/host/runner.d.ts +54 -8
  76. package/dist/host/runner.js +1997 -305
  77. package/dist/host/runner.js.map +1 -1
  78. package/dist/host/state.d.ts +25 -19
  79. package/dist/host/state.js +303 -89
  80. package/dist/host/state.js.map +1 -1
  81. package/dist/host/view.d.ts +83 -0
  82. package/dist/host/view.js +975 -0
  83. package/dist/host/view.js.map +1 -0
  84. package/dist/host/worker-protocol.js +1 -1
  85. package/dist/host/worker-protocol.js.map +1 -1
  86. package/dist/state/database.d.ts +1 -0
  87. package/dist/state/database.js +15 -0
  88. package/dist/state/database.js.map +1 -1
  89. package/dist/state/prune.d.ts +3 -1
  90. package/dist/state/prune.js +9 -33
  91. package/dist/state/prune.js.map +1 -1
  92. package/dist/state/schema.js +88 -98
  93. package/dist/state/schema.js.map +1 -1
  94. package/dist/state/workflow-messages.d.ts +96 -0
  95. package/dist/state/workflow-messages.js +335 -0
  96. package/dist/state/workflow-messages.js.map +1 -0
  97. package/dist/viewer/backup.d.ts +2 -0
  98. package/dist/viewer/backup.js +28 -0
  99. package/dist/viewer/backup.js.map +1 -0
  100. package/dist/viewer/cli.d.ts +4 -0
  101. package/dist/viewer/cli.js +150 -170
  102. package/dist/viewer/cli.js.map +1 -1
  103. package/dist/viewer/render.js +1 -1
  104. package/dist/viewer/render.js.map +1 -1
  105. package/dist/viewer/tui.d.ts +5 -7
  106. package/dist/viewer/tui.js +245 -108
  107. package/dist/viewer/tui.js.map +1 -1
  108. package/dist/workflows/human-decision.d.ts +2 -0
  109. package/dist/workflows/human-decision.js +8 -2
  110. package/dist/workflows/human-decision.js.map +1 -1
  111. package/dist/workflows/index.d.ts +1 -1
  112. package/dist/workflows/index.js.map +1 -1
  113. package/dist/workflows/settings.d.ts +1 -8
  114. package/dist/workflows/settings.js.map +1 -1
  115. package/dist/workflows/store.d.ts +67 -20
  116. package/dist/workflows/store.js +451 -378
  117. package/dist/workflows/store.js.map +1 -1
  118. package/dist/workflows/tool-input.d.ts +21 -0
  119. package/dist/workflows/tool-input.js +23 -1
  120. package/dist/workflows/tool-input.js.map +1 -1
  121. package/dist/workflows/types.d.ts +5 -5
  122. package/dist/workflows/workflow-message-content.d.ts +38 -0
  123. package/dist/workflows/workflow-message-content.js +157 -0
  124. package/dist/workflows/workflow-message-content.js.map +1 -0
  125. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  126. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  127. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  128. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  129. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  130. package/docs/2026-09-01-unified-workflow-client-plan.md +385 -0
  131. package/docs/2026-09-02-installed-live-e2e-plan.md +239 -0
  132. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  133. package/docs/DEFERRED_TURNS.md +66 -294
  134. package/docs/HUMAN_DECISIONS.md +29 -30
  135. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  136. package/docs/SQLITE_STATE.md +31 -25
  137. package/docs/WORKFLOW_HOST.md +155 -77
  138. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  139. package/docs/development.md +2 -1
  140. package/docs/live-replay-protocol.md +74 -132
  141. package/docs/tui-viewer.md +12 -14
  142. package/docs/workflows.md +78 -28
  143. package/herdr-plugin.toml +1 -1
  144. package/package.json +9 -3
  145. package/plugins/herdr/viewer.mjs +14 -5
  146. package/protocol/client.v1.schema.json +137 -0
  147. package/protocol/fixtures/client-v1.json +23 -0
  148. package/src/channels/adapter-entry.ts +220 -0
  149. package/src/channels/config.ts +296 -0
  150. package/src/channels/protocol.ts +333 -0
  151. package/src/channels/telegram.ts +335 -0
  152. package/src/client/activity.ts +6 -0
  153. package/src/client/client.ts +939 -0
  154. package/src/client/index.ts +25 -0
  155. package/src/client/materialize.ts +228 -0
  156. package/src/client/protocol.ts +333 -0
  157. package/src/client/resolver.ts +26 -0
  158. package/src/client/view.ts +163 -0
  159. package/src/controllers/index.ts +0 -1
  160. package/src/controllers/sqlite.ts +339 -808
  161. package/src/extension/index.ts +853 -665
  162. package/src/extension/recorder.ts +687 -0
  163. package/src/extension/remote-recorder-store.ts +126 -0
  164. package/src/extension/session-events.ts +119 -0
  165. package/src/extension/session-view.ts +157 -51
  166. package/src/extension/step-message.ts +19 -9
  167. package/src/extension/widget.ts +53 -25
  168. package/src/extension/workflow-message-coordinator.ts +334 -0
  169. package/src/host/channel-effects.ts +465 -0
  170. package/src/host/channel-supervisor.ts +73 -0
  171. package/src/host/child-worker-supervisor.ts +1 -1
  172. package/src/host/resolver-entry.ts +11 -26
  173. package/src/host/runner.ts +2639 -450
  174. package/src/host/state.ts +404 -137
  175. package/src/host/view.ts +1212 -0
  176. package/src/host/worker-protocol.ts +1 -1
  177. package/src/state/database.ts +13 -0
  178. package/src/state/prune.ts +14 -42
  179. package/src/state/schema.ts +88 -98
  180. package/src/state/workflow-messages.ts +548 -0
  181. package/src/viewer/backup.ts +29 -0
  182. package/src/viewer/cli.ts +171 -185
  183. package/src/viewer/render.ts +1 -5
  184. package/src/viewer/tui.ts +261 -124
  185. package/src/workflows/human-decision.ts +18 -2
  186. package/src/workflows/index.ts +0 -1
  187. package/src/workflows/settings.ts +1 -20
  188. package/src/workflows/store.ts +650 -511
  189. package/src/workflows/tool-input.ts +36 -1
  190. package/src/workflows/types.ts +5 -5
  191. package/src/workflows/workflow-message-content.ts +197 -0
  192. package/dist/extension/decision-channels.d.ts +0 -134
  193. package/dist/extension/decision-channels.js +0 -1307
  194. package/dist/extension/decision-channels.js.map +0 -1
  195. package/dist/host/client.d.ts +0 -48
  196. package/dist/host/client.js +0 -216
  197. package/dist/host/client.js.map +0 -1
  198. package/dist/host/protocol.d.ts +0 -38
  199. package/dist/host/protocol.js +0 -156
  200. package/dist/host/protocol.js.map +0 -1
  201. package/dist/viewer/watch.d.ts +0 -6
  202. package/dist/viewer/watch.js +0 -46
  203. package/dist/viewer/watch.js.map +0 -1
  204. package/src/extension/decision-channels.ts +0 -1826
  205. package/src/host/client.ts +0 -293
  206. package/src/host/protocol.ts +0 -196
  207. package/src/viewer/watch.ts +0 -51
@@ -1,5 +1,6 @@
1
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { WorkflowDisplayStatus } from "../client/view.js";
3
4
  import { formatDuration } from "../render/format.js";
4
5
  import { nodeTypeGlyph } from "../render/node-type.js";
5
6
  import {
@@ -14,17 +15,19 @@ import { sanitizeText } from "../workflows/text.js";
14
15
  import type {
15
16
  WorkflowDefinitionSnapshot,
16
17
  WorkflowRunState,
17
- WorkflowRunStatus,
18
18
  WorkflowUpdateRecord,
19
19
  } from "../workflows/types.js";
20
20
 
21
- const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
21
+ const STATUS_GLYPHS: Record<WorkflowDisplayStatus, string> = {
22
+ queued: "…",
22
23
  running: "◐",
23
- waiting: "",
24
+ waiting: "",
25
+ paused: "⏸",
24
26
  completed: "✓",
25
27
  failed: "✗",
26
28
  timed_out: "✗",
27
29
  cancelled: "✗",
30
+ ambiguous: "!",
28
31
  };
29
32
 
30
33
  /**
@@ -35,17 +38,28 @@ const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
35
38
  const PI_MAX_WIDGET_LINES = 10;
36
39
  const MAX_NODE_ERROR_CHARS = 120;
37
40
 
38
- export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
39
- 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) {
40
54
  return "◐";
41
55
  }
56
+ if (state.waitingOn === nodeId) {
57
+ return "⏸";
58
+ }
42
59
  const result = state.results[nodeId];
43
60
  if (!result) {
44
61
  return "·";
45
62
  }
46
- if (state.waitingOn === nodeId) {
47
- return "⏸";
48
- }
49
63
  return result.outcome === "ok" ? "✓" : "✗";
50
64
  }
51
65
 
@@ -79,35 +93,43 @@ export function buildWidgetView(
79
93
  theme?: WidgetTheme,
80
94
  updateHistory?: WorkflowUpdateRecord[],
81
95
  actionHint?: string,
96
+ displayStatus?: WorkflowDisplayStatus,
97
+ displayReason?: string | null,
98
+ controls?: readonly string[],
82
99
  ): WidgetView {
83
100
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
84
101
  if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
85
102
 
86
103
  // `held` covers pauses the state cannot see yet: an escape-interrupted
87
104
  // step or a pause requested while the current node is still finishing.
88
- const paused = held || state.paused === true;
89
- const glyph = paused ? "⏸" : (STATUS_GLYPHS[state.status] ?? "·");
90
- const statusText = paused ? "paused" : state.status;
105
+ const status = displayStatus ?? (held || state.paused === true ? "paused" : state.status);
106
+ const paused = status === "paused";
107
+ const glyph = STATUS_GLYPHS[status];
108
+ const statusText = status;
91
109
  // Titles, status details, and errors can carry model- or shell-controlled
92
110
  // text; never let escape sequences or newlines reach the terminal.
93
111
  const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
94
- const headerTone = statusTone(paused ? "waiting" : state.status);
112
+ const headerTone = statusTone(status);
95
113
  const header = `${paint(theme, headerTone, glyph)} workflow ${sanitizeText(state.workflowName)}${title} ${paint(theme, headerTone, `[${statusText}]`)}`;
96
114
 
97
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
+ }
98
122
  if (state.error) {
99
123
  footer.push(paint(theme, "error", ` error: ${truncate(sanitizeText(state.error), 120)}`));
100
124
  }
101
- if (state.status === "waiting" && state.waitingOn) {
102
- footer.push(
103
- paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
104
- );
125
+ if (status === "waiting" && state.waitingOn) {
126
+ footer.push(paint(theme, "warning", ` waiting on step: ${sanitizeText(state.waitingOn)}`));
105
127
  }
106
128
  const hint = actionHint?.trim() ? sanitizeText(actionHint) : undefined;
107
129
  const progress = progressLines(state, now, updateHistory).slice(0, 4);
108
130
  const baseBudget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
109
131
  const nodes = displayNodeIds(snapshot).map((nodeId) =>
110
- compactNodeLine(state, snapshot, nodeId, now, paused, theme),
132
+ compactNodeLine(state, snapshot, nodeId, now, paused, status, theme),
111
133
  );
112
134
  const combineHintWithWindow =
113
135
  hint !== undefined && nodes.length > 0 && nodes.length + 1 > baseBudget;
@@ -255,15 +277,17 @@ function compactNodeLine(
255
277
  nodeId: string,
256
278
  now: Date,
257
279
  paused: boolean,
280
+ displayStatus: WorkflowDisplayStatus,
258
281
  theme?: WidgetTheme,
259
282
  ): string {
260
283
  const node = snapshot.nodes[nodeId];
261
- 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);
262
288
  const typeGlyph = node ? nodeTypeGlyph(node.nodeType, node.actionExecution) : "?";
263
289
  const name = sanitizeText(nodeId);
264
- const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
265
- const isCurrent = state.currentNode === nodeId;
266
- const isWaiting = state.waitingOn === nodeId;
290
+ const segments = nodeRuntimeSegments(state, snapshot, nodeId, now, isCurrent);
267
291
 
268
292
  if (isCurrent || isWaiting) {
269
293
  const tone = paused || isWaiting ? "warning" : "accent";
@@ -296,6 +320,7 @@ function nodeRuntimeSegments(
296
320
  snapshot: WorkflowDefinitionSnapshot,
297
321
  nodeId: string,
298
322
  now: Date,
323
+ isCurrent: boolean,
299
324
  ): string[] {
300
325
  const segments: string[] = [];
301
326
  const node = snapshot.nodes[nodeId];
@@ -307,11 +332,11 @@ function nodeRuntimeSegments(
307
332
  segments.push("assistant response");
308
333
  }
309
334
  const completedAttempts = state.steps.filter((step) => step.nodeId === nodeId).length;
310
- const attempts = completedAttempts + (state.currentNode === nodeId ? 1 : 0);
335
+ const attempts = completedAttempts + (isCurrent ? 1 : 0);
311
336
  if (attempts > 1) {
312
337
  segments.push(`↻${attempts}`);
313
338
  }
314
- if (state.currentNode === nodeId && state.currentSettingsChangeNumber !== undefined) {
339
+ if (isCurrent && state.currentSettingsChangeNumber !== undefined) {
315
340
  segments.push(`settings ${state.currentSettingsChangeNumber}`);
316
341
  } else {
317
342
  const latest = state.steps.findLast((step) => step.nodeId === nodeId);
@@ -321,8 +346,8 @@ function nodeRuntimeSegments(
321
346
  }
322
347
 
323
348
  const result = state.results[nodeId];
324
- if (state.currentNode === nodeId) {
325
- if (state.statusDetail) {
349
+ if (isCurrent) {
350
+ if (state.currentNode === nodeId && state.statusDetail) {
326
351
  segments.push(sanitizeText(state.statusDetail));
327
352
  }
328
353
  const elapsed = elapsedSince(state.currentNodeStartedAt, now);
@@ -407,7 +432,10 @@ function statusTone(status: string): ThemeColor {
407
432
  case "cancelled":
408
433
  return "error";
409
434
  case "waiting":
435
+ case "paused":
410
436
  return "warning";
437
+ case "ambiguous":
438
+ return "error";
411
439
  case "running":
412
440
  default:
413
441
  return "accent";
@@ -0,0 +1,334 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { WorkflowClient } from "../client/client.js";
4
+ import type { WorkflowSessionView, WorkflowTurnReport } from "../client/view.js";
5
+ import type { JsonValue } from "../state/json.js";
6
+ import type { WorkflowMessage, WorkflowTurnStopReason } from "../state/workflow-messages.js";
7
+
8
+ const WORKFLOW_MESSAGE_ID_FIELD = "workflowMessageId";
9
+
10
+ type PendingTurn = {
11
+ workflowTurnId: string;
12
+ workflowMessageId: string | null;
13
+ runId: string | null;
14
+ startedReported: boolean;
15
+ end: { stopReason: WorkflowTurnStopReason; responseSessionEntryId: string | null } | null;
16
+ };
17
+
18
+ /** Adds every host-owned workflow message to one Pi session through one public API path. */
19
+ export class WorkflowMessageCoordinator {
20
+ private readonly queued = new Set<string>();
21
+ private readonly closedTurnMessages = new Set<string>();
22
+ private synchronizing = false;
23
+ private lastBranchEpoch: string | null = null;
24
+ private view: WorkflowSessionView | null = null;
25
+ private turn: PendingTurn | null = null;
26
+
27
+ updateView(view: WorkflowSessionView): void {
28
+ this.view = view;
29
+ const visibleIds = new Set(view.workflowMessages.map((message) => message.workflowMessageId));
30
+ for (const messageId of this.closedTurnMessages) {
31
+ if (!visibleIds.has(messageId)) this.closedTurnMessages.delete(messageId);
32
+ }
33
+ for (const message of view.workflowMessages) {
34
+ if (message.status === "sent" || message.status === "cancelled") {
35
+ this.queued.delete(message.workflowMessageId);
36
+ }
37
+ }
38
+ if (
39
+ this.turn !== null &&
40
+ this.turn.workflowMessageId === null &&
41
+ view.openWorkflowMessageId !== null
42
+ ) {
43
+ const message = messageById(view, view.openWorkflowMessageId);
44
+ if (message !== undefined && messageStartsTurn(message)) this.bindTurn(message);
45
+ }
46
+ }
47
+
48
+ branchChanged(): void {
49
+ this.lastBranchEpoch = null;
50
+ }
51
+
52
+ startTurn(): void {
53
+ if (this.turn !== null && this.turn.end === null) return;
54
+ this.turn = {
55
+ workflowTurnId: `workflow-turn-${randomUUID()}`,
56
+ workflowMessageId: null,
57
+ runId: null,
58
+ startedReported: false,
59
+ end: null,
60
+ };
61
+ const candidate = this.turnCandidate();
62
+ if (candidate !== undefined) this.bindTurn(candidate);
63
+ }
64
+
65
+ endTurn(stopReason: WorkflowTurnStopReason, responseSessionEntryId: string | null): void {
66
+ if (this.turn === null) this.startTurn();
67
+ if (this.turn !== null) this.turn.end = { stopReason, responseSessionEntryId };
68
+ }
69
+
70
+ activeTurnMessage(): WorkflowMessage | undefined {
71
+ if (this.view === null || this.turn?.workflowMessageId === null || this.turn === null) {
72
+ return undefined;
73
+ }
74
+ return messageById(this.view, this.turn.workflowMessageId);
75
+ }
76
+
77
+ async synchronize(
78
+ pi: ExtensionAPI,
79
+ client: WorkflowClient,
80
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager">,
81
+ ): Promise<void> {
82
+ if (this.synchronizing || this.view === null) return;
83
+ this.synchronizing = true;
84
+ try {
85
+ const view = this.view;
86
+ if (!view.coordinatorActive || view.coordinatorEpoch === null) return;
87
+ if (this.turn === null && !ctx.isIdle() && view.openWorkflowTurn !== null) {
88
+ this.turn = {
89
+ workflowTurnId: view.openWorkflowTurn.workflowTurnId,
90
+ workflowMessageId: view.openWorkflowTurn.workflowMessageId,
91
+ runId: view.openWorkflowTurn.runId,
92
+ startedReported: false,
93
+ end: null,
94
+ };
95
+ }
96
+ if (
97
+ view.branchReportRequired ||
98
+ this.lastBranchEpoch !== view.coordinatorEpoch ||
99
+ this.hasUnconfirmedBranchEntry(ctx, view)
100
+ ) {
101
+ await this.reportBranch(client, ctx, view);
102
+ }
103
+ await this.flushTurn(client, view);
104
+ const messageId = view.nextWorkflowMessageId;
105
+ if (messageId === null || this.queued.has(messageId)) return;
106
+ const message = messageById(view, messageId);
107
+ if (message === undefined || message.status !== "pending") return;
108
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
109
+
110
+ this.queued.add(messageId);
111
+ const existingEntry = branchWorkflowEntries(ctx.sessionManager.getBranch()).get(messageId);
112
+ if (existingEntry !== undefined) {
113
+ await this.reportBranch(client, ctx, view);
114
+ return;
115
+ }
116
+
117
+ // There must be no asynchronous boundary between these final checks and
118
+ // the documented Pi sendMessage call.
119
+ if (
120
+ this.view !== view ||
121
+ !view.coordinatorActive ||
122
+ view.coordinatorEpoch === null ||
123
+ view.nextWorkflowMessageId !== messageId ||
124
+ !ctx.isIdle() ||
125
+ ctx.hasPendingMessages() ||
126
+ branchWorkflowEntries(ctx.sessionManager.getBranch()).has(messageId)
127
+ ) {
128
+ this.queued.delete(messageId);
129
+ return;
130
+ }
131
+ try {
132
+ pi.sendMessage(
133
+ {
134
+ customType: message.content.customType,
135
+ content: message.content.content,
136
+ display: message.content.display,
137
+ details: message.content.details,
138
+ },
139
+ { triggerTurn: message.content.triggerTurn },
140
+ );
141
+ } catch (error) {
142
+ this.queued.delete(messageId);
143
+ throw error;
144
+ }
145
+ await this.reportBranch(client, ctx, view);
146
+ await this.flushTurn(client, view);
147
+ } finally {
148
+ this.synchronizing = false;
149
+ }
150
+ }
151
+
152
+ clear(): void {
153
+ this.queued.clear();
154
+ this.closedTurnMessages.clear();
155
+ this.view = null;
156
+ this.turn = null;
157
+ this.lastBranchEpoch = null;
158
+ this.synchronizing = false;
159
+ }
160
+
161
+ private turnCandidate(): WorkflowMessage | undefined {
162
+ const view = this.view;
163
+ if (view === null) return undefined;
164
+ if (view.openWorkflowMessageId !== null) {
165
+ const open = messageById(view, view.openWorkflowMessageId);
166
+ if (
167
+ open !== undefined &&
168
+ !this.closedTurnMessages.has(open.workflowMessageId) &&
169
+ messageStartsTurn(open)
170
+ ) {
171
+ return open;
172
+ }
173
+ }
174
+ for (const messageId of this.queued) {
175
+ const queued = messageById(view, messageId);
176
+ if (queued?.status === "pending" && messageStartsTurn(queued)) return queued;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ private bindTurn(message: WorkflowMessage): void {
182
+ if (this.turn === null) return;
183
+ this.turn.workflowMessageId = message.workflowMessageId;
184
+ this.turn.runId = message.runId;
185
+ }
186
+
187
+ private async flushTurn(client: WorkflowClient, view: WorkflowSessionView): Promise<void> {
188
+ const pending = this.turn;
189
+ if (
190
+ pending === null ||
191
+ pending.workflowMessageId === null ||
192
+ pending.runId === null ||
193
+ view.coordinatorEpoch === null ||
194
+ this.lastBranchEpoch !== view.coordinatorEpoch
195
+ ) {
196
+ return;
197
+ }
198
+ const message = messageById(view, pending.workflowMessageId);
199
+ if (message?.status !== "sent") return;
200
+ if (!pending.startedReported) {
201
+ await reportTurn(client, {
202
+ state: "started",
203
+ workflowMessageId: pending.workflowMessageId,
204
+ workflowTurnId: pending.workflowTurnId,
205
+ runId: pending.runId,
206
+ targetSessionId: view.sessionId,
207
+ coordinatorEpoch: view.coordinatorEpoch,
208
+ });
209
+ pending.startedReported = true;
210
+ }
211
+ if (pending.end === null) return;
212
+ await reportTurn(client, {
213
+ state: "ended",
214
+ workflowMessageId: pending.workflowMessageId,
215
+ workflowTurnId: pending.workflowTurnId,
216
+ runId: pending.runId,
217
+ targetSessionId: view.sessionId,
218
+ coordinatorEpoch: view.coordinatorEpoch,
219
+ stopReason: pending.end.stopReason,
220
+ responseSessionEntryId: pending.end.responseSessionEntryId,
221
+ });
222
+ if (message.kind === "terminal" || message.kind === "followUp") {
223
+ this.closedTurnMessages.add(pending.workflowMessageId);
224
+ }
225
+ for (const current of new Set([view, this.view])) {
226
+ if (current?.openWorkflowTurn?.workflowTurnId === pending.workflowTurnId) {
227
+ current.openWorkflowTurn = null;
228
+ }
229
+ if (
230
+ current?.openWorkflowMessageId === pending.workflowMessageId &&
231
+ (message.kind === "terminal" || message.kind === "followUp")
232
+ ) {
233
+ current.openWorkflowMessageId = null;
234
+ }
235
+ }
236
+ this.turn = null;
237
+ }
238
+
239
+ private async reportBranch(
240
+ client: WorkflowClient,
241
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager">,
242
+ view: WorkflowSessionView,
243
+ ): Promise<void> {
244
+ if (view.coordinatorEpoch === null) return;
245
+ const allowed = new Set(view.workflowMessages.map((message) => message.workflowMessageId));
246
+ const branch = branchWorkflowEntries(ctx.sessionManager.getBranch());
247
+ const entries = [...branch]
248
+ .filter(([workflowMessageId]) => allowed.has(workflowMessageId))
249
+ .map(([workflowMessageId, piSessionEntryId]) => ({ workflowMessageId, piSessionEntryId }));
250
+ const isIdle = ctx.isIdle();
251
+ const hasPendingMessages = ctx.hasPendingMessages();
252
+ const response = await client.request({
253
+ operation: "workflowMessage.reportBranch",
254
+ payload: {
255
+ targetSessionId: view.sessionId,
256
+ coordinatorEpoch: view.coordinatorEpoch,
257
+ entries,
258
+ isIdle,
259
+ hasPendingMessages,
260
+ },
261
+ });
262
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
263
+ throw new Error(response.error ?? "Workflow host rejected the Pi branch report");
264
+ }
265
+ for (const entry of entries) {
266
+ const message = messageById(view, entry.workflowMessageId);
267
+ if (message !== undefined) {
268
+ message.status = "sent";
269
+ message.piSessionEntryId = entry.piSessionEntryId;
270
+ }
271
+ this.queued.delete(entry.workflowMessageId);
272
+ }
273
+ this.lastBranchEpoch = view.coordinatorEpoch;
274
+ }
275
+
276
+ private hasUnconfirmedBranchEntry(
277
+ ctx: Pick<ExtensionContext, "sessionManager">,
278
+ view: WorkflowSessionView,
279
+ ): boolean {
280
+ const branch = branchWorkflowEntries(ctx.sessionManager.getBranch());
281
+ return view.workflowMessages.some(
282
+ (message) => message.status !== "sent" && branch.has(message.workflowMessageId),
283
+ );
284
+ }
285
+ }
286
+
287
+ export function branchWorkflowEntries(entries: readonly unknown[]): Map<string, string> {
288
+ const found = new Map<string, string>();
289
+ for (const value of entries) {
290
+ if (!isRecord(value) || typeof value.id !== "string") continue;
291
+ if (value.type !== "custom_message" && value.role !== "custom") continue;
292
+ if (!isRecord(value.details)) continue;
293
+ const workflowMessageId = value.details[WORKFLOW_MESSAGE_ID_FIELD];
294
+ if (typeof workflowMessageId === "string") found.set(workflowMessageId, value.id);
295
+ }
296
+ return found;
297
+ }
298
+
299
+ export function responseEntryId(entries: readonly unknown[]): string | null {
300
+ for (const value of [...entries].reverse()) {
301
+ if (!isRecord(value) || typeof value.id !== "string") continue;
302
+ if (value.type === "message" && isRecord(value.message) && value.message.role === "assistant") {
303
+ return value.id;
304
+ }
305
+ if (value.role === "assistant") return value.id;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ function messageById(
311
+ view: WorkflowSessionView,
312
+ workflowMessageId: string,
313
+ ): WorkflowMessage | undefined {
314
+ return view.workflowMessages.find((message) => message.workflowMessageId === workflowMessageId);
315
+ }
316
+
317
+ function messageStartsTurn(message: WorkflowMessage): boolean {
318
+ return message.kind === "step" || message.kind === "terminal" || message.kind === "followUp";
319
+ }
320
+
321
+ async function reportTurn(client: WorkflowClient, report: WorkflowTurnReport): Promise<void> {
322
+ const response = await client.request({
323
+ operation: "workflowTurn.report",
324
+ runId: report.runId,
325
+ payload: report as unknown as JsonValue,
326
+ });
327
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
328
+ throw new Error(response.error ?? "Workflow host rejected the model-turn report");
329
+ }
330
+ }
331
+
332
+ function isRecord(value: unknown): value is Record<string, unknown> {
333
+ return typeof value === "object" && value !== null && !Array.isArray(value);
334
+ }