@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,241 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
6
+ import { discoverControllers, loadControllerFile } from "../controllers/loader.js";
7
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
8
+ import { compositionMetadata } from "../workflows/composition.js";
9
+ import { errorMessage } from "../workflows/errors.js";
10
+ import { validateWorkflowDefinition } from "../workflows/graph.js";
11
+ import { resolveWorkflowRef } from "../workflows/loader.js";
12
+ import { applyWorkflowSettingsPatch } from "../workflows/settings.js";
13
+ import { createDefinitionSnapshot } from "../workflows/store.js";
14
+ import type { WorkflowDefinition, WorkflowSource } from "../workflows/types.js";
15
+ import { MAX_PROTOCOL_MESSAGE_BYTES } from "./protocol.js";
16
+
17
+ export type ResolvedWorkflowLaunch = {
18
+ schema: "pi-workflows.resolved-launch.v1";
19
+ workflowName: string;
20
+ workflowSourceRef: string;
21
+ workflowSource: JsonValue;
22
+ definitionDigest: string;
23
+ definitionSnapshot: JsonValue;
24
+ };
25
+
26
+ export type ResolvedControllerInitialization = {
27
+ schema: "pi-workflows.resolved-controller-initialization.v1";
28
+ controllerName: string;
29
+ controllerPath: string;
30
+ sourceHash: string;
31
+ initialStatus: JsonValue;
32
+ };
33
+
34
+ export type ResolvedSettingsChange = {
35
+ schema: "pi-workflows.resolved-settings-change.v1";
36
+ definitionDigest: string;
37
+ patch: JsonValue;
38
+ settings: JsonValue;
39
+ paths: JsonValue;
40
+ };
41
+
42
+ type ResolverRequest = {
43
+ schema: "pi-workflows.resolve-request.v1";
44
+ cwd: string;
45
+ workflowRef: string;
46
+ };
47
+
48
+ type SettingsValidationRequest = {
49
+ schema: "pi-workflows.settings-validation-request.v1";
50
+ cwd: string;
51
+ workflowRef: string;
52
+ definitionDigest: string;
53
+ mountPath: string;
54
+ current: JsonValue;
55
+ patch: JsonValue;
56
+ actorId: string;
57
+ };
58
+
59
+ type ControllerInitializationRequest = {
60
+ schema: "pi-workflows.controller-initialization-request.v1";
61
+ cwd: string;
62
+ controllerName: string;
63
+ spec: JsonValue;
64
+ };
65
+
66
+ type ResolverInput = ResolverRequest | SettingsValidationRequest | ControllerInitializationRequest;
67
+ type ResolverOutput =
68
+ | ResolvedWorkflowLaunch
69
+ | ResolvedSettingsChange
70
+ | ResolvedControllerInitialization;
71
+
72
+ export async function resolveWorkflowLaunch(
73
+ request: ResolverRequest,
74
+ ): Promise<ResolvedWorkflowLaunch> {
75
+ if (request.schema !== "pi-workflows.resolve-request.v1") {
76
+ throw new Error("Invalid workflow resolver request schema");
77
+ }
78
+ const resolved = await loadResolvedWorkflow(request.cwd, request.workflowRef);
79
+ return {
80
+ schema: "pi-workflows.resolved-launch.v1",
81
+ workflowName: resolved.definition.name,
82
+ workflowSourceRef: sourceRef(resolved.source),
83
+ workflowSource: {
84
+ root: resolved.source,
85
+ mounted: compositionMetadata(resolved.definition)?.sources ?? [],
86
+ } as JsonValue,
87
+ definitionDigest: resolved.definitionDigest,
88
+ definitionSnapshot: resolved.snapshot,
89
+ };
90
+ }
91
+
92
+ export async function resolveControllerInitialization(
93
+ request: ControllerInitializationRequest,
94
+ ): Promise<ResolvedControllerInitialization> {
95
+ const discovered = (await discoverControllers({ cwd: request.cwd })).find(
96
+ (candidate) => candidate.name === request.controllerName,
97
+ );
98
+ if (discovered === undefined) {
99
+ throw new Error(`Controller not found: ${request.controllerName}`);
100
+ }
101
+ const definition = await loadControllerFile(discovered.path);
102
+ if (definition.name !== request.controllerName) {
103
+ throw new Error("Controller source name does not match its discovered name");
104
+ }
105
+ const initialStatus = parseJson(canonicalJson(definition.initialStatus(request.spec)));
106
+ const sourceHash = createHash("sha256")
107
+ .update(await fs.readFile(discovered.path))
108
+ .digest("hex");
109
+ return {
110
+ schema: "pi-workflows.resolved-controller-initialization.v1",
111
+ controllerName: definition.name,
112
+ controllerPath: discovered.path,
113
+ sourceHash,
114
+ initialStatus,
115
+ };
116
+ }
117
+
118
+ export async function resolveSettingsChange(
119
+ request: SettingsValidationRequest,
120
+ ): Promise<ResolvedSettingsChange> {
121
+ if (request.schema !== "pi-workflows.settings-validation-request.v1") {
122
+ throw new Error("Invalid workflow settings validation request schema");
123
+ }
124
+ const resolved = await loadResolvedWorkflow(request.cwd, request.workflowRef);
125
+ if (resolved.definitionDigest !== request.definitionDigest) {
126
+ throw new Error("Workflow source changed before the settings proposal was validated");
127
+ }
128
+ const definition = settingsDefinition(resolved.definition, request.mountPath);
129
+ if (definition === undefined) {
130
+ throw new Error(
131
+ `Workflow scope ${request.mountPath || "/"} does not declare editable settings`,
132
+ );
133
+ }
134
+ const applied = await applyWorkflowSettingsPatch(
135
+ definition,
136
+ request.current,
137
+ request.patch,
138
+ { type: "controller", id: request.actorId },
139
+ "controller-request",
140
+ );
141
+ return {
142
+ schema: "pi-workflows.resolved-settings-change.v1",
143
+ definitionDigest: resolved.definitionDigest,
144
+ patch: applied.patch as JsonValue,
145
+ settings: applied.json,
146
+ paths: definition.paths as unknown as JsonValue,
147
+ };
148
+ }
149
+
150
+ async function loadResolvedWorkflow(
151
+ cwd: string,
152
+ workflowRef: string,
153
+ ): Promise<{
154
+ definition: WorkflowDefinition;
155
+ source: WorkflowSource;
156
+ snapshot: JsonValue;
157
+ definitionDigest: string;
158
+ }> {
159
+ const resolved = await resolveWorkflowRef(workflowRef, { cwd }, builtinWorkflowCatalog);
160
+ validateWorkflowDefinition(resolved.definition);
161
+ const snapshot = createDefinitionSnapshot(resolved.definition) as unknown as JsonValue;
162
+ const definitionDigest = `sha256:${createHash("sha256")
163
+ .update(canonicalJson(snapshot))
164
+ .digest("hex")}`;
165
+ return { definition: resolved.definition, source: resolved.source, snapshot, definitionDigest };
166
+ }
167
+
168
+ function settingsDefinition(definition: WorkflowDefinition, mountPath: string) {
169
+ return mountPath === ""
170
+ ? definition.settings
171
+ : compositionMetadata(definition)?.scopes[mountPath]?.settings;
172
+ }
173
+
174
+ function sourceRef(source: WorkflowSource): string {
175
+ return source.kind === "builtin" ? `builtin:${source.id}` : source.path;
176
+ }
177
+
178
+ async function readRequest(): Promise<ResolverInput> {
179
+ const chunks: Buffer[] = [];
180
+ let length = 0;
181
+ for await (const chunk of process.stdin) {
182
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
183
+ length += buffer.byteLength;
184
+ if (length > MAX_PROTOCOL_MESSAGE_BYTES) {
185
+ throw new Error("Workflow resolver request exceeds 1 MiB");
186
+ }
187
+ chunks.push(buffer);
188
+ }
189
+ const value = parseJson(Buffer.concat(chunks).toString("utf8").trimEnd());
190
+ if (!isRecord(value) || typeof value.cwd !== "string") {
191
+ throw new Error("Invalid source resolver request");
192
+ }
193
+ if (value.schema === "pi-workflows.resolve-request.v1" && typeof value.workflowRef === "string") {
194
+ return value as ResolverRequest;
195
+ }
196
+ if (
197
+ value.schema === "pi-workflows.controller-initialization-request.v1" &&
198
+ typeof value.controllerName === "string" &&
199
+ Object.hasOwn(value, "spec")
200
+ ) {
201
+ return value as ControllerInitializationRequest;
202
+ }
203
+ if (
204
+ value.schema === "pi-workflows.settings-validation-request.v1" &&
205
+ typeof value.workflowRef === "string" &&
206
+ typeof value.definitionDigest === "string" &&
207
+ typeof value.mountPath === "string" &&
208
+ typeof value.actorId === "string" &&
209
+ Object.hasOwn(value, "current") &&
210
+ Object.hasOwn(value, "patch")
211
+ ) {
212
+ return value as SettingsValidationRequest;
213
+ }
214
+ throw new Error("Invalid workflow resolver request schema");
215
+ }
216
+
217
+ async function main(): Promise<void> {
218
+ try {
219
+ const request = await readRequest();
220
+ const result: ResolverOutput =
221
+ request.schema === "pi-workflows.resolve-request.v1"
222
+ ? await resolveWorkflowLaunch(request)
223
+ : request.schema === "pi-workflows.controller-initialization-request.v1"
224
+ ? await resolveControllerInitialization(request)
225
+ : await resolveSettingsChange(request);
226
+ const encoded = `${canonicalJson(result)}\n`;
227
+ if (Buffer.byteLength(encoded) > MAX_PROTOCOL_MESSAGE_BYTES) {
228
+ throw new Error("Workflow resolver result exceeds 1 MiB");
229
+ }
230
+ process.stdout.write(encoded);
231
+ } catch (error) {
232
+ process.stderr.write(`${errorMessage(error)}\n`);
233
+ process.exitCode = 1;
234
+ }
235
+ }
236
+
237
+ function isRecord(value: unknown): value is Record<string, unknown> {
238
+ return typeof value === "object" && value !== null && !Array.isArray(value);
239
+ }
240
+
241
+ if (process.argv[1] === fileURLToPath(import.meta.url)) void main();
@@ -7,7 +7,6 @@ import type {
7
7
  AgentStepRequest,
8
8
  AgentStepSubmission,
9
9
  } from "../workflows/types.js";
10
- import type { HostProcessRegistry } from "./processes.js";
11
10
  import { RPC_SUBMISSION_PREFIX } from "./rpc-bridge.js";
12
11
 
13
12
  // Production resolves the compiled .js; tests and dev checkouts load the .ts
@@ -15,7 +14,7 @@ import { RPC_SUBMISSION_PREFIX } from "./rpc-bridge.js";
15
14
  const BRIDGE_PATH = ["./rpc-bridge.js", "./rpc-bridge.ts"]
16
15
  .map((candidate) => fileURLToPath(new URL(candidate, import.meta.url)))
17
16
  .find((candidate) => fs.existsSync(candidate));
18
- const ABORT_GRACE_MS = 3_000;
17
+ const DEFAULT_ABORT_GRACE_MS = 3_000;
19
18
 
20
19
  type StepSubmission = {
21
20
  action: "submit";
@@ -34,9 +33,18 @@ type StepUpdate = {
34
33
 
35
34
  type StepAction = StepSubmission | StepUpdate;
36
35
 
36
+ export interface SupervisedProcessRegistry {
37
+ register(pid: number): unknown | Promise<unknown>;
38
+ unregister(pid: number): unknown | Promise<unknown>;
39
+ }
40
+
37
41
  export type RpcStepExecutorOptions = {
38
42
  cwd: string;
39
- registry: HostProcessRegistry;
43
+ registry?: SupervisedProcessRegistry;
44
+ /** Start a separate process group, or inherit the caller's process group. */
45
+ processGroup?: "own" | "inherit";
46
+ /** Grace period before a process group receives SIGKILL. */
47
+ abortGraceMs?: number;
40
48
  /** Absolute path to the pi binary; defaults to the installed `pi`. */
41
49
  piBin?: string;
42
50
  /** Extra environment for the child; the host's environment is inherited. */
@@ -63,6 +71,8 @@ export class RpcStepExecutor implements AgentStepExecutor {
63
71
  private actions: StepAction[] = [];
64
72
  private submissionWaiters: Array<() => void> = [];
65
73
  private stdoutBuffer = "";
74
+ private registeredPid: number | null = null;
75
+ private closePromise: Promise<void> | null = null;
66
76
 
67
77
  constructor(options: RpcStepExecutorOptions) {
68
78
  this.options = options;
@@ -70,7 +80,7 @@ export class RpcStepExecutor implements AgentStepExecutor {
70
80
  }
71
81
 
72
82
  async runAgentStep(request: AgentStepRequest, signal: AbortSignal): Promise<AgentStepSubmission> {
73
- this.ensureStarted();
83
+ await this.ensureStarted();
74
84
  let prompt = request.prompt;
75
85
  for (;;) {
76
86
  throwIfAborted(signal);
@@ -88,38 +98,41 @@ export class RpcStepExecutor implements AgentStepExecutor {
88
98
  }
89
99
  }
90
100
 
91
- /** Stop the child's whole process group, then unregister it. */
101
+ /** Stop the child process tree, then unregister its exact group leader. */
92
102
  async close(): Promise<void> {
103
+ this.closePromise ??= this.closeChild();
104
+ await this.closePromise;
105
+ }
106
+
107
+ private async closeChild(): Promise<void> {
93
108
  const child = this.child;
94
109
  this.child = null;
95
- if (child === null) {
96
- return;
97
- }
110
+ if (child === null) return;
98
111
  const pid = child.pid;
99
- if (this.childExited === null) {
112
+ try {
100
113
  try {
101
114
  child.stdin?.end();
102
115
  } catch {
103
116
  // The pipe may already be closed.
104
117
  }
105
- // The child spawns in its own group; tool calls can leave
106
- // grandchildren that leader-only signals would orphan forever.
107
- if (pid !== undefined) {
118
+ const ownGroup = this.options.processGroup !== "inherit";
119
+ if (pid !== undefined && ownGroup) {
108
120
  killGroup(pid, "SIGTERM");
109
- } else {
121
+ const exited = await waitForGroupExit(
122
+ pid,
123
+ this.options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS,
124
+ );
125
+ if (!exited) killGroup(pid, "SIGKILL");
126
+ } else if (this.childExited === null) {
110
127
  child.kill("SIGTERM");
128
+ const exited = await waitForExit(
129
+ child,
130
+ this.options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS,
131
+ );
132
+ if (!exited) child.kill("SIGKILL");
111
133
  }
112
- const exited = await waitForExit(child, ABORT_GRACE_MS);
113
- if (!exited) {
114
- if (pid !== undefined) {
115
- killGroup(pid, "SIGKILL");
116
- } else {
117
- child.kill("SIGKILL");
118
- }
119
- }
120
- }
121
- if (pid !== undefined) {
122
- this.options.registry.unregister(pid);
134
+ } finally {
135
+ await this.unregisterChild();
123
136
  }
124
137
  }
125
138
 
@@ -127,7 +140,8 @@ export class RpcStepExecutor implements AgentStepExecutor {
127
140
  return this.childExited;
128
141
  }
129
142
 
130
- private ensureStarted(): void {
143
+ private async ensureStarted(): Promise<void> {
144
+ if (this.closePromise !== null) throw new Error("Headless pi session is closed");
131
145
  if (this.child !== null) {
132
146
  const exited = this.currentExit();
133
147
  if (exited !== null) {
@@ -159,20 +173,12 @@ export class RpcStepExecutor implements AgentStepExecutor {
159
173
  cwd: this.options.cwd,
160
174
  env: { ...process.env, ...this.options.env },
161
175
  stdio: ["pipe", "pipe", "pipe"],
162
- // Own process group: the host kills groups, never individual PIDs,
163
- // so a killed host cannot leave an orphaned agent working.
164
- detached: true,
176
+ detached: this.options.processGroup !== "inherit",
165
177
  },
166
178
  );
167
179
  this.child = child;
168
- if (child.pid !== undefined) {
169
- this.options.registry.register(child.pid);
170
- }
171
180
  child.on("exit", (code, signal) => {
172
181
  this.childExited = { code, signal };
173
- if (child.pid !== undefined) {
174
- this.options.registry.unregister(child.pid);
175
- }
176
182
  this.wakeSubmissionWaiters();
177
183
  });
178
184
  // A failed spawn (missing binary, EACCES) emits error instead of exit;
@@ -213,6 +219,23 @@ export class RpcStepExecutor implements AgentStepExecutor {
213
219
  child.stdout?.on("data", (chunk: Buffer) => {
214
220
  this.stdoutBuffer = (this.stdoutBuffer + chunk.toString("utf8")).slice(-64 * 1024);
215
221
  });
222
+ if (child.pid !== undefined && this.options.registry !== undefined) {
223
+ try {
224
+ await this.options.registry.register(child.pid);
225
+ this.registeredPid = child.pid;
226
+ } catch (error) {
227
+ killGroup(child.pid, "SIGKILL");
228
+ this.child = null;
229
+ throw error;
230
+ }
231
+ }
232
+ }
233
+
234
+ private async unregisterChild(): Promise<void> {
235
+ const pid = this.registeredPid;
236
+ if (pid === null) return;
237
+ this.registeredPid = null;
238
+ await this.options.registry?.unregister(pid);
216
239
  }
217
240
 
218
241
  private async promptForSubmission(
@@ -310,6 +333,25 @@ function throwIfAborted(signal: AbortSignal): void {
310
333
  }
311
334
  }
312
335
 
336
+ async function waitForGroupExit(pid: number, timeoutMs: number): Promise<boolean> {
337
+ if (process.platform === "win32") return true;
338
+ const deadline = Date.now() + timeoutMs;
339
+ while (processGroupExists(pid)) {
340
+ if (Date.now() >= deadline) return false;
341
+ await new Promise((resolve) => setTimeout(resolve, 25));
342
+ }
343
+ return true;
344
+ }
345
+
346
+ function processGroupExists(pid: number): boolean {
347
+ try {
348
+ process.kill(-pid, 0);
349
+ return true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
313
355
  async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
314
356
  if (child.exitCode !== null || child.signalCode !== null) {
315
357
  return true;