pi-daddy 0.47.0 → 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 (87) hide show
  1. package/CHANGELOG.md +12 -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/ledger-report.d.ts.map +1 -1
  23. package/dist/governance/ledger-report.js +7 -0
  24. package/dist/governance/ledger-report.js.map +1 -1
  25. package/dist/governance/native-session.d.ts +10 -1
  26. package/dist/governance/native-session.d.ts.map +1 -1
  27. package/dist/governance/native-session.js +15 -0
  28. package/dist/governance/native-session.js.map +1 -1
  29. package/dist/governance/retention-contract.d.ts +30 -0
  30. package/dist/governance/retention-contract.d.ts.map +1 -1
  31. package/dist/governance/retention-contract.js +9 -4
  32. package/dist/governance/retention-contract.js.map +1 -1
  33. package/dist/kernel/auto-mode.d.ts +1 -0
  34. package/dist/kernel/auto-mode.d.ts.map +1 -1
  35. package/dist/kernel/auto-mode.js.map +1 -1
  36. package/dist/kernel/chain.d.ts +1 -0
  37. package/dist/kernel/chain.d.ts.map +1 -1
  38. package/dist/kernel/chain.js.map +1 -1
  39. package/dist/kernel/delegate.d.ts.map +1 -1
  40. package/dist/kernel/delegate.js +8 -0
  41. package/dist/kernel/delegate.js.map +1 -1
  42. package/dist/kernel/dispatch-operation.d.ts +47 -0
  43. package/dist/kernel/dispatch-operation.d.ts.map +1 -0
  44. package/dist/kernel/dispatch-operation.js +4 -0
  45. package/dist/kernel/dispatch-operation.js.map +1 -0
  46. package/dist/kernel/propagation.d.ts +4 -1
  47. package/dist/kernel/propagation.d.ts.map +1 -1
  48. package/dist/kernel/propagation.js +11 -1
  49. package/dist/kernel/propagation.js.map +1 -1
  50. package/dist/products/activity-timeline.d.ts +8 -0
  51. package/dist/products/activity-timeline.d.ts.map +1 -1
  52. package/dist/products/activity-timeline.js +8 -1
  53. package/dist/products/activity-timeline.js.map +1 -1
  54. package/dist/products/dashboard-cli.d.ts.map +1 -1
  55. package/dist/products/dashboard-cli.js +2 -1
  56. package/dist/products/dashboard-cli.js.map +1 -1
  57. package/dist/products/dashboard-screen.d.ts +1 -0
  58. package/dist/products/dashboard-screen.d.ts.map +1 -1
  59. package/dist/products/dashboard-screen.js +20 -2
  60. package/dist/products/dashboard-screen.js.map +1 -1
  61. package/extensions/activity-timeline.ts +12 -2
  62. package/extensions/delegate-chain.ts +46 -4
  63. package/extensions/delegation-operations.ts +171 -0
  64. package/extensions/delegation.ts +34 -10
  65. package/extensions/execute-child.ts +21 -3
  66. package/extensions/execution-evidence.ts +3 -0
  67. package/extensions/fanout-outcome.ts +1 -0
  68. package/extensions/grants.ts +2 -3
  69. package/extensions/run-delegation.ts +58 -3
  70. package/extensions/runtime-snapshot.ts +59 -1
  71. package/package.json +1 -1
  72. package/src/executors/child-final.ts +17 -1
  73. package/src/governance/auto-mode-policy.ts +27 -1
  74. package/src/governance/dispatch-operation-client.ts +96 -0
  75. package/src/governance/dispatch-operations.ts +81 -0
  76. package/src/governance/execution-retention.ts +7 -3
  77. package/src/governance/ledger-report.ts +9 -0
  78. package/src/governance/native-session.ts +28 -1
  79. package/src/governance/retention-contract.ts +9 -4
  80. package/src/kernel/auto-mode.ts +1 -0
  81. package/src/kernel/chain.ts +1 -0
  82. package/src/kernel/delegate.ts +14 -0
  83. package/src/kernel/dispatch-operation.ts +38 -0
  84. package/src/kernel/propagation.ts +18 -1
  85. package/src/products/activity-timeline.ts +10 -0
  86. package/src/products/dashboard-cli.ts +2 -1
  87. package/src/products/dashboard-screen.ts +21 -2
@@ -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.0",
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;
@@ -1,3 +1,5 @@
1
+ import { createDispatchOperations } from "./dispatch-operations.ts";
2
+ import { connectDispatchOperations } from "./dispatch-operation-client.ts";
1
3
  /** Root-owned live Auto authority. Descendants receive read/admit access, never mutation access. */
2
4
  import { randomBytes, randomUUID } from "node:crypto";
3
5
  import { chmod, unlink } from "node:fs/promises";
@@ -38,6 +40,7 @@ export async function createAutoModeAuthority(initial: {
38
40
  source = initial.source,
39
41
  revision = 0,
40
42
  closed = false;
43
+ const operations = createDispatchOperations();
41
44
  const pending = new Map<string, PendingApproval>();
42
45
  const waiters = new Set<() => void>();
43
46
  const sockets = new Set<Socket>();
@@ -92,12 +95,14 @@ export async function createAutoModeAuthority(initial: {
92
95
  const controller = new AbortController();
93
96
  let input = "",
94
97
  handled = false,
95
- untrack: (() => void) | undefined;
98
+ untrack: (() => void) | undefined,
99
+ abandonOperation: (() => void) | undefined;
96
100
  socket.on("error", () => {});
97
101
  socket.once("close", () => {
98
102
  sockets.delete(socket);
99
103
  controller.abort();
100
104
  untrack?.();
105
+ abandonOperation?.();
101
106
  });
102
107
  const reply = (value: unknown) => {
103
108
  if (!socket.destroyed) socket.end(JSON.stringify(value) + "\n");
@@ -129,6 +134,21 @@ export async function createAutoModeAuthority(initial: {
129
134
  } else if (request.action === "pending" && validPending(request.approval)) {
130
135
  socket.setTimeout(0);
131
136
  untrack = trackPending(request.approval);
137
+ } else if (request.action === "operation-claim") {
138
+ const value = operations.claim(request.request);
139
+ if (!value.reused) {
140
+ socket.setTimeout(0);
141
+ abandonOperation = () => operations.abandoned(value.operation.operationId, value.ticket!);
142
+ socket.write(JSON.stringify({ ok: true, value }) + "\n");
143
+ } else reply({ ok: true, value });
144
+ } else if (request.action === "operation-read") {
145
+ reply({ ok: true, value: operations.read(request.operationId) });
146
+ } else if (request.action === "operation-started") {
147
+ operations.started(request.operationId, request.ticket, request.cwd, request.workspaceId);
148
+ reply({ ok: true });
149
+ } else if (request.action === "operation-finish") {
150
+ operations.finish(request.operationId, request.ticket, request.state, request.runtime);
151
+ reply({ ok: true });
132
152
  } else throw new Error("Unknown Auto policy action");
133
153
  } catch (error) {
134
154
  reply({ ok: false, error: String(error) });
@@ -153,7 +173,9 @@ export async function createAutoModeAuthority(initial: {
153
173
  } catch {}
154
174
  };
155
175
  process.once("exit", exit);
176
+ const operationAccess = connectDispatchOperations(reference);
156
177
  return {
178
+ operations: operationAccess,
157
179
  reference,
158
180
  snapshot,
159
181
  read: async () => snapshot(),
@@ -175,6 +197,7 @@ export async function createAutoModeAuthority(initial: {
175
197
  close: async () => {
176
198
  if (closed) return;
177
199
  closed = true;
200
+ operationAccess.close();
178
201
  enabled = false;
179
202
  for (const wake of [...waiters]) wake();
180
203
  for (const socket of sockets) socket.destroy();
@@ -233,6 +256,7 @@ function request(ref: AutoModeRef, action: "read" | "admit" | "wait", signal?: A
233
256
  }
234
257
 
235
258
  export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
259
+ const operations = connectDispatchOperations(reference);
236
260
  let closed = false;
237
261
  const active = new Set<AbortController>();
238
262
  const pending = new Set<Socket>();
@@ -252,6 +276,7 @@ export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
252
276
  };
253
277
  return {
254
278
  reference,
279
+ operations,
255
280
  read: async () => {
256
281
  const value = (await call("read")) as AutoModeSnapshot;
257
282
  if (
@@ -290,6 +315,7 @@ export function connectAutoMode(reference: AutoModeRef): AutoModeReader {
290
315
  },
291
316
  close: async () => {
292
317
  closed = true;
318
+ operations.close();
293
319
  for (const controller of active) controller.abort();
294
320
  for (const socket of pending) socket.destroy();
295
321
  },
@@ -0,0 +1,96 @@
1
+ /** A held claim connection makes caller loss uncertain; another caller never inherits execution. */
2
+ import { createConnection, type Socket } from "node:net";
3
+ import type { AutoModeRef } from "../kernel/auto-mode.ts";
4
+ import type {
5
+ OperationAccess,
6
+ OperationClaim,
7
+ OperationReference,
8
+ OperationRequest,
9
+ } from "../kernel/dispatch-operation.ts";
10
+
11
+ export function connectDispatchOperations(reference: AutoModeRef): OperationAccess {
12
+ const sockets = new Set<Socket>();
13
+ let closed = false;
14
+ const request = (
15
+ action: string,
16
+ input: object,
17
+ signal?: AbortSignal,
18
+ hold = false,
19
+ ): Promise<{ value: any; socket: Socket }> =>
20
+ new Promise((resolve, reject) => {
21
+ if (closed || signal?.aborted) return reject(Error("Operation owner unavailable or request aborted"));
22
+ const socket = createConnection(reference.socketPath);
23
+ sockets.add(socket);
24
+ let output = "",
25
+ answered = false;
26
+ const cleanup = () => signal?.removeEventListener("abort", abort);
27
+ const fail = (error: Error) => {
28
+ cleanup();
29
+ socket.destroy();
30
+ if (!answered) {
31
+ answered = true;
32
+ reject(error);
33
+ }
34
+ };
35
+ const abort = () => fail(Error("Operation request aborted"));
36
+ signal?.addEventListener("abort", abort, { once: true });
37
+ socket.setEncoding("utf8");
38
+ socket.setTimeout(2000, () => fail(Error("Operation owner unavailable")));
39
+ socket.once("error", () => fail(Error("Operation owner unavailable")));
40
+ socket.once("connect", () => socket.write(JSON.stringify({ ...reference, action, ...input }) + "\n"));
41
+ socket.once("close", () => {
42
+ sockets.delete(socket);
43
+ cleanup();
44
+ if (!answered) fail(Error("Operation owner disconnected"));
45
+ });
46
+ socket.on("data", (chunk) => {
47
+ if (answered) return;
48
+ output += chunk;
49
+ if (output.length > 65536) return fail(Error("Operation response too large"));
50
+ const end = output.indexOf("\n");
51
+ if (end < 0) return;
52
+ try {
53
+ const reply = JSON.parse(output.slice(0, end));
54
+ if (reply.ok !== true) throw Error(reply.error ?? "Operation request failed");
55
+ answered = true;
56
+ cleanup();
57
+ socket.setTimeout(0);
58
+ if (!hold || reply.value?.reused) socket.destroy();
59
+ resolve({ value: reply.value, socket });
60
+ } catch (error) {
61
+ fail(error instanceof Error ? error : Error(String(error)));
62
+ }
63
+ });
64
+ });
65
+ return {
66
+ claim: async (input: OperationRequest, signal?: AbortSignal): Promise<OperationClaim> => {
67
+ const { value, socket } = await request("operation-claim", { request: input }, signal, true);
68
+ if (value.reused) return { operation: value.operation, reused: true };
69
+ const update = async (action: string, data: object) => {
70
+ if (socket.destroyed) throw Error("Operation claim owner disconnected; settlement is uncertain");
71
+ return request(action, { operationId: input.operationId, ticket: value.ticket, ...data });
72
+ };
73
+ return {
74
+ operation: value.operation,
75
+ reused: false,
76
+ started: async (cwd, workspaceId) => {
77
+ await update("operation-started", { cwd, workspaceId });
78
+ },
79
+ finish: async (state, runtime) => {
80
+ try {
81
+ await update("operation-finish", { state, runtime });
82
+ Object.assign(value.operation, (await request("operation-read", { operationId: input.operationId })).value);
83
+ } finally {
84
+ socket.destroy();
85
+ }
86
+ },
87
+ };
88
+ },
89
+ read: async (operationId: string) =>
90
+ (await request("operation-read", { operationId })).value as OperationReference | null,
91
+ close: () => {
92
+ closed = true;
93
+ for (const socket of sockets) socket.destroy();
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,81 @@
1
+ /** Atomic leader election at the tree owner. No task text, execution permission or cached output. */
2
+ import { randomBytes } from "node:crypto";
3
+ import type { OperationReference, OperationRequest } from "../kernel/dispatch-operation.ts";
4
+ import { validOperationId } from "../kernel/dispatch-operation.ts";
5
+
6
+ export function createDispatchOperations() {
7
+ const records = new Map<string, { digest: string; ticket: string; value: OperationReference }>();
8
+ const copy = (value: OperationReference) => structuredClone(value);
9
+ const requireTicket = (id: string, ticket: string) => {
10
+ const row = records.get(id);
11
+ if (!row || typeof ticket !== "string" || row.ticket !== ticket) throw Error("Operation owner mismatch");
12
+ return row;
13
+ };
14
+ return {
15
+ claim(request: OperationRequest) {
16
+ if (
17
+ !request ||
18
+ !validOperationId(request.operationId) ||
19
+ typeof request.requestDigest !== "string" ||
20
+ !/^[a-f0-9]{64}$/.test(request.requestDigest) ||
21
+ typeof request.executionId !== "string" ||
22
+ !request.executionId.startsWith("exec:") ||
23
+ request.executionId.length > 128 ||
24
+ typeof request.cwd !== "string" ||
25
+ !request.cwd.startsWith("/") ||
26
+ request.cwd.length > 4096 ||
27
+ !(request.workspaceId === null || typeof request.workspaceId === "string")
28
+ )
29
+ throw Error("Invalid dispatch operation request");
30
+ const old = records.get(request.operationId);
31
+ if (old) {
32
+ if (old.digest !== request.requestDigest)
33
+ throw Error("Operation identity conflict; inspect the original request before choosing a new operation_id");
34
+ return { reused: true, operation: copy(old.value) };
35
+ }
36
+ // Never evict a terminal ID and accidentally permit a mutation to run twice.
37
+ if (records.size >= 4096) throw Error("Session operation registry is full; no dispatch was admitted");
38
+ const now = new Date().toISOString(),
39
+ ticket = randomBytes(24).toString("hex");
40
+ const value: OperationReference = {
41
+ operationId: request.operationId,
42
+ executionId: request.executionId,
43
+ cwd: request.cwd,
44
+ workspaceId: request.workspaceId,
45
+ state: "admitting",
46
+ startedAt: now,
47
+ updatedAt: now,
48
+ };
49
+ records.set(request.operationId, { digest: request.requestDigest, ticket, value });
50
+ return { reused: false, operation: copy(value), ticket };
51
+ },
52
+ read(id: string) {
53
+ if (!validOperationId(id)) throw Error("Invalid operation_id");
54
+ const value = records.get(id)?.value;
55
+ return value ? copy(value) : null;
56
+ },
57
+ started(id: string, ticket: string, cwd: string, workspaceId: string | null) {
58
+ const row = requireTicket(id, ticket);
59
+ if (row.value.state !== "admitting") throw Error("Operation is no longer admitting");
60
+ if (
61
+ typeof cwd !== "string" ||
62
+ !cwd.startsWith("/") ||
63
+ cwd.length > 4096 ||
64
+ !(workspaceId === null || typeof workspaceId === "string")
65
+ )
66
+ throw Error("Invalid operation workspace");
67
+ row.value = { ...row.value, state: "running", cwd, workspaceId, updatedAt: new Date().toISOString() };
68
+ },
69
+ finish(id: string, ticket: string, state: OperationReference["state"], runtime?: OperationReference["runtime"]) {
70
+ const row = requireTicket(id, ticket);
71
+ if (!["settled", "not-started", "uncertain"].includes(state)) throw Error("Invalid operation terminal state");
72
+ if (!["admitting", "running"].includes(row.value.state)) throw Error("Operation already ended");
73
+ row.value = { ...row.value, state, ...(runtime ? { runtime } : {}), updatedAt: new Date().toISOString() };
74
+ },
75
+ abandoned(id: string, ticket: string) {
76
+ const row = requireTicket(id, ticket);
77
+ if (["admitting", "running"].includes(row.value.state))
78
+ row.value = { ...row.value, state: "uncertain", updatedAt: new Date().toISOString() };
79
+ },
80
+ };
81
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type NativeSessionObservation,
9
9
  type NativeSessionManager,
10
10
  type NativeSessionCapture,
11
+ type CapturedSessionBranch,
11
12
  } from "./native-session.ts";
12
13
  export {
13
14
  buildExecutionRetentionManifest,
@@ -26,7 +27,7 @@ export { ENV_EXECUTION_ARCHIVE } from "../kernel/env-names.ts";
26
27
  import { isUnderPiProjectDir } from "../kernel/project-paths.ts";
27
28
 
28
29
  /** Operator-owned archive boundary; never a model-facing parameter or an authority source. */
29
- export const RETENTION_VERSION = "2.0";
30
+ export const RETENTION_VERSION = "2.1";
30
31
  const LIMIT = 1024 * 1024;
31
32
  const MAX_ACTIVE = 32;
32
33
  let active = 0;
@@ -49,7 +50,7 @@ export interface RetainedContent {
49
50
  bytes: number | null;
50
51
  }
51
52
  export interface ExecutionRetentionManifest {
52
- version: "2.0";
53
+ version: "2.0" | "2.1";
53
54
  archiveId: string;
54
55
  identity: RetentionIdentity;
55
56
  native: {
@@ -155,9 +156,10 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
155
156
  let nativeTask: Promise<void> | undefined;
156
157
  let nextNative:
157
158
  | {
158
- source: "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager";
159
+ source: "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager" | "pi-captured-final";
159
160
  value: string;
160
161
  manager?: NativeSessionManager;
162
+ capturedBranch?: CapturedSessionBranch;
161
163
  }
162
164
  | undefined;
163
165
  let pinnedSessionId: string | undefined;
@@ -236,6 +238,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
236
238
  source: reference.source,
237
239
  value: reference.value,
238
240
  ...(reference.manager ? { manager: reference.manager } : {}),
241
+ ...(reference.capturedBranch ? { capturedBranch: { ...reference.capturedBranch } } : {}),
239
242
  };
240
243
  if (nativeTask) return;
241
244
  nativeTask = Promise.resolve()
@@ -258,6 +261,7 @@ export function beginExecutionRetention(identity: RetentionIdentity, directory =
258
261
  path: current.value,
259
262
  source: current.source,
260
263
  manager: current.manager,
264
+ capturedBranch: current.capturedBranch,
261
265
  expectedSessionId: pinnedSessionId,
262
266
  expectedFile: pinnedFile,
263
267
  });
@@ -289,6 +289,15 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
289
289
  events += 1;
290
290
  return;
291
291
  }
292
+ if (
293
+ event.ledgerVersion === 3 &&
294
+ (event.event === "session_config" || event.event === "cost_gate" || event.event === "episode_outcome")
295
+ ) {
296
+ // These facts passed the current v3 schema above. They are history, not capability decisions,
297
+ // and must not inflate approval, executor, lifecycle or retired-event counters.
298
+ events += 1;
299
+ return;
300
+ }
292
301
  if (event.ledgerVersion === 3 && isRetiredLedgerEvent(event)) {
293
302
  // Written by 0.30.0 or earlier; counted, never treated as corruption (see RETIRED_LEDGER_EVENT_KINDS).
294
303
  retired += 1;
@@ -7,7 +7,8 @@ import { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts";
7
7
  export { ENV_NATIVE_SESSION_ROOT } from "../kernel/env-names.ts";
8
8
 
9
9
  export const MAX_NATIVE_SESSION_BYTES = 1024 * 1024;
10
- export type NativeSessionSource = "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager";
10
+ export type NativeSessionSource =
11
+ "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager" | "pi-captured-final";
11
12
  export interface NativeSessionObservation {
12
13
  source: NativeSessionSource | null;
13
14
  status: "missing" | "verified" | "invalid" | "changed" | "truncated" | "unsupported";
@@ -25,6 +26,13 @@ export interface NativeSessionCapture {
25
26
  bytes?: Buffer;
26
27
  fileIdentity?: { device: string; inode: string };
27
28
  }
29
+ /** Byte-bound metadata from the executor's qualified native final, not a locator-derived branch. */
30
+ export interface CapturedSessionBranch {
31
+ path: string;
32
+ sessionId: string;
33
+ leafId: string;
34
+ sha256: string;
35
+ }
28
36
  /** Read-only methods of pi 0.84.2's public SessionManager; no runtime/session creation or model work. */
29
37
  export interface NativeSessionManager {
30
38
  getSessionFile(): string | undefined;
@@ -59,6 +67,7 @@ export function parseNativeSessionBytes(
59
67
  expectedSessionId?: string;
60
68
  truncated?: boolean;
61
69
  liveLeaf?: { sessionId: string; leafId: string | null };
70
+ capturedBranch?: CapturedSessionBranch;
62
71
  },
63
72
  ): NativeSessionCapture {
64
73
  const observation = { ...missingNativeSession(), source: input.source, sessionPath: input.path };
@@ -138,6 +147,23 @@ export function parseNativeSessionBytes(
138
147
  observation.branchLeafId = input.liveLeaf.leafId;
139
148
  observation.branchState = "observed";
140
149
  }
150
+ if (input.source === "pi-captured-final" && !input.capturedBranch)
151
+ return fail("invalid", "native-session-final-branch-missing");
152
+ if (input.capturedBranch) {
153
+ const branch = input.capturedBranch;
154
+ if (
155
+ input.source !== "pi-captured-final" ||
156
+ branch.path !== input.path ||
157
+ branch.sessionId !== header.id ||
158
+ branch.sha256 !== observation.sha256 ||
159
+ !ids.has(branch.leafId) ||
160
+ (input.liveLeaf && input.liveLeaf.leafId !== branch.leafId)
161
+ ) {
162
+ return fail("changed", "native-session-final-branch-mismatch");
163
+ }
164
+ observation.branchLeafId = branch.leafId;
165
+ observation.branchState = "observed";
166
+ }
141
167
  observation.status = "verified";
142
168
  observation.reason = observation.branchState === "unknown" ? "active-branch-unknown" : null;
143
169
  return result;
@@ -150,6 +176,7 @@ export async function readNativeSession(input: {
150
176
  expectedSessionId?: string;
151
177
  allowedRoot?: string;
152
178
  manager?: NativeSessionManager;
179
+ capturedBranch?: CapturedSessionBranch;
153
180
  expectedFile?: { device: string; inode: string };
154
181
  }): Promise<NativeSessionCapture> {
155
182
  const gap = (status: NativeSessionObservation["status"], reason: string): NativeSessionCapture => ({