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
@@ -0,0 +1,357 @@
1
+ /**
2
+ * `delegate_chain` — a governed sequential pipeline, planned and gated as one unit (ADR-0033).
3
+ *
4
+ * Its own file rather than a third tool in `delegation.ts`, which is near the 400-line ceiling. The seam is real
5
+ * anyway: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in **composition** — each
6
+ * step's task is built from the previous step's output, and the whole thing is planned before any of it runs.
7
+ *
8
+ * **Nothing here re-implements a governance rule.** Every step goes through `runOneDelegation`, so the grant, the
9
+ * ceiling, `agent:<name>` authorisation, the depth bound, the ledger and `--tools` enforcement are exactly what a
10
+ * single `delegate` gets. What a chain adds is the handoff (`src/chain.ts`), one gate instead of N, a budget unit
11
+ * per step, and abort-on-failure.
12
+ *
13
+ * **Why the upfront gate is exact rather than an approximation**, which is the least obvious thing here: an approval
14
+ * is keyed `capability@subject` and **the task is never part of it** (ADR-0021 — the task is not stored anywhere). So
15
+ * the union of a chain's gated capabilities is fully determined before any step's task exists, even though steps
16
+ * 2..N have no task until their predecessor runs. That is what makes asking once honest instead of optimistic.
17
+ */
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import type { InheritableApproval } from "../src/approval.ts";
22
+ import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
23
+ import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
24
+ import { planDelegation } from "../src/delegate.ts";
25
+ import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
26
+ import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
27
+ import type { Capability } from "../src/resolve.ts";
28
+ import { appendRecord, buildRecord } from "../src/ledger.ts";
29
+ import { obtainApprovals, snapshotOf } from "./approvals.ts";
30
+ import { runOneDelegation } from "./run-delegation.ts";
31
+ import type { GrantsSession } from "./session.ts";
32
+
33
+ /** One step of a chain, as the model describes it. */
34
+ interface StepSpec {
35
+ task: string;
36
+ agent?: string;
37
+ tools?: string[];
38
+ model?: string;
39
+ }
40
+
41
+ /**
42
+ * One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
43
+ *
44
+ * **One request per `capability@subject`, not per subject.** Grouping by subject alone merged every `tools:`-only
45
+ * step under the constant `<delegate>` and froze the *first* step's task into the dialog — so an operator approved
46
+ * `tool:write` while reading a task that only listed files. The dialog is the one place a human learns what they are
47
+ * authorising, so it names the step that actually needs the capability.
48
+ */
49
+ interface GateRequest {
50
+ subject: string;
51
+ /** `"definition"` offers `always`; `"delegate"` must not (ADR-0019). Derived from the subject, never assumed. */
52
+ path: "definition" | "delegate";
53
+ capability: Capability;
54
+ /** The task of the step that needs this capability. */
55
+ task: string;
56
+ }
57
+
58
+ /** Either every gate the chain will hit, or the first step that can never run and why. */
59
+ interface ChainPlan {
60
+ requests: GateRequest[];
61
+ /** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
62
+ doomed?: { step: number; reason: string };
63
+ }
64
+
65
+ /**
66
+ * Plan every step and collect the gates — or find a step that can never run.
67
+ *
68
+ * **`plan.ok` is honoured here, and ignoring it was a privilege path.** The first version read only
69
+ * `gatedBlocked`, so a step refused for an unheld `agent:` id, an unknown definition, an empty task or a universal
70
+ * capability still raised its gate and the answer was still banked. Measured: `delegate({tools:["bash","agent:x"]})`
71
+ * asks nobody and refuses, while the same step inside a chain raised a dialog, took *Allow for this session*, refused
72
+ * anyway — and left `tool:bash` pre-approved for every later delegation in the session. On the `agent:` path it
73
+ * banked a **30-day** entry, including for a step whose task was whitespace, whose dialog therefore read `task:`
74
+ * followed by nothing.
75
+ *
76
+ * `shouldSeekApproval` is the rule every other gate in this package already applies, and its own docstring names
77
+ * this hazard: *"both banked against a spawn that never happened, and both reachable by a model that appends one
78
+ * unheld capability to an otherwise ordinary request."* The chain reimplemented the decision beside it instead of
79
+ * routing through it. So a doomed step now refuses the whole chain **before any dialog**, which is the same
80
+ * principle the executor check follows.
81
+ *
82
+ * Planned with no UI: `planWithApprovals` would open a dialog per step during the very phase whose purpose is to ask
83
+ * upfront. A step's task is unknown for everything after the first, which is fine — an approval key never contains
84
+ * the task (ADR-0021), so the set of gates is fully determined without it.
85
+ */
86
+ async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
87
+ const requests: GateRequest[] = [];
88
+ const seen = new Set<string>();
89
+ const context = await session.delegationContext();
90
+
91
+ for (const [index, step] of steps.entries()) {
92
+ // The real task, not a placeholder: the planner's own empty-task guard must run here rather than at spawn time,
93
+ // or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
94
+ // time and cannot change which capabilities are gated.
95
+ const plan = planDelegation(
96
+ { task: step.task, agent: step.agent, tools: step.tools, model: step.model },
97
+ { ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
98
+ );
99
+
100
+ // A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
101
+ // for a spawn that will never happen.
102
+ if (!plan.ok && !shouldSeekApproval(plan.result)) {
103
+ return { requests: [], doomed: { step: index + 1, reason: plan.reason ?? "this step cannot run" } };
104
+ }
105
+
106
+ const subject = step.agent ?? DELEGATE_SUBJECT;
107
+ for (const capability of plan.result.gatedBlocked) {
108
+ const key = `${capability}@${subject}`;
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ requests.push({ subject, path: step.agent ? "definition" : "delegate", capability, task: step.task });
112
+ }
113
+ }
114
+ return { requests };
115
+ }
116
+
117
+ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
118
+ // No `mayDelegate` guard here: `registerDelegationTools` already returns before calling this, so a second check
119
+ // was dead code that made the leaf half of a wiring test pass for the wrong reason. The guard lives in one place.
120
+
121
+ const stepShape = Type.Object({
122
+ task: Type.String({
123
+ description:
124
+ `What this step should do. Write ${PLACEHOLDER} where the previous step's output belongs; if you omit it, ` +
125
+ `that output is appended instead. The first step never receives one.`,
126
+ }),
127
+ agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
128
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
129
+ model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
130
+ });
131
+
132
+ const params = Type.Object({
133
+ steps: Type.Array(stepShape, {
134
+ minItems: 1,
135
+ maxItems: MAX_CHAIN_STEPS,
136
+ description: "The steps to run IN ORDER. Each one sees the previous one's output.",
137
+ }),
138
+ });
139
+
140
+ pi.registerTool({
141
+ name: "delegate_chain",
142
+ label: "Delegate a chain of sub-agents (governed, sequential)",
143
+ description:
144
+ "Run sub-agents ONE AFTER ANOTHER, each receiving the previous one's output. Use this when a step needs " +
145
+ "what an earlier step produced — a decision feeding a design feeding an implementation. Use `delegate_all` " +
146
+ "instead when the tasks are independent and can run at the same time, and `delegate` for a single child. " +
147
+ `At most ${MAX_CHAIN_STEPS} steps, each spending one unit of the session's fan-out budget. Every step is ` +
148
+ "governed exactly as a single `delegate` is: it holds only what you grant it, and you cannot grant what you " +
149
+ "do not hold. A failed step ABORTS the rest, and you still receive everything that completed.",
150
+ parameters: params,
151
+ async execute(_toolCallId, args, signal, onUpdate, ctx) {
152
+ const steps = args.steps ?? [];
153
+
154
+ // **Every cheap refusal happens before any human is asked.** Yesterday's lesson on the `delegate` path: with
155
+ // `PI_GRANTS_HERDR=1` and herdr down, the gate ran first, an operator approved `bash`, a 30-day entry was
156
+ // written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
157
+ // gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
158
+ // ordering is simply bypassed.
159
+ if (session.executor.refusal) throw new Error(`chain refused: ${session.executor.refusal}`);
160
+
161
+ // Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
162
+ // fan-out cannot disagree about what the budget means.
163
+ const split = splitBudget(session.fanoutBudget, steps.length);
164
+ if (!split.ok) throw new Error(`chain refused: ${split.reason}`);
165
+
166
+ // Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
167
+ // `planChain`.
168
+ const chainPlan = await planChain(session, steps, split.perChild);
169
+ if (chainPlan.doomed) {
170
+ throw new Error(
171
+ `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
172
+ `asked to approve anything — a step that cannot run must not bank authority for a spawn that will ` +
173
+ `never happen.`,
174
+ );
175
+ }
176
+
177
+ // One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
178
+ const preApproved: InheritableApproval[] = [];
179
+ let declined: { capability: Capability; subject: string } | undefined;
180
+ let humanDenied = false;
181
+
182
+ for (const request of chainPlan.requests) {
183
+ const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
184
+ humanDenied = humanDenied || outcome.humanDenied;
185
+ if (!outcome.approved.includes(request.capability)) {
186
+ // **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
187
+ // `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
188
+ // not run. The single-delegate path breaks on the first no for the same reason.
189
+ declined = { capability: request.capability, subject: request.subject };
190
+ break;
191
+ }
192
+ preApproved.push({
193
+ capability: request.capability,
194
+ subject: request.subject,
195
+ scope: outcome.scopes[request.capability] ?? ("once" as const),
196
+ // Pinned to THIS subject's body (ADR-0022). Stamped from a single shared subject, the pin was verified
197
+ // against one definition's instructions while the capability was spent on another's.
198
+ bodySha256: snapshotOf(session, request.subject)?.bodySha256,
199
+ });
200
+ }
201
+
202
+ if (declined) {
203
+ // Recorded before it is thrown, and recorded against the subject that was actually refused. The first version
204
+ // hardcoded step 1's identity, so the trail asserted a human had denied a capability for `digger` when they
205
+ // had *approved* it for `digger` and denied it for `shaper` — the ledger and the approval store asserting
206
+ // opposite facts about the same key, which is R-28's shape.
207
+ if (session.ledgerPath) {
208
+ const at = steps.findIndex((step) => (step.agent ?? DELEGATE_SUBJECT) === declined.subject);
209
+ await appendRecord(
210
+ { path: session.ledgerPath, strict: true },
211
+ buildRecord({
212
+ parentId: session.ownSpawnId,
213
+ childId: childSpawnId(session.ownSpawnId, Math.max(0, at)),
214
+ depth: session.depth + 1,
215
+ agentType: declined.subject === DELEGATE_SUBJECT ? "delegate" : declined.subject,
216
+ requested: [declined.capability],
217
+ parentGrant: session.ownGrant,
218
+ result: { effective: [], denied: [], clipped: [], gatedBlocked: [declined.capability], universal: [], subsumedBy: [] },
219
+ blocked: true,
220
+ humanDenied,
221
+ reason: `chain refused: ${declined.capability} not approved for ${declined.subject}; no step ran`,
222
+ executor: session.executor.kind,
223
+ now: new Date(),
224
+ }),
225
+ ).catch((error) => {
226
+ // Not swallowed. Everywhere else a `strict` ledger failure fails closed, and losing the one line that
227
+ // records a human's refusal is the direction rule 8 forbids — the chain refuses either way, so saying so
228
+ // costs nothing.
229
+ ctx.ui?.notify?.(
230
+ `grants: the chain was refused AND its ledger line could not be written (${String(error)}) — the ` +
231
+ `refusal happened, but this audit trail does not show it.`,
232
+ "error",
233
+ );
234
+ });
235
+ }
236
+ throw new Error(
237
+ `chain refused: ${declined.capability} was not approved for ${declined.subject}, so no step ran. A chain ` +
238
+ `is gated as a unit — running only its approved steps would return a partial result that reads like a ` +
239
+ `complete one.`,
240
+ );
241
+ }
242
+
243
+ const children: ChildProgress[] = steps.map((step) => ({
244
+ label: step.agent ?? "delegate",
245
+ state: "starting",
246
+ startedAt: Date.now(),
247
+ tail: emptyTail,
248
+ }));
249
+ const paint = throttle(() => {
250
+ (onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
251
+ content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
252
+ });
253
+ }, PAINT_INTERVAL_MS);
254
+
255
+ const outcomes: Array<{ ok: boolean; text: string; reason?: string; step: number; agent?: string }> = [];
256
+ let previous: string | undefined;
257
+ let aborted = false;
258
+ /**
259
+ * Approvals still available to later steps.
260
+ *
261
+ * **`once` is consumed by the first step that spends it, and not honouring that was a confused deputy.**
262
+ * Measured: three steps of one definition, one dialog naming step 1's task — *"survey the north field"* — and
263
+ * three children spawned, the last of which had been told *"…and burn the evidence"*. Steps 2 and 3 were never
264
+ * described to anyone. Two sequential plain `delegate` calls raise two dialogs, because a `once` answer never
265
+ * enters `sessionApprovals`; the chain was the outlier. R-29 exists for this exact shape one level down.
266
+ *
267
+ * A later step needing the same capability now reaches its own gate and prompts with its OWN task, which is
268
+ * what `once` means.
269
+ */
270
+ let available = [...preApproved];
271
+
272
+ for (const [index, step] of steps.entries()) {
273
+ const childId = childSpawnId(session.ownSpawnId, index);
274
+ const outcome = await runOneDelegation(
275
+ session,
276
+ chainStepSpec(step, previous),
277
+ { parentId: session.ownSpawnId, childId },
278
+ split.perChild,
279
+ ctx,
280
+ signal,
281
+ {
282
+ preApproved: available,
283
+ approvalFacts: {
284
+ // Only this step's subject, so the record says what was authorised for THIS child rather than for the
285
+ // chain as a whole.
286
+ approved: available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => a.capability),
287
+ sources: Object.fromEntries(
288
+ available
289
+ .filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT))
290
+ .map((a) => [a.capability, "prompt" as const]),
291
+ ),
292
+ scopes: Object.fromEntries(
293
+ available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => [a.capability, a.scope]),
294
+ ),
295
+ humanDenied: false,
296
+ },
297
+ onProgress: (update) => {
298
+ const child = children[index];
299
+ if (!child) return;
300
+ if (update.paneId) child.paneId = update.paneId;
301
+ if (update.agentName) child.agentName = update.agentName;
302
+ if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
303
+ if (update.snapshot) child.tail = replaceTail(update.snapshot);
304
+ if (update.state) child.state = update.state;
305
+ else if (child.state === "starting") child.state = "running";
306
+ paint.call();
307
+ },
308
+ // Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
309
+ taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
310
+ },
311
+ );
312
+
313
+ children[index].state = outcome.ok ? "completed" : "failed";
314
+ children[index].settledAt = Date.now();
315
+ outcomes.push({ ok: outcome.ok, text: outcome.text, reason: outcome.reason, step: index + 1, agent: step.agent });
316
+
317
+ // Spend any `once` this step was handed, before the next step sees the list.
318
+ const spentSubject = step.agent ?? DELEGATE_SUBJECT;
319
+ available = available.filter((a) => !(a.scope === "once" && a.subject === spentSubject));
320
+
321
+ if (!outcome.ok) {
322
+ // **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
323
+ // never what an orchestrator wants. Everything completed is still returned — R-03's rule.
324
+ aborted = true;
325
+ for (let rest = index + 1; rest < children.length; rest += 1) children[rest].state = "failed";
326
+ break;
327
+ }
328
+ previous = outcome.text;
329
+ }
330
+ paint.flush();
331
+
332
+ const report = outcomes
333
+ .map((o) => {
334
+ const label = `### step ${o.step}${o.agent ? ` (${o.agent})` : ""}`;
335
+ return o.ok ? `${label} — completed\n\n${o.text || "(no output)"}` : `${label} — FAILED: ${o.reason}${o.text ? `\n\n${o.text}` : ""}`;
336
+ })
337
+ .join("\n\n---\n\n");
338
+
339
+ const skipped = steps.length - outcomes.length;
340
+ const tail = aborted
341
+ ? `\n\n---\n\n**The chain stopped at step ${outcomes.length}.** ${skipped} later step(s) did not run, ` +
342
+ `because each one's task is built from the previous step's output and there was none to pass on.`
343
+ : "";
344
+
345
+ if (outcomes.length === 1 && !outcomes[0].ok) {
346
+ // Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
347
+ // nothing ran is how a wrong summary gets written.
348
+ throw new Error(`chain failed at its first step.\n\n${report}`);
349
+ }
350
+
351
+ return {
352
+ content: [{ type: "text", text: `${report}${tail}` }],
353
+ details: { steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted, budgetPerStep: split.perChild },
354
+ };
355
+ },
356
+ });
357
+ }
@@ -13,9 +13,91 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
14
  import { maySpawnDefinition } from "../src/delegate.ts";
15
15
  import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
16
+ import {
17
+ PAINT_INTERVAL_MS,
18
+ appendTail,
19
+ emptyTail,
20
+ renderProgress,
21
+ replaceTail,
22
+ throttle,
23
+ type ChildProgress,
24
+ } from "../src/progress.ts";
25
+ import { registerChainTool } from "./delegate-chain.ts";
16
26
  import { runOneDelegation } from "./run-delegation.ts";
17
27
  import { type GrantsSession } from "./session.ts";
18
28
 
29
+ /**
30
+ * Wire a set of children to pi's partial-result channel — ADR-0032.
31
+ *
32
+ * **One block for the whole call**, not one per child: `onUpdate` replaces the tool's rendered result, so N
33
+ * independent painters would each overwrite the others and the operator would see one child flickering.
34
+ * `delegate` is simply the one-child case of the same thing, which is why both tools come through here.
35
+ *
36
+ * Painting is throttled, and the trailing frame matters more than the throttling: without it the final paint —
37
+ * the one showing every child settled — is the one most likely to be dropped, so the block would freeze
38
+ * mid-run. `flush()` at the end is what guarantees the last state is the one left on screen.
39
+ *
40
+ * **Nothing here is the result.** The tool's content still comes from each child's returned text.
41
+ */
42
+ function progressReporter(
43
+ session: GrantsSession,
44
+ labels: string[],
45
+ onUpdate: ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined,
46
+ ) {
47
+ const started = Date.now();
48
+ const children: ChildProgress[] = labels.map((label) => ({
49
+ label,
50
+ state: "starting",
51
+ startedAt: started,
52
+ tail: emptyTail,
53
+ }));
54
+
55
+ const paint = throttle(() => {
56
+ onUpdate?.({
57
+ content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
58
+ });
59
+ }, PAINT_INTERVAL_MS);
60
+
61
+ return {
62
+ /** One sink per child index, shaped for `runOneDelegation`'s `onProgress`. */
63
+ sink: (index: number) => (update: {
64
+ chunk?: string;
65
+ snapshot?: string[];
66
+ paneId?: string;
67
+ agentName?: string;
68
+ state?: ChildProgress["state"];
69
+ }) => {
70
+ const child = children[index];
71
+ if (!child) return;
72
+ if (update.paneId) child.paneId = update.paneId;
73
+ // **Reported, not derived.** It used to be rebuilt here from the label and the child id, which was true
74
+ // until `runHerdrPane` began uniquifying the name to stop herdr's `agent_name_taken` — after which the
75
+ // block named an agent that did not exist, and an operator following it would find nothing. The name is
76
+ // known only where it is minted, so it travels from there.
77
+ if (update.agentName) child.agentName = update.agentName;
78
+ // Two shapes, deliberately distinct, because the two executors are: a subprocess APPENDS bytes, a pane
79
+ // reports a SNAPSHOT that replaces. Collapsing them into one "output" field is what caused the herdr
80
+ // path's amplification, so the difference is spelled out in the type rather than remembered.
81
+ if (update.chunk) child.tail = appendTail(child.tail, update.chunk);
82
+ if (update.snapshot) child.tail = replaceTail(update.snapshot);
83
+ if (update.state) child.state = update.state;
84
+ else if (child.state === "starting") child.state = "running";
85
+ paint.call();
86
+ },
87
+ /** Record how each child ended and leave the final frame on screen. */
88
+ settle: (outcomes: Array<{ ok: boolean }>) => {
89
+ const at = Date.now();
90
+ outcomes.forEach((outcome, index) => {
91
+ const child = children[index];
92
+ if (!child) return;
93
+ child.state = outcome.ok ? "completed" : "failed";
94
+ child.settledAt = at;
95
+ });
96
+ paint.flush();
97
+ },
98
+ };
99
+ }
100
+
19
101
  export interface DelegationRegistration {
20
102
  /**
21
103
  * Re-derive "which definitions may this session spawn?" into the registered tool schemas.
@@ -113,7 +195,10 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
113
195
  "written by the operator. Use 'tools' only when no definition fits. Grant 'delegate' if the " +
114
196
  "sub-agent must itself delegate further; withhold it to make the sub-agent a leaf.",
115
197
  parameters: delegateParams,
116
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
198
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
199
+ // ADR-0032: one child, same block. `_onUpdate` was discarded here, so a delegation showed the bare word
200
+ // `delegate` for up to DEFAULT_TIMEOUT_MS — ten minutes of nothing.
201
+ const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
117
202
  const outcome = await runOneDelegation(
118
203
  session,
119
204
  { task: params.task, agent: params.agent, tools: params.tools, model: params.model },
@@ -124,7 +209,9 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
124
209
  session.fanoutBudget,
125
210
  ctx,
126
211
  signal,
212
+ { onProgress: progress.sink(0) },
127
213
  );
214
+ progress.settle([outcome]);
128
215
 
129
216
  if (!outcome.ok) {
130
217
  // THROW, do not return. `AgentToolResult` has no `isError` field: pi sets it only when `execute`
@@ -168,7 +255,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
168
255
  "when independent tasks can proceed in parallel — several reviewers over one diff, say — and read " +
169
256
  "every child's outcome, because one can be refused while the others succeed.",
170
257
  parameters: delegateAllParams,
171
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
258
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
172
259
  const children = params.children ?? [];
173
260
  const split = splitBudget(session.fanoutBudget, children.length);
174
261
  if (!split.ok) {
@@ -177,6 +264,10 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
177
264
  throw new Error(`fan-out refused: ${split.reason}`);
178
265
  }
179
266
 
267
+ // ADR-0032: ONE status block covering every child. `onUpdate` replaces the tool's rendered result, so a
268
+ // painter per child would have each overwriting the others.
269
+ const progress = progressReporter(session, children.map((c) => c.agent ?? "delegate"), onUpdate as never);
270
+
180
271
  // Concurrent by construction. Each child gets its own budget share and its own ledger id, so the
181
272
  // records form a tree and two siblings can never be confused for one another.
182
273
  const outcomes = await Promise.all(
@@ -188,9 +279,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
188
279
  split.perChild,
189
280
  ctx,
190
281
  signal,
282
+ { onProgress: progress.sink(index) },
191
283
  ),
192
284
  ),
193
285
  );
286
+ progress.settle(outcomes);
194
287
 
195
288
  const failed = outcomes.filter((o) => !o.ok);
196
289
  // Every child is reported, including the ones that failed. R-03's rule: a missing result must never
@@ -223,6 +316,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
223
316
  },
224
317
  });
225
318
 
319
+ // ADR-0033. Registered here so all three tools appear together and share the `mayDelegate` guard, but its logic
320
+ // lives in its own file: `delegate` and `delegate_all` differ only in cardinality, while a chain differs in
321
+ // composition, and this file is near the 400-line ceiling.
322
+ registerChainTool(pi, session);
323
+
226
324
  return {
227
325
  // Written through the CONSTRUCTED schema (`properties.agent`) rather than the object handed to
228
326
  // `Type.Optional`, because `Optional` shallow-copies — mutating the input would update a discarded
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { Capability } from "../src/resolve.ts";
16
+ import type { ExecutorChoice } from "../src/executor.ts";
16
17
  import type { Catalog } from "../src/catalog.ts";
17
18
  import type { SkillDefinition } from "../src/definitions.ts";
18
19
  import type { GatedPlan } from "./run-delegation.ts";
@@ -23,6 +24,13 @@ export interface GrantsCommandContext {
23
24
  cwd: string;
24
25
  governed: boolean;
25
26
  ownGrant: Capability[];
27
+ /**
28
+ * Which executor this session settled on, and why — ADR-0031.
29
+ *
30
+ * The whole `ExecutorChoice` rather than a rendered string, so this command cannot compose a different
31
+ * sentence from the one the session banner printed. Two spellings of one fact is R-28.
32
+ */
33
+ executor: ExecutorChoice;
26
34
  observed: boolean;
27
35
  depth: number;
28
36
  maxDepth: number;
@@ -71,7 +79,7 @@ handler: async (args: string, ctx: any) => {
71
79
  // Everything this command may see, named in one place. Previously these were whatever happened to be in
72
80
  // the enclosing closure — which is how a diagnostic came to disagree with the enforcer (R-28).
73
81
  const {
74
- cwd, governed, ownGrant, observed, depth, maxDepth, ledgerPath,
82
+ cwd, governed, ownGrant, executor, observed, depth, maxDepth, ledgerPath,
75
83
  catalog, definitions, sessionApprovals, inheritedApprovals, snapshotOf, previewDelegation,
76
84
  } = ctx.grants as GrantsCommandContext;
77
85
 
@@ -102,6 +110,17 @@ handler: async (args: string, ctx: any) => {
102
110
  ` integrity ${report.ok ? "OK" : `${report.corrupt.length} UNPARSEABLE LINE(S)`}`,
103
111
  ];
104
112
  for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.text}`);
113
+ // ADR-0031, and R-51's lesson applied on the day the field was added rather than a release later: a field
114
+ // the writer sets and no diagnostic reads is one that needs `jq`, and `docs/SPEC.md` claims the executor is
115
+ // announced "per child in the ledger". `unknown` is shown only when present, because on a fresh ledger it
116
+ // is always zero and a permanent zero is noise; on an upgraded one it is the count of pre-0.16 lines, which
117
+ // is worth seeing.
118
+ lines.push(
119
+ ` executors ${report.executors.herdr} herdr pane(s) · ${report.executors.process} subprocess(es)` +
120
+ (report.executors.unknown > 0
121
+ ? ` · ${report.executors.unknown} not recorded (written before 0.16.0, which added the field)`
122
+ : ""),
123
+ );
105
124
  // R-51. ADR-0018 advertises that the ledger answers "did these four children run the same
106
125
  // instructions?" and "has this definition changed since?" — and nothing read `definitionDigest`, so
107
126
  // both needed hand-written jq and the second was not reproducible with `sha256sum` (the digest covers
@@ -300,6 +319,12 @@ handler: async (args: string, ctx: any) => {
300
319
  const lines = [
301
320
  governed ? "grants: ACTIVE" : "grants: inactive (set PI_GRANTS_GRANT to govern this session)",
302
321
  ` holding ${ownGrant.join(", ") || "(nothing)"}${observed ? " (observed)" : " (inherited, not yet observed)"}`,
322
+ // ADR-0031/0032. This screen named the grant, the depth, the ledger, the approvals and the catalog, and
323
+ // never said WHERE children run — so an operator on a machine where herdr hosts their whole workspace
324
+ // could not discover that their children were invisible subprocesses. That gap is what produced
325
+ // ADR-0031, and its probe is only defensible because this line exists. Placed next to `holding` so the
326
+ // two facts about what a spawn will be sit together.
327
+ ` executor ${executor.disclosure}`,
303
328
  ` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
304
329
  ` ledger ${ledgerPath ?? "(not recording — set PI_GRANTS_LEDGER)"}`,
305
330
  ` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +