pi-daddy 0.13.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 (105) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/dist/approval-prompt.d.ts +116 -0
  5. package/dist/approval-prompt.d.ts.map +1 -0
  6. package/dist/approval-prompt.js +124 -0
  7. package/dist/approval-prompt.js.map +1 -0
  8. package/dist/approval-store.d.ts +149 -0
  9. package/dist/approval-store.d.ts.map +1 -0
  10. package/dist/approval-store.js +311 -0
  11. package/dist/approval-store.js.map +1 -0
  12. package/dist/approval.d.ts +221 -0
  13. package/dist/approval.d.ts.map +1 -0
  14. package/dist/approval.js +244 -0
  15. package/dist/approval.js.map +1 -0
  16. package/dist/capabilities.d.ts +31 -0
  17. package/dist/capabilities.d.ts.map +1 -0
  18. package/dist/capabilities.js +45 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/catalog.d.ts +81 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +164 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/definitions.d.ts +101 -0
  25. package/dist/definitions.d.ts.map +1 -0
  26. package/dist/definitions.js +182 -0
  27. package/dist/definitions.js.map +1 -0
  28. package/dist/delegate.d.ts +133 -0
  29. package/dist/delegate.d.ts.map +1 -0
  30. package/dist/delegate.js +261 -0
  31. package/dist/delegate.js.map +1 -0
  32. package/dist/fanout.d.ts +62 -0
  33. package/dist/fanout.d.ts.map +1 -0
  34. package/dist/fanout.js +84 -0
  35. package/dist/fanout.js.map +1 -0
  36. package/dist/file-lock.d.ts +54 -0
  37. package/dist/file-lock.d.ts.map +1 -0
  38. package/dist/file-lock.js +142 -0
  39. package/dist/file-lock.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ledger-report.d.ts +106 -0
  45. package/dist/ledger-report.d.ts.map +1 -0
  46. package/dist/ledger-report.js +157 -0
  47. package/dist/ledger-report.js.map +1 -0
  48. package/dist/ledger.d.ts +139 -0
  49. package/dist/ledger.d.ts.map +1 -0
  50. package/dist/ledger.js +94 -0
  51. package/dist/ledger.js.map +1 -0
  52. package/dist/pane-reaper.d.ts +49 -0
  53. package/dist/pane-reaper.d.ts.map +1 -0
  54. package/dist/pane-reaper.js +120 -0
  55. package/dist/pane-reaper.js.map +1 -0
  56. package/dist/pi-tools.d.ts +20 -0
  57. package/dist/pi-tools.d.ts.map +1 -0
  58. package/dist/pi-tools.js +21 -0
  59. package/dist/pi-tools.js.map +1 -0
  60. package/dist/propagation.d.ts +185 -0
  61. package/dist/propagation.d.ts.map +1 -0
  62. package/dist/propagation.js +255 -0
  63. package/dist/propagation.js.map +1 -0
  64. package/dist/resolve.d.ts +110 -0
  65. package/dist/resolve.d.ts.map +1 -0
  66. package/dist/resolve.js +181 -0
  67. package/dist/resolve.js.map +1 -0
  68. package/dist/run-child.d.ts +50 -0
  69. package/dist/run-child.d.ts.map +1 -0
  70. package/dist/run-child.js +109 -0
  71. package/dist/run-child.js.map +1 -0
  72. package/dist/run-herdr.d.ts +91 -0
  73. package/dist/run-herdr.d.ts.map +1 -0
  74. package/dist/run-herdr.js +306 -0
  75. package/dist/run-herdr.js.map +1 -0
  76. package/dist/spawn.d.ts +60 -0
  77. package/dist/spawn.d.ts.map +1 -0
  78. package/dist/spawn.js +116 -0
  79. package/dist/spawn.js.map +1 -0
  80. package/extensions/approvals.ts +285 -0
  81. package/extensions/delegation.ts +237 -0
  82. package/extensions/grants-command.ts +310 -0
  83. package/extensions/grants.ts +346 -0
  84. package/extensions/run-delegation.ts +270 -0
  85. package/extensions/session.ts +234 -0
  86. package/package.json +122 -0
  87. package/src/approval-prompt.ts +222 -0
  88. package/src/approval-store.ts +383 -0
  89. package/src/approval.ts +364 -0
  90. package/src/capabilities.ts +51 -0
  91. package/src/catalog.ts +186 -0
  92. package/src/definitions.ts +233 -0
  93. package/src/delegate.ts +383 -0
  94. package/src/fanout.ts +96 -0
  95. package/src/file-lock.ts +142 -0
  96. package/src/index.ts +59 -0
  97. package/src/ledger-report.ts +227 -0
  98. package/src/ledger.ts +214 -0
  99. package/src/pane-reaper.ts +132 -0
  100. package/src/pi-tools.ts +25 -0
  101. package/src/propagation.ts +308 -0
  102. package/src/resolve.ts +235 -0
  103. package/src/run-child.ts +149 -0
  104. package/src/run-herdr.ts +357 -0
  105. package/src/spawn.ts +156 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The human-approval flow: what this session may inherit, republish, and ask a person for.
3
+ *
4
+ * Split out of `extensions/grants.ts`. Everything here takes the session as an explicit first argument
5
+ * rather than closing over module state — the same discipline `grants-command.ts` was extracted under, and
6
+ * for the same reason: R-28 was a defect in an *argument list*, invisible to 226 pure tests because
7
+ * nothing tested argument lists. A parameter can be read; a closure has to be reconstructed.
8
+ *
9
+ * The session is passed whole, not destructured, because `ownGrant`, `definitions` and `cwd` all change
10
+ * during a session. Reading them through the object is what keeps this flow looking at the live values
11
+ * instead of whatever they were when the extension loaded.
12
+ */
13
+
14
+ import {
15
+ approvalKey,
16
+ expiryFor,
17
+ resolveApprovals,
18
+ verifyInherited,
19
+ type ApprovalPath,
20
+ type ApprovalScope,
21
+ type ApprovalSource,
22
+ type InheritableApproval,
23
+ type ResolveApprovalsResult,
24
+ type SubjectSnapshot,
25
+ } from "../src/approval.ts";
26
+ import { loadApprovals, saveApproval } from "../src/approval-store.ts";
27
+ import type { createApprovalGate } from "../src/approval-prompt.ts";
28
+ import { timeoutMsFromEnv, type PromptOutcomeKind } from "../src/approval-prompt.ts";
29
+ import { ceilingForDefinition, digestDefinition } from "../src/definitions.ts";
30
+ import type { Capability } from "../src/resolve.ts";
31
+ import type { GrantsSession } from "./session.ts";
32
+
33
+ /**
34
+ * What a subject looks like right now, for the confused-deputy check in the approval store.
35
+ *
36
+ * ADR-0010's property is unchanged: an `always` approval is void once the thing it was granted for has
37
+ * changed. Two things moved. The source is a `SKILL.md`'s `allowed-tools` rather than an agent type's
38
+ * frontmatter (ADR-0016); and "the thing it was granted for" now includes the **body** as well as the
39
+ * tools (ADR-0019), because a definition whose instructions were rewritten is not the definition the human
40
+ * approved. An undeclared definition yields an EMPTY ceiling, not a wildcard, so a stored approval for it
41
+ * can never be revalidated by accident.
42
+ *
43
+ * `<delegate>` — the `tools:` form's subject — resolves to `null` here and always will: it names no file,
44
+ * so there is nothing to compare against. That is also why that path is never offered `always`.
45
+ */
46
+ export function snapshotOf(session: GrantsSession, subject: string): SubjectSnapshot | null {
47
+ const definition = session.definitions.get(subject);
48
+ if (!definition) return null;
49
+ return {
50
+ ceiling: ceilingForDefinition(definition).capabilities,
51
+ bodySha256: digestDefinition(definition).sha256,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * What this session may republish to children (ADR-0014).
57
+ *
58
+ * Two changes from the version that published bare capability names. Each entry keeps its **subject**,
59
+ * so an approval given for one agent type cannot satisfy another; and each keeps its **scope**, so
60
+ * `inheritApprovals` can drop `once` rather than handing a whole subtree an approval a human gave for
61
+ * a single spawn.
62
+ *
63
+ * `once` never enters `sessionApprovals` in the first place, so everything here is `session` or
64
+ * `always` — but the scope is carried rather than assumed, because assuming it is what went wrong.
65
+ */
66
+ export function republishable(session: GrantsSession): InheritableApproval[] {
67
+ const fromKey = (key: string) => {
68
+ const subject = key.slice(key.indexOf("@") + 1);
69
+ return {
70
+ capability: key.slice(0, key.indexOf("@")),
71
+ subject,
72
+ scope: "session" as const,
73
+ // ADR-0022: the digest is taken from THIS session's view of the definition, not carried over from
74
+ // whatever the parent sent. Republishing a stale hash would let a rewritten body travel one more hop
75
+ // on a pin nobody re-checked, which is the hole this closes rather than moves.
76
+ bodySha256: snapshotOf(session, subject)?.bodySha256,
77
+ };
78
+ };
79
+ return [
80
+ // Inherited keys arrive already clamped and already `once`-free from the level above — but NOT already
81
+ // verified, and republishing an unverified one launders it. `fromKey` stamps this session's current
82
+ // digest, so a key whose pin no longer matches would go out looking valid and be honoured by a child
83
+ // that loaded the same current body: the approval was given about a different text and would travel
84
+ // one hop further on a pin nobody checked. That is ADR-0022's own hole, reappearing inside the fix.
85
+ ...[...verifyInherited(session.inheritedApprovals, (name) => snapshotOf(session, name))].map(fromKey),
86
+ ...[...session.sessionApprovals].map(fromKey),
87
+ ];
88
+ }
89
+
90
+ export interface ApprovalOutcome {
91
+ approved: Capability[];
92
+ /**
93
+ * How far EACH capability's yes reaches (F1b/F5).
94
+ *
95
+ * Was a single `scope`, declared outside the prompt loop and overwritten by the last capability answered.
96
+ * Approve `tool:bash` *once* and `tool:write` *for this session* and both were re-stamped `session` —
97
+ * so ADR-0014's rule that "`once` stops at the boundary" was reopened by a mixed answer, and the ledger's
98
+ * `approvalScope` described one capability while claiming to describe the set.
99
+ *
100
+ * Only prompted capabilities appear here. One satisfied from the store or by inheritance is deliberately
101
+ * absent, so the caller's `?? "once"` keeps it out of what a child inherits — the conservative reading,
102
+ * and the behaviour that was already in place.
103
+ */
104
+ scopes: Record<Capability, ApprovalScope>;
105
+ /**
106
+ * Where EACH approved capability's yes came from (R-46).
107
+ *
108
+ * Was a single `source`, chosen as `scope ? "prompt" : sources[approved[0]]` — which told the ledger a
109
+ * human had been asked about capabilities satisfied silently from the store. `resolveApprovals` has
110
+ * always computed this map; the bug was that it was thrown away.
111
+ */
112
+ sources: Record<Capability, ApprovalSource>;
113
+ humanDenied: boolean;
114
+ /**
115
+ * Which of the five prompt outcomes ended the loop, when one did (ADR-0026's F5).
116
+ *
117
+ * `humanDenied` is one bit of a five-way discriminant. `no-ui`, `dismissed` and `error` all produced the
118
+ * same record with only free-text `reason` to separate them, and they call for different responses: a
119
+ * dismissal is a queue or a longer timeout, `no-ui` is an operator pre-approving, `error` is a defect.
120
+ */
121
+ gateOutcome?: PromptOutcomeKind;
122
+ reason?: string;
123
+ }
124
+
125
+ /** The slice of pi's `ExtensionContext` this flow needs — a UI to ask through, and how to ask. */
126
+ export interface ApprovalUIContext {
127
+ ui: Parameters<typeof createApprovalGate>[0]["ui"];
128
+ hasUI: boolean;
129
+ mode: string;
130
+ }
131
+
132
+ /**
133
+ * The approvals already in hand: inherited, given earlier this session, or persisted from a previous one.
134
+ *
135
+ * No dialog, and no I/O beyond reading the store. Extracted (R-38) so that a **read-only preview can run
136
+ * the same resolution the enforcer runs**. `/grants` used to list each definition from `planDelegation`
137
+ * alone, which knows nothing about approvals — so a definition whose only gated capability was covered by a
138
+ * valid persisted approval was listed as `BLOCK … requires explicit approval` while a real spawn proceeded
139
+ * with no human in the loop. That is R-28's shape (a diagnostic disagreeing with the enforcer) and it hid a
140
+ * standing approval from the operator who ran the command to find exactly that.
141
+ */
142
+ export async function storedApprovals(
143
+ session: GrantsSession,
144
+ gated: Capability[],
145
+ subject: string,
146
+ ): Promise<ResolveApprovalsResult> {
147
+ const { valid } = await loadApprovals({
148
+ cwd: session.cwd,
149
+ now: new Date(),
150
+ snapshotOf: (name) => snapshotOf(session, name),
151
+ });
152
+ return resolveApprovals({
153
+ gated,
154
+ subject,
155
+ sessionApprovals: session.sessionApprovals,
156
+ persisted: valid,
157
+ // ADR-0022. Verified HERE rather than at parse time because it needs `session.definitions`, which
158
+ // arrives at `session_start` — after the session object is built.
159
+ inherited: verifyInherited(session.inheritedApprovals, (name) => snapshotOf(session, name)),
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Satisfy as many gated capabilities as possible, asking a human only for what is left.
165
+ *
166
+ * Returns what was approved and how, so the caller can re-resolve with the same pure `resolve()` and
167
+ * the ledger can record which of the three flavours of "no" applies (see `ledger.ts`'s `GrantRecord`).
168
+ *
169
+ * **`ctx: null` means preview**: report what is already in hand and ask nobody. Deliberately not expressed
170
+ * as `hasUI: false`, which means *"there is nobody here to ask"* — that path replaces the plan's reason with
171
+ * a message about pre-approving in an interactive session, which is right for a governed child and both
172
+ * wrong and noisy for a listing that never intended to ask. The two are different facts and are kept
173
+ * distinguishable.
174
+ */
175
+ export async function obtainApprovals(
176
+ session: GrantsSession,
177
+ gatedBlocked: Capability[],
178
+ subject: string,
179
+ path: ApprovalPath,
180
+ ctx: ApprovalUIContext | null,
181
+ task?: string,
182
+ signal?: AbortSignal,
183
+ ): Promise<ApprovalOutcome> {
184
+ const snapshot = (name: string) => snapshotOf(session, name);
185
+ const pre = await storedApprovals(session, gatedBlocked, subject);
186
+ if (ctx === null || pre.needsPrompt.length === 0) {
187
+ return { approved: pre.approved, sources: pre.sources, scopes: {}, humanDenied: false };
188
+ }
189
+
190
+ // The gate PROVIDER lives on the session, not here: `obtainApprovals` runs once per `tool_call` and once
191
+ // per `delegate.execute`, so a queue built per call would start empty every time and de-duplicate
192
+ // nothing. Two concurrent delegations would stack two dialogs asking the identical question, which is
193
+ // precisely what spec §6.1 exists to prevent. Options are per-call; the queue is session-long.
194
+ const gate = session.approvalGateFor({
195
+ ui: ctx.ui,
196
+ hasUI: ctx.hasUI,
197
+ mode: ctx.mode,
198
+ timeoutMs: timeoutMsFromEnv(process.env.PI_GRANTS_APPROVAL_TIMEOUT),
199
+ });
200
+
201
+ const approved = [...pre.approved];
202
+ // Seeded with what the store already answered, then one entry added per capability a human is asked
203
+ // about — so a mixed set reports exactly which yes came from where.
204
+ const sources: Record<Capability, ApprovalSource> = { ...pre.sources };
205
+ const scopes: Record<Capability, ApprovalScope> = {};
206
+ let humanDenied = false;
207
+ let gateOutcome: PromptOutcomeKind | undefined;
208
+ let reason: string | undefined;
209
+
210
+ for (const capability of pre.needsPrompt) {
211
+ const outcome = await gate.request({ capability, subject, path, task, signal });
212
+ if (outcome.scope === null) {
213
+ // Forward the gate's own discriminant rather than re-deriving it from `hasUI`: `hasUI` is true in
214
+ // RPC mode too, so an automated client's timeout or dismissal there would misreport as "a human
215
+ // declined" if we asked `ctx.hasUI` instead. Only `kind === "declined"` means a person said no.
216
+ humanDenied = outcome.kind === "declined";
217
+ // The full discriminant, not just the one bit of it that had a field. `no-ui`, `dismissed` and
218
+ // `error` used to produce records identical to each other, separated only by free-text `reason` —
219
+ // so "was there an operator who timed out, or was there nobody?" had no answer, and the two want
220
+ // different fixes. It was computed here all along and thrown away.
221
+ gateOutcome = outcome.kind;
222
+ reason = outcome.reason;
223
+ break;
224
+ }
225
+ approved.push(capability);
226
+ // R-66: `prompt` means A HUMAN SAW A DIALOG FOR THIS. A caller that joined another's in-flight answer
227
+ // did not, so it records the source it actually had — the session approval that answer created. Eight
228
+ // concurrent children under one *Allow for this session* used to write eight lines each claiming a
229
+ // prompt. Same repair as R-46, one level up: stop asserting a human was asked on a path where they
230
+ // were not.
231
+ sources[capability] = outcome.joined ? "session" : "prompt";
232
+ scopes[capability] = outcome.scope;
233
+
234
+ if (outcome.scope === "session" || outcome.scope === "always") {
235
+ session.sessionApprovals.add(approvalKey(capability, subject));
236
+ }
237
+ if (outcome.scope === "always") {
238
+ const now = new Date();
239
+ const current = snapshot(subject);
240
+ // No readable ceiling means the entry would carry `grantAtApproval: []`, which `entryVerdict`
241
+ // compares against the type's ceiling on every load — so it could only ever come back
242
+ // "type-missing" or "type-changed". Writing it is not unsafe (it fails closed), it is simply a
243
+ // dead entry that silently accumulates in the file. Skip it and say so, taking the same
244
+ // downgrade-to-session path as a failed write below: the human's yes still stands.
245
+ const written =
246
+ current === null
247
+ ? false
248
+ : await saveApproval(
249
+ session.cwd,
250
+ approvalKey(capability, subject),
251
+ {
252
+ approvedAt: now.toISOString(),
253
+ expiresAt: expiryFor(now),
254
+ cwd: session.cwd,
255
+ grantAtApproval: current.ceiling,
256
+ // ADR-0019: the tools AND the instructions the human actually saw. Pinning only the
257
+ // former would let a rewritten body inherit a yes that was given about different text.
258
+ bodyAtApproval: current.bodySha256,
259
+ // The TASK is deliberately absent (ADR-0021). It is shown in the dialog, where a human
260
+ // needs it, and never written down, because the model assembles it from the parent's
261
+ // context and it can carry anything the parent could see.
262
+ },
263
+ snapshot,
264
+ now,
265
+ );
266
+ if (!written) {
267
+ // The human already said yes; the security decision stands. Only the convenience cache
268
+ // failed, so this downgrades scope rather than refusing the delegation (see approval-store.ts).
269
+ ctx.ui.notify(
270
+ current === null
271
+ ? `grants: cannot persist the approval for ${capability} — no definition named ${subject} is ` +
272
+ `readable here, so a stored entry could never be valid; it applies for this session only`
273
+ : `grants: could not persist the approval for ${capability} — it applies for this session only`,
274
+ "warning",
275
+ );
276
+ // The human's yes stands; only the cache failed. Downgrade THIS capability, not the set — under
277
+ // the old scalar this also rewrote the scope of every other capability answered in the same call.
278
+ scopes[capability] = "session";
279
+ }
280
+ }
281
+ session.publishChildEnv(); // a new session approval widens what children may inherit — republish now
282
+ }
283
+
284
+ return { approved, sources, scopes, humanDenied, gateOutcome, reason };
285
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Governed delegation, as pi sees it: the `delegate` and `delegate_all` tool registrations.
3
+ *
4
+ * Unlike the tripwire in `grants.ts` this PROVISIONS — the grant is an argument, so the orchestrator hands
5
+ * each child exactly the capabilities it should have. Both tools are registered only when this session may
6
+ * delegate, so withholding `tool:delegate` genuinely makes a session a leaf (S-5).
7
+ *
8
+ * What a delegation actually DOES lives in `./run-delegation.ts`; this file is the pi-facing surface —
9
+ * schemas, descriptions, and turning an outcome into the throw-or-return contract pi expects.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+ import { maySpawnDefinition } from "../src/delegate.ts";
15
+ import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
16
+ import { runOneDelegation } from "./run-delegation.ts";
17
+ import { type GrantsSession } from "./session.ts";
18
+
19
+ export interface DelegationRegistration {
20
+ /**
21
+ * Re-derive "which definitions may this session spawn?" into the registered tool schemas.
22
+ *
23
+ * Must be called once the session knows the answer — `session_start` loads the definitions, and the first
24
+ * provider request tightens `ownGrant`. Both change the list, and neither has happened when the tools are
25
+ * registered. See R-39: without this the answer is permanently `none`.
26
+ */
27
+ refreshSpawnable: () => void;
28
+ }
29
+
30
+
31
+ /**
32
+ * Register `delegate` and `delegate_all` — but only if this session may delegate.
33
+ *
34
+ * The conditional is the whole of S-5: an unconditionally-registered `delegate` appears in every child's
35
+ * ceiling, so a delegator without it was told every single agent type "requires tool:delegate".
36
+ */
37
+ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession): DelegationRegistration {
38
+ if (!session.mayDelegate) return { refreshSpawnable: () => {} };
39
+
40
+ /**
41
+ * Definitions this session is actually authorised to spawn (ADR-0017), for the tool description.
42
+ *
43
+ * Listing all of them would tell the model it can spawn things every attempt at which is refused — the
44
+ * R-28 shape again, a description disagreeing with the enforcer. Computed from the same
45
+ * `maySpawnDefinition` the planner uses.
46
+ */
47
+ const spawnable = () =>
48
+ [...session.definitions.keys()].filter((name) => maySpawnDefinition(session.ownGrant, name)).sort();
49
+
50
+ /**
51
+ * **R-39.** This used to be computed once, right here, and it was always `[]`.
52
+ *
53
+ * `registerDelegationTools` is called synchronously from the extension factory, while
54
+ * `session.definitions` is only populated in the `session_start` hook — which fires afterwards. So every
55
+ * model in every governed session was told `Available: none.` and did the reasonable thing: it used
56
+ * `delegate({tools})`, the path with no operator-authored instructions, no `agent:` prerequisite, no body
57
+ * digest on the record, and no `always` approval available. **ADR-0017 and ADR-0019 bought expressiveness
58
+ * the model was structurally prevented from using**, and every dialog was a `<delegate>` dialog again.
59
+ *
60
+ * The comment this replaces reasoned carefully about grant staleness and never noticed the map was empty.
61
+ *
62
+ * The repair rests on a measured fact: **pi serialises a tool's schema at request time, not at
63
+ * registration**, so mutating the description after the definitions load reaches the provider. Verified
64
+ * directly — a probe that rewrote a parameter description in `session_start` saw the new text arrive in
65
+ * `before_provider_request`'s payload.
66
+ */
67
+ const describeAgent = (names: string[]) =>
68
+ `Name of a definition to spawn — its allowed-tools become the grant and its instructions ` +
69
+ `become the sub-agent's system prompt. Available: ${names.join(", ") || "none"}.`;
70
+
71
+ const childShape = Type.Object({
72
+ task: Type.String({ description: "The task for this sub-agent. It receives only this." }),
73
+ agent: Type.Optional(Type.String({ description: describeAgent(spawnable()) })),
74
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
75
+ model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
76
+ });
77
+
78
+ const delegateAllParams = Type.Object({
79
+ children: Type.Array(childShape, {
80
+ minItems: 1,
81
+ maxItems: MAX_CHILDREN_PER_CALL,
82
+ description: "The sub-agents to run concurrently. Each is independent and unaware of the others.",
83
+ }),
84
+ });
85
+
86
+ const delegateParams = Type.Object({
87
+ task: Type.String({ description: "The task for the sub-agent. It receives only this." }),
88
+ agent: Type.Optional(Type.String({ description: describeAgent(spawnable()) })),
89
+ tools: Type.Optional(
90
+ Type.Array(Type.String(), {
91
+ description:
92
+ "Capabilities to grant when no 'agent' is named, e.g. [\"read\",\"grep\"] or " +
93
+ "[\"tool:read\",\"ext:pkg/tool\"]. Empty means no tools. Ignored when 'agent' is given.",
94
+ }),
95
+ ),
96
+ model: Type.Optional(
97
+ Type.String({
98
+ // A bare id resolves across all known providers and can land on one there is no key for, so the
99
+ // form is named rather than implied — see the "Verified live" defect in the README.
100
+ description:
101
+ "Model for the sub-agent as provider/id, e.g. \"openai-codex/gpt-5.6-sol\". " +
102
+ "Defaults to this session's model, already provider-qualified.",
103
+ }),
104
+ ),
105
+ });
106
+
107
+ pi.registerTool({
108
+ name: "delegate",
109
+ label: "Delegate (governed)",
110
+ description:
111
+ "Delegate a task to a sub-agent holding ONLY the capabilities you grant it. You cannot grant what " +
112
+ "you do not hold. Prefer 'agent' — it spawns a definition whose capabilities and instructions were " +
113
+ "written by the operator. Use 'tools' only when no definition fits. Grant 'delegate' if the " +
114
+ "sub-agent must itself delegate further; withhold it to make the sub-agent a leaf.",
115
+ parameters: delegateParams,
116
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
117
+ const outcome = await runOneDelegation(
118
+ session,
119
+ { task: params.task, agent: params.agent, tools: params.tools, model: params.model },
120
+ { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, 0) },
121
+ // A single blocking delegation spends nothing from the subtree budget: cardinality is already
122
+ // bounded to one by the call being blocking, which is the accident fan-out removes. Passing the
123
+ // budget through unchanged means a child can still fan out with what this session was given.
124
+ session.fanoutBudget,
125
+ ctx,
126
+ signal,
127
+ );
128
+
129
+ if (!outcome.ok) {
130
+ // THROW, do not return. `AgentToolResult` has no `isError` field: pi sets it only when `execute`
131
+ // throws (`pi-agent-core/dist/agent-loop.js` — a normal return is hardcoded `isError: false`).
132
+ // Returning `isError: true` was silently discarded, so every refusal this package made was
133
+ // recorded by pi as a SUCCESSFUL tool call. Found by the integration suite on its first run.
134
+ const detail = outcome.text ? `\n\n${outcome.text}` : "";
135
+ throw new Error(`delegation refused: ${outcome.reason}${detail}`);
136
+ }
137
+
138
+ return {
139
+ content: [{ type: "text", text: outcome.text || "(no output)" }],
140
+ details: { granted: outcome.granted, depth: outcome.depth, exitCode: outcome.exitCode },
141
+ };
142
+ },
143
+ });
144
+
145
+ /**
146
+ * Bounded SYNCHRONOUS fan-out — ADR-0015's option A′.
147
+ *
148
+ * One call spawns several governed children concurrently and returns when the last one finishes. There is
149
+ * deliberately no background mode, no result-by-id and no child registry, and that scoping is the whole
150
+ * design: **fan-out and background are separable, fan-out carries most of the value, and background
151
+ * carries nearly all of the state-machine holes.** Because the turn still owns the children, the parent
152
+ * cannot exit before them, the tool-call signal is still live, the timeout still outlives every child,
153
+ * results are returned rather than stored, and there are no ids to dangle across a compaction.
154
+ *
155
+ * Every child goes through `runOneDelegation`, so each one is planned, gated, audited and bounded by
156
+ * exactly the same rules as a single `delegate`. What fan-out adds is a **cardinality bound** (the
157
+ * budget) and **sibling identity** (F8) — the two things ADR-0008 never had, because a blocking
158
+ * `delegate` bounded cardinality to one by accident.
159
+ */
160
+ pi.registerTool({
161
+ name: "delegate_all",
162
+ label: "Delegate to several sub-agents (governed, parallel)",
163
+ description:
164
+ "Run several sub-agents CONCURRENTLY and return all their results. Each child is governed exactly " +
165
+ "as with `delegate`: it holds only what you grant it, and you cannot grant what you do not hold. " +
166
+ `At most ${MAX_CHILDREN_PER_CALL} children per call, and a session-wide budget bounds the total ` +
167
+ "across the whole delegation subtree. Children cannot see each other or share context. Use this " +
168
+ "when independent tasks can proceed in parallel — several reviewers over one diff, say — and read " +
169
+ "every child's outcome, because one can be refused while the others succeed.",
170
+ parameters: delegateAllParams,
171
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
172
+ const children = params.children ?? [];
173
+ const split = splitBudget(session.fanoutBudget, children.length);
174
+ if (!split.ok) {
175
+ // Thrown, not returned: a returned `isError` is discarded by pi, so a refusal that came back as a
176
+ // normal result would read to the orchestrator as a successful fan-out of zero children.
177
+ throw new Error(`fan-out refused: ${split.reason}`);
178
+ }
179
+
180
+ // Concurrent by construction. Each child gets its own budget share and its own ledger id, so the
181
+ // records form a tree and two siblings can never be confused for one another.
182
+ const outcomes = await Promise.all(
183
+ children.map((child, index) =>
184
+ runOneDelegation(
185
+ session,
186
+ child,
187
+ { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, index) },
188
+ split.perChild,
189
+ ctx,
190
+ signal,
191
+ ),
192
+ ),
193
+ );
194
+
195
+ const failed = outcomes.filter((o) => !o.ok);
196
+ // Every child is reported, including the ones that failed. R-03's rule: a missing result must never
197
+ // be indistinguishable from an empty one, and a fan-out that hid its refusals would let an
198
+ // orchestrator summarise four reviews when only three happened.
199
+ const report = outcomes
200
+ .map((outcome, index) => {
201
+ const label = `### child ${index + 1}${children[index].agent ? ` (${children[index].agent})` : ""}`;
202
+ return outcome.ok
203
+ ? `${label} — completed\n\n${outcome.text || "(no output)"}`
204
+ : `${label} — FAILED: ${outcome.reason}${outcome.text ? `\n\n${outcome.text}` : ""}`;
205
+ })
206
+ .join("\n\n---\n\n");
207
+
208
+ if (failed.length === children.length) {
209
+ // All of them failed, so there is no partial result to hand back — and a tool that returns text
210
+ // when nothing ran is exactly how a wrong summary gets written.
211
+ throw new Error(`fan-out failed: every child was refused or failed.\n\n${report}`);
212
+ }
213
+
214
+ return {
215
+ content: [{ type: "text", text: report }],
216
+ details: {
217
+ children: outcomes.length,
218
+ failed: failed.length,
219
+ budgetPerChild: split.perChild,
220
+ granted: outcomes.map((o) => o.granted),
221
+ },
222
+ };
223
+ },
224
+ });
225
+
226
+ return {
227
+ // Written through the CONSTRUCTED schema (`properties.agent`) rather than the object handed to
228
+ // `Type.Optional`, because `Optional` shallow-copies — mutating the input would update a discarded
229
+ // clone. Both tools are refreshed from one place so they cannot disagree about what is spawnable.
230
+ refreshSpawnable: () => {
231
+ const names = spawnable();
232
+ const description = describeAgent(names);
233
+ (delegateParams.properties.agent as { description?: string }).description = description;
234
+ (childShape.properties.agent as { description?: string }).description = description;
235
+ },
236
+ };
237
+ }