pi-daddy 0.47.1 → 0.48.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 (79) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +26 -1
  3. package/dist/executors/child-final.d.ts +3 -0
  4. package/dist/executors/child-final.d.ts.map +1 -1
  5. package/dist/executors/child-final.js +13 -1
  6. package/dist/executors/child-final.js.map +1 -1
  7. package/dist/governance/auto-mode-policy.d.ts.map +1 -1
  8. package/dist/governance/auto-mode-policy.js +32 -1
  9. package/dist/governance/auto-mode-policy.js.map +1 -1
  10. package/dist/governance/dispatch-operation-client.d.ts +4 -0
  11. package/dist/governance/dispatch-operation-client.d.ts.map +1 -0
  12. package/dist/governance/dispatch-operation-client.js +93 -0
  13. package/dist/governance/dispatch-operation-client.js.map +1 -0
  14. package/dist/governance/dispatch-operations.d.ts +17 -0
  15. package/dist/governance/dispatch-operations.d.ts.map +1 -0
  16. package/dist/governance/dispatch-operations.js +81 -0
  17. package/dist/governance/dispatch-operations.js.map +1 -0
  18. package/dist/governance/execution-retention.d.ts +5 -4
  19. package/dist/governance/execution-retention.d.ts.map +1 -1
  20. package/dist/governance/execution-retention.js +3 -1
  21. package/dist/governance/execution-retention.js.map +1 -1
  22. package/dist/governance/native-session.d.ts +10 -1
  23. package/dist/governance/native-session.d.ts.map +1 -1
  24. package/dist/governance/native-session.js +15 -0
  25. package/dist/governance/native-session.js.map +1 -1
  26. package/dist/governance/retention-contract.d.ts +30 -0
  27. package/dist/governance/retention-contract.d.ts.map +1 -1
  28. package/dist/governance/retention-contract.js +9 -4
  29. package/dist/governance/retention-contract.js.map +1 -1
  30. package/dist/kernel/auto-mode.d.ts +1 -0
  31. package/dist/kernel/auto-mode.d.ts.map +1 -1
  32. package/dist/kernel/auto-mode.js.map +1 -1
  33. package/dist/kernel/chain.d.ts +1 -0
  34. package/dist/kernel/chain.d.ts.map +1 -1
  35. package/dist/kernel/chain.js.map +1 -1
  36. package/dist/kernel/delegate.d.ts.map +1 -1
  37. package/dist/kernel/delegate.js +8 -0
  38. package/dist/kernel/delegate.js.map +1 -1
  39. package/dist/kernel/dispatch-operation.d.ts +47 -0
  40. package/dist/kernel/dispatch-operation.d.ts.map +1 -0
  41. package/dist/kernel/dispatch-operation.js +4 -0
  42. package/dist/kernel/dispatch-operation.js.map +1 -0
  43. package/dist/kernel/propagation.d.ts +4 -1
  44. package/dist/kernel/propagation.d.ts.map +1 -1
  45. package/dist/kernel/propagation.js +11 -1
  46. package/dist/kernel/propagation.js.map +1 -1
  47. package/dist/products/activity-timeline.d.ts +8 -0
  48. package/dist/products/activity-timeline.d.ts.map +1 -1
  49. package/dist/products/activity-timeline.js +8 -1
  50. package/dist/products/activity-timeline.js.map +1 -1
  51. package/dist/products/dashboard-screen.d.ts +1 -0
  52. package/dist/products/dashboard-screen.d.ts.map +1 -1
  53. package/dist/products/dashboard-screen.js +20 -2
  54. package/dist/products/dashboard-screen.js.map +1 -1
  55. package/extensions/activity-timeline.ts +12 -2
  56. package/extensions/delegate-chain.ts +46 -4
  57. package/extensions/delegation-operations.ts +171 -0
  58. package/extensions/delegation.ts +34 -10
  59. package/extensions/execute-child.ts +21 -3
  60. package/extensions/execution-evidence.ts +3 -0
  61. package/extensions/fanout-outcome.ts +1 -0
  62. package/extensions/grants.ts +2 -3
  63. package/extensions/run-delegation.ts +58 -3
  64. package/extensions/runtime-snapshot.ts +59 -1
  65. package/package.json +1 -1
  66. package/src/executors/child-final.ts +17 -1
  67. package/src/governance/auto-mode-policy.ts +27 -1
  68. package/src/governance/dispatch-operation-client.ts +96 -0
  69. package/src/governance/dispatch-operations.ts +81 -0
  70. package/src/governance/execution-retention.ts +7 -3
  71. package/src/governance/native-session.ts +28 -1
  72. package/src/governance/retention-contract.ts +9 -4
  73. package/src/kernel/auto-mode.ts +1 -0
  74. package/src/kernel/chain.ts +1 -0
  75. package/src/kernel/delegate.ts +14 -0
  76. package/src/kernel/dispatch-operation.ts +38 -0
  77. package/src/kernel/propagation.ts +18 -1
  78. package/src/products/activity-timeline.ts +10 -0
  79. package/src/products/dashboard-screen.ts +21 -2
@@ -0,0 +1,171 @@
1
+ /** Non-authorizing dispatch election. The elected caller still runs the ordinary governance path. */
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import { Type } from "typebox";
5
+ import { shouldSeekApproval } from "../src/kernel/approval.ts";
6
+ import { planDelegation } from "../src/kernel/delegate.ts";
7
+ import type { OperationClaim } from "../src/kernel/dispatch-operation.ts";
8
+ import { validOperationId } from "../src/kernel/dispatch-operation.ts";
9
+ import { GovernanceRefusal } from "../src/kernel/refusals.ts";
10
+ import { assertDefinitionIdentity } from "./definition-describe.ts";
11
+ import { assertDelegationAuthority } from "./delegation-authority.ts";
12
+ import { recordDelegationDecision } from "./delegation-ledger.ts";
13
+ import type { GrantsSession } from "./session.ts";
14
+ import type { ChildSpec, DelegationToolContext } from "./run-delegation.ts";
15
+ import type { ExecutionOccurrenceIds } from "./execution-occurrence.ts";
16
+ import type { DelegationOutcome } from "./execute-child.ts";
17
+
18
+ export const operationIdShape = () =>
19
+ Type.Optional(
20
+ Type.String({
21
+ minLength: 1,
22
+ maxLength: 160,
23
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$",
24
+ description:
25
+ "Stable caller operation ID. Identical concurrent dispatch returns its existing reference; terminal IDs never replay work. Use a new ID only for an intentional new attempt.",
26
+ }),
27
+ );
28
+ function canonical(value: unknown): unknown {
29
+ if (Array.isArray(value)) return value.map(canonical);
30
+ if (value && typeof value === "object")
31
+ return Object.fromEntries(
32
+ Object.entries(value)
33
+ .filter(([, v]) => v !== undefined)
34
+ .sort(([a], [b]) => a.localeCompare(b))
35
+ .map(([k, v]) => [k, canonical(v)]),
36
+ );
37
+ return value;
38
+ }
39
+ export async function claimDelegationOperation(
40
+ session: GrantsSession,
41
+ spec: ChildSpec,
42
+ ids: ExecutionOccurrenceIds,
43
+ ctx: DelegationToolContext,
44
+ signal?: AbortSignal,
45
+ ): Promise<OperationClaim | undefined> {
46
+ if (spec.operation_id === undefined) return undefined;
47
+ assertDelegationAuthority(session);
48
+ if (!validOperationId(spec.operation_id)) throw Error("Invalid operation_id");
49
+ if (signal?.aborted) throw Error("Operation request aborted");
50
+ await session.ensureDefinitions?.();
51
+ assertDefinitionIdentity(session, spec);
52
+ const context = await session.delegationContext();
53
+ const model = spec.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
54
+ const preview = planDelegation(
55
+ { ...spec, model, boundWorkspaceId: spec.workspace?.workspace_id },
56
+ {
57
+ ...context,
58
+ stageHandoff: undefined,
59
+ fanoutBudget: 0,
60
+ spawnId: ids.parentId,
61
+ childSpawnId: ids.childId,
62
+ childExecutionId: ids.executionId,
63
+ },
64
+ );
65
+ if (!preview.ok && !shouldSeekApproval(preview.result)) {
66
+ await recordDelegationDecision({ session, plan: preview, ids, agent: spec.agent });
67
+ if (preview.refusal) throw new GovernanceRefusal(preview.refusal);
68
+ throw Error(preview.reason ?? "Operation request is not authorized");
69
+ }
70
+ const operations = session.autoMode?.operations;
71
+ if (!operations) throw Error("Session operation admission is unavailable; no dispatch started");
72
+ const cwd = await realpath(ctx.cwd);
73
+ const { operation_id: _id, ...request } = spec;
74
+ const requestDigest = createHash("sha256")
75
+ .update(
76
+ JSON.stringify(
77
+ canonical({
78
+ request,
79
+ cwd,
80
+ model,
81
+ piThinking: session.currentThinking?.(),
82
+ definitionDigest: preview.definitionDigest,
83
+ grant: [...session.ownGrant].sort(),
84
+ gated: [...session.gated].sort(),
85
+ depth: session.depth,
86
+ maxDepth: session.maxDepth,
87
+ executor: session.executor.kind,
88
+ definition: spec.agent ? session.definitions.get(spec.agent)?.body : null,
89
+ runtimeSettings: session.definitionRuntimeSettings,
90
+ runtimeOverrides: [...session.definitionRuntimeOverrides.entries()].sort(([a], [b]) => a.localeCompare(b)),
91
+ workspaceRoot: spec.workspace ? session.workspacePin?.get(spec.workspace.workspace_id) : null,
92
+ }),
93
+ ),
94
+ )
95
+ .digest("hex");
96
+ return operations.claim(
97
+ {
98
+ operationId: spec.operation_id,
99
+ requestDigest,
100
+ executionId: ids.executionId,
101
+ cwd,
102
+ workspaceId: spec.workspace?.workspace_id ?? null,
103
+ },
104
+ signal,
105
+ );
106
+ }
107
+ export function existingOperationOutcome(claim: OperationClaim, depth: number): DelegationOutcome {
108
+ const active = ["admitting", "running"].includes(claim.operation.state);
109
+ return {
110
+ ok: false,
111
+ work: "unknown",
112
+ text:
113
+ `No new child started. Operation ${claim.operation.operationId} already exists (${claim.operation.state}), execution ${claim.operation.executionId}. ` +
114
+ (active
115
+ ? "Follow the original operation; this reference is not its final result."
116
+ : "No mutation result was replayed. Inspect the original outcome before an intentional new attempt with a new operation_id."),
117
+ reason: active ? "existing-operation" : "operation-already-ended",
118
+ granted: [],
119
+ depth,
120
+ exitCode: null,
121
+ operation: { ...claim.operation, reused: true },
122
+ };
123
+ }
124
+ export async function finishDelegationOperation(claim: OperationClaim, result: DelegationOutcome) {
125
+ const cleanup = result.cleanup;
126
+ const state =
127
+ cleanup?.state === "settled"
128
+ ? "settled"
129
+ : cleanup?.state === "not-started" || (!result.work && !result.final && !cleanup)
130
+ ? "not-started"
131
+ : "uncertain";
132
+ await claim.finish!(state, {
133
+ work: result.work,
134
+ control: result.control,
135
+ ...(result.final
136
+ ? {
137
+ final:
138
+ result.final.state === "complete"
139
+ ? {
140
+ state: result.final.state,
141
+ sessionId: result.final.sessionId,
142
+ messageId: result.final.messageId,
143
+ leafId: result.final.leafId,
144
+ sha256: result.final.sha256,
145
+ }
146
+ : { state: result.final.state },
147
+ }
148
+ : {}),
149
+ ...(state === "not-started" && !cleanup ? { cleanup: { state: "not-started" } } : {}),
150
+ ...(cleanup
151
+ ? {
152
+ cleanup: {
153
+ state: cleanup.state,
154
+ ...(cleanup.state === "settled"
155
+ ? {
156
+ receiptPath: cleanup.identity.receiptPath,
157
+ executionId: cleanup.identity.executionId,
158
+ }
159
+ : {}),
160
+ },
161
+ }
162
+ : {}),
163
+ });
164
+ result.operation = {
165
+ ...claim.operation,
166
+ state,
167
+ cwd: result.cwd ?? claim.operation.cwd,
168
+ workspaceId: result.workspaceId ?? claim.operation.workspaceId,
169
+ reused: false,
170
+ };
171
+ }
@@ -1,3 +1,4 @@
1
+ import { operationIdShape, claimDelegationOperation } from "./delegation-operations.ts";
1
2
  import { publicEvidenceCall } from "./public-evidence.ts";
2
3
  import { executionEvidenceContent } from "./execution-evidence.ts";
3
4
  import { reconcileDelegationCapacity, reserveDelegationCapacity } from "./session-capacity.ts";
@@ -190,6 +191,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
190
191
  ),
191
192
  );
192
193
  const childShape = Type.Object({
194
+ operation_id: operationIdShape(),
193
195
  definitionId: Type.Optional(
194
196
  Type.String({ description: "Snapshot id returned by delegate_describe; required for Principal phases." }),
195
197
  ),
@@ -212,6 +214,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
212
214
  });
213
215
 
214
216
  const delegateParams = Type.Object({
217
+ operation_id: operationIdShape(),
215
218
  definitionId: Type.Optional(
216
219
  Type.String({ description: "Snapshot id returned by delegate_describe; required for Principal phases." }),
217
220
  ),
@@ -255,16 +258,18 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
255
258
  // `delegate` for up to DEFAULT_TIMEOUT_MS — sixty minutes by default.
256
259
  const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
257
260
  const occurrence = newDelegationOccurrence(session, 0);
261
+ const operationClaim = await claimDelegationOperation(session, params, occurrence, ctx, signal);
258
262
  const capture = publicEvidenceCall(
259
263
  session.publicEvidence,
260
264
  _toolCallId,
261
265
  "delegate",
262
266
  [params],
263
- [occurrence.executionId],
267
+ [operationClaim?.operation.executionId ?? occurrence.executionId],
264
268
  );
265
269
  const outcome = await runOneDelegation(
266
270
  session,
267
271
  {
272
+ operation_id: params.operation_id,
268
273
  definitionId: params.definitionId,
269
274
  task: params.task,
270
275
  agent: params.agent,
@@ -281,6 +286,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
281
286
  ctx,
282
287
  signal,
283
288
  {
289
+ operationClaim,
284
290
  onProgress: progress.sink(0),
285
291
  toolCallId: _toolCallId,
286
292
  onDefinition: (definition) => capture.selected(0, definition),
@@ -296,7 +302,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
296
302
  throw new Error(message);
297
303
  }
298
304
  // Pi 1.0.4 preserves returned isError. Keep every outcome dimension and useful output together.
299
- const failed = !outcome.ok || outcome.control === "failed";
305
+ const failed = !outcome.operation?.reused && (!outcome.ok || outcome.control === "failed");
300
306
  return capture.finish(
301
307
  {
302
308
  isError: failed,
@@ -348,8 +354,19 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
348
354
  const children = params.children ?? [];
349
355
  if (session.capacityRefusal) throw new GovernanceRefusal(session.capacityRefusal);
350
356
  await reconcileDelegationCapacity(session);
351
- const split = splitBudget(session.capacity.available, children.length);
357
+ const occurrences = children.map((_, index) => newDelegationOccurrence(session, index));
358
+ const claims: Array<import("../src/kernel/dispatch-operation.ts").OperationClaim | undefined> = [];
359
+ try {
360
+ for (const [index, child] of children.entries())
361
+ claims.push(await claimDelegationOperation(session, child, occurrences[index], ctx, signal));
362
+ } catch (error) {
363
+ await Promise.all(claims.map((claim) => claim?.finish?.("not-started")));
364
+ throw error;
365
+ }
366
+ const fresh = children.filter((_, index) => !claims[index]?.reused).length;
367
+ const split = fresh === 0 ? { ok: true as const, perChild: 0 } : splitBudget(session.capacity.available, fresh);
352
368
  if (!split.ok) {
369
+ await Promise.all(claims.map((claim) => claim?.finish?.("not-started")));
353
370
  // Thrown, not returned: a returned `isError` is discarded by pi, so a refusal that came back as a
354
371
  // normal result would read to the orchestrator as a successful fan-out of zero children.
355
372
  throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `fan-out refused: ${split.reason}`));
@@ -370,25 +387,32 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
370
387
  // live and its writer lease is deliberately retained" — a resource-retention notice, not a per-child
371
388
  // failure. Losing it meant nobody was told a lease is held with no owner until the process exits
372
389
  // (R-116).
373
- const infrastructureErrors: unknown[] = [],
374
- occurrences = children.map((_, index) => newDelegationOccurrence(session, index));
390
+ const infrastructureErrors: unknown[] = [];
375
391
  const capture = publicEvidenceCall(
376
392
  session.publicEvidence,
377
393
  _toolCallId,
378
394
  "delegate_all",
379
395
  children,
380
- occurrences.map((row) => row.executionId),
396
+ occurrences.map((row, index) => claims[index]?.operation.executionId ?? row.executionId),
381
397
  );
382
398
  const pending = children.map(async (child, index): Promise<DelegationOutcome> => {
399
+ let enteredDelegation = false;
383
400
  try {
401
+ // Reserve each batch share synchronously here, before another dispatch can interleave.
402
+ const capacityReservation = claims[index]?.reused
403
+ ? undefined
404
+ : reserveDelegationCapacity(session, occurrences[index].executionId, split.perChild);
405
+ enteredDelegation = true;
384
406
  return await runOneDelegation(session, child, occurrences[index], split.perChild, ctx, signal, {
385
- // Reserve each batch share synchronously here, before another dispatch can interleave.
386
- capacityReservation: reserveDelegationCapacity(session, occurrences[index].executionId, split.perChild),
407
+ operationClaim: claims[index],
408
+ capacityReservation,
387
409
  onDefinition: (definition) => capture.selected(index, definition),
388
410
  onProgress: progress.sink(index),
389
411
  toolCallId: _toolCallId,
390
412
  });
391
413
  } catch (error) {
414
+ if (!enteredDelegation)
415
+ await claims[index]?.finish?.("not-started", { cleanup: { state: "not-started" } }).catch(() => undefined);
392
416
  infrastructureErrors.push(error);
393
417
  return childFailureOutcome(error, session.depth + 1);
394
418
  }
@@ -401,7 +425,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
401
425
  // critical block landed in the same fan-out (R-117).
402
426
  throwFanoutInfrastructure(outcomes, infrastructureErrors);
403
427
 
404
- const failed = outcomes.filter((o) => !o.ok);
428
+ const failed = outcomes.filter((o) => !o.ok && !o.operation?.reused);
405
429
  // Every child is reported, including the ones that failed. R-03's rule: a missing result must never
406
430
  // be indistinguishable from an empty one, and a fan-out that hid its refusals would let an
407
431
  // orchestrator summarise four reviews when only three happened.
@@ -411,7 +435,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
411
435
 
412
436
  return capture.finish(
413
437
  {
414
- isError: outcomes.some((o) => !o.ok || o.control === "failed"),
438
+ isError: outcomes.some((o) => !o.operation?.reused && (!o.ok || o.control === "failed")),
415
439
  content: [
416
440
  { type: "text", text: report },
417
441
  executionEvidenceContent("delegate_all", outcomes, children.length),
@@ -1,9 +1,10 @@
1
+ import { realpath } from "node:fs/promises";
1
2
  import { BoundedReadCleanupError } from "../src/kernel/bounded-read.ts";
2
3
  import { retainDiscoveryCleanupFailure } from "./session.ts";
3
4
  import type { CapacityReservation } from "../src/kernel/capacity.ts";
4
5
  import { runHerdrOwned } from "../src/executors/herdr-owned.ts";
5
6
  import { runCapturedExecution } from "../src/executors/captured-execution.ts";
6
- import type { ChildFinal } from "../src/executors/child-final.ts";
7
+ import { capturedFinalSession, type ChildFinal } from "../src/executors/child-final.ts";
7
8
  import type { CapturedWorkerCleanup } from "../src/kernel/captured-worker-contract.ts";
8
9
  import type { Delegation } from "../src/kernel/delegate.ts";
9
10
  import {
@@ -33,6 +34,9 @@ import { releaseDelegationWorkspace, type PreparedWorkspace } from "./workspace-
33
34
  import { ActivityTimelineRecorder, ENV_ACTIVITY_PARENT_TASK } from "../src/products/activity-timeline.ts";
34
35
  import { resolvedModelOf, type ResolvedDefinitionRuntime } from "./definition-runtime.ts";
35
36
  export interface DelegationOutcome {
37
+ operation?: import("../src/kernel/dispatch-operation.ts").OperationReference & { reused: boolean };
38
+ cwd?: string;
39
+ workspaceId?: string | null;
36
40
  ok: boolean;
37
41
  text: string;
38
42
  reason?: string;
@@ -121,6 +125,9 @@ export interface ChildProgressUpdate {
121
125
  * blaming "ledger" (R-99). The failure is reported alongside the outcome instead of replacing it.
122
126
  */
123
127
  export interface ChildExecutionInput {
128
+ operationClaim?: import("../src/kernel/dispatch-operation.ts").OperationClaim;
129
+ /** Composition-only launch observation; no permission or result authority. */
130
+ onExecutorEntry?: () => void;
124
131
  session: GrantsSession;
125
132
  capacityReservation?: CapacityReservation;
126
133
  plan: Delegation;
@@ -140,12 +147,14 @@ export async function executePlannedChild(input: ChildExecutionInput): Promise<D
140
147
  const runtime = input.session.reloadLifecycle?.runtimeSettlement;
141
148
  let registered = false;
142
149
  try {
150
+ input = { ...input, cwd: await realpath(input.preparedWorkspace?.workspace.root ?? input.cwd) };
143
151
  runtime?.begin(input.executionId);
144
152
  registered = Boolean(runtime);
145
153
  const result = await executePreparedChild(
146
154
  input,
147
155
  () => {
148
156
  executorEntered = true;
157
+ input.onExecutorEntry?.();
149
158
  },
150
159
  runtime,
151
160
  );
@@ -162,7 +171,11 @@ export async function executePlannedChild(input: ChildExecutionInput): Promise<D
162
171
  reason: [result.reason, `runtime settlement record failed: ${String(error)}`].filter(Boolean).join("; "),
163
172
  };
164
173
  }
165
- return result;
174
+ return {
175
+ ...result,
176
+ cwd: input.preparedWorkspace?.workspace.root ?? input.cwd,
177
+ workspaceId: input.preparedWorkspace?.workspace.workspaceId ?? null,
178
+ };
166
179
  } catch (error) {
167
180
  const failures: unknown[] = [error];
168
181
  if (registered)
@@ -336,6 +349,7 @@ async function executePreparedChild(
336
349
  const remainingTimeoutMs = Math.max(1, Date.parse(deadlineAt) - Date.now());
337
350
  const terminationGraceMs = Math.min(DEFAULT_KILL_GRACE_MS, Math.max(1, Math.floor(remainingTimeoutMs / 10)));
338
351
  const cwd = preparedWorkspace?.workspace.root ?? input.cwd;
352
+ await input.operationClaim?.started?.(cwd, preparedWorkspace?.workspace.workspaceId ?? null);
339
353
  const leaseAbort = new AbortController();
340
354
  const writerLease = preparedWorkspace?.lease.access === "write" ? preparedWorkspace.lease : undefined;
341
355
  // Tracked as a FACT, not only as an abort: "the kernel lock protecting this workspace evaporated under
@@ -460,7 +474,11 @@ async function executePreparedChild(
460
474
  : undefined,
461
475
  );
462
476
 
463
- if (sessionFlag >= 0) retention.observeSession({ source: "pi-session-file", value: plan.args[sessionFlag + 1] });
477
+ const capturedBranch = capturedFinalSession(output.final);
478
+ if (capturedBranch)
479
+ retention.observeSession({ source: "pi-captured-final", value: capturedBranch.path, capturedBranch });
480
+ else if (sessionFlag >= 0)
481
+ retention.observeSession({ source: "pi-session-file", value: plan.args[sessionFlag + 1] });
464
482
  retention.capture("result", Buffer.from(output.text), true);
465
483
  if ("diagnosticsTruncated" in output && output.diagnosticsTruncated)
466
484
  teardownFailures.push("diagnostic output truncated");
@@ -49,6 +49,9 @@ function outcomeEvidence(outcome: DelegationOutcome, index: number) {
49
49
  const final = outcome.final;
50
50
  return {
51
51
  ordinal: index + 1,
52
+ cwd: outcome.cwd ?? outcome.operation?.cwd ?? null,
53
+ workspaceId: outcome.workspaceId ?? outcome.operation?.workspaceId ?? null,
54
+ operation: outcome.operation ?? null,
52
55
  ok: outcome.ok,
53
56
  work: outcome.work ?? null,
54
57
  control: outcome.control ?? null,
@@ -43,6 +43,7 @@ export function buildFanoutReport(
43
43
  return outcomes
44
44
  .map((outcome, index) => {
45
45
  const label = `### child ${index + 1}${children[index]?.agent ? ` (${children[index].agent})` : ""}`;
46
+ if (outcome.operation?.reused) return `${label} — existing operation\n\n${outcome.text}`;
46
47
  return outcome.ok && outcome.control !== "failed"
47
48
  ? `${label} — completed\n\n${outcome.text || "(no output)"}`
48
49
  : `${label} — FAILED: ${outcome.reason}${outcome.text ? `\n\n${outcome.text}` : ""}`;
@@ -365,12 +365,11 @@ export default function (pi: ExtensionAPI, trustedSession?: GrantsSession) {
365
365
  return undefined;
366
366
  });
367
367
 
368
- // Observe this session's real tool surface once, and tighten the grant to it. Authoritative because
369
- // it is exactly what pi sent the model.
368
+ // Observe the native reachable tool surface, including callable tools whose declarations Codemode hides.
370
369
  pi.on("before_provider_request", (event, ctx) => {
371
370
  try {
372
371
  if (session.observed) return undefined;
373
- const names = observeToolNames(event.payload);
372
+ const names = observeToolNames(event.payload, pi.getAllTools(), pi.getActiveTools?.());
374
373
  if (names === null) return undefined;
375
374
  session.observed = true;
376
375
  session.observedTools = names;
@@ -1,3 +1,9 @@
1
+ import {
2
+ claimDelegationOperation,
3
+ existingOperationOutcome,
4
+ finishDelegationOperation,
5
+ } from "./delegation-operations.ts";
6
+ import type { OperationClaim } from "../src/kernel/dispatch-operation.ts";
1
7
  import { reconcileDelegationCapacity, reserveDelegationCapacity } from "./session-capacity.ts";
2
8
  import type { CapacityReservation } from "../src/kernel/capacity.ts";
3
9
  import { assertDefinitionIdentity } from "./definition-describe.ts";
@@ -43,7 +49,8 @@ import {
43
49
  } from "./workspace-runtime.ts";
44
50
 
45
51
  /** What one child was asked to do. The shape both tools accept, per child. */
46
- interface ChildSpec {
52
+ export interface ChildSpec {
53
+ operation_id?: string;
47
54
  definitionId?: string;
48
55
  task: string;
49
56
  agent?: string;
@@ -64,7 +71,7 @@ interface ChildSpec {
64
71
  * bridged the gap, which is the same "a value that was whatever happened to be in scope" shape the module
65
72
  * header lists four defects for.
66
73
  */
67
- interface DelegationToolContext extends ApprovalUIContext {
74
+ export interface DelegationToolContext extends ApprovalUIContext {
68
75
  cwd: string;
69
76
  model?: { provider: string; id: string };
70
77
  modelRegistry: ModelCatalogue;
@@ -242,7 +249,7 @@ export async function planWithApprovals(
242
249
  * was refused". `delegate` converts a failure back into a throw to keep its own contract, which matters:
243
250
  * `AgentToolResult` has no `isError` field, so a returned error is silently discarded by pi.
244
251
  */
245
- export async function runOneDelegation(
252
+ async function runOneDelegationImplementation(
246
253
  session: GrantsSession,
247
254
  spec: ChildSpec,
248
255
  ids: ExecutionOccurrenceIds,
@@ -265,6 +272,9 @@ export async function runOneDelegation(
265
272
  * mistake unspellable.
266
273
  */
267
274
  options: {
275
+ operationClaim?: OperationClaim;
276
+ /** Trusted observation at the actual executor boundary, not a caller/tool parameter. */
277
+ onExecutorEntry?: () => void;
268
278
  /** Trusted reservation made before a chain's upfront gate. */
269
279
  capacityReservation?: CapacityReservation;
270
280
  /** Exact pair selected during the chain preflight; never reselect after its approval. */
@@ -513,6 +523,8 @@ export async function runOneDelegation(
513
523
  executorEntered = true;
514
524
  return await executePlannedChild({
515
525
  capacityReservation: capacity,
526
+ operationClaim: options.operationClaim,
527
+ onExecutorEntry: options.onExecutorEntry,
516
528
  session,
517
529
  plan,
518
530
  agent: spec.agent,
@@ -534,3 +546,46 @@ export async function runOneDelegation(
534
546
  );
535
547
  }
536
548
  }
549
+
550
+ /** Duplicate references never enter the executor, consume capacity, or become chain input. */
551
+ export async function runOneDelegation(
552
+ ...args: Parameters<typeof runOneDelegationImplementation>
553
+ ): Promise<DelegationOutcome> {
554
+ const [session, spec, ids, , ctx, signal] = args;
555
+ const options = args[6] ?? {};
556
+ const claim = options.operationClaim ?? (await claimDelegationOperation(session, spec, ids, ctx, signal));
557
+ if (claim?.reused) {
558
+ options.capacityReservation?.finalize({ state: "not-started", reason: "existing operation reused" });
559
+ return existingOperationOutcome(claim, session.depth + 1);
560
+ }
561
+ let executorEntered = false;
562
+ try {
563
+ args[6] = {
564
+ ...options,
565
+ operationClaim: claim,
566
+ onExecutorEntry: () => {
567
+ executorEntered = true;
568
+ options.onExecutorEntry?.();
569
+ },
570
+ };
571
+ const result = await runOneDelegationImplementation(...args);
572
+ if (claim) {
573
+ try {
574
+ await finishDelegationOperation(claim, result);
575
+ } catch (error) {
576
+ // A post-execution observation failure must not erase the native final or settlement receipt.
577
+ result.control = "failed";
578
+ result.reason = [result.reason, `operation status update failed: ${String(error)}`].filter(Boolean).join("; ");
579
+ result.operation = { ...claim.operation, state: "uncertain", reused: false };
580
+ }
581
+ }
582
+ return result;
583
+ } catch (error) {
584
+ if (claim)
585
+ await claim.finish!(
586
+ executorEntered ? "uncertain" : "not-started",
587
+ executorEntered ? undefined : { cleanup: { state: "not-started" } },
588
+ ).catch(() => undefined);
589
+ throw error;
590
+ }
591
+ }
@@ -5,6 +5,7 @@ import { openRuntimeSettlement, settlementHash } from "../src/governance/runtime
5
5
  import { reconcileDelegationCapacity } from "./session-capacity.ts";
6
6
  import { agentDir } from "../src/kernel/project-paths.ts";
7
7
  import type { GrantsSession } from "./session.ts";
8
+ export const OPERATION_STATUS_EVENT = "pi-daddy:operation-status:v1";
8
9
  export const RUNTIME_SNAPSHOT_EVENT = "pi-daddy:runtime-snapshot:v1";
9
10
  export function registerRuntimeSnapshot(
10
11
  pi: ExtensionAPI,
@@ -15,7 +16,7 @@ export function registerRuntimeSnapshot(
15
16
  let bound: { sessionId: string; cwd: string; lifecycle: object } | undefined, problem: string | undefined;
16
17
  const generation = {};
17
18
  let bindSequence = 0;
18
- const unsubscribe = pi.events?.on?.(RUNTIME_SNAPSHOT_EVENT, (input: unknown) => {
19
+ const unsubscribeRuntime = pi.events?.on?.(RUNTIME_SNAPSHOT_EVENT, (input: unknown) => {
19
20
  const request = input as {
20
21
  version?: number;
21
22
  requestId?: string;
@@ -91,6 +92,63 @@ export function registerRuntimeSnapshot(
91
92
  }
92
93
  })().catch(() => undefined);
93
94
  });
95
+ const unsubscribeOperations = pi.events?.on?.(OPERATION_STATUS_EVENT, (input: unknown) => {
96
+ const request = input as {
97
+ version?: number;
98
+ requestId?: string;
99
+ sessionId?: string;
100
+ cwd?: string;
101
+ operationId?: string;
102
+ reply?: (value: unknown) => void;
103
+ };
104
+ if (
105
+ request?.version !== 1 ||
106
+ typeof request.requestId !== "string" ||
107
+ request.requestId.length > 512 ||
108
+ typeof request.operationId !== "string" ||
109
+ typeof request.reply !== "function"
110
+ )
111
+ return;
112
+ if (bound && session.reloadLifecycle.runtimeSnapshotGeneration !== generation) return;
113
+ void (async () => {
114
+ const current = bound,
115
+ lifecycle = session.reloadLifecycle;
116
+ const envelope = {
117
+ version: 1,
118
+ requestId: request.requestId,
119
+ sessionId: current?.sessionId ?? "",
120
+ cwd: current?.cwd ?? "",
121
+ operationId: request.operationId,
122
+ };
123
+ if (
124
+ !current ||
125
+ current.lifecycle !== lifecycle ||
126
+ request.sessionId !== current.sessionId ||
127
+ request.cwd !== current.cwd ||
128
+ !session.ownerBound ||
129
+ !session.autoMode?.operations
130
+ ) {
131
+ request.reply!({ ...envelope, qualified: false, operation: null, reason: "operation owner is not ready" });
132
+ return;
133
+ }
134
+ try {
135
+ const operation = await session.autoMode.operations.read(request.operationId!);
136
+ if (
137
+ bound !== current ||
138
+ session.reloadLifecycle !== lifecycle ||
139
+ lifecycle.runtimeSnapshotGeneration !== generation
140
+ )
141
+ throw Error("operation owner changed during read");
142
+ request.reply!({ ...envelope, qualified: true, operation });
143
+ } catch (error) {
144
+ request.reply!({ ...envelope, qualified: false, operation: null, reason: String(error) });
145
+ }
146
+ })().catch(() => undefined);
147
+ });
148
+ const unsubscribe = () => {
149
+ unsubscribeRuntime?.();
150
+ unsubscribeOperations?.();
151
+ };
94
152
  return {
95
153
  async bind(ctx) {
96
154
  const sequence = ++bindSequence,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { join } from "node:path";
5
5
  import { readdir } from "node:fs/promises";
6
6
  import { SessionManager, type FileEntry } from "@earendil-works/pi-coding-agent";
7
+ import type { CapturedSessionBranch } from "../governance/native-session.ts";
7
8
  import { BoundedReadCleanupError, readBoundedBytes } from "../kernel/bounded-read.ts";
8
9
  const MAX_FINAL_BYTES = 4 * 1024 * 1024,
9
10
  MAX_LINE_BYTES = 32 * 1024 * 1024,
@@ -13,6 +14,11 @@ const hash = (s: string) => createHash("sha256").update(s, "utf8").digest("hex")
13
14
  export type ChildFinal =
14
15
  | { state: "complete"; text: string; sessionId: string; messageId: string; leafId: string; sha256: string }
15
16
  | { state: "unavailable"; reason: string; diagnosticText?: string };
17
+ const capturedSessions = new WeakMap<ChildFinal, Readonly<CapturedSessionBranch>>();
18
+ /** Only the original in-process final carries this observation; JSON/copies do not recreate it. */
19
+ export function capturedFinalSession(final: ChildFinal): Readonly<CapturedSessionBranch> | undefined {
20
+ return capturedSessions.get(final);
21
+ }
16
22
  export class ChildFinalCapture {
17
23
  private decoder = new TextDecoder("utf-8", { fatal: true });
18
24
  private buffer = "";
@@ -171,7 +177,7 @@ export class ChildFinalCapture {
171
177
  !isDeepStrictEqual(currentUser.message, this.user)
172
178
  )
173
179
  throw Error("stream final does not match the current persisted branch and turn");
174
- return {
180
+ const complete: ChildFinal = {
175
181
  state: "complete",
176
182
  text,
177
183
  sessionId: this.sessionId,
@@ -179,6 +185,16 @@ export class ChildFinalCapture {
179
185
  leafId: manager.getLeafId()!,
180
186
  sha256: hash(text),
181
187
  };
188
+ capturedSessions.set(
189
+ complete,
190
+ Object.freeze({
191
+ path,
192
+ sessionId: this.sessionId,
193
+ leafId: complete.leafId,
194
+ sha256: createHash("sha256").update(read.bytes).digest("hex"),
195
+ }),
196
+ );
197
+ return complete;
182
198
  } catch (error) {
183
199
  if (error instanceof BoundedReadCleanupError) {
184
200
  if (!this.onReadCleanup) throw error;