@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,79 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ import { canonicalJson, type JsonValue } from "../state/json.js";
5
+ import { ChildWorkerSupervisor, type SupervisedWorkerResult } from "./child-worker-supervisor.js";
6
+ import {
7
+ encodeControllerWorkerLine,
8
+ parseControllerWorkerMessage,
9
+ type ControllerWorkerLaunchEnvelope,
10
+ type ControllerWorkerMessage,
11
+ type ControllerWorkerResponse,
12
+ } from "./controller-worker-protocol.js";
13
+ import { HostProcessRegistry, type ProcessIdentity } from "./processes.js";
14
+ import type { WorkerOutcome } from "./state.js";
15
+
16
+ export class ControllerWorkerSupervisor {
17
+ private readonly supervisor: ChildWorkerSupervisor<
18
+ ControllerWorkerMessage,
19
+ ControllerWorkerResponse
20
+ >;
21
+
22
+ constructor(
23
+ readonly envelope: ControllerWorkerLaunchEnvelope,
24
+ private readonly options: {
25
+ registry: HostProcessRegistry;
26
+ onMessage: (message: ControllerWorkerMessage) => Promise<ControllerWorkerResponse>;
27
+ env?: Record<string, string>;
28
+ startupTimeoutMs?: number;
29
+ workerEntryPath?: string;
30
+ onSpawn?: (identity: ProcessIdentity) => void;
31
+ onDiagnostic?: (message: string) => void;
32
+ },
33
+ ) {
34
+ this.supervisor = new ChildWorkerSupervisor({
35
+ label: "Controller worker",
36
+ registry: options.registry,
37
+ onMessage: options.onMessage,
38
+ parseMessage: parseControllerWorkerMessage,
39
+ encodeResponse: encodeControllerWorkerLine,
40
+ isReady: (message) => message.operation === "worker.ready",
41
+ isTerminal: (message) =>
42
+ message.operation === "worker.finished" || message.operation === "worker.failed",
43
+ ...(options.startupTimeoutMs === undefined
44
+ ? {}
45
+ : { startupTimeoutMs: options.startupTimeoutMs }),
46
+ ...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
47
+ ...(options.onDiagnostic === undefined ? {} : { onDiagnostic: options.onDiagnostic }),
48
+ });
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ const builtEntry = fileURLToPath(new URL("./controller-worker-entry.js", import.meta.url));
53
+ const sourceEntry = fileURLToPath(new URL("./controller-worker-entry.ts", import.meta.url));
54
+ const workerEntry = this.options.workerEntryPath ?? builtEntry;
55
+ const args =
56
+ this.options.workerEntryPath === undefined && !fs.existsSync(builtEntry)
57
+ ? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
58
+ : [workerEntry];
59
+ await this.supervisor.start({
60
+ args,
61
+ cwd: this.envelope.projectPath,
62
+ env: {
63
+ ...process.env,
64
+ ...this.options.env,
65
+ PI_WORKFLOWS_CONTROLLER_WORKER_LAUNCH: Buffer.from(
66
+ canonicalJson(this.envelope as unknown as JsonValue),
67
+ ).toString("base64url"),
68
+ },
69
+ });
70
+ }
71
+
72
+ async wait(): Promise<SupervisedWorkerResult> {
73
+ return await this.supervisor.wait();
74
+ }
75
+
76
+ async stop(outcome: WorkerOutcome = "cancelled"): Promise<void> {
77
+ await this.supervisor.stop(outcome);
78
+ }
79
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { errorMessage } from "../workflows/errors.js";
3
+ import { WorkflowHost } from "./runner.js";
4
+
5
+ async function main(): Promise<void> {
6
+ const databaseIndex = process.argv.indexOf("--database");
7
+ const databasePath = databaseIndex < 0 ? undefined : process.argv[databaseIndex + 1];
8
+ const host = new WorkflowHost({
9
+ ...(databasePath === undefined ? {} : { databasePath }),
10
+ onLog: (message) => process.stderr.write(`[pi-workflows host] ${message}\n`),
11
+ });
12
+ const shutdown = () => void host.stop();
13
+ process.once("SIGINT", shutdown);
14
+ process.once("SIGTERM", shutdown);
15
+ try {
16
+ await host.start();
17
+ } catch (error) {
18
+ process.stderr.write(`${errorMessage(error)}\n`);
19
+ process.exitCode = 1;
20
+ }
21
+ }
22
+
23
+ void main();
@@ -1,111 +1,228 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
 
5
+ export type ProcessIdentity = {
6
+ pid: number;
7
+ startIdentity: string;
8
+ };
9
+
10
+ type ProcessRegistryFile = {
11
+ schema: "pi-workflows.process-registry.v1";
12
+ processes: ProcessIdentity[];
13
+ };
14
+
4
15
  /**
5
- * Tracks headless child processes (spawned `pi --mode rpc` sessions) so a
6
- * killed host never leaves orphans working. Children spawn in their own
7
- * process group; the registry file lets a later host reap leftovers by
8
- * killing the whole group. The file lives next to the project store and is
9
- * only ever touched by one host at a time (the advisory lock ensures it).
16
+ * Tracks supervised process groups by PID and operating-system start identity.
17
+ * A later host kills a recorded process only when both values still match.
10
18
  */
11
19
  export class HostProcessRegistry {
12
20
  private readonly filePath: string;
13
- private readonly pids = new Set<number>();
21
+ private readonly processes = new Map<number, ProcessIdentity>();
14
22
 
15
23
  constructor(storeDir: string) {
16
24
  this.filePath = path.join(storeDir, "host.children.json");
17
25
  }
18
26
 
19
- register(pid: number): void {
20
- this.pids.add(pid);
27
+ register(pid: number): ProcessIdentity {
28
+ const startIdentity = processStartIdentity(pid);
29
+ if (startIdentity === undefined) {
30
+ throw new Error(`Cannot attest supervised process start identity: ${pid}`);
31
+ }
32
+ const identity = { pid, startIdentity };
33
+ this.processes.set(pid, identity);
21
34
  this.persist();
35
+ return identity;
22
36
  }
23
37
 
24
38
  unregister(pid: number): void {
25
- this.pids.delete(pid);
39
+ this.processes.delete(pid);
26
40
  this.persist();
27
41
  }
28
42
 
29
43
  get size(): number {
30
- return this.pids.size;
44
+ return this.processes.size;
45
+ }
46
+
47
+ /** Kill one exact registered process group and remove its durable record. */
48
+ kill(pid: number): boolean {
49
+ const identity = this.processes.get(pid);
50
+ if (identity === undefined) return false;
51
+ if (matchesProcessIdentity(identity)) {
52
+ killProcessGroup(identity.pid, "SIGTERM");
53
+ if (matchesProcessIdentity(identity)) killProcessGroup(identity.pid, "SIGKILL");
54
+ }
55
+ this.processes.delete(pid);
56
+ this.persist();
57
+ return true;
31
58
  }
32
59
 
33
- /** Kill every registered child's process group, escalating to SIGKILL. */
60
+ /** Kill every process that still has its registered start identity. */
34
61
  killAll(): void {
35
- for (const pid of this.pids) {
36
- killProcessGroup(pid, "SIGTERM");
62
+ for (const identity of this.processes.values()) {
63
+ if (matchesProcessIdentity(identity)) killProcessGroup(identity.pid, "SIGTERM");
37
64
  }
38
- for (const pid of this.pids) {
39
- killProcessGroup(pid, "SIGKILL");
65
+ for (const identity of this.processes.values()) {
66
+ if (matchesProcessIdentity(identity)) killProcessGroup(identity.pid, "SIGKILL");
40
67
  }
41
- this.pids.clear();
68
+ this.processes.clear();
42
69
  this.persist();
43
70
  }
44
71
 
45
- /**
46
- * Reap children recorded by a previous host that died without cleanup.
47
- * Called once at host start, before the lock is taken.
48
- */
72
+ /** Reap only exact stale process identities recorded by an older host. */
49
73
  reapOrphans(): number[] {
50
74
  const recorded = this.readFile();
51
75
  const reaped: number[] = [];
52
- const stillAlive: number[] = [];
53
- for (const pid of recorded) {
54
- if (isAlive(pid)) {
55
- killProcessGroup(pid, "SIGKILL");
56
- reaped.push(pid);
57
- if (isAlive(pid)) {
58
- // A group kill that left the leader alive stays registered so a
59
- // later start tries again instead of forgetting it.
60
- stillAlive.push(pid);
61
- }
62
- }
63
- }
64
- this.pids.clear();
65
- for (const pid of stillAlive) {
66
- this.pids.add(pid);
76
+ const stillAlive: ProcessIdentity[] = [];
77
+ for (const identity of recorded) {
78
+ if (!matchesProcessIdentity(identity)) continue;
79
+ killProcessGroup(identity.pid, "SIGKILL");
80
+ reaped.push(identity.pid);
81
+ if (matchesProcessIdentity(identity)) stillAlive.push(identity);
67
82
  }
83
+ this.processes.clear();
84
+ for (const identity of stillAlive) this.processes.set(identity.pid, identity);
68
85
  this.persist();
69
86
  return reaped;
70
87
  }
71
88
 
72
- private readFile(): number[] {
89
+ private readFile(): ProcessIdentity[] {
73
90
  try {
74
91
  const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as unknown;
75
- if (!Array.isArray(parsed)) {
76
- return [];
77
- }
78
- return parsed.filter((pid): pid is number => Number.isSafeInteger(pid) && pid > 0);
92
+ if (!isRegistryFile(parsed)) return [];
93
+ return parsed.processes;
79
94
  } catch {
80
95
  return [];
81
96
  }
82
97
  }
83
98
 
84
99
  private persist(): void {
100
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
101
+ const file: ProcessRegistryFile = {
102
+ schema: "pi-workflows.process-registry.v1",
103
+ processes: [...this.processes.values()].sort((left, right) => left.pid - right.pid),
104
+ };
105
+ const temporary = `${this.filePath}.${process.pid}.tmp`;
106
+ fs.writeFileSync(temporary, `${JSON.stringify(file)}\n`, { encoding: "utf8", mode: 0o600 });
107
+ fs.renameSync(temporary, this.filePath);
108
+ fs.chmodSync(this.filePath, 0o600);
109
+ }
110
+ }
111
+
112
+ /** Return the operating-system parent PID for a live process. */
113
+ export function processParentPid(pid: number): number | undefined {
114
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
115
+ if (process.platform === "linux") {
85
116
  try {
86
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
87
- // Write exactly the live set. Unioning with the previous file would
88
- // resurrect exited pids, and a later host reaping them could kill an
89
- // unrelated process group that reused one.
90
- fs.writeFileSync(this.filePath, `${JSON.stringify([...this.pids])}\n`, {
91
- encoding: "utf8",
92
- mode: 0o600,
93
- });
117
+ const fields = linuxStatFields(pid);
118
+ const parentPid = Number(fields[1]);
119
+ return Number.isSafeInteger(parentPid) && parentPid > 0 ? parentPid : undefined;
94
120
  } catch {
95
- // Registry bookkeeping is best-effort; orphan reaping is the backstop.
121
+ return undefined;
96
122
  }
97
123
  }
124
+ if (process.platform === "win32") {
125
+ try {
126
+ const parentPid = Number(
127
+ execFileSync(
128
+ "powershell.exe",
129
+ [
130
+ "-NoProfile",
131
+ "-NonInteractive",
132
+ "-Command",
133
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").ParentProcessId`,
134
+ ],
135
+ { encoding: "utf8", timeout: 2_000, windowsHide: true },
136
+ ).trim(),
137
+ );
138
+ return Number.isSafeInteger(parentPid) && parentPid > 0 ? parentPid : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ try {
144
+ const parentPid = Number(
145
+ execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
146
+ encoding: "utf8",
147
+ timeout: 2_000,
148
+ }).trim(),
149
+ );
150
+ return Number.isSafeInteger(parentPid) && parentPid > 0 ? parentPid : undefined;
151
+ } catch {
152
+ return undefined;
153
+ }
98
154
  }
99
155
 
100
- function isAlive(pid: number): boolean {
156
+ /** Return an operating-system process start value that fences PID reuse. */
157
+ export function processStartIdentity(pid: number): string | undefined {
158
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
159
+ if (process.platform === "linux") {
160
+ try {
161
+ const startTicks = linuxStatFields(pid)[19];
162
+ if (startTicks !== undefined) return `linux-proc-start:${startTicks}`;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ if (process.platform === "win32") {
168
+ try {
169
+ const ticks = execFileSync(
170
+ "powershell.exe",
171
+ [
172
+ "-NoProfile",
173
+ "-NonInteractive",
174
+ "-Command",
175
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
176
+ ],
177
+ { encoding: "utf8", timeout: 2_000, windowsHide: true },
178
+ ).trim();
179
+ return ticks.length === 0 ? undefined : `windows-start-ticks:${ticks}`;
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ }
101
184
  try {
102
- process.kill(pid, 0);
103
- return true;
185
+ const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
186
+ encoding: "utf8",
187
+ timeout: 2_000,
188
+ }).trim();
189
+ return started.length === 0 ? undefined : `${process.platform}-ps-start:${started}`;
104
190
  } catch {
105
- return false;
191
+ return undefined;
106
192
  }
107
193
  }
108
194
 
195
+ function linuxStatFields(pid: number): string[] {
196
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
197
+ const commandEnd = stat.lastIndexOf(")");
198
+ if (commandEnd < 0) throw new Error(`Linux process stat is invalid: ${pid}`);
199
+ return stat
200
+ .slice(commandEnd + 2)
201
+ .trim()
202
+ .split(/\s+/);
203
+ }
204
+
205
+ export function matchesProcessIdentity(identity: ProcessIdentity): boolean {
206
+ return processStartIdentity(identity.pid) === identity.startIdentity;
207
+ }
208
+
209
+ function isRegistryFile(value: unknown): value is ProcessRegistryFile {
210
+ if (!isRecord(value) || value.schema !== "pi-workflows.process-registry.v1") return false;
211
+ if (!Array.isArray(value.processes)) return false;
212
+ return value.processes.every(
213
+ (entry) =>
214
+ isRecord(entry) &&
215
+ Number.isSafeInteger(entry.pid) &&
216
+ (entry.pid as number) > 0 &&
217
+ typeof entry.startIdentity === "string" &&
218
+ entry.startIdentity.length > 0,
219
+ );
220
+ }
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
224
+ }
225
+
109
226
  function killProcessGroup(pid: number, signal: NodeJS.Signals): void {
110
227
  try {
111
228
  process.kill(-pid, signal);
@@ -113,7 +230,7 @@ function killProcessGroup(pid: number, signal: NodeJS.Signals): void {
113
230
  try {
114
231
  process.kill(pid, signal);
115
232
  } catch {
116
- // Already gone.
233
+ // The exact process has already exited.
117
234
  }
118
235
  }
119
236
  }
@@ -0,0 +1,196 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
4
+
5
+ export const HOST_PROTOCOL_VERSION = 1;
6
+ export const HOST_REQUEST_SCHEMA = "pi-workflows.host-request.v1" as const;
7
+ export const HOST_RESPONSE_SCHEMA = "pi-workflows.host-response.v1" as const;
8
+ export const MAX_PROTOCOL_MESSAGE_BYTES = 1024 * 1024;
9
+
10
+ export const HOST_OPERATIONS = [
11
+ "run.start",
12
+ "run.pause",
13
+ "run.resume",
14
+ "run.cancel",
15
+ "run.status",
16
+ "run.list",
17
+ "checkpoint.answer",
18
+ "decision.answer",
19
+ "interaction.submit",
20
+ "interaction.update",
21
+ "notification.claim",
22
+ "notification.deliver",
23
+ "turn.claim",
24
+ "turn.resolve",
25
+ "controller.list",
26
+ "controller.get",
27
+ "controller.apply",
28
+ "controller.reconcile",
29
+ "controller.delete",
30
+ "host.status",
31
+ "host.stop",
32
+ ] as const;
33
+
34
+ export type HostOperation = (typeof HOST_OPERATIONS)[number];
35
+ export type HostOutcome =
36
+ | "accepted"
37
+ | "adopted"
38
+ | "rejected"
39
+ | "conflict"
40
+ | "notFound"
41
+ | "claimLost"
42
+ | "unavailable";
43
+
44
+ export type HostRequest = {
45
+ schema: typeof HOST_REQUEST_SCHEMA;
46
+ requestId: string;
47
+ clientId: string;
48
+ operation: HostOperation;
49
+ idempotencyKey: string;
50
+ runId?: string;
51
+ expectedRevision?: number;
52
+ payload: JsonValue;
53
+ };
54
+
55
+ export type HostResponse = {
56
+ schema: typeof HOST_RESPONSE_SCHEMA;
57
+ requestId: string;
58
+ outcome: HostOutcome;
59
+ revision?: number;
60
+ receipt?: JsonValue;
61
+ error?: string;
62
+ };
63
+
64
+ export class HostProtocolError extends Error {
65
+ constructor(message: string) {
66
+ super(message);
67
+ this.name = "HostProtocolError";
68
+ }
69
+ }
70
+
71
+ export function encodeProtocolLine(message: HostRequest | HostResponse): Buffer {
72
+ const encoded = Buffer.from(`${canonicalJson(message)}\n`, "utf8");
73
+ if (encoded.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) {
74
+ throw new HostProtocolError("Host protocol message exceeds 1 MiB");
75
+ }
76
+ return encoded;
77
+ }
78
+
79
+ export function parseHostRequest(line: string | Buffer): HostRequest {
80
+ const value = parseProtocolValue(line);
81
+ if (value.schema !== HOST_REQUEST_SCHEMA)
82
+ throw new HostProtocolError("Invalid host request schema");
83
+ requireOpaqueId(value.requestId, "requestId");
84
+ requireOpaqueId(value.clientId, "clientId");
85
+ requireOpaqueId(value.idempotencyKey, "idempotencyKey");
86
+ if (!HOST_OPERATIONS.includes(value.operation as HostOperation)) {
87
+ throw new HostProtocolError("Invalid host request operation");
88
+ }
89
+ if (value.runId !== undefined) requireOpaqueId(value.runId, "runId");
90
+ if (
91
+ value.expectedRevision !== undefined &&
92
+ (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0)
93
+ ) {
94
+ throw new HostProtocolError("Host request expectedRevision must be a non-negative integer");
95
+ }
96
+ if (!Object.hasOwn(value, "payload"))
97
+ throw new HostProtocolError("Host request payload is required");
98
+ canonicalJson(value.payload);
99
+ return value as HostRequest;
100
+ }
101
+
102
+ export function parseHostResponse(line: string | Buffer): HostResponse {
103
+ const value = parseProtocolValue(line);
104
+ if (value.schema !== HOST_RESPONSE_SCHEMA) {
105
+ throw new HostProtocolError("Invalid host response schema");
106
+ }
107
+ requireOpaqueId(value.requestId, "requestId");
108
+ const outcomes: HostOutcome[] = [
109
+ "accepted",
110
+ "adopted",
111
+ "rejected",
112
+ "conflict",
113
+ "notFound",
114
+ "claimLost",
115
+ "unavailable",
116
+ ];
117
+ if (!outcomes.includes(value.outcome as HostOutcome)) {
118
+ throw new HostProtocolError("Invalid host response outcome");
119
+ }
120
+ if (
121
+ value.revision !== undefined &&
122
+ (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0)
123
+ ) {
124
+ throw new HostProtocolError("Host response revision must be a non-negative integer");
125
+ }
126
+ if (value.error !== undefined && typeof value.error !== "string") {
127
+ throw new HostProtocolError("Host response error must be text");
128
+ }
129
+ if (value.receipt !== undefined) canonicalJson(value.receipt);
130
+ return value as HostResponse;
131
+ }
132
+
133
+ export function requestFingerprint(request: HostRequest): Buffer {
134
+ return createHash("sha256").update(canonicalJson(request)).digest();
135
+ }
136
+
137
+ export function hostSocketPath(databasePath: string): string {
138
+ const stateDirectory = path.dirname(path.resolve(databasePath));
139
+ if (process.platform === "win32") {
140
+ const suffix = createHash("sha256").update(stateDirectory).digest("hex").slice(0, 24);
141
+ return `\\\\.\\pipe\\pi-workflows-${suffix}`;
142
+ }
143
+ return path.join(stateDirectory, "host", "host.sock");
144
+ }
145
+
146
+ export class NdjsonFrameDecoder {
147
+ private buffered = Buffer.alloc(0);
148
+
149
+ push(chunk: Buffer): Buffer[] {
150
+ this.buffered = Buffer.concat([this.buffered, chunk]);
151
+ if (this.buffered.byteLength > MAX_PROTOCOL_MESSAGE_BYTES && !this.buffered.includes(0x0a)) {
152
+ throw new HostProtocolError("Host protocol message exceeds 1 MiB");
153
+ }
154
+ const frames: Buffer[] = [];
155
+ for (;;) {
156
+ const newline = this.buffered.indexOf(0x0a);
157
+ if (newline < 0) break;
158
+ const frame = this.buffered.subarray(0, newline);
159
+ this.buffered = this.buffered.subarray(newline + 1);
160
+ if (frame.byteLength === 0) continue;
161
+ if (frame.byteLength + 1 > MAX_PROTOCOL_MESSAGE_BYTES) {
162
+ throw new HostProtocolError("Host protocol message exceeds 1 MiB");
163
+ }
164
+ frames.push(frame);
165
+ }
166
+ return frames;
167
+ }
168
+ }
169
+
170
+ function parseProtocolValue(line: string | Buffer): Record<string, unknown> {
171
+ const buffer = Buffer.isBuffer(line) ? line : Buffer.from(line, "utf8");
172
+ if (buffer.byteLength + 1 > MAX_PROTOCOL_MESSAGE_BYTES) {
173
+ throw new HostProtocolError("Host protocol message exceeds 1 MiB");
174
+ }
175
+ let value: unknown;
176
+ try {
177
+ value = parseJson(buffer.toString("utf8"));
178
+ } catch {
179
+ throw new HostProtocolError("Host protocol message is not valid canonical JSON");
180
+ }
181
+ if (!isRecord(value)) throw new HostProtocolError("Host protocol message must be an object");
182
+ if (canonicalJson(value) !== buffer.toString("utf8")) {
183
+ throw new HostProtocolError("Host protocol message must use canonical JSON");
184
+ }
185
+ return value;
186
+ }
187
+
188
+ function requireOpaqueId(value: unknown, field: string): asserts value is string {
189
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) {
190
+ throw new HostProtocolError(`Host request ${field} must be nonempty text of at most 256 bytes`);
191
+ }
192
+ }
193
+
194
+ function isRecord(value: unknown): value is Record<string, unknown> {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }