@tryinget/pi-agent-registry 0.3.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.
@@ -0,0 +1,326 @@
1
+ // ---
2
+ // summary: write-once dispatch receipts binding exact-task standing-agent dispatch facts with canonical digests.
3
+ // read_when:
4
+ // - changing receipt identity, immutability mechanics, or verification semantics.
5
+ // ---
6
+
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import * as fs from "node:fs";
9
+ import { mkdir, realpath } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { basename, join } from "node:path";
12
+ import { DISPATCH_RECEIPT_SCHEMA } from "./dispatch-contract.ts";
13
+
14
+ /** Minimal registry-owned agent-dir resolution for the receipts home (honors PI_CODING_AGENT_DIR). */
15
+ function resolvePiAgentDir(): string {
16
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
17
+ if (configured) {
18
+ return configured === "~" ? homedir() : configured;
19
+ }
20
+ return join(homedir(), ".pi", "agent");
21
+ }
22
+
23
+ export const DISPATCH_RECEIPTS_DIR_ENV = "PI_AGENT_REGISTRY_DISPATCH_RECEIPTS_DIR";
24
+
25
+ export class DispatchReceiptError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "DispatchReceiptError";
29
+ }
30
+ }
31
+
32
+ export interface DispatchReceipt {
33
+ schema: typeof DISPATCH_RECEIPT_SCHEMA;
34
+ phase: "fleet_phase_2";
35
+ agent: {
36
+ name: string;
37
+ role?: string;
38
+ creation_task?: string;
39
+ tools: string[];
40
+ thinking: string;
41
+ model: string | null;
42
+ skillProfile?: string;
43
+ loadedSkills: string[];
44
+ manifestSha256: string;
45
+ manifestBlobOid: string;
46
+ systemPromptSha256: string;
47
+ agentRepo: {
48
+ commit: string;
49
+ treeOid: string;
50
+ status: "clean_observed";
51
+ statusSha256: string;
52
+ revisionStable: boolean;
53
+ };
54
+ };
55
+ task: {
56
+ id: number;
57
+ repo: string;
58
+ title: string;
59
+ status: string;
60
+ claimedBy: string;
61
+ leaseExpiresAt: string | null;
62
+ };
63
+ dispatch: {
64
+ attemptIndex: number;
65
+ settlement: "settled" | "not_settled";
66
+ objective: string;
67
+ objectiveSha256: string;
68
+ mutationPolicy: "read_only";
69
+ allowedPaths: string[];
70
+ forbiddenPaths: string[];
71
+ effectCorrelationId: string;
72
+ executionTimeoutSeconds: number;
73
+ startupTimeoutSeconds: number;
74
+ asc: {
75
+ dispatchId: string;
76
+ attemptId: string;
77
+ sessionName: string;
78
+ sessionFile: string;
79
+ status: string;
80
+ exitCode?: number;
81
+ effectDisposition: string;
82
+ effectReceiptPath?: string;
83
+ requestedModel?: string;
84
+ effectiveModel?: string;
85
+ usage?: Record<string, unknown>;
86
+ };
87
+ outputSha256: string;
88
+ outputChars: number;
89
+ };
90
+ observation: {
91
+ parentRepoRoot: string;
92
+ parentHead: string;
93
+ preStatusSha256: string;
94
+ postStatusSha256: string;
95
+ headStable: boolean;
96
+ noMutationObserved: boolean;
97
+ boundary: string;
98
+ };
99
+ recordedAt: string;
100
+ receiptSha256: string;
101
+ }
102
+
103
+ /** Deterministic JSON serialization: sorted code-point keys, no ambient whitespace. */
104
+ export function canonicalJsonString(value: unknown): string {
105
+ const serialize = (input: unknown): string => {
106
+ if (input === null || typeof input !== "object") {
107
+ return JSON.stringify(input);
108
+ }
109
+ if (Array.isArray(input)) {
110
+ return `[${input.map(serialize).join(",")}]`;
111
+ }
112
+ const entries = Object.entries(input as Record<string, unknown>)
113
+ .filter(([, v]) => v !== undefined)
114
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
115
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${serialize(v)}`).join(",")}}`;
116
+ };
117
+ return serialize(value);
118
+ }
119
+
120
+ export function sha256Hex(value: string | Buffer): string {
121
+ return createHash("sha256").update(value).digest("hex");
122
+ }
123
+
124
+ /** Digest of the receipt over its canonical form without the `receiptSha256` field. */
125
+ export function computeDispatchReceiptSha256(
126
+ receipt: Omit<DispatchReceipt, "receiptSha256">,
127
+ ): string {
128
+ const { receiptSha256: _omitted, ...rest } = receipt as DispatchReceipt;
129
+ return sha256Hex(canonicalJsonString(rest));
130
+ }
131
+
132
+ export function dispatchReceiptFileName(agent: string, task: number, attemptIndex: number): string {
133
+ if (!Number.isInteger(attemptIndex) || attemptIndex < 1 || attemptIndex > 99) {
134
+ throw new DispatchReceiptError("dispatch receipt attempt index must be 1..99");
135
+ }
136
+ return `ak-${task}.${agent}.${String(attemptIndex).padStart(2, "0")}.dispatch-receipt.json`;
137
+ }
138
+
139
+ export function attemptIndexFromReceiptFileName(name: string): number | undefined {
140
+ const match = /^ak-\d+\.[A-Za-z0-9._-]+\.(\d{2})\.dispatch-receipt\.json$/u.exec(name);
141
+ if (!match) return undefined;
142
+ const index = Number(match[1]);
143
+ return index >= 1 && index <= 99 ? index : undefined;
144
+ }
145
+
146
+ export function resolveDispatchReceiptsDir(explicit?: string): string {
147
+ const configured = explicit ?? process.env[DISPATCH_RECEIPTS_DIR_ENV]?.trim();
148
+ if (configured) {
149
+ return configured;
150
+ }
151
+ return join(resolvePiAgentDir(), "dispatch-receipts");
152
+ }
153
+
154
+ /** Build one receipt input from explicit pipeline facts (phase/stamp included). */
155
+ export function buildDispatchReceiptInput(facts: {
156
+ agent: DispatchReceipt["agent"];
157
+ task: DispatchReceipt["task"];
158
+ dispatch: DispatchReceipt["dispatch"];
159
+ observation: DispatchReceipt["observation"];
160
+ recordedAt: string;
161
+ }): Omit<DispatchReceipt, "receiptSha256"> {
162
+ return {
163
+ schema: DISPATCH_RECEIPT_SCHEMA,
164
+ phase: "fleet_phase_2",
165
+ agent: facts.agent,
166
+ task: facts.task,
167
+ dispatch: facts.dispatch,
168
+ observation: facts.observation,
169
+ recordedAt: facts.recordedAt,
170
+ };
171
+ }
172
+
173
+ export interface WrittenDispatchReceipt {
174
+ receipt: DispatchReceipt;
175
+ receiptPath: string;
176
+ receiptSha256: string;
177
+ bytes: number;
178
+ }
179
+
180
+ /**
181
+ * Publish one immutable receipt: canonical bytes, private temporary file,
182
+ * hard-link publication (O_EXCL-equivalent), read-only final mode, and a
183
+ * verified re-read. Any pre-existing receipt for the same (agent, task)
184
+ * fails closed with the existing digest so one pair can never be re-spent.
185
+ */
186
+ export async function writeImmutableDispatchReceipt(
187
+ receiptInput: Omit<DispatchReceipt, "receiptSha256">,
188
+ options?: { dir?: string },
189
+ ): Promise<WrittenDispatchReceipt> {
190
+ const receipt: DispatchReceipt = {
191
+ ...receiptInput,
192
+ receiptSha256: computeDispatchReceiptSha256(receiptInput),
193
+ };
194
+ const bytes = `${JSON.stringify(receipt, null, 2)}\n`;
195
+ const configuredDir = resolveDispatchReceiptsDir(options?.dir);
196
+ const dir = await realpath(configuredDir).catch(async () => {
197
+ await mkdir(configuredDir, { recursive: true });
198
+ return realpath(configuredDir);
199
+ });
200
+ const receiptPath = join(
201
+ dir,
202
+ dispatchReceiptFileName(receipt.agent.name, receipt.task.id, receipt.dispatch.attemptIndex),
203
+ );
204
+ if (basename(receiptPath) !== receiptPath.split("/").pop()) {
205
+ throw new DispatchReceiptError("dispatch receipt requires a safe file name");
206
+ }
207
+ if (fs.existsSync(receiptPath)) {
208
+ const existing = await readDispatchReceipt(receiptPath);
209
+ throw new DispatchReceiptError(
210
+ existing && existing.receiptSha256 === receipt.receiptSha256
211
+ ? `dispatch receipt already recorded for ak-${receipt.task.id}/${receipt.agent.name} (sha256 ${existing.receiptSha256})`
212
+ : `dispatch receipt collision for ak-${receipt.task.id}/${receipt.agent.name}`,
213
+ );
214
+ }
215
+ const temporaryPath = `${receiptPath}.${process.pid}.${randomUUID()}.tmp`;
216
+ let descriptor: number | undefined;
217
+ try {
218
+ descriptor = fs.openSync(
219
+ temporaryPath,
220
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
221
+ 0o600,
222
+ );
223
+ fs.writeFileSync(descriptor, bytes, "utf8");
224
+ fs.fsyncSync(descriptor);
225
+ fs.closeSync(descriptor);
226
+ descriptor = undefined;
227
+ fs.linkSync(temporaryPath, receiptPath);
228
+ fs.unlinkSync(temporaryPath);
229
+ fs.chmodSync(receiptPath, 0o400);
230
+ const dirDescriptor = fs.openSync(dir, fs.constants.O_RDONLY);
231
+ try {
232
+ fs.fsyncSync(dirDescriptor);
233
+ } finally {
234
+ fs.closeSync(dirDescriptor);
235
+ }
236
+ } finally {
237
+ if (descriptor !== undefined) fs.closeSync(descriptor);
238
+ try {
239
+ fs.unlinkSync(temporaryPath);
240
+ } catch {
241
+ // Publication or earlier cleanup already removed the private temporary file.
242
+ }
243
+ }
244
+ const verified = await readDispatchReceipt(receiptPath);
245
+ if (!verified || verified.receiptSha256 !== receipt.receiptSha256) {
246
+ throw new DispatchReceiptError("published dispatch receipt failed verification");
247
+ }
248
+ return {
249
+ receipt: verified,
250
+ receiptPath,
251
+ receiptSha256: verified.receiptSha256,
252
+ bytes: Buffer.byteLength(bytes, "utf8"),
253
+ };
254
+ }
255
+
256
+ export async function readDispatchReceipt(
257
+ receiptPath: string,
258
+ ): Promise<DispatchReceipt | undefined> {
259
+ let raw: string;
260
+ try {
261
+ raw = await fs.promises.readFile(receiptPath, "utf8");
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ let parsed: DispatchReceipt;
266
+ try {
267
+ parsed = JSON.parse(raw) as DispatchReceipt;
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ if (parsed.schema !== DISPATCH_RECEIPT_SCHEMA) {
272
+ return undefined;
273
+ }
274
+ if (computeDispatchReceiptSha256(parsed) !== parsed.receiptSha256) {
275
+ return undefined;
276
+ }
277
+ return parsed;
278
+ }
279
+
280
+ export interface DispatchAttemptLedger {
281
+ attempts: Array<{ receipt: DispatchReceipt; receiptPath: string; attemptIndex: number }>;
282
+ settled?: { receipt: DispatchReceipt; receiptPath: string };
283
+ nextAttemptIndex: number;
284
+ }
285
+
286
+ /** Enumerate one (agent, exact task) pair's immutable attempt receipts. */
287
+ export async function readDispatchAttemptLedger(
288
+ agent: string,
289
+ task: number,
290
+ options?: { dir?: string },
291
+ ): Promise<DispatchAttemptLedger> {
292
+ const dir = resolveDispatchReceiptsDir(options?.dir);
293
+ let names: string[] = [];
294
+ try {
295
+ names = await fs.promises.readdir(dir);
296
+ } catch {
297
+ return { attempts: [], nextAttemptIndex: 1 };
298
+ }
299
+ const attempts: DispatchAttemptLedger["attempts"] = [];
300
+ for (const name of names) {
301
+ const attemptIndex = attemptIndexFromReceiptFileName(name);
302
+ if (attemptIndex === undefined) continue;
303
+ if (name !== dispatchReceiptFileName(agent, task, attemptIndex)) continue;
304
+ const receipt = await readDispatchReceipt(join(dir, name));
305
+ if (receipt && receipt.agent.name === agent && receipt.task.id === task) {
306
+ if (receipt.dispatch.attemptIndex !== attemptIndex) {
307
+ throw new DispatchReceiptError(
308
+ `dispatch receipt file name ${name} disagrees with its recorded attempt index ${receipt.dispatch.attemptIndex}`,
309
+ );
310
+ }
311
+ attempts.push({ receipt, receiptPath: join(dir, name), attemptIndex });
312
+ } else if (receipt) {
313
+ throw new DispatchReceiptError(
314
+ `dispatch receipt file name ${name} carries a different (agent, task) pair than its contents record`,
315
+ );
316
+ }
317
+ }
318
+ attempts.sort((a, b) => a.attemptIndex - b.attemptIndex);
319
+ const settled = attempts.find((entry) => entry.receipt.dispatch.settlement === "settled");
320
+ const maxIndex = attempts.length > 0 ? attempts[attempts.length - 1].attemptIndex : 0;
321
+ return {
322
+ attempts,
323
+ ...(settled ? { settled } : {}),
324
+ nextAttemptIndex: maxIndex + 1,
325
+ };
326
+ }
@@ -0,0 +1,135 @@
1
+ // ---
2
+ // summary: composes the ASC DispatchSubagentRequest and runtime options for Fleet Phase-2 read-only dispatch.
3
+ // read_when:
4
+ // - changing the dispatched child task contract, model policy, or ASC runtime wiring.
5
+ // ---
6
+
7
+ import type {
8
+ AscExecutionRuntime,
9
+ DispatchSubagentRequest,
10
+ ResolvedSubagentModelSelection,
11
+ SubagentModelContext,
12
+ } from "@tryinget/pi-autonomous-session-control/execution";
13
+ import { createAgentSkillProfileResolver } from "./agent-skill-resolver.ts";
14
+ import type { AscExecutionSurface } from "./asc-execution-surface.ts";
15
+ import type { AkTaskSnapshot } from "./dispatch-contract.ts";
16
+ import {
17
+ DISPATCH_CHILD_PROVENANCE_ENV,
18
+ DISPATCH_EXECUTION_TIMEOUT_SECONDS,
19
+ DISPATCH_STARTUP_TIMEOUT_SECONDS,
20
+ } from "./dispatch-contract.ts";
21
+ import type { AgentManifest } from "./manifest.ts";
22
+ import type { AgentRegistry, ResolvedAgentLaunch } from "./registry.ts";
23
+
24
+ export interface DispatchRequestInputs {
25
+ manifest: AgentManifest;
26
+ launch: ResolvedAgentLaunch;
27
+ task: AkTaskSnapshot;
28
+ objective: string;
29
+ parentRoot: string;
30
+ manifestSha256: string;
31
+ }
32
+
33
+ /**
34
+ * Transport-safe envelope for the child's initial instructions. The ASC child
35
+ * transport forwards the composed prompt as the child pi process's leading
36
+ * positional argument, and a pi CLI invocation cannot start a positional
37
+ * prompt with dash-led tokens; persona files legitimately begin with YAML
38
+ * front matter (`---`). A registry-authored dispatch header both states the
39
+ * dispatch identity and keeps the argv value dash-safe without altering the
40
+ * persona bytes it wraps.
41
+ */
42
+ export function dispatchPromptEnvelope(inputs: DispatchRequestInputs): string {
43
+ return [
44
+ `# Standing-agent dispatch: ${inputs.manifest.name} (AK task ${inputs.task.id}, Fleet Phase 2, read-only)`,
45
+ "",
46
+ inputs.launch.systemPrompt,
47
+ ].join("\n");
48
+ }
49
+
50
+ /** Binds the exact task, agent identity, and immutable manifest bytes into one correlation id. */
51
+ export function dispatchEffectCorrelationId(inputs: DispatchRequestInputs): string {
52
+ return `pi-agent-registry:ak-${inputs.task.id}:${inputs.manifest.name}:${inputs.manifestSha256.slice(0, 16)}`;
53
+ }
54
+
55
+ /** Compose the one permitted Phase-2 child request: read-only, exact-task, one level deep. */
56
+ export function composeDispatchSubagentRequest(
57
+ inputs: DispatchRequestInputs,
58
+ ): DispatchSubagentRequest {
59
+ const declaredTools = [...inputs.manifest.tools];
60
+ const allowedPaths =
61
+ inputs.manifest.scope?.repos && inputs.manifest.scope.repos.length > 0
62
+ ? [...inputs.manifest.scope.repos]
63
+ : [inputs.parentRoot];
64
+ const forbiddenPaths = [...(inputs.manifest.scope?.forbidden ?? []), ".git", "node_modules"];
65
+ return {
66
+ profile: "custom",
67
+ name: inputs.manifest.name,
68
+ objective: inputs.objective,
69
+ tools: declaredTools.join(","),
70
+ systemPrompt: dispatchPromptEnvelope(inputs),
71
+ thinking: inputs.launch.thinking as DispatchSubagentRequest["thinking"],
72
+ extensions: inputs.launch.extensions,
73
+ skillProfile: inputs.manifest.name,
74
+ mutationPolicy: "read_only",
75
+ deliverable:
76
+ "A concise written read-only observation report with exact file/line citations, coverage, findings, and uncertainty.",
77
+ acceptanceCriteria: [
78
+ "Every claim cites an exact observed file path or command output.",
79
+ "The report states what was not covered.",
80
+ ],
81
+ constraints: [
82
+ "Fleet Phase-2 exact-task read-only standing-agent dispatch: mutate nothing.",
83
+ "No file writes, no git mutations, no AK or database writes, no installs, no publishing, no worktree creation.",
84
+ `The exact AK task ${inputs.task.id} ("${inputs.task.title}") authorizes this one read-only observation; do not broaden it.`,
85
+ "Do not call dispatch_agent; standing-agent dispatch is exactly one level deep.",
86
+ "Stay inside the advisory operating territory in your system prompt.",
87
+ ],
88
+ evidenceRequired: ["Cite exact file paths for every material claim."],
89
+ stopConditions: [
90
+ "The requested read-only deliverable is complete.",
91
+ "Any next step would require mutation or broader authorization.",
92
+ "Authorization or scope becomes uncertain.",
93
+ ],
94
+ allowedPaths,
95
+ forbiddenPaths,
96
+ timeout: DISPATCH_EXECUTION_TIMEOUT_SECONDS,
97
+ startupTimeout: DISPATCH_STARTUP_TIMEOUT_SECONDS,
98
+ env: {
99
+ [DISPATCH_CHILD_PROVENANCE_ENV]: `ak-${inputs.task.id}:${inputs.manifest.name}`,
100
+ },
101
+ effectCorrelationId: dispatchEffectCorrelationId(inputs),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Create the ASC-owned execution runtime: ASC resolves the sessions dir and
107
+ * the model (session-inherited unless the manifest pins one), and the registry
108
+ * supplies only its skill-profile seam.
109
+ */
110
+ export function createPhase2AscRuntime(
111
+ surface: AscExecutionSurface,
112
+ registry: AgentRegistry,
113
+ launch: ResolvedAgentLaunch,
114
+ options: { cwd: string },
115
+ createRuntime?: DispatchRuntimeFactory,
116
+ ): AscExecutionRuntime {
117
+ const sessionsDir = surface.resolveSubagentSessionsDir({ cwd: options.cwd }).path;
118
+ const runtimeOptions: Parameters<DispatchRuntimeFactory>[0] = {
119
+ sessionsDir,
120
+ modelProvider: (modelCtx?: SubagentModelContext): ResolvedSubagentModelSelection =>
121
+ launch.model
122
+ ? { requestedModel: launch.model, effectiveModel: launch.model, source: "custom" }
123
+ : surface.resolveSubagentModelSelection(modelCtx),
124
+ extraSkillProfileResolver: createAgentSkillProfileResolver(registry),
125
+ };
126
+ return createRuntime
127
+ ? createRuntime(runtimeOptions)
128
+ : surface.createAscExecutionRuntime(runtimeOptions);
129
+ }
130
+
131
+ export type DispatchRuntimeFactory = (options: {
132
+ sessionsDir: string;
133
+ modelProvider: (modelCtx?: SubagentModelContext) => ResolvedSubagentModelSelection;
134
+ extraSkillProfileResolver: ReturnType<typeof createAgentSkillProfileResolver>;
135
+ }) => AscExecutionRuntime;