@osolmaz/pi-workflows 0.13.4 → 0.15.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 (237) hide show
  1. package/README.md +146 -160
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -2928
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -0,0 +1,61 @@
1
+ import type { 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
+
5
+ export type MessageCardView = {
6
+ title: string;
7
+ status?: string;
8
+ expandedText?: string;
9
+ };
10
+
11
+ export type MessageCardColor = "accent" | "dim" | "error" | "success" | "warning";
12
+
13
+ export function renderMessageCard(view: MessageCardView, theme: Theme): Box {
14
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
15
+ box.addChild(new TruncatedText(view.title));
16
+ if (view.status !== undefined) {
17
+ box.addChild(new TruncatedText(view.status));
18
+ }
19
+ if (view.expandedText !== undefined) {
20
+ box.addChild(new Text(`\n${view.expandedText}`));
21
+ }
22
+ return box;
23
+ }
24
+
25
+ export function customMessageContentText(content: unknown): string {
26
+ if (typeof content === "string") return cleanMultiline(content);
27
+ if (!Array.isArray(content)) return "";
28
+ return content
29
+ .map((part) => {
30
+ if (part === null || typeof part !== "object" || !("text" in part)) return "";
31
+ const text = (part as { text?: unknown }).text;
32
+ return typeof text === "string" ? cleanMultiline(text) : "";
33
+ })
34
+ .filter(Boolean)
35
+ .join("\n");
36
+ }
37
+
38
+ export function cleanOptionalSingleLine(value: unknown): string | undefined {
39
+ return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
40
+ }
41
+
42
+ export function cleanSingleLine(value: string): string {
43
+ return sanitizeText(value);
44
+ }
45
+
46
+ export function paintMessageCard(
47
+ theme: Pick<Theme, "fg"> | undefined,
48
+ color: MessageCardColor,
49
+ text: string,
50
+ ): string {
51
+ return theme?.fg(color, text) ?? text;
52
+ }
53
+
54
+ function cleanMultiline(value: string): string {
55
+ return value
56
+ .replaceAll("\r\n", "\n")
57
+ .replaceAll("\r", "\n")
58
+ .split("\n")
59
+ .map((line) => sanitizeText(line))
60
+ .join("\n");
61
+ }
@@ -1,17 +1,25 @@
1
1
  import { createHash } from "node:crypto";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
3
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
3
- import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
4
- import { sanitizeText } from "../workflows/text.js";
5
4
  import type {
6
5
  AgentStepContract,
7
6
  AgentStepPresentation,
8
7
  AgentStepSubmission,
9
8
  } from "../workflows/types.js";
10
- import { visibleAssistantText, type PromptDeliveryKind } from "./executor.js";
9
+ import {
10
+ cleanOptionalSingleLine,
11
+ cleanSingleLine,
12
+ customMessageContentText,
13
+ paintMessageCard,
14
+ renderMessageCard,
15
+ type MessageCardView,
16
+ } from "./message-card.js";
11
17
 
12
18
  export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = "pi-workflows-agent-step";
13
19
  export const WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA = "pi-workflows.agent-step-message.v1";
14
20
 
21
+ type PromptDeliveryKind = "step" | "reminder" | "resume";
22
+
15
23
  export type WorkflowAgentStepMessageDetails = {
16
24
  schema: typeof WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA;
17
25
  kind: PromptDeliveryKind;
@@ -25,27 +33,13 @@ type WorkflowAgentStepMessage = {
25
33
  details?: unknown;
26
34
  };
27
35
 
28
- type WorkflowAgentStepView = {
29
- title: string;
30
- status?: string;
31
- expandedText?: string;
32
- };
36
+ type WorkflowAgentStepView = MessageCardView;
33
37
 
34
38
  export function registerWorkflowAgentStepMessageRenderer(pi: ExtensionAPI): void {
35
39
  pi.registerMessageRenderer<WorkflowAgentStepMessageDetails>(
36
40
  WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
37
- (message, { expanded }, theme) => {
38
- const view = buildWorkflowAgentStepView(message, expanded, theme);
39
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
40
- box.addChild(new TruncatedText(view.title));
41
- if (view.status !== undefined) {
42
- box.addChild(new TruncatedText(view.status));
43
- }
44
- if (view.expandedText !== undefined) {
45
- box.addChild(new Text(`\n${view.expandedText}`));
46
- }
47
- return box;
48
- },
41
+ (message, { expanded }, theme) =>
42
+ renderMessageCard(buildWorkflowAgentStepView(message, expanded, theme), theme),
49
43
  );
50
44
  }
51
45
 
@@ -65,12 +59,14 @@ export function buildWorkflowAgentStepView(
65
59
  const label = runTitle ?? workflowName;
66
60
  const suffix = kind === "step" ? "" : ` · ${kind}`;
67
61
  const glyph = kind === "step" ? "▶" : "↻";
68
- const title = paint(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
62
+ const title = paintMessageCard(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
69
63
 
70
64
  if (!expanded) {
71
65
  return {
72
66
  title,
73
- ...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
67
+ ...(statusDetail !== undefined
68
+ ? { status: paintMessageCard(theme, "dim", statusDetail) }
69
+ : {}),
74
70
  };
75
71
  }
76
72
 
@@ -88,11 +84,11 @@ export function buildWorkflowAgentStepView(
88
84
  `Completion: ${assistant ? "assistant response" : "workflow submission"}`,
89
85
  `Expected output: ${cleanSingleLine(expectedOutput)}`,
90
86
  ];
91
- const prompt = contentText(message.content);
87
+ const prompt = customMessageContentText(message.content);
92
88
  return {
93
89
  title,
94
- ...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
95
- expandedText: `${metadata.map((line) => paint(theme, "dim", line)).join("\n")}\n\n${prompt}`,
90
+ ...(statusDetail !== undefined ? { status: paintMessageCard(theme, "dim", statusDetail) } : {}),
91
+ expandedText: `${metadata.map((line) => paintMessageCard(theme, "dim", line)).join("\n")}\n\n${prompt}`,
96
92
  };
97
93
  }
98
94
 
@@ -168,6 +164,43 @@ function sessionEntry(value: unknown): Record<string, unknown> | undefined {
168
164
  : undefined;
169
165
  }
170
166
 
167
+ function assistantMessageLike(
168
+ message: unknown,
169
+ ): Pick<AssistantMessage, "role" | "content" | "stopReason" | "errorMessage"> | undefined {
170
+ if (message === null || typeof message !== "object") return undefined;
171
+ const candidate = message as Partial<AssistantMessage>;
172
+ if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return undefined;
173
+ if (typeof candidate.stopReason !== "string") return undefined;
174
+ return candidate as Pick<AssistantMessage, "role" | "content" | "stopReason" | "errorMessage">;
175
+ }
176
+
177
+ /** Extract the exact visible text blocks from one finalized assistant message. */
178
+ export function visibleAssistantText(message: unknown, maxChars?: number): string {
179
+ const assistant = assistantMessageLike(message);
180
+ if (assistant === undefined) {
181
+ throw new Error("Assistant step settled without a final assistant message");
182
+ }
183
+ if (assistant.stopReason !== "stop" && assistant.stopReason !== "length") {
184
+ throw new Error(
185
+ assistant.errorMessage?.trim() ||
186
+ `Assistant step stopped with ${JSON.stringify(assistant.stopReason)} before a final response`,
187
+ );
188
+ }
189
+ const text = assistant.content
190
+ .filter((part) => part.type === "text")
191
+ .map((part) => part.text)
192
+ .join("\n");
193
+ if (text.trim().length === 0) {
194
+ throw new Error("Assistant step returned no visible text");
195
+ }
196
+ if (maxChars !== undefined && text.length > maxChars) {
197
+ throw new Error(
198
+ `Assistant response has ${text.length} characters, above the configured limit of ${maxChars}`,
199
+ );
200
+ }
201
+ return text;
202
+ }
203
+
171
204
  function isAssistantWithVisibleText(message: unknown): boolean {
172
205
  if (message === null || typeof message !== "object") return false;
173
206
  const candidate = message as { role?: unknown; content?: unknown; stopReason?: unknown };
@@ -208,41 +241,3 @@ function parseDetails(value: unknown): WorkflowAgentStepMessageDetails | undefin
208
241
  }
209
242
  return candidate as WorkflowAgentStepMessageDetails;
210
243
  }
211
-
212
- function contentText(content: unknown): string {
213
- if (typeof content === "string") return cleanMultiline(content);
214
- if (!Array.isArray(content)) return "";
215
- return content
216
- .map((part) => {
217
- if (part === null || typeof part !== "object" || !("text" in part)) return "";
218
- const text = (part as { text?: unknown }).text;
219
- return typeof text === "string" ? cleanMultiline(text) : "";
220
- })
221
- .filter(Boolean)
222
- .join("\n");
223
- }
224
-
225
- function cleanOptionalSingleLine(value: unknown): string | undefined {
226
- return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
227
- }
228
-
229
- function cleanSingleLine(value: string): string {
230
- return sanitizeText(value);
231
- }
232
-
233
- function cleanMultiline(value: string): string {
234
- return value
235
- .replaceAll("\r\n", "\n")
236
- .replaceAll("\r", "\n")
237
- .split("\n")
238
- .map((line) => sanitizeText(line))
239
- .join("\n");
240
- }
241
-
242
- function paint(
243
- theme: Pick<Theme, "fg"> | undefined,
244
- color: "accent" | "dim",
245
- text: string,
246
- ): string {
247
- return theme?.fg(color, text) ?? text;
248
- }
@@ -0,0 +1,183 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { errorMessage } from "../workflows/errors.js";
4
+ import { HostProcessRegistry, type ProcessIdentity } from "./processes.js";
5
+ import { NdjsonFrameDecoder } from "./protocol.js";
6
+ import type { WorkerOutcome } from "./state.js";
7
+
8
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
9
+ const DEFAULT_KILL_GRACE_MS = 2_000;
10
+ const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
11
+
12
+ export type SupervisedWorkerResult = {
13
+ outcome: WorkerOutcome;
14
+ exitCode: number | null;
15
+ signal: NodeJS.Signals | null;
16
+ diagnostic?: string;
17
+ };
18
+
19
+ type ChildWorkerSupervisorOptions<Message, Response> = {
20
+ label: string;
21
+ registry: HostProcessRegistry;
22
+ onMessage: (message: Message) => Promise<Response>;
23
+ parseMessage: (frame: Buffer) => Message;
24
+ encodeResponse: (response: Response) => Buffer;
25
+ isReady: (message: Message) => boolean;
26
+ isTerminal: (message: Message) => boolean;
27
+ startupTimeoutMs?: number;
28
+ onSpawn?: (identity: ProcessIdentity) => void;
29
+ onDiagnostic?: (message: string) => void;
30
+ };
31
+
32
+ type ChildWorkerStartOptions = {
33
+ args: string[];
34
+ cwd: string;
35
+ env: NodeJS.ProcessEnv;
36
+ };
37
+
38
+ /** Shared supervision, framing, diagnostics, and process-group shutdown for host workers. */
39
+ export class ChildWorkerSupervisor<Message, Response> {
40
+ private child: ChildProcessWithoutNullStreams | null = null;
41
+ private identity: ProcessIdentity | null = null;
42
+ private readonly decoder = new NdjsonFrameDecoder();
43
+ private diagnostic = Buffer.alloc(0);
44
+ private terminalMessage = false;
45
+ private stopping: WorkerOutcome | null = null;
46
+ private processing = Promise.resolve();
47
+ private readyResolve: (() => void) | undefined;
48
+ private readyReject: ((error: Error) => void) | undefined;
49
+ private exitResolve: ((result: SupervisedWorkerResult) => void) | undefined;
50
+ private readonly readyPromise = new Promise<void>((resolve, reject) => {
51
+ this.readyResolve = resolve;
52
+ this.readyReject = reject;
53
+ });
54
+ private readonly exitPromise = new Promise<SupervisedWorkerResult>((resolve) => {
55
+ this.exitResolve = resolve;
56
+ });
57
+
58
+ constructor(private readonly options: ChildWorkerSupervisorOptions<Message, Response>) {}
59
+
60
+ async start(options: ChildWorkerStartOptions): Promise<void> {
61
+ if (this.child !== null) throw new Error(`${this.options.label} already started`);
62
+ const child = spawn(process.execPath, options.args, {
63
+ cwd: options.cwd,
64
+ env: options.env,
65
+ detached: process.platform !== "win32",
66
+ stdio: ["pipe", "pipe", "pipe"],
67
+ });
68
+ this.child = child;
69
+ if (child.pid === undefined) throw new Error(`${this.options.label} did not receive a PID`);
70
+ this.identity = this.options.registry.register(child.pid);
71
+ this.options.onSpawn?.(this.identity);
72
+ child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk));
73
+ child.stderr.on("data", (chunk: Buffer) => this.onStderr(chunk));
74
+ child.on("error", (error) => this.readyReject?.(error));
75
+ child.on("exit", (code, signal) => this.onExit(code, signal));
76
+
77
+ const timeout = setTimeout(() => {
78
+ this.readyReject?.(new Error(`${this.options.label} startup timed out`));
79
+ void this.stop("timedOut");
80
+ }, this.options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS);
81
+ timeout.unref?.();
82
+ try {
83
+ await this.readyPromise;
84
+ } finally {
85
+ clearTimeout(timeout);
86
+ }
87
+ }
88
+
89
+ async wait(): Promise<SupervisedWorkerResult> {
90
+ return await this.exitPromise;
91
+ }
92
+
93
+ async stop(outcome: WorkerOutcome = "cancelled"): Promise<void> {
94
+ if (this.child === null) {
95
+ this.exitResolve?.({ outcome, exitCode: null, signal: null });
96
+ return;
97
+ }
98
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
99
+ this.stopping = outcome;
100
+ signalProcess(this.child.pid, "SIGTERM");
101
+ const exited = await Promise.race([
102
+ this.exitPromise.then(() => true),
103
+ new Promise<false>((resolve) => {
104
+ const timer = setTimeout(() => resolve(false), DEFAULT_KILL_GRACE_MS);
105
+ timer.unref?.();
106
+ }),
107
+ ]);
108
+ if (!exited) signalProcess(this.child.pid, "SIGKILL");
109
+ }
110
+
111
+ private onStdout(chunk: Buffer): void {
112
+ let frames: Buffer[];
113
+ try {
114
+ frames = this.decoder.push(chunk);
115
+ } catch (error) {
116
+ this.appendDiagnostic(errorMessage(error));
117
+ void this.stop("crashed");
118
+ return;
119
+ }
120
+ for (const frame of frames) {
121
+ this.processing = this.processing
122
+ .then(async () => {
123
+ const message = this.options.parseMessage(frame);
124
+ if (this.options.isTerminal(message)) this.terminalMessage = true;
125
+ const response = await this.options.onMessage(message);
126
+ if (this.child?.stdin.destroyed !== false) return;
127
+ if (!this.child.stdin.write(this.options.encodeResponse(response))) {
128
+ await once(this.child.stdin, "drain");
129
+ }
130
+ if (this.options.isReady(message)) this.readyResolve?.();
131
+ })
132
+ .catch((error: unknown) => {
133
+ this.appendDiagnostic(errorMessage(error));
134
+ void this.stop("crashed");
135
+ });
136
+ }
137
+ }
138
+
139
+ private onStderr(chunk: Buffer): void {
140
+ this.options.onDiagnostic?.(chunk.toString("utf8").trimEnd());
141
+ if (this.diagnostic.byteLength >= MAX_DIAGNOSTIC_BYTES) return;
142
+ this.diagnostic = Buffer.concat([
143
+ this.diagnostic,
144
+ chunk.subarray(0, MAX_DIAGNOSTIC_BYTES - this.diagnostic.byteLength),
145
+ ]);
146
+ }
147
+
148
+ private appendDiagnostic(message: string): void {
149
+ this.onStderr(Buffer.from(`${message}\n`, "utf8"));
150
+ }
151
+
152
+ private onExit(code: number | null, signal: NodeJS.Signals | null): void {
153
+ if (this.identity !== null) this.options.registry.unregister(this.identity.pid);
154
+ const outcome =
155
+ this.stopping ?? (code === 0 && this.terminalMessage ? "exited" : ("crashed" as const));
156
+ const diagnostic = this.diagnostic.toString("utf8").trim();
157
+ const result: SupervisedWorkerResult = {
158
+ outcome,
159
+ exitCode: code,
160
+ signal,
161
+ ...(diagnostic.length === 0 ? {} : { diagnostic }),
162
+ };
163
+ if (outcome !== "exited") {
164
+ this.readyReject?.(
165
+ new Error(diagnostic || `${this.options.label} exited with code ${String(code)}`),
166
+ );
167
+ }
168
+ this.exitResolve?.(result);
169
+ }
170
+ }
171
+
172
+ function signalProcess(pid: number | undefined, signal: NodeJS.Signals): void {
173
+ if (pid === undefined) return;
174
+ try {
175
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
176
+ } catch {
177
+ try {
178
+ process.kill(pid, signal);
179
+ } catch {
180
+ // The process has already exited.
181
+ }
182
+ }
183
+ }