@fitchmultz/pi-subagents 0.52.2 → 0.52.4

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.
@@ -70,13 +70,18 @@ The final version loads that module only when a human opens `/agents`, then
70
70
  revalidates the current session owner. These small samples overlap the baseline
71
71
  ranges and do not establish a generalized speedup or latency guarantee.
72
72
 
73
- Interactive mains scan at most 50 unfinished or newly discovered metadata rows
74
- per second. Immutable launches and finished rows are cached; finished history
75
- does not consume the active refresh budget. Liveness is checked once per active
76
- conversation per scan, not for completed idle actors. The open watch polls at
77
- 250 ms, reading at most 64 KiB plus a 4 KiB identity header and retaining 80 bounded
78
- display lines. Those are display limits, not truncation of the native journal.
79
- Children and print/RPC sessions do not install the watcher.
73
+ Interactive mains process at most 50 unfinished or newly discovered metadata rows
74
+ per scan. Immutable launches and finished rows are cached; finished history does
75
+ not consume that budget. Liveness is checked once per active conversation per
76
+ scan, not for completed idle actors. While work is live or rows are pending, a
77
+ one-second interval schedules scans; relevant filesystem and native events can
78
+ also trigger them.
79
+
80
+ The open viewer indexes its native journal incrementally, reading at most 256 KiB
81
+ per indexing refresh. Ordinary scheduled display updates are spaced by 250 ms.
82
+ The viewer mounts up to 24 history cards and hydrates text in 32,768-code-point
83
+ pages. These are display mechanics, not truncation of the native journal. Children
84
+ and print/RPC sessions do not install the observer.
80
85
 
81
86
  ### TypeBox host-instance startup (#234)
82
87
 
@@ -186,6 +191,98 @@ an end-to-end model, TUI or fleet CPU speedup claim. The native `park-shells` te
186
191
  separately exercises repeated checks, witness death, missing and replaced
187
192
  publications, and continuation on official Pi and the maintained fork.
188
193
 
194
+ ### Resident child usage accounting
195
+
196
+ A child records each run's usage from its native journal. A resident child now
197
+ keeps the IDs and billing fields of the records it validated and parses only
198
+ records appended after its previous LF boundary. A cold actor, a restored parked
199
+ child and the parent's pending-accounting repair still parse from byte 0 once.
200
+
201
+ On macOS arm64 with Node 24.22.1, a read-only replay over one real 152.5 MB child
202
+ journal with 11 saved runs compared release 0.52.2 (`1a088c6`) with this change,
203
+ using the same inherited baseline and terminal for each run. Both returned
204
+ identical contributions (1,129 in total), matching every saved result. The fresh
205
+ reader read 1,068,938,072 bytes across the 11 runs; one resident index read
206
+ 152,966,603 bytes. The replay assumes one actor stayed resident for every run; a
207
+ restart or restore between runs pays one full read. No latency change is claimed.
208
+
209
+ The native `usage` test runs a second continuation in the resident actor on
210
+ official Pi and the maintained fork. It read 773 bytes of a 41,605-byte official
211
+ journal and 773 bytes of a 42,439-byte fork journal. Release 0.52.2 read the whole
212
+ journal from byte 0 in the same test on both hosts.
213
+
214
+ ### Host SDK reuse on first delegation
215
+
216
+ In a production-only install, Pi's loader gives the extension the running host's
217
+ SDK. The extension now keeps that module when its factory runs. The SDK adapter
218
+ returns it for the selected Pi package when the module's `getPackageDir()` resolves
219
+ to that package and `PI_PACKAGE_DIR` is unset. The first delegation, `/agents` and
220
+ parked-history restore therefore no longer import a second, unbundled copy of the
221
+ same SDK from disk. Before, each parent process paid that load once, and later
222
+ calls reused the second copy. The adapter still imports the selected package's
223
+ `dist/index.js` everywhere else. That covers a set `PI_PACKAGE_DIR`, which replaces
224
+ every copy's `getPackageDir()` answer, and a package-local SDK copy, such as a full
225
+ development install or one removed by a later refresh. Detached children also keep
226
+ the import.
227
+
228
+ On macOS arm64 with Node v24.22.1, five alternating before/after pairs per host
229
+ and scenario ran the real bundled print-mode CLI. The package was installed through
230
+ `settings.packages` in a private HOME and agent directory. Each run used offline
231
+ mode, disabled telemetry, `PI_TIMING=1` and a local faux provider, with no network
232
+ or paid model calls. The hosts were the maintained fork at `581b5c18` (bundled CLI
233
+ SHA-256 `f3960fd2e951ac0a47725af534f55b28122553aa56d143ad546dace094fc1d77`) and
234
+ official Pi 1.4.0 (`e79626f2dd6f94aa45d30f3fa63cd84319a6eefcd150b353cfaf274366926774`).
235
+ Before was the packed 0.52.2 tree at `1a088c6`; after was the same tree plus this
236
+ change. The machine was busy, with load averages of 17–39 at the start and end of
237
+ each set, so ranges are wide.
238
+
239
+ | Metric (median; observed range) | Fork before | Fork after | Official before | Official after |
240
+ | ----------------------------------- | ------------------- | ------------------- | ------------------- | ------------------- |
241
+ | First `delegate` call, ms | 1410 (672–1682) | 115 (93–176) | 1874 (796–6190) | 104 (48–2339) |
242
+ | Parent RSS growth in that call, MiB | 66.3 (62.6–67.5) | 0.4 (0.4–0.5) | 84.5 (83.5–85.5) | 0.4 (0.4–0.5) |
243
+ | `/agents` viewer module load, ms | 361 (345–439) | 35 (27–37) | 523 (510–583) | 28 (25–30) |
244
+ | RSS growth in that load, MiB | 65.5 (65.2–66.0) | 1.9 (1.5–2.3) | 82.8 (82.4–84.5) | 4.5 (4.3–5.2) |
245
+ | Extension import, `PI_TIMING`, ms | 370 (217–465) | 395 (88–556) | 94 (75–193) | 100 (85–143) |
246
+ | RSS at `session_start`, MiB | 162.5 (145.6–170.4) | 163.3 (153.4–170.2) | 170.2 (169.7–171.2) | 171.4 (168.9–173.0) |
247
+
248
+ The `delegate` time runs from the parent's tool start to its receipt; RSS growth is
249
+ the parent's `process.memoryUsage().rss` difference across the same window. The
250
+ table combines the startup and `/agents` sets of one campaign (40 runs) with the
251
+ delegate set of a later campaign (20 runs). That delegate harness held the parent's
252
+ turn until Pi's once-per-second completion scan could deliver the child's notice.
253
+ All 60 runs exited 0 with zero errors. Every child finished, its completion resumed
254
+ the parent, and every `/agents` load returned the viewer.
255
+
256
+ Two earlier delegate sets did not hold the parent's turn, so the print run could end
257
+ before that scan. That happened to one official run with the change in each set. The
258
+ first harness counted it as an error without recording which check failed; its child
259
+ finished. In the second, the completion did not resume the parent before exit. Their
260
+ delegate timings remain valid because the window ends at the receipt. The second set
261
+ gave medians of 554 to 46 ms on the fork and 1735 to 98 ms on official Pi, with the
262
+ same memory pattern. Across it and the table's set, the change was faster in all 10
263
+ fork pairs and in 9 of 10 official pairs. In the exception, during a load spike, the
264
+ after run took 2339 ms against 1874 ms before. Official latency is therefore noisy,
265
+ and its magnitude is inconclusive under this load; the memory result held in every
266
+ pair. Child startup time and RSS showed no consistent change, because children keep
267
+ the absolute import. Startup showed no measurable difference: Jiti's debug log lists
268
+ the same native, transpiled and virtual loads, plus one more lookup in its existing
269
+ virtual SDK table.
270
+
271
+ A CPU profile of one fork pair splits the 678 ms before window into Node internals
272
+ (336 ms), native work without a script location (203 ms) and second-copy package code
273
+ (115 ms). Node's module-loading code accounts for 251 ms of those internals, led by
274
+ source-text module compilation (109 ms). File opens, stats, reads and closes account
275
+ for 152 ms of the native work. The 93 ms after window contains no second-copy code.
276
+ Single fallback probes still imported from disk with `PI_PACKAGE_DIR` set (69 MiB
277
+ growth during delegation). With a full development install under the fork host, they
278
+ loaded the exact fork package (55 MiB), not the checkout's official copy.
279
+
280
+ The package smoke's bundled RPC CLI checks both production-only layouts. There, the
281
+ adapter must return the host's `SessionManager`, the class delegation uses to create
282
+ the child session. The baseline adapter returned a separate class on both hosts. This
283
+ is a first-call and memory measurement on one machine. It is not a cold-cache,
284
+ interactive TUI, model-latency or fleet claim.
285
+
189
286
  ## Necessary costs
190
287
 
191
288
  - Native Pi owns context loading and model execution. The extension cannot remove
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.2",
3
+ "version": "0.52.4",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -416,7 +416,9 @@ try {
416
416
  import { writeFileSync } from "node:fs";
417
417
  import { Type } from "typebox";
418
418
  import { Assert } from "typebox/value";
419
+ import * as PiSDK from "@earendil-works/pi-coding-agent";
419
420
  import { Type as AgentType, Assert as AgentAssert } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/agent/typebox.js")).href)};
421
+ import { loadSDK, packageRoot as selectedRoot } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/agent/host.js")).href)};
420
422
  export default function(pi) {
421
423
  // Native development graphs can resolve local peers before Jiti runs.
422
424
  // Production-only installs must instead reuse the bundled host's virtual modules.
@@ -424,8 +426,11 @@ export default function(pi) {
424
426
  assert.equal(AgentType, Type, "extension schemas reuse Pi's TypeBox instance");
425
427
  assert.equal(AgentAssert, Assert, "extension validation reuses Pi's TypeBox instance");
426
428
  }
427
- pi.on("session_start", (_event, ctx) => {
428
- writeFileSync(${JSON.stringify(marker)}, JSON.stringify(pi.getActiveTools()));
429
+ pi.on("session_start", async (_event, ctx) => {
430
+ // Delegation, /agents and parked history load the SDK through this adapter. Jiti wraps
431
+ // each importer's namespace, so compare the session class delegation constructs.
432
+ const sdkReused = (await loadSDK(selectedRoot())).SessionManager === PiSDK.SessionManager;
433
+ writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ tools: pi.getActiveTools(), sdkReused }));
429
434
  ctx.shutdown();
430
435
  });
431
436
  }`,
@@ -468,7 +473,11 @@ export default function(pi) {
468
473
  "",
469
474
  );
470
475
  assert.doesNotMatch(output, /Failed to load extension|ERR_INTERNAL_ASSERTION|Extension error/);
471
- assert.deepEqual(JSON.parse(readFileSync(marker, "utf8")).sort(), expectedTools);
476
+ const cli = JSON.parse(readFileSync(marker, "utf8"));
477
+ assert.deepEqual(cli.tools.sort(), expectedTools);
478
+ if (!existsSync(join(packageRoot, "node_modules/@earendil-works/pi-coding-agent"))) {
479
+ assert.equal(cli.sdkReused, true, "production-only installs reuse Pi's loaded SDK");
480
+ }
472
481
  } finally {
473
482
  await cliProcesses.stop();
474
483
  }
@@ -15,13 +15,11 @@ function guard(pi: ExtensionAPI, directory: string, token: string): void {
15
15
  if (event.toolName === "delegate" || event.toolName === "agent_runs") {
16
16
  return { block: true, reason: "Only a main session may delegate or control children." };
17
17
  }
18
+ if (event.toolName !== "bash" || typeof event.input.command !== "string") {
19
+ return;
20
+ }
18
21
  const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
19
- if (
20
- event.toolName === "bash" &&
21
- typeof event.input.command === "string" &&
22
- owner &&
23
- (owner.source === "builtin" || localCwdOwner(owner))
24
- ) {
22
+ if (owner && (owner.source === "builtin" || localCwdOwner(owner))) {
25
23
  // Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
26
24
  const input = event.input;
27
25
  input.command = shellCommand(event.input.command, directory, token);
package/src/agent/host.ts CHANGED
@@ -85,7 +85,22 @@ export function packageRoot(): string {
85
85
  }
86
86
  return path.dirname(manifest);
87
87
  }
88
+ // Pi's resolution of this extension's SDK import: the running host in production installs,
89
+ // possibly a package-local copy in development checkouts.
90
+ let resolvedSDK: HostSDK | undefined;
91
+ export function rememberSDK(sdk: HostSDK): void {
92
+ resolvedSDK = sdk;
93
+ }
88
94
  export async function loadSDK(root: string): Promise<HostSDK> {
95
+ // Reuse it only as the selected root. PI_PACKAGE_DIR replaces getPackageDir()'s own
96
+ // location, so only an unset override proves that identity.
97
+ if (
98
+ resolvedSDK !== undefined &&
99
+ (process.env.PI_PACKAGE_DIR ?? "") === "" &&
100
+ fs.realpathSync(resolvedSDK.getPackageDir()) === fs.realpathSync(root)
101
+ ) {
102
+ return resolvedSDK;
103
+ }
89
104
  // Selected host SDK, not a second package-local runtime hidden behind Jiti aliases.
90
105
  // oxlint-disable-next-line typescript/no-unsafe-assignment
91
106
  const sdk: HostSDK = await import(pathToFileURL(path.join(root, "dist/index.js")).href);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import * as loadedSDK from "@earendil-works/pi-coding-agent";
4
5
  import type {
5
6
  ExtensionAPI,
6
7
  ExtensionContext,
@@ -16,7 +17,7 @@ import {
16
17
  signalProcess,
17
18
  stopChildren,
18
19
  } from "./process.ts";
19
- import { isChild, errorText } from "./host.ts";
20
+ import { isChild, errorText, rememberSDK } from "./host.ts";
20
21
  import { serveParked } from "./parking.ts";
21
22
  import {
22
23
  resultSchema,
@@ -509,5 +510,7 @@ class AgentRuntime {
509
510
  }
510
511
  }
511
512
  export default function agents(pi: ExtensionAPI): void {
513
+ // Delegation, /agents and parked history then avoid importing a second SDK graph.
514
+ rememberSDK(loadedSDK);
512
515
  new AgentRuntime(pi).register();
513
516
  }
@@ -188,7 +188,7 @@ export class Card implements Component {
188
188
  if (this.arguments !== value.args) {
189
189
  this.tool.updateArgs(projected(value.args) ?? {});
190
190
  this.arguments = value.args;
191
- this.invalidate();
191
+ this.cache = undefined;
192
192
  }
193
193
  }
194
194
  updateTool(value: ViewedTool, options: CardOptions, preparing: boolean): void {
@@ -207,7 +207,7 @@ export class Card implements Component {
207
207
  this.phase = phase;
208
208
  this.value = source.result;
209
209
  this.ended = ended;
210
- this.invalidate();
210
+ this.cache = undefined;
211
211
  }
212
212
  }
213
213
  private preview(value: unknown, isError: boolean, partial: boolean): void {
@@ -245,7 +245,7 @@ export class Card implements Component {
245
245
  this.status?.setText(message.ended ? "Awaiting journal publication" : "In progress");
246
246
  this.blocks = message.blocks.slice();
247
247
  this.ended = message.ended;
248
- this.invalidate();
248
+ this.cache = undefined;
249
249
  }
250
250
  invalidate(): void {
251
251
  this.cache = undefined;
@@ -21,6 +21,7 @@ import type { ObserverRow } from "./observe-chrome.ts";
21
21
  const STATUS_KEY = "agent-observer";
22
22
  type Roots = Map<string, ReturnType<typeof actor>>;
23
23
  type Commit = (live: ReadonlyMap<string, boolean>) => void;
24
+ type WidgetSnapshot = { status: string; rows: readonly ObserverRow[]; more: number; error: string };
24
25
 
25
26
  class Observer {
26
27
  private ctx?: ExtensionContext;
@@ -36,7 +37,7 @@ class Observer {
36
37
  private watcher?: fs.FSWatcher;
37
38
  private widgetVisible = false;
38
39
  private widgetSignature = "";
39
- private widgetRender?: () => void;
40
+ private widgetRender?: (snapshot: WidgetSnapshot) => void;
40
41
  private readonly launches = new Map<string, Launch>();
41
42
  private readonly finished = new Set<string>();
42
43
  private pending: string[] = [];
@@ -185,6 +186,7 @@ class Observer {
185
186
  const scanning = this.update()
186
187
  .catch((error: unknown) => {
187
188
  this.error = plainText(errorText(error));
189
+ this.widget();
188
190
  })
189
191
  .finally(() => {
190
192
  if (this.scanning !== scanning) {
@@ -278,23 +280,24 @@ class Observer {
278
280
  this.widgetSignature = "";
279
281
  return;
280
282
  }
281
- const signature = JSON.stringify([this.status(), rows.slice(0, 4), rows.length]);
283
+ const visible = rows.slice(0, 4);
284
+ const more = rows.length - visible.length;
285
+ const snapshot = { status: this.status(), rows: visible, more, error: this.error };
286
+ const signature = JSON.stringify(snapshot);
282
287
  if (signature === this.widgetSignature) {
283
288
  return;
284
289
  }
285
290
  this.widgetSignature = signature;
286
291
  if (this.widgetVisible) {
287
- this.widgetRender?.();
292
+ this.widgetRender?.(snapshot);
288
293
  return;
289
294
  }
290
295
  this.widgetVisible = true;
291
296
  ctx.ui.setWidget(
292
297
  STATUS_KEY,
293
298
  (tui, theme) => {
294
- this.widgetRender = () => {
295
- tui.requestRender();
296
- };
297
299
  const content = new Container();
300
+ let shown: WidgetSnapshot = snapshot;
298
301
  const open = (id: string) => {
299
302
  if (ctx !== this.ctx) {
300
303
  return;
@@ -303,39 +306,38 @@ class Observer {
303
306
  ctx.ui.notify(errorText(error), "error");
304
307
  });
305
308
  };
309
+ const build = () => {
310
+ content.clear();
311
+ content.addChild(actionText(theme.fg("accent", shown.status), () => open("")));
312
+ for (const row of shown.rows) {
313
+ const glyph = row.live ? "●" : "○";
314
+ const color = row.live ? "accent" : "warning";
315
+ content.addChild(
316
+ actionText(
317
+ `${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`,
318
+ () => open(row.id),
319
+ ),
320
+ );
321
+ }
322
+ if (shown.more > 0) {
323
+ content.addChild(
324
+ actionText(theme.fg("dim", `+${shown.more} more · /agents`), () => open("")),
325
+ );
326
+ }
327
+ if (shown.error.length > 0) {
328
+ content.addChild(new TruncatedText(theme.fg("warning", shown.error), 0, 0));
329
+ }
330
+ };
331
+ build();
332
+ this.widgetRender = (next) => {
333
+ shown = next;
334
+ build();
335
+ tui.requestRender();
336
+ };
306
337
  return {
307
- render: (width) => {
308
- const strip = this.strip();
309
- const visible = strip.slice(0, 4);
310
- content.clear();
311
- content.addChild(actionText(theme.fg("accent", this.status()), () => open("")));
312
- for (const row of visible) {
313
- const glyph = row.live ? "●" : "○";
314
- const color = row.live ? "accent" : "warning";
315
- content.addChild(
316
- actionText(
317
- `${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`,
318
- () => open(row.id),
319
- ),
320
- );
321
- }
322
- if (strip.length > visible.length) {
323
- content.addChild(
324
- actionText(
325
- theme.fg("dim", `+${strip.length - visible.length} more · /agents`),
326
- () => open(""),
327
- ),
328
- );
329
- }
330
- if (this.error.length > 0) {
331
- content.addChild(new TruncatedText(theme.fg("warning", this.error), 0, 0));
332
- }
333
- return content.render(width);
334
- },
338
+ render: (width) => content.render(width),
335
339
  handleMouse: (event) => content.handleMouse(event),
336
- invalidate: () => {
337
- // Render reads current rows, expansion and theme on every frame.
338
- },
340
+ invalidate: build,
339
341
  };
340
342
  },
341
343
  { placement: "aboveEditor" },
@@ -85,8 +85,20 @@ async function cut(
85
85
  return undefined;
86
86
  }
87
87
  const PARK_AFTER_MS = 5 * 60_000;
88
- const digest = (file: string): string =>
89
- createHash("sha256").update(fs.readFileSync(file)).digest("hex");
88
+ /** SHA-256 of every state byte through one reused chunk, like the native state reader. */
89
+ function digest(file: string): string {
90
+ const hash = createHash("sha256");
91
+ const fd = fs.openSync(file, "r");
92
+ try {
93
+ const chunk = Buffer.allocUnsafe(64 * 1024);
94
+ for (let count = fs.readSync(fd, chunk); count > 0; count = fs.readSync(fd, chunk)) {
95
+ hash.update(chunk.subarray(0, count));
96
+ }
97
+ } finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ return hash.digest("hex");
101
+ }
90
102
 
91
103
  /** Parked authority names this session and exactly the saved generation's state bytes. */
92
104
  function exact(
@@ -7,7 +7,7 @@ import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
7
7
  import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
8
8
  import { errorText, loadSDK } from "./host.ts";
9
9
  import { Parking, type Runtime } from "./parking.ts";
10
- import { childUsage } from "./usage.ts";
10
+ import { UsageIndex } from "./usage.ts";
11
11
  import { initializeCwd } from "./cwd.ts";
12
12
  import { childRuntime } from "./child-runtime.ts";
13
13
  import { ActorObservation, type ObservationStats } from "./observation.ts";
@@ -53,6 +53,8 @@ class Actor {
53
53
  private readonly started = new Set<string>();
54
54
  // Actor-local validation metadata only; never retain command prompts or receipt authority.
55
55
  private readonly validated = new Map<string, { identity: string; id: string }>();
56
+ // Every run of this conversation shares one native journal; later runs read only its suffix.
57
+ private readonly accounting = new UsageIndex();
56
58
  private session?: AgentSession;
57
59
  private observation?: ActorObservation;
58
60
  private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
@@ -111,7 +113,7 @@ class Actor {
111
113
  terminal?: string,
112
114
  ): Pick<Result, "contributions" | "accountingError" | "accountingPending"> {
113
115
  try {
114
- return { contributions: childUsage(launch, baseline, terminal) };
116
+ return { contributions: this.accounting.read(launch, baseline, terminal) };
115
117
  } catch (error) {
116
118
  return {
117
119
  accountingError: errorText(error),
@@ -1,9 +1,9 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
- import { journalStamp } from "../shared/native-journal.ts";
3
+ import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
4
4
  import { nativeProjection } from "../shared/journal-projections.ts";
5
5
  import { scanJournal } from "../shared/journal-frames.ts";
6
- import { isRecord } from "../shared/unknown.ts";
6
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
7
7
  import { Assert } from "./typebox.ts";
8
8
  import {
9
9
  contributionsSchema,
@@ -14,7 +14,8 @@ import {
14
14
  } from "./contracts.ts";
15
15
  import { read, runDirectory, write } from "./storage.ts";
16
16
 
17
- function billing(entry: Record<string, unknown>) {
17
+ /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
18
+ function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
18
19
  if (entry.checkpoint === true) {
19
20
  return;
20
21
  }
@@ -25,13 +26,13 @@ function billing(entry: Record<string, unknown>) {
25
26
  ) {
26
27
  return;
27
28
  }
28
- const required = entry.type === "usage" || message.role === "assistant";
29
- if (required && message.usage === undefined) {
30
- throw new Error("Required native usage is unavailable.");
29
+ if (message.usage === undefined) {
30
+ return entry.type === "usage" || message.role === "assistant" ? "missing" : undefined;
31
31
  }
32
- return message.usage === undefined ? undefined : message;
32
+ const { role, provider, model, responseModel, usage } = message;
33
+ return { role, provider, model, responseModel, usage };
33
34
  }
34
- function attribution(message: Record<string, unknown>, launch: Launch) {
35
+ function attribution(message: UnknownRecord, launch: Launch) {
35
36
  if (message.role === "toolResult") {
36
37
  return { provider: launch.provider, model: launch.model };
37
38
  }
@@ -44,63 +45,118 @@ function attribution(message: Record<string, unknown>, launch: Launch) {
44
45
  model: typeof model === "string" ? model : launch.model,
45
46
  };
46
47
  }
47
- export function childUsage(
48
- launch: Launch,
49
- baseline: ReadonlySet<string>,
50
- terminal?: string,
51
- ): Contribution[] {
52
- const values: unknown[] = [];
53
- const observed = { header: false, ended: false };
54
- const reachedTerminal = new Error("Native terminal boundary reached.");
55
- const fd = fs.openSync(launch.sessionFile, "r");
56
- try {
57
- const stamp = journalStamp(fs.fstatSync(fd, { bigint: true }));
48
+ interface Entry {
49
+ readonly id: string;
50
+ readonly billing: UnknownRecord | "missing" | undefined;
51
+ }
52
+ /** Accounting metadata of the validated LF-published journal records before `end`. */
53
+ interface Prefix {
54
+ stat: fs.BigIntStats;
55
+ end: number;
56
+ header?: UnknownRecord;
57
+ readonly entries: Entry[];
58
+ }
59
+ function identify(header: UnknownRecord, launch: Launch): void {
60
+ if (header.type !== "session" || header.id !== launch.sessionId) {
61
+ throw new Error("Foreign native usage journal identity.");
62
+ }
63
+ }
64
+ /** An unchanged or native v3 appended journal keeps its prefix; any other change rebuilds it. */
65
+ function reusable(cached: Prefix | undefined, stat: fs.BigIntStats): Prefix {
66
+ return cached !== undefined &&
67
+ (journalStamp(cached.stat) === journalStamp(stat) ||
68
+ nativeAppend(cached.stat, stat, cached.header))
69
+ ? cached
70
+ : { stat, end: 0, entries: [] };
71
+ }
72
+ /**
73
+ * Child usage of one physical native journal. A retained index parses only records after its last
74
+ * accepted LF boundary. Baselines, required usage and attribution apply to each read.
75
+ */
76
+ export class UsageIndex {
77
+ private prefix?: Prefix;
78
+ read(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): Contribution[] {
79
+ const values: unknown[] = [];
80
+ const reachedTerminal = new Error("Native terminal boundary reached.");
81
+ const charge = (entry: Entry): void => {
82
+ const billed = baseline.has(entry.id) ? undefined : entry.billing;
83
+ if (billed === "missing") {
84
+ throw new Error("Required native usage is unavailable.");
85
+ }
86
+ if (billed !== undefined) {
87
+ values.push({
88
+ id: `${launch.sessionId}:${entry.id}`,
89
+ ...attribution(billed, launch),
90
+ usage: billed.usage,
91
+ });
92
+ }
93
+ if (entry.id === terminal) {
94
+ throw reachedTerminal;
95
+ }
96
+ };
97
+ const cached = this.prefix;
98
+ // Only a completed read is retained.
99
+ this.prefix = undefined;
100
+ const fd = fs.openSync(launch.sessionFile, "r");
58
101
  try {
59
- scanJournal(
60
- fd,
61
- nativeProjection,
62
- ({ value }) => {
63
- if (!observed.header) {
64
- if (value.type !== "session" || value.id !== launch.sessionId) {
65
- throw new Error("Foreign native usage journal identity.");
102
+ const stat = fs.fstatSync(fd, { bigint: true });
103
+ const prefix = reusable(cached, stat);
104
+ let ended = false;
105
+ try {
106
+ if (prefix.header !== undefined) {
107
+ identify(prefix.header, launch);
108
+ }
109
+ for (const entry of prefix.entries) {
110
+ charge(entry);
111
+ }
112
+ scanJournal(
113
+ fd,
114
+ nativeProjection,
115
+ ({ value, end }) => {
116
+ if (prefix.header === undefined) {
117
+ identify(value, launch);
118
+ prefix.header = value;
119
+ prefix.end = end;
120
+ return;
121
+ }
122
+ if (typeof value.id !== "string" || typeof value.type !== "string") {
123
+ throw new Error("Missing native usage entry identity.");
66
124
  }
67
- observed.header = true;
68
- return;
69
- }
70
- if (typeof value.id !== "string" || typeof value.type !== "string") {
71
- throw new Error("Missing native usage entry identity.");
72
- }
73
- const message = baseline.has(value.id) ? undefined : billing(value);
74
- if (message) {
75
- values.push({
76
- id: `${launch.sessionId}:${value.id}`,
77
- ...attribution(message, launch),
78
- usage: message.usage,
79
- });
80
- }
81
- if (value.id === terminal) {
82
- observed.ended = true;
83
- throw reachedTerminal;
84
- }
85
- },
86
- { policy: "live", requireNewline: true },
87
- );
88
- } catch (error) {
89
- if (error !== reachedTerminal) {
90
- throw error;
125
+ const entry = { id: value.id, billing: billing(value) };
126
+ prefix.entries.push(entry);
127
+ prefix.end = end;
128
+ charge(entry);
129
+ },
130
+ { policy: "live", requireNewline: true, start: prefix.end },
131
+ );
132
+ } catch (error) {
133
+ if (error !== reachedTerminal) {
134
+ throw error;
135
+ }
136
+ ended = true;
91
137
  }
138
+ if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== journalStamp(stat)) {
139
+ throw new Error("Native usage journal changed while reading.");
140
+ }
141
+ if (prefix.header === undefined || (terminal !== undefined && !ended)) {
142
+ throw new Error("Native terminal or session identity is not LF-published.");
143
+ }
144
+ Assert(contributionsSchema, values);
145
+ prefix.stat = stat;
146
+ this.prefix = prefix;
147
+ return values;
148
+ } finally {
149
+ fs.closeSync(fd);
92
150
  }
93
- if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
94
- throw new Error("Native usage journal changed while reading.");
95
- }
96
- } finally {
97
- fs.closeSync(fd);
98
- }
99
- if (!observed.header || (terminal !== undefined && !observed.ended)) {
100
- throw new Error("Native terminal or session identity is not LF-published.");
101
151
  }
102
- Assert(contributionsSchema, values);
103
- return values;
152
+ }
153
+ /** A fresh read for callers without an owner lifetime. */
154
+ export function childUsage(
155
+ launch: Launch,
156
+ baseline: ReadonlySet<string>,
157
+ terminal?: string,
158
+ ): Contribution[] {
159
+ return new UsageIndex().read(launch, baseline, terminal);
104
160
  }
105
161
  export function readCanonicalResult(base: string, launch: Launch): Result | undefined {
106
162
  const file = path.join(runDirectory(base, launch.id), "result.json");