pi-daddy 0.18.1 → 0.20.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 (218) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +136 -23
  3. package/contracts/ledger/v2/README.md +59 -0
  4. package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
  5. package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
  6. package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
  7. package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
  8. package/contracts/ledger/v2/ledger-event.schema.json +633 -0
  9. package/contracts/ledger/v3/README.md +36 -0
  10. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  11. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  12. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  13. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  14. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  15. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  16. package/dist/approval-prompt.d.ts +2 -1
  17. package/dist/approval-prompt.d.ts.map +1 -1
  18. package/dist/approval-prompt.js +9 -0
  19. package/dist/approval-prompt.js.map +1 -1
  20. package/dist/approval.d.ts +4 -2
  21. package/dist/approval.d.ts.map +1 -1
  22. package/dist/approval.js +4 -0
  23. package/dist/approval.js.map +1 -1
  24. package/dist/capabilities.d.ts +90 -0
  25. package/dist/capabilities.d.ts.map +1 -1
  26. package/dist/capabilities.js +114 -3
  27. package/dist/capabilities.js.map +1 -1
  28. package/dist/catalog.d.ts +15 -1
  29. package/dist/catalog.d.ts.map +1 -1
  30. package/dist/catalog.js +54 -3
  31. package/dist/catalog.js.map +1 -1
  32. package/dist/check-runner.d.ts.map +1 -1
  33. package/dist/check-runner.js +19 -7
  34. package/dist/check-runner.js.map +1 -1
  35. package/dist/cli.d.ts.map +1 -1
  36. package/dist/cli.js +21 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/correlation.d.ts.map +1 -1
  39. package/dist/correlation.js +10 -0
  40. package/dist/correlation.js.map +1 -1
  41. package/dist/dashboard-cli.d.ts +18 -0
  42. package/dist/dashboard-cli.d.ts.map +1 -0
  43. package/dist/dashboard-cli.js +154 -0
  44. package/dist/dashboard-cli.js.map +1 -0
  45. package/dist/dashboard-handshake.d.ts +37 -0
  46. package/dist/dashboard-handshake.d.ts.map +1 -0
  47. package/dist/dashboard-handshake.js +127 -0
  48. package/dist/dashboard-handshake.js.map +1 -0
  49. package/dist/dashboard-herdr.d.ts +54 -0
  50. package/dist/dashboard-herdr.d.ts.map +1 -0
  51. package/dist/dashboard-herdr.js +286 -0
  52. package/dist/dashboard-herdr.js.map +1 -0
  53. package/dist/dashboard-projection.d.ts +73 -0
  54. package/dist/dashboard-projection.d.ts.map +1 -0
  55. package/dist/dashboard-projection.js +294 -0
  56. package/dist/dashboard-projection.js.map +1 -0
  57. package/dist/dashboard-render.d.ts +10 -0
  58. package/dist/dashboard-render.d.ts.map +1 -0
  59. package/dist/dashboard-render.js +208 -0
  60. package/dist/dashboard-render.js.map +1 -0
  61. package/dist/definitions.d.ts.map +1 -1
  62. package/dist/definitions.js +7 -1
  63. package/dist/definitions.js.map +1 -1
  64. package/dist/delegate-types.d.ts +6 -2
  65. package/dist/delegate-types.d.ts.map +1 -1
  66. package/dist/delegate-types.js.map +1 -1
  67. package/dist/delegate.d.ts.map +1 -1
  68. package/dist/delegate.js +34 -4
  69. package/dist/delegate.js.map +1 -1
  70. package/dist/delegation-approval.d.ts.map +1 -1
  71. package/dist/delegation-approval.js +37 -12
  72. package/dist/delegation-approval.js.map +1 -1
  73. package/dist/execution-id.d.ts +6 -0
  74. package/dist/execution-id.d.ts.map +1 -0
  75. package/dist/execution-id.js +13 -0
  76. package/dist/execution-id.js.map +1 -0
  77. package/dist/executor.d.ts +2 -1
  78. package/dist/executor.d.ts.map +1 -1
  79. package/dist/executor.js +1 -0
  80. package/dist/executor.js.map +1 -1
  81. package/dist/grant-env.d.ts +2 -0
  82. package/dist/grant-env.d.ts.map +1 -1
  83. package/dist/grant-env.js +26 -3
  84. package/dist/grant-env.js.map +1 -1
  85. package/dist/index.d.ts +4 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +4 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/init.d.ts +13 -1
  90. package/dist/init.d.ts.map +1 -1
  91. package/dist/init.js +35 -2
  92. package/dist/init.js.map +1 -1
  93. package/dist/lease-helper.d.ts +58 -0
  94. package/dist/lease-helper.d.ts.map +1 -0
  95. package/dist/lease-helper.js +94 -0
  96. package/dist/lease-helper.js.map +1 -0
  97. package/dist/lease-record.d.ts +15 -3
  98. package/dist/lease-record.d.ts.map +1 -1
  99. package/dist/lease-record.js.map +1 -1
  100. package/dist/ledger-events.d.ts +59 -15
  101. package/dist/ledger-events.d.ts.map +1 -1
  102. package/dist/ledger-events.js +67 -5
  103. package/dist/ledger-events.js.map +1 -1
  104. package/dist/ledger-identifiers.d.ts +7 -0
  105. package/dist/ledger-identifiers.d.ts.map +1 -0
  106. package/dist/ledger-identifiers.js +20 -0
  107. package/dist/ledger-identifiers.js.map +1 -0
  108. package/dist/ledger-report.d.ts +5 -2
  109. package/dist/ledger-report.d.ts.map +1 -1
  110. package/dist/ledger-report.js +38 -14
  111. package/dist/ledger-report.js.map +1 -1
  112. package/dist/ledger-v3-validation.d.ts +11 -0
  113. package/dist/ledger-v3-validation.d.ts.map +1 -0
  114. package/dist/ledger-v3-validation.js +265 -0
  115. package/dist/ledger-v3-validation.js.map +1 -0
  116. package/dist/ledger.d.ts +27 -9
  117. package/dist/ledger.d.ts.map +1 -1
  118. package/dist/ledger.js +38 -6
  119. package/dist/ledger.js.map +1 -1
  120. package/dist/propagation.d.ts +19 -1
  121. package/dist/propagation.d.ts.map +1 -1
  122. package/dist/propagation.js +23 -1
  123. package/dist/propagation.js.map +1 -1
  124. package/dist/refusals.d.ts +1 -1
  125. package/dist/refusals.d.ts.map +1 -1
  126. package/dist/refusals.js +1 -0
  127. package/dist/refusals.js.map +1 -1
  128. package/dist/resolve.d.ts +10 -0
  129. package/dist/resolve.d.ts.map +1 -1
  130. package/dist/resolve.js +27 -3
  131. package/dist/resolve.js.map +1 -1
  132. package/dist/routing-authority.d.ts +71 -0
  133. package/dist/routing-authority.d.ts.map +1 -0
  134. package/dist/routing-authority.js +100 -0
  135. package/dist/routing-authority.js.map +1 -0
  136. package/dist/run-child.d.ts +3 -1
  137. package/dist/run-child.d.ts.map +1 -1
  138. package/dist/run-child.js +30 -2
  139. package/dist/run-child.js.map +1 -1
  140. package/dist/run-herdr.d.ts +2 -0
  141. package/dist/run-herdr.d.ts.map +1 -1
  142. package/dist/run-herdr.js +8 -0
  143. package/dist/run-herdr.js.map +1 -1
  144. package/dist/skill-packages.d.ts +11 -5
  145. package/dist/skill-packages.d.ts.map +1 -1
  146. package/dist/skill-packages.js +20 -11
  147. package/dist/skill-packages.js.map +1 -1
  148. package/dist/workflow-fact-id.d.ts +4 -0
  149. package/dist/workflow-fact-id.d.ts.map +1 -0
  150. package/dist/workflow-fact-id.js +14 -0
  151. package/dist/workflow-fact-id.js.map +1 -0
  152. package/dist/workflow-facts.d.ts +34 -0
  153. package/dist/workflow-facts.d.ts.map +1 -0
  154. package/dist/workflow-facts.js +43 -0
  155. package/dist/workflow-facts.js.map +1 -0
  156. package/dist/workspace-lease.d.ts +15 -3
  157. package/dist/workspace-lease.d.ts.map +1 -1
  158. package/dist/workspace-lease.js +81 -24
  159. package/dist/workspace-lease.js.map +1 -1
  160. package/dist/workspace.d.ts +25 -0
  161. package/dist/workspace.d.ts.map +1 -1
  162. package/dist/workspace.js +142 -5
  163. package/dist/workspace.js.map +1 -1
  164. package/extensions/chain-ledger.ts +4 -0
  165. package/extensions/chain-plan.ts +98 -0
  166. package/extensions/delegate-chain.ts +63 -122
  167. package/extensions/delegation-ledger.ts +60 -0
  168. package/extensions/delegation.ts +20 -14
  169. package/extensions/execute-child.ts +85 -21
  170. package/extensions/execution-occurrence.ts +20 -0
  171. package/extensions/grants-command.ts +33 -4
  172. package/extensions/grants.ts +50 -1
  173. package/extensions/init-command.ts +33 -2
  174. package/extensions/run-delegation.ts +36 -56
  175. package/extensions/session-report.ts +13 -20
  176. package/extensions/session.ts +16 -3
  177. package/extensions/workspace-runtime.ts +44 -4
  178. package/herdr-plugin/herdr-plugin.toml +18 -0
  179. package/package.json +22 -5
  180. package/src/approval-prompt.ts +2 -1
  181. package/src/approval.ts +4 -2
  182. package/src/capabilities.ts +120 -4
  183. package/src/catalog.ts +62 -4
  184. package/src/check-runner.ts +24 -8
  185. package/src/cli.ts +24 -1
  186. package/src/correlation.ts +11 -0
  187. package/src/dashboard-cli.ts +171 -0
  188. package/src/dashboard-handshake.ts +185 -0
  189. package/src/dashboard-herdr.ts +355 -0
  190. package/src/dashboard-projection.ts +390 -0
  191. package/src/dashboard-render.ts +254 -0
  192. package/src/definitions.ts +7 -1
  193. package/src/delegate-types.ts +6 -2
  194. package/src/delegate.ts +43 -4
  195. package/src/delegation-approval.ts +37 -12
  196. package/src/execution-id.ts +18 -0
  197. package/src/executor.ts +2 -1
  198. package/src/grant-env.ts +39 -7
  199. package/src/index.ts +26 -0
  200. package/src/init.ts +40 -2
  201. package/src/lease-helper.ts +97 -0
  202. package/src/lease-record.ts +15 -3
  203. package/src/ledger-events.ts +116 -28
  204. package/src/ledger-identifiers.ts +21 -0
  205. package/src/ledger-report.ts +40 -19
  206. package/src/ledger-v3-validation.ts +266 -0
  207. package/src/ledger.ts +75 -12
  208. package/src/propagation.ts +24 -1
  209. package/src/refusals.ts +1 -0
  210. package/src/resolve.ts +29 -3
  211. package/src/routing-authority.ts +121 -0
  212. package/src/run-child.ts +35 -3
  213. package/src/run-herdr.ts +9 -0
  214. package/src/skill-packages.ts +20 -13
  215. package/src/workflow-fact-id.ts +16 -0
  216. package/src/workflow-facts.ts +65 -0
  217. package/src/workspace-lease.ts +84 -24
  218. package/src/workspace.ts +179 -6
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ADR-0035's three routing guards, and why each one is shaped the way it is.
3
+ *
4
+ * Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
5
+ * same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
6
+ * line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
7
+ * is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
8
+ * alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
9
+ * guards.
10
+ *
11
+ * Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
12
+ * assembling records. `denied` is present exactly when the attempt should count as an escalation.
13
+ */
14
+
15
+ import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
16
+ import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
17
+ import { WILDCARD } from "./pi-tools.ts";
18
+ import type { RefusalCode } from "./refusals.ts";
19
+
20
+ export interface RoutingRefusal {
21
+ code: RefusalCode;
22
+ reason: string;
23
+ /**
24
+ * The capability to record in `denied`, when this refusal IS an escalation attempt.
25
+ *
26
+ * Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
27
+ * with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
28
+ * unparseable. Present for an unauthorised route, which is exactly a bid for authority.
29
+ */
30
+ denied?: Capability[];
31
+ }
32
+
33
+ /**
34
+ * May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
35
+ *
36
+ * Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
37
+ * governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
38
+ * every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
39
+ * in `docs/probes/g36-workspace-attenuation`).
40
+ *
41
+ * **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
42
+ * it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
43
+ * `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
44
+ * `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
45
+ * unreadable. 0.18.1 is what happens when a comma goes unremarked.
46
+ *
47
+ * **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
48
+ * closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
49
+ * every audit query. An empty id is a malformed id, not an absent one.
50
+ */
51
+ export function checkRoutingAuthority(
52
+ boundWorkspaceId: string | undefined,
53
+ ownGrant: readonly Capability[],
54
+ ): RoutingRefusal | null {
55
+ if (boundWorkspaceId === undefined) return null;
56
+ const authorising = workspaceCapability(boundWorkspaceId);
57
+ if (!isSafeWorkspaceId(boundWorkspaceId)) {
58
+ return {
59
+ code: "GRANT_ID_MALFORMED",
60
+ reason:
61
+ `workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
62
+ `become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
63
+ `and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
64
+ };
65
+ }
66
+ if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
67
+ // Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
68
+ // attempt, so listing it here points the model at a destination it can never reach.
69
+ const held = ownGrant
70
+ .filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
71
+ .sort();
72
+ return {
73
+ code: "WORKSPACE_NOT_AUTHORIZED",
74
+ // Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
75
+ // route somewhere this session was not granted IS an attempt to exceed the grant.
76
+ denied: [authorising],
77
+ reason:
78
+ `cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
79
+ (held.length > 0
80
+ ? `It may route to: ${held.join(", ")}.`
81
+ : `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
87
+ *
88
+ * `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
89
+ * it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
90
+ * as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
91
+ * to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
92
+ * code for "this grant would not actually narrow", which is precisely what routing authority over every
93
+ * registered root does.
94
+ *
95
+ * **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
96
+ * `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
97
+ * `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
98
+ * nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
99
+ * child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
100
+ * `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
101
+ * appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
102
+ *
103
+ * **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
104
+ * wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
105
+ * `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
106
+ * `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
107
+ */
108
+ export function checkWorkspaceWildcardRequest(
109
+ requested: readonly Capability[],
110
+ ownGrant: readonly Capability[],
111
+ ): RoutingRefusal | null {
112
+ if (!requested.includes(WORKSPACE_WILDCARD)) return null;
113
+ if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
114
+ return {
115
+ code: "NARROWING_VIOLATED",
116
+ reason:
117
+ `cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
118
+ `holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
119
+ `Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
120
+ };
121
+ }
package/src/run-child.ts CHANGED
@@ -13,6 +13,15 @@ import { spawn } from "node:child_process";
13
13
  import { StringDecoder } from "node:string_decoder";
14
14
  import { parseBound } from "./propagation.ts";
15
15
 
16
+ interface RunChildTestControl { hardDeadlineAtAfterSpawn(): number }
17
+ const TEST_CONTROL = Symbol.for("pi-daddy.internal.run-child-test-control");
18
+
19
+ function currentRunChildTestControl(): RunChildTestControl | undefined {
20
+ if (process.env.NODE_TEST_CONTEXT !== "child-v8") return undefined;
21
+ const storage = (globalThis as Record<symbol, { getStore(): RunChildTestControl | undefined }>)[TEST_CONTROL];
22
+ return storage?.getStore();
23
+ }
24
+
16
25
  /**
17
26
  * The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
18
27
  *
@@ -64,9 +73,11 @@ export interface ChildRunRequest {
64
73
  signal?: AbortSignal;
65
74
  /** Hard cap on captured output. Beyond it the child is killed and the result flagged. */
66
75
  maxOutputBytes?: number;
67
- /** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
76
+ /** Soft wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
68
77
  timeoutMs?: number;
69
78
  killGraceMs?: number;
79
+ /** Absolute epoch-millisecond hard cap: SIGKILL fires here even when the SIGTERM grace has not elapsed. */
80
+ hardDeadlineAt?: number;
70
81
  /**
71
82
  * Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
72
83
  *
@@ -106,9 +117,11 @@ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
106
117
  export const DEFAULT_KILL_GRACE_MS = 5000;
107
118
 
108
119
  export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
120
+ const testControl = currentRunChildTestControl();
109
121
  const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
110
122
  const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
111
123
  const killGraceMs = request.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
124
+ const hardDeadlineAt = request.hardDeadlineAt;
112
125
 
113
126
  // A-R3: checked BEFORE spawning. `AbortSignal` does not replay, so a listener attached after an
114
127
  // `await` cannot observe an abort that already happened — and the child would then run to completion
@@ -160,6 +173,16 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
160
173
  timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
161
174
  };
162
175
 
176
+ // A timer phase may run before libuv delivers an exit that the OS already observed, especially after the
177
+ // controller event loop was blocked across the deadline. Give pending child-process events one turn to
178
+ // publish exitCode/signalCode before deciding that a governed PID is still live.
179
+ const controlIfRunning = (control: () => void) => {
180
+ setImmediate(() => {
181
+ if (done || child.exitCode !== null || child.signalCode !== null) return;
182
+ control();
183
+ });
184
+ };
185
+
163
186
  /** How much of `text` the progress display has already been shown. */
164
187
  let emittedUpTo = 0;
165
188
 
@@ -225,11 +248,20 @@ export function runChild(request: ChildRunRequest): Promise<ChildRunResult> {
225
248
  child.stderr?.on("data", capture);
226
249
 
227
250
  timers.push(
228
- setTimeout(() => {
251
+ setTimeout(() => controlIfRunning(() => {
229
252
  timedOut = true;
230
253
  stop();
231
- }, timeoutMs),
254
+ }), timeoutMs),
232
255
  );
256
+ const activeHardDeadlineAt = testControl?.hardDeadlineAtAfterSpawn() ?? hardDeadlineAt;
257
+ if (activeHardDeadlineAt !== undefined) {
258
+ // Independent of the soft timer and measured from the recorded epoch, not from `spawn`: neither a
259
+ // delayed soft callback nor spawn/setup time may start a fresh grace period beyond the lifecycle bound.
260
+ timers.push(setTimeout(() => controlIfRunning(() => {
261
+ timedOut = true;
262
+ child.kill("SIGKILL");
263
+ }), Math.max(0, activeHardDeadlineAt - Date.now())));
264
+ }
233
265
 
234
266
  const onAbort = () => {
235
267
  aborted = true;
package/src/run-herdr.ts CHANGED
@@ -98,6 +98,8 @@ export interface HerdrRunRequest {
98
98
  * name a human can switch to **while the child is alive**, which is the whole point of the pane surviving.
99
99
  */
100
100
  onPane?: (paneId: string, agentName: string) => void;
101
+ /** Called only after agent start and prompt both succeed; pane creation alone is still `starting`. */
102
+ onRunning?: (paneId: string, agentName: string) => void;
101
103
  /** Security hook for attaching a writer lease to the tab. Unlike display callbacks, errors fail the run. */
102
104
  onTab?: (tabId: string) => void;
103
105
  /** Close even a settled pane before releasing a writer lease; no post-lease prompt may remain live. */
@@ -300,6 +302,13 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
300
302
 
301
303
  const prompted = parseReply(await exec(["agent", "prompt", agentName, request.prompt]));
302
304
  if (prompted.error) return { ...empty, spawnError: `herdr agent prompt failed: ${prompted.error}` };
305
+ if (request.onRunning) {
306
+ try {
307
+ request.onRunning(paneId, agentName);
308
+ } catch {
309
+ /* display/observation only */
310
+ }
311
+ }
303
312
 
304
313
  const settled = await waitForSettled(exec, { ...request, name: agentName }, before, deadline, maxOutputBytes);
305
314
  if (settled.aborted || settled.timedOut) {
@@ -30,7 +30,8 @@ import { homedir } from "node:os";
30
30
  import { join, resolve, sep } from "node:path";
31
31
  import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
32
32
  import { WILDCARD } from "./pi-tools.ts";
33
- import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
33
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
34
+ import { isSafeCapability } from "./capabilities.ts";
34
35
 
35
36
  export interface DiscoveredSkill {
36
37
  definition: SkillDefinition;
@@ -107,21 +108,27 @@ export function isSafeName(name: string): boolean {
107
108
  * grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
108
109
  * exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
109
110
  *
110
- * The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`, and
111
- * `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused separately and
112
- * loudly, because "you tried to grant yourself everything" is a different fact from "that is not a name".
111
+ * The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
112
+ * `workspace:<id>`, and `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
113
+ * separately and loudly, because "you tried to grant yourself everything" is a different fact from "that is
114
+ * not a name".
115
+ *
116
+ * `workspace:` was absent until 2026-08-21, so the boundary that GENERATES grants could not emit the one
117
+ * capability ADR-0035's breaking change made mandatory: a package needing to route was reported as declaring
118
+ * something that "is not a name". A namespace has to be added here as well as to the enforcing path, and
119
+ * that is the whole lesson of the review this came out of.
113
120
  */
114
- export function isSafeCapability(id: Capability): boolean {
115
- const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
116
- return (
117
- new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
118
- new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
119
- );
120
- }
121
+ // Imported as well as re-exported: a bare `export … from` does not bind the name in this module's scope, and
122
+ // `refusalFor` below calls it. Re-exported because this has been the import site since 0.13.0.
123
+ export { isSafeCapability };
121
124
 
122
- /** The two ids that confer root authority. A package declaring one is claiming it, not describing a need. */
125
+ /**
126
+ * The ids that confer authority over a whole namespace. A package declaring one is claiming it, not
127
+ * describing a need — so it is reported as a *wildcard claim* rather than as a malformed name, which is a
128
+ * different sentence to show an operator. `workspace:*` belongs here for the same reason `agent:*` does.
129
+ */
123
130
  function wildcardsIn(capabilities: Capability[]): Capability[] {
124
- return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
131
+ return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD);
125
132
  }
126
133
 
127
134
  /**
@@ -0,0 +1,16 @@
1
+ const WORKFLOW_FACT_ID_RE = /^fact:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2
+ const WORKFLOW_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
3
+
4
+ export function isWorkflowFactId(value: unknown): value is string {
5
+ return typeof value === "string" && WORKFLOW_FACT_ID_RE.test(value);
6
+ }
7
+
8
+ export function isWorkflowIdentifier(value: unknown): value is string {
9
+ return typeof value === "string" && WORKFLOW_IDENTIFIER_RE.test(value);
10
+ }
11
+
12
+ export function workflowFactStateMatches(provenance: unknown, state: unknown): boolean {
13
+ return (provenance === "planned" && state === "pending") ||
14
+ (provenance === "observed" && state === "observed") ||
15
+ (provenance === "controller_validated" && ["started", "completed", "blocked"].includes(String(state)));
16
+ }
@@ -0,0 +1,65 @@
1
+ import { LEDGER_VERSION } from "./ledger.ts";
2
+ import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
3
+ import { isWorkflowFactId, isWorkflowIdentifier, workflowFactStateMatches } from "./workflow-fact-id.ts";
4
+ import { assertLedgerV3Wire } from "./ledger-v3-validation.ts";
5
+
6
+ export const WORKFLOW_FACT_PROVENANCE = ["planned", "observed", "controller_validated"] as const;
7
+ export type WorkflowFactProvenance = typeof WORKFLOW_FACT_PROVENANCE[number];
8
+ export const WORKFLOW_FACT_KINDS = ["workflow_phase", "inline_skill", "transition"] as const;
9
+ export type WorkflowFactKind = typeof WORKFLOW_FACT_KINDS[number];
10
+ export const WORKFLOW_FACT_STATES = ["pending", "observed", "started", "completed", "blocked"] as const;
11
+ export type WorkflowFactState = typeof WORKFLOW_FACT_STATES[number];
12
+
13
+ export interface WorkflowFactEvent {
14
+ ledgerVersion: typeof LEDGER_VERSION;
15
+ event: "workflow_fact";
16
+ ts: string;
17
+ factId: string;
18
+ /** Package/controller identifier, not free-form display text. */
19
+ source: string;
20
+ provenance: WorkflowFactProvenance;
21
+ kind: WorkflowFactKind;
22
+ /** Phase, skill, or transition identifier. Never task text. */
23
+ subject: string;
24
+ state: WorkflowFactState;
25
+ /** run_id is required by the builder; other values remain non-authoritative labels. */
26
+ correlation: CorrelationMetadata;
27
+ }
28
+
29
+ export function buildWorkflowFactEvent(args: {
30
+ factId: string;
31
+ source: string;
32
+ provenance: WorkflowFactProvenance;
33
+ kind: WorkflowFactKind;
34
+ subject: string;
35
+ state: WorkflowFactState;
36
+ correlation: CorrelationMetadata;
37
+ now: Date;
38
+ }): WorkflowFactEvent {
39
+ if (!isWorkflowFactId(args.factId)) throw new TypeError("factId must be a pi-daddy workflow fact id");
40
+ if (!isWorkflowIdentifier(args.source)) throw new TypeError("workflow fact source must be an identifier");
41
+ if (!WORKFLOW_FACT_PROVENANCE.includes(args.provenance)) throw new TypeError("workflow fact provenance is not recognised");
42
+ if (!WORKFLOW_FACT_KINDS.includes(args.kind)) throw new TypeError("workflow fact kind is not recognised");
43
+ if (!isWorkflowIdentifier(args.subject)) throw new TypeError("workflow fact subject must be an identifier, not task text");
44
+ if (!WORKFLOW_FACT_STATES.includes(args.state)) throw new TypeError("workflow fact state is not recognised");
45
+ const correlation = normaliseCorrelation(args.correlation);
46
+ if (!correlation?.run_id) throw new TypeError("workflow facts require correlation.run_id");
47
+ if (!workflowFactStateMatches(args.provenance, args.state)) {
48
+ const expected = args.provenance === "planned" ? "pending" : args.provenance === "observed"
49
+ ? "observed" : "started, completed, or blocked";
50
+ throw new TypeError(`${args.provenance.replaceAll("_", "-")} workflow facts must use ${expected}`);
51
+ }
52
+ const event: WorkflowFactEvent = {
53
+ ledgerVersion: LEDGER_VERSION,
54
+ event: "workflow_fact",
55
+ ts: args.now.toISOString(),
56
+ factId: args.factId,
57
+ source: args.source,
58
+ provenance: args.provenance,
59
+ kind: args.kind,
60
+ subject: args.subject,
61
+ state: args.state,
62
+ correlation,
63
+ };
64
+ return assertLedgerV3Wire(event);
65
+ }
@@ -14,6 +14,7 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { GovernanceRefusal, refusal } from "./refusals.ts";
16
16
  import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
17
+ import { assertCloseBounds, HELPER_SOURCE, LEASE_READY, unrefStream } from "./lease-helper.ts";
17
18
 
18
19
  export const ENV_WORKSPACE_LEASE_DIR = "PI_GRANTS_WORKSPACE_LEASE_DIR";
19
20
 
@@ -22,28 +23,17 @@ export function defaultWorkspaceLeaseDir(env: NodeJS.ProcessEnv = process.env):
22
23
  return env[ENV_WORKSPACE_LEASE_DIR] ?? join(agentDir, "pi-daddy", "workspace-leases");
23
24
  }
24
25
 
25
- const LEASE_READY = "PI_DADDY_LEASE_READY";
26
- // The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
27
- // the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
28
- // descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
29
- // guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
30
- // WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
31
- // and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
32
- // with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
33
- const HELPER_SOURCE = `
34
- import { execFile } from "node:child_process";
35
- import { writeFileSync } from "node:fs";
36
- let clean=false, target=null, buffered="";
37
- process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
38
- process.stdin.setEncoding("utf8");
39
- process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
40
- process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=()=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp();setTimeout(close,1000)});close();});
41
- process.stdin.resume();`;
42
26
 
43
27
  /**
44
- * The ledger outcome for a release. ONE definition, exported, because two call sites had their own copies
45
- * and the check runner's copy asserted `released` unconditionally — reproducing in the evidence path the
46
- * exact defect this union was added to expose (R-100/R-103).
28
+ * The ledger outcome for a release. Exported because call sites had their own copies and the check runner's
29
+ * asserted `released` unconditionally — reproducing in the evidence path the exact defect this union was
30
+ * added to expose (R-100/R-103).
31
+ *
32
+ * **It is NOT the only definition, and saying so here was wrong.** `extensions/workspace-runtime.ts` keeps a
33
+ * private copy with no `not-held` arm, and it is the one the whole delegation path uses — so releasing a
34
+ * *read* lease on a delegated child is still recorded `released`, a handover the kernel never performed.
35
+ * Measured; open as R-141; the sentence claiming a single definition survived two review passes because
36
+ * `test/workspace.test.ts` pins the `not-held` arm on THIS function, which no delegation path calls.
47
37
  */
48
38
  export function leaseReleaseLedgerOutcome(
49
39
  outcome: LeaseReleaseOutcome | "retained",
@@ -78,9 +68,17 @@ export async function acquireWorkspaceLease(input: {
78
68
  signal?: AbortSignal;
79
69
  flockCommand?: string;
80
70
  acquisitionTimeoutMs?: number;
71
+ /**
72
+ * Bound on how long ONE `herdr tab close` attempt may take before it counts as failed (R-146). A hung
73
+ * herdr is not a failed herdr: without this the attempt never returns and the retry bound below is
74
+ * unreachable.
75
+ */
76
+ herdrCloseTimeoutMs?: number;
81
77
  /** Bound on the helper's `herdr tab close` retries before it releases the lock anyway (R-102). */
82
78
  herdrCloseAttempts?: number;
83
79
  }): Promise<WorkspaceLease> {
80
+ // Before the read-lease return, so both paths are validated (R-152).
81
+ assertCloseBounds(input);
84
82
  if (input.access === "read") {
85
83
  return {
86
84
  workspace: input.workspace,
@@ -89,7 +87,9 @@ export async function acquireWorkspaceLease(input: {
89
87
  recovered: false,
90
88
  attachProcess: () => {},
91
89
  attachHerdrTab: () => {},
92
- markRetained: async () => {},
90
+ // A read lease took no kernel lock, so there is nothing to keep: `not-held` is the same answer
91
+ // `release()` gives, and it is the truth rather than a retention that never happened (R-152).
92
+ markRetained: async () => "not-held" as const,
93
93
  readCloseFailure: async () => null,
94
94
  lost: new Promise(() => {}),
95
95
  // No kernel lock was ever taken, so there is no handover to claim (R-105, release side).
@@ -108,7 +108,12 @@ export async function acquireWorkspaceLease(input: {
108
108
  process.execPath, "--input-type=module", "-e", HELPER_SOURCE,
109
109
  ], {
110
110
  stdio: ["pipe", "pipe", "pipe"],
111
- env: { ...process.env, PI_DADDY_LEASE_MARKER: paths.marker, PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10) },
111
+ env: {
112
+ ...process.env,
113
+ PI_DADDY_LEASE_MARKER: paths.marker,
114
+ PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10),
115
+ PI_DADDY_LEASE_CLOSE_TIMEOUT_MS: String(input.herdrCloseTimeoutMs ?? 15_000),
116
+ },
112
117
  // Own process group, for two reasons. It lets teardown kill `flock` AND the helper holding the lock
113
118
  // file descriptor as one unit even before the helper has reported its pid (R-99), and it stops a
114
119
  // stray group signal — a terminal Ctrl-C, a closed window — from releasing a live writer's lock as
@@ -240,12 +245,67 @@ export async function acquireWorkspaceLease(input: {
240
245
  async markRetained(reason = "retained") {
241
246
  // Deliberately does NOT touch the kernel lock or the helper: the pane may still be live. It only
242
247
  // stops the record from looking like a crash.
248
+ //
249
+ // **Both guards below exist because the CALLER ledgers whatever this returns (R-152).**
250
+ //
251
+ // Already settled: `release()` checked `settled` and this did not, so a completed handover could be
252
+ // rewritten into `retained:…` and the memoized answer flipped with it — the mirror of the defect R-146
253
+ // fixed, reachable by the `try { … } finally { await lease.release() }` shape this API invites.
254
+ if (settled) return settled;
255
+ // Already dead: the helper is gone and the kernel lock with it, so the fact is `lost`. Recording a
256
+ // retention here tells an operator a pane may still be live and tells the next owner nothing crashed —
257
+ // precisely the two facts R-103's outcome union was added to keep apart.
258
+ if (holder.exitCode !== null || holder.signalCode !== null) return (settled = "lost");
243
259
  const current = await readMetadata(paths.metadata);
260
+ let recorded = false;
244
261
  if (current !== "malformed" && current?.token === token) {
245
- await atomicMetadata(paths.metadata, {
262
+ recorded = await atomicMetadata(paths.metadata, {
246
263
  ...metadata, state: "released", released_at: new Date().toISOString(), release_reason: `retained:${reason}`,
247
- }).catch(() => undefined);
264
+ }).then(() => true, () => false);
248
265
  }
266
+ /**
267
+ * **It must, however, let THIS process exit (R-146).** Leaving the helper alone is the decision;
268
+ * leaving the parent's three pipes and the child handle referenced was an accident, and it meant a
269
+ * retained lease wedged its own process forever — measured `exit=124` against `exit=0` for the same
270
+ * sequence ending in `release()`.
271
+ *
272
+ * **Which hosts it actually wedged, corrected after review.** `process.exit()` runs `exit` handlers and
273
+ * ignores pending handles, so this only bites a host that lets the loop drain: pi's **print** mode sets
274
+ * `process.exitCode` and returns (`dist/main.js`), and a library consumer — an ADR-0034 external
275
+ * controller calling this package directly — does the same. pi's interactive and rpc modes call
276
+ * `process.exit()` explicitly (`dist/modes/interactive/interactive-mode.js:3148`), so there the process
277
+ * still leaves and `pane-reaper`'s `process.once("exit")` sweep still runs. An earlier version of this
278
+ * comment claimed the sweep was lost generally and cited `src/cli.ts`, which has one subcommand
279
+ * (`init`) and can neither hold a lease nor open a pane.
280
+ *
281
+ * Unref rather than close, because the lock must outlive this call: when the parent does exit, the
282
+ * helper sees EOF and runs the path it was written for — bounded `herdr tab close` attempts, a marker
283
+ * file, then release anyway (R-102: an unreleasable lock strands a worktree with no in-product
284
+ * recovery, which is strictly worse than a recorded failure to close). Narrowed to this path on
285
+ * purpose: at spawn it would remove the accidental guarantee that a process cannot exit while a lease
286
+ * is still ACTIVE, which is a different decision and not this fix.
287
+ */
288
+ /**
289
+ * **Terminal (R-146).** `releasing` stops a late `holder.close` being reported as a lost lease, and
290
+ * `settled` stops a later `release()` running the clean handshake — which it did: measured, it returned
291
+ * `released`, overwrote `retained:herdr-close-failed` with `completed`, and sent `{release:true}` so the
292
+ * helper exited `clean` and never attempted the close. The pane retention exists to protect was
293
+ * abandoned silently, and the ledger asserted a clean handover for a lease kept *because* a pane would
294
+ * not close. No in-tree caller does this — but `WorkspaceLease` is a public export and
295
+ * `try { … } finally { await lease.release() }` is the obvious shape for the external controllers this
296
+ * whole fix is for.
297
+ */
298
+ releasing = true;
299
+ // **Settle only if the record was actually written.** An unwritable lease directory used to leave the
300
+ // record `state: "active"` while the ledger said `retained`, so the next owner reported a phantom
301
+ // `recovered: true` — and making retention terminal removed the later `release()` that would still have
302
+ // written the handover. Unsettled, that repair is available again; the residue is stated in R-152
303
+ // rather than implied, because nothing here can write a record to a directory that refuses writes.
304
+ if (recorded) settled = "retained";
305
+ holder.unref();
306
+ unrefStream(holder.stdout);
307
+ unrefStream(holder.stderr);
308
+ return "retained";
249
309
  },
250
310
  async readCloseFailure() {
251
311
  try {