pi-agent-replicate 0.0.0-stage → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +152 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +68 -2
  6. package/RELEASING.md +132 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +23 -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/configuration.md +14 -0
  15. package/docs/development.md +27 -0
  16. package/docs/public-subagent-tree-api.md +58 -0
  17. package/docs/retained-roles.md +71 -0
  18. package/docs/settled-lifecycle-contract.md +71 -0
  19. package/docs/worktree-subagents.md +58 -0
  20. package/examples/role-pack/extension.ts +18 -0
  21. package/examples/role-pack/package.json +16 -0
  22. package/examples/role-pack/roles/example-reviewer.md +12 -0
  23. package/index.d.ts +116 -0
  24. package/index.js +1064 -0
  25. package/index.ts +155 -0
  26. package/package.json +70 -4
  27. package/pi-extension/subagents/activity.js +575 -0
  28. package/pi-extension/subagents/activity.ts +980 -0
  29. package/pi-extension/subagents/completion.js +184 -0
  30. package/pi-extension/subagents/completion.ts +229 -0
  31. package/pi-extension/subagents/delivery-drain.js +15 -0
  32. package/pi-extension/subagents/delivery-drain.ts +20 -0
  33. package/pi-extension/subagents/delivery-files.js +86 -0
  34. package/pi-extension/subagents/delivery-files.ts +105 -0
  35. package/pi-extension/subagents/delivery-log.js +51 -0
  36. package/pi-extension/subagents/delivery-log.ts +51 -0
  37. package/pi-extension/subagents/delivery-payload.ts +55 -0
  38. package/pi-extension/subagents/delivery.ts +219 -0
  39. package/pi-extension/subagents/execution-owner.js +30 -0
  40. package/pi-extension/subagents/execution-owner.ts +38 -0
  41. package/pi-extension/subagents/herdr.js +645 -0
  42. package/pi-extension/subagents/herdr.ts +927 -0
  43. package/pi-extension/subagents/index.ts +3400 -0
  44. package/pi-extension/subagents/launch.js +774 -0
  45. package/pi-extension/subagents/launch.ts +1124 -0
  46. package/pi-extension/subagents/lifecycle.js +477 -0
  47. package/pi-extension/subagents/lifecycle.ts +582 -0
  48. package/pi-extension/subagents/lineage.js +806 -0
  49. package/pi-extension/subagents/lineage.ts +841 -0
  50. package/pi-extension/subagents/replica-background.js +32 -0
  51. package/pi-extension/subagents/replica-background.ts +38 -0
  52. package/pi-extension/subagents/replica-branch.ts +45 -0
  53. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  54. package/pi-extension/subagents/replica-history.js +22 -0
  55. package/pi-extension/subagents/replica-history.ts +21 -0
  56. package/pi-extension/subagents/replica-work.js +326 -0
  57. package/pi-extension/subagents/replica-work.ts +336 -0
  58. package/pi-extension/subagents/replica-worker.js +74 -0
  59. package/pi-extension/subagents/replica-worker.ts +71 -0
  60. package/pi-extension/subagents/replication.ts +99 -0
  61. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  62. package/pi-extension/subagents/role-catalog.js +123 -0
  63. package/pi-extension/subagents/role-catalog.ts +24 -0
  64. package/pi-extension/subagents/runtime-routing.js +154 -0
  65. package/pi-extension/subagents/runtime-routing.ts +239 -0
  66. package/pi-extension/subagents/session-binding.js +346 -0
  67. package/pi-extension/subagents/session-binding.ts +339 -0
  68. package/pi-extension/subagents/session-work.js +219 -0
  69. package/pi-extension/subagents/session-work.ts +167 -0
  70. package/pi-extension/subagents/session.js +318 -0
  71. package/pi-extension/subagents/session.ts +448 -0
  72. package/pi-extension/subagents/settled-contract.js +47 -0
  73. package/pi-extension/subagents/settled-contract.ts +175 -0
  74. package/pi-extension/subagents/source-state.js +180 -0
  75. package/pi-extension/subagents/source-state.ts +184 -0
  76. package/pi-extension/subagents/status.ts +493 -0
  77. package/pi-extension/subagents/subagent-done.js +805 -0
  78. package/pi-extension/subagents/subagent-done.ts +878 -0
  79. package/pi-extension/subagents/terminal.js +111 -0
  80. package/pi-extension/subagents/terminal.ts +208 -0
  81. package/pi-extension/subagents/watchdog.js +122 -0
  82. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ const ABORT_MESSAGE = "Aborted while waiting for subagent to finish";
3
+ const TERMINAL_SENTINEL = /__SUBAGENT_DONE_(\d+)__/;
4
+ export function interpretExitSidecar(data) {
5
+ const payload = data;
6
+ if (payload?.type === "ping") {
7
+ return {
8
+ reason: "ping",
9
+ exitCode: 0,
10
+ ping: {
11
+ ...(typeof payload.id === "string"
12
+ ? { id: payload.id }
13
+ : {}),
14
+ name: typeof payload.name === "string" ? payload.name : "subagent",
15
+ message: typeof payload.message === "string" ? payload.message : "",
16
+ },
17
+ };
18
+ }
19
+ if (payload?.type === "error") {
20
+ const errorMessage = typeof payload.errorMessage === "string" && payload.errorMessage.trim()
21
+ ? payload.errorMessage
22
+ : "Subagent exited with stopReason=error (no errorMessage in sidecar).";
23
+ return { reason: "error", exitCode: 1, errorMessage };
24
+ }
25
+ if (payload?.type === "done")
26
+ return {
27
+ reason: "done",
28
+ exitCode: 0,
29
+ ...(typeof payload.summary === "string" && payload.summary.trim() ? { summary: payload.summary } : {}),
30
+ };
31
+ return {
32
+ reason: "error",
33
+ exitCode: 1,
34
+ errorMessage: "Invalid subagent completion sidecar: unsupported payload type.",
35
+ };
36
+ }
37
+ /** Read completion evidence without acknowledging or removing it. */
38
+ export function readExitSidecar(sessionFile) {
39
+ if (!sessionFile)
40
+ return null;
41
+ const exitFile = `${sessionFile}.exit`;
42
+ if (!existsSync(exitFile))
43
+ return null;
44
+ try {
45
+ return JSON.parse(readFileSync(exitFile, "utf8"));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Remove completion evidence only after durable result and inbox publication. */
52
+ export function acknowledgeExitSidecar(sessionFile) {
53
+ rmSync(`${sessionFile}.exit`, { force: true });
54
+ }
55
+ /** Remove only the exact help evidence that was durably published. */
56
+ export function acknowledgePingSidecar(sessionFile, pingId) {
57
+ const payload = readExitSidecar(sessionFile);
58
+ if (payload == null || typeof payload !== "object")
59
+ return;
60
+ const candidate = payload;
61
+ if (candidate.type !== "ping" || candidate.id !== pingId)
62
+ return;
63
+ rmSync(`${sessionFile}.exit`, { force: true });
64
+ }
65
+ function consumeExitSidecar(sessionFile) {
66
+ const payload = readExitSidecar(sessionFile);
67
+ return payload == null ? null : interpretExitSidecar(payload);
68
+ }
69
+ function terminalExitCode(screen) {
70
+ const match = screen.match(TERMINAL_SENTINEL);
71
+ return match ? Number.parseInt(match[1], 10) : null;
72
+ }
73
+ /** Keep the last concrete error block, not shell prompts or unrelated startup warnings. */
74
+ function failureDiagnostic(output) {
75
+ const lines = output.replace(/\r/g, "").split("\n");
76
+ const start = lines.map(line => /^\s*(?:[A-Za-z]*Error:|Failed to load extension|Cannot find module)/.test(line)).lastIndexOf(true);
77
+ if (start < 0)
78
+ return undefined;
79
+ const block = lines.slice(start);
80
+ const end = block.findIndex(line => /^\s*(?:Warning:|Hint:)/.test(line) || TERMINAL_SENTINEL.test(line));
81
+ return block.slice(0, end < 0 ? block.length : end).join("\n").trim().slice(0, 8192) || undefined;
82
+ }
83
+ function completionArtifact(options) {
84
+ return consumeExitSidecar(options.resolveSessionFile?.() ?? options.sessionFile);
85
+ }
86
+ async function waitForDisappearanceArtifacts(signal, options) {
87
+ const immediate = completionArtifact(options);
88
+ if (immediate)
89
+ return immediate;
90
+ const graceMs = Math.max(0, options.paneDisappearanceGraceMs ?? 500);
91
+ const deadline = Date.now() + graceMs;
92
+ while (Date.now() < deadline) {
93
+ const remaining = deadline - Date.now();
94
+ await abortableDelay(Math.min(25, remaining), signal);
95
+ const result = completionArtifact(options);
96
+ if (result)
97
+ return result;
98
+ }
99
+ return null;
100
+ }
101
+ function abortableDelay(milliseconds, signal) {
102
+ if (signal.aborted)
103
+ return Promise.reject(new Error(ABORT_MESSAGE));
104
+ return new Promise((resolve, reject) => {
105
+ const onAbort = () => {
106
+ clearTimeout(timer);
107
+ reject(new Error(ABORT_MESSAGE));
108
+ };
109
+ const timer = setTimeout(() => {
110
+ signal.removeEventListener("abort", onAbort);
111
+ resolve();
112
+ }, milliseconds);
113
+ signal.addEventListener("abort", onAbort, { once: true });
114
+ });
115
+ }
116
+ export async function waitForCompletion(signal, options) {
117
+ const startedAt = Date.now();
118
+ for (;;) {
119
+ if (signal.aborted)
120
+ throw new Error(ABORT_MESSAGE);
121
+ const sidecarResult = completionArtifact(options);
122
+ if (sidecarResult)
123
+ return sidecarResult;
124
+ try {
125
+ const exitCode = terminalExitCode(await options.readTerminalTail());
126
+ if (exitCode !== null) {
127
+ // The shell sentinel can race the child-side exit sidecar. Give the
128
+ // durable intent a short chance to win so an explicit final summary is
129
+ // not mistaken for a previously delivered settled turn.
130
+ const sidecarResult = options.sessionFile
131
+ ? await waitForDisappearanceArtifacts(signal, options)
132
+ : null;
133
+ if (sidecarResult)
134
+ return sidecarResult;
135
+ if (options.confirmTerminalExit && !await options.confirmTerminalExit()) {
136
+ await abortableDelay(options.intervalMs, signal);
137
+ continue;
138
+ }
139
+ let errorMessage;
140
+ if (options.readFailureOutput) {
141
+ try {
142
+ errorMessage = failureDiagnostic(await options.readFailureOutput());
143
+ }
144
+ catch { /* Confirmed exit still reports without an invented diagnostic. */ }
145
+ }
146
+ if (options.confirmTerminalExit && !await options.confirmTerminalExit()) {
147
+ await abortableDelay(options.intervalMs, signal);
148
+ continue;
149
+ }
150
+ return { reason: "sentinel", exitCode, ...(errorMessage ? { errorMessage } : {}) };
151
+ }
152
+ }
153
+ catch {
154
+ // Terminal reads are only sentinel/output probes; Herdr status is polled
155
+ // independently below, even when terminal reads succeed.
156
+ }
157
+ if (options.inspectPane) {
158
+ let inspection;
159
+ try {
160
+ inspection = await options.inspectPane();
161
+ }
162
+ catch {
163
+ inspection = { kind: "unavailable", error: "inspectPane threw" };
164
+ }
165
+ const observedAt = Date.now();
166
+ options.onPaneInspection?.(inspection, observedAt);
167
+ if (inspection.kind === "missing") {
168
+ // Pane closure and atomic artifact publication are separate operations.
169
+ // Allow a short bounded grace window before declaring evidence lost.
170
+ const racedCompletion = await waitForDisappearanceArtifacts(signal, options);
171
+ if (racedCompletion)
172
+ return racedCompletion;
173
+ return {
174
+ reason: "error",
175
+ exitCode: 1,
176
+ errorMessage: "Subagent pane disappeared before completion evidence was recorded.",
177
+ paneDisappeared: true,
178
+ };
179
+ }
180
+ }
181
+ options.onTick?.(Math.floor((Date.now() - startedAt) / 1000));
182
+ await abortableDelay(options.intervalMs, signal);
183
+ }
184
+ }
@@ -0,0 +1,229 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+
3
+ const ABORT_MESSAGE = "Aborted while waiting for subagent to finish";
4
+ const TERMINAL_SENTINEL = /__SUBAGENT_DONE_(\d+)__/;
5
+
6
+ export interface CompletionResult {
7
+ reason: "done" | "ping" | "sentinel" | "error";
8
+ exitCode: number;
9
+ ping?: { id?: string; name: string; message: string };
10
+ summary?: string;
11
+ errorMessage?: string;
12
+ /** Confirmed Herdr pane absence without a completion artifact. */
13
+ paneDisappeared?: true;
14
+ }
15
+
16
+ export interface CompletionOptions {
17
+ intervalMs: number;
18
+ readTerminalTail: () => Promise<string>;
19
+ /** Replica-only diagnostic read after confirmed exit, never a failure trigger itself. */
20
+ readFailureOutput?: () => Promise<string>;
21
+ /** Revalidate replica process exit after races with sidecars or replacement startup. */
22
+ confirmTerminalExit?: () => boolean | Promise<boolean>;
23
+ inspectPane?: () => Promise<import("./lifecycle.ts").PaneInspection>;
24
+ /** Bounded artifact grace after terminal or pane completion evidence. Default: 500ms. */
25
+ paneDisappearanceGraceMs?: number;
26
+ onPaneInspection?: (
27
+ inspection: import("./lifecycle.ts").PaneInspection,
28
+ observedAt: number,
29
+ ) => void;
30
+ sessionFile?: string;
31
+ /** Resolve the current file after a same-session process handoff. */
32
+ resolveSessionFile?: () => string;
33
+ onTick?: (elapsedSeconds: number) => void;
34
+ }
35
+
36
+ export function interpretExitSidecar(data: unknown): CompletionResult {
37
+ const payload = data as {
38
+ type?: unknown;
39
+ name?: unknown;
40
+ message?: unknown;
41
+ summary?: unknown;
42
+ errorMessage?: unknown;
43
+ };
44
+
45
+ if (payload?.type === "ping") {
46
+ return {
47
+ reason: "ping",
48
+ exitCode: 0,
49
+ ping: {
50
+ ...(typeof (payload as { id?: unknown }).id === "string"
51
+ ? { id: (payload as { id: string }).id }
52
+ : {}),
53
+ name: typeof payload.name === "string" ? payload.name : "subagent",
54
+ message: typeof payload.message === "string" ? payload.message : "",
55
+ },
56
+ };
57
+ }
58
+
59
+ if (payload?.type === "error") {
60
+ const errorMessage =
61
+ typeof payload.errorMessage === "string" && payload.errorMessage.trim()
62
+ ? payload.errorMessage
63
+ : "Subagent exited with stopReason=error (no errorMessage in sidecar).";
64
+ return { reason: "error", exitCode: 1, errorMessage };
65
+ }
66
+
67
+ if (payload?.type === "done") return {
68
+ reason: "done",
69
+ exitCode: 0,
70
+ ...(typeof payload.summary === "string" && payload.summary.trim() ? { summary: payload.summary } : {}),
71
+ };
72
+
73
+ return {
74
+ reason: "error",
75
+ exitCode: 1,
76
+ errorMessage: "Invalid subagent completion sidecar: unsupported payload type.",
77
+ };
78
+ }
79
+
80
+ /** Read completion evidence without acknowledging or removing it. */
81
+ export function readExitSidecar(sessionFile: string | undefined): unknown | null {
82
+ if (!sessionFile) return null;
83
+ const exitFile = `${sessionFile}.exit`;
84
+ if (!existsSync(exitFile)) return null;
85
+ try { return JSON.parse(readFileSync(exitFile, "utf8")); }
86
+ catch { return null; }
87
+ }
88
+
89
+ /** Remove completion evidence only after durable result and inbox publication. */
90
+ export function acknowledgeExitSidecar(sessionFile: string): void {
91
+ rmSync(`${sessionFile}.exit`, { force: true });
92
+ }
93
+
94
+
95
+ /** Remove only the exact help evidence that was durably published. */
96
+ export function acknowledgePingSidecar(sessionFile: string, pingId: string | undefined): void {
97
+ const payload = readExitSidecar(sessionFile);
98
+ if (payload == null || typeof payload !== "object") return;
99
+ const candidate = payload as { type?: unknown; id?: unknown };
100
+ if (candidate.type !== "ping" || candidate.id !== pingId) return;
101
+ rmSync(`${sessionFile}.exit`, { force: true });
102
+ }
103
+
104
+ function consumeExitSidecar(sessionFile: string | undefined): CompletionResult | null {
105
+ const payload = readExitSidecar(sessionFile);
106
+ return payload == null ? null : interpretExitSidecar(payload);
107
+ }
108
+
109
+ function terminalExitCode(screen: string): number | null {
110
+ const match = screen.match(TERMINAL_SENTINEL);
111
+ return match ? Number.parseInt(match[1], 10) : null;
112
+ }
113
+
114
+ /** Keep the last concrete error block, not shell prompts or unrelated startup warnings. */
115
+ function failureDiagnostic(output: string): string | undefined {
116
+ const lines = output.replace(/\r/g, "").split("\n");
117
+ const start = lines.map(line => /^\s*(?:[A-Za-z]*Error:|Failed to load extension|Cannot find module)/.test(line)).lastIndexOf(true);
118
+ if (start < 0) return undefined;
119
+ const block = lines.slice(start);
120
+ const end = block.findIndex(line => /^\s*(?:Warning:|Hint:)/.test(line) || TERMINAL_SENTINEL.test(line));
121
+ return block.slice(0, end < 0 ? block.length : end).join("\n").trim().slice(0, 8192) || undefined;
122
+ }
123
+ function completionArtifact(options: CompletionOptions): CompletionResult | null {
124
+ return consumeExitSidecar(options.resolveSessionFile?.() ?? options.sessionFile);
125
+ }
126
+
127
+ async function waitForDisappearanceArtifacts(
128
+ signal: AbortSignal,
129
+ options: CompletionOptions,
130
+ ): Promise<CompletionResult | null> {
131
+ const immediate = completionArtifact(options);
132
+ if (immediate) return immediate;
133
+
134
+ const graceMs = Math.max(0, options.paneDisappearanceGraceMs ?? 500);
135
+ const deadline = Date.now() + graceMs;
136
+ while (Date.now() < deadline) {
137
+ const remaining = deadline - Date.now();
138
+ await abortableDelay(Math.min(25, remaining), signal);
139
+ const result = completionArtifact(options);
140
+ if (result) return result;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
146
+ if (signal.aborted) return Promise.reject(new Error(ABORT_MESSAGE));
147
+
148
+ return new Promise<void>((resolve, reject) => {
149
+ const onAbort = () => {
150
+ clearTimeout(timer);
151
+ reject(new Error(ABORT_MESSAGE));
152
+ };
153
+ const timer = setTimeout(() => {
154
+ signal.removeEventListener("abort", onAbort);
155
+ resolve();
156
+ }, milliseconds);
157
+ signal.addEventListener("abort", onAbort, { once: true });
158
+ });
159
+ }
160
+
161
+ export async function waitForCompletion(
162
+ signal: AbortSignal,
163
+ options: CompletionOptions,
164
+ ): Promise<CompletionResult> {
165
+ const startedAt = Date.now();
166
+
167
+ for (;;) {
168
+ if (signal.aborted) throw new Error(ABORT_MESSAGE);
169
+
170
+ const sidecarResult = completionArtifact(options);
171
+ if (sidecarResult) return sidecarResult;
172
+
173
+ try {
174
+ const exitCode = terminalExitCode(await options.readTerminalTail());
175
+ if (exitCode !== null) {
176
+ // The shell sentinel can race the child-side exit sidecar. Give the
177
+ // durable intent a short chance to win so an explicit final summary is
178
+ // not mistaken for a previously delivered settled turn.
179
+ const sidecarResult = options.sessionFile
180
+ ? await waitForDisappearanceArtifacts(signal, options)
181
+ : null;
182
+ if (sidecarResult) return sidecarResult;
183
+ if (options.confirmTerminalExit && !await options.confirmTerminalExit()) {
184
+ await abortableDelay(options.intervalMs, signal);
185
+ continue;
186
+ }
187
+ let errorMessage: string | undefined;
188
+ if (options.readFailureOutput) {
189
+ try { errorMessage = failureDiagnostic(await options.readFailureOutput()); }
190
+ catch { /* Confirmed exit still reports without an invented diagnostic. */ }
191
+ }
192
+ if (options.confirmTerminalExit && !await options.confirmTerminalExit()) {
193
+ await abortableDelay(options.intervalMs, signal);
194
+ continue;
195
+ }
196
+ return { reason: "sentinel", exitCode, ...(errorMessage ? { errorMessage } : {}) };
197
+ }
198
+ } catch {
199
+ // Terminal reads are only sentinel/output probes; Herdr status is polled
200
+ // independently below, even when terminal reads succeed.
201
+ }
202
+
203
+ if (options.inspectPane) {
204
+ let inspection: import("./lifecycle.ts").PaneInspection;
205
+ try {
206
+ inspection = await options.inspectPane();
207
+ } catch {
208
+ inspection = { kind: "unavailable", error: "inspectPane threw" };
209
+ }
210
+ const observedAt = Date.now();
211
+ options.onPaneInspection?.(inspection, observedAt);
212
+ if (inspection.kind === "missing") {
213
+ // Pane closure and atomic artifact publication are separate operations.
214
+ // Allow a short bounded grace window before declaring evidence lost.
215
+ const racedCompletion = await waitForDisappearanceArtifacts(signal, options);
216
+ if (racedCompletion) return racedCompletion;
217
+ return {
218
+ reason: "error",
219
+ exitCode: 1,
220
+ errorMessage: "Subagent pane disappeared before completion evidence was recorded.",
221
+ paneDisappeared: true,
222
+ };
223
+ }
224
+ }
225
+
226
+ options.onTick?.(Math.floor((Date.now() - startedAt) / 1000));
227
+ await abortableDelay(options.intervalMs, signal);
228
+ }
229
+ }
@@ -0,0 +1,15 @@
1
+ /** Wait without a deadline until durable descendant state reports drained. */
2
+ export async function waitForDeliveryDrain(options) {
3
+ if (options.isDrained())
4
+ return;
5
+ const now = options.now ?? Date.now;
6
+ const delay = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
7
+ const waitedSince = now();
8
+ options.onWaitOpen?.({ waitedSince });
9
+ while (!options.isDrained()) {
10
+ if (options.signal?.aborted)
11
+ throw options.signal.reason ?? new Error("Drain wait aborted");
12
+ await delay(50);
13
+ }
14
+ options.onWaitRelease?.({ waitedSince, waitedMs: Math.max(0, now() - waitedSince) });
15
+ }
@@ -0,0 +1,20 @@
1
+ /** Wait without a deadline until durable descendant state reports drained. */
2
+ export async function waitForDeliveryDrain(options: {
3
+ isDrained: () => boolean;
4
+ delay?: (ms: number) => Promise<void>;
5
+ now?: () => number;
6
+ onWaitOpen?: (info: { waitedSince: number }) => void;
7
+ onWaitRelease?: (info: { waitedSince: number; waitedMs: number }) => void;
8
+ signal?: AbortSignal;
9
+ }): Promise<void> {
10
+ if (options.isDrained()) return;
11
+ const now = options.now ?? Date.now;
12
+ const delay = options.delay ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
13
+ const waitedSince = now();
14
+ options.onWaitOpen?.({ waitedSince });
15
+ while (!options.isDrained()) {
16
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("Drain wait aborted");
17
+ await delay(50);
18
+ }
19
+ options.onWaitRelease?.({ waitedSince, waitedMs: Math.max(0, now() - waitedSince) });
20
+ }
@@ -0,0 +1,86 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync, } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ function fsyncDirectory(path) {
4
+ const fd = openSync(path, "r");
5
+ try {
6
+ fsyncSync(fd);
7
+ }
8
+ finally {
9
+ closeSync(fd);
10
+ }
11
+ }
12
+ function deliveryIdFromFile(path) {
13
+ try {
14
+ const header = readFileSync(path, "utf8").split(/\r?\n\r?\n/, 1)[0] ?? "";
15
+ return /^delivery[\s_-]+id\s*[:=]\s*(.+)$/im.exec(header)?.[1]?.trim();
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ function files(path) {
22
+ try {
23
+ return readdirSync(path).filter((name) => /^\d+-[^/]+\.md$/.test(name));
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+ function existingDelivery(path, deliveryId) {
30
+ for (const filename of files(path)) {
31
+ if (deliveryIdFromFile(join(path, filename)) !== deliveryId)
32
+ continue;
33
+ const sequence = Number.parseInt(filename, 10);
34
+ return { path: join(path, filename), sequence, filename };
35
+ }
36
+ return undefined;
37
+ }
38
+ function nextSequence(path) {
39
+ return files(path).reduce((max, filename) => {
40
+ const sequence = Number.parseInt(filename, 10);
41
+ return Number.isFinite(sequence) ? Math.max(max, sequence) : max;
42
+ }, 0) + 1;
43
+ }
44
+ function resultContents(input, sequence) {
45
+ return [
46
+ `Status: ${input.status}`,
47
+ `Agent name: ${input.agentName}`,
48
+ `Child session id: ${input.childSessionId}`,
49
+ `Time: ${new Date(input.now).toISOString()}`,
50
+ `Delivery sequence: ${sequence}`,
51
+ `Delivery id: ${input.deliveryId}`,
52
+ "",
53
+ input.answer ?? "Nothing was captured for this delivery.",
54
+ ].join("\n");
55
+ }
56
+ /** Materialize one immutable result file, recovering the same delivery after races or restart. */
57
+ export function materializeResultFile(input) {
58
+ const sessionsDir = isAbsolute(input.sessionsDir) ? input.sessionsDir : resolve(input.sessionsDir);
59
+ const directory = join(sessionsDir, input.childSessionId);
60
+ mkdirSync(directory, { recursive: true });
61
+ for (;;) {
62
+ const recovered = existingDelivery(directory, input.deliveryId);
63
+ if (recovered)
64
+ return recovered;
65
+ const sequence = nextSequence(directory);
66
+ const filename = `${String(sequence).padStart(2, "0")}-${input.status}.md`;
67
+ const path = join(directory, filename);
68
+ let fd;
69
+ try {
70
+ fd = openSync(path, "wx", 0o600);
71
+ writeSync(fd, resultContents(input, sequence), undefined, "utf8");
72
+ fsyncSync(fd);
73
+ closeSync(fd);
74
+ fd = undefined;
75
+ fsyncDirectory(directory);
76
+ return { path, sequence, filename };
77
+ }
78
+ catch (error) {
79
+ if (fd !== undefined)
80
+ closeSync(fd);
81
+ if (error.code === "EEXIST")
82
+ continue;
83
+ throw error;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ writeSync,
9
+ } from "node:fs";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+ import type { DeliveryStatus } from "./delivery-payload.ts";
12
+
13
+ export interface ResultFileInput {
14
+ sessionsDir: string;
15
+ childSessionId: string;
16
+ deliveryId: string;
17
+ status: DeliveryStatus;
18
+ agentName: string;
19
+ answer?: string;
20
+ now: number;
21
+ }
22
+
23
+ export interface ResultFileHandle {
24
+ path: string;
25
+ sequence: number;
26
+ filename: string;
27
+ }
28
+
29
+ function fsyncDirectory(path: string): void {
30
+ const fd = openSync(path, "r");
31
+ try { fsyncSync(fd); } finally { closeSync(fd); }
32
+ }
33
+
34
+ function deliveryIdFromFile(path: string): string | undefined {
35
+ try {
36
+ const header = readFileSync(path, "utf8").split(/\r?\n\r?\n/, 1)[0] ?? "";
37
+ return /^delivery[\s_-]+id\s*[:=]\s*(.+)$/im.exec(header)?.[1]?.trim();
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ function files(path: string): string[] {
44
+ try { return readdirSync(path).filter((name) => /^\d+-[^/]+\.md$/.test(name)); }
45
+ catch { return []; }
46
+ }
47
+
48
+ function existingDelivery(path: string, deliveryId: string): ResultFileHandle | undefined {
49
+ for (const filename of files(path)) {
50
+ if (deliveryIdFromFile(join(path, filename)) !== deliveryId) continue;
51
+ const sequence = Number.parseInt(filename, 10);
52
+ return { path: join(path, filename), sequence, filename };
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ function nextSequence(path: string): number {
58
+ return files(path).reduce((max, filename) => {
59
+ const sequence = Number.parseInt(filename, 10);
60
+ return Number.isFinite(sequence) ? Math.max(max, sequence) : max;
61
+ }, 0) + 1;
62
+ }
63
+
64
+ function resultContents(input: ResultFileInput, sequence: number): string {
65
+ return [
66
+ `Status: ${input.status}`,
67
+ `Agent name: ${input.agentName}`,
68
+ `Child session id: ${input.childSessionId}`,
69
+ `Time: ${new Date(input.now).toISOString()}`,
70
+ `Delivery sequence: ${sequence}`,
71
+ `Delivery id: ${input.deliveryId}`,
72
+ "",
73
+ input.answer ?? "Nothing was captured for this delivery.",
74
+ ].join("\n");
75
+ }
76
+
77
+ /** Materialize one immutable result file, recovering the same delivery after races or restart. */
78
+ export function materializeResultFile(input: ResultFileInput): ResultFileHandle {
79
+ const sessionsDir = isAbsolute(input.sessionsDir) ? input.sessionsDir : resolve(input.sessionsDir);
80
+ const directory = join(sessionsDir, input.childSessionId);
81
+ mkdirSync(directory, { recursive: true });
82
+
83
+ for (;;) {
84
+ const recovered = existingDelivery(directory, input.deliveryId);
85
+ if (recovered) return recovered;
86
+
87
+ const sequence = nextSequence(directory);
88
+ const filename = `${String(sequence).padStart(2, "0")}-${input.status}.md`;
89
+ const path = join(directory, filename);
90
+ let fd: number | undefined;
91
+ try {
92
+ fd = openSync(path, "wx", 0o600);
93
+ writeSync(fd, resultContents(input, sequence), undefined, "utf8");
94
+ fsyncSync(fd);
95
+ closeSync(fd);
96
+ fd = undefined;
97
+ fsyncDirectory(directory);
98
+ return { path, sequence, filename };
99
+ } catch (error) {
100
+ if (fd !== undefined) closeSync(fd);
101
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
102
+ throw error;
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,51 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ const ALLOWED_FIELDS = [
4
+ "childId",
5
+ "deliveryId",
6
+ "status",
7
+ "resultPath",
8
+ "phase",
9
+ "error",
10
+ "waitedSince",
11
+ "waitedMs",
12
+ "attempt",
13
+ "cause",
14
+ "reason",
15
+ "surface",
16
+ ];
17
+ function normalizeError(value) {
18
+ if (value == null)
19
+ return undefined;
20
+ return value instanceof Error ? value.message : String(value);
21
+ }
22
+ /** Create a contained append-only JSON-lines delivery recorder. */
23
+ export function createDeliveryLog(options) {
24
+ const now = options.now ?? Date.now;
25
+ return {
26
+ record(event, fields = {}) {
27
+ try {
28
+ mkdirSync(dirname(options.logPath), { recursive: true });
29
+ const record = {
30
+ version: 1,
31
+ time: new Date(now()).toISOString(),
32
+ event,
33
+ };
34
+ for (const key of ALLOWED_FIELDS) {
35
+ if (!(key in fields))
36
+ continue;
37
+ const value = key === "error" ? normalizeError(fields[key]) : fields[key];
38
+ if (value !== undefined)
39
+ record[key] = value;
40
+ }
41
+ appendFileSync(options.logPath, `${JSON.stringify(record)}\n`, "utf8");
42
+ }
43
+ catch (error) {
44
+ try {
45
+ options.onFailure?.(error);
46
+ }
47
+ catch { /* containment sink is best effort */ }
48
+ }
49
+ },
50
+ };
51
+ }