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
@@ -0,0 +1,124 @@
1
+ /** Packaged pane entrypoint. Parent liveness is a private socket, native worker liveness is its control pipe. */
2
+ import { stat } from "node:fs/promises";
3
+ import { processTreeActivity } from "./process-activity.ts";
4
+ import { createConnection } from "node:net";
5
+ import { runOwnedChild, type OwnedChildRunRequest } from "./owned-worker.ts";
6
+ import { wire } from "./herdr-wire.ts";
7
+ export async function launchHerdrWorker(socketPath: string, token: string): Promise<void> {
8
+ const socket = createConnection(socketPath);
9
+ const abort = new AbortController();
10
+ let started = false,
11
+ authenticated = false,
12
+ acknowledge: (() => void) | undefined,
13
+ rejectGate: ((error: Error) => void) | undefined;
14
+ const lost = (error: Error) => {
15
+ abort.abort();
16
+ rejectGate?.(error);
17
+ };
18
+ socket.on("close", () => lost(Error("Herdr coordinator connection closed")));
19
+ const send = wire(
20
+ socket,
21
+ (message) => {
22
+ if (message?.type === "cancel") {
23
+ abort.abort();
24
+ rejectGate?.(Error("Herdr coordinator cancelled startup"));
25
+ return;
26
+ }
27
+ if (message?.type === "start" && authenticated) {
28
+ acknowledge?.();
29
+ acknowledge = undefined;
30
+ rejectGate = undefined;
31
+ return;
32
+ }
33
+ if (message?.type === "display" && authenticated && typeof message.text === "string") {
34
+ process.stdout.write(message.text);
35
+ return;
36
+ }
37
+ if (message?.type !== "request" || started) {
38
+ lost(Error("invalid Herdr coordinator request"));
39
+ socket.destroy();
40
+ return;
41
+ }
42
+ started = true;
43
+ authenticated = true;
44
+ const request = message.request as OwnedChildRunRequest & { sessionPath?: string };
45
+ let childPid: number | undefined;
46
+ void (async () => {
47
+ try {
48
+ const result = await runOwnedChild({
49
+ ...request,
50
+ signal: abort.signal,
51
+ activityProbe: async () => {
52
+ const file = request.sessionPath
53
+ ? await stat(request.sessionPath)
54
+ .then((s) => String(s.size) + ":" + s.mtimeMs)
55
+ .catch(() => undefined)
56
+ : undefined;
57
+ const tree = childPid === undefined ? undefined : await processTreeActivity(childPid);
58
+ return file === undefined && tree === undefined ? undefined : String(file) + "|" + tree;
59
+ },
60
+ onOwnership: (identity) =>
61
+ new Promise<void>((resolve, reject) => {
62
+ acknowledge = resolve;
63
+ rejectGate = reject;
64
+ if (abort.signal.aborted) {
65
+ reject(Error("Herdr coordinator unavailable before ownership gate"));
66
+ return;
67
+ }
68
+ send({ type: "ownership", identity });
69
+ }),
70
+ onObservation: (stream, bytes) => {
71
+ try {
72
+ const input = Buffer.from(bytes);
73
+ for (let offset = 0; offset < input.length; offset += 65536)
74
+ send({ type: "bytes", stream, data: input.subarray(offset, offset + 65536).toString("base64") });
75
+ } catch (error) {
76
+ lost(error instanceof Error ? error : Error(String(error)));
77
+ }
78
+ },
79
+ onStreamEnd: (stream) => {
80
+ try {
81
+ send({ type: "stream-end", stream });
82
+ } catch {}
83
+ },
84
+ onSpawn: (pid) => {
85
+ childPid = pid;
86
+ send({ type: "spawned", pid });
87
+ process.stdout.write("pi-daddy: governed child running\n");
88
+ },
89
+ });
90
+ if (!socket.destroyed) {
91
+ send({ type: "result", result });
92
+ socket.end();
93
+ }
94
+ process.stdout.write("\npi-daddy: " + result.cleanup.state + "\n");
95
+ } catch (error) {
96
+ if (!socket.destroyed) {
97
+ try {
98
+ send({ type: "failure", reason: String(error) });
99
+ } finally {
100
+ socket.end();
101
+ }
102
+ }
103
+ }
104
+ })();
105
+ },
106
+ lost,
107
+ );
108
+ await new Promise<void>((resolve, reject) => {
109
+ socket.once("connect", () => {
110
+ try {
111
+ send({ type: "hello", token });
112
+ } catch (error) {
113
+ reject(error);
114
+ }
115
+ });
116
+ socket.once("close", resolve);
117
+ socket.once("error", reject);
118
+ });
119
+ }
120
+ if (/herdr-launcher\.(?:js|ts)$/.test(process.argv[1] ?? "")) {
121
+ const [, , path, token] = process.argv;
122
+ if (!path || !token) throw Error("Herdr launcher requires its private socket and nonce");
123
+ await launchHerdrWorker(path, token);
124
+ }
@@ -0,0 +1,260 @@
1
+ /** Governed work runs in its own Herdr pane, under the same independently settling native owner as captured work. */
2
+ import { createServer, type Socket } from "node:net";
3
+ import { randomUUID } from "node:crypto";
4
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import type { OwnedChildRunRequest, OwnedChildRunResult } from "./owned-worker.ts";
9
+ import type { CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
10
+ import {
11
+ isCapturedWorkerIdentity,
12
+ readCapturedWorkerReceipt,
13
+ validateLiveWorker,
14
+ } from "../governance/captured-worker-record.ts";
15
+ import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
16
+ import { WORKER_SHA256 } from "./worker-artifact.ts";
17
+ import { wire } from "./herdr-wire.ts";
18
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
19
+ export interface HerdrOwnedOptions {
20
+ exec?: HerdrExec;
21
+ workspace?: string;
22
+ keepPane?: boolean;
23
+ onPane?: (paneId: string, tabId: string) => void;
24
+ onDisplay?: (display: (text: string) => void) => void;
25
+ /** Tests may select a freshly built launcher. Production always uses the packaged build. */
26
+ launcherPath?: string;
27
+ }
28
+ export async function runHerdrOwned(
29
+ request: OwnedChildRunRequest,
30
+ options: HerdrOwnedOptions = {},
31
+ ): Promise<OwnedChildRunResult> {
32
+ const exec = options.exec ?? defaultExec;
33
+ const directory = await mkdtemp(join(tmpdir(), "pd-herdr-"));
34
+ const path = join(directory, "owner.sock"),
35
+ token = randomUUID();
36
+ const launcher =
37
+ options.launcherPath ?? fileURLToPath(new URL("../../dist/executors/herdr-launcher.js", import.meta.url));
38
+ const helper = fileURLToPath(new URL("../../native/linux-x64/worker", import.meta.url));
39
+ let socket: Socket | undefined, send: ((value: unknown) => void) | undefined;
40
+ let identity: CapturedWorkerIdentity | undefined, outcome: OwnedChildRunResult | undefined, error: string | undefined;
41
+ let tabId: string | undefined,
42
+ entered = false,
43
+ done = false,
44
+ authenticated = false;
45
+ let finish!: () => void;
46
+ const startupTimer = setTimeout(
47
+ () => {
48
+ fail("Herdr launcher did not connect before startup deadline");
49
+ socket?.destroy();
50
+ finish();
51
+ },
52
+ Math.min(request.timeoutMs ?? 10000, 10000),
53
+ );
54
+ const completed = new Promise<void>((resolve) => {
55
+ finish = resolve;
56
+ });
57
+ const abort = () => {
58
+ try {
59
+ send?.({ type: "cancel" });
60
+ } catch {}
61
+ };
62
+ const fail = (cause: unknown) => {
63
+ error ??= String(cause);
64
+ abort();
65
+ };
66
+ const server = createServer((candidate) => {
67
+ if (socket) {
68
+ candidate.destroy();
69
+ return;
70
+ }
71
+ socket = candidate;
72
+ send = wire(
73
+ candidate,
74
+ (message) => {
75
+ if (!authenticated) {
76
+ if (message?.type !== "hello" || message.token !== token) throw Error("Herdr launcher nonce mismatch");
77
+ authenticated = true;
78
+ clearTimeout(startupTimer);
79
+ const {
80
+ signal: _signal,
81
+ onOwnership: _owner,
82
+ onSpawn: _spawn,
83
+ onOutput: _out,
84
+ onObservation: _observation,
85
+ onStreamEnd: _end,
86
+ activityProbe: _probe,
87
+ ...serializable
88
+ } = request;
89
+ send!({ type: "request", request: serializable });
90
+ return;
91
+ }
92
+ if (message?.type === "ownership") {
93
+ if (identity || !isCapturedWorkerIdentity(message.identity)) throw Error("ambiguous Herdr worker ownership");
94
+ const actual = message.identity as CapturedWorkerIdentity;
95
+ if (
96
+ actual.executionId !== request.executionId ||
97
+ actual.root !== request.cwd ||
98
+ actual.helperSha256 !== WORKER_SHA256 ||
99
+ actual.ownershipPath !== join(request.ownershipDir, "ownership.json")
100
+ )
101
+ throw Error("Herdr worker identity differs from requested execution");
102
+ identity = actual;
103
+ void (async () => {
104
+ try {
105
+ await validateLiveWorker(actual, helper);
106
+ await request.onOwnership?.(actual);
107
+ if (!done && !request.signal?.aborted && !error) {
108
+ request.onSpawn?.(actual.workerPid);
109
+ send!({ type: "start" });
110
+ } else abort();
111
+ } catch (cause) {
112
+ fail(cause);
113
+ abort();
114
+ }
115
+ })();
116
+ return;
117
+ }
118
+ if (
119
+ message?.type === "bytes" &&
120
+ ["stdout", "stderr"].includes(message.stream) &&
121
+ typeof message.data === "string"
122
+ ) {
123
+ if (
124
+ !identity ||
125
+ message.data.length > 90000 ||
126
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(message.data)
127
+ )
128
+ throw Error("invalid Herdr output frame");
129
+ try {
130
+ request.onObservation?.(message.stream, Buffer.from(message.data, "base64"));
131
+ } catch {}
132
+ return;
133
+ }
134
+ if (message?.type === "stream-end" && ["stdout", "stderr"].includes(message.stream)) {
135
+ try {
136
+ request.onStreamEnd?.(message.stream);
137
+ } catch {}
138
+ return;
139
+ }
140
+ if (message?.type === "spawned" && identity && message.pid === identity.workerPid) {
141
+ return;
142
+ }
143
+ if (message?.type === "result" && !outcome) {
144
+ outcome = message.result;
145
+ return;
146
+ }
147
+ if (message?.type === "failure") {
148
+ fail(message.reason);
149
+ return;
150
+ }
151
+ throw Error("invalid Herdr worker transport message");
152
+ },
153
+ fail,
154
+ );
155
+ options.onDisplay?.((text) => {
156
+ if (authenticated && send) {
157
+ for (let at = 0; at < text.length; at += 16000) send({ type: "display", text: text.slice(at, at + 16000) });
158
+ }
159
+ });
160
+ candidate.on("close", finish);
161
+ });
162
+ const timeout = setTimeout(
163
+ () => {
164
+ fail("Herdr execution exceeded its transport deadline");
165
+ socket?.destroy();
166
+ finish();
167
+ },
168
+ (request.timeoutMs ?? 3600000) + Math.min(request.killGraceMs ?? 1500, 60000) + 7000,
169
+ );
170
+ request.signal?.addEventListener("abort", abort, { once: true });
171
+ try {
172
+ await new Promise<void>((resolve, reject) => {
173
+ server.once("error", reject);
174
+ server.listen(path, resolve);
175
+ });
176
+ await chmod(path, 0o600);
177
+ if (request.signal?.aborted || (request.hardDeadlineAt !== undefined && request.hardDeadlineAt <= Date.now()))
178
+ return {
179
+ code: null,
180
+ text: "",
181
+ truncated: false,
182
+ timedOut: false,
183
+ aborted: true,
184
+ cleanup: { state: "not-started", reason: "cancelled before Herdr pane launch" },
185
+ };
186
+ const created = parseReply(
187
+ await exec([
188
+ "tab",
189
+ "create",
190
+ "--cwd",
191
+ request.cwd,
192
+ "--label",
193
+ "pi-daddy governed child",
194
+ "--no-focus",
195
+ ...(options.workspace ? ["--workspace", options.workspace] : []),
196
+ ]),
197
+ );
198
+ if (created.error) throw Error(created.error);
199
+ const pane = created.result?.root_pane as { pane_id?: string; tab_id?: string } | undefined;
200
+ tabId = pane?.tab_id;
201
+ if (!pane?.pane_id || !tabId) throw Error("Herdr creation returned no exact pane/tab identity");
202
+ options.onPane?.(pane.pane_id, tabId);
203
+ if (error || request.signal?.aborted) throw Error(error ?? "cancelled before Herdr command launch");
204
+ entered = true;
205
+ const launchReply = await exec([
206
+ "pane",
207
+ "run",
208
+ pane.pane_id,
209
+ (options.keepPane ? "" : "exec ") + [process.execPath, launcher, path, token].map(quote).join(" "),
210
+ ]);
211
+ if (launchReply.code !== 0) throw Error(parseReply(launchReply).error ?? "Herdr pane launch failed");
212
+ if (launchReply.stdout.trim()) {
213
+ const launched = parseReply(launchReply);
214
+ if (launched.error) throw Error(launched.error);
215
+ }
216
+ await completed;
217
+ if (outcome?.cleanup?.state === "not-started" && !identity) return outcome;
218
+ if (!outcome || !identity) throw Error(error ?? "Herdr launcher closed without a complete result/ownership");
219
+ const receipt = await readCapturedWorkerReceipt(identity);
220
+ if (!receipt) throw Error("Herdr worker supplied no independently verified cleanup receipt");
221
+ return { ...outcome, cleanup: { state: "settled", identity, receipt }, ...(error ? { spawnError: error } : {}) };
222
+ } catch (cause) {
223
+ fail(cause);
224
+ socket?.destroy();
225
+ // Lost transport is never evidence of cleanup. Reconcile only the original bound receipt.
226
+ let receipt = identity ? await readCapturedWorkerReceipt(identity) : null;
227
+ const deadline = Date.now() + Math.min(request.killGraceMs ?? 1500, 60000) + 5500;
228
+ while (identity && !receipt && Date.now() < deadline) {
229
+ await new Promise((resolve) => setTimeout(resolve, 50));
230
+ receipt = await readCapturedWorkerReceipt(identity);
231
+ }
232
+ return {
233
+ code: receipt?.workerCode ?? null,
234
+ text: "",
235
+ truncated: false,
236
+ timedOut: false,
237
+ aborted: Boolean(request.signal?.aborted),
238
+ spawnError: error ?? String(cause),
239
+ cleanup:
240
+ identity && receipt
241
+ ? { state: "settled", identity, receipt }
242
+ : entered
243
+ ? { state: "unknown", ...(identity ? { identity } : {}), reason: error ?? String(cause) }
244
+ : { state: "not-started", reason: error ?? String(cause) },
245
+ };
246
+ } finally {
247
+ done = true;
248
+ clearTimeout(timeout);
249
+ clearTimeout(startupTimer);
250
+ request.signal?.removeEventListener("abort", abort);
251
+ socket?.destroy();
252
+ await new Promise<void>((resolve) => server.close(() => resolve()));
253
+ if (tabId && !options.keepPane) {
254
+ const closed = parseReply(await exec(["tab", "close", tabId]));
255
+ if (closed.error && !/tab .* not found|tab_not_found/.test(closed.error))
256
+ console.error("pi-daddy: settled worker pane could not close:", closed.error);
257
+ }
258
+ await rm(directory, { recursive: true, force: true });
259
+ }
260
+ }
@@ -0,0 +1,33 @@
1
+ /** Measured transport matrix. Reachability alone never qualifies a backend or changes execution location. */
2
+ import { defaultExec, type HerdrExec, type HerdrProbe } from "./herdr-cli.ts";
3
+ export const QUALIFIED_HERDR_VERSION = "0.8.2";
4
+ export const QUALIFIED_HERDR_PROTOCOL = 20;
5
+ export async function qualifyHerdr(probe: HerdrProbe, exec: HerdrExec = defaultExec): Promise<HerdrProbe> {
6
+ if (!probe.ok) return probe;
7
+ try {
8
+ if (process.platform !== "linux" || process.arch !== "x64") throw Error("Herdr owned execution requires Linux x64");
9
+ const [client, server] = await Promise.all([exec(["--version"]), exec(["status", "server"])]);
10
+ if (client.code !== 0 || client.stdout.trim() !== "herdr " + QUALIFIED_HERDR_VERSION)
11
+ throw Error("Herdr client version is not qualified");
12
+ const fields = Object.fromEntries(
13
+ server.stdout
14
+ .trim()
15
+ .split("\n")
16
+ .map((line) => {
17
+ const at = line.indexOf(":");
18
+ return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
19
+ }),
20
+ );
21
+ if (
22
+ server.code !== 0 ||
23
+ fields.status !== "running" ||
24
+ fields.version !== QUALIFIED_HERDR_VERSION ||
25
+ fields.protocol !== String(QUALIFIED_HERDR_PROTOCOL) ||
26
+ fields.compatible !== "yes"
27
+ )
28
+ throw Error("Herdr server version/protocol is not qualified");
29
+ return { ...probe, qualified: true };
30
+ } catch (error) {
31
+ return { ...probe, qualified: false, qualificationReason: String(error) };
32
+ }
33
+ }
@@ -0,0 +1,43 @@
1
+ /** Bounded local framing; this channel carries private execution input only between its two owners. */
2
+ import type { Socket } from "node:net";
3
+ export const HERDR_WIRE_LIMIT = 8 * 1024 * 1024;
4
+ export function wire(socket: Socket, receive: (value: any) => void, failed: (error: Error) => void) {
5
+ let pending = "";
6
+ const decoder = new TextDecoder("utf-8", { fatal: true });
7
+ const fail = (error: unknown) => {
8
+ failed(error instanceof Error ? error : new Error(String(error)));
9
+ socket.destroy();
10
+ };
11
+ socket.on("data", (bytes) => {
12
+ try {
13
+ pending += decoder.decode(bytes, { stream: true });
14
+ let end: number;
15
+ while ((end = pending.indexOf("\n")) >= 0) {
16
+ if (Buffer.byteLength(pending.slice(0, end)) > HERDR_WIRE_LIMIT)
17
+ throw Error("Herdr transport record exceeds bound");
18
+ const line = pending.slice(0, end);
19
+ pending = pending.slice(end + 1);
20
+ receive(JSON.parse(line));
21
+ }
22
+ if (Buffer.byteLength(pending) > HERDR_WIRE_LIMIT) throw Error("Herdr transport record exceeds bound");
23
+ } catch (error) {
24
+ fail(error);
25
+ }
26
+ });
27
+ socket.on("end", () => {
28
+ try {
29
+ pending += decoder.decode();
30
+ if (pending) throw Error("Herdr transport record is incomplete");
31
+ } catch (error) {
32
+ fail(error);
33
+ }
34
+ });
35
+ socket.on("error", (error) => failed(error));
36
+ return (value: unknown) => {
37
+ const text = JSON.stringify(value) + "\n";
38
+ if (Buffer.byteLength(text) > HERDR_WIRE_LIMIT || socket.writableLength > HERDR_WIRE_LIMIT * 2)
39
+ throw Error("Herdr transport buffering exceeds bound");
40
+ if (socket.destroyed || !socket.writable) throw Error("Herdr transport is closed");
41
+ socket.write(text);
42
+ };
43
+ }
@@ -43,21 +43,22 @@ export interface PromptRequest {
43
43
  signal?: AbortSignal;
44
44
  }
45
45
 
46
- /**
47
- * Which of the five outcomes a request produced.
48
- *
49
- * `scope: null` alone conflates four different kinds of no — a caller that needs to know whether a
50
- * PERSON actually declined (as opposed to nobody being there to ask, a dismissal, or the dialog itself
51
- * breaking) cannot recover that from `scope` or from parsing `reason`. Only "declined" means a human said
52
- * no; the other three are absence-of-signal, not a signal.
53
- */
54
- export const PROMPT_OUTCOME_KINDS = ["granted", "declined", "dismissed", "no-ui", "error"] as const;
46
+ /** Only declined means a human chose Deny. Expiry, cancellation and dismissal grant nothing. */
47
+ export const PROMPT_OUTCOME_KINDS = [
48
+ "granted",
49
+ "declined",
50
+ "dismissed",
51
+ "expired",
52
+ "aborted",
53
+ "no-ui",
54
+ "error",
55
+ ] as const;
55
56
  export type PromptOutcomeKind = (typeof PROMPT_OUTCOME_KINDS)[number];
56
57
 
57
58
  export interface PromptOutcome {
58
59
  /** The scope the human chose, or null for any form of no. */
59
60
  scope: ApprovalScope | null;
60
- /** Which of the five outcomes this was. Set on every return path. */
61
+ /** Which outcome this was. Set on every return path. */
61
62
  kind: PromptOutcomeKind;
62
63
  /** Why, when the answer was no. */
63
64
  reason?: string;
@@ -87,17 +88,14 @@ export interface ApprovalGate {
87
88
  request(request: PromptRequest): Promise<PromptOutcome>;
88
89
  }
89
90
 
90
- const DEFAULT_TIMEOUT_MS = 120_000;
91
-
92
91
  /**
93
92
  * Read `PI_DADDY_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
94
93
  *
95
- * Only literal `0` disables the deadline. Other values must be canonical whole seconds
94
+ * Absent or literal `0` waits for the human without a deadline. Other values must be canonical whole seconds
96
95
  * within Node's signed 32-bit millisecond timer limit; malformed settings refuse a needed prompt.
97
96
  */
98
97
  export function timeoutMsFromEnv(raw: string | undefined): number | undefined {
99
- if (raw === undefined) return DEFAULT_TIMEOUT_MS;
100
- if (raw === "0") return undefined;
98
+ if (raw === undefined || raw === "0") return undefined;
101
99
  if (!/^[1-9][0-9]*$/.test(raw) || Number(raw) > 2_147_483)
102
100
  throw new Error(
103
101
  "PI_DADDY_APPROVAL_TIMEOUT must be literal 0 or whole seconds 1..2147483 without signs, padding or units",
@@ -108,6 +106,64 @@ function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | n
108
106
  return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
109
107
  }
110
108
 
109
+ const interrupted = (request: PromptRequest, kind: "expired" | "aborted"): PromptOutcome => ({
110
+ scope: null,
111
+ kind,
112
+ reason:
113
+ `approval for ${request.capability} ${kind === "expired" ? "expired before an answer" : "was canceled"}. ` +
114
+ "No permission was granted. Retry the delegation to open a new approval prompt.",
115
+ });
116
+
117
+ /** Own the deadline so Pi's undefined dismissal cannot erase whether our timer or cancellation fired. */
118
+ async function selectApproval(
119
+ options: ApprovalGateOptions,
120
+ request: PromptRequest,
121
+ title: string,
122
+ labels: string[],
123
+ ): Promise<string | undefined | PromptOutcome> {
124
+ const timeout = typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs;
125
+ const controller = new AbortController();
126
+ let outcome: PromptOutcome | undefined;
127
+ let wake!: (value: PromptOutcome) => void;
128
+ const stopped = new Promise<PromptOutcome>((resolve) => {
129
+ wake = resolve;
130
+ });
131
+ const stop = (kind: "expired" | "aborted") => {
132
+ if (outcome) return;
133
+ outcome = interrupted(request, kind);
134
+ // Resolve our tagged outcome before a UI reacts to abort by returning undefined.
135
+ wake(outcome);
136
+ controller.abort();
137
+ };
138
+ const abort = () => stop("aborted");
139
+ request.signal?.addEventListener("abort", abort, { once: true });
140
+ const timer = timeout === undefined ? undefined : setTimeout(() => stop("expired"), timeout);
141
+ try {
142
+ if (request.signal?.aborted) stop("aborted");
143
+ if (outcome) return outcome;
144
+ const chosen = await Promise.race([options.ui.select(title, labels, { signal: controller.signal }), stopped]);
145
+ return outcome ?? chosen;
146
+ } finally {
147
+ if (timer !== undefined) clearTimeout(timer);
148
+ request.signal?.removeEventListener("abort", abort);
149
+ }
150
+ }
151
+
152
+ async function joinApproval(pending: Promise<PromptOutcome>, request: PromptRequest): Promise<PromptOutcome> {
153
+ if (!request.signal) return pending;
154
+ let abort!: () => void;
155
+ const stopped = new Promise<PromptOutcome>((resolve) => {
156
+ abort = () => resolve(interrupted(request, "aborted"));
157
+ request.signal!.addEventListener("abort", abort, { once: true });
158
+ if (request.signal!.aborted) abort();
159
+ });
160
+ try {
161
+ return await Promise.race([pending, stopped]);
162
+ } finally {
163
+ request.signal.removeEventListener("abort", abort);
164
+ }
165
+ }
166
+
111
167
  /**
112
168
  * The single-flight queue: one dialog per approval key at a time.
113
169
  *
@@ -160,24 +216,27 @@ export function createApprovalGate(
160
216
  `grants: approve ${request.capability} for ${request.subject}?` +
161
217
  (request.task ? `\n task: ${request.task}` : "");
162
218
 
163
- let chosen: string | undefined;
219
+ let chosen: string | undefined | PromptOutcome;
164
220
  try {
165
- chosen = await options.ui.select(title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])], {
166
- timeout: typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs,
167
- signal: request.signal,
168
- });
221
+ chosen = await selectApproval(options, request, title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])]);
169
222
  } catch (error) {
170
223
  // A governance layer that errors must deny, not permit. This is a dialog malfunction, not a
171
224
  // person's answer — `kind: "error"` keeps it out of "declined".
172
225
  return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
173
226
  }
174
227
 
175
- if (request.signal?.aborted)
176
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
228
+ if (request.signal?.aborted) return interrupted(request, "aborted");
177
229
 
178
- // undefined covers dismissal, timeout, and abort. All are a no, but none is a person saying no.
230
+ if (typeof chosen === "object") return chosen;
231
+ // Only an ordinary UI dismissal reaches undefined; our deadline and abort carry explicit outcomes.
179
232
  if (chosen === undefined) {
180
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was dismissed` };
233
+ return {
234
+ scope: null,
235
+ kind: "dismissed",
236
+ reason:
237
+ `approval for ${request.capability} was dismissed. No permission was granted. ` +
238
+ "Retry the delegation to open a new approval prompt.",
239
+ };
181
240
  }
182
241
  if (chosen === DENY_LABEL) {
183
242
  return { scope: null, kind: "declined", reason: `${request.capability} was denied by a human` };
@@ -191,8 +250,7 @@ export function createApprovalGate(
191
250
 
192
251
  return {
193
252
  async request(request: PromptRequest): Promise<PromptOutcome> {
194
- if (request.signal?.aborted)
195
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
253
+ if (request.signal?.aborted) return interrupted(request, "aborted");
196
254
  if (!options.hasUI) {
197
255
  // Nobody was there to ask — distinct from a person declining, which is why this is its own kind
198
256
  // rather than being folded into "declined" (see PromptOutcomeKind).
@@ -215,9 +273,8 @@ export function createApprovalGate(
215
273
  for (;;) {
216
274
  const existing = inFlight.get(key);
217
275
  if (!existing) break;
218
- const outcome = await existing;
219
- if (request.signal?.aborted)
220
- return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
276
+ const outcome = await joinApproval(existing, request);
277
+ if (request.signal?.aborted) return interrupted(request, "aborted");
221
278
  // **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
222
279
  // is correct — the human authorised the capability for the session, not for one child — but the
223
280
  // caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote
@@ -23,7 +23,7 @@ const SHA256_RE = /^[a-f0-9]{64}$/i;
23
23
  const EXECUTORS = new Set(["process", "herdr"]);
24
24
  const APPROVAL_SOURCES = new Set(["prompt", "session", "persisted", "inherited"]);
25
25
  const APPROVAL_SCOPES = new Set(["once", "session", "always"]);
26
- const GATE_OUTCOMES = new Set(["declined", "dismissed", "no-ui", "error"]);
26
+ const GATE_OUTCOMES = new Set(["declined", "dismissed", "expired", "aborted", "no-ui", "error"]);
27
27
  const LEASE_OUTCOMES = new Set([
28
28
  "acquired",
29
29
  "uncontended",
@@ -44,7 +44,7 @@ export const LEDGER_EVENT_KINDS = [
44
44
  "episode_outcome",
45
45
  ] as const;
46
46
  export type LedgerEventKind = (typeof LEDGER_EVENT_KINDS)[number];
47
- export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
47
+ export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "expired", "aborted", "no-ui", "error"] as const;
48
48
  export type LedgerGateOutcome = (typeof LEDGER_GATE_OUTCOMES)[number];
49
49
 
50
50
  export interface LedgerEventBase {
@@ -137,9 +137,8 @@ export interface GrantRecord extends LedgerEventBase {
137
137
  * The discriminant was already computed and thrown away. ADR-0026 leans on this vocabulary being able to
138
138
  * say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
139
139
  *
140
- * **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
141
- * a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
142
- * four-member ledger enum because the approval source/scope fields already record a yes.
140
+ * **Privacy is unchanged**: this is a fixed enum, not task-authored text. Explicit expiry and caller
141
+ * cancellation have their own outcomes; `granted` is omitted because approval source/scope records a yes.
143
142
  */
144
143
  gateOutcome?: LedgerGateOutcome;
145
144
  /**