pi-agent-replicate 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +147 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +239 -2
  6. package/RELEASING.md +130 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +20 -0
  10. package/docs/adr/0003-installable-role-packs.md +29 -0
  11. package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
  12. package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
  13. package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
  14. package/docs/public-subagent-tree-api.md +58 -0
  15. package/docs/settled-lifecycle-contract.md +71 -0
  16. package/docs/worktree-subagents.md +58 -0
  17. package/examples/role-pack/extension.ts +18 -0
  18. package/examples/role-pack/package.json +16 -0
  19. package/examples/role-pack/roles/example-reviewer.md +12 -0
  20. package/index.d.ts +116 -0
  21. package/index.js +1064 -0
  22. package/index.ts +155 -0
  23. package/package.json +70 -4
  24. package/pi-extension/subagents/activity.js +575 -0
  25. package/pi-extension/subagents/activity.ts +980 -0
  26. package/pi-extension/subagents/completion.js +184 -0
  27. package/pi-extension/subagents/completion.ts +229 -0
  28. package/pi-extension/subagents/delivery-drain.js +15 -0
  29. package/pi-extension/subagents/delivery-drain.ts +20 -0
  30. package/pi-extension/subagents/delivery-files.js +86 -0
  31. package/pi-extension/subagents/delivery-files.ts +105 -0
  32. package/pi-extension/subagents/delivery-log.js +51 -0
  33. package/pi-extension/subagents/delivery-log.ts +51 -0
  34. package/pi-extension/subagents/delivery-payload.ts +55 -0
  35. package/pi-extension/subagents/delivery.ts +219 -0
  36. package/pi-extension/subagents/execution-owner.js +30 -0
  37. package/pi-extension/subagents/execution-owner.ts +38 -0
  38. package/pi-extension/subagents/herdr.js +645 -0
  39. package/pi-extension/subagents/herdr.ts +927 -0
  40. package/pi-extension/subagents/index.ts +3400 -0
  41. package/pi-extension/subagents/launch.js +774 -0
  42. package/pi-extension/subagents/launch.ts +1124 -0
  43. package/pi-extension/subagents/lifecycle.js +477 -0
  44. package/pi-extension/subagents/lifecycle.ts +582 -0
  45. package/pi-extension/subagents/lineage.js +806 -0
  46. package/pi-extension/subagents/lineage.ts +841 -0
  47. package/pi-extension/subagents/replica-background.js +32 -0
  48. package/pi-extension/subagents/replica-background.ts +38 -0
  49. package/pi-extension/subagents/replica-branch.ts +45 -0
  50. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  51. package/pi-extension/subagents/replica-history.js +22 -0
  52. package/pi-extension/subagents/replica-history.ts +21 -0
  53. package/pi-extension/subagents/replica-work.js +326 -0
  54. package/pi-extension/subagents/replica-work.ts +336 -0
  55. package/pi-extension/subagents/replica-worker.js +74 -0
  56. package/pi-extension/subagents/replica-worker.ts +71 -0
  57. package/pi-extension/subagents/replication.ts +99 -0
  58. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  59. package/pi-extension/subagents/role-catalog.js +123 -0
  60. package/pi-extension/subagents/role-catalog.ts +24 -0
  61. package/pi-extension/subagents/runtime-routing.js +154 -0
  62. package/pi-extension/subagents/runtime-routing.ts +239 -0
  63. package/pi-extension/subagents/session-binding.js +346 -0
  64. package/pi-extension/subagents/session-binding.ts +339 -0
  65. package/pi-extension/subagents/session-work.js +219 -0
  66. package/pi-extension/subagents/session-work.ts +167 -0
  67. package/pi-extension/subagents/session.js +318 -0
  68. package/pi-extension/subagents/session.ts +448 -0
  69. package/pi-extension/subagents/settled-contract.js +47 -0
  70. package/pi-extension/subagents/settled-contract.ts +175 -0
  71. package/pi-extension/subagents/source-state.js +180 -0
  72. package/pi-extension/subagents/source-state.ts +184 -0
  73. package/pi-extension/subagents/status.ts +493 -0
  74. package/pi-extension/subagents/subagent-done.js +805 -0
  75. package/pi-extension/subagents/subagent-done.ts +878 -0
  76. package/pi-extension/subagents/terminal.js +111 -0
  77. package/pi-extension/subagents/terminal.ts +208 -0
  78. package/pi-extension/subagents/watchdog.js +122 -0
  79. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,841 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ linkSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ renameSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { readSessionBinding, readSessionHeader, sessionBindingStore, sessionLineageRoots } from "./session-binding.ts";
17
+ import type { WorktreeLaunch } from "./launch.ts";
18
+ import { readExecutionOwner, type ExecutionOwner } from "./execution-owner.ts";
19
+
20
+ /** Durable launch registration and recursive ownership projection. */
21
+ export interface LineageRegistration {
22
+ rootDir: string;
23
+ rootId: string;
24
+ nodeId: string;
25
+ parentNodeId?: string;
26
+ parentSessionId?: string;
27
+ parentSessionFile?: string;
28
+ launchKind: "fresh" | "resume" | "worktree" | "fork";
29
+ }
30
+
31
+ export interface LineageNodeState {
32
+ /** Attempt-specific tool ownership survives receiver recovery and movement. */
33
+ executionOwner?: ExecutionOwner;
34
+ /** Preserve replica lifecycle when restoring a watcher. */
35
+ replication?: boolean;
36
+ /** Work/checkpoint ownership must survive moved or deleted session files. */
37
+ replicaWorkFile?: string;
38
+ nodeId: string;
39
+ parentNodeId?: string;
40
+ parentSessionId?: string;
41
+ parentSessionFile?: string;
42
+ launchKind?: string;
43
+ /** Original review target; moving the session does not make it an ordinary pane. */
44
+ worktree?: WorktreeLaunch;
45
+ name?: string;
46
+ task?: string;
47
+ agent?: string;
48
+ surface?: string;
49
+ tabId?: string;
50
+ sessionFile?: string;
51
+ activityFile?: string;
52
+ settledEventsFile?: string;
53
+ startTime?: number;
54
+
55
+ interactive?: boolean;
56
+ autoExit?: boolean;
57
+ workspaceId?: string;
58
+ cwd?: string;
59
+ terminal?: { outcome: string; resultId?: string; resultContent?: string };
60
+ terminalDelivered?: string;
61
+
62
+ /** Latest durable result activity that can wake this node's owner. */
63
+ lastDeliveredAt?: number;
64
+ settledDelivered: string[];
65
+ cleanupPending?: boolean;
66
+ /** Cancellation requested but not yet proven and/or published. */
67
+ cancellation?: {
68
+ intent: boolean;
69
+ surface?: string;
70
+ pids: number[];
71
+ proven: boolean;
72
+ };
73
+ }
74
+
75
+ /** Acknowledge an exact-parent native launch error without sending a second result. */
76
+ export function acknowledgeReportedLaunchFailure(lineage: Pick<LineageRegistration, "rootDir" | "nodeId">, error: string, publishEvent: typeof appendLineageEvent = appendLineageEvent): void {
77
+ const { rootDir, nodeId } = lineage;
78
+ if (hasUndrainedDescendants(reduceLineage(rootDir), nodeId)) throw new Error("Replica descendants have not drained.");
79
+ const publish = (eventId: string, type: string, fields: Record<string, unknown> = {}) => {
80
+ if (!publishEvent(rootDir, eventId, type, nodeId, fields) && !hasLineageEvent(rootDir, eventId, type)) {
81
+ throw new Error(`Unable to publish reported launch acknowledgement: ${type}`);
82
+ }
83
+ };
84
+ publish(`terminal:${nodeId}`, "terminal", { outcome: "error", resultContent: error });
85
+ // A crash after delivery must leave resource cleanup discoverable.
86
+ publish(`cleanup-pending:${nodeId}`, "cleanup_pending");
87
+ publish(`terminal-delivered:${nodeId}`, "terminal_delivered", { deliveryId: `terminal:${nodeId}` });
88
+ }
89
+ export interface LineageState {
90
+ rootId: string;
91
+ nodes: Map<string, LineageNodeState>;
92
+ events: number;
93
+ }
94
+
95
+ interface LineageEvent {
96
+ version: 1;
97
+ eventId: string;
98
+ type: string;
99
+ nodeId: string;
100
+ /** Ordering is informational only. Reduction never depends on this clock. */
101
+ at: number;
102
+ rootId?: string;
103
+ [key: string]: unknown;
104
+ }
105
+
106
+ function safeId(value: string): string {
107
+ return Buffer.from(value, "utf8").toString("base64url");
108
+ }
109
+
110
+ function eventDir(rootDir: string): string {
111
+ const dir = join(rootDir, "events");
112
+ if (!existsSync(dir)) {
113
+ mkdirSync(dir, { recursive: true });
114
+ fsyncDirectory(dirname(dir));
115
+ }
116
+ return dir;
117
+ }
118
+
119
+ function fsyncDirectory(path: string): void {
120
+ try {
121
+ const fd = openSync(path, "r");
122
+ try { fsyncSync(fd); } finally { closeSync(fd); }
123
+ } catch {
124
+ // Some filesystems do not permit directory fsync. Publication remains atomic.
125
+ }
126
+ }
127
+
128
+ function writeDurable(path: string, content: string): void {
129
+ const fd = openSync(path, "wx", 0o600);
130
+ try {
131
+ writeFileSync(fd, content, "utf8");
132
+ fsyncSync(fd);
133
+ } finally {
134
+ closeSync(fd);
135
+ }
136
+ }
137
+
138
+ /** Register a node before creating any Herdr pane or workspace. */
139
+ export function registerLineage(options: {
140
+ artifactDir: string;
141
+ nodeId: string;
142
+ parentNodeId?: string;
143
+ parentSessionId?: string;
144
+ parentSessionFile?: string;
145
+ launchKind?: LineageRegistration["launchKind"];
146
+ inheritedRootDir?: string;
147
+ inheritedRootId?: string;
148
+ }): LineageRegistration {
149
+ const rootId = options.inheritedRootId ?? `lineage-${safeId(options.nodeId)}-${randomUUID()}`;
150
+ const rootDir = options.inheritedRootDir ?? join(options.artifactDir, "lineage", rootId);
151
+ if (options.parentNodeId === options.nodeId) {
152
+ throw new Error(`Lineage integrity error: node ${options.nodeId} cannot be its own parent`);
153
+ }
154
+ if (options.inheritedRootDir && options.parentNodeId) {
155
+ const inherited = reduceLineage(options.inheritedRootDir);
156
+ const error = lineageIntegrityError(inherited);
157
+ if (error) throw new Error(error);
158
+ if (!inherited.nodes.has(options.parentNodeId)) {
159
+ throw new Error(`Lineage integrity error: parent ${options.parentNodeId} does not exist`);
160
+ }
161
+ }
162
+ mkdirSync(rootDir, { recursive: true });
163
+ fsyncDirectory(dirname(rootDir));
164
+ const registration: LineageRegistration = {
165
+ rootDir,
166
+ rootId,
167
+ nodeId: options.nodeId,
168
+ ...(options.parentNodeId ? { parentNodeId: options.parentNodeId } : {}),
169
+ ...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
170
+ ...(options.parentSessionFile ? { parentSessionFile: options.parentSessionFile } : {}),
171
+ launchKind: options.launchKind ?? "fresh",
172
+ };
173
+ appendLineageEvent(rootDir, `launch:${options.nodeId}`, "launch", options.nodeId, {
174
+ rootId,
175
+ parentNodeId: registration.parentNodeId,
176
+ parentSessionId: registration.parentSessionId,
177
+ parentSessionFile: registration.parentSessionFile,
178
+ launchKind: registration.launchKind,
179
+ });
180
+ return registration;
181
+ }
182
+
183
+ /**
184
+ * Publish one immutable event atomically. link(2), rather than rename(2),
185
+ * gives no-replace semantics when two Pi processes race on one effect ID.
186
+ */
187
+ export function appendLineageEvent(
188
+ rootDir: string,
189
+ eventId: string,
190
+ type: string,
191
+ nodeId: string,
192
+ data: Record<string, unknown> = {},
193
+ ): boolean {
194
+ if (!eventId || !type || !nodeId) return false;
195
+ const dir = eventDir(rootDir);
196
+ const encoded = safeId(eventId);
197
+ const file = join(dir, `${encoded}.json`);
198
+ const pending = join(dir, `.${encoded}.${process.pid}.${randomUUID()}.pending`);
199
+ const event: LineageEvent = {
200
+ ...data,
201
+ version: 1,
202
+ eventId,
203
+ type,
204
+ nodeId,
205
+ at: Date.now(),
206
+ };
207
+ try {
208
+ writeDurable(pending, JSON.stringify(event));
209
+ // Hard-link publication is exclusive and leaves the pending file harmless
210
+ // if another process won the same stable ID.
211
+ linkSync(pending, file);
212
+ unlinkSync(pending);
213
+ fsyncDirectory(dir);
214
+ return true;
215
+ } catch {
216
+ try { unlinkSync(pending); } catch {}
217
+ return false;
218
+ }
219
+ }
220
+
221
+ function validEvent(value: unknown, fileName?: string, rootId?: string): value is LineageEvent {
222
+ const e = value as Partial<LineageEvent> | null;
223
+ if (!e || e.version !== 1 || typeof e.eventId !== "string" || !e.eventId ||
224
+ typeof e.type !== "string" || !e.type || typeof e.nodeId !== "string" ||
225
+ !e.nodeId || !Number.isFinite(e.at)) return false;
226
+ if (rootId && e.rootId !== undefined && e.rootId !== rootId) return false;
227
+ if (fileName && fileName !== `${safeId(e.eventId)}.json`) return false;
228
+ return true;
229
+ }
230
+
231
+ const eventRank: Record<string, number> = {
232
+ launch: 0,
233
+ launch_metadata: 1,
234
+ cancel_intent: 2,
235
+ cancel_identity: 3,
236
+ cancel_proven: 4,
237
+ settled_delivered: 5,
238
+ terminal: 6,
239
+ inbox: 7,
240
+ terminal_delivered: 8,
241
+ inbox_materialized: 9,
242
+ cleanup_pending: 10,
243
+ cleanup_done: 11,
244
+ };
245
+
246
+ /** Reduce valid immutable events deterministically; wall-clock order is ignored. */
247
+ export function reduceLineage(rootDir: string): LineageState {
248
+ const rootId = rootDir.split(/[\\/]/).pop() ?? "unknown";
249
+ const nodes = new Map<string, LineageNodeState>();
250
+ let events = 0;
251
+ let files: string[] = [];
252
+ try { files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json")); } catch { return { rootId, nodes, events }; }
253
+ const parsed: LineageEvent[] = [];
254
+ for (const file of files) {
255
+ try {
256
+ const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
257
+ if (validEvent(value, file, rootId)) parsed.push(value);
258
+ } catch {}
259
+ }
260
+ parsed.sort((a, b) => (eventRank[a.type] ?? 50) - (eventRank[b.type] ?? 50) || a.eventId.localeCompare(b.eventId));
261
+ const launched = new Set<string>();
262
+ for (const event of parsed) {
263
+ if (event.type !== "launch" && !launched.has(event.nodeId)) continue;
264
+ let node = nodes.get(event.nodeId);
265
+ if (!node) {
266
+ node = { nodeId: event.nodeId, settledDelivered: [] };
267
+ nodes.set(event.nodeId, node);
268
+ }
269
+ if (event.type === "launch") {
270
+ launched.add(event.nodeId);
271
+ // First launch identity wins. A conflicting duplicate is not a legal
272
+ // transition and cannot change the logical parent after publication.
273
+ if (!node.parentNodeId && typeof event.parentNodeId === "string") node.parentNodeId = event.parentNodeId;
274
+ if (!node.parentSessionId && typeof event.parentSessionId === "string") node.parentSessionId = event.parentSessionId;
275
+ if (!node.parentSessionFile && typeof event.parentSessionFile === "string") node.parentSessionFile = event.parentSessionFile;
276
+ if (!node.launchKind && typeof event.launchKind === "string") node.launchKind = event.launchKind;
277
+ } else if (event.type === "launch_metadata") {
278
+ // Metadata may be rewritten by a resume. Prefer the latest recorded run;
279
+ // this comparison is deterministic and does not use event publication time.
280
+ const runStart = typeof event.startTime === "number" ? event.startTime : 0;
281
+ if (node.startTime !== undefined && runStart < node.startTime) continue;
282
+ node.executionOwner = readExecutionOwner(event.executionOwner);
283
+ if (typeof event.name === "string") node.name = event.name;
284
+ if (typeof event.task === "string") node.task = event.task;
285
+ if (typeof event.agent === "string") node.agent = event.agent;
286
+ if (typeof event.surface === "string") node.surface = event.surface;
287
+ if (typeof event.tabId === "string") node.tabId = event.tabId;
288
+ if (typeof event.sessionFile === "string") node.sessionFile = event.sessionFile;
289
+ if (typeof event.activityFile === "string") node.activityFile = event.activityFile;
290
+ if (typeof event.settledEventsFile === "string") node.settledEventsFile = event.settledEventsFile;
291
+ if (typeof event.startTime === "number") node.startTime = event.startTime;
292
+
293
+ if (typeof event.interactive === "boolean") node.interactive = event.interactive;
294
+ if (typeof event.autoExit === "boolean") node.autoExit = event.autoExit;
295
+ if (typeof event.replication === "boolean") node.replication = event.replication;
296
+ if (typeof event.replicaWorkFile === "string") node.replicaWorkFile = event.replicaWorkFile;
297
+ if (typeof event.workspaceId === "string") node.workspaceId = event.workspaceId;
298
+ if (typeof event.cwd === "string") node.cwd = event.cwd;
299
+ if (event.worktree && typeof event.worktree === "object" &&
300
+ ["path", "workspaceId", "paneId", "branch", "baseRef", "baseSha", "manifestFile"].every((key) => typeof (event.worktree as Record<string, unknown>)[key] === "string")) {
301
+ node.worktree = event.worktree as WorktreeLaunch;
302
+ }
303
+ } else if (event.type === "cancel_intent" || event.type === "cancel_identity" || event.type === "cancel_proven") {
304
+ const prior = node.cancellation ?? { intent: false, pids: [], proven: false };
305
+ prior.intent = true;
306
+ if (typeof event.surface === "string") prior.surface = event.surface;
307
+ if (Array.isArray(event.pids)) {
308
+ const pids = event.pids.filter((pid): pid is number => Number.isInteger(pid) && pid > 0);
309
+ if (pids.length > 0) prior.pids = [...new Set(pids)];
310
+ }
311
+ if (event.type === "cancel_proven") prior.proven = true;
312
+ node.cancellation = prior;
313
+ } else if (event.type === "terminal") {
314
+ if (!node.terminal) node.terminal = {
315
+ outcome: typeof event.outcome === "string" ? event.outcome : "unknown",
316
+ resultId: typeof event.resultId === "string" ? event.resultId : undefined,
317
+ resultContent: typeof event.resultContent === "string" ? event.resultContent : undefined,
318
+ };
319
+ } else if (event.type === "terminal_delivered" || event.type === "inbox") {
320
+ const kind = event.type === "inbox" ? event.kind : undefined;
321
+ const payload = event.payload as Record<string, unknown> | undefined;
322
+ if (node.terminal && (event.type === "terminal_delivered" || kind === "terminal" || payload?.kind === "terminal")) {
323
+ node.terminalDelivered ??= String(event.deliveryId ?? event.eventId);
324
+ }
325
+ node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
326
+ } else if (event.type === "inbox_materialized") {
327
+ node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
328
+ } else if (event.type === "settled_delivered" && typeof event.resultId === "string" && !node.settledDelivered.includes(event.resultId)) {
329
+ node.settledDelivered.push(event.resultId);
330
+ node.lastDeliveredAt = Math.max(node.lastDeliveredAt ?? 0, event.at);
331
+ } else if (event.type === "cleanup_pending") {
332
+ if (!node.terminalDelivered) continue;
333
+ node.cleanupPending = true;
334
+ } else if (event.type === "cleanup_done") {
335
+ if (node.terminalDelivered) node.cleanupPending = false;
336
+ }
337
+ events++;
338
+ }
339
+ return { rootId, nodes, events };
340
+ }
341
+
342
+ /** Return an attributable graph error instead of treating a cycle as an endless drain. */
343
+ export function lineageIntegrityError(state: LineageState): string | undefined {
344
+ for (const node of state.nodes.values()) {
345
+ if (node.parentNodeId && !state.nodes.has(node.parentNodeId)) {
346
+ return `Lineage integrity error: parent ${node.parentNodeId} of ${node.nodeId} does not exist`;
347
+ }
348
+ const path = new Set<string>();
349
+ let current: LineageNodeState | undefined = node;
350
+ while (current?.parentNodeId) {
351
+ if (path.has(current.nodeId) || current.parentNodeId === current.nodeId) {
352
+ return `Lineage integrity error: cycle contains ${current.nodeId}`;
353
+ }
354
+ path.add(current.nodeId);
355
+ current = state.nodes.get(current.parentNodeId);
356
+ }
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ /** A node is drained only after terminal delivery and all recursive children. */
362
+ export function isLineageNodeDrained(state: LineageState, nodeId: string): boolean {
363
+ const integrity = lineageIntegrityError(state);
364
+ if (integrity) throw new Error(integrity);
365
+ const visit = (id: string): boolean => {
366
+ const node = state.nodes.get(id);
367
+ if (!node || !node.terminal || !node.terminalDelivered) return false;
368
+ for (const child of state.nodes.values()) {
369
+ if (child.parentNodeId === id && !visit(child.nodeId)) return false;
370
+ }
371
+ return true;
372
+ };
373
+ return visit(nodeId);
374
+ }
375
+
376
+ /** Latest durable delivery activity from any direct or recursive descendant. */
377
+ export function latestDescendantDeliveryAt(
378
+ state: LineageState,
379
+ nodeId: string,
380
+ ): number | undefined {
381
+ const integrity = lineageIntegrityError(state);
382
+ if (integrity) throw new Error(integrity);
383
+ let latest: number | undefined;
384
+ const visit = (parentId: string): void => {
385
+ for (const child of state.nodes.values()) {
386
+ if (child.parentNodeId !== parentId) continue;
387
+ if (child.lastDeliveredAt !== undefined) {
388
+ latest = Math.max(latest ?? 0, child.lastDeliveredAt);
389
+ }
390
+ visit(child.nodeId);
391
+ }
392
+ };
393
+ visit(nodeId);
394
+ return latest;
395
+ }
396
+
397
+ export function hasUndrainedDescendants(state: LineageState, nodeId: string): boolean {
398
+ const integrity = lineageIntegrityError(state);
399
+ if (integrity) throw new Error(integrity);
400
+ return [...state.nodes.values()].some((child) => child.parentNodeId === nodeId && !isLineageNodeDrained(state, child.nodeId));
401
+ }
402
+
403
+ /** Record the once-only durable sink entry used by the drain boundary. */
404
+ export function appendLineageInbox(
405
+ rootDir: string,
406
+ nodeId: string,
407
+ deliveryId: string,
408
+ sink: { sessionId?: string; sessionFile?: string },
409
+ payload: Record<string, unknown> = {},
410
+ ): boolean {
411
+ if (!sink.sessionId || !sink.sessionFile) return false;
412
+ const eventId = `inbox:${deliveryId}`;
413
+ const file = join(eventDir(rootDir), `${safeId(eventId)}.json`);
414
+ try {
415
+ const existing = JSON.parse(readFileSync(file, "utf8")) as Partial<LineageEvent>;
416
+ if (validEvent(existing, `${safeId(eventId)}.json`, rootDir.split(/[\\/]/).pop()) && existing.type === "inbox" && existing.nodeId === nodeId) return true;
417
+ } catch {}
418
+ return appendLineageEvent(rootDir, eventId, "inbox", nodeId, {
419
+ deliveryId,
420
+ ...sink,
421
+ payload,
422
+ });
423
+ }
424
+
425
+ export interface LineageInboxRecord {
426
+ rootDir: string;
427
+ nodeId: string;
428
+ deliveryId: string;
429
+ sessionId?: string;
430
+ sessionFile?: string;
431
+ payload: Record<string, unknown>;
432
+ activitySequence?: number;
433
+ }
434
+
435
+ export type MaterializationClaim =
436
+ | { status: "acquired"; token: string }
437
+ | { status: "materialized" }
438
+ | { status: "busy" };
439
+
440
+ /** Stable identity for one process incarnation. A missing start time is conservative. */
441
+ export interface ProcessIncarnation {
442
+ pid: number;
443
+ startTime?: string;
444
+ }
445
+
446
+ interface MaterializationClaimRecord {
447
+ version: 1;
448
+ deliveryId: string;
449
+ nodeId: string;
450
+ token: string;
451
+ process: ProcessIncarnation;
452
+ claimedAt: number;
453
+ }
454
+
455
+ interface MaterializationGateRecord {
456
+ version: 1;
457
+ token: string;
458
+ process: ProcessIncarnation;
459
+ claimedAt: number;
460
+ }
461
+
462
+ function materializationClaimDir(rootDir: string): string {
463
+ const dir = join(rootDir, "materialization-claims");
464
+ if (!existsSync(dir)) {
465
+ mkdirSync(dir, { recursive: true });
466
+ fsyncDirectory(rootDir);
467
+ }
468
+ return dir;
469
+ }
470
+
471
+ function claimFile(rootDir: string, deliveryId: string): string {
472
+ return join(materializationClaimDir(rootDir), `${safeId(deliveryId)}.claim`);
473
+ }
474
+
475
+ /** Linux stat fields after the command name, starting with process state. */
476
+ function linuxProcessFields(pid: number): string[] | undefined {
477
+ if (process.platform !== "linux") return undefined;
478
+ try {
479
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
480
+ const closingParen = stat.lastIndexOf(")");
481
+ return closingParen >= 0 ? stat.slice(closingParen + 2).trim().split(/\s+/) : undefined;
482
+ } catch { return undefined; }
483
+ }
484
+
485
+ function processAlive(pid: number): boolean {
486
+ try { process.kill(pid, 0); }
487
+ catch (error) {
488
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
489
+ }
490
+ // A zombie retains its PID and start time but cannot finish or release its gate.
491
+ const state = linuxProcessFields(pid)?.[0];
492
+ return state !== "Z" && state !== "X";
493
+ }
494
+
495
+ /** Linux process start time (field 22 of /proc/<pid>/stat). */
496
+ function processStartTime(pid: number): string | undefined {
497
+ return linuxProcessFields(pid)?.[19] || undefined;
498
+ }
499
+
500
+ function currentProcessIncarnation(): ProcessIncarnation {
501
+ const startTime = processStartTime(process.pid);
502
+ return { pid: process.pid, ...(startTime ? { startTime } : {}) };
503
+ }
504
+
505
+ function exactSessionEvidence(check: () => boolean): boolean {
506
+ try { return check() === true; } catch { return false; }
507
+ }
508
+
509
+ function readMaterializationClaim(path: string, deliveryId: string, nodeId: string): MaterializationClaimRecord | undefined {
510
+ try {
511
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<MaterializationClaimRecord> & { pid?: number; startTime?: string };
512
+ const identity = value.process ?? (Number.isInteger(value.pid) ? { pid: value.pid, startTime: value.startTime } : undefined);
513
+ if (value.version !== 1 || value.deliveryId !== deliveryId || value.nodeId !== nodeId ||
514
+ typeof value.token !== "string" || !identity || !Number.isInteger(identity.pid) || (identity.pid ?? 0) <= 0 ||
515
+ (identity.startTime != null && typeof identity.startTime !== "string") || !Number.isFinite(value.claimedAt)) return undefined;
516
+ return { ...value, process: identity } as MaterializationClaimRecord;
517
+ } catch {
518
+ return undefined;
519
+ }
520
+ }
521
+
522
+ function readMaterializationGate(path: string): MaterializationGateRecord | undefined {
523
+ try {
524
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<MaterializationGateRecord>;
525
+ if (value.version !== 1 || typeof value.token !== "string" || !value.process ||
526
+ !Number.isInteger(value.process.pid) || value.process.pid <= 0 ||
527
+ (value.process.startTime != null && typeof value.process.startTime !== "string") ||
528
+ !Number.isFinite(value.claimedAt)) return undefined;
529
+ return value as MaterializationGateRecord;
530
+ } catch {
531
+ return undefined;
532
+ }
533
+ }
534
+
535
+ /** Check a saved process identity without treating an unknown live incarnation as dead. */
536
+ export function processIncarnationAlive(identity: ProcessIncarnation, observer: ProcessIncarnation): boolean {
537
+ if (!processAlive(identity.pid)) return false;
538
+ const observed = identity.pid === observer.pid ? observer : { pid: identity.pid, startTime: processStartTime(identity.pid) };
539
+ // Portable filesystems/platforms may not expose an incarnation identity. Do
540
+ // not reclaim a live PID unless its identity can be disproved.
541
+ if (!identity.startTime || !observed.startTime) return true;
542
+ return identity.startTime === observed.startTime;
543
+ }
544
+
545
+ function materializationGateFile(rootDir: string): string {
546
+ return join(materializationClaimDir(rootDir), "lineage.gate");
547
+ }
548
+
549
+ function acquireMaterializationGate(rootDir: string, observer: ProcessIncarnation): { token: string } | null {
550
+ const path = materializationGateFile(rootDir);
551
+ for (let attempt = 0; attempt < 3; attempt += 1) {
552
+ const token = randomUUID();
553
+ const pending = join(dirname(path), `.${process.pid}.${randomUUID()}.gate.pending`);
554
+ try {
555
+ writeDurable(pending, JSON.stringify({ version: 1, token, process: observer, claimedAt: Date.now() }));
556
+ linkSync(pending, path);
557
+ unlinkSync(pending);
558
+ fsyncDirectory(dirname(path));
559
+ return { token };
560
+ } catch {
561
+ try { unlinkSync(pending); } catch {}
562
+ }
563
+ const current = readMaterializationGate(path);
564
+ if (!current && existsSync(path)) return null;
565
+ if (current && processIncarnationAlive(current.process, observer)) return null;
566
+ try {
567
+ const tombstone = `${path}.${process.pid}.${randomUUID()}.stale`;
568
+ renameSync(path, tombstone);
569
+ try { unlinkSync(tombstone); } catch {}
570
+ } catch {}
571
+ }
572
+ return null;
573
+ }
574
+
575
+ function releaseMaterializationGate(rootDir: string, token: string): void {
576
+ const path = materializationGateFile(rootDir);
577
+ const current = readMaterializationGate(path);
578
+ if (current?.token === token) try { unlinkSync(path); } catch {}
579
+ }
580
+
581
+ /** Run one synchronous durable mutation under a process-owned filesystem gate. */
582
+ export function withLineageMutationGate<T>(rootDir: string, mutate: (owner: ProcessIncarnation) => T): T {
583
+ const owner = currentProcessIncarnation();
584
+ const gate = acquireMaterializationGate(rootDir, owner);
585
+ if (!gate) throw new Error("Durable state has an active mutation.");
586
+ try { return mutate(owner); } finally { releaseMaterializationGate(rootDir, gate.token); }
587
+ }
588
+ function readLineageInboxes(rootDir: string): { record: LineageInboxRecord; materialized: boolean }[] {
589
+ const result: { record: LineageInboxRecord; materialized: boolean }[] = [];
590
+ const materialized = new Set<string>();
591
+ let files: string[] = [];
592
+ try { files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json")); } catch { return result; }
593
+ const records: LineageInboxRecord[] = [];
594
+ for (const file of files) {
595
+ try {
596
+ const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
597
+ if (!validEvent(value, file, rootDir.split(/[\\/]/).pop())) continue;
598
+ if (value.type === "inbox_materialized" && typeof value.deliveryId === "string") materialized.add(value.deliveryId);
599
+ if (value.type !== "inbox" || typeof value.deliveryId !== "string") continue;
600
+ const payload = value.payload && typeof value.payload === "object" ? value.payload as Record<string, unknown> : {};
601
+ records.push({ rootDir, nodeId: value.nodeId, deliveryId: value.deliveryId, sessionId: typeof value.sessionId === "string" ? value.sessionId : undefined, sessionFile: typeof value.sessionFile === "string" ? value.sessionFile : undefined, payload, activitySequence: typeof payload.activitySequence === "number" ? payload.activitySequence : undefined });
602
+ } catch {}
603
+ }
604
+ return records.map((record) => ({ record, materialized: materialized.has(record.deliveryId) }));
605
+ }
606
+
607
+ function sameMaterializationSink(left: LineageInboxRecord, right: LineageInboxRecord): boolean {
608
+ return Boolean(left.sessionId && left.sessionFile && left.sessionId === right.sessionId && left.sessionFile === right.sessionFile);
609
+ }
610
+
611
+ function pendingBefore(rootDir: string, deliveryId: string): boolean {
612
+ const entries = readLineageInboxes(rootDir);
613
+ const target = entries.find((entry) => entry.record.deliveryId === deliveryId)?.record;
614
+ if (!target) return false;
615
+ const pending = entries.filter((entry) => !entry.materialized && sameMaterializationSink(entry.record, target)).map((entry) => entry.record);
616
+ pending.sort((left, right) => (left.activitySequence ?? Number.MAX_SAFE_INTEGER) - (right.activitySequence ?? Number.MAX_SAFE_INTEGER) || left.deliveryId.localeCompare(right.deliveryId));
617
+ return pending.findIndex((record) => record.deliveryId === deliveryId) > 0;
618
+ }
619
+
620
+ /**
621
+ * Acquire the cross-process claim used before materializing one inbox entry.
622
+ * The durable lineage gate makes all delivery IDs for one sink observe the
623
+ * same activity order, not merely one claim per delivery.
624
+ */
625
+ export function claimLineageInboxMaterialization(options: {
626
+ rootDir: string;
627
+ deliveryId: string;
628
+ nodeId: string;
629
+ hasExactSessionEvidence: () => boolean;
630
+ /** Test seam; production derives PID plus Linux process start time. */
631
+ processIdentity?: ProcessIncarnation;
632
+ }): MaterializationClaim {
633
+ const { rootDir, deliveryId, nodeId } = options;
634
+ if (isLineageInboxMaterialized(rootDir, deliveryId)) return { status: "materialized" };
635
+ const observer = options.processIdentity ?? currentProcessIncarnation();
636
+ const gate = acquireMaterializationGate(rootDir, observer);
637
+ if (!gate) return { status: "busy" };
638
+ try {
639
+ if (isLineageInboxMaterialized(rootDir, deliveryId)) return { status: "materialized" };
640
+ const path = claimFile(rootDir, deliveryId);
641
+ const existing = readMaterializationClaim(path, deliveryId, nodeId);
642
+ if (existing) {
643
+ if (exactSessionEvidence(options.hasExactSessionEvidence)) {
644
+ if (appendLineageEvent(rootDir, `materialized:${deliveryId}`, "inbox_materialized", nodeId, { deliveryId })) {
645
+ try { unlinkSync(path); } catch {}
646
+ return { status: "materialized" };
647
+ }
648
+ return isLineageInboxMaterialized(rootDir, deliveryId) ? { status: "materialized" } : { status: "busy" };
649
+ }
650
+ if (processIncarnationAlive(existing.process, observer)) return { status: "busy" };
651
+ try {
652
+ const tombstone = `${path}.${process.pid}.${randomUUID()}.stale`;
653
+ renameSync(path, tombstone);
654
+ try { unlinkSync(tombstone); } catch {}
655
+ } catch { return { status: "busy" }; }
656
+ }
657
+ if (pendingBefore(rootDir, deliveryId)) return { status: "busy" };
658
+ const token = randomUUID();
659
+ const pending = join(dirname(path), `.${safeId(deliveryId)}.${process.pid}.${randomUUID()}.pending`);
660
+ try {
661
+ writeDurable(pending, JSON.stringify({ version: 1, deliveryId, nodeId, token, process: observer, claimedAt: Date.now() }));
662
+ linkSync(pending, path);
663
+ unlinkSync(pending);
664
+ fsyncDirectory(dirname(path));
665
+ return { status: "acquired", token };
666
+ } catch {
667
+ try { unlinkSync(pending); } catch {}
668
+ return { status: "busy" };
669
+ }
670
+ } finally {
671
+ releaseMaterializationGate(rootDir, gate.token);
672
+ }
673
+ }
674
+
675
+ /** Acknowledge a materialization and release only the claim held by this observer. */
676
+ export function completeLineageInboxMaterialization(
677
+ rootDir: string,
678
+ deliveryId: string,
679
+ nodeId: string,
680
+ token: string,
681
+ ): boolean {
682
+ const acknowledged = appendLineageEvent(rootDir, `materialized:${deliveryId}`, "inbox_materialized", nodeId, { deliveryId }) ||
683
+ isLineageInboxMaterialized(rootDir, deliveryId);
684
+ if (!acknowledged) return false;
685
+ const path = claimFile(rootDir, deliveryId);
686
+ const claim = readMaterializationClaim(path, deliveryId, nodeId);
687
+ return claim?.token === token || isLineageInboxMaterialized(rootDir, deliveryId);
688
+ }
689
+
690
+ /** Release a failed claim only after exact-session evidence is checked. */
691
+ export function releaseLineageInboxMaterialization(options: {
692
+ rootDir: string;
693
+ deliveryId: string;
694
+ nodeId: string;
695
+ token: string;
696
+ hasExactSessionEvidence: () => boolean;
697
+ }): boolean {
698
+ if (isLineageInboxMaterialized(options.rootDir, options.deliveryId)) return true;
699
+ if (exactSessionEvidence(options.hasExactSessionEvidence)) {
700
+ return completeLineageInboxMaterialization(options.rootDir, options.deliveryId, options.nodeId, options.token);
701
+ }
702
+ const path = claimFile(options.rootDir, options.deliveryId);
703
+ const claim = readMaterializationClaim(path, options.deliveryId, options.nodeId);
704
+ if (claim?.token !== options.token) return false;
705
+ try { unlinkSync(path); } catch {}
706
+ return true;
707
+ }
708
+
709
+ /** Return whether an inbox has a durable materialization acknowledgement. */
710
+ export function isLineageInboxMaterialized(rootDir: string, deliveryId: string): boolean {
711
+ let files: string[] = [];
712
+ try { files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json")); } catch { return false; }
713
+ for (const file of files) {
714
+ try {
715
+ const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
716
+ if (validEvent(value, file, rootDir.split(/[\\/]/).pop()) && value.type === "inbox_materialized" && value.deliveryId === deliveryId) return true;
717
+ } catch {}
718
+ }
719
+ return false;
720
+ }
721
+
722
+ /** Return whether a specific immutable event was already published. */
723
+ export function hasLineageEvent(rootDir: string, eventId: string, type?: string): boolean {
724
+ const file = join(eventDir(rootDir), `${safeId(eventId)}.json`);
725
+ try {
726
+ const value = JSON.parse(readFileSync(file, "utf8"));
727
+ return validEvent(value, `${safeId(eventId)}.json`, rootDir.split(/[\\/]/).pop()) && value.eventId === eventId && (!type || value.type === type);
728
+ } catch { return false; }
729
+ }
730
+
731
+ /** Find durable inbox entries not yet materialized for their exact session sink. */
732
+ export function pendingLineageInboxes(rootDir: string, sessionId: string, sessionFile: string): LineageInboxRecord[] {
733
+ const pending: LineageInboxRecord[] = [];
734
+ let files: string[] = [];
735
+ try { files = readdirSync(join(rootDir, "events")).filter((f) => f.endsWith(".json")); } catch { return pending; }
736
+ const materialized = new Set<string>();
737
+ const inboxes: LineageInboxRecord[] = [];
738
+ for (const file of files) {
739
+ try {
740
+ const value = JSON.parse(readFileSync(join(rootDir, "events", file), "utf8"));
741
+ if (!validEvent(value, file, rootDir.split(/[\\/]/).pop())) continue;
742
+ if (value.type === "inbox_materialized" && typeof value.deliveryId === "string") materialized.add(value.deliveryId);
743
+ else if (value.type === "inbox" && value.sessionId === sessionId && typeof value.deliveryId === "string") {
744
+ const payload = value.payload && typeof value.payload === "object" ? value.payload as Record<string, unknown> : {};
745
+ inboxes.push({ rootDir, nodeId: value.nodeId, deliveryId: value.deliveryId, sessionId, sessionFile, payload, activitySequence: typeof payload.activitySequence === "number" ? payload.activitySequence : undefined });
746
+ }
747
+ } catch {}
748
+ }
749
+ for (const inbox of inboxes) if (!materialized.has(inbox.deliveryId)) pending.push(inbox);
750
+ return pending.sort((a, b) => (a.activitySequence ?? Number.MAX_SAFE_INTEGER) - (b.activitySequence ?? Number.MAX_SAFE_INTEGER) || a.deliveryId.localeCompare(b.deliveryId));
751
+ }
752
+
753
+ /** Read the authoritative lineage pointer attached to a child session. */
754
+ export function readLineageAttachment(sessionFile: string): LineageRegistration | undefined {
755
+ const embedded = readSessionBinding(sessionFile)?.child?.lineage;
756
+ if (embedded && existsSync(join(embedded.rootDir, "events"))) return embedded;
757
+ try {
758
+ const value = JSON.parse(readFileSync(`${sessionFile}.lineage.json`, "utf8")) as Partial<LineageRegistration>;
759
+ if (typeof value.rootDir !== "string" || typeof value.rootId !== "string" || typeof value.nodeId !== "string") return undefined;
760
+ if (!existsSync(join(value.rootDir, "events"))) return undefined;
761
+ return value as LineageRegistration;
762
+ } catch {
763
+ return undefined;
764
+ }
765
+ }
766
+
767
+ /** Discover durable roots, preferring session attachments over directory guesses. */
768
+ export function discoverLineageRoots(sessionDir: string, sessionFile?: string): string[] {
769
+ const result = new Set<string>();
770
+ if (sessionFile) {
771
+ const attachment = readLineageAttachment(sessionFile);
772
+ if (attachment) result.add(attachment.rootDir);
773
+ const binding = readSessionBinding(sessionFile);
774
+ const header = readSessionHeader(sessionFile);
775
+ if (header) {
776
+ for (const rootDir of sessionLineageRoots(binding?.storeDir ?? sessionBindingStore(), header.id)) result.add(rootDir);
777
+ }
778
+ // Bound sessions need only their index and their own legacy artifacts,
779
+ // including a root published after startup but not yet indexed by a launcher.
780
+ if (binding) {
781
+ const lineageDir = join(sessionDir, "artifacts", binding.sessionId, "lineage");
782
+ try {
783
+ for (const root of readdirSync(lineageDir, { withFileTypes: true })) {
784
+ if (root.isDirectory()) result.add(join(lineageDir, root.name));
785
+ }
786
+ } catch { /* This session may have no local artifacts after a move. */ }
787
+ return [...result].sort();
788
+ }
789
+ }
790
+ try {
791
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
792
+ if (entry.isFile() && entry.name.endsWith(".lineage.json")) {
793
+ const attachment = readLineageAttachment(join(sessionDir, entry.name.slice(0, -".lineage.json".length)));
794
+ if (attachment) result.add(attachment.rootDir);
795
+ }
796
+ }
797
+ } catch {}
798
+ try {
799
+ for (const artifact of readdirSync(join(sessionDir, "artifacts"), { withFileTypes: true })) {
800
+ if (!artifact.isDirectory()) continue;
801
+ const lineageDir = join(sessionDir, "artifacts", artifact.name, "lineage");
802
+ for (const root of readdirSync(lineageDir, { withFileTypes: true })) {
803
+ if (root.isDirectory()) result.add(join(lineageDir, root.name));
804
+ }
805
+ }
806
+ } catch {}
807
+ return [...result].sort();
808
+ }
809
+
810
+ export function lineageEnvironment(registration: LineageRegistration): Record<string, string> {
811
+ return {
812
+ PI_SUBAGENT_LINEAGE_DIR: registration.rootDir,
813
+ PI_SUBAGENT_LINEAGE_ROOT: registration.rootId,
814
+ ...(registration.parentNodeId ? { PI_SUBAGENT_PARENT_NODE: registration.parentNodeId } : {}),
815
+ };
816
+ }
817
+
818
+ /**
819
+ * Read lineage only in the Pi process launched for this node.
820
+ *
821
+ * Shell commands inherit the Pi environment. The launcher PID prevents an
822
+ * ordinary child process from impersonating its Pi owner and registering test
823
+ * fixtures or other local work as real descendants.
824
+ */
825
+ export function lineageFromEnvironment(
826
+ env: NodeJS.ProcessEnv = process.env,
827
+ parentPid = process.ppid,
828
+ ): Pick<LineageRegistration, "rootDir" | "rootId" | "nodeId" | "parentNodeId"> | undefined {
829
+ if (
830
+ !env.PI_SUBAGENT_LINEAGE_DIR ||
831
+ !env.PI_SUBAGENT_LINEAGE_ROOT ||
832
+ !env.PI_SUBAGENT_ID ||
833
+ env.PI_SUBAGENT_LAUNCHER_PID !== String(parentPid)
834
+ ) return undefined;
835
+ return {
836
+ rootDir: env.PI_SUBAGENT_LINEAGE_DIR,
837
+ rootId: env.PI_SUBAGENT_LINEAGE_ROOT,
838
+ nodeId: env.PI_SUBAGENT_ID,
839
+ ...(env.PI_SUBAGENT_PARENT_NODE ? { parentNodeId: env.PI_SUBAGENT_PARENT_NODE } : {}),
840
+ };
841
+ }