pi-daddy 0.43.1 → 0.44.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 (221) hide show
  1. package/CHANGELOG.md +93 -31
  2. package/README.md +15 -0
  3. package/THIRD_PARTY_LICENSES/musl-COPYRIGHT.txt +193 -0
  4. package/THIRD_PARTY_LICENSES/zig-MIT.txt +21 -0
  5. package/THIRD_PARTY_NOTICES.md +9 -0
  6. package/dist/cli.d.ts +2 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +10 -5
  9. package/dist/cli.js.map +1 -1
  10. package/dist/executors/activity-session.js +116 -65
  11. package/dist/executors/activity-session.js.map +1 -1
  12. package/dist/executors/captured-execution.d.ts +13 -0
  13. package/dist/executors/captured-execution.d.ts.map +1 -0
  14. package/dist/executors/captured-execution.js +53 -0
  15. package/dist/executors/captured-execution.js.map +1 -0
  16. package/dist/executors/child-final.d.ts +31 -0
  17. package/dist/executors/child-final.d.ts.map +1 -0
  18. package/dist/executors/child-final.js +197 -0
  19. package/dist/executors/child-final.js.map +1 -0
  20. package/dist/executors/executor.d.ts +2 -1
  21. package/dist/executors/executor.d.ts.map +1 -1
  22. package/dist/executors/executor.js +12 -0
  23. package/dist/executors/executor.js.map +1 -1
  24. package/dist/executors/owned-worker.d.ts +18 -0
  25. package/dist/executors/owned-worker.d.ts.map +1 -0
  26. package/dist/executors/owned-worker.js +295 -0
  27. package/dist/executors/owned-worker.js.map +1 -0
  28. package/dist/executors/worker-artifact.d.ts +3 -0
  29. package/dist/executors/worker-artifact.d.ts.map +1 -0
  30. package/dist/executors/worker-artifact.js +3 -0
  31. package/dist/executors/worker-artifact.js.map +1 -0
  32. package/dist/governance/approval-prompt.d.ts +4 -3
  33. package/dist/governance/approval-prompt.d.ts.map +1 -1
  34. package/dist/governance/approval-prompt.js +13 -6
  35. package/dist/governance/approval-prompt.js.map +1 -1
  36. package/dist/governance/captured-worker-record.d.ts +9 -0
  37. package/dist/governance/captured-worker-record.d.ts.map +1 -0
  38. package/dist/governance/captured-worker-record.js +123 -0
  39. package/dist/governance/captured-worker-record.js.map +1 -0
  40. package/dist/governance/lease-record.d.ts +4 -0
  41. package/dist/governance/lease-record.d.ts.map +1 -1
  42. package/dist/governance/lease-record.js +19 -2
  43. package/dist/governance/lease-record.js.map +1 -1
  44. package/dist/governance/ledger-events.d.ts +5 -5
  45. package/dist/governance/ledger-events.d.ts.map +1 -1
  46. package/dist/governance/ledger-v3-validation.js +1 -1
  47. package/dist/governance/ledger-v3-validation.js.map +1 -1
  48. package/dist/governance/workspace-acceptance.d.ts +3 -1
  49. package/dist/governance/workspace-acceptance.d.ts.map +1 -1
  50. package/dist/governance/workspace-acceptance.js +21 -5
  51. package/dist/governance/workspace-acceptance.js.map +1 -1
  52. package/dist/governance/workspace-lease.d.ts.map +1 -1
  53. package/dist/governance/workspace-lease.js +44 -1
  54. package/dist/governance/workspace-lease.js.map +1 -1
  55. package/dist/kernel/authority-text.d.ts +3 -0
  56. package/dist/kernel/authority-text.d.ts.map +1 -0
  57. package/dist/kernel/authority-text.js +22 -0
  58. package/dist/kernel/authority-text.js.map +1 -0
  59. package/dist/kernel/bounded-read-failures.d.ts +9 -0
  60. package/dist/kernel/bounded-read-failures.d.ts.map +1 -0
  61. package/dist/kernel/bounded-read-failures.js +33 -0
  62. package/dist/kernel/bounded-read-failures.js.map +1 -0
  63. package/dist/kernel/bounded-read-sync.d.ts +24 -0
  64. package/dist/kernel/bounded-read-sync.d.ts.map +1 -0
  65. package/dist/kernel/bounded-read-sync.js +75 -0
  66. package/dist/kernel/bounded-read-sync.js.map +1 -0
  67. package/dist/kernel/bounded-read.d.ts +29 -2
  68. package/dist/kernel/bounded-read.d.ts.map +1 -1
  69. package/dist/kernel/bounded-read.js +98 -9
  70. package/dist/kernel/bounded-read.js.map +1 -1
  71. package/dist/kernel/capacity.d.ts +36 -0
  72. package/dist/kernel/capacity.d.ts.map +1 -0
  73. package/dist/kernel/capacity.js +94 -0
  74. package/dist/kernel/capacity.js.map +1 -0
  75. package/dist/kernel/captured-worker-contract.d.ts +38 -0
  76. package/dist/kernel/captured-worker-contract.d.ts.map +1 -0
  77. package/dist/kernel/captured-worker-contract.js +2 -0
  78. package/dist/kernel/captured-worker-contract.js.map +1 -0
  79. package/dist/kernel/catalog.d.ts +12 -3
  80. package/dist/kernel/catalog.d.ts.map +1 -1
  81. package/dist/kernel/catalog.js +28 -8
  82. package/dist/kernel/catalog.js.map +1 -1
  83. package/dist/kernel/chain.d.ts +1 -0
  84. package/dist/kernel/chain.d.ts.map +1 -1
  85. package/dist/kernel/chain.js.map +1 -1
  86. package/dist/kernel/definitions.d.ts +11 -1
  87. package/dist/kernel/definitions.d.ts.map +1 -1
  88. package/dist/kernel/definitions.js +6 -3
  89. package/dist/kernel/definitions.js.map +1 -1
  90. package/dist/kernel/fanout.d.ts +5 -25
  91. package/dist/kernel/fanout.d.ts.map +1 -1
  92. package/dist/kernel/fanout.js +15 -30
  93. package/dist/kernel/fanout.js.map +1 -1
  94. package/dist/kernel/grant-env.d.ts +0 -2
  95. package/dist/kernel/grant-env.d.ts.map +1 -1
  96. package/dist/kernel/grant-env.js +0 -1
  97. package/dist/kernel/grant-env.js.map +1 -1
  98. package/dist/kernel/model-preflight.d.ts +8 -0
  99. package/dist/kernel/model-preflight.d.ts.map +1 -1
  100. package/dist/kernel/model-preflight.js +23 -0
  101. package/dist/kernel/model-preflight.js.map +1 -1
  102. package/dist/kernel/selected-definitions.d.ts +15 -0
  103. package/dist/kernel/selected-definitions.d.ts.map +1 -0
  104. package/dist/kernel/selected-definitions.js +146 -0
  105. package/dist/kernel/selected-definitions.js.map +1 -0
  106. package/dist/kernel/skill-packages.d.ts +4 -2
  107. package/dist/kernel/skill-packages.d.ts.map +1 -1
  108. package/dist/kernel/skill-packages.js +34 -41
  109. package/dist/kernel/skill-packages.js.map +1 -1
  110. package/dist/kernel/skill-resources.d.ts.map +1 -1
  111. package/dist/kernel/skill-resources.js +8 -11
  112. package/dist/kernel/skill-resources.js.map +1 -1
  113. package/dist/kernel/workspace.d.ts +6 -3
  114. package/dist/kernel/workspace.d.ts.map +1 -1
  115. package/dist/kernel/workspace.js +10 -6
  116. package/dist/kernel/workspace.js.map +1 -1
  117. package/dist/products/dashboard-cli.js +1 -1
  118. package/dist/products/dashboard-cli.js.map +1 -1
  119. package/dist/products/dashboard-render.d.ts +1 -2
  120. package/dist/products/dashboard-render.d.ts.map +1 -1
  121. package/dist/products/dashboard-render.js +3 -1
  122. package/dist/products/dashboard-render.js.map +1 -1
  123. package/dist/products/dashboard-session-client.d.ts +1 -2
  124. package/dist/products/dashboard-session-client.d.ts.map +1 -1
  125. package/dist/products/dashboard-session-client.js.map +1 -1
  126. package/dist/products/episode-report.d.ts +12 -8
  127. package/dist/products/episode-report.d.ts.map +1 -1
  128. package/dist/products/episode-report.js +61 -12
  129. package/dist/products/episode-report.js.map +1 -1
  130. package/extensions/approvals.ts +1 -1
  131. package/extensions/chain-plan.ts +9 -3
  132. package/extensions/context-staging.ts +30 -35
  133. package/extensions/dashboard-session-server.ts +1 -2
  134. package/extensions/definition-describe.ts +44 -0
  135. package/extensions/definition-runtime.ts +89 -26
  136. package/extensions/delegate-chain.ts +273 -256
  137. package/extensions/delegation-activation.ts +1 -1
  138. package/extensions/delegation.ts +41 -53
  139. package/extensions/execute-child.ts +167 -134
  140. package/extensions/fanout-outcome.ts +1 -9
  141. package/extensions/grants-command.ts +2 -9
  142. package/extensions/grants.ts +106 -38
  143. package/extensions/init-command.ts +52 -12
  144. package/extensions/reload-environment.ts +21 -2
  145. package/extensions/retired-inputs.ts +7 -0
  146. package/extensions/run-delegation.ts +212 -265
  147. package/extensions/session-capacity.ts +53 -0
  148. package/extensions/session-environment.ts +5 -2
  149. package/extensions/session-model-prompt.ts +33 -32
  150. package/extensions/session-report.ts +28 -11
  151. package/extensions/session.ts +200 -157
  152. package/extensions/workspace-runtime.ts +2 -1
  153. package/native/README.md +25 -0
  154. package/native/linux-x64/worker +0 -0
  155. package/native/linux-x64/worker.sha256 +1 -0
  156. package/native/provenance.json +42 -0
  157. package/native/toolchain.json +18 -0
  158. package/native/worker.c +209 -0
  159. package/package.json +15 -7
  160. package/src/cli.ts +16 -6
  161. package/src/executors/activity-session.ts +120 -66
  162. package/src/executors/captured-execution.ts +61 -0
  163. package/src/executors/child-final.ts +190 -0
  164. package/src/executors/executor.ts +14 -1
  165. package/src/executors/owned-worker.ts +322 -0
  166. package/src/executors/worker-artifact.ts +2 -0
  167. package/src/governance/approval-prompt.ts +18 -8
  168. package/src/governance/captured-worker-record.ts +138 -0
  169. package/src/governance/lease-record.ts +21 -2
  170. package/src/governance/ledger-events.ts +5 -5
  171. package/src/governance/ledger-v3-validation.ts +1 -1
  172. package/src/governance/workspace-acceptance.ts +20 -4
  173. package/src/governance/workspace-lease.ts +59 -1
  174. package/src/kernel/authority-text.ts +17 -0
  175. package/src/kernel/bounded-read-failures.ts +42 -0
  176. package/src/kernel/bounded-read-sync.ts +87 -0
  177. package/src/kernel/bounded-read.ts +128 -11
  178. package/src/kernel/capacity.ts +123 -0
  179. package/src/kernel/captured-worker-contract.ts +29 -0
  180. package/src/kernel/catalog.ts +45 -16
  181. package/src/kernel/chain.ts +1 -0
  182. package/src/kernel/definitions.ts +11 -2
  183. package/src/kernel/fanout.ts +17 -32
  184. package/src/kernel/grant-env.ts +0 -3
  185. package/src/kernel/model-preflight.ts +29 -0
  186. package/src/kernel/selected-definitions.ts +156 -0
  187. package/src/kernel/skill-packages.ts +40 -38
  188. package/src/kernel/skill-resources.ts +8 -9
  189. package/src/kernel/workspace.ts +12 -5
  190. package/src/products/dashboard-cli.ts +1 -1
  191. package/src/products/dashboard-render.ts +6 -2
  192. package/src/products/dashboard-session-client.ts +1 -2
  193. package/src/products/episode-report.ts +80 -17
  194. package/dist/advisors/advisor.d.ts +0 -90
  195. package/dist/advisors/advisor.d.ts.map +0 -1
  196. package/dist/advisors/advisor.js +0 -154
  197. package/dist/advisors/advisor.js.map +0 -1
  198. package/dist/advisors/decider.d.ts +0 -88
  199. package/dist/advisors/decider.d.ts.map +0 -1
  200. package/dist/advisors/decider.js +0 -29
  201. package/dist/advisors/decider.js.map +0 -1
  202. package/dist/advisors/jev.d.ts +0 -41
  203. package/dist/advisors/jev.d.ts.map +0 -1
  204. package/dist/advisors/jev.js +0 -139
  205. package/dist/advisors/jev.js.map +0 -1
  206. package/dist/advisors/settings.d.ts +0 -49
  207. package/dist/advisors/settings.d.ts.map +0 -1
  208. package/dist/advisors/settings.js +0 -99
  209. package/dist/advisors/settings.js.map +0 -1
  210. package/dist/governance/episode-cost-gate.d.ts +0 -31
  211. package/dist/governance/episode-cost-gate.d.ts.map +0 -1
  212. package/dist/governance/episode-cost-gate.js +0 -112
  213. package/dist/governance/episode-cost-gate.js.map +0 -1
  214. package/extensions/advisor-session.ts +0 -67
  215. package/extensions/effort-advice.ts +0 -112
  216. package/extensions/pruning-advice.ts +0 -133
  217. package/src/advisors/advisor.ts +0 -246
  218. package/src/advisors/decider.ts +0 -74
  219. package/src/advisors/jev.ts +0 -163
  220. package/src/advisors/settings.ts +0 -115
  221. package/src/governance/episode-cost-gate.ts +0 -128
@@ -33,6 +33,97 @@
33
33
  import { constants } from "node:fs";
34
34
  import { open, type FileHandle } from "node:fs/promises";
35
35
 
36
+ /** Explicitly trusted per-read I/O ports, never requester/environment input. */
37
+ export interface BoundedReadPorts {
38
+ open(path: string, flags: number): Promise<FileHandle>;
39
+ read(
40
+ handle: FileHandle,
41
+ buffer: Buffer,
42
+ offset: number,
43
+ length: number,
44
+ position: number,
45
+ ): Promise<{ bytesRead: number }>;
46
+ close(handle: FileHandle): Promise<void>;
47
+ }
48
+ /** A failed read stays failed even after this explicit recovery capability succeeds. */
49
+ export class BoundedReadCleanupError extends Error {
50
+ readonly cleanup: () => Promise<void>;
51
+ /** Latest physical failure, including falsy rejections; cause remains the original failure. */
52
+ lastCause: unknown;
53
+ constructor(cause: unknown, cleanup: () => Promise<void>) {
54
+ super("bounded reader descriptor cleanup unresolved; retain and explicitly retry", { cause });
55
+ this.name = "BoundedReadCleanupError";
56
+ this.lastCause = cause;
57
+ this.cleanup = cleanup;
58
+ }
59
+ }
60
+
61
+ const retained = new Set<BoundedReadCleanup>();
62
+ /** Trusted diagnostics/recovery only; not admission, a quota, or a Root-wide shutdown join. */
63
+ export function retainedBoundedReadCleanups(): readonly BoundedReadCleanupOwner[] {
64
+ return [...retained];
65
+ }
66
+ export interface BoundedReadCleanupOwner {
67
+ readonly handle: FileHandle | undefined;
68
+ cleanup(): Promise<void>;
69
+ }
70
+ class BoundedReadCleanup implements BoundedReadCleanupOwner {
71
+ private held: FileHandle | undefined;
72
+ private active?: Promise<void>;
73
+ private failure?: BoundedReadCleanupError;
74
+ private finish!: () => void;
75
+ private readonly reading = new Promise<void>((resolve) => (this.finish = resolve));
76
+ private readonly close: BoundedReadPorts["close"];
77
+ constructor(close: BoundedReadPorts["close"]) {
78
+ this.close = close;
79
+ retained.add(this);
80
+ }
81
+ acquired(handle: FileHandle) {
82
+ this.held = handle;
83
+ }
84
+ get handle() {
85
+ return this.held;
86
+ }
87
+ readSettled() {
88
+ this.finish();
89
+ }
90
+ cleanup(): Promise<void> {
91
+ if (this.active) return this.active;
92
+ // Publish before calling any trusted port: reentrant/concurrent cleanup shares this operation.
93
+ const operation = this.reading.then(async () => {
94
+ if (!this.held) {
95
+ retained.delete(this);
96
+ return;
97
+ }
98
+ try {
99
+ await this.close(this.held);
100
+ } catch (cause) {
101
+ this.failure ??= new BoundedReadCleanupError(cause, () => this.cleanup());
102
+ this.failure.lastCause = cause;
103
+ throw this.failure;
104
+ }
105
+ this.held = undefined;
106
+ retained.delete(this);
107
+ });
108
+ this.active = operation;
109
+ void operation.then(
110
+ () => {
111
+ this.active = undefined;
112
+ },
113
+ () => {
114
+ this.active = undefined;
115
+ },
116
+ );
117
+ return operation;
118
+ }
119
+ }
120
+
121
+ const boundedReadPorts: BoundedReadPorts = {
122
+ open,
123
+ read: (handle, buffer, offset, length, position) => handle.read(buffer, offset, length, position),
124
+ close: (handle) => handle.close(),
125
+ };
126
+
36
127
  export interface BoundedReadLimits {
37
128
  /** Refuse anything at or over this, before allocating it. */
38
129
  maxBytes: number;
@@ -50,12 +141,13 @@ export interface BoundedReadLimits {
50
141
  * one definition that will not be offered — and a shared reader must not decide that for them.
51
142
  */
52
143
  export type BoundedReadFailure =
53
- | { why: "unopenable"; detail: string }
144
+ | { why: "unopenable"; detail: string; code?: string }
54
145
  | { why: "not-a-regular-file"; detail: string }
55
146
  | { why: "too-large"; detail: string; size: number }
56
147
  | { why: "grew-while-reading"; detail: string }
57
148
  | { why: "timed-out"; detail: string }
58
- | { why: "unreadable"; detail: string };
149
+ | { why: "invalid-utf8"; detail: string }
150
+ | { why: "unreadable"; detail: string; code?: string };
59
151
 
60
152
  export type BoundedReadResult = { ok: true; text: string } | ({ ok: false } & BoundedReadFailure);
61
153
  export type BoundedReadBytes = { ok: true; bytes: Buffer } | ({ ok: false } & BoundedReadFailure);
@@ -67,18 +159,42 @@ export type BoundedReadBytes = { ok: true; bytes: Buffer } | ({ ok: false } & Bo
67
159
  * UTF-8 round trip unchanged — a latin-1 byte that decodes to U+FFFD changes the file's digest, and that
68
160
  * check is only possible against the original buffer. Decoding here and re-encoding there would defeat it.
69
161
  */
70
- export async function readBoundedFile(path: string, limits: BoundedReadLimits): Promise<BoundedReadResult> {
71
- const read = await readBoundedBytes(path, limits);
72
- return read.ok ? { ok: true, text: read.bytes.toString("utf8") } : read;
162
+ export async function readBoundedFile(
163
+ path: string,
164
+ limits: BoundedReadLimits,
165
+ ports: BoundedReadPorts = boundedReadPorts,
166
+ ): Promise<BoundedReadResult> {
167
+ const read = await readBoundedBytes(path, limits, ports);
168
+ if (!read.ok) return read;
169
+ try {
170
+ return { ok: true, text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(read.bytes) };
171
+ } catch {
172
+ return { ok: false, why: "invalid-utf8", detail: `${path} is not valid UTF-8` };
173
+ }
174
+ }
175
+
176
+ /** Preserve typed I/O evidence; a caller must not classify absence by searching diagnostic prose. */
177
+ function ioCode(error: unknown): { code?: string } {
178
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string")
179
+ return { code: error.code };
180
+ return {};
73
181
  }
74
182
 
75
- export async function readBoundedBytes(path: string, limits: BoundedReadLimits): Promise<BoundedReadBytes> {
183
+ export async function readBoundedBytes(
184
+ path: string,
185
+ limits: BoundedReadLimits,
186
+ ports: BoundedReadPorts = boundedReadPorts,
187
+ ): Promise<BoundedReadBytes> {
76
188
  const now = limits.now ?? Date.now;
77
189
  let handle: FileHandle;
190
+ const cleanup = new BoundedReadCleanup((held) => ports.close(held));
78
191
  try {
79
- handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
192
+ handle = await ports.open(path, constants.O_RDONLY | constants.O_NONBLOCK);
193
+ cleanup.acquired(handle);
80
194
  } catch (error) {
81
- return { ok: false, why: "unopenable", detail: String(error) };
195
+ cleanup.readSettled();
196
+ await cleanup.cleanup();
197
+ return { ok: false, why: "unopenable", detail: String(error), ...ioCode(error) };
82
198
  }
83
199
  try {
84
200
  const info = await handle.stat();
@@ -106,7 +222,7 @@ export async function readBoundedBytes(path: string, limits: BoundedReadLimits):
106
222
  why: "timed-out",
107
223
  detail: `${path} did not finish reading within ${limits.timeoutMs}ms`,
108
224
  };
109
- const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
225
+ const { bytesRead } = await ports.read(handle, buffer, filled, buffer.length - filled, filled);
110
226
  if (bytesRead === 0) break;
111
227
  filled += bytesRead;
112
228
  }
@@ -118,8 +234,9 @@ export async function readBoundedBytes(path: string, limits: BoundedReadLimits):
118
234
  };
119
235
  return { ok: true, bytes: buffer.subarray(0, filled) };
120
236
  } catch (error) {
121
- return { ok: false, why: "unreadable", detail: String(error) };
237
+ return { ok: false, why: "unreadable", detail: String(error), ...ioCode(error) };
122
238
  } finally {
123
- await handle.close().catch(() => {});
239
+ cleanup.readSettled();
240
+ await cleanup.cleanup();
124
241
  }
125
242
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Owner-local active descendant capacity. A reservation holds one child plus a disjoint subtree allowance.
3
+ * The allowance excludes this owner session. Conservatively hold all of it until the owned subtree settles;
4
+ * this permits reuse after settlement, but does not promise perfect utilization or a lifetime call limit.
5
+ */
6
+ import type { CapturedWorkerCleanup, CapturedWorkerIdentity } from "./captured-worker-contract.ts";
7
+
8
+ export type CapacityReservationState = "reserved" | "retained" | "released";
9
+ export interface CapacityReservation {
10
+ readonly executionId: string;
11
+ readonly childAllowance: number;
12
+ readonly cost: number;
13
+ readonly state: CapacityReservationState;
14
+ readonly identity: CapturedWorkerIdentity | undefined;
15
+ /** Bind actual ownership before releasing the worker gate. Throws on replacement or a different execution. */
16
+ bindOwnership(identity: CapturedWorkerIdentity): void;
17
+ /** Only trusted executor facts belong here; model output and ledger projections never authorize release. */
18
+ finalize(cleanup: CapturedWorkerCleanup): CapacityReservationState;
19
+ }
20
+ export type CapacityReservationResult = { ok: true; reservation: CapacityReservation } | { ok: false; reason: string };
21
+ export interface CapacityAllocator {
22
+ readonly total: number;
23
+ readonly available: number;
24
+ readonly reserved: number;
25
+ /** Still-held occurrences; released reservations are removed, execution IDs remain tombstoned. */
26
+ readonly retainedReservations: readonly CapacityReservation[];
27
+ reserve(executionId: string, childAllowance: number): CapacityReservationResult;
28
+ }
29
+
30
+ const identityKeys = [
31
+ "revision",
32
+ "executionId",
33
+ "nonce",
34
+ "root",
35
+ "rootDevice",
36
+ "rootInode",
37
+ "bootId",
38
+ "pidNamespace",
39
+ "helperPid",
40
+ "helperStartTicks",
41
+ "helperSha256",
42
+ "workerPid",
43
+ "ownershipPath",
44
+ "receiptPath",
45
+ ] as const;
46
+ function sameIdentity(a: CapturedWorkerIdentity, b: CapturedWorkerIdentity): boolean {
47
+ return identityKeys.every((key) => a[key] === b[key]);
48
+ }
49
+ const validAllowance = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
50
+
51
+ /** Synchronous reservations are atomic within the actual session owner, including overlapping tool calls. */
52
+ export function createCapacityAllocator(total: number): CapacityAllocator {
53
+ if (!validAllowance(total)) throw new Error("descendant capacity must be a non-negative safe integer");
54
+ let available = total;
55
+ // Keep occurrence tombstones: a stale finalizer must never release a newly reused execution identifier.
56
+ const executions = new Set<string>();
57
+ const held = new Map<string, CapacityReservation>();
58
+ return Object.freeze({
59
+ total,
60
+ get available() {
61
+ return available;
62
+ },
63
+ get reserved() {
64
+ return total - available;
65
+ },
66
+ get retainedReservations() {
67
+ return [...held.values()].filter((reservation) => reservation.state === "retained");
68
+ },
69
+ reserve(executionId: string, childAllowance: number): CapacityReservationResult {
70
+ if (!executionId || executionId.length > 512 || executions.has(executionId))
71
+ return { ok: false, reason: "capacity reservation requires a fresh bounded execution identifier" };
72
+ if (!validAllowance(childAllowance) || !Number.isSafeInteger(childAllowance + 1))
73
+ return { ok: false, reason: "child allowance must be a non-negative safe integer below the numeric ceiling" };
74
+ const cost = childAllowance + 1;
75
+ if (cost > available)
76
+ return {
77
+ ok: false,
78
+ reason: `active descendant capacity exhausted: ${cost} reserved units requested, ${available} available`,
79
+ };
80
+ available -= cost;
81
+ executions.add(executionId);
82
+ let state: CapacityReservationState = "reserved";
83
+ let identity: CapturedWorkerIdentity | undefined;
84
+ const reservation: CapacityReservation = Object.freeze({
85
+ executionId,
86
+ childAllowance,
87
+ cost,
88
+ get state() {
89
+ return state;
90
+ },
91
+ get identity() {
92
+ return identity;
93
+ },
94
+ bindOwnership(actual: CapturedWorkerIdentity): void {
95
+ if (
96
+ state === "released" ||
97
+ actual.executionId !== executionId ||
98
+ (identity && !sameIdentity(identity, actual))
99
+ )
100
+ throw new Error("capacity ownership does not match its reserved execution");
101
+ identity = Object.freeze({ ...actual });
102
+ },
103
+ finalize(cleanup: CapturedWorkerCleanup): CapacityReservationState {
104
+ if (state === "released") return state;
105
+ const provenNotStarted = cleanup.state === "not-started" && !identity && state === "reserved";
106
+ const settled =
107
+ cleanup.state === "settled" &&
108
+ identity !== undefined &&
109
+ sameIdentity(identity, cleanup.identity) &&
110
+ sameIdentity(identity, cleanup.receipt.identity) &&
111
+ cleanup.receipt.state === "settled" &&
112
+ cleanup.receipt.reapedAll === true;
113
+ if (!provenNotStarted && !settled) return (state = "retained");
114
+ held.delete(executionId);
115
+ available += cost;
116
+ return (state = "released");
117
+ },
118
+ });
119
+ held.set(executionId, reservation);
120
+ return { ok: true, reservation };
121
+ },
122
+ });
123
+ }
@@ -0,0 +1,29 @@
1
+ /** Versioned, execution-bound worker facts. These records never grant capabilities. */
2
+ export interface CapturedWorkerIdentity {
3
+ revision: 1;
4
+ executionId: string;
5
+ nonce: string;
6
+ root: string;
7
+ rootDevice: string;
8
+ rootInode: string;
9
+ bootId: string;
10
+ pidNamespace: string;
11
+ helperPid: number;
12
+ helperStartTicks: string;
13
+ helperSha256: string;
14
+ workerPid: number;
15
+ ownershipPath: string;
16
+ receiptPath: string;
17
+ }
18
+ export interface CapturedWorkerReceipt {
19
+ state: "settled";
20
+ identity: CapturedWorkerIdentity;
21
+ workerCode: number | null;
22
+ workerSignal: number;
23
+ reason: "worker-exit" | "owner-loss" | "cancelled" | "helper-signal" | "ownership-write-failed" | "start-failed";
24
+ reapedAll: true;
25
+ }
26
+ export type CapturedWorkerCleanup =
27
+ | { state: "settled"; identity: CapturedWorkerIdentity; receipt: CapturedWorkerReceipt }
28
+ | { state: "unknown"; identity?: CapturedWorkerIdentity; reason: string }
29
+ | { state: "not-started"; reason: string };
@@ -19,6 +19,8 @@
19
19
  */
20
20
 
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { BoundedReadCleanupError } from "./bounded-read.ts";
23
+ import { collectBoundedReadFailures } from "./bounded-read-failures.ts";
22
24
  import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
23
25
  import { join } from "node:path";
24
26
  import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
@@ -133,16 +135,26 @@ export function makeCatalog(entries: CatalogEntry[], registryRefusal?: string):
133
135
  };
134
136
  }
135
137
 
138
+ /** Trusted loader dependencies, not catalog/request configuration. */
139
+ export interface CatalogLoaders {
140
+ definitions?: typeof loadDefinitions;
141
+ registry?: typeof loadWorkspaceRegistry;
142
+ }
143
+
136
144
  /** Build the live catalog. `observedTools` comes from a provider payload; null when not yet seen. */
137
- export async function buildCatalog(input: {
138
- cwd: string;
139
- observedTools: string[] | null;
140
- /** Operator workspace registry (`PI_DADDY_WORKSPACE_REGISTRY`). Absent or unreadable yields no entries. */
141
- registryPath?: string;
142
- }): Promise<Catalog> {
143
- const [skills, definitions, workspaces] = await Promise.all([
144
- loadSkills(input.cwd),
145
- loadDefinitions(input.cwd),
145
+ export async function buildCatalog(
146
+ input: {
147
+ cwd: string;
148
+ snapshot?: { skills: CatalogEntry[]; definitions: Map<string, SkillDefinition> };
149
+ observedTools: string[] | null;
150
+ /** Operator workspace registry (`PI_DADDY_WORKSPACE_REGISTRY`). Absent or unreadable yields no entries. */
151
+ registryPath?: string;
152
+ },
153
+ loaders: CatalogLoaders = {},
154
+ ): Promise<Catalog> {
155
+ const outcomes = await Promise.allSettled([
156
+ Promise.resolve().then(() => input.snapshot?.skills ?? loadSkills(input.cwd)),
157
+ Promise.resolve().then(() => input.snapshot?.definitions ?? (loaders.definitions ?? loadDefinitions)(input.cwd)),
146
158
  // Fails SOFT, and only because nothing here is an authority. A malformed registry must not stop a
147
159
  // session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
148
160
  // refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
@@ -151,15 +163,32 @@ export async function buildCatalog(input: {
151
163
  // **What the refusal is no longer allowed to do is vanish.** This handler was `() => []`, so the display
152
164
  // list was lost AND the reason with it. The reason now rides on the catalog and `/grants` prints it.
153
165
  input.registryPath
154
- ? loadWorkspaceRegistry(input.registryPath).then(
155
- (r) => ({ entries: workspaceEntries(r, input.registryPath), refusal: undefined as string | undefined }),
156
- (error: unknown) => ({
157
- entries: [] as CatalogEntry[],
158
- refusal: error instanceof Error ? error.message : String(error),
159
- }),
160
- )
166
+ ? Promise.resolve()
167
+ .then(() => (loaders.registry ?? loadWorkspaceRegistry)(input.registryPath!))
168
+ .then(
169
+ (r) => ({ entries: workspaceEntries(r, input.registryPath), refusal: undefined as string | undefined }),
170
+ (error: unknown) => {
171
+ if (error instanceof BoundedReadCleanupError) throw error;
172
+ return {
173
+ entries: [] as CatalogEntry[],
174
+ refusal: error instanceof Error ? error.message : String(error),
175
+ };
176
+ },
177
+ )
161
178
  : Promise.resolve({ entries: [] as CatalogEntry[], refusal: undefined as string | undefined }),
162
179
  ]);
180
+ // Join EVERY started branch before ordinary discovery fallback can discard a physical close failure.
181
+ const cleanup = collectBoundedReadFailures(
182
+ outcomes.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])),
183
+ );
184
+ if (cleanup) throw cleanup;
185
+ const value = <T>(result: PromiseSettledResult<T>): T => {
186
+ if (result.status === "rejected") throw result.reason;
187
+ return result.value;
188
+ };
189
+ const skills = value(outcomes[0]);
190
+ const definitions = value(outcomes[1]);
191
+ const workspaces = value(outcomes[2]);
163
192
  return makeCatalog(
164
193
  [
165
194
  // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
@@ -152,6 +152,7 @@ export function composeStepTask(template: string, previous: string | undefined):
152
152
 
153
153
  /** One chain step as `runOneDelegation` takes it: the operator's spec with its task composed. */
154
154
  export interface ChainStep {
155
+ definitionId?: string;
155
156
  task: string;
156
157
  agent?: string;
157
158
  tools?: string[];
@@ -61,6 +61,10 @@ export function isDefinitionPackageVersion(value: unknown): value is string {
61
61
  }
62
62
 
63
63
  export interface SkillDefinition {
64
+ /** Immutable selected-resource identity, independent of the model-authored name. */
65
+ definitionId?: string;
66
+ selectedSkillHash?: string;
67
+ binding?: Readonly<{ package: "principal-pi-skills"; phase: string }>;
64
68
  /** From the path, never the frontmatter — see `parseSkillDefinition`. */
65
69
  name: string;
66
70
  description: string;
@@ -68,6 +72,8 @@ export interface SkillDefinition {
68
72
  allowedTools?: string;
69
73
  /** The spec's sanctioned extension point: a map of string keys to string values. */
70
74
  metadata?: Record<string, string>;
75
+ /** Raw authored JSON pairs; validated only when runtime selection needs them. */
76
+ runtimePreferences?: string;
71
77
  /** Everything after the frontmatter — the child's system prompt. */
72
78
  body: string;
73
79
  source: string;
@@ -160,6 +166,7 @@ export function parseSkillDefinition(source: string, text: string): SkillDefinit
160
166
  name: skillResourceName(source),
161
167
  description,
162
168
  allowedTools: fields.get("allowed-tools"),
169
+ runtimePreferences: fields.get("runtime-preferences"),
163
170
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
164
171
  body: text.slice(match[0].length).trim(),
165
172
  source,
@@ -232,11 +239,13 @@ export const DEFINITION_READ_TIMEOUT_MS = 2_000;
232
239
  export async function loadDefinitions(
233
240
  cwd: string,
234
241
  skipped?: (path: string, reason: string) => void,
242
+ // Trusted per-loader reader, never a skill's metadata or environment input.
243
+ readFile: typeof readBoundedFile = readBoundedFile,
235
244
  ): Promise<Map<string, SkillDefinition>> {
236
245
  const definitions = new Map<string, SkillDefinition>();
237
246
  const packageVersions = new Map<string, string | undefined>();
238
247
  for (const { path, metadata } of (await resolveSkillResources(cwd)).skills) {
239
- const read = await readBoundedFile(path, {
248
+ const read = await readFile(path, {
240
249
  maxBytes: DEFINITION_MAX_BYTES,
241
250
  timeoutMs: DEFINITION_READ_TIMEOUT_MS,
242
251
  });
@@ -247,7 +256,7 @@ export async function loadDefinitions(
247
256
  const parsed = parseSkillDefinition(path, read.text);
248
257
  if (parsed && metadata.origin === "package" && metadata.baseDir) {
249
258
  if (!packageVersions.has(metadata.baseDir)) {
250
- const manifest = await readBoundedFile(join(metadata.baseDir, "package.json"), {
259
+ const manifest = await readFile(join(metadata.baseDir, "package.json"), {
251
260
  maxBytes: DEFINITION_MAX_BYTES,
252
261
  timeoutMs: DEFINITION_READ_TIMEOUT_MS,
253
262
  });
@@ -1,35 +1,16 @@
1
- /**
2
- * Bounded synchronous fan-out — ADR-0015's option A′, and the cardinality bound ADR-0008 never had.
3
- *
4
- * **The gap this closes.** ADR-0008 bounds what each child may *hold* and says nothing about how many
5
- * children exist. `resolve()` is a set operation; nowhere in `src/` was there a count. Blocking `delegate`
6
- * bounded cardinality to one *by accident of being blocking*, so the invariant was never tested. Fan-out
7
- * removes that accident: with `maxDepth: 2` and five children per call, five delegators each spawning five
8
- * is thirty concurrent model sessions — **every ledger line individually correct and narrow, the aggregate
9
- * a machine DoS and an uncapped bill.** The critic's verdict on this was that silence is itself a finding.
10
- *
11
- * **Why a budget rather than a per-call limit.** A per-call cap of K with depth D still permits K^D
12
- * descendants, which is the same exponential wearing a smaller number. A *budget* is subtractive and
13
- * therefore total: a session holding budget B may create at most B descendants in its whole subtree,
14
- * because it spends from B to spawn and hands each child a share of what is left. That composes across
15
- * process boundaries with **no shared state** — the same property that makes depth work — so it needs no
16
- * registry, no lock and no counter file.
17
- *
18
- * It is deliberately NOT a concurrency limit. How many run at once is a resource question for the executor;
19
- * how many may exist at all is a governance question, and this is the governance answer.
20
- */
21
-
1
+ /** Active descendant allocation arithmetic. Enforcement requires the shared owner allocator in capacity.ts. */
22
2
  import { parseBound } from "./propagation.ts";
3
+ import { GovernanceRefusal, refusal } from "./refusals.ts";
23
4
 
24
- /** Total descendants a session may create across its entire subtree, when nothing is configured. */
5
+ /** Active descendant allowance, excluding the owner session, when nothing is configured. */
25
6
  export const DEFAULT_FANOUT_BUDGET = 8;
26
7
 
27
8
  /**
28
9
  * Hard ceiling on children in a single call, independent of budget.
29
10
  *
30
11
  * A budget alone would let one call spend all of it at once, and a hundred simultaneous `pi` processes is
31
- * a different failure from a hundred spread over a session. This is the blast-radius bound; the budget is
32
- * the total bound. Both are needed because they answer different questions.
12
+ * a different failure from a hundred spread over a session. The owner allocator also conserves active
13
+ * capacity across overlapping calls.
33
14
  */
34
15
  export const MAX_CHILDREN_PER_CALL = 8;
35
16
 
@@ -43,15 +24,16 @@ export const MAX_CHILDREN_PER_CALL = 8;
43
24
  */
44
25
  export const MAX_CHAIN_STEPS = MAX_CHILDREN_PER_CALL;
45
26
 
46
- /** Read the budget from the environment, failing to the default on absent *or* malformed input. */
27
+ /** Absent uses the existing default; explicit zero stays exhausted and malformed input refuses. */
47
28
  export function budgetFromEnv(raw: string | undefined): number {
29
+ if (raw === undefined) return DEFAULT_FANOUT_BUDGET;
48
30
  const parsed = parseBound(raw);
49
- // Malformed and zero both fall back rather than disabling fan-out silently — G7's rule. A budget that a
50
- // typo can switch off is the A-S4 defect wearing different clothes. To genuinely forbid delegation an
51
- // operator sets `maxDepth: 0`, which says what it means.
52
- return parsed === undefined || parsed === null || parsed === 0 ? DEFAULT_FANOUT_BUDGET : parsed;
31
+ if (parsed === null || parsed === undefined)
32
+ throw new GovernanceRefusal(
33
+ refusal("FANOUT_EXCEEDED", "PI_DADDY_FANOUT must be a non-negative safe decimal integer"),
34
+ );
35
+ return parsed;
53
36
  }
54
-
55
37
  export interface BudgetSplit {
56
38
  ok: boolean;
57
39
  reason?: string;
@@ -60,7 +42,7 @@ export interface BudgetSplit {
60
42
  }
61
43
 
62
44
  /**
63
- * Spend `count` from a budget and divide the remainder among the children.
45
+ * Propose disjoint child allowances from currently available capacity; this does not reserve anything.
64
46
  *
65
47
  * The parent pays one unit per child it creates *before* sharing what is left, so a subtree can never
66
48
  * exceed the budget it started with: spawning is itself an expenditure, not a free act that only its
@@ -68,7 +50,10 @@ export interface BudgetSplit {
68
50
  * inventing it — the safe direction, and the reason a deep tree converges to zero instead of oscillating.
69
51
  */
70
52
  export function splitBudget(budget: number, count: number): BudgetSplit {
71
- if (count <= 0) return { ok: false, reason: "a fan-out needs at least one child", perChild: 0 };
53
+ if (!Number.isSafeInteger(budget) || budget < 0)
54
+ return { ok: false, reason: "capacity must be a non-negative safe integer", perChild: 0 };
55
+ if (!Number.isSafeInteger(count) || count <= 0)
56
+ return { ok: false, reason: "a fan-out needs at least one child", perChild: 0 };
72
57
  if (count > MAX_CHILDREN_PER_CALL) {
73
58
  return {
74
59
  ok: false,
@@ -140,8 +140,6 @@ export interface ProjectSettings {
140
140
  ledger: string;
141
141
  /** Gated by default (ADR-0012): closed under subsumption, so gating tool:write also gates tool:bash. */
142
142
  gatedByDefault: Capability[];
143
- /** Cumulative provider-reported USD cost at which the episode asks whether to continue. */
144
- episodeCostCeiling: number;
145
143
  }
146
144
 
147
145
  export function buildProjectSettings(input: GrantEnvInput): ProjectSettings {
@@ -168,7 +166,6 @@ export function buildProjectSettings(input: GrantEnvInput): ProjectSettings {
168
166
  cautions: [...input.cautions],
169
167
  ledger: PROJECT_FILES.ledger,
170
168
  gatedByDefault: ["tool:bash"],
171
- episodeCostCeiling: 5,
172
169
  };
173
170
  }
174
171
 
@@ -16,6 +16,7 @@ export function supportedModelEfforts(model: {
16
16
 
17
17
  export interface ModelCatalogue {
18
18
  find(provider: string, modelId: string): unknown;
19
+ getProviderAuthStatus?(provider: string): { configured: boolean };
19
20
  }
20
21
 
21
22
  /** Resolve an explicit provider/id against pi's own session catalogue without probing credentials or network. */
@@ -42,3 +43,31 @@ export function preflightModel(
42
43
  { model: model || "(empty)" },
43
44
  );
44
45
  }
46
+
47
+ /** Exact candidate only; never enumerate providers, resolve credentials, probe, or clamp effort. */
48
+ export function runtimePairUnavailable(
49
+ choice: { model?: string; thinking?: string },
50
+ catalogue: ModelCatalogue,
51
+ allowUnresolved = false,
52
+ ): string | undefined {
53
+ if (!choice.model) return undefined; // No explicit/current choice: preserve Pi's ordinary configured default.
54
+ const slash = choice.model.indexOf("/");
55
+ const provider = choice.model.slice(0, slash),
56
+ id = choice.model.slice(slash + 1);
57
+ if (slash <= 0 || !id) return "runtime model needs provider/model";
58
+ const model = catalogue.find(provider, id);
59
+ if (!model) return allowUnresolved ? undefined : `model ${choice.model} is not in pi's session catalogue`;
60
+ if (
61
+ choice.thinking !== undefined &&
62
+ typeof model === "object" &&
63
+ model !== null &&
64
+ "reasoning" in model &&
65
+ !supportedModelEfforts(model as { reasoning: boolean }).includes(choice.thinking as never)
66
+ ) {
67
+ return `model ${choice.model} does not support thinking ${choice.thinking}`;
68
+ }
69
+ // configured:false cannot distinguish keyless, missing and unknown public provider auth semantics.
70
+ // Reading the passive status is permitted evidence; never turn it into an authentication attempt.
71
+ catalogue.getProviderAuthStatus?.(provider);
72
+ return undefined;
73
+ }