pi-daddy 0.45.0 → 0.46.1

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 (70) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +391 -45
  3. package/contracts/ledger-record/v1/governance-event.schema.json +2 -0
  4. package/dist/executors/captured-execution.d.ts +2 -2
  5. package/dist/executors/captured-execution.d.ts.map +1 -1
  6. package/dist/executors/captured-execution.js +2 -2
  7. package/dist/executors/captured-execution.js.map +1 -1
  8. package/dist/executors/executor.d.ts.map +1 -1
  9. package/dist/executors/executor.js +4 -2
  10. package/dist/executors/executor.js.map +1 -1
  11. package/dist/executors/herdr-cli.d.ts +2 -0
  12. package/dist/executors/herdr-cli.d.ts.map +1 -1
  13. package/dist/executors/herdr-cli.js +2 -2
  14. package/dist/executors/herdr-cli.js.map +1 -1
  15. package/dist/executors/herdr-launcher.d.ts +2 -0
  16. package/dist/executors/herdr-launcher.d.ts.map +1 -0
  17. package/dist/executors/herdr-launcher.js +123 -0
  18. package/dist/executors/herdr-launcher.js.map +1 -0
  19. package/dist/executors/herdr-owned.d.ts +13 -0
  20. package/dist/executors/herdr-owned.d.ts.map +1 -0
  21. package/dist/executors/herdr-owned.js +229 -0
  22. package/dist/executors/herdr-owned.js.map +1 -0
  23. package/dist/executors/herdr-qualification.d.ts +6 -0
  24. package/dist/executors/herdr-qualification.d.ts.map +1 -0
  25. package/dist/executors/herdr-qualification.js +33 -0
  26. package/dist/executors/herdr-qualification.js.map +1 -0
  27. package/dist/executors/herdr-wire.d.ts +5 -0
  28. package/dist/executors/herdr-wire.d.ts.map +1 -0
  29. package/dist/executors/herdr-wire.js +47 -0
  30. package/dist/executors/herdr-wire.js.map +1 -0
  31. package/dist/governance/approval-prompt.d.ts +4 -11
  32. package/dist/governance/approval-prompt.d.ts.map +1 -1
  33. package/dist/governance/approval-prompt.js +83 -24
  34. package/dist/governance/approval-prompt.js.map +1 -1
  35. package/dist/governance/ledger-v3-validation.js +1 -1
  36. package/dist/governance/ledger-v3-validation.js.map +1 -1
  37. package/dist/governance/ledger.d.ts +3 -4
  38. package/dist/governance/ledger.d.ts.map +1 -1
  39. package/dist/governance/ledger.js +1 -1
  40. package/dist/governance/ledger.js.map +1 -1
  41. package/dist/governance/runtime-settlement-lock.d.ts +5 -0
  42. package/dist/governance/runtime-settlement-lock.d.ts.map +1 -0
  43. package/dist/governance/runtime-settlement-lock.js +72 -0
  44. package/dist/governance/runtime-settlement-lock.js.map +1 -0
  45. package/dist/governance/runtime-settlement.d.ts +22 -0
  46. package/dist/governance/runtime-settlement.d.ts.map +1 -0
  47. package/dist/governance/runtime-settlement.js +236 -0
  48. package/dist/governance/runtime-settlement.js.map +1 -0
  49. package/extensions/activity-timeline.ts +94 -31
  50. package/extensions/execute-child.ts +98 -74
  51. package/extensions/execution-controls.ts +42 -0
  52. package/extensions/executor-session.ts +2 -1
  53. package/extensions/grants-command.ts +4 -0
  54. package/extensions/grants.ts +4 -0
  55. package/extensions/reload-environment.ts +8 -0
  56. package/extensions/runtime-snapshot.ts +123 -0
  57. package/extensions/session.ts +1 -1
  58. package/package.json +1 -1
  59. package/src/executors/captured-execution.ts +2 -1
  60. package/src/executors/executor.ts +4 -2
  61. package/src/executors/herdr-cli.ts +19 -12
  62. package/src/executors/herdr-launcher.ts +124 -0
  63. package/src/executors/herdr-owned.ts +260 -0
  64. package/src/executors/herdr-qualification.ts +33 -0
  65. package/src/executors/herdr-wire.ts +43 -0
  66. package/src/governance/approval-prompt.ts +86 -29
  67. package/src/governance/ledger-v3-validation.ts +1 -1
  68. package/src/governance/ledger.ts +3 -4
  69. package/src/governance/runtime-settlement-lock.ts +76 -0
  70. package/src/governance/runtime-settlement.ts +271 -0
@@ -1,7 +1,7 @@
1
1
  import { BoundedReadCleanupError } from "../src/kernel/bounded-read.ts";
2
2
  import { retainDiscoveryCleanupFailure } from "./session.ts";
3
3
  import type { CapacityReservation } from "../src/kernel/capacity.ts";
4
- import { HERDR_UNQUALIFIED_REASON } from "../src/executors/executor.ts";
4
+ import { runHerdrOwned } from "../src/executors/herdr-owned.ts";
5
5
  import { runCapturedExecution } from "../src/executors/captured-execution.ts";
6
6
  import type { ChildFinal } from "../src/executors/child-final.ts";
7
7
  import type { CapturedWorkerCleanup } from "../src/kernel/captured-worker-contract.ts";
@@ -20,13 +20,12 @@ import {
20
20
  ENV_CHILD_IDLE_TIMEOUT,
21
21
  ENV_CHILD_TIMEOUT,
22
22
  idleTimeoutFromEnv,
23
- runChild,
24
23
  timeoutFromEnv,
25
24
  } from "../src/kernel/run-child.ts";
26
25
  import { activitySessionFor } from "../src/executors/activity-session.ts";
27
26
  import { processTreeActivity } from "../src/executors/process-activity.ts";
28
27
  import { resolveWorkspace } from "../src/executors/herdr-cli.ts";
29
- import { HerdrWriterCloseError, runHerdrPane } from "../src/executors/run-herdr.ts";
28
+ import { HerdrWriterCloseError } from "../src/executors/run-herdr.ts";
30
29
  import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/kernel/refusals.ts";
31
30
  import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
32
31
  import { CHILD_ATTRIBUTION_ENV_KEYS, ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
@@ -138,15 +137,49 @@ export interface ChildExecutionInput {
138
137
  }
139
138
  export async function executePlannedChild(input: ChildExecutionInput): Promise<DelegationOutcome> {
140
139
  let executorEntered = false;
140
+ const runtime = input.session.reloadLifecycle?.runtimeSettlement;
141
+ let registered = false;
141
142
  try {
142
- return await executePreparedChild(input, () => {
143
- executorEntered = true;
144
- });
143
+ runtime?.begin(input.executionId);
144
+ registered = Boolean(runtime);
145
+ const result = await executePreparedChild(
146
+ input,
147
+ () => {
148
+ executorEntered = true;
149
+ },
150
+ runtime,
151
+ );
152
+ try {
153
+ await runtime?.finish(
154
+ input.executionId,
155
+ result.cleanup ?? { state: "unknown", reason: "executor omitted cleanup" },
156
+ result.control === "failed" ? (result.reason ?? "runtime control failed") : undefined,
157
+ );
158
+ } catch (error) {
159
+ return {
160
+ ...result,
161
+ control: "failed",
162
+ reason: [result.reason, `runtime settlement record failed: ${String(error)}`].filter(Boolean).join("; "),
163
+ };
164
+ }
165
+ return result;
145
166
  } catch (error) {
146
- if (executorEntered) throw error;
147
- // Every operation above the executor-entry marker is local setup: no helper or worker exists yet.
148
- input.capacityReservation?.finalize({ state: "not-started", reason: "child setup failed before executor entry" });
149
167
  const failures: unknown[] = [error];
168
+ if (registered)
169
+ try {
170
+ await runtime?.finish(input.executionId, {
171
+ state: executorEntered ? "unknown" : "not-started",
172
+ reason: String(error),
173
+ });
174
+ } catch (recordError) {
175
+ failures.push(recordError);
176
+ }
177
+ if (executorEntered) {
178
+ if (failures.length > 1) throw new AggregateError(failures, failures.map(String).join("; "));
179
+ throw error;
180
+ }
181
+ // Setup failures prove no worker started, even if recording that fact also failed.
182
+ input.capacityReservation?.finalize({ state: "not-started", reason: "child setup failed before executor entry" });
150
183
  try {
151
184
  const released = await releaseDelegationWorkspace({
152
185
  prepared: input.preparedWorkspace,
@@ -174,6 +207,7 @@ export async function executePlannedChild(input: ChildExecutionInput): Promise<D
174
207
  async function executePreparedChild(
175
208
  input: ChildExecutionInput,
176
209
  onExecutorEntry: () => void,
210
+ runtime?: import("../src/governance/runtime-settlement.ts").RuntimeSettlement,
177
211
  ): Promise<DelegationOutcome> {
178
212
  const { session, plan, childId, executionId, parentExecutionId, preparedWorkspace, signal, onProgress } = input;
179
213
  const ledgerPath = session.ledgerPath;
@@ -229,7 +263,7 @@ async function executePreparedChild(
229
263
  const activitySession = await activitySessionFor(plan.args, executionId);
230
264
  // Disposed on every path (review finding: it leaked on every throw), except when the operator keeps a Herdr pane,
231
265
  // where the interactive pi in that pane is still alive and still appending to this file.
232
- const keepPaneRequested = session.executor.kind === "herdr" && process.env[ENV_HERDR_KEEP_PANE] === "1";
266
+ const keepPaneRequested = false; // The qualified pane launcher exits; retained panes contain no live Pi writer.
233
267
  try {
234
268
  return await executeWithActivitySession();
235
269
  } finally {
@@ -368,73 +402,63 @@ async function executePreparedChild(
368
402
  }
369
403
  };
370
404
  try {
371
- if (String(session.executor.kind) === "herdr")
372
- throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", HERDR_UNQUALIFIED_REASON));
405
+ if (session.executor.refusal)
406
+ throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", session.executor.refusal));
373
407
  executorEntered = true;
374
408
  onExecutorEntry();
375
- const output =
376
- session.executor.kind === "herdr"
377
- ? await runHerdrPane({
378
- args: args.slice(0, -1),
379
- prompt: args[args.length - 1].trimStart(),
380
- env: plan.env,
381
- cwd,
382
- name: `${input.agent ?? "delegate"}-${childId}`,
383
- workspace: resolveWorkspace(process.env),
384
- signal: executionSignal,
385
- timeoutMs: remainingTimeoutMs,
386
- idleTimeoutMs: configuredIdleMs,
387
- activityProbe: activitySession.probe, // Herdr: session file plus pane text when a display is attached
388
- keepPane: writerLease ? false : process.env[ENV_HERDR_KEEP_PANE] === "1",
389
- closeOnSettle: Boolean(writerLease),
390
- onPane: (paneId, agentName) => {
391
- retention.native({ paneId, agentName });
392
- onProgress?.({ paneId, agentName, state: "starting" });
393
- },
394
- onObservation: (bytes) => retention.capture("paneSnapshot", bytes, true),
395
- onSessionReference: (reference) => retention.observeSession(reference),
396
- onRunning: (paneId, agentName) => {
397
- // Record first: runHerdrPane isolates this display callback, so a renderer exception after the
398
- // observation cannot suppress the authoritative running event.
399
- recordRunning("herdr", { id: paneId, agentName });
400
- onProgress?.({ paneId, agentName, state: "running" });
401
- },
402
- onTab: preparedWorkspace ? (tabId) => preparedWorkspace.lease.attachHerdrTab(tabId) : undefined,
403
- onNativeTab: (tabId) => retention.native({ tabId }),
404
- onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
405
- })
406
- : await runCapturedExecution({
407
- executionId,
408
- sessionPath: activitySession.path,
409
- onReadCleanup,
410
- onOwnership: async (identity) => {
411
- input.capacityReservation?.bindOwnership(identity);
412
- await preparedWorkspace?.lease.attachCapturedWorker(identity);
413
- },
414
- command: "pi",
415
- args,
416
- env: mergeChildEnv(process.env, plan.env),
417
- cwd,
418
- signal: executionSignal,
419
- // SIGTERM gets only grace that fits INSIDE the recorded deadline. The independent hard timer
420
- // prevents a delayed soft-timeout callback from starting a fresh grace period beyond that bound.
421
- timeoutMs: Math.max(1, remainingTimeoutMs - terminationGraceMs),
422
- hardDeadlineAt: Date.parse(deadlineAt),
423
- idleTimeoutMs: configuredIdleMs,
424
- activityProbe: probe,
425
- onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
426
- onObservation: (stream, bytes) => retention.capture(stream, bytes),
427
- onSpawn: (pid) => {
428
- childPid = pid;
429
- // Lease attachment is a security hook and may fail the spawn. The shared reporters isolate
430
- // display exceptions before they reach this callback; runChild deliberately kills on any error
431
- // here, so presentation must never be added directly without that reporter boundary.
409
+ let displayHerdr: ((text: string) => void) | undefined;
410
+ const output = await runCapturedExecution(
411
+ {
412
+ executionId,
413
+ sessionPath: activitySession.path,
414
+ onReadCleanup,
415
+ onOwnership: async (identity) => {
416
+ runtime?.bind(identity);
417
+ input.capacityReservation?.bindOwnership(identity);
418
+ await preparedWorkspace?.lease.attachCapturedWorker(identity);
419
+ },
420
+ command: "pi",
421
+ args,
422
+ env: mergeChildEnv(process.env, plan.env),
423
+ cwd,
424
+ signal: executionSignal,
425
+ // SIGTERM gets only grace that fits INSIDE the recorded deadline. The independent hard timer
426
+ // prevents a delayed soft-timeout callback from starting a fresh grace period beyond that bound.
427
+ timeoutMs: Math.max(1, remainingTimeoutMs - terminationGraceMs),
428
+ hardDeadlineAt: Date.parse(deadlineAt),
429
+ idleTimeoutMs: configuredIdleMs,
430
+ activityProbe: probe,
431
+ onOutput: (chunk) => {
432
+ displayHerdr?.(chunk);
433
+ onProgress?.({ chunk });
434
+ },
435
+ onObservation: (stream, bytes) => retention.capture(stream, bytes),
436
+ onSpawn: (pid) => {
437
+ childPid = pid;
438
+ // Lease attachment is a security hook and may fail the spawn. The shared reporters isolate
439
+ // display exceptions before they reach this callback; runChild deliberately kills on any error
440
+ // here, so presentation must never be added directly without that reporter boundary.
432
441
 
433
- retention.native({ pid });
434
- recordRunning("process");
435
- onProgress?.({ state: "running" });
436
- },
437
- });
442
+ retention.native({ pid });
443
+ recordRunning(session.executor.kind);
444
+ onProgress?.({ state: "running" });
445
+ },
446
+ },
447
+ session.executor.kind === "herdr"
448
+ ? (request) =>
449
+ runHerdrOwned(request, {
450
+ onDisplay: (display) => {
451
+ displayHerdr = display;
452
+ },
453
+ workspace: resolveWorkspace(process.env),
454
+ keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
455
+ onPane: (paneId, tabId) => {
456
+ retention.native({ paneId, tabId });
457
+ onProgress?.({ paneId, state: "starting" });
458
+ },
459
+ })
460
+ : undefined,
461
+ );
438
462
 
439
463
  if (sessionFlag >= 0) retention.observeSession({ source: "pi-session-file", value: plan.args[sessionFlag + 1] });
440
464
  retention.capture("result", Buffer.from(output.text), true);
@@ -0,0 +1,42 @@
1
+ /** Operator-facing execution settings; display never changes admission, deadlines or retention. */
2
+ import { timeoutFromEnv, idleTimeoutFromEnv } from "../src/kernel/run-child.ts";
3
+ import { timeoutMsFromEnv } from "../src/governance/approval-prompt.ts";
4
+ import {
5
+ ENV_APPROVAL_TIMEOUT,
6
+ ENV_CHILD_TIMEOUT,
7
+ ENV_CHILD_IDLE_TIMEOUT,
8
+ ENV_EXECUTION_ARCHIVE,
9
+ } from "../src/kernel/env-names.ts";
10
+
11
+ export function renderExecutionControls(
12
+ nativeSessionRoot: string | undefined,
13
+ env: NodeJS.ProcessEnv = process.env,
14
+ ): string[] {
15
+ let approval: string;
16
+ try {
17
+ const timeout = timeoutMsFromEnv(env[ENV_APPROVAL_TIMEOUT]);
18
+ approval = timeout === undefined ? "wait for human; no deadline" : `${timeout / 1000}s configured deadline`;
19
+ } catch {
20
+ approval = `${ENV_APPROVAL_TIMEOUT} invalid; a needed new prompt will refuse`;
21
+ }
22
+ const source = (key: string) => (env[key] === undefined ? "default" : `from ${key}; zero/invalid uses default`);
23
+ return [
24
+ ` child wall ${timeoutFromEnv(env[ENV_CHILD_TIMEOUT]) / 1000}s (${source(ENV_CHILD_TIMEOUT)})`,
25
+ ` child idle ${idleTimeoutFromEnv(env[ENV_CHILD_IDLE_TIMEOUT]) / 1000}s (${source(ENV_CHILD_IDLE_TIMEOUT)})`,
26
+ " process safety controls; no time/token budget is added to the model prompt",
27
+ ` approval ${approval}`,
28
+ ` transcripts ${
29
+ nativeSessionRoot === undefined
30
+ ? "off"
31
+ : nativeSessionRoot
32
+ ? `opted in at ${JSON.stringify(nativeSessionRoot)}; destination checked at dispatch`
33
+ : "opted in but native session root is missing"
34
+ }`,
35
+ ` diagnostics ${
36
+ env[ENV_EXECUTION_ARCHIVE]
37
+ ? `configured at ${JSON.stringify(env[ENV_EXECUTION_ARCHIVE])}; check each result's retention status`
38
+ : "off"
39
+ }`,
40
+ " local diagnostic retention is separate from JEV/LoRA consent",
41
+ ];
42
+ }
@@ -1,9 +1,10 @@
1
1
  import { chooseExecutor, needsProbe, ENV_HERDR } from "../src/executors/executor.ts";
2
+ import { qualifyHerdr } from "../src/executors/herdr-qualification.ts";
2
3
  import { probeHerdr } from "../src/executors/herdr-cli.ts";
3
4
  import type { GrantsSession } from "./session.ts";
4
5
 
5
6
  /** Settle one executor at session start; a later daemon change never relocates siblings. */
6
7
  export async function resolveExecutor(session: GrantsSession): Promise<void> {
7
8
  const raw = process.env[ENV_HERDR];
8
- session.executor = chooseExecutor(raw, needsProbe(raw) ? await probeHerdr() : null);
9
+ session.executor = chooseExecutor(raw, needsProbe(raw) ? await qualifyHerdr(await probeHerdr()) : null);
9
10
  }
@@ -12,6 +12,7 @@
12
12
  * being whatever happened to be in scope.
13
13
  */
14
14
 
15
+ import { renderExecutionControls } from "./execution-controls.ts";
15
16
  import type { Capability } from "../src/kernel/resolve.ts";
16
17
  import type { ExecutorChoice } from "../src/executors/executor.ts";
17
18
  import type { Catalog } from "../src/kernel/catalog.ts";
@@ -25,6 +26,8 @@ import type { ResolvedDefinitionRuntime } from "./definition-runtime.ts";
25
26
  export interface GrantsCommandContext {
26
27
  cwd: string;
27
28
  governed: boolean;
29
+ /** Host opt-in captured by this session, not a later environment reread. */
30
+ nativeSessionRoot?: string;
28
31
  ownGrant: Capability[];
29
32
  /**
30
33
  * Which executor this session settled on, and why — ADR-0031.
@@ -407,6 +410,7 @@ export const grantsCommand = {
407
410
  // ADR-0031, and its probe is only defensible because this line exists. Placed next to `holding` so the
408
411
  // two facts about what a spawn will be sit together.
409
412
  ` executor ${executor.disclosure}`,
413
+ ...renderExecutionControls(ctx.grants.nativeSessionRoot),
410
414
  ` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
411
415
  ` ledger ${ledgerPath || "(not recording — set PI_DADDY_LEDGER)"}`,
412
416
  ` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
@@ -20,6 +20,7 @@
20
20
  * spawn triggered the human prompt, and `childEnv` clamps it to the grant again on the way out. Each
21
21
  * child derives its own grant from the tool array of its first provider request.
22
22
  */
23
+ import { registerRuntimeSnapshot } from "./runtime-snapshot.ts";
23
24
  import { fileURLToPath } from "node:url";
24
25
  import { homedir } from "node:os";
25
26
  import { join, resolve } from "node:path";
@@ -80,6 +81,7 @@ export default function (pi: ExtensionAPI, trustedSession?: GrantsSession) {
80
81
  const session = trustedSession ?? createGrantsSession(extensionPath, undefined, observerExtensionPath);
81
82
  session.currentThinking = typeof pi.getThinkingLevel === "function" ? () => pi.getThinkingLevel() : undefined;
82
83
  registerActivityTimeline(pi, session);
84
+ const runtimeSnapshot = registerRuntimeSnapshot(pi, session);
83
85
  const dashboardPluginRoot = fileURLToPath(new URL("../herdr-plugin/", import.meta.url));
84
86
  const dashboardPaths = defaultDashboardPaths(agentDir());
85
87
  // Definitions are registered only after owner-bound session_start. Until then there is no delegation
@@ -215,6 +217,7 @@ export default function (pi: ExtensionAPI, trustedSession?: GrantsSession) {
215
217
  // (R-60), and `probeHerdr` is documented as never throwing precisely so this is belt-and-braces.
216
218
  try {
217
219
  await resolveExecutor(session);
220
+ if (session.reloadLifecycle === reload.lifecycle) await runtimeSnapshot.bind(ctx);
218
221
  } catch (error) {
219
222
  if (session.reloadLifecycle !== reload.lifecycle) return undefined;
220
223
  // Says what is actually true of the state left behind, which depends on the variable: with
@@ -459,6 +462,7 @@ export default function (pi: ExtensionAPI, trustedSession?: GrantsSession) {
459
462
  },
460
463
  }
461
464
  : {}),
465
+ nativeSessionRoot: session.nativeSessionRoot,
462
466
  catalog: session.catalog,
463
467
  definitions: session.definitions,
464
468
  sessionApprovals: session.sessionApprovals,
@@ -4,6 +4,10 @@ import type { BoundedReadCleanupError } from "../src/kernel/bounded-read.ts";
4
4
 
5
5
  /** The root baseline and latest child publication for one real Pi session owner. */
6
6
  export interface ReloadLifecycle {
7
+ runtimeSnapshotUnsubscribe?: () => void;
8
+ runtimeSnapshotGeneration?: object;
9
+ runtimeSettlements?: Map<string, import("../src/governance/runtime-settlement.ts").RuntimeSettlement>;
10
+ runtimeSettlement?: import("../src/governance/runtime-settlement.ts").RuntimeSettlement;
7
11
  root: Record<string, string | undefined>;
8
12
  /** Never replaced for the same owner, even when an explicit root environment changes. */
9
13
  capacity?: OwnerCapacity;
@@ -99,6 +103,10 @@ export function bindReloadLifecycle(
99
103
  activityRootId: existing.activityRootId,
100
104
  episodeId: existing.episodeId,
101
105
  capacity: existing.capacity,
106
+ runtimeSettlement: existing.runtimeSettlement,
107
+ runtimeSettlements: existing.runtimeSettlements,
108
+ runtimeSnapshotUnsubscribe: existing.runtimeSnapshotUnsubscribe,
109
+ runtimeSnapshotGeneration: existing.runtimeSnapshotGeneration,
102
110
  };
103
111
  holder.owners.set(owner, existing);
104
112
  }
@@ -0,0 +1,123 @@
1
+ /** Trusted extension event bridge. Tool calls and model-produced text cannot supply runtime facts. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { realpath } from "node:fs/promises";
4
+ import { openRuntimeSettlement, settlementHash } from "../src/governance/runtime-settlement.ts";
5
+ import { reconcileDelegationCapacity } from "./session-capacity.ts";
6
+ import { agentDir } from "../src/kernel/project-paths.ts";
7
+ import type { GrantsSession } from "./session.ts";
8
+ export const RUNTIME_SNAPSHOT_EVENT = "pi-daddy:runtime-snapshot:v1";
9
+ export function registerRuntimeSnapshot(
10
+ pi: ExtensionAPI,
11
+ session: GrantsSession,
12
+ ): {
13
+ bind(ctx: ExtensionContext): Promise<void>;
14
+ } {
15
+ let bound: { sessionId: string; cwd: string; lifecycle: object } | undefined, problem: string | undefined;
16
+ const generation = {};
17
+ let bindSequence = 0;
18
+ const unsubscribe = pi.events?.on?.(RUNTIME_SNAPSHOT_EVENT, (input: unknown) => {
19
+ const request = input as {
20
+ version?: number;
21
+ requestId?: string;
22
+ sessionId?: string;
23
+ cwd?: string;
24
+ reply?: (value: unknown) => void;
25
+ };
26
+ if (
27
+ request?.version !== 1 ||
28
+ typeof request.requestId !== "string" ||
29
+ request.requestId.length > 512 ||
30
+ typeof request.reply !== "function"
31
+ )
32
+ return;
33
+ if (bound && session.reloadLifecycle.runtimeSnapshotGeneration !== generation) return;
34
+ void (async () => {
35
+ const current = bound,
36
+ lifecycle = session.reloadLifecycle;
37
+ const unavailable = (reason: string) => ({
38
+ version: 1,
39
+ requestId: request.requestId,
40
+ sessionId: current?.sessionId ?? "",
41
+ cwd: current?.cwd ?? "",
42
+ ownerScope: "",
43
+ ownerId: "",
44
+ backend: session.executor.kind,
45
+ qualified: false,
46
+ state: "unknown",
47
+ evidenceDigest: settlementHash({ reason }),
48
+ settledExecutionIds: [],
49
+ outstandingExecutionIds: [],
50
+ reason,
51
+ });
52
+ if (
53
+ !current ||
54
+ current.lifecycle !== lifecycle ||
55
+ request.sessionId !== current.sessionId ||
56
+ request.cwd !== current.cwd ||
57
+ !lifecycle.runtimeSettlement
58
+ ) {
59
+ request.reply!(unavailable(problem ?? "runtime owner is not ready"));
60
+ return;
61
+ }
62
+ try {
63
+ if (Array.isArray(session.capacity.retainedReservations)) await reconcileDelegationCapacity(session);
64
+ const snapshot = await lifecycle.runtimeSettlement.snapshot();
65
+ if (
66
+ bound !== current ||
67
+ session.reloadLifecycle !== lifecycle ||
68
+ lifecycle.runtimeSnapshotGeneration !== generation
69
+ ) {
70
+ request.reply!(unavailable("runtime owner changed during snapshot"));
71
+ return;
72
+ }
73
+ const qualified = !session.executor.refusal && !session.discoveryCleanupFailure && !session.capacityRefusal;
74
+ request.reply!({
75
+ version: 1,
76
+ requestId: request.requestId,
77
+ sessionId: current.sessionId,
78
+ cwd: current.cwd,
79
+ ...snapshot,
80
+ backend: session.executor.kind,
81
+ qualified,
82
+ state: !qualified
83
+ ? "unknown"
84
+ : session.capacity.reserved > 0 && snapshot.state === "idle"
85
+ ? "busy"
86
+ : snapshot.state,
87
+ ...(problem ? { reason: problem } : {}),
88
+ });
89
+ } catch (error) {
90
+ request.reply!(unavailable(String(error)));
91
+ }
92
+ })().catch(() => undefined);
93
+ });
94
+ return {
95
+ async bind(ctx) {
96
+ const sequence = ++bindSequence,
97
+ lifecycle = session.reloadLifecycle;
98
+ lifecycle.runtimeSettlement = undefined;
99
+ bound = undefined;
100
+ problem = undefined;
101
+ try {
102
+ const sessionId = ctx.sessionManager.getSessionId(),
103
+ cwd = await realpath(ctx.cwd);
104
+ if (sequence !== bindSequence || session.reloadLifecycle !== lifecycle) return;
105
+ if (lifecycle.runtimeSnapshotUnsubscribe !== unsubscribe) lifecycle.runtimeSnapshotUnsubscribe?.();
106
+ lifecycle.runtimeSnapshotUnsubscribe = unsubscribe;
107
+ lifecycle.runtimeSnapshotGeneration = generation;
108
+ lifecycle.runtimeSettlements ??= new Map();
109
+ const key = JSON.stringify([sessionId, cwd]);
110
+ let runtime = lifecycle.runtimeSettlements.get(key);
111
+ if (!runtime) {
112
+ runtime = await openRuntimeSettlement(agentDir(), sessionId, cwd);
113
+ lifecycle.runtimeSettlements.set(key, runtime);
114
+ }
115
+ if (sequence !== bindSequence || session.reloadLifecycle !== lifecycle) return;
116
+ lifecycle.runtimeSettlement = runtime;
117
+ bound = { sessionId, cwd, lifecycle: session.reloadLifecycle };
118
+ } catch (error) {
119
+ if (sequence === bindSequence && session.reloadLifecycle === lifecycle) problem = String(error);
120
+ }
121
+ },
122
+ };
123
+ }
@@ -693,7 +693,7 @@ export function createGrantsSession(
693
693
  // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
694
694
  // has not run when this session object is built, so a captured value would plan `--print` for a session
695
695
  // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
696
- interactive: session.executor.kind === "herdr",
696
+ interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
697
697
  ...(approved ? { approved } : {}),
698
698
  }),
699
699
  storeCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.45.0",
3
+ "version": "0.46.1",
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",
@@ -15,6 +15,7 @@ export async function runCapturedExecution(
15
15
  sessionPath: string;
16
16
  onReadCleanup?: (error: BoundedReadCleanupError) => void;
17
17
  },
18
+ runOwned: typeof runOwnedChild = runOwnedChild,
18
19
  ): Promise<CapturedExecutionResult> {
19
20
  let directory: string;
20
21
  try {
@@ -36,7 +37,7 @@ export async function runCapturedExecution(
36
37
  }
37
38
  const capture = new ChildFinalCapture(request.onOutput, request.onReadCleanup);
38
39
  const args = [...request.args.slice(0, -1), "--mode", "json", request.args.at(-1)!];
39
- const output = await runOwnedChild({
40
+ const output = await runOwned({
40
41
  ...request,
41
42
  args,
42
43
  ownershipDir: join(directory, "ownership"),
@@ -44,15 +44,17 @@ export function needsProbe(raw: string | undefined): boolean {
44
44
  }
45
45
 
46
46
  export const HERDR_UNQUALIFIED_REASON =
47
- "Herdr governed execution is not qualified for complete current finals and subtree cleanup in this release. " +
47
+ "Herdr governed execution is not qualified for complete current finals and subtree cleanup on the detected transport. " +
48
48
  "Delegation is refused; set PI_DADDY_HERDR=0 explicitly to select captured execution. No backend fallback occurs.";
49
49
 
50
50
  export function chooseExecutor(raw: string | undefined, probe: HerdrProbe | null): ExecutorChoice {
51
51
  const choice = selectExecutor(raw, probe);
52
52
  if (choice.kind !== "herdr") return choice;
53
+ if (!choice.refusal && probe?.qualified === true)
54
+ return { ...choice, disclosure: `${choice.disclosure} — qualified native ownership` };
53
55
  return {
54
56
  ...choice,
55
- refusal: [choice.refusal, HERDR_UNQUALIFIED_REASON].filter(Boolean).join(" "),
57
+ refusal: [choice.refusal, HERDR_UNQUALIFIED_REASON, probe?.qualificationReason].filter(Boolean).join(" "),
56
58
  disclosure: `${choice.disclosure} — unqualified; governed delegation will refuse`,
57
59
  };
58
60
  }
@@ -19,17 +19,22 @@ export type HerdrExec = (args: string[]) => Promise<{ code: number | null; stdou
19
19
 
20
20
  export const defaultExec: HerdrExec = (args) =>
21
21
  new Promise((settle) => {
22
- execFile("herdr", args, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => {
23
- const raw = (error as { code?: unknown } | null)?.code;
24
- const code = typeof raw === "number" ? raw : error ? 1 : 0;
25
- // **A string `code` is a spawn failure, and it used to be thrown away.** `ENOENT` — herdr not installed —
26
- // arrives as `code: "ENOENT"`, so the numeric test failed, the message was dropped, and an operator with
27
- // `PI_DADDY_HERDR=1` on a machine without herdr was told *"herdr is not answering (unparseable herdr
28
- // reply: (no output))"* rather than that the binary is missing. Rule 8 wants the loud version, and this is
29
- // the first diagnostic such an operator meets.
30
- const spawnFailure = typeof raw === "string" ? `herdr could not be run (${raw}): ${error?.message ?? ""}` : "";
31
- settle({ code, stdout: String(stdout), stderr: spawnFailure || String(stderr) });
32
- });
22
+ execFile(
23
+ "herdr",
24
+ args,
25
+ { maxBuffer: 32 * 1024 * 1024, timeout: 10000, killSignal: "SIGKILL" },
26
+ (error, stdout, stderr) => {
27
+ const raw = (error as { code?: unknown } | null)?.code;
28
+ const code = typeof raw === "number" ? raw : error ? 1 : 0;
29
+ // **A string `code` is a spawn failure, and it used to be thrown away.** `ENOENT` — herdr not installed —
30
+ // arrives as `code: "ENOENT"`, so the numeric test failed, the message was dropped, and an operator with
31
+ // `PI_DADDY_HERDR=1` on a machine without herdr was told *"herdr is not answering (unparseable herdr
32
+ // reply: (no output))"* rather than that the binary is missing. Rule 8 wants the loud version, and this is
33
+ // the first diagnostic such an operator meets.
34
+ const spawnFailure = typeof raw === "string" ? `herdr could not be run (${raw}): ${error?.message ?? ""}` : "";
35
+ settle({ code, stdout: String(stdout), stderr: spawnFailure || String(stderr) });
36
+ },
37
+ );
33
38
  });
34
39
 
35
40
  /**
@@ -44,7 +49,7 @@ export function parseReply(reply: { stdout: string; stderr: string }): {
44
49
  error?: string;
45
50
  } {
46
51
  try {
47
- const parsed = JSON.parse(reply.stdout) as {
52
+ const parsed = JSON.parse(reply.stdout.trim() || reply.stderr.trim()) as {
48
53
  result?: Record<string, unknown>;
49
54
  error?: { message?: string; code?: string };
50
55
  };
@@ -63,6 +68,8 @@ export const PROBE_TIMEOUT_MS = 2000;
63
68
 
64
69
  export interface HerdrProbe {
65
70
  ok: boolean;
71
+ qualified?: boolean;
72
+ qualificationReason?: string;
66
73
  /** herdr's own words when it is not reachable. Carried so the disclosure line can name the reason. */
67
74
  error?: string;
68
75
  }