pi-daddy 0.43.0 → 0.44.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.
- package/CHANGELOG.md +104 -33
- package/README.md +27 -5
- package/THIRD_PARTY_LICENSES/musl-COPYRIGHT.txt +193 -0
- package/THIRD_PARTY_LICENSES/zig-MIT.txt +21 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/contracts/ledger-record/v1/README.md +40 -25
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -5
- package/dist/cli.js.map +1 -1
- package/dist/executors/activity-session.js +116 -65
- package/dist/executors/activity-session.js.map +1 -1
- package/dist/executors/captured-execution.d.ts +13 -0
- package/dist/executors/captured-execution.d.ts.map +1 -0
- package/dist/executors/captured-execution.js +53 -0
- package/dist/executors/captured-execution.js.map +1 -0
- package/dist/executors/child-final.d.ts +31 -0
- package/dist/executors/child-final.d.ts.map +1 -0
- package/dist/executors/child-final.js +197 -0
- package/dist/executors/child-final.js.map +1 -0
- package/dist/executors/executor.d.ts +2 -1
- package/dist/executors/executor.d.ts.map +1 -1
- package/dist/executors/executor.js +12 -0
- package/dist/executors/executor.js.map +1 -1
- package/dist/executors/owned-worker.d.ts +18 -0
- package/dist/executors/owned-worker.d.ts.map +1 -0
- package/dist/executors/owned-worker.js +295 -0
- package/dist/executors/owned-worker.js.map +1 -0
- package/dist/executors/worker-artifact.d.ts +3 -0
- package/dist/executors/worker-artifact.d.ts.map +1 -0
- package/dist/executors/worker-artifact.js +3 -0
- package/dist/executors/worker-artifact.js.map +1 -0
- package/dist/governance/approval-prompt.d.ts +4 -3
- package/dist/governance/approval-prompt.d.ts.map +1 -1
- package/dist/governance/approval-prompt.js +13 -6
- package/dist/governance/approval-prompt.js.map +1 -1
- package/dist/governance/captured-worker-record.d.ts +9 -0
- package/dist/governance/captured-worker-record.d.ts.map +1 -0
- package/dist/governance/captured-worker-record.js +123 -0
- package/dist/governance/captured-worker-record.js.map +1 -0
- package/dist/governance/lease-record.d.ts +4 -0
- package/dist/governance/lease-record.d.ts.map +1 -1
- package/dist/governance/lease-record.js +19 -2
- package/dist/governance/lease-record.js.map +1 -1
- package/dist/governance/ledger-events.d.ts +5 -5
- package/dist/governance/ledger-events.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +1 -1
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/workspace-acceptance.d.ts +3 -1
- package/dist/governance/workspace-acceptance.d.ts.map +1 -1
- package/dist/governance/workspace-acceptance.js +21 -5
- package/dist/governance/workspace-acceptance.js.map +1 -1
- package/dist/governance/workspace-lease.d.ts.map +1 -1
- package/dist/governance/workspace-lease.js +44 -1
- package/dist/governance/workspace-lease.js.map +1 -1
- package/dist/kernel/authority-text.d.ts +3 -0
- package/dist/kernel/authority-text.d.ts.map +1 -0
- package/dist/kernel/authority-text.js +22 -0
- package/dist/kernel/authority-text.js.map +1 -0
- package/dist/kernel/bounded-read-failures.d.ts +9 -0
- package/dist/kernel/bounded-read-failures.d.ts.map +1 -0
- package/dist/kernel/bounded-read-failures.js +33 -0
- package/dist/kernel/bounded-read-failures.js.map +1 -0
- package/dist/kernel/bounded-read-sync.d.ts +24 -0
- package/dist/kernel/bounded-read-sync.d.ts.map +1 -0
- package/dist/kernel/bounded-read-sync.js +75 -0
- package/dist/kernel/bounded-read-sync.js.map +1 -0
- package/dist/kernel/bounded-read.d.ts +29 -2
- package/dist/kernel/bounded-read.d.ts.map +1 -1
- package/dist/kernel/bounded-read.js +98 -9
- package/dist/kernel/bounded-read.js.map +1 -1
- package/dist/kernel/capacity.d.ts +36 -0
- package/dist/kernel/capacity.d.ts.map +1 -0
- package/dist/kernel/capacity.js +94 -0
- package/dist/kernel/capacity.js.map +1 -0
- package/dist/kernel/captured-worker-contract.d.ts +38 -0
- package/dist/kernel/captured-worker-contract.d.ts.map +1 -0
- package/dist/kernel/captured-worker-contract.js +2 -0
- package/dist/kernel/captured-worker-contract.js.map +1 -0
- package/dist/kernel/catalog.d.ts +12 -3
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +28 -8
- package/dist/kernel/catalog.js.map +1 -1
- package/dist/kernel/chain.d.ts +1 -0
- package/dist/kernel/chain.d.ts.map +1 -1
- package/dist/kernel/chain.js.map +1 -1
- package/dist/kernel/definitions.d.ts +11 -1
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js +6 -3
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/fanout.d.ts +5 -25
- package/dist/kernel/fanout.d.ts.map +1 -1
- package/dist/kernel/fanout.js +15 -30
- package/dist/kernel/fanout.js.map +1 -1
- package/dist/kernel/grant-env.d.ts +0 -2
- package/dist/kernel/grant-env.d.ts.map +1 -1
- package/dist/kernel/grant-env.js +0 -1
- package/dist/kernel/grant-env.js.map +1 -1
- package/dist/kernel/model-preflight.d.ts +8 -0
- package/dist/kernel/model-preflight.d.ts.map +1 -1
- package/dist/kernel/model-preflight.js +23 -0
- package/dist/kernel/model-preflight.js.map +1 -1
- package/dist/kernel/selected-definitions.d.ts +15 -0
- package/dist/kernel/selected-definitions.d.ts.map +1 -0
- package/dist/kernel/selected-definitions.js +146 -0
- package/dist/kernel/selected-definitions.js.map +1 -0
- package/dist/kernel/skill-packages.d.ts +4 -2
- package/dist/kernel/skill-packages.d.ts.map +1 -1
- package/dist/kernel/skill-packages.js +34 -41
- package/dist/kernel/skill-packages.js.map +1 -1
- package/dist/kernel/skill-resources.d.ts.map +1 -1
- package/dist/kernel/skill-resources.js +8 -11
- package/dist/kernel/skill-resources.js.map +1 -1
- package/dist/kernel/workspace.d.ts +6 -3
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +10 -6
- package/dist/kernel/workspace.js.map +1 -1
- package/dist/products/dashboard-cli.js +1 -1
- package/dist/products/dashboard-cli.js.map +1 -1
- package/dist/products/dashboard-render.d.ts +1 -2
- package/dist/products/dashboard-render.d.ts.map +1 -1
- package/dist/products/dashboard-render.js +3 -1
- package/dist/products/dashboard-render.js.map +1 -1
- package/dist/products/dashboard-session-client.d.ts +1 -2
- package/dist/products/dashboard-session-client.d.ts.map +1 -1
- package/dist/products/dashboard-session-client.js.map +1 -1
- package/dist/products/episode-report.d.ts +12 -8
- package/dist/products/episode-report.d.ts.map +1 -1
- package/dist/products/episode-report.js +61 -12
- package/dist/products/episode-report.js.map +1 -1
- package/extensions/approvals.ts +1 -1
- package/extensions/chain-plan.ts +9 -3
- package/extensions/context-staging.ts +30 -35
- package/extensions/dashboard-session-server.ts +1 -2
- package/extensions/definition-describe.ts +44 -0
- package/extensions/definition-runtime.ts +89 -26
- package/extensions/delegate-chain.ts +273 -256
- package/extensions/delegation-activation.ts +1 -1
- package/extensions/delegation.ts +41 -53
- package/extensions/execute-child.ts +167 -134
- package/extensions/fanout-outcome.ts +1 -9
- package/extensions/grants-command.ts +2 -9
- package/extensions/grants.ts +106 -38
- package/extensions/init-command.ts +52 -12
- package/extensions/reload-environment.ts +21 -2
- package/extensions/retired-inputs.ts +7 -0
- package/extensions/run-delegation.ts +212 -265
- package/extensions/session-capacity.ts +53 -0
- package/extensions/session-environment.ts +5 -2
- package/extensions/session-model-prompt.ts +33 -32
- package/extensions/session-report.ts +28 -11
- package/extensions/session.ts +200 -157
- package/extensions/workspace-runtime.ts +2 -1
- package/native/README.md +25 -0
- package/native/linux-x64/worker +0 -0
- package/native/linux-x64/worker.sha256 +1 -0
- package/native/provenance.json +42 -0
- package/native/toolchain.json +18 -0
- package/native/worker.c +209 -0
- package/package.json +15 -7
- package/src/cli.ts +16 -6
- package/src/executors/activity-session.ts +120 -66
- package/src/executors/captured-execution.ts +61 -0
- package/src/executors/child-final.ts +190 -0
- package/src/executors/executor.ts +14 -1
- package/src/executors/owned-worker.ts +322 -0
- package/src/executors/worker-artifact.ts +2 -0
- package/src/governance/approval-prompt.ts +18 -8
- package/src/governance/captured-worker-record.ts +138 -0
- package/src/governance/lease-record.ts +21 -2
- package/src/governance/ledger-events.ts +5 -5
- package/src/governance/ledger-v3-validation.ts +1 -1
- package/src/governance/workspace-acceptance.ts +20 -4
- package/src/governance/workspace-lease.ts +59 -1
- package/src/kernel/authority-text.ts +17 -0
- package/src/kernel/bounded-read-failures.ts +42 -0
- package/src/kernel/bounded-read-sync.ts +87 -0
- package/src/kernel/bounded-read.ts +128 -11
- package/src/kernel/capacity.ts +123 -0
- package/src/kernel/captured-worker-contract.ts +29 -0
- package/src/kernel/catalog.ts +45 -16
- package/src/kernel/chain.ts +1 -0
- package/src/kernel/definitions.ts +11 -2
- package/src/kernel/fanout.ts +17 -32
- package/src/kernel/grant-env.ts +0 -3
- package/src/kernel/model-preflight.ts +29 -0
- package/src/kernel/selected-definitions.ts +156 -0
- package/src/kernel/skill-packages.ts +40 -38
- package/src/kernel/skill-resources.ts +8 -9
- package/src/kernel/workspace.ts +12 -5
- package/src/products/dashboard-cli.ts +1 -1
- package/src/products/dashboard-render.ts +6 -2
- package/src/products/dashboard-session-client.ts +1 -2
- package/src/products/episode-report.ts +80 -17
- package/dist/advisors/advisor.d.ts +0 -90
- package/dist/advisors/advisor.d.ts.map +0 -1
- package/dist/advisors/advisor.js +0 -154
- package/dist/advisors/advisor.js.map +0 -1
- package/dist/advisors/decider.d.ts +0 -88
- package/dist/advisors/decider.d.ts.map +0 -1
- package/dist/advisors/decider.js +0 -29
- package/dist/advisors/decider.js.map +0 -1
- package/dist/advisors/jev.d.ts +0 -41
- package/dist/advisors/jev.d.ts.map +0 -1
- package/dist/advisors/jev.js +0 -139
- package/dist/advisors/jev.js.map +0 -1
- package/dist/advisors/settings.d.ts +0 -49
- package/dist/advisors/settings.d.ts.map +0 -1
- package/dist/advisors/settings.js +0 -99
- package/dist/advisors/settings.js.map +0 -1
- package/dist/governance/episode-cost-gate.d.ts +0 -31
- package/dist/governance/episode-cost-gate.d.ts.map +0 -1
- package/dist/governance/episode-cost-gate.js +0 -112
- package/dist/governance/episode-cost-gate.js.map +0 -1
- package/extensions/advisor-session.ts +0 -67
- package/extensions/effort-advice.ts +0 -112
- package/extensions/pruning-advice.ts +0 -133
- package/src/advisors/advisor.ts +0 -246
- package/src/advisors/decider.ts +0 -74
- package/src/advisors/jev.ts +0 -163
- package/src/advisors/settings.ts +0 -115
- package/src/governance/episode-cost-gate.ts +0 -128
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Assemble the qualified owner and primary final channel; optional observations cannot replace either. */
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runOwnedChild, type OwnedChildRunRequest, type OwnedChildRunResult } from "./owned-worker.ts";
|
|
6
|
+
import type { BoundedReadCleanupError } from "../kernel/bounded-read.ts";
|
|
7
|
+
import { ChildFinalCapture, type ChildFinal } from "./child-final.ts";
|
|
8
|
+
export interface CapturedExecutionResult extends OwnedChildRunResult {
|
|
9
|
+
final: ChildFinal;
|
|
10
|
+
diagnostics: string;
|
|
11
|
+
diagnosticsTruncated: boolean;
|
|
12
|
+
}
|
|
13
|
+
export async function runCapturedExecution(
|
|
14
|
+
request: Omit<OwnedChildRunRequest, "ownershipDir" | "captureStdout"> & {
|
|
15
|
+
sessionPath: string;
|
|
16
|
+
onReadCleanup?: (error: BoundedReadCleanupError) => void;
|
|
17
|
+
},
|
|
18
|
+
): Promise<CapturedExecutionResult> {
|
|
19
|
+
let directory: string;
|
|
20
|
+
try {
|
|
21
|
+
directory = await mkdtemp(join(tmpdir(), "pi-daddy-owner-"));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const reason = `ownership directory unavailable before helper launch: ${String(error)}`;
|
|
24
|
+
return {
|
|
25
|
+
code: null,
|
|
26
|
+
text: "",
|
|
27
|
+
truncated: false,
|
|
28
|
+
timedOut: false,
|
|
29
|
+
aborted: false,
|
|
30
|
+
spawnError: reason,
|
|
31
|
+
cleanup: { state: "not-started", reason },
|
|
32
|
+
final: { state: "unavailable", reason },
|
|
33
|
+
diagnostics: "",
|
|
34
|
+
diagnosticsTruncated: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const capture = new ChildFinalCapture(request.onOutput, request.onReadCleanup);
|
|
38
|
+
const args = [...request.args.slice(0, -1), "--mode", "json", request.args.at(-1)!];
|
|
39
|
+
const output = await runOwnedChild({
|
|
40
|
+
...request,
|
|
41
|
+
args,
|
|
42
|
+
ownershipDir: join(directory, "ownership"),
|
|
43
|
+
captureStdout: false,
|
|
44
|
+
stopOnOutputLimit: false,
|
|
45
|
+
onObservation: (stream, bytes) => {
|
|
46
|
+
if (stream === "stdout") capture.observe(Buffer.from(bytes));
|
|
47
|
+
request.onObservation?.(stream, bytes);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const fork = args.indexOf("--fork") >= 0;
|
|
51
|
+
const id = args.indexOf("--session-id");
|
|
52
|
+
const final = await capture.finish(request.sessionPath, fork && id >= 0 ? args[id + 1] : undefined);
|
|
53
|
+
return {
|
|
54
|
+
...output,
|
|
55
|
+
final,
|
|
56
|
+
truncated: false,
|
|
57
|
+
diagnostics: output.text,
|
|
58
|
+
diagnosticsTruncated: output.truncated,
|
|
59
|
+
text: final.state === "complete" ? final.text : (final.diagnosticText ?? ""),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/** Pi 1.0.4 current final: JSON settlement plus the exact persisted session active branch. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readdir } from "node:fs/promises";
|
|
6
|
+
import { SessionManager, type FileEntry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { BoundedReadCleanupError, readBoundedBytes } from "../kernel/bounded-read.ts";
|
|
8
|
+
const MAX_FINAL_BYTES = 4 * 1024 * 1024,
|
|
9
|
+
MAX_LINE_BYTES = 32 * 1024 * 1024,
|
|
10
|
+
MAX_SESSION_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
const object = (x: unknown): x is Record<string, any> => !!x && typeof x === "object" && !Array.isArray(x);
|
|
12
|
+
const hash = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
13
|
+
export type ChildFinal =
|
|
14
|
+
| { state: "complete"; text: string; sessionId: string; messageId: string; leafId: string; sha256: string }
|
|
15
|
+
| { state: "unavailable"; reason: string; diagnosticText?: string };
|
|
16
|
+
export class ChildFinalCapture {
|
|
17
|
+
private decoder = new TextDecoder("utf-8", { fatal: true });
|
|
18
|
+
private buffer = "";
|
|
19
|
+
private problem?: string;
|
|
20
|
+
private sessionId?: string;
|
|
21
|
+
private settled = false;
|
|
22
|
+
private active = new Set<string>();
|
|
23
|
+
private final?: Record<string, any>;
|
|
24
|
+
private user?: Record<string, any>;
|
|
25
|
+
private readonly onText?: (text: string) => void;
|
|
26
|
+
private readonly onReadCleanup?: (error: BoundedReadCleanupError) => void;
|
|
27
|
+
constructor(onText?: (text: string) => void, onReadCleanup?: (error: BoundedReadCleanupError) => void) {
|
|
28
|
+
this.onText = onText;
|
|
29
|
+
this.onReadCleanup = onReadCleanup;
|
|
30
|
+
}
|
|
31
|
+
observe(bytes: Buffer): void {
|
|
32
|
+
if (this.problem) return;
|
|
33
|
+
try {
|
|
34
|
+
this.buffer += this.decoder.decode(bytes, { stream: true });
|
|
35
|
+
let end: number;
|
|
36
|
+
while ((end = this.buffer.indexOf("\n")) >= 0) {
|
|
37
|
+
const line = this.buffer.slice(0, end);
|
|
38
|
+
this.buffer = this.buffer.slice(end + 1);
|
|
39
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) throw Error("protocol line exceeds capture bound");
|
|
40
|
+
if (line.trim()) this.event(JSON.parse(line));
|
|
41
|
+
}
|
|
42
|
+
if (Buffer.byteLength(this.buffer) > MAX_LINE_BYTES) throw Error("protocol line exceeds capture bound");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.problem = String(error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
private event(event: unknown): void {
|
|
48
|
+
if (!object(event) || typeof event.type !== "string") throw Error("malformed protocol event");
|
|
49
|
+
if (event.type === "session") {
|
|
50
|
+
if (this.sessionId || typeof event.id !== "string" || event.version !== 3)
|
|
51
|
+
throw Error("ambiguous session header");
|
|
52
|
+
this.sessionId = event.id;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!this.sessionId) throw Error("protocol event precedes session identity");
|
|
56
|
+
if (
|
|
57
|
+
event.type === "message_update" &&
|
|
58
|
+
event.assistantMessageEvent?.type === "text_delta" &&
|
|
59
|
+
typeof event.assistantMessageEvent.delta === "string"
|
|
60
|
+
) {
|
|
61
|
+
try {
|
|
62
|
+
this.onText?.(event.assistantMessageEvent.delta);
|
|
63
|
+
} catch {
|
|
64
|
+
/* display is observational */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (["agent_start", "turn_start", "message_start", "message_update"].includes(event.type)) {
|
|
68
|
+
this.settled = false;
|
|
69
|
+
this.final = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (event.type === "tool_execution_start") {
|
|
72
|
+
this.settled = false;
|
|
73
|
+
this.final = undefined;
|
|
74
|
+
if (typeof event.toolCallId !== "string" || this.active.has(event.toolCallId))
|
|
75
|
+
throw Error("ambiguous tool start");
|
|
76
|
+
this.active.add(event.toolCallId);
|
|
77
|
+
}
|
|
78
|
+
if (event.type === "tool_execution_end") {
|
|
79
|
+
this.settled = false;
|
|
80
|
+
if (!this.active.delete(event.toolCallId)) throw Error("unmatched tool completion");
|
|
81
|
+
}
|
|
82
|
+
if (event.type === "message_end") {
|
|
83
|
+
if (!object(event.message)) throw Error("malformed message end");
|
|
84
|
+
this.settled = false;
|
|
85
|
+
this.final = undefined;
|
|
86
|
+
if (event.message.role === "user") {
|
|
87
|
+
this.user = event.message;
|
|
88
|
+
this.final = undefined;
|
|
89
|
+
this.settled = false;
|
|
90
|
+
}
|
|
91
|
+
if (event.message.role === "assistant") {
|
|
92
|
+
this.final = event.message;
|
|
93
|
+
this.settled = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (event.type === "agent_settled") this.settled = true;
|
|
97
|
+
}
|
|
98
|
+
private visible(): string {
|
|
99
|
+
if (!Array.isArray(this.final?.content)) return "";
|
|
100
|
+
return this.final.content
|
|
101
|
+
.filter((b: unknown) => object(b) && b.type === "text" && typeof b.text === "string")
|
|
102
|
+
.map((b: any) => b.text)
|
|
103
|
+
.join("");
|
|
104
|
+
}
|
|
105
|
+
async finish(sessionPath: string, expectedSessionId?: string, readSession = readBoundedBytes): Promise<ChildFinal> {
|
|
106
|
+
const unavailable = (reason: string): ChildFinal => ({
|
|
107
|
+
state: "unavailable",
|
|
108
|
+
reason,
|
|
109
|
+
...(this.visible() ? { diagnosticText: this.visible().slice(0, 8192) } : {}),
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
this.buffer += this.decoder.decode();
|
|
113
|
+
if (this.buffer.trim()) throw Error("unterminated protocol record");
|
|
114
|
+
if (this.problem) throw Error(this.problem);
|
|
115
|
+
if (!this.sessionId || (expectedSessionId && this.sessionId !== expectedSessionId))
|
|
116
|
+
throw Error("session identity mismatch");
|
|
117
|
+
if (!this.settled || this.active.size) throw Error("current run is not settled");
|
|
118
|
+
const final = this.final;
|
|
119
|
+
if (
|
|
120
|
+
!final ||
|
|
121
|
+
!this.user ||
|
|
122
|
+
final.stopReason !== "stop" ||
|
|
123
|
+
!Array.isArray(final.content) ||
|
|
124
|
+
final.content.some((b: unknown) => !object(b) || !["text", "thinking"].includes(b.type)) ||
|
|
125
|
+
final.content.some((b: any) => b.type === "text" && typeof b.text !== "string")
|
|
126
|
+
)
|
|
127
|
+
throw Error("no eligible current terminal assistant message");
|
|
128
|
+
const text = this.visible();
|
|
129
|
+
if (!text || Buffer.byteLength(text) > MAX_FINAL_BYTES)
|
|
130
|
+
throw Error("complete final is empty or exceeds capture bound");
|
|
131
|
+
let path = sessionPath;
|
|
132
|
+
if (expectedSessionId) {
|
|
133
|
+
const names = (await readdir(sessionPath)).filter((n) => n.endsWith(`_${expectedSessionId}.jsonl`));
|
|
134
|
+
if (names.length !== 1) throw Error("exact fork session is missing or ambiguous");
|
|
135
|
+
path = join(sessionPath, names[0]);
|
|
136
|
+
}
|
|
137
|
+
const read = await readSession(path, { maxBytes: MAX_SESSION_BYTES, timeoutMs: 3000 });
|
|
138
|
+
if (!read.ok) throw Error(`session read unavailable: ${read.detail}`);
|
|
139
|
+
const source = new TextDecoder("utf-8", { fatal: true }).decode(read.bytes);
|
|
140
|
+
if (!source.endsWith("\n")) throw Error("incomplete session record");
|
|
141
|
+
const entries = source
|
|
142
|
+
.split("\n")
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.map((l) => JSON.parse(l));
|
|
145
|
+
const header = entries[0];
|
|
146
|
+
if (!object(header) || header.type !== "session" || header.version !== 3 || header.id !== this.sessionId)
|
|
147
|
+
throw Error("persisted session identity mismatch");
|
|
148
|
+
const seen = new Set<string>();
|
|
149
|
+
for (const entry of entries.slice(1)) {
|
|
150
|
+
if (
|
|
151
|
+
!object(entry) ||
|
|
152
|
+
entry.type === "session" ||
|
|
153
|
+
typeof entry.id !== "string" ||
|
|
154
|
+
seen.has(entry.id) ||
|
|
155
|
+
(entry.parentId !== null && !seen.has(entry.parentId))
|
|
156
|
+
)
|
|
157
|
+
throw Error("ambiguous session tree");
|
|
158
|
+
seen.add(entry.id);
|
|
159
|
+
}
|
|
160
|
+
// Public SDK derives the active branch from the already bounded, validated in-memory bytes.
|
|
161
|
+
const manager = SessionManager.inMemory(header.cwd, undefined, entries as FileEntry[]);
|
|
162
|
+
const branch = manager.getBranch();
|
|
163
|
+
const messages = branch.filter((e) => e.type === "message");
|
|
164
|
+
const last = messages.at(-1),
|
|
165
|
+
currentUser = messages.findLast((e) => e.message.role === "user");
|
|
166
|
+
if (
|
|
167
|
+
!last ||
|
|
168
|
+
last.message.role !== "assistant" ||
|
|
169
|
+
!isDeepStrictEqual(last.message, final) ||
|
|
170
|
+
!currentUser ||
|
|
171
|
+
!isDeepStrictEqual(currentUser.message, this.user)
|
|
172
|
+
)
|
|
173
|
+
throw Error("stream final does not match the current persisted branch and turn");
|
|
174
|
+
return {
|
|
175
|
+
state: "complete",
|
|
176
|
+
text,
|
|
177
|
+
sessionId: this.sessionId,
|
|
178
|
+
messageId: last.id,
|
|
179
|
+
leafId: manager.getLeafId()!,
|
|
180
|
+
sha256: hash(text),
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error instanceof BoundedReadCleanupError) {
|
|
184
|
+
if (!this.onReadCleanup) throw error;
|
|
185
|
+
this.onReadCleanup(error);
|
|
186
|
+
}
|
|
187
|
+
return unavailable(String(error));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -28,7 +28,7 @@ export interface ExecutorChoice {
|
|
|
28
28
|
/** Whether a probe was needed at all — false only for `0`. */
|
|
29
29
|
probed: boolean;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Unavailable or unqualified execution. Every delegation must refuse with this.
|
|
32
32
|
*
|
|
33
33
|
* Note that `kind` stays `"herdr"` in that case, deliberately: nothing downstream may mistake a refusing
|
|
34
34
|
* session for a working process-executor one.
|
|
@@ -43,7 +43,20 @@ export function needsProbe(raw: string | undefined): boolean {
|
|
|
43
43
|
return raw === undefined || raw === "1";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export const HERDR_UNQUALIFIED_REASON =
|
|
47
|
+
"Herdr governed execution is not qualified for complete current finals and subtree cleanup in this release. " +
|
|
48
|
+
"Delegation is refused; set PI_DADDY_HERDR=0 explicitly to select captured execution. No backend fallback occurs.";
|
|
49
|
+
|
|
46
50
|
export function chooseExecutor(raw: string | undefined, probe: HerdrProbe | null): ExecutorChoice {
|
|
51
|
+
const choice = selectExecutor(raw, probe);
|
|
52
|
+
if (choice.kind !== "herdr") return choice;
|
|
53
|
+
return {
|
|
54
|
+
...choice,
|
|
55
|
+
refusal: [choice.refusal, HERDR_UNQUALIFIED_REASON].filter(Boolean).join(" "),
|
|
56
|
+
disclosure: `${choice.disclosure} — unqualified; governed delegation will refuse`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function selectExecutor(raw: string | undefined, probe: HerdrProbe | null): ExecutorChoice {
|
|
47
60
|
if (raw === "0") {
|
|
48
61
|
return {
|
|
49
62
|
kind: "process",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/** Captured Linux execution with a gated, independently settling native subreaper owner. */
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { lstat, mkdir, open, realpath } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { constants as osConstants } from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { StringDecoder } from "node:string_decoder";
|
|
10
|
+
import { WORKER_SHA256 } from "./worker-artifact.ts";
|
|
11
|
+
import type { ChildRunRequest, ChildRunResult } from "../kernel/run-child.ts";
|
|
12
|
+
import { takeBytes, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from "../kernel/run-child.ts";
|
|
13
|
+
import type { CapturedWorkerCleanup, CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
|
|
14
|
+
import {
|
|
15
|
+
isCapturedWorkerIdentity,
|
|
16
|
+
readCapturedWorkerReceipt,
|
|
17
|
+
validateLiveWorker,
|
|
18
|
+
} from "../governance/captured-worker-record.ts";
|
|
19
|
+
export type { CapturedWorkerCleanup, CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
|
|
20
|
+
|
|
21
|
+
export interface OwnedChildRunRequest extends ChildRunRequest {
|
|
22
|
+
executionId: string;
|
|
23
|
+
/** Fresh, caller-private directory. Existing directories are refused rather than overwritten. */
|
|
24
|
+
ownershipDir: string;
|
|
25
|
+
onOwnership?: (identity: CapturedWorkerIdentity) => Promise<void> | void;
|
|
26
|
+
/** JSON protocol consumers observe all bytes without capturing enormous replay streams as answer text. */
|
|
27
|
+
captureStdout?: boolean;
|
|
28
|
+
/** Bounded diagnostics may truncate without stopping independently captured primary output. */
|
|
29
|
+
stopOnOutputLimit?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface OwnedChildRunResult extends ChildRunResult {
|
|
32
|
+
cleanup: CapturedWorkerCleanup;
|
|
33
|
+
}
|
|
34
|
+
export async function runOwnedChild(request: OwnedChildRunRequest): Promise<OwnedChildRunResult> {
|
|
35
|
+
const notStarted = (reason: string, aborted = false): OwnedChildRunResult => ({
|
|
36
|
+
code: null,
|
|
37
|
+
text: "",
|
|
38
|
+
truncated: false,
|
|
39
|
+
timedOut: false,
|
|
40
|
+
aborted,
|
|
41
|
+
...(!aborted ? { spawnError: reason } : {}),
|
|
42
|
+
cleanup: { state: "not-started", reason },
|
|
43
|
+
});
|
|
44
|
+
if (request.hardDeadlineAt !== undefined && request.hardDeadlineAt <= Date.now())
|
|
45
|
+
return { ...notStarted("deadline elapsed before helper launch"), timedOut: true };
|
|
46
|
+
if (request.signal?.aborted) return notStarted("cancelled before helper launch", true);
|
|
47
|
+
if (process.platform !== "linux" || process.arch !== "x64")
|
|
48
|
+
return notStarted("captured worker requires qualified Linux x64 helper");
|
|
49
|
+
let helper: string, root: string, hash: string;
|
|
50
|
+
const nonce = randomUUID();
|
|
51
|
+
let binary: import("node:fs/promises").FileHandle | undefined;
|
|
52
|
+
try {
|
|
53
|
+
helper = fileURLToPath(new URL("../../native/linux-x64/worker", import.meta.url));
|
|
54
|
+
const info = await lstat(helper);
|
|
55
|
+
if (!info.isFile() || info.isSymbolicLink() || !(info.mode & 0o111))
|
|
56
|
+
throw new Error("packaged worker helper is not an ordinary executable");
|
|
57
|
+
binary = await open(helper, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
58
|
+
const pinned = await binary.stat();
|
|
59
|
+
if (pinned.dev !== info.dev || pinned.ino !== info.ino) throw new Error("worker helper changed before pinning");
|
|
60
|
+
hash = WORKER_SHA256;
|
|
61
|
+
if (
|
|
62
|
+
!/^[a-f0-9]{64}$/.test(hash) ||
|
|
63
|
+
createHash("sha256")
|
|
64
|
+
.update(await binary.readFile())
|
|
65
|
+
.digest("hex") !== hash
|
|
66
|
+
)
|
|
67
|
+
throw new Error("packaged worker helper hash mismatch");
|
|
68
|
+
root = await realpath(request.cwd);
|
|
69
|
+
if (!request.executionId || request.executionId.length > 512)
|
|
70
|
+
throw new Error("executionId must be bounded and nonempty");
|
|
71
|
+
await mkdir(request.ownershipDir, { mode: 0o700 });
|
|
72
|
+
if ((await realpath(request.ownershipDir)) !== request.ownershipDir)
|
|
73
|
+
throw new Error("ownershipDir must be a canonical absolute path");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await binary?.close();
|
|
76
|
+
return notStarted(String(error));
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const grace = Math.min(request.killGraceMs ?? DEFAULT_KILL_GRACE_MS, 60000);
|
|
80
|
+
const cleanupCeiling = grace + 5000;
|
|
81
|
+
const child = spawn(
|
|
82
|
+
"/proc/self/fd/5",
|
|
83
|
+
[
|
|
84
|
+
request.executionId,
|
|
85
|
+
nonce,
|
|
86
|
+
root,
|
|
87
|
+
hash,
|
|
88
|
+
request.ownershipDir,
|
|
89
|
+
String(grace),
|
|
90
|
+
String(cleanupCeiling),
|
|
91
|
+
"--",
|
|
92
|
+
request.command,
|
|
93
|
+
...request.args,
|
|
94
|
+
],
|
|
95
|
+
{
|
|
96
|
+
cwd: root,
|
|
97
|
+
env: request.env,
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", binary!.fd],
|
|
99
|
+
detached: true,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
void binary!.close();
|
|
103
|
+
const control = child.stdio[3] as import("node:stream").Duplex;
|
|
104
|
+
const status = child.stdio[4] as import("node:stream").Readable;
|
|
105
|
+
let identity: CapturedWorkerIdentity | undefined;
|
|
106
|
+
let text = "",
|
|
107
|
+
count = 0,
|
|
108
|
+
truncated = false,
|
|
109
|
+
timedOut = false,
|
|
110
|
+
aborted = false,
|
|
111
|
+
idle = false;
|
|
112
|
+
let failure: string | undefined,
|
|
113
|
+
done = false,
|
|
114
|
+
stopping = false,
|
|
115
|
+
ready = "";
|
|
116
|
+
let stopWatchdog: NodeJS.Timeout | undefined, idleTimer: NodeJS.Timeout | undefined;
|
|
117
|
+
const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
118
|
+
const timers: NodeJS.Timeout[] = [];
|
|
119
|
+
const observe = (stream: "stdout" | "stderr", bytes: Buffer) => {
|
|
120
|
+
armIdle();
|
|
121
|
+
try {
|
|
122
|
+
request.onObservation?.(stream, bytes);
|
|
123
|
+
} catch {
|
|
124
|
+
/* observer owns its own incomplete/error state */
|
|
125
|
+
}
|
|
126
|
+
if (stream === "stdout" && request.captureStdout === false) return;
|
|
127
|
+
const chunk = decoders[stream].write(bytes);
|
|
128
|
+
const kept = takeBytes(chunk, Math.max(0, (request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) - count));
|
|
129
|
+
count += Buffer.byteLength(kept);
|
|
130
|
+
text += kept;
|
|
131
|
+
try {
|
|
132
|
+
if (kept) request.onOutput?.(kept);
|
|
133
|
+
} catch {
|
|
134
|
+
/* display only */
|
|
135
|
+
}
|
|
136
|
+
if (kept.length < chunk.length) {
|
|
137
|
+
truncated = true;
|
|
138
|
+
if (request.stopOnOutputLimit !== false) stop();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const stop = (hard = false) => {
|
|
142
|
+
if (hard && !done && control.writable) control.write("K");
|
|
143
|
+
if (done || stopping) return;
|
|
144
|
+
stopping = true;
|
|
145
|
+
if (control.writable) control.write("C");
|
|
146
|
+
stopWatchdog = setTimeout(() => {
|
|
147
|
+
failure ??= "worker helper failed to establish cleanup before its bound";
|
|
148
|
+
child.kill("SIGKILL");
|
|
149
|
+
child.stdout?.destroy();
|
|
150
|
+
child.stderr?.destroy();
|
|
151
|
+
status.destroy();
|
|
152
|
+
}, cleanupCeiling + 1000);
|
|
153
|
+
};
|
|
154
|
+
function armIdle() {
|
|
155
|
+
if (!request.idleTimeoutMs || done || stopping) return;
|
|
156
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
157
|
+
idleTimer = setTimeout(() => {
|
|
158
|
+
timedOut = true;
|
|
159
|
+
idle = true;
|
|
160
|
+
stop();
|
|
161
|
+
}, request.idleTimeoutMs);
|
|
162
|
+
}
|
|
163
|
+
child.stdout?.on("data", (bytes) => observe("stdout", bytes));
|
|
164
|
+
child.stderr?.on("data", (bytes) => observe("stderr", bytes));
|
|
165
|
+
for (const stream of ["stdout", "stderr"] as const)
|
|
166
|
+
child[stream]?.on("end", () => {
|
|
167
|
+
const tail = decoders[stream].end();
|
|
168
|
+
if (tail && !(stream === "stdout" && request.captureStdout === false)) {
|
|
169
|
+
const kept = takeBytes(tail, Math.max(0, (request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) - count));
|
|
170
|
+
text += kept;
|
|
171
|
+
count += Buffer.byteLength(kept);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
request.onStreamEnd?.(stream);
|
|
175
|
+
} catch {
|
|
176
|
+
/* observer only */
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
control.on("error", () => {
|
|
180
|
+
/* helper close is evaluated against independent receipt */
|
|
181
|
+
});
|
|
182
|
+
status.on("data", (bytes: Buffer) => {
|
|
183
|
+
if (identity) return;
|
|
184
|
+
ready += bytes.toString("utf8");
|
|
185
|
+
if (ready.length > 16384) {
|
|
186
|
+
failure = "oversized worker readiness";
|
|
187
|
+
stop();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!ready.includes("\n")) return;
|
|
191
|
+
void (async () => {
|
|
192
|
+
try {
|
|
193
|
+
const value: unknown = JSON.parse(ready.trim());
|
|
194
|
+
if (
|
|
195
|
+
!isCapturedWorkerIdentity(value) ||
|
|
196
|
+
value.executionId !== request.executionId ||
|
|
197
|
+
value.nonce !== nonce ||
|
|
198
|
+
value.root !== root ||
|
|
199
|
+
value.helperPid !== child.pid ||
|
|
200
|
+
value.helperSha256 !== hash ||
|
|
201
|
+
value.ownershipPath !== join(request.ownershipDir, "ownership.json")
|
|
202
|
+
)
|
|
203
|
+
throw new Error("wrong worker readiness identity");
|
|
204
|
+
identity = value;
|
|
205
|
+
await validateLiveWorker(identity, helper);
|
|
206
|
+
await request.onOwnership?.(identity);
|
|
207
|
+
if (request.hardDeadlineAt !== undefined && request.hardDeadlineAt <= Date.now()) {
|
|
208
|
+
timedOut = true;
|
|
209
|
+
stop(true);
|
|
210
|
+
}
|
|
211
|
+
if (!stopping && !done) {
|
|
212
|
+
request.onSpawn?.(identity.workerPid);
|
|
213
|
+
control.write("S");
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
failure = String(error);
|
|
217
|
+
stop();
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
});
|
|
221
|
+
// Startup is bounded by the caller's timeout/deadline too. A separate readiness cutoff can
|
|
222
|
+
// discard a valid delayed ownership record and prevent later exact receipt reconciliation.
|
|
223
|
+
timers.push(
|
|
224
|
+
setTimeout(() => {
|
|
225
|
+
timedOut = true;
|
|
226
|
+
stop();
|
|
227
|
+
}, request.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
228
|
+
);
|
|
229
|
+
if (request.hardDeadlineAt !== undefined)
|
|
230
|
+
timers.push(
|
|
231
|
+
setTimeout(
|
|
232
|
+
() => {
|
|
233
|
+
timedOut = true;
|
|
234
|
+
stop(true);
|
|
235
|
+
},
|
|
236
|
+
Math.max(0, request.hardDeadlineAt - Date.now()),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
armIdle();
|
|
240
|
+
if (request.activityProbe && request.idleTimeoutMs) {
|
|
241
|
+
let previous: unknown,
|
|
242
|
+
probing = false;
|
|
243
|
+
timers.push(
|
|
244
|
+
setInterval(() => {
|
|
245
|
+
if (probing || done) return;
|
|
246
|
+
probing = true;
|
|
247
|
+
Promise.resolve()
|
|
248
|
+
.then(() => request.activityProbe!())
|
|
249
|
+
.then((value) => {
|
|
250
|
+
if (value !== undefined && value !== previous) {
|
|
251
|
+
previous = value;
|
|
252
|
+
armIdle();
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
.catch(() => undefined)
|
|
256
|
+
.finally(() => {
|
|
257
|
+
probing = false;
|
|
258
|
+
});
|
|
259
|
+
}, request.activityProbeIntervalMs ?? 2000),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const onAbort = () => {
|
|
263
|
+
aborted = true;
|
|
264
|
+
stop();
|
|
265
|
+
};
|
|
266
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
if (request.hardDeadlineAt !== undefined && request.hardDeadlineAt <= Date.now()) {
|
|
268
|
+
timedOut = true;
|
|
269
|
+
stop(true);
|
|
270
|
+
}
|
|
271
|
+
if (request.signal?.aborted) onAbort();
|
|
272
|
+
child.on("error", (error) => {
|
|
273
|
+
failure = String(error);
|
|
274
|
+
});
|
|
275
|
+
child.on("exit", () => {
|
|
276
|
+
void (async () => {
|
|
277
|
+
if (!identity || !(await readCapturedWorkerReceipt(identity))) {
|
|
278
|
+
failure ??= "helper exited without an independent settlement receipt";
|
|
279
|
+
child.stdout?.destroy();
|
|
280
|
+
child.stderr?.destroy();
|
|
281
|
+
status.destroy();
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
});
|
|
285
|
+
child.on("close", () => {
|
|
286
|
+
if (done) return;
|
|
287
|
+
done = true;
|
|
288
|
+
timers.forEach(clearTimeout);
|
|
289
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
290
|
+
if (stopWatchdog) clearTimeout(stopWatchdog);
|
|
291
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
292
|
+
control.destroy();
|
|
293
|
+
void (async () => {
|
|
294
|
+
const receipt = identity ? await readCapturedWorkerReceipt(identity) : null;
|
|
295
|
+
const cleanup: CapturedWorkerCleanup =
|
|
296
|
+
receipt && identity
|
|
297
|
+
? { state: "settled", identity, receipt }
|
|
298
|
+
: {
|
|
299
|
+
state: "unknown",
|
|
300
|
+
...(identity ? { identity } : {}),
|
|
301
|
+
reason: failure ?? "helper closed without matching durable settlement receipt",
|
|
302
|
+
};
|
|
303
|
+
const signal = receipt?.workerSignal
|
|
304
|
+
? (Object.entries(osConstants.signals).find(
|
|
305
|
+
([, value]) => value === receipt.workerSignal,
|
|
306
|
+
)?.[0] as NodeJS.Signals)
|
|
307
|
+
: null;
|
|
308
|
+
resolve({
|
|
309
|
+
code: receipt?.workerCode ?? null,
|
|
310
|
+
signal,
|
|
311
|
+
text,
|
|
312
|
+
truncated,
|
|
313
|
+
timedOut,
|
|
314
|
+
...(idle ? { idle: true as const } : {}),
|
|
315
|
+
aborted,
|
|
316
|
+
...(failure ? { spawnError: failure } : {}),
|
|
317
|
+
cleanup,
|
|
318
|
+
});
|
|
319
|
+
})();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
@@ -79,7 +79,8 @@ export interface ApprovalGateOptions {
|
|
|
79
79
|
hasUI: boolean;
|
|
80
80
|
/** pi's `ctx.mode`, quoted back in the refusal so an operator can see why. */
|
|
81
81
|
mode: string;
|
|
82
|
-
|
|
82
|
+
/** Resolve only when this caller must open a new dialog; unused invalid settings cannot revoke authority. */
|
|
83
|
+
timeoutMs?: number | (() => number | undefined);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export interface ApprovalGate {
|
|
@@ -91,16 +92,18 @@ const DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
91
92
|
/**
|
|
92
93
|
* Read `PI_DADDY_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
|
|
93
94
|
*
|
|
94
|
-
* `0`
|
|
95
|
-
*
|
|
95
|
+
* Only literal `0` disables the deadline. Other values must be canonical whole seconds
|
|
96
|
+
* within Node's signed 32-bit millisecond timer limit; malformed settings refuse a needed prompt.
|
|
96
97
|
*/
|
|
97
98
|
export function timeoutMsFromEnv(raw: string | undefined): number | undefined {
|
|
98
99
|
if (raw === undefined) return DEFAULT_TIMEOUT_MS;
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
100
|
+
if (raw === "0") return undefined;
|
|
101
|
+
if (!/^[1-9][0-9]*$/.test(raw) || Number(raw) > 2_147_483)
|
|
102
|
+
throw new Error(
|
|
103
|
+
"PI_DADDY_APPROVAL_TIMEOUT must be literal 0 or whole seconds 1..2147483 without signs, padding or units",
|
|
104
|
+
);
|
|
105
|
+
return Number(raw) * 1000;
|
|
102
106
|
}
|
|
103
|
-
|
|
104
107
|
function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | null {
|
|
105
108
|
return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
|
|
106
109
|
}
|
|
@@ -160,7 +163,7 @@ export function createApprovalGate(
|
|
|
160
163
|
let chosen: string | undefined;
|
|
161
164
|
try {
|
|
162
165
|
chosen = await options.ui.select(title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])], {
|
|
163
|
-
timeout: options.timeoutMs,
|
|
166
|
+
timeout: typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs,
|
|
164
167
|
signal: request.signal,
|
|
165
168
|
});
|
|
166
169
|
} catch (error) {
|
|
@@ -169,6 +172,9 @@ export function createApprovalGate(
|
|
|
169
172
|
return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
if (request.signal?.aborted)
|
|
176
|
+
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
177
|
+
|
|
172
178
|
// undefined covers dismissal, timeout, and abort. All are a no, but none is a person saying no.
|
|
173
179
|
if (chosen === undefined) {
|
|
174
180
|
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was dismissed` };
|
|
@@ -185,6 +191,8 @@ export function createApprovalGate(
|
|
|
185
191
|
|
|
186
192
|
return {
|
|
187
193
|
async request(request: PromptRequest): Promise<PromptOutcome> {
|
|
194
|
+
if (request.signal?.aborted)
|
|
195
|
+
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
188
196
|
if (!options.hasUI) {
|
|
189
197
|
// Nobody was there to ask — distinct from a person declining, which is why this is its own kind
|
|
190
198
|
// rather than being folded into "declined" (see PromptOutcomeKind).
|
|
@@ -208,6 +216,8 @@ export function createApprovalGate(
|
|
|
208
216
|
const existing = inFlight.get(key);
|
|
209
217
|
if (!existing) break;
|
|
210
218
|
const outcome = await existing;
|
|
219
|
+
if (request.signal?.aborted)
|
|
220
|
+
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
211
221
|
// **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
|
|
212
222
|
// is correct — the human authorised the capability for the session, not for one child — but the
|
|
213
223
|
// caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote
|