@basein/runner 0.3.25 → 0.3.27

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 (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
@@ -34,6 +34,15 @@
34
34
  * birth time, mtime, and the bytes at its head and at the offset) and read
35
35
  * again from the start, which is exactly what the old code did every time.
36
36
  */
37
+ import { type RateFlags } from "../replay/pricing.js";
38
+ /**
39
+ * The session's own transcript for a path a hook handed over: a sub-agent's
40
+ * file (`<session>/subagents/…/agent-….jsonl`) names its session's, and any
41
+ * other path is itself. A session is measured from its own transcript, which
42
+ * adds its sub-agents' files up ({@link subagentUsage}): were a sub-agent's
43
+ * file taken for it, a turn's marks would be read from two different files.
44
+ */
45
+ export declare function sessionTranscriptPath(path: string): string;
37
46
  /** The day the agent was told it is, and the words that told it (issue 1212). */
38
47
  export interface AgentDay {
39
48
  /** "Today's date is Thursday, 2023-11-30 and the current time is 00:00:00." */
@@ -151,17 +160,62 @@ export declare function settledLastAssistantText(transcriptPath: string | undefi
151
160
  confirmMs?: number;
152
161
  pollMs?: number;
153
162
  }): Promise<string>;
163
+ /** How many prompts the transcript holds so far: user lines of the main conversation that carry text. */
164
+ export declare function promptCount(transcriptPath: string | undefined): number;
165
+ /**
166
+ * The assistant's last text of the turn that ended before prompt number
167
+ * `prompt` (the first prompt is 1): "" when that turn wrote none, and
168
+ * undefined while that prompt has not been written — or is long past.
169
+ *
170
+ * WHY. A turn's answer is read a moment after its `Stop`, once its last
171
+ * message is in the file. A prompt queued behind the turn can open the next
172
+ * turn inside that moment, and "the last assistant text" is then the next
173
+ * turn's: a recording was finished with the answer to another prompt. The
174
+ * answer of a turn is what stood when the next prompt was written.
175
+ */
176
+ export declare function answerBeforePrompt(transcriptPath: string | undefined, prompt: number): string | undefined;
154
177
  /**
155
- * Per-model token usage, as it appears on one assistant message.
178
+ * Wait for the turn's last message to be in the transcript: until the
179
+ * conversation's newest line is an assistant message that calls no tool, or
180
+ * `timeoutMs` has passed. True when it is there.
181
+ *
182
+ * WHY. `Stop` fires before Claude Code has written the turn's last message (see
183
+ * {@link settledLastAssistantText}), and that message carries its own `usage`.
184
+ * A turn's cost read at `Stop` is therefore one model call short — and the
185
+ * call it lacks is counted in no turn at all, because the next turn's mark is
186
+ * taken after the line has landed. On a recording that is one call in many. On
187
+ * a replay it is most of the turn: the model's one call there is the one that
188
+ * reads the plan's results and answers. The console showed such turns as 93%
189
+ * and 97% saved where Claude Code's own bill said 42% (the service's
190
+ * improvelst.md, item 1).
191
+ *
192
+ * Returns at once when the message is already there, when the file cannot be
193
+ * read (there is no cost to wait for), and when its lines do not say how a
194
+ * message stopped and the newest one calls no tool (an older host, a fixture).
156
195
  */
157
- export interface ModelUsage {
196
+ export declare function settledTurnEnd(transcriptPath: string | undefined, opts?: {
197
+ timeoutMs?: number;
198
+ pollMs?: number;
199
+ }): Promise<boolean>;
200
+ /**
201
+ * Token usage of one model, summed over the requests that are priced alike:
202
+ * the flags of {@link RateFlags} say which (a long prompt, fast mode, US-only
203
+ * inference), and are absent for the ordinary ones.
204
+ */
205
+ export interface ModelUsage extends RateFlags {
158
206
  model: string;
159
207
  inputTokens: number;
160
208
  outputTokens: number;
161
209
  cacheReadTokens: number;
210
+ /** Every cache write, the 1-hour ones among them. */
162
211
  cacheCreationTokens: number;
212
+ /** Of those, the tokens written to the 1-hour cache. Absent for none. */
213
+ cacheCreation1hTokens?: number;
163
214
  }
164
- /** Cumulative per-model totals, captured when a run starts. */
215
+ /**
216
+ * Cumulative totals, captured when a run starts: one entry per model and way
217
+ * of pricing, under the model's name with its rate key after it.
218
+ */
165
219
  export type UsageMark = Map<string, ModelUsage>;
166
220
  /**
167
221
  * Snapshot cumulative usage at the moment a run starts.
@@ -180,6 +234,13 @@ export declare function markTranscriptUsage(transcriptPath: string | undefined):
180
234
  * rotated transcript degrades to "no usage" rather than to a negative cost.
181
235
  */
182
236
  export declare function usageSince(transcriptPath: string | undefined, mark: UsageMark | undefined): ModelUsage[];
237
+ /**
238
+ * The usage between two marks of one transcript, `from` the earlier: what
239
+ * {@link usageSince} gives, with `to` in the place of the transcript as it is
240
+ * now. A turn whose books are closed after the next turn has started ends at
241
+ * that turn's mark, not at whatever the file holds by then.
242
+ */
243
+ export declare function usageBetween(mark: UsageMark | undefined, now: UsageMark): ModelUsage[];
183
244
  /** The first user prompt in the transcript — the run's `input` when a hook omits it. */
184
245
  export declare function firstUserPrompt(transcriptPath: string | undefined): string;
185
246
  //# sourceMappingURL=transcript.d.ts.map
@@ -34,9 +34,11 @@
34
34
  * birth time, mtime, and the bytes at its head and at the offset) and read
35
35
  * again from the start, which is exactly what the old code did every time.
36
36
  */
37
- import { closeSync, openSync, readSync, statSync } from "node:fs";
37
+ import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
38
+ import { join } from "node:path";
38
39
  import { redactText } from "../record/redact.js";
39
40
  import { truncateString } from "../record/truncate.js";
41
+ import { rateKey, requestRates } from "../replay/pricing.js";
40
42
  function blocksOf(line) {
41
43
  const content = line.message?.content ?? line.content;
42
44
  if (Array.isArray(content))
@@ -116,8 +118,24 @@ function keepWithin(into, held, piece) {
116
118
  into.push(kept);
117
119
  return held + kept.length;
118
120
  }
121
+ /** Turn answers kept per transcript: a turn's books close within seconds of its end. */
122
+ const MAX_ANSWERS_KEPT = 8;
119
123
  const indexes = new Map();
120
- function freshIndex(st) {
124
+ /**
125
+ * The files of a transcript's sub-agents, read for their tokens only
126
+ * ({@link subagentUsage}): transcript path → the main index they were read
127
+ * beside, one usage-only index per file, and `gone`: what the files whose
128
+ * index was let go of had used (one past {@link MAX_SUBAGENT_FILES}, one read
129
+ * again from its start after a rewrite). Dropped with the main index.
130
+ */
131
+ const subagentIndexes = new Map();
132
+ /** Sub-agent files read per transcript, at most. */
133
+ const MAX_SUBAGENT_FILES = 256;
134
+ /** How deep under `subagents/` their files are looked for (`workflows/wf_…/agent-….jsonl`). */
135
+ const SUBAGENT_DEPTH = 3;
136
+ /** What a line must hold to be worth parsing for its tokens. */
137
+ const USAGE_KEY = Buffer.from('"usage"');
138
+ function freshIndex(st, usageOnly) {
121
139
  return {
122
140
  ino: st.ino,
123
141
  birthtimeMs: st.birthtimeMs,
@@ -127,8 +145,11 @@ function freshIndex(st) {
127
145
  anchor: Buffer.alloc(0),
128
146
  skipping: false,
129
147
  lineNo: 0,
148
+ usageOnly: usageOnly !== undefined,
130
149
  usage: new Map(),
131
- usageSeen: new Set(),
150
+ // A sub-agent's file shares its transcript's set: a message that a host
151
+ // writes into both files is counted once, whichever is read first.
152
+ usageSeen: usageOnly?.seen ?? new Set(),
132
153
  lastAssistantText: "",
133
154
  firstUserPrompt: "",
134
155
  sawPrompt: false,
@@ -137,6 +158,10 @@ function freshIndex(st) {
137
158
  toolUses: new Map(),
138
159
  systemDayLine: "",
139
160
  hostDay: "",
161
+ tail: "",
162
+ prompts: 0,
163
+ textSincePrompt: false,
164
+ answersBefore: new Map(),
140
165
  };
141
166
  }
142
167
  /** Whether `idx` can be continued for a file that now looks like `st`. */
@@ -168,41 +193,65 @@ function readAt(fd, position, length) {
168
193
  * an empty transcript, as the whole-file reader always did.
169
194
  */
170
195
  function indexFor(path) {
196
+ return indexIn(indexes, path, MAX_INDEXES, undefined, (dropped) => subagentIndexes.delete(dropped));
197
+ }
198
+ /**
199
+ * {@link indexFor} over a store of its own: `usageOnly` for the files of a
200
+ * transcript's sub-agents, which are read for their tokens and nothing else.
201
+ * `onDrop` hears of every path the store lets go of.
202
+ *
203
+ * A sub-agent's file that cannot be looked at for a moment keeps its index:
204
+ * its tokens were used. Let go of, an index was made again later beside the
205
+ * transcript's set of counted messages, which held every one of the file's —
206
+ * and the file counted $0 from then on. Every index such a store does let go
207
+ * of, or replaces, goes to `usageOnly.retire` with what it had counted.
208
+ */
209
+ function indexIn(store, path, cap, usageOnly, onDrop) {
171
210
  let st;
172
211
  try {
173
212
  st = statSync(path);
174
213
  }
175
214
  catch {
176
- indexes.delete(path);
215
+ if (usageOnly)
216
+ return store.get(path);
217
+ store.delete(path);
218
+ onDrop?.(path);
177
219
  return undefined;
178
220
  }
179
221
  if (!st.isFile())
180
- return undefined;
181
- let idx = indexes.get(path);
222
+ return usageOnly ? store.get(path) : undefined;
223
+ let idx = store.get(path);
182
224
  // Re-inserted on every use, so the Map's order is least-recently-used first.
183
225
  if (idx)
184
- indexes.delete(path);
185
- if (!idx || !continues(idx, st))
186
- idx = freshIndex(st);
187
- indexes.set(path, idx);
188
- while (indexes.size > MAX_INDEXES) {
189
- const oldest = indexes.keys().next().value;
226
+ store.delete(path);
227
+ if (!idx || !continues(idx, st)) {
228
+ if (idx)
229
+ usageOnly?.retire?.(idx);
230
+ idx = freshIndex(st, usageOnly);
231
+ }
232
+ store.set(path, idx);
233
+ while (store.size > cap) {
234
+ const oldest = store.keys().next().value;
190
235
  if (oldest === undefined)
191
236
  break;
192
- indexes.delete(oldest);
237
+ const old = store.get(oldest);
238
+ store.delete(oldest);
239
+ if (old)
240
+ usageOnly?.retire?.(old);
241
+ onDrop?.(oldest);
193
242
  }
194
243
  if (st.size === idx.offset)
195
244
  return idx;
196
245
  try {
197
- return catchUp(path, idx, st);
246
+ return catchUp(store, path, idx, st, usageOnly);
198
247
  }
199
248
  catch {
200
249
  // Unreadable mid-way (locked, deleted): what was folded in so far stands.
201
- return indexes.get(path);
250
+ return store.get(path);
202
251
  }
203
252
  }
204
253
  /** Fold in what was appended; the index to use from now on (a fresh one after a rewrite). */
205
- function catchUp(path, stale, st) {
254
+ function catchUp(store, path, stale, st, usageOnly) {
206
255
  const fd = openSync(path, "r");
207
256
  try {
208
257
  let idx = stale;
@@ -213,8 +262,9 @@ function catchUp(path, stale, st) {
213
262
  const anchorAt = idx.offset - idx.anchor.length;
214
263
  const anchor = readAt(fd, anchorAt, idx.anchor.length);
215
264
  if (!head.equals(idx.head) || !anchor.equals(idx.anchor)) {
216
- idx = freshIndex(st);
217
- indexes.set(path, idx);
265
+ usageOnly?.retire?.(idx);
266
+ idx = freshIndex(st, usageOnly);
267
+ store.set(path, idx);
218
268
  }
219
269
  }
220
270
  fold(idx, fd, st.size);
@@ -287,6 +337,104 @@ function fold(idx, fd, size) {
287
337
  }
288
338
  }
289
339
  }
340
+ /**
341
+ * The files of `transcriptPath`'s sub-agents: every `agent-*.jsonl` under
342
+ * `<the transcript without .jsonl>/subagents/`, sub-folders included
343
+ * (a workflow's agents are in `workflows/wf_…/`). Links are not followed.
344
+ */
345
+ function subagentFiles(transcriptPath) {
346
+ if (!transcriptPath.endsWith(".jsonl"))
347
+ return [];
348
+ const out = [];
349
+ const walk = (dir, depth) => {
350
+ let entries;
351
+ try {
352
+ entries = readdirSync(dir, { withFileTypes: true });
353
+ }
354
+ catch {
355
+ return; // no such folder: the session started no sub-agent
356
+ }
357
+ for (const entry of entries) {
358
+ if (out.length >= MAX_SUBAGENT_FILES)
359
+ return;
360
+ if (entry.isFile() && entry.name.startsWith("agent-") && entry.name.endsWith(".jsonl")) {
361
+ out.push(join(dir, entry.name));
362
+ }
363
+ else if (entry.isDirectory() && depth < SUBAGENT_DEPTH) {
364
+ walk(join(dir, entry.name), depth + 1);
365
+ }
366
+ }
367
+ };
368
+ walk(join(transcriptPath.slice(0, -".jsonl".length), "subagents"), 0);
369
+ return out;
370
+ }
371
+ /**
372
+ * The token usage in the files of a transcript's sub-agents, one total per
373
+ * file, each caught up with what was appended since the last call.
374
+ *
375
+ * WHY. Claude Code writes a sub-agent's conversation into a file of its own,
376
+ * and its tokens are on the session's bill. The transcript alone held 30,000
377
+ * tokens of a report whose two Explore agents used 400,000: the run was
378
+ * recorded at a tenth of its cost, and every saving against it measured far
379
+ * too small (the service's imp.md B5).
380
+ *
381
+ * `main` is the transcript's own index: a file read beside another one — the
382
+ * transcript was rewritten — is read again from its start.
383
+ *
384
+ * NEVER LESS THAN BEFORE. Every file read so far counts, listed this time or
385
+ * not — a folder that could not be listed for a moment, a file that could not
386
+ * be looked at, one past {@link MAX_SUBAGENT_FILES} — and so does what the
387
+ * files whose index was let go of had used (`gone`). A file read again is
388
+ * read beside the transcript's set of counted messages, so nothing counts
389
+ * twice. A total that fell made the turn across the fall cost $0 for that
390
+ * model, the main transcript's own tokens with it, and the file $0 for good.
391
+ */
392
+ function subagentUsage(transcriptPath, main) {
393
+ const files = subagentFiles(transcriptPath);
394
+ let known = subagentIndexes.get(transcriptPath);
395
+ if (known && known.beside !== main) {
396
+ subagentIndexes.delete(transcriptPath);
397
+ known = undefined;
398
+ }
399
+ if (files.length === 0 && !known)
400
+ return [];
401
+ if (!known) {
402
+ known = { beside: main, files: new Map(), gone: new Map() };
403
+ subagentIndexes.set(transcriptPath, known);
404
+ }
405
+ const gone = known.gone;
406
+ const usageOnly = { seen: main.usageSeen, retire: (old) => addUsage(gone, old.usage) };
407
+ for (const file of files)
408
+ indexIn(known.files, file, MAX_SUBAGENT_FILES, usageOnly);
409
+ return [gone, ...[...known.files.values()].map((idx) => idx.usage)];
410
+ }
411
+ /** `from`'s tokens added into `into`, key by key. */
412
+ function addUsage(into, from) {
413
+ for (const [key, u] of from) {
414
+ const sum = into.get(key);
415
+ if (!sum) {
416
+ into.set(key, { ...u });
417
+ continue;
418
+ }
419
+ sum.inputTokens += u.inputTokens;
420
+ sum.outputTokens += u.outputTokens;
421
+ sum.cacheReadTokens += u.cacheReadTokens;
422
+ sum.cacheCreationTokens += u.cacheCreationTokens;
423
+ if (u.cacheCreation1hTokens)
424
+ sum.cacheCreation1hTokens = (sum.cacheCreation1hTokens ?? 0) + u.cacheCreation1hTokens;
425
+ }
426
+ }
427
+ /**
428
+ * The session's own transcript for a path a hook handed over: a sub-agent's
429
+ * file (`<session>/subagents/…/agent-….jsonl`) names its session's, and any
430
+ * other path is itself. A session is measured from its own transcript, which
431
+ * adds its sub-agents' files up ({@link subagentUsage}): were a sub-agent's
432
+ * file taken for it, a turn's marks would be read from two different files.
433
+ */
434
+ export function sessionTranscriptPath(path) {
435
+ const m = /^(.*)[\\/]subagents[\\/](?:.*[\\/])?agent-[^\\/]+\.jsonl$/.exec(path);
436
+ return m ? `${m[1]}.jsonl` : path;
437
+ }
290
438
  function parse(bytes) {
291
439
  const text = bytes.toString("utf8").trim();
292
440
  if (!text)
@@ -300,6 +448,10 @@ function parse(bytes) {
300
448
  }
301
449
  }
302
450
  function ingest(idx, bytes) {
451
+ // A sub-agent's file is read for its tokens: most of its lines are tool
452
+ // results, some of them megabytes, and none of those is parsed.
453
+ if (idx.usageOnly && !bytes.includes(USAGE_KEY))
454
+ return;
303
455
  const line = parse(bytes);
304
456
  // A terminated line that does not parse is corrupt, not half-written: skip it.
305
457
  if (line)
@@ -316,9 +468,46 @@ function dayLineIn(text) {
316
468
  // The whole line, never cut at a full stop: "Nov. 30, 2023" has one inside.
317
469
  return redactText(DAY_LINE.exec(whole)?.[0] ?? "").slice(0, 200);
318
470
  }
471
+ /** A message's `usage` into the index's totals, deduplicated by message id (see `totalUsage`'s note on why). */
472
+ function foldUsage(idx, message) {
473
+ const usage = message?.usage;
474
+ if (!usage || typeof usage !== "object")
475
+ return;
476
+ const id = message?.id;
477
+ if (id && idx.usageSeen.has(id))
478
+ return;
479
+ if (id)
480
+ idx.usageSeen.add(id);
481
+ const model = message?.model ?? "unknown";
482
+ const input = count(usage.input_tokens);
483
+ const read = count(usage.cache_read_input_tokens);
484
+ const written = count(usage.cache_creation_input_tokens);
485
+ // A message is one request, and some of what sets its price is the
486
+ // request's own: its prompt length, its speed, where it ran. Requests
487
+ // priced differently are summed apart (replay/pricing.ts).
488
+ const rates = requestRates(model, {
489
+ promptTokens: input + read + written,
490
+ speed: usage.speed,
491
+ inferenceGeo: usage.inference_geo,
492
+ });
493
+ const key = model + rateKey(rates);
494
+ const acc = idx.usage.get(key) ?? { ...zeroUsage(model), ...rates };
495
+ acc.inputTokens += input;
496
+ acc.outputTokens += count(usage.output_tokens);
497
+ acc.cacheReadTokens += read;
498
+ acc.cacheCreationTokens += written;
499
+ const written1h = Math.min(written, count(usage.cache_creation?.ephemeral_1h_input_tokens));
500
+ if (written1h > 0)
501
+ acc.cacheCreation1hTokens = (acc.cacheCreation1hTokens ?? 0) + written1h;
502
+ idx.usage.set(key, acc);
503
+ }
319
504
  /** What one line contributes to every answer this module gives. */
320
505
  function foldLine(idx, line) {
321
506
  idx.lineNo += 1;
507
+ if (idx.usageOnly) {
508
+ foldUsage(idx, line.message);
509
+ return;
510
+ }
322
511
  // The day the agent was told it is (issue 1212): a system prompt's
323
512
  // "Today's date is …", and the host's own date reminder beside it.
324
513
  if (line.type === "attachment" && line.attachment?.type === "prompt_snapshot") {
@@ -337,31 +526,43 @@ function foldLine(idx, line) {
337
526
  const message = line.message;
338
527
  const role = message?.role ?? line.type;
339
528
  const blocks = blocksOf(line);
340
- // Usage, deduplicated by message id (see `totalUsage`'s note on why).
341
- const usage = message?.usage;
342
- if (usage && typeof usage === "object") {
343
- const id = message?.id;
344
- if (!id || !idx.usageSeen.has(id)) {
345
- if (id)
346
- idx.usageSeen.add(id);
347
- const model = message?.model ?? "unknown";
348
- const acc = idx.usage.get(model) ?? zeroUsage(model);
349
- acc.inputTokens += usage.input_tokens ?? 0;
350
- acc.outputTokens += usage.output_tokens ?? 0;
351
- acc.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
352
- acc.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
353
- idx.usage.set(model, acc);
529
+ foldUsage(idx, message);
530
+ // Where the conversation stands ({@link settledTurnEnd}). A sub-agent's
531
+ // lines are another conversation: its last answer does not end this turn.
532
+ if (line.isSidechain !== true) {
533
+ if (role === "assistant") {
534
+ const stop = message?.stop_reason;
535
+ const calls = stop === "tool_use" || (stop == null && blocks.some((b) => b.type === "tool_use"));
536
+ idx.tail = calls ? "tool_call" : "answer";
537
+ }
538
+ else if (role === "user") {
539
+ idx.tail = "user";
354
540
  }
355
541
  }
356
542
  if (role === "assistant") {
357
543
  const text = textOf(blocks);
358
- if (text)
544
+ if (text) {
359
545
  idx.lastAssistantText = text;
546
+ if (line.isSidechain !== true)
547
+ idx.textSincePrompt = true;
548
+ }
360
549
  }
361
550
  else if (role === "user") {
362
551
  // A user line carrying text is a prompt: "this prompt's results" start
363
552
  // again from it, its own tool results included.
364
553
  const text = textOf(blocks);
554
+ if (text && line.isSidechain !== true) {
555
+ // What the turn before this prompt answered, kept for a turn whose
556
+ // books close after the next one has begun ({@link answerBeforePrompt}).
557
+ idx.prompts += 1;
558
+ idx.answersBefore.set(idx.prompts, idx.textSincePrompt ? idx.lastAssistantText : "");
559
+ idx.textSincePrompt = false;
560
+ if (idx.answersBefore.size > MAX_ANSWERS_KEPT) {
561
+ const oldest = idx.answersBefore.keys().next().value;
562
+ if (oldest !== undefined)
563
+ idx.answersBefore.delete(oldest);
564
+ }
565
+ }
365
566
  if (text) {
366
567
  if (!idx.sawPrompt)
367
568
  idx.firstUserPrompt = text;
@@ -512,6 +713,7 @@ export async function settledTextAfterToolUse(transcriptPath, toolUseId, opts =
512
713
  /** Forget every cached index (tests; a caller that knows files were replaced). */
513
714
  export function forgetTranscripts() {
514
715
  indexes.clear();
716
+ subagentIndexes.clear();
515
717
  }
516
718
  // ── accessors ───────────────────────────────────────────────────────────────
517
719
  /**
@@ -645,6 +847,66 @@ export async function settledLastAssistantText(transcriptPath, opts = {}) {
645
847
  }
646
848
  return latest || before;
647
849
  }
850
+ /** How many prompts the transcript holds so far: user lines of the main conversation that carry text. */
851
+ export function promptCount(transcriptPath) {
852
+ if (!transcriptPath)
853
+ return 0;
854
+ return indexFor(transcriptPath)?.prompts ?? 0;
855
+ }
856
+ /**
857
+ * The assistant's last text of the turn that ended before prompt number
858
+ * `prompt` (the first prompt is 1): "" when that turn wrote none, and
859
+ * undefined while that prompt has not been written — or is long past.
860
+ *
861
+ * WHY. A turn's answer is read a moment after its `Stop`, once its last
862
+ * message is in the file. A prompt queued behind the turn can open the next
863
+ * turn inside that moment, and "the last assistant text" is then the next
864
+ * turn's: a recording was finished with the answer to another prompt. The
865
+ * answer of a turn is what stood when the next prompt was written.
866
+ */
867
+ export function answerBeforePrompt(transcriptPath, prompt) {
868
+ if (!transcriptPath)
869
+ return undefined;
870
+ return indexFor(transcriptPath)?.answersBefore.get(prompt);
871
+ }
872
+ /**
873
+ * Wait for the turn's last message to be in the transcript: until the
874
+ * conversation's newest line is an assistant message that calls no tool, or
875
+ * `timeoutMs` has passed. True when it is there.
876
+ *
877
+ * WHY. `Stop` fires before Claude Code has written the turn's last message (see
878
+ * {@link settledLastAssistantText}), and that message carries its own `usage`.
879
+ * A turn's cost read at `Stop` is therefore one model call short — and the
880
+ * call it lacks is counted in no turn at all, because the next turn's mark is
881
+ * taken after the line has landed. On a recording that is one call in many. On
882
+ * a replay it is most of the turn: the model's one call there is the one that
883
+ * reads the plan's results and answers. The console showed such turns as 93%
884
+ * and 97% saved where Claude Code's own bill said 42% (the service's
885
+ * improvelst.md, item 1).
886
+ *
887
+ * Returns at once when the message is already there, when the file cannot be
888
+ * read (there is no cost to wait for), and when its lines do not say how a
889
+ * message stopped and the newest one calls no tool (an older host, a fixture).
890
+ */
891
+ export async function settledTurnEnd(transcriptPath, opts = {}) {
892
+ if (!transcriptPath)
893
+ return false;
894
+ const pollMs = Math.max(10, opts.pollMs ?? 100);
895
+ const deadline = Date.now() + Math.max(0, opts.timeoutMs ?? 2_000);
896
+ for (;;) {
897
+ const tail = indexFor(transcriptPath)?.tail;
898
+ if (tail === undefined)
899
+ return false;
900
+ if (tail === "answer")
901
+ return true;
902
+ const left = deadline - Date.now();
903
+ if (left <= 0)
904
+ return false;
905
+ // Not unref'd: the turn's report waits on this, and a recorder asked to
906
+ // stop meanwhile stays for the moment it takes rather than lose it.
907
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, left)));
908
+ }
909
+ }
648
910
  const zeroUsage = (model) => ({
649
911
  model,
650
912
  inputTokens: 0,
@@ -652,6 +914,8 @@ const zeroUsage = (model) => ({
652
914
  cacheReadTokens: 0,
653
915
  cacheCreationTokens: 0,
654
916
  });
917
+ /** A token count as a transcript line gives it: a missing or broken one is 0. */
918
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
655
919
  /**
656
920
  * Sum every assistant message's `usage` in the whole file, keyed by model.
657
921
  *
@@ -682,6 +946,9 @@ function totalUsage(transcriptPath) {
682
946
  return totals;
683
947
  for (const [model, u] of idx.usage)
684
948
  totals.set(model, { ...u });
949
+ // And what the session's sub-agents used, each in a file of its own.
950
+ for (const usage of subagentUsage(transcriptPath, idx))
951
+ addUsage(totals, usage);
685
952
  return totals;
686
953
  }
687
954
  /**
@@ -718,17 +985,31 @@ export function markTranscriptUsage(transcriptPath) {
718
985
  export function usageSince(transcriptPath, mark) {
719
986
  if (!transcriptPath)
720
987
  return [];
721
- const now = totalUsage(transcriptPath);
988
+ return usageBetween(mark, totalUsage(transcriptPath));
989
+ }
990
+ /**
991
+ * The usage between two marks of one transcript, `from` the earlier: what
992
+ * {@link usageSince} gives, with `to` in the place of the transcript as it is
993
+ * now. A turn whose books are closed after the next turn has started ends at
994
+ * that turn's mark, not at whatever the file holds by then.
995
+ */
996
+ export function usageBetween(mark, now) {
722
997
  const out = [];
723
- for (const [model, current] of now) {
724
- const before = mark?.get(model);
998
+ for (const [key, current] of now) {
999
+ const before = mark?.get(key);
725
1000
  const delta = {
726
- model,
1001
+ // The entry's own model and flags: its key carries the rate key too.
1002
+ ...current,
727
1003
  inputTokens: Math.max(0, current.inputTokens - (before?.inputTokens ?? 0)),
728
1004
  outputTokens: Math.max(0, current.outputTokens - (before?.outputTokens ?? 0)),
729
1005
  cacheReadTokens: Math.max(0, current.cacheReadTokens - (before?.cacheReadTokens ?? 0)),
730
1006
  cacheCreationTokens: Math.max(0, current.cacheCreationTokens - (before?.cacheCreationTokens ?? 0)),
731
1007
  };
1008
+ const written1h = Math.max(0, (current.cacheCreation1hTokens ?? 0) - (before?.cacheCreation1hTokens ?? 0));
1009
+ if (written1h > 0)
1010
+ delta.cacheCreation1hTokens = Math.min(written1h, delta.cacheCreationTokens);
1011
+ else
1012
+ delete delta.cacheCreation1hTokens;
732
1013
  if (delta.inputTokens ||
733
1014
  delta.outputTokens ||
734
1015
  delta.cacheReadTokens ||
@@ -59,15 +59,30 @@ export interface FrameHandlers {
59
59
  export interface FrameOptions {
60
60
  maxLineBytes?: number;
61
61
  }
62
+ /** Note that `msg` was parsed from `text`, so it can be written again as that text. */
63
+ export declare function rememberText(msg: object, text: string): void;
64
+ /** Forget the text `msg` was parsed from: it is serialised again when written, with any change made to it. */
65
+ export declare function forgetText(msg: unknown): void;
66
+ /**
67
+ * The text `msg` was parsed from, when it can be sent again as one line;
68
+ * undefined for a message nobody read off the wire, and for one whose text
69
+ * holds a raw line break (a pretty-printed HTTP body: inside a JSON string a
70
+ * line break is always escaped, so a raw one is layout, and layout cannot
71
+ * cross a line-delimited stream). One trailing CR is a CRLF sender's line
72
+ * ending, not part of the message.
73
+ */
74
+ export declare function receivedText(msg: unknown): string | undefined;
62
75
  /**
63
76
  * Read newline-delimited JSON messages off `input`.
64
77
  * Returns a detach function that stops the reader (it does not close `input`).
65
78
  */
66
79
  export declare function readFrames(input: Readable, handlers: FrameHandlers, opts?: FrameOptions): () => void;
67
80
  /**
68
- * Write one message as a single line. Serialization failure is swallowed after
69
- * being reported: a proxy that throws while answering the host takes the host's
70
- * session down with it, which rule 4 of §1 forbids.
81
+ * Write one message as a single line: the line it was read from when `msg` is
82
+ * the very object a reader produced ({@link receivedText}), its serialisation
83
+ * otherwise. Serialization failure is swallowed after being reported: a proxy
84
+ * that throws while answering the host takes the host's session down with it,
85
+ * which rule 4 of §1 forbids.
71
86
  */
72
87
  export declare function writeFrame(output: Writable, msg: unknown, onError?: (err: Error) => void): void;
73
88
  /**