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