pi-daddy 0.15.0 → 0.17.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 (81) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +46 -12
  3. package/dist/chain.d.ts +94 -0
  4. package/dist/chain.d.ts.map +1 -0
  5. package/dist/chain.js +161 -0
  6. package/dist/chain.js.map +1 -0
  7. package/dist/cli.js +0 -0
  8. package/dist/delegate.d.ts.map +1 -1
  9. package/dist/delegate.js +6 -2
  10. package/dist/delegate.js.map +1 -1
  11. package/dist/executor.d.ts +38 -0
  12. package/dist/executor.d.ts.map +1 -0
  13. package/dist/executor.js +93 -0
  14. package/dist/executor.js.map +1 -0
  15. package/dist/fanout.d.ts +9 -0
  16. package/dist/fanout.d.ts.map +1 -1
  17. package/dist/fanout.js +9 -0
  18. package/dist/fanout.js.map +1 -1
  19. package/dist/herdr-cli.d.ts +78 -0
  20. package/dist/herdr-cli.d.ts.map +1 -0
  21. package/dist/herdr-cli.js +113 -0
  22. package/dist/herdr-cli.js.map +1 -0
  23. package/dist/herdr-name.d.ts +37 -0
  24. package/dist/herdr-name.d.ts.map +1 -0
  25. package/dist/herdr-name.js +59 -0
  26. package/dist/herdr-name.js.map +1 -0
  27. package/dist/herdr-poll.d.ts +104 -0
  28. package/dist/herdr-poll.d.ts.map +1 -0
  29. package/dist/herdr-poll.js +150 -0
  30. package/dist/herdr-poll.js.map +1 -0
  31. package/dist/herdr-stage.d.ts +40 -0
  32. package/dist/herdr-stage.d.ts.map +1 -0
  33. package/dist/herdr-stage.js +54 -0
  34. package/dist/herdr-stage.js.map +1 -0
  35. package/dist/ledger-report.d.ts +18 -0
  36. package/dist/ledger-report.d.ts.map +1 -1
  37. package/dist/ledger-report.js +10 -0
  38. package/dist/ledger-report.js.map +1 -1
  39. package/dist/ledger.d.ts +31 -0
  40. package/dist/ledger.d.ts.map +1 -1
  41. package/dist/ledger.js +2 -0
  42. package/dist/ledger.js.map +1 -1
  43. package/dist/pane-reaper.d.ts +66 -4
  44. package/dist/pane-reaper.d.ts.map +1 -1
  45. package/dist/pane-reaper.js +131 -9
  46. package/dist/pane-reaper.js.map +1 -1
  47. package/dist/progress.d.ts +96 -0
  48. package/dist/progress.d.ts.map +1 -0
  49. package/dist/progress.js +167 -0
  50. package/dist/progress.js.map +1 -0
  51. package/dist/run-child.d.ts +27 -0
  52. package/dist/run-child.d.ts.map +1 -1
  53. package/dist/run-child.js +84 -7
  54. package/dist/run-child.js.map +1 -1
  55. package/dist/run-herdr.d.ts +41 -28
  56. package/dist/run-herdr.d.ts.map +1 -1
  57. package/dist/run-herdr.js +150 -167
  58. package/dist/run-herdr.js.map +1 -1
  59. package/extensions/delegate-chain.ts +357 -0
  60. package/extensions/delegation.ts +100 -2
  61. package/extensions/grants-command.ts +26 -1
  62. package/extensions/grants.ts +85 -163
  63. package/extensions/run-delegation.ts +130 -13
  64. package/extensions/session-report.ts +231 -0
  65. package/extensions/session.ts +63 -11
  66. package/extensions/tripwire.ts +44 -0
  67. package/package.json +21 -1
  68. package/src/chain.ts +174 -0
  69. package/src/delegate.ts +6 -2
  70. package/src/executor.ts +122 -0
  71. package/src/fanout.ts +10 -0
  72. package/src/herdr-cli.ts +125 -0
  73. package/src/herdr-name.ts +61 -0
  74. package/src/herdr-poll.ts +185 -0
  75. package/src/herdr-stage.ts +55 -0
  76. package/src/ledger-report.ts +21 -0
  77. package/src/ledger.ts +33 -0
  78. package/src/pane-reaper.ts +147 -9
  79. package/src/progress.ts +206 -0
  80. package/src/run-child.ts +96 -7
  81. package/src/run-herdr.ts +170 -174
@@ -21,28 +21,22 @@
21
21
  * child derives its own grant from the tool array of its first provider request.
22
22
  */
23
23
 
24
- import { existsSync } from "node:fs";
25
24
  import { fileURLToPath } from "node:url";
26
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
26
  import { WILDCARD } from "../src/pi-tools.ts";
28
- import { AGENT_WILDCARD } from "../src/resolve.ts";
29
- import { legacyApprovalsPath, sharedApprovalsPath } from "../src/approval-store.ts";
30
27
  import { buildCatalog } from "../src/catalog.ts";
31
- import { loadDefinitions } from "../src/definitions.ts";
32
- import { appendRecord, buildRecord, verifyLedger } from "../src/ledger.ts";
28
+ import { appendRecord, buildRecord } from "../src/ledger.ts";
29
+ import { openPaneCount, reapOpenPanesAsync } from "../src/pane-reaper.ts";
33
30
  import {
34
31
  ENV_GRANT, deriveOwnGrant, observeToolNames } from "../src/propagation.ts";
35
32
  import { snapshotOf } from "./approvals.ts";
36
33
  import { registerDelegationTools } from "./delegation.ts";
37
34
  import { grantsCommand } from "./grants-command.ts";
38
35
  import { runInit } from "./init-command.ts";
39
- import type { Capability } from "../src/resolve.ts";
40
-
41
36
  import { planWithApprovals } from "./run-delegation.ts";
42
- import { createGrantsSession, loadProjectDefinitions, type GrantsSession } from "./session.ts";
43
- import { renderSpawnableSummary, summariseSpawnable } from "./spawn-summary.ts";
44
-
45
- const SPAWN_TOOLS = new Set(["Agent", "subagent", "spawn_agent"]);
37
+ import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
38
+ import { reportSessionStart } from "./session-report.ts";
39
+ import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
46
40
 
47
41
  export default function (pi: ExtensionAPI) {
48
42
  // The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
@@ -70,6 +64,10 @@ export default function (pi: ExtensionAPI) {
70
64
  // When they do not, the session is governed by a different directory's decision than the one it is
71
65
  // working in, which is exactly the confusion a grant must never cause, so it is said out loud rather
72
66
  // than left to be inferred from a surprising refusal. Sync, so it is not the R-60 shape.
67
+ // Inside no try of its own, and that is deliberate: it is synchronous, so it cannot be the R-60 shape. A
68
+ // reviewer noted it sits before the try that contains `resolveExecutor` — which is the right order, because
69
+ // this warning is about WHICH directory's grant was read and must reach the operator even if everything
70
+ // after it fails.
73
71
  if (session.storeCwd !== ctx.cwd && process.env[ENV_GRANT] === undefined) {
74
72
  ctx.ui.notify(
75
73
  `grants: this session's stored grant was read for ${session.storeCwd}, but pi is working in ` +
@@ -97,163 +95,62 @@ export default function (pi: ExtensionAPI) {
97
95
  "error",
98
96
  );
99
97
  }
98
+ // ADR-0031: probe once, HERE, before anything reports — so the disclosure line can name the executor,
99
+ // and so a demanded-but-unreachable herdr is reported before the operator's first prompt rather than at
100
+ // their first delegation. Its own try, because a failure here must not cancel the controls after it
101
+ // (R-60), and `probeHerdr` is documented as never throwing precisely so this is belt-and-braces.
102
+ try {
103
+ await resolveExecutor(session);
104
+ } catch (error) {
105
+ // Says what is actually true of the state left behind, which depends on the variable: with
106
+ // `PI_GRANTS_HERDR=1` the session holds a REFUSAL and every delegation fails, so telling the operator
107
+ // "using the captured subprocess" would be the opposite of what happens. The old wording asserted the
108
+ // fallback unconditionally.
109
+ ctx.ui.notify(
110
+ `grants: could not settle which executor to use ` +
111
+ `(${error instanceof Error ? error.message : String(error)}) — ` +
112
+ (session.executor.refusal
113
+ ? `PI_GRANTS_HERDR=1 still demands herdr, so every delegation in this session will refuse. ` +
114
+ `Unset it to let this session probe, or set 0 to choose subprocesses.`
115
+ : `using the captured subprocess, which needs nothing installed. Set PI_GRANTS_HERDR=0 to make ` +
116
+ `that explicit, or 1 to demand herdr panes.`),
117
+ "warning",
118
+ );
119
+ }
100
120
  session.publishChildEnv();
101
121
  // The definitions now exist, so the `delegate` schema can finally name them (R-39). pi serialises a
102
122
  // tool's schema at REQUEST time, not at registration — measured — which is what makes this reach the
103
123
  // model at all.
104
124
  delegation.refreshSpawnable();
105
- // A malformed bound is now loud as well as safe. Silently disabling spawning would be just as
106
- // confusing as silently disabling the limit was dangerous — the operator set the variable, so
107
- // they need to know it did not take effect (G7 / A-S4).
108
- if (session.malformedBounds.length > 0) {
109
- ctx.ui.notify(
110
- `grants: ${session.malformedBounds.join(" and ")} could not be read as a non-negative integer — ` +
111
- `spawning is disabled for this session (failing closed)`,
112
- "warning",
113
- );
114
- }
115
- // ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
116
- // import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
117
- // operator whose approvals silently stopped applying deserves to know why.
118
- // ADR-0020: the pre-0.11 single shared store is ignored, not migrated. Same reasoning shape as the
119
- // legacy file below — an operator whose approvals silently stopped applying must be told why — but a
120
- // different reason for not migrating: splitting it by `cwd` would be lossless, and it is still declined
121
- // because one-shot migration code in the layer with nine defects buys less than one re-approval costs.
122
- try {
123
- if (existsSync(sharedApprovalsPath())) {
124
- ctx.ui.notify(
125
- `grants: ignoring ${sharedApprovalsPath()} — approvals are now stored one file per governed ` +
126
- `directory (ADR-0020), because a single shared file could not hold two projects' approvals ` +
127
- `for a same-named definition. Re-approve when next asked. **Deleting the old file is ` +
128
- `recommended, not merely safe**: entries written by 0.10.x may contain the task text a model ` +
129
- `composed at approval time, which this version no longer stores anywhere (ADR-0021).`,
130
- "warning",
131
- );
132
- }
133
- } catch {
134
- /* never throw into the agent loop */
135
- }
136
- try {
137
- if (existsSync(legacyApprovalsPath(ctx.cwd))) {
138
- ctx.ui.notify(
139
- `grants: ignoring ${legacyApprovalsPath(ctx.cwd)} — approvals now live outside the workspace ` +
140
- `(it was writable by the very agents it gated). Re-approve when next asked; the old file is ` +
141
- `safe to delete.`,
142
- "warning",
143
- );
144
- }
145
- } catch {
146
- /* never throw into the agent loop */
147
- }
148
- // R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
149
- // definition's CEILING — which never contains `agent:<name>`, because the authorisation check
150
- // (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
151
- // who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
152
- // no dialog and no warning. It DOES bite when a definition passes the id down in its own
153
- // `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
154
- // the namespace ADR-0017 just promoted out of exactly that state.
125
+ // Everything an operator is TOLD at session start now lives in `./session-report.ts`. Lifted because
126
+ // this file had reached 398 of the 400-line ceiling and ADR-0032 adds a control to it; the split is the
127
+ // same move `session.ts` and `grants-command.ts` were extracted under, and the guard is obeyed rather
128
+ // than raised.
155
129
  //
156
- // Warned rather than enforced: making it gate the spawn is a behaviour change and wants a decision.
157
- // Silence is the part that is indefensible either way.
158
- // `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
159
- // `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
160
- // file" is not true of everything it covers. Paired with a shell that is every body on disk running
161
- // with `bash`. `docs/SPEC.md` calls the combination poor and nothing detected it, which is R-47's
162
- // shape in a control shipped one day later.
163
- if (session.ownGrant.includes(AGENT_WILDCARD) && session.gated.length === 0 && session.ownGrant.includes("tool:bash")) {
164
- ctx.ui.notify(
165
- `grants: PI_GRANTS_GRANT pairs agent:* with tool:bash and gates nothing — every SKILL.md in ` +
166
- `this project AND in ~/.pi/agent/skills (which other tools install into) may run with a shell. ` +
167
- `Enumerate the agent: ids you mean, or leave PI_GRANTS_GATED at its default so bash is asked for.`,
168
- "warning",
169
- );
170
- }
171
- const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
172
- if (inertGates.length > 0) {
130
+ // Its own try/catch, required by `test/session-start-guard.test.ts` and right on the merits: every
131
+ // control inside `reportSessionStart` already has one, but a throw from the reporter ITSELF would
132
+ // otherwise reach the blanket catch below and be reported as "session start did not complete" — which
133
+ // would be true and useless, because nothing about the grant or its enforcement depends on any of it.
134
+ // Naming that distinction is the difference between an operator checking their configuration and an
135
+ // operator distrusting their governance.
136
+ try {
137
+ await reportSessionStart(session, ctx);
138
+ } catch (error) {
173
139
  ctx.ui.notify(
174
- `grants: ${inertGates.join(", ")} in PI_GRANTS_GATED does NOT gate spawning that definition — ` +
175
- `the authorisation check for a definition is separate and ungated, so a human is never asked. ` +
176
- `It applies only where a definition passes the id down in its own allowed-tools. To control ` +
177
- `which definitions may run, withhold the agent: capability from PI_GRANTS_GRANT instead.`,
140
+ // Names the CHECKS as well as the display lines. The first version listed only "the grant, the
141
+ // executor and the spawnable definitions" — which understated it: a throw partway through the reporter
142
+ // also skips the legacy/shared approval-store notices and the **ledger-corruption check**, which is an
143
+ // error rather than an FYI. Telling an operator that three lines are missing, when a control also did
144
+ // not run, is the reassuring half of the truth.
145
+ `grants: the session-start report could not be produced ` +
146
+ `(${error instanceof Error ? error.message : String(error)}) — the grant, the executor and the ` +
147
+ `spawnable definitions were not printed, AND the checks that run alongside them did not complete: ` +
148
+ `ledger integrity was not verified and any ignored-approvals notices were not shown. Run /grants and ` +
149
+ `/grants ledger for all of it. Governance itself is unaffected: it is enforced by --tools when a ` +
150
+ `child is spawned.`,
178
151
  "warning",
179
152
  );
180
153
  }
181
- // R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
182
- // and a check an operator has to know to run is not a control, it is a feature. Setting
183
- // `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
184
- // part of keeping one.
185
- //
186
- // Corruption only, deliberately. The escalation count is a *query* — `/grants ledger` answers it —
187
- // and reporting historical attempts unprompted at every start is the fatigue shape R-25 names, which
188
- // ends with the operator ignoring the line that matters.
189
- //
190
- // Awaited rather than fired and forgotten: it is one read, on a path that already awaits two
191
- // directory scans, and awaiting is what guarantees the warning reaches a live `ctx.ui`.
192
- //
193
- // R-60. `verifyLedger` RETHROWS every read error that is not ENOENT — right for `/grants ledger`,
194
- // where an operator asked a direct question and deserves the failure — and this call is the only one
195
- // that makes it inside the blanket catch below. So an unreadable ledger threw here and cancelled every
196
- // remaining control **in silence**: no alarm, and not even the `holding [...]` line that is the one
197
- // sign governance is on. Confirmed by execution — `PI_GRANTS_LEDGER` naming a directory produced ZERO
198
- // notifications from a governed session. A trail that cannot be read at all is a worse failure than a
199
- // torn line, and it was the one case this control said nothing about.
200
- if (session.ledgerPath) {
201
- try {
202
- const report = await verifyLedger(session.ledgerPath);
203
- if (report.exists && !report.ok) {
204
- ctx.ui.notify(
205
- `grants: ledger ${session.ledgerPath} has ${report.corrupt.length} unparseable line(s) — ` +
206
- `first at line ${report.corrupt[0]?.line}. A torn line is indistinguishable from a spawn that ` +
207
- `never happened, so this audit trail is incomplete. Run /grants ledger for detail; the file is ` +
208
- `left alone because a corrupt line is evidence.`,
209
- "error",
210
- );
211
- }
212
- } catch (error) {
213
- ctx.ui.notify(
214
- `grants: ledger ${session.ledgerPath} could not be read ` +
215
- `(${(error as { code?: string }).code ?? String(error)}) — nothing can be verified about this ` +
216
- `audit trail, and the first spawn will refuse rather than proceed unrecorded. Check that ` +
217
- `PI_GRANTS_LEDGER names a writable FILE.`,
218
- "error",
219
- );
220
- }
221
- }
222
- if (session.governed) {
223
- ctx.ui.notify(
224
- `grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
225
- "info",
226
- );
227
- // B1 / P4. The grant alone never named the definitions, never said where they came from, and never
228
- // said which ones were being WITHHELD — so an operator who had just installed a package of
229
- // `SKILL.md` files could not tell governance-is-working from did-the-install-fail. Classified by the
230
- // real planner (see `./spawn-summary.ts`), never by a second reading of the rules.
231
- //
232
- // Its own try/catch, and not because `summariseSpawnable` throws today: this is the R-60 shape
233
- // exactly — one added `await` inside the blanket catch cancelling every control below it in
234
- // silence. There is nothing below it now; there will be.
235
- try {
236
- const line = renderSpawnableSummary(
237
- await summariseSpawnable(
238
- session.definitions,
239
- (name) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
240
- // The session facts that make every per-definition verdict identical. `mayDelegate` in
241
- // particular: without `tool:delegate` there is no delegate tool at all, and the line used to
242
- // report definitions as spawnable in the one session where nothing can ever be spawned.
243
- { mayDelegate: session.mayDelegate, depth: session.depth, maxDepth: session.maxDepth },
244
- ),
245
- session.definitions.size,
246
- );
247
- if (line) ctx.ui.notify(line, "info");
248
- } catch (error) {
249
- ctx.ui.notify(
250
- `grants: could not work out which definitions are spawnable ` +
251
- `(${error instanceof Error ? error.message : String(error)}) — run /grants for the per-definition ` +
252
- `verdict. Nothing about the grant or its enforcement depends on this line.`,
253
- "warning",
254
- );
255
- }
256
- }
257
154
  } catch (error) {
258
155
  // Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
259
156
  // reach the agent loop. Swallowing SILENTLY is what let R-60 exist, and would let the next one exist
@@ -273,6 +170,33 @@ export default function (pi: ExtensionAPI) {
273
170
  return undefined;
274
171
  });
275
172
 
173
+ /**
174
+ * Reap the herdr panes this agent run opened — ADR-0032.
175
+ *
176
+ * **`agent_settled`, not `turn_end`, and that is the whole finding.** `turn_end` fires at the end of each
177
+ * provider round-trip, i.e. no later than the `finally` that used to close the pane — so building pane
178
+ * lifetime on it would have shipped a no-op that read like a feature. `agent_settled` is documented as firing
179
+ * once the run has fully settled with no retry, compaction or queued continuation: the moment the operator
180
+ * gets their prompt back. Independent corroboration that this is the right boundary: herdr's own pi
181
+ * integration drives its busy/idle display from `agent_start`/`agent_settled`.
182
+ *
183
+ * The sweep is ASYNC (`reapOpenPanesAsync`) rather than the `exit` handler's sync one, which is `execFileSync`
184
+ * with a six-second budget by necessity. Running that here would freeze pi for up to six seconds every time
185
+ * the operator gets their prompt back — a feature built to make work visible, stalling the thing it serves.
186
+ *
187
+ * `exit` remains the backstop. SIGKILL still orphans panes, exactly as R-62 records, and no signal handler is
188
+ * installed here for the reason R-62 gives: it would turn pi's "interrupt this turn" into "exit pi".
189
+ */
190
+ pi.on("agent_settled", async () => {
191
+ try {
192
+ if (session.executor.kind !== "herdr" || openPaneCount() === 0) return undefined;
193
+ await reapOpenPanesAsync();
194
+ } catch {
195
+ /* never throw into the agent loop; the exit handler is still the backstop */
196
+ }
197
+ return undefined;
198
+ });
199
+
276
200
  // Observe this session's real tool surface once, and tighten the grant to it. Authoritative because
277
201
  // it is exactly what pi sent the model.
278
202
  pi.on("before_provider_request", (event) => {
@@ -321,11 +245,7 @@ export default function (pi: ExtensionAPI) {
321
245
  pi.on("tool_call", async (event) => {
322
246
  if (!session.governed || !SPAWN_TOOLS.has(event.toolName)) return undefined;
323
247
 
324
- const reason =
325
- `grants: "${event.toolName}" spawns sub-agents outside this session's governance — refused. ` +
326
- `This session grants capabilities by spawning them itself (\`delegate\`), so a child created by ` +
327
- `another extension would hold whatever that extension decided, with no grant, no depth bound and ` +
328
- `no ledger entry. Use \`delegate\` instead. If you meant to run ungoverned, unset PI_GRANTS_GRANT.`;
248
+ const reason = tripwireReason(event.toolName);
329
249
 
330
250
  if (session.ledgerPath) {
331
251
  // Recorded like any other refusal: an audit that omits the spawns we turned away cannot answer
@@ -339,6 +259,7 @@ export default function (pi: ExtensionAPI) {
339
259
  agentType: event.toolName,
340
260
  // The wildcard is the honest record: an unknown spawner was going to hand this child whatever
341
261
  // IT decided, and we have no way to know what that would have been.
262
+ executor: session.executor.kind,
342
263
  requested: [WILDCARD],
343
264
  parentGrant: session.ownGrant,
344
265
  result: { effective: [], denied: [WILDCARD], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
@@ -371,6 +292,7 @@ export default function (pi: ExtensionAPI) {
371
292
  cwd: session.cwd,
372
293
  governed: session.governed,
373
294
  ownGrant: session.ownGrant,
295
+ executor: session.executor,
374
296
  observed: session.observed,
375
297
  depth: session.depth,
376
298
  maxDepth: session.maxDepth,
@@ -22,7 +22,9 @@ import type { Capability } from "../src/resolve.ts";
22
22
  import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
23
23
  import { runHerdrPane } from "../src/run-herdr.ts";
24
24
  import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
25
- import { ENV_HERDR_KEEP_PANE, ENV_HERDR_WORKSPACE, type GrantsSession } from "./session.ts";
25
+ import type { InheritableApproval } from "../src/approval.ts";
26
+ import { resolveWorkspace } from "../src/herdr-cli.ts";
27
+ import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
26
28
 
27
29
  /** What one child was asked to do. The shape both tools accept, per child. */
28
30
  interface ChildSpec {
@@ -71,12 +73,23 @@ export async function planWithApprovals(
71
73
  extra: Record<string, unknown>,
72
74
  ctx: ApprovalUIContext | null,
73
75
  signal?: AbortSignal,
76
+ /**
77
+ * Approvals a caller has ALREADY obtained, so this plan does not ask again — ADR-0033's upfront gate.
78
+ *
79
+ * `delegate_chain` collects the union of its steps' gated capabilities and asks once, then hands the answer to
80
+ * every step. Without this each step would re-open the dialog *after* the operator had already answered for the
81
+ * whole chain, which is R-25's fatigue shape with nothing bought.
82
+ *
83
+ * It cannot widen anything: `planDelegation` intersects `approved` with the grant on every path, so a
84
+ * pre-approval for something the session does not hold is still refused. What it changes is who is asked.
85
+ */
86
+ preApproved?: InheritableApproval[],
74
87
  ): Promise<GatedPlan> {
75
88
  // Spelled ONCE. It is asked for twice — when the human is prompted, and when the answer is fed back into
76
89
  // the re-plan — and two spellings of one argument is the defect R-28 was.
77
90
  const approvalSubject = request.agent ?? DELEGATE_SUBJECT;
78
91
 
79
- let plan = planDelegation(request, { ...(await session.delegationContext()), ...extra });
92
+ let plan = planDelegation(request, { ...(await session.delegationContext(preApproved)), ...extra });
80
93
  if (plan.ok || !shouldSeekApproval(plan.result)) return { plan };
81
94
 
82
95
  let approval: ApprovalOutcome | undefined;
@@ -156,16 +169,95 @@ export async function runOneDelegation(
156
169
  budget: number | undefined,
157
170
  ctx: DelegationToolContext,
158
171
  signal: AbortSignal | undefined,
172
+ /**
173
+ * Progress for the parent's status block (ADR-0032). Optional, so nothing here depends on being watched.
174
+ *
175
+ * One sink for both executors: the herdr path additionally reports a pane id, and the process path never
176
+ * has one. Every field is display-only — the child's answer is still the returned outcome.
177
+ */
178
+ /**
179
+ * The optional tail, as ONE object rather than positional arguments.
180
+ *
181
+ * **R-28's lesson, applied before it cost anything.** Adding `preApproved` as a seventh positional parameter put
182
+ * it in front of `onProgress`, and two existing call sites silently passed a progress sink where approvals were
183
+ * expected. TypeScript caught it only because the types happen to differ — which is luck, not a control, and
184
+ * R-28 was precisely "a defect in an argument list that 226 pure tests could not see". An object makes the
185
+ * mistake unspellable.
186
+ */
187
+ options: {
188
+ /** Progress for the parent's status block (ADR-0032). Display only. */
189
+ onProgress?: (update: {
190
+ /** Appended (process executor: a genuine byte stream). */
191
+ chunk?: string;
192
+ /** Replaces (herdr executor: a snapshot of a bounded terminal). The two are NOT interchangeable. */
193
+ snapshot?: string[];
194
+ paneId?: string;
195
+ /** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
196
+ agentName?: string;
197
+ state?: "running" | "completed" | "failed";
198
+ }) => void;
199
+ /** Approvals already obtained by the caller — see `planWithApprovals`. `delegate_chain` uses it. */
200
+ preApproved?: InheritableApproval[];
201
+ /**
202
+ * The child whose output composed this child's task (ADR-0033).
203
+ *
204
+ * Recorded, never acted on: it exists so "who wrote this instruction?" is answerable from the trail, which is
205
+ * the question the chain's framed-rather-than-enforced handoff makes worth asking.
206
+ */
207
+ taskFrom?: string;
208
+ /**
209
+ * What the caller's own gate decided, for the LEDGER — not for the plan.
210
+ *
211
+ * **Required because pre-filling `approved` silences the record.** The doc comment on `planWithApprovals` above
212
+ * warns about exactly this: satisfying the gate on the first plan means `obtainApprovals` never runs, so
213
+ * `approval` is undefined and this record writes no `approved`, `approvalSources`, `approvalScopes` or
214
+ * `humanDenied`. Measured: a chain step that spent `tool:bash` on a human's click was indistinguishable from one
215
+ * where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
216
+ * not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
217
+ */
218
+ approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "humanDenied">;
219
+ } = {},
159
220
  ): Promise<DelegationOutcome> {
221
+ const { onProgress, preApproved, taskFrom, approvalFacts } = options;
160
222
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
161
223
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
162
224
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
163
225
  const request = { task: spec.task, agent: spec.agent, tools: spec.tools, model: spec.model ?? defaultModel };
164
226
  const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
165
227
 
228
+ // ADR-0031: herdr was DEMANDED (`PI_GRANTS_HERDR=1`) and is not answering. Refused rather than relocated —
229
+ // the operator chose that over falling back, so the ledger can never name a child that ran somewhere nobody
230
+ // chose.
231
+ //
232
+ // **Decided BEFORE the gate, and the ordering is a fix.** This sat after `planWithApprovals`, which opens the
233
+ // approval dialog — so with herdr down a human was asked to approve `tool:bash`, answered *Always*, and was
234
+ // then refused anyway. Measured: the answer still reached `process.env.PI_GRANTS_APPROVED`, still wrote a
235
+ // **30-day project-wide** entry to the persisted store, and still produced a ledger line asserting a human
236
+ // approved `bash` for a child that never existed. A refused operation must not leave authority behind, and
237
+ // asking for permission that cannot be used is R-25's fatigue shape with nothing bought.
238
+ //
239
+ // `ctx: null` rather than skipping the plan entirely: the ledger still gets a full, honest record of what was
240
+ // requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
241
+ // *asked*. It is the same argument `/grants` uses for its preview.
242
+ const refusal = session.executor.refusal;
166
243
  // Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
167
- // disagree (R-38). This call is the enforcing one: it passes `ctx`, so a human CAN be asked.
168
- let { plan, approval: approvalOutcome } = await planWithApprovals(session, request, extra, ctx, signal);
244
+ // disagree (R-38). This call is the enforcing one when a human may be asked: `ctx` is passed unless the
245
+ // executor has already made the outcome certain.
246
+ let { plan, approval: approvalOutcome } = await planWithApprovals(
247
+ session,
248
+ request,
249
+ extra,
250
+ refusal ? null : ctx,
251
+ signal,
252
+ preApproved,
253
+ );
254
+
255
+ // Applied in front of the ledger write below, so the record describes a refusal rather than a spawn. Turning
256
+ // `plan.ok` off reuses the existing blocked-record path, so this adds a reason rather than a second refusal
257
+ // mechanism.
258
+ if (refusal) {
259
+ plan = { ...plan, ok: false, reason: `grants: ${refusal}` };
260
+ }
169
261
 
170
262
  // G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
171
263
  if (session.ledgerPath) {
@@ -179,15 +271,21 @@ export async function runOneDelegation(
179
271
  childId: ids.childId,
180
272
  depth: plan.childDepth,
181
273
  agentType: spec.agent ?? "delegate",
274
+ // ADR-0031: where this child actually ran. Read off the live session, which the probe has settled
275
+ // by now, so the record and the executor cannot disagree.
276
+ executor: session.executor.kind,
277
+ taskFrom,
182
278
  requested: plan.requested,
183
279
  parentGrant: session.ownGrant,
184
280
  result: plan.result,
185
281
  blocked: !plan.ok,
186
282
  reason: plan.reason,
187
- approved: approvalOutcome?.approved,
188
- approvalSources: approvalOutcome?.sources,
189
- approvalScopes: approvalOutcome?.scopes,
190
- humanDenied: approvalOutcome?.humanDenied,
283
+ // `approvalOutcome` when this call's own gate ran; `approvalFacts` when a caller gated upfront on our behalf
284
+ // (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
285
+ approved: approvalOutcome?.approved ?? approvalFacts?.approved,
286
+ approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
287
+ approvalScopes: approvalOutcome?.scopes ?? approvalFacts?.scopes,
288
+ humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
191
289
  gateOutcome: approvalOutcome?.gateOutcome,
192
290
  // ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
193
291
  // recomputed the digest could record one the planner never used.
@@ -208,10 +306,14 @@ export async function runOneDelegation(
208
306
  // G8: bounded output, a wall-clock timeout with SIGTERM->SIGKILL escalation, and an abort observed
209
307
  // even if it happened before we got here. See src/run-child.ts for why each one exists.
210
308
  //
211
- // ADR-0016 point 6: two executors, one plan. `runChild` is the default because it needs nothing
212
- // installed; herdr gives the same governed argv a VISIBLE, attachable pane. Opt-in per session rather
213
- // than auto-detected — a governed run must not silently relocate because a binary is on PATH.
214
- const output = session.useHerdr
309
+ // ADR-0016 point 6: two executors, one plan. `runChild` needs nothing installed; herdr gives the same
310
+ // governed argv a VISIBLE, attachable pane.
311
+ //
312
+ // **Which one is chosen was reversed by ADR-0031**: the session probes for a reachable herdr server at
313
+ // startup rather than waiting to be told. Still never detected from a binary on `PATH` — only from a server
314
+ // that answered — and the choice is disclosed at session start, in `/grants`, and per child in the ledger.
315
+ // Read live off `session.executor`, because the probe finishes after this module is loaded.
316
+ const output = session.executor.kind === "herdr"
215
317
  ? await runHerdrPane({
216
318
  args: plan.args.slice(0, -1),
217
319
  // The task is delivered as a prompt, so it never reaches argv at all. `plan.args` still ends
@@ -223,10 +325,22 @@ export async function runOneDelegation(
223
325
  env: plan.env,
224
326
  cwd: ctx.cwd,
225
327
  name: `${spec.agent ?? "delegate"}-${ids.childId}`,
226
- workspace: process.env[ENV_HERDR_WORKSPACE],
328
+ // Was `process.env[ENV_HERDR_WORKSPACE]`, i.e. "omitted lets herdr choose" — which put children in a
329
+ // different workspace from the pi session that spawned them, so switching to one meant hopping
330
+ // workspaces rather than tabs. `resolveWorkspace` prefers the operator's explicit answer and otherwise
331
+ // inherits the parent's own `HERDR_WORKSPACE_ID` (measured; herdr sets it in every pane it creates).
332
+ workspace: resolveWorkspace(process.env),
227
333
  signal,
228
334
  timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
229
335
  keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
336
+ // ADR-0032. The pane id arrives first and is what a human switches to; the pane's tail follows as the
337
+ // child works. Both are display only.
338
+ //
339
+ // `snapshot`, not `chunk`: `agent read` returns a snapshot of a bounded terminal, and the sink must
340
+ // REPLACE what it holds. Treating it as a stream produced an 89,000× amplification and fabricated lines
341
+ // the child never printed — see `tailLines` in `src/herdr-poll.ts`.
342
+ onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
343
+ onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
230
344
  })
231
345
  : await runChild({
232
346
  command: "pi",
@@ -238,6 +352,9 @@ export async function runOneDelegation(
238
352
  cwd: ctx.cwd,
239
353
  signal,
240
354
  timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
355
+ // No pane on this path, so streaming is the ONLY observability a subprocess child can have — which is
356
+ // why ADR-0032 chose the streaming option over status lines alone.
357
+ onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
241
358
  });
242
359
 
243
360
  // G8: a child that failed is reported as a failure. A non-zero exit, a timeout and a truncated flood