@fitchmultz/pi-subagents 0.50.0 → 0.52.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 (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -4,28 +4,105 @@ import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
- import { readChildProcessIdentity, trySignalChildTree } from "../shared/post-exit-stdio-guard.js";
8
- import { processSchema } from "./contracts.js";
9
- import { conversation, read, write } from "./storage.js";
10
- export function alive(identity) {
7
+ import { isChildProcessReplaced, observeChildProcessReplaced, readChildProcessIdentity, readProcessClock, trySignalChildTree, } from "../shared/post-exit-stdio-guard.js";
8
+ import { agentObservationSocketPath } from "../pi-intercom/broker/paths.js";
9
+ import { hasErrorCode } from "../shared/unknown.js";
10
+ import { attachmentSchema, parkedSchema, processSchema, } from "./contracts.js";
11
+ import { conversation, enqueue, launchAt, list, read, write } from "./storage.js";
12
+ import { errorText } from "./host.js";
13
+ /** Owner records follow their process's birth; stat after read only moves the bound later. */
14
+ export function published(file, record) {
15
+ return record && { ...record, published: fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs };
16
+ }
17
+ /** Settles liveness without a birth lookup; undefined means compare the recorded birth. */
18
+ function probe(identity) {
11
19
  try {
12
20
  process.kill(identity.pid, 0);
13
21
  }
14
22
  catch (error) {
15
- if (error instanceof Error && "code" in error && error.code === "ESRCH") {
16
- return false;
23
+ // EPERM still names a live PID, possibly another user's reuse; check its birth below.
24
+ if (!hasErrorCode(error, "EPERM")) {
25
+ return !hasErrorCode(error, "ESRCH");
17
26
  }
18
- return true;
19
27
  }
20
- const actual = readChildProcessIdentity(identity.pid);
21
- return identity.identity === "unverified" || actual === undefined || actual === identity.identity;
28
+ return identity.identity === "unverified" ? true : undefined;
29
+ }
30
+ export function alive(identity) {
31
+ // Only a proven different birth disproves a live PID; ambiguous identities stay possibly owned.
32
+ return (probe(identity) ??
33
+ (readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity)));
34
+ }
35
+ /** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
36
+ export async function observeAlive(identities) {
37
+ const verdicts = [];
38
+ for (const claim of identities) {
39
+ // One `ps` at a time: each spawn briefly holds the event loop, so concurrent spawns add up.
40
+ const { pid, identity, clock } = claim;
41
+ verdicts.push(probe(claim) ??
42
+ // oxlint-disable-next-line no-await-in-loop
43
+ !(await observeChildProcessReplaced(pid, identity, claim.published, clock)));
44
+ }
45
+ return verdicts;
46
+ }
47
+ /**
48
+ * A child's routine parent check, one observation at a time. A dead parent detaches the child
49
+ * only while its check is current and the attachment generation is unchanged: a newer parent
50
+ * may adopt the child meanwhile.
51
+ */
52
+ export class ParentCheck {
53
+ current;
54
+ /**
55
+ * Reads the attachment synchronously, so an unreadable one still fails the caller at once.
56
+ * Missing or released attachments detach immediately; a check in flight makes this a no-op.
57
+ */
58
+ check(file, detached) {
59
+ if (this.current !== undefined) {
60
+ return Promise.resolve();
61
+ }
62
+ const attachment = published(file, read(file, attachmentSchema));
63
+ if (attachment === undefined || !attachment.attached) {
64
+ detached();
65
+ return Promise.resolve();
66
+ }
67
+ const check = {};
68
+ this.current = check;
69
+ const settle = async () => {
70
+ try {
71
+ const [live] = await observeAlive([attachment]);
72
+ if (this.current === check &&
73
+ !live &&
74
+ read(file, attachmentSchema)?.generation === attachment.generation) {
75
+ detached();
76
+ }
77
+ }
78
+ finally {
79
+ if (this.current === check) {
80
+ this.current = undefined;
81
+ }
82
+ }
83
+ };
84
+ return settle();
85
+ }
86
+ /** A check settling later no longer acts. */
87
+ cancel() {
88
+ this.current = undefined;
89
+ }
22
90
  }
23
- export function ownIdentity() {
24
- // An ordinary main need not be a group leader. Ambiguous live PIDs fail closed.
25
- return { pid: process.pid, identity: readChildProcessIdentity(process.pid) ?? "unverified" };
91
+ function replaced(identity) {
92
+ return (identity.identity !== "unverified" &&
93
+ isChildProcessReplaced(identity.pid, identity.identity, identity.published, identity.clock));
94
+ }
95
+ export function ownIdentity(pid = process.pid) {
96
+ // An ordinary main or managed launcher need not be a group leader. Ambiguous live PIDs fail closed.
97
+ return {
98
+ pid,
99
+ identity: readChildProcessIdentity(pid) ?? "unverified",
100
+ clock: readProcessClock(),
101
+ };
26
102
  }
27
103
  export function actor(base, launch) {
28
- return read(path.join(conversation(base, launch), "actor.lock", "process.json"), processSchema);
104
+ const file = path.join(conversation(base, launch), "actor.lock", "process.json");
105
+ return published(file, read(file, processSchema));
29
106
  }
30
107
  function childEnvironment(launch) {
31
108
  const env = { ...process.env };
@@ -73,12 +150,17 @@ function spawnActor(base, launch, token) {
73
150
  pid: child.pid,
74
151
  identity: readChildProcessIdentity(child.pid) ?? "unverified",
75
152
  token,
153
+ clock: readProcessClock(),
76
154
  });
77
155
  }
78
- async function awaitStopping(dir, existing) {
79
- if (existing && fs.existsSync(path.join(dir, `stopping-${existing.token}.json`))) {
80
- const deadline = Date.now() + 5000;
81
- while (alive(existing)) {
156
+ async function awaitStopping(dir, existing, signal) {
157
+ if (existing) {
158
+ const stopping = path.join(dir, `stopping-${existing.token}.json`);
159
+ // Parking ends in exit or a removed marker, after native shutdown persistence.
160
+ const parking = path.join(dir, "actor.lock", "parking.json");
161
+ const deadline = Date.now() + (fs.existsSync(stopping) ? 5000 : 30_000);
162
+ while ((fs.existsSync(stopping) || fs.existsSync(parking)) && alive(existing)) {
163
+ signal?.throwIfAborted();
82
164
  if (Date.now() > deadline) {
83
165
  throw new Error("Previous actor is still stopping; no replacement was launched.");
84
166
  }
@@ -88,19 +170,22 @@ async function awaitStopping(dir, existing) {
88
170
  }
89
171
  }
90
172
  }
91
- export async function ensureActor(base, launch) {
173
+ export async function ensureActor(base, launch, signal) {
92
174
  const dir = conversation(base, launch);
93
175
  const lock = path.join(dir, "actor.lock");
94
- await awaitStopping(dir, actor(base, launch));
176
+ await awaitStopping(dir, actor(base, launch), signal);
177
+ signal?.throwIfAborted();
95
178
  // Another parallel tool may replace the claim while this invocation yields.
96
179
  const existing = actor(base, launch);
97
180
  if (existing && alive(existing)) {
98
181
  return;
99
182
  }
183
+ parkFailure(base, launch, existing);
100
184
  if (existing) {
101
185
  if (shells(base, launch, existing.token).some(alive)) {
102
186
  throw new Error("Owned native shell work remains live; stop it before continuing.");
103
187
  }
188
+ removeObservationEndpoint(existing.token);
104
189
  fs.rmSync(lock, { recursive: true });
105
190
  }
106
191
  try {
@@ -118,12 +203,48 @@ export async function ensureActor(base, launch) {
118
203
  write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
119
204
  spawnActor(base, launch, token);
120
205
  }
121
- function verifyLiveness(pid) {
206
+ /** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
207
+ export function parkFailure(base, launch, exited) {
208
+ const dir = conversation(base, launch);
209
+ const parked = read(path.join(dir, "parked.json"), parkedSchema);
210
+ if (exited && parked && parked.token !== exited.token) {
211
+ // Only the exited claim that saved this state may hand it to the next generation.
212
+ throw new Error("Parked working state belongs to another actor generation; nothing was reopened.");
213
+ }
214
+ const file = path.join(dir, "park-failed.json");
215
+ if (fs.existsSync(file)) {
216
+ throw new Error(`This idle child exited without saving its exact working state (${file}). Nothing was restarted or admitted; messages for it stay queued. Inspect it, then delegate new work.`);
217
+ }
218
+ }
219
+ /** A broker wake may only restore exact parked state, never reopen the journal alone. */
220
+ export async function wakeActor(base, launch, signal) {
221
+ const dir = conversation(base, launch);
222
+ await awaitStopping(dir, actor(base, launch), signal);
223
+ parkFailure(base, launch);
224
+ if (!fs.existsSync(path.join(dir, "parked.json"))) {
225
+ throw new Error("No parked working state; the child was not woken.");
226
+ }
227
+ await ensureActor(base, launch, signal);
228
+ }
229
+ /** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
230
+ function removeObservationEndpoint(token) {
122
231
  try {
123
- process.kill(pid, 0);
232
+ const socketPath = agentObservationSocketPath(token);
233
+ if (fs.lstatSync(socketPath, { throwIfNoEntry: false })?.isSocket() === true) {
234
+ fs.rmSync(socketPath, { force: true });
235
+ }
236
+ }
237
+ catch {
238
+ // Observation leftovers are inert to a new token and must never block native work.
239
+ }
240
+ }
241
+ function verifyLiveness(identity) {
242
+ try {
243
+ process.kill(identity.pid, 0);
124
244
  }
125
245
  catch (error) {
126
- if (error instanceof Error && "code" in error && error.code === "ESRCH") {
246
+ // Another user's live PID is released only by the same proof as a signalable one.
247
+ if (hasErrorCode(error, "ESRCH") || (hasErrorCode(error, "EPERM") && replaced(identity))) {
127
248
  return false;
128
249
  }
129
250
  throw new Error("Actor liveness is ambiguous; no stop was sent.", { cause: error });
@@ -131,13 +252,19 @@ function verifyLiveness(pid) {
131
252
  return true;
132
253
  }
133
254
  export function verifyProcess(identity) {
134
- if (!verifyLiveness(identity.pid)) {
255
+ if (!verifyLiveness(identity)) {
135
256
  return false;
136
257
  }
137
258
  const actual = readChildProcessIdentity(identity.pid);
138
- if (identity.identity !== "unverified" && actual === undefined && !verifyLiveness(identity.pid)) {
139
- // Native abort can reap a shell between the liveness probe and birth lookup.
140
- return false;
259
+ if (identity.identity !== "unverified" && actual !== identity.identity) {
260
+ if (actual === undefined && !verifyLiveness(identity)) {
261
+ // Native abort can reap a shell between the liveness probe and birth lookup.
262
+ return false;
263
+ }
264
+ // A different-birth process at the PID proves the recorded process is gone.
265
+ if (replaced(identity)) {
266
+ return false;
267
+ }
141
268
  }
142
269
  if (identity.identity === "unverified" || actual !== identity.identity) {
143
270
  throw new Error("Cannot verify actor process identity; no signal sent.");
@@ -150,13 +277,86 @@ export function signalProcess(identity, signal) {
150
277
  }
151
278
  return trySignalChildTree({ pid: identity.pid, kill: (s) => process.kill(identity.pid, s) }, signal);
152
279
  }
280
+ export function requestStop(base, launch, onError) {
281
+ const attempt = (operation) => {
282
+ try {
283
+ return operation();
284
+ }
285
+ catch (error) {
286
+ if (onError === undefined) {
287
+ throw error;
288
+ }
289
+ onError(error);
290
+ return false;
291
+ }
292
+ };
293
+ const identity = actor(base, launch);
294
+ const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
295
+ // Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
296
+ const live = targets.filter((target) => attempt(() => verifyProcess(target)));
297
+ const command = identity && live.includes(identity)
298
+ ? enqueue(base, launch, "stop", { actorToken: identity.token })
299
+ : undefined;
300
+ if (identity && live.length > 0) {
301
+ write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
302
+ at: Date.now(),
303
+ });
304
+ }
305
+ live.forEach((target) => {
306
+ attempt(() => signalProcess(target, "SIGTERM"));
307
+ });
308
+ return { targets: live, command };
309
+ }
310
+ export async function stopChildren(base, owner) {
311
+ const roots = new Set();
312
+ const targets = [];
313
+ for (const id of list(base)) {
314
+ try {
315
+ const launch = launchAt(base, id, owner);
316
+ if (!roots.has(launch.rootId)) {
317
+ roots.add(launch.rootId);
318
+ targets.push(...requestStop(base, launch, (error) => {
319
+ console.error(`Child shutdown ${id}: ${errorText(error)}`);
320
+ }).targets);
321
+ }
322
+ }
323
+ catch (error) {
324
+ console.error(`Child shutdown ${id}: ${errorText(error)}`);
325
+ }
326
+ }
327
+ await waitForExit(targets, 3000);
328
+ for (const target of targets) {
329
+ try {
330
+ signalProcess(target, "SIGKILL");
331
+ }
332
+ catch (error) {
333
+ console.error(`Child shutdown ${target.pid}: ${errorText(error)}`);
334
+ }
335
+ }
336
+ await waitForExit(targets, 1000);
337
+ if (targets.some(alive)) {
338
+ console.error("Child shutdown could not confirm every owned process exit.");
339
+ }
340
+ }
341
+ async function waitForExit(targets, timeout) {
342
+ const deadline = Date.now() + timeout;
343
+ while (Date.now() < deadline && targets.some(alive)) {
344
+ // Join actual process exit while native abort persists the interrupted turn.
345
+ // oxlint-disable-next-line no-await-in-loop
346
+ await delay(25);
347
+ }
348
+ }
153
349
  export function shells(base, launch, token) {
154
350
  const dir = path.join(conversation(base, launch), "shells", token);
155
351
  if (!fs.existsSync(dir)) {
156
352
  return [];
157
353
  }
158
- return fs.readdirSync(dir).map((name) => {
159
- const identity = read(path.join(dir, name), processSchema);
354
+ return fs
355
+ .readdirSync(dir)
356
+ .filter((name) => name.endsWith(".json"))
357
+ .map((name) => {
358
+ const file = path.join(dir, name);
359
+ const identity = published(file, read(file, processSchema));
160
360
  if (!identity || identity.token !== token) {
161
361
  throw new Error("Foreign or missing native shell identity.");
162
362
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { scanJournal } from "../shared/journal-frames.js";
3
- import { journalStamp } from "../shared/native-journal.js";
3
+ import { journalStamp, nativeAppend } from "../shared/native-journal.js";
4
+ import { isRecord } from "../shared/unknown.js";
4
5
  const detailFields = new Set([
5
6
  "owner",
6
7
  "id",
@@ -30,7 +31,7 @@ const receiptProjection = (keys) => {
30
31
  if (keys.length === 0) {
31
32
  return true;
32
33
  }
33
- if (["type", "id", "customType"].includes(String(keys[0]))) {
34
+ if (["type", "id", "customType", "version"].includes(String(keys[0]))) {
34
35
  return keys.length === 1 ? 4096 : false;
35
36
  }
36
37
  if (keys[0] !== "message") {
@@ -44,32 +45,103 @@ const receiptProjection = (keys) => {
44
45
  }
45
46
  return detailsProjection(keys.slice(1));
46
47
  };
47
- /** Physical LF publication owns delivery, including when native memory is stale. */
48
- export function receiptId(file, owner, matches) {
49
- if (file === undefined || !fs.existsSync(file)) {
50
- return;
48
+ const indexes = new Map();
49
+ function toolCompletion(message) {
50
+ return isRecord(message) &&
51
+ message.role === "toolResult" &&
52
+ (message.toolName === "delegate" || message.toolName === "agent_runs") &&
53
+ isRecord(message.details) &&
54
+ message.details.receivedResult === true
55
+ ? message.details
56
+ : undefined;
57
+ }
58
+ function completionDetails(entry) {
59
+ if (entry.type === "custom_message" && entry.customType === "agent-completion") {
60
+ return entry.details;
61
+ }
62
+ return entry.type === "message" ? toolCompletion(entry.message) : undefined;
63
+ }
64
+ function addEntry(journal, entry) {
65
+ const header = journal.entries.at(0);
66
+ const owner = header?.type === "session" ? header.id : undefined;
67
+ const details = typeof owner === "string" ? completionDetails(entry) : undefined;
68
+ if (isRecord(details) &&
69
+ details.owner === owner &&
70
+ details.completed === true &&
71
+ typeof details.id === "string" &&
72
+ !journal.completions.has(details.id)) {
73
+ journal.completions.set(details.id, typeof entry.id === "string" ? entry.id : undefined);
74
+ }
75
+ journal.entries.push(entry);
76
+ }
77
+ function prefix(stat, cached) {
78
+ return cached && nativeAppend(cached.stat, stat, cached.entries[0])
79
+ ? cached
80
+ : { end: 0, entries: [], completions: new Map() };
81
+ }
82
+ function index(file, observed) {
83
+ const cached = indexes.get(file);
84
+ if (cached?.stamp === journalStamp(observed)) {
85
+ return cached;
51
86
  }
52
87
  const fd = fs.openSync(file, "r");
53
- let first = true;
54
- let owned = false;
55
- let found;
56
88
  try {
57
- const stamp = journalStamp(fs.fstatSync(fd, { bigint: true }));
58
- scanJournal(fd, receiptProjection, ({ value }) => {
59
- if (first) {
60
- first = false;
61
- owned = value.type === "session" && value.id === owner;
62
- }
63
- else if (owned && found === undefined && typeof value.id === "string" && matches(value)) {
64
- found = value.id;
65
- }
66
- }, { policy: "live", requireNewline: true });
89
+ const stat = fs.fstatSync(fd, { bigint: true });
90
+ const stamp = journalStamp(stat);
91
+ if (cached?.stamp === stamp) {
92
+ return cached;
93
+ }
94
+ const retained = prefix(stat, cached);
95
+ const appended = [];
96
+ const end = scanJournal(fd, receiptProjection, ({ value }) => {
97
+ appended.push(value);
98
+ }, {
99
+ policy: "live",
100
+ requireNewline: true,
101
+ start: retained.end,
102
+ });
67
103
  if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
68
104
  throw new Error("Native receipt journal changed while reading.");
69
105
  }
70
- return found;
106
+ for (const entry of appended) {
107
+ addEntry(retained, entry);
108
+ }
109
+ const result = { ...retained, stamp, end, stat };
110
+ indexes.delete(file);
111
+ indexes.set(file, result);
112
+ if (indexes.size > 8) {
113
+ const oldest = indexes.keys().next().value;
114
+ if (oldest !== undefined) {
115
+ indexes.delete(oldest);
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+ catch (error) {
121
+ indexes.delete(file);
122
+ throw error;
71
123
  }
72
124
  finally {
73
125
  fs.closeSync(fd);
74
126
  }
75
127
  }
128
+ /** Physical LF publication owns delivery, including when native memory is stale. */
129
+ function owned(file, owner) {
130
+ const stat = file === undefined ? undefined : fs.statSync(file, { bigint: true, throwIfNoEntry: false });
131
+ if (file === undefined || stat === undefined) {
132
+ return;
133
+ }
134
+ const journal = index(file, stat);
135
+ const header = journal.entries.at(0);
136
+ return header?.type === "session" && header.id === owner ? journal : undefined;
137
+ }
138
+ export function receiptId(file, owner, matches) {
139
+ const found = owned(file, owner)?.entries.find((entry) => entry.type !== "session" && matches(entry));
140
+ return typeof found?.id === "string" ? found.id : undefined;
141
+ }
142
+ /** Live read-only completion IDs by run; later appends extend this view.
143
+ * A matching entry without an ID is unpublished. Invalidations replace the view.
144
+ */
145
+ export function completionReceipts(file, owner) {
146
+ return owned(file, owner)?.completions ?? new Map();
147
+ }