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.
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/dist/approval-prompt.d.ts +116 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +124 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/approval-store.d.ts +149 -0
- package/dist/approval-store.d.ts.map +1 -0
- package/dist/approval-store.js +311 -0
- package/dist/approval-store.js.map +1 -0
- package/dist/approval.d.ts +221 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +244 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +31 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +45 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog.d.ts +81 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +164 -0
- package/dist/catalog.js.map +1 -0
- package/dist/definitions.d.ts +101 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +182 -0
- package/dist/definitions.js.map +1 -0
- package/dist/delegate.d.ts +133 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +261 -0
- package/dist/delegate.js.map +1 -0
- package/dist/fanout.d.ts +62 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +84 -0
- package/dist/fanout.js.map +1 -0
- package/dist/file-lock.d.ts +54 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +142 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-report.d.ts +106 -0
- package/dist/ledger-report.d.ts.map +1 -0
- package/dist/ledger-report.js +157 -0
- package/dist/ledger-report.js.map +1 -0
- package/dist/ledger.d.ts +139 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +94 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pane-reaper.d.ts +49 -0
- package/dist/pane-reaper.d.ts.map +1 -0
- package/dist/pane-reaper.js +120 -0
- package/dist/pane-reaper.js.map +1 -0
- package/dist/pi-tools.d.ts +20 -0
- package/dist/pi-tools.d.ts.map +1 -0
- package/dist/pi-tools.js +21 -0
- package/dist/pi-tools.js.map +1 -0
- package/dist/propagation.d.ts +185 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +255 -0
- package/dist/propagation.js.map +1 -0
- package/dist/resolve.d.ts +110 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +181 -0
- package/dist/resolve.js.map +1 -0
- package/dist/run-child.d.ts +50 -0
- package/dist/run-child.d.ts.map +1 -0
- package/dist/run-child.js +109 -0
- package/dist/run-child.js.map +1 -0
- package/dist/run-herdr.d.ts +91 -0
- package/dist/run-herdr.d.ts.map +1 -0
- package/dist/run-herdr.js +306 -0
- package/dist/run-herdr.js.map +1 -0
- package/dist/spawn.d.ts +60 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +116 -0
- package/dist/spawn.js.map +1 -0
- package/extensions/approvals.ts +285 -0
- package/extensions/delegation.ts +237 -0
- package/extensions/grants-command.ts +310 -0
- package/extensions/grants.ts +346 -0
- package/extensions/run-delegation.ts +270 -0
- package/extensions/session.ts +234 -0
- package/package.json +122 -0
- package/src/approval-prompt.ts +222 -0
- package/src/approval-store.ts +383 -0
- package/src/approval.ts +364 -0
- package/src/capabilities.ts +51 -0
- package/src/catalog.ts +186 -0
- package/src/definitions.ts +233 -0
- package/src/delegate.ts +383 -0
- package/src/fanout.ts +96 -0
- package/src/file-lock.ts +142 -0
- package/src/index.ts +59 -0
- package/src/ledger-report.ts +227 -0
- package/src/ledger.ts +214 -0
- package/src/pane-reaper.ts +132 -0
- package/src/pi-tools.ts +25 -0
- package/src/propagation.ts +308 -0
- package/src/resolve.ts +235 -0
- package/src/run-child.ts +149 -0
- package/src/run-herdr.ts +357 -0
- 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
|
+
}
|