@basein/runner 0.3.25 → 0.3.26

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 (64) 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.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +15 -11
  45. package/dist/replay/bundle.js +167 -33
  46. package/dist/replay/controller.d.ts +62 -0
  47. package/dist/replay/controller.js +218 -32
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/pricing.d.ts +61 -11
  52. package/dist/replay/pricing.js +135 -42
  53. package/dist/replay/safety.js +43 -5
  54. package/dist/upstream/http-client.d.ts +6 -3
  55. package/dist/upstream/http-client.js +54 -13
  56. package/dist/upstream/stdio-client.d.ts +7 -0
  57. package/dist/upstream/stdio-client.js +1 -1
  58. package/dist/util/atomic-write.d.ts +9 -1
  59. package/dist/util/atomic-write.js +9 -6
  60. package/dist/util/service-health.d.ts +7 -0
  61. package/dist/util/service-health.js +10 -3
  62. package/package.json +1 -1
  63. package/production.md +2 -2
  64. package/scripts/install.ps1 +14 -2
@@ -31,16 +31,16 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
- import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
34
+ import { agentDayOf, answerBeforePrompt, contextForToolUse, intentForToolUse, markTranscriptUsage, promptCount, recentToolResults, sessionTranscriptPath, settledLastAssistantText, settledTextAfterToolUse, settledTurnEnd, thinkingForToolUse, usageBetween, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
36
  import { asSent, userText } from "../record/prompt-text.js";
37
37
  import { redact, redactText } from "../record/redact.js";
38
38
  import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
39
39
  import { StepQueue } from "../record/queue.js";
40
40
  import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioDisputer, isScenarioReporter, isServiceProber, isToolListReporter, } from "../record/recorder.js";
41
- import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, } from "../replay/controller.js";
41
+ import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, sentACall, } from "../replay/controller.js";
42
42
  import { localDay } from "../replay/derive.js";
43
- import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
43
+ import { calculateCostUsd, hasModelPricing, requestRates } from "../replay/pricing.js";
44
44
  import { logDetail, logLine, logOnce, errText } from "../util/log.js";
45
45
  import { journal } from "../util/journal.js";
46
46
  import { packageVersion } from "../util/version.js";
@@ -106,6 +106,19 @@ const INTENT_ARM_PROBE_MS = 500;
106
106
  const INTENT_ARM_DERIVE_MS = 1_500;
107
107
  /** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
108
108
  const FRAGMENT_CREATE_MS = 300;
109
+ /**
110
+ * How long after its caller cancelled it a plan is not armed again (when some
111
+ * of its steps had run): long enough for the person to look at what the cancel
112
+ * left and ask again, short enough that a plan asked for tomorrow runs.
113
+ */
114
+ const AFTER_CANCEL_MS = 10 * 60_000;
115
+ /**
116
+ * How long a turn's books wait, after `Stop`, for Claude Code to write the
117
+ * turn's last message — whose tokens are the turn's too (`sealRun`). The wait
118
+ * the answer's text always had. `BIR_COST_SETTLE_MS` changes it; 0 is no wait.
119
+ */
120
+ const COST_SETTLE_MS = 2_000;
121
+ const COST_SETTLE_MAX_MS = 10_000;
109
122
  /**
110
123
  * How long the model's first call after a hand-over waits for the service to
111
124
  * say whether the work recorded after an earlier hand-over at that step has a
@@ -619,6 +632,14 @@ function hostSinksOf(payload) {
619
632
  * Claude's own names, the ones a transcript line carries, and they are mapped
620
633
  * here as the transcript's are folded: a missing count is 0. Undefined when
621
634
  * the payload carries no usable list — which is "no usage", never zero.
635
+ *
636
+ * A host sends one total per model, never one per request, and a model with a
637
+ * long-prompt price (Haiku 5.5: over 100,000 tokens, five times the rates) is
638
+ * priced by each request's own length. Under the threshold no request in the
639
+ * total was over it, and the short rates are right. Over it, which ones were
640
+ * cannot be told: the total is `rateUnknown`, and the run unmeasured — priced
641
+ * at the short rates, 150,000 tokens of one request came to a fifth of their
642
+ * cost, booked as measured.
622
643
  */
623
644
  function hostUsageOf(payload) {
624
645
  if (!Array.isArray(payload.usage))
@@ -629,12 +650,25 @@ function hostUsageOf(payload) {
629
650
  if (!item || typeof item !== "object")
630
651
  continue;
631
652
  const u = item;
653
+ const written = count(u.cache_creation_input_tokens);
654
+ // Claude's own split of the cache writes, when the host passes it on: a
655
+ // 1-hour write bills at 2x input, a 5-minute one at 1.25x.
656
+ const split = u.cache_creation;
657
+ const written1h = Math.min(written, count(split?.ephemeral_1h_input_tokens));
658
+ const model = typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown";
659
+ const inputTokens = count(u.input_tokens);
660
+ const cacheReadTokens = count(u.cache_read_input_tokens);
661
+ // The whole total as if it were one request's prompt: over the model's
662
+ // long-prompt threshold, one of its requests may have been.
663
+ const longMaybe = requestRates(model, { promptTokens: inputTokens + cacheReadTokens + written }).longPrompt === true;
632
664
  out.push({
633
- model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
634
- inputTokens: count(u.input_tokens),
665
+ model,
666
+ inputTokens,
635
667
  outputTokens: count(u.output_tokens),
636
- cacheReadTokens: count(u.cache_read_input_tokens),
637
- cacheCreationTokens: count(u.cache_creation_input_tokens),
668
+ cacheReadTokens,
669
+ cacheCreationTokens: written,
670
+ ...(written1h > 0 ? { cacheCreation1hTokens: written1h } : {}),
671
+ ...(longMaybe ? { rateUnknown: true } : {}),
638
672
  });
639
673
  }
640
674
  return out;
@@ -642,12 +676,19 @@ function hostUsageOf(payload) {
642
676
  /**
643
677
  * Price a turn's token usage. Measured only when tokens were used and every
644
678
  * model that used them has a price: a model outside the table costs "$0",
645
- * and a zero called measured is booked as a saving nobody made.
679
+ * and a zero called measured is booked as a saving nobody made. Nor is a
680
+ * total whose rates cannot be told ({@link HostUsage.rateUnknown}).
646
681
  */
647
682
  function priceUsage(usage) {
648
683
  const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
649
684
  const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
650
- return { usd, measured: usage.length > 0 && unpriced.length === 0, unpriced };
685
+ const rateUnknown = [...new Set(usage.filter((u) => u.rateUnknown).map((u) => u.model))];
686
+ return {
687
+ usd,
688
+ measured: usage.length > 0 && unpriced.length === 0 && rateUnknown.length === 0,
689
+ unpriced,
690
+ ...(rateUnknown.length > 0 ? { rateUnknown } : {}),
691
+ };
651
692
  }
652
693
  /**
653
694
  * What `/health` answers as `hostFields`: how much of another host's protocol
@@ -698,6 +739,15 @@ export class ControlServer {
698
739
  attention = new Map();
699
740
  /** Plans a `/scenario/run` is executing right now: one run per plan. */
700
741
  runningPlans = new Set();
742
+ /**
743
+ * Plans whose direct run its caller cancelled, by scenario id, and when
744
+ * ({@link declineAfterCancel}): the same plan is not armed again for
745
+ * {@link AFTER_CANCEL_MS} when the run sent a call, so its steps do not run
746
+ * twice. Here, not on the session: after `/clear` — a new session — or in a
747
+ * second window the same request armed the plan again, and ran every step
748
+ * a second time.
749
+ */
750
+ cancelledScenarios = new Map();
701
751
  /**
702
752
  * Steer plans whose last pin the guard declined (control/pin-guard.ts): the
703
753
  * model made the step's call its own way, so the plan is a step behind the
@@ -1565,7 +1615,9 @@ export class ControlServer {
1565
1615
  const stashed = this.claimStashedNote(payload.session_id);
1566
1616
  // The guard answering first means no directive went out: a plan that
1567
1617
  // arms after that must never steer a model that was not told of it.
1618
+ let answeredLate = false;
1568
1619
  onLate(() => {
1620
+ answeredLate = true;
1569
1621
  const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1570
1622
  if (run) {
1571
1623
  run.matchExpired = true;
@@ -1574,7 +1626,11 @@ export class ControlServer {
1574
1626
  return stashed ? this.withContext({}, "UserPromptSubmit", stashed) : {};
1575
1627
  });
1576
1628
  const out = await this.onPrompt(payload);
1577
- return stashed ? this.withContext(out, "UserPromptSubmit", stashed) : out;
1629
+ // Or the note this prompt's own match brought here: the plan was held
1630
+ // back after a cancel in another session (`declineAfterCancel`). Not
1631
+ // once the deadline has answered — it then waits for the next hook.
1632
+ const note = stashed ?? (answeredLate ? undefined : this.claimStashedNote(payload.session_id));
1633
+ return note ? this.withContext(out, "UserPromptSubmit", note) : out;
1578
1634
  }
1579
1635
  case "/tool/pre":
1580
1636
  return await this.onToolPre(payload, onLate);
@@ -1635,7 +1691,22 @@ export class ControlServer {
1635
1691
  send(res, status, body) {
1636
1692
  if (res.headersSent || res.writableEnded)
1637
1693
  return;
1638
- const text = JSON.stringify(body ?? {});
1694
+ // An answer that cannot be written as JSON (a BigInt or a cycle in a tool's
1695
+ // arguments that came back as `updatedInput`) threw here, past the route's
1696
+ // own handler, and the host's hook request was left with no answer at all
1697
+ // until its own timeout, 15 s later. A hook that answers nothing lets the
1698
+ // call go ahead as the model wrote it: said, and answered so at once.
1699
+ let text;
1700
+ try {
1701
+ text = JSON.stringify(body ?? {});
1702
+ }
1703
+ catch (err) {
1704
+ logLine("hook.unserialisable_answer", {
1705
+ error: errText(err),
1706
+ why: "the answer could not be written as JSON — answered with none, so the call goes ahead as written",
1707
+ });
1708
+ text = "{}";
1709
+ }
1639
1710
  res.writeHead(status, {
1640
1711
  "content-type": "application/json",
1641
1712
  "content-length": Buffer.byteLength(text),
@@ -1699,8 +1770,10 @@ export class ControlServer {
1699
1770
  this.sessions.set(sessionId, session);
1700
1771
  }
1701
1772
  session.lastSeenAt = Date.now();
1773
+ // Always the session's own transcript: a hook of a sub-agent that named
1774
+ // the sub-agent's file would have the turn's marks read from two files.
1702
1775
  if (payload.transcript_path)
1703
- session.transcriptPath = payload.transcript_path;
1776
+ session.transcriptPath = sessionTranscriptPath(payload.transcript_path);
1704
1777
  if (payload.cwd)
1705
1778
  session.cwd = payload.cwd;
1706
1779
  // The first host a session names is the one it keeps. Any route may carry
@@ -1880,6 +1953,10 @@ export class ControlServer {
1880
1953
  // every run of another host would then only record.
1881
1954
  hostReplay: asked.replay === true ? true : undefined,
1882
1955
  };
1956
+ // The turn before this one ends where this one starts (`RunState.endMark`).
1957
+ const before = session.run ?? session.previous;
1958
+ if (before && before.finished && before.endMark === undefined)
1959
+ before.endMark = run.usageMark;
1883
1960
  session.run = run;
1884
1961
  logLine("run.start", {
1885
1962
  run: runId,
@@ -2113,6 +2190,7 @@ export class ControlServer {
2113
2190
  if (!otherHost(session))
2114
2191
  state.reportTool = () => this.reportToolServed();
2115
2192
  this.declineWithoutDeliveryTool(state);
2193
+ this.declineAfterCancel(session, state);
2116
2194
  // A steer plan with a shell or file-write step can only be followed if the
2117
2195
  // directive shows its computed arguments (the pin guard holds those steps
2118
2196
  // to their exact shape), so give its parameters a moment to settle —
@@ -2259,6 +2337,74 @@ export class ControlServer {
2259
2337
  run.replays.push(state);
2260
2338
  return undefined;
2261
2339
  }
2340
+ /**
2341
+ * A plan whose caller cancelled it (Esc on `run_scenario`, or a timeout) a
2342
+ * few minutes ago, after at least one of its steps had run, is not armed
2343
+ * again when the same plan matches: the steps' side effects are in place
2344
+ * already, and the model is told so by the note kept for it
2345
+ * ({@link stashCancelledNote}). Armed, the plan would have run them a second
2346
+ * time — a row added twice, a mail sent twice — and the model would have had
2347
+ * the note and the instruction to run the plan in the same message.
2348
+ *
2349
+ * The mark is spent by the first match it is asked about, whichever way that
2350
+ * goes: it holds back one arming, never a stream of them, and never on a
2351
+ * plan that sent no call (there is nothing to repeat: a step left out, one
2352
+ * whose logic broke, one that could not be sent), nor one that is long
2353
+ * cancelled ({@link AFTER_CANCEL_MS}). A person who wants the plan again asks
2354
+ * again. Except while the cancelled run is still going — Esc does not stop a
2355
+ * step in flight, and the run ends after it: every match until then is held,
2356
+ * and the first one after spends the mark.
2357
+ *
2358
+ * The mark is the recorder's, not the session's: after `/clear` or in a
2359
+ * second window the same request arrives in another session. The note the
2360
+ * cancelled run left goes with the decline, to the session it is declined
2361
+ * in, when that note is still unread.
2362
+ *
2363
+ * Returns true when it declined.
2364
+ */
2365
+ declineAfterCancel(session, state) {
2366
+ const id = state.scenarioId;
2367
+ if (!state.plan || typeof id !== "string")
2368
+ return false;
2369
+ const mark = this.cancelledScenarios.get(id);
2370
+ if (!mark)
2371
+ return false;
2372
+ const running = this.runningPlans.has(mark.state);
2373
+ if (!running)
2374
+ this.cancelledScenarios.delete(id);
2375
+ if (Date.now() - mark.at > AFTER_CANCEL_MS && !running)
2376
+ return false;
2377
+ if (!running && !sentACall(mark.state))
2378
+ return false;
2379
+ logLine("replay.decision", {
2380
+ verdict: "no-steer",
2381
+ code: "after_cancel",
2382
+ scenario: id,
2383
+ sess: session.sessionId,
2384
+ why: running
2385
+ ? "the same plan was cancelled a moment ago and its run is still finishing the step it was on — not armed again, so that step does not run twice; the agent does the task"
2386
+ : "the same plan was cancelled a moment ago after some of its steps had run — not armed again, so those steps do not run twice; the agent does the task, told what ran",
2387
+ });
2388
+ // Its note, for the model that is about to do the task by hand.
2389
+ if (!session.stashedNote && this.replay.hasUndeliveredNote(mark.state))
2390
+ session.stashedNote = mark.state;
2391
+ state.plan = undefined;
2392
+ state.mode = "none";
2393
+ state.stepsPlanned = 0;
2394
+ state.pinned.clear();
2395
+ state.declined = "after_cancel";
2396
+ return true;
2397
+ }
2398
+ /** Mark `state`'s scenario held after its caller cancelled it ({@link declineAfterCancel}). */
2399
+ holdAfterCancel(scenarioId, state) {
2400
+ const now = Date.now();
2401
+ // Marks long past their time, and not running, are dropped as new ones come.
2402
+ for (const [id, mark] of this.cancelledScenarios) {
2403
+ if (now - mark.at > AFTER_CANCEL_MS && !this.runningPlans.has(mark.state))
2404
+ this.cancelledScenarios.delete(id);
2405
+ }
2406
+ this.cancelledScenarios.set(scenarioId, { state, at: now });
2407
+ }
2262
2408
  /**
2263
2409
  * A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
2264
2410
  * with no `bir` server that is a tool the model does not have: it would say
@@ -2797,7 +2943,7 @@ export class ControlServer {
2797
2943
  * `reason` says why the run is closed. It decides something only in a
2798
2944
  * session of another host (see {@link CloseReason}).
2799
2945
  */
2800
- sealRun(session, reason = "stop") {
2946
+ sealRun(session, reason = "stop", settle = false) {
2801
2947
  const run = session.run;
2802
2948
  if (!run || run.finished)
2803
2949
  return;
@@ -2811,7 +2957,16 @@ export class ControlServer {
2811
2957
  // handed over first, before the report is built, so the report says what
2812
2958
  // happened: `fell_back` when a step ran, `failed` when none did, and not
2813
2959
  // `not_steered`, as if no plan had started.
2814
- let cutShort = early;
2960
+ //
2961
+ // A turn of a Claude Code session closed by anything but its own `Stop`
2962
+ // ended before its work did too: an API error (`StopFailure`), the
2963
+ // person's next prompt on a turn still open (Esc), a session that went
2964
+ // quiet, ended or was switched off, a recorder that stopped. What it cost
2965
+ // is no measure of the task. Its plan's report is unmeasured, and the turn
2966
+ // is no sample of the usual cost: one $0.01 beside a $2.00 recording made
2967
+ // the usual cost about $1.00, and every later saving about $1.00 too small
2968
+ // (the service's imp.md B2). The recording itself is left as it was.
2969
+ let cutShort = early || (!other && reason !== "stop");
2815
2970
  if (other) {
2816
2971
  const live = this.livePlan(run);
2817
2972
  if (live && this.replay.hostHandOver(live, "closed"))
@@ -2832,15 +2987,91 @@ export class ControlServer {
2832
2987
  this.shelveUnannounced(unannounced);
2833
2988
  // A `run_scenario` still waiting for this run's plan will never get it.
2834
2989
  this.directCalls = this.directCalls.filter((c) => c.run !== run);
2835
- const durationMs = Date.now() - run.startedAtMs;
2990
+ // The turn ends now, whenever its books are closed below.
2991
+ const sealedAtMs = Date.now();
2992
+ const durationMs = sealedAtMs - run.startedAtMs;
2993
+ // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
2994
+ // and duration are the model's own work, not the steps the scenario did.
2995
+ // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
2996
+ // hands over one number for the whole run, and it is the fragment's: the
2997
+ // plan's own steps made no model call, so all of it was spent after the
2998
+ // hand-over.
2999
+ const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
3000
+ const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
3001
+ const ownWork = fragment ?? repeatAfterPlan;
3002
+ const recordedMs = ownWork?.startedAtMs ? sealedAtMs - ownWork.startedAtMs : durationMs;
3003
+ // How many prompts the transcript holds now: the turn's answer is what
3004
+ // stood before the next one (`turnAnswer`).
3005
+ const promptsAtSeal = other ? undefined : promptCount(session.transcriptPath);
3006
+ const closeBooks = () => this.closeBooks(session, run, {
3007
+ reason,
3008
+ early,
3009
+ cutShort,
3010
+ durationMs,
3011
+ recordedMs,
3012
+ sealedAtMs,
3013
+ ownWork,
3014
+ promptsAtSeal,
3015
+ });
3016
+ // THE COST IS READ ONCE THE TURN'S LAST MESSAGE IS WRITTEN. `Stop` comes
3017
+ // before Claude Code writes it, and it carries tokens of its own: read at
3018
+ // `Stop`, every turn was one model call short, and on a replay that call
3019
+ // is most of the turn (control/transcript.ts, `settledTurnEnd`). Only an
3020
+ // ordinary stop waits — an interrupted turn, a session that ended and a
3021
+ // recorder closing have no last message coming — and never on the hook's
3022
+ // path: the seal is done, and its drain waits for the books.
3023
+ const settleMs = settle && !other && run.usageMark !== undefined ? this.costSettleMs() : 0;
3024
+ if (settleMs > 0 && session.transcriptPath) {
3025
+ this.holdSeal(run, settledTurnEnd(session.transcriptPath, { timeoutMs: settleMs })
3026
+ .catch(() => false)
3027
+ .then(() => {
3028
+ try {
3029
+ closeBooks();
3030
+ }
3031
+ catch (err) {
3032
+ logLine("run.books_failed", { run: run.runId, error: errText(err) });
3033
+ }
3034
+ }));
3035
+ }
3036
+ else {
3037
+ closeBooks();
3038
+ }
3039
+ }
3040
+ /** How long a turn's books wait for its last message: `BIR_COST_SETTLE_MS`, 0 for no wait. */
3041
+ costSettleMs() {
3042
+ if (this.opts.costSettleMs !== undefined)
3043
+ return Math.max(0, this.opts.costSettleMs);
3044
+ const raw = process.env.BIR_COST_SETTLE_MS;
3045
+ const n = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
3046
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, COST_SETTLE_MAX_MS) : COST_SETTLE_MS;
3047
+ }
3048
+ /**
3049
+ * `work` joins what a drain of `run` waits for ({@link drainRun}). Never
3050
+ * rejects: a seal's waits are best effort, and say so in the log themselves.
3051
+ */
3052
+ holdSeal(run, work) {
3053
+ const done = work.then(() => undefined, () => undefined);
3054
+ run.sealing = run.sealing ? Promise.all([run.sealing, done]).then(() => undefined) : done;
3055
+ }
3056
+ /**
3057
+ * The second half of {@link sealRun}: what the turn cost, its execution
3058
+ * reports, and the finish of its recording. Run at the seal, or a moment
3059
+ * after it, once the turn's last message is in the transcript.
3060
+ */
3061
+ closeBooks(session, run, sealed) {
3062
+ const { reason, early, cutShort, durationMs, recordedMs, sealedAtMs, ownWork, promptsAtSeal } = sealed;
3063
+ const other = otherHost(session);
3064
+ // A prompt that came while the books waited opened the session's next run:
3065
+ // this turn's tokens end at that run's mark, and what follows is its own.
3066
+ const until = run.endMark;
2836
3067
  // What this turn cost the model. Computed once, used twice: it is this run's
2837
3068
  // `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
2838
3069
  // turn's execution report.
2839
- const cost = this.runCost(session, run);
3070
+ const cost = this.runCost(session, run, until);
2840
3071
  // A matched turn records nothing, but it still owes the ledger a number —
2841
3072
  // including when it declined to steer, which is a *baseline* sample and is
2842
3073
  // what every saving is measured against (docs/calculatedReplay.md §11).
2843
- this.reportExecution(session, run, durationMs, cost, cutShort, reason);
3074
+ this.reportExecution(session, run, durationMs, cost, cutShort, reason, { sealedAtMs, until });
2844
3075
  // The plan's calls kept for a redo went into the repeat when it opened
2845
3076
  // (AC2); nothing reads them after the turn, and they can run to megabytes
2846
3077
  // on a sealed run the session keeps as its previous one.
@@ -2848,27 +3079,22 @@ export class ControlServer {
2848
3079
  s.turnCalls = undefined;
2849
3080
  s.turnCallChars = 0;
2850
3081
  }
2851
- // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
2852
- // and duration are the model's own work, not the steps the scenario did.
2853
- // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
2854
- // hands over one number for the whole run, and it is the fragment's: the
2855
- // plan's own steps made no model call, so all of it was spent after the
2856
- // hand-over.
2857
- const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
2858
- const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
2859
- const ownWork = fragment ?? repeatAfterPlan;
2860
- const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark) : cost;
2861
- const recordedMs = ownWork?.startedAtMs ? Date.now() - ownWork.startedAtMs : durationMs;
3082
+ const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark, until) : cost;
2862
3083
  if (run.recording) {
2863
3084
  const transcriptPath = session.transcriptPath;
2864
3085
  // The answer is the host's when it handed one over, and the
2865
3086
  // transcript's otherwise — never both. A run that ended early has none:
2866
3087
  // whatever text there is, it is not the answer to the prompt.
3088
+ // A prompt written since the seal opened the next turn: the answer is
3089
+ // what stood before it, never what that turn has written meanwhile.
3090
+ const beforeNext = promptsAtSeal === undefined ? undefined : answerBeforePrompt(transcriptPath, promptsAtSeal + 1);
2867
3091
  const answerText = early
2868
3092
  ? Promise.resolve("")
2869
3093
  : run.hostAnswer !== undefined
2870
3094
  ? Promise.resolve(run.hostAnswer)
2871
- : settledLastAssistantText(transcriptPath).catch(() => "");
3095
+ : beforeNext !== undefined
3096
+ ? Promise.resolve(beforeNext)
3097
+ : settledLastAssistantText(transcriptPath).catch(() => "");
2872
3098
  // What the finish of another host's run says beside its answer and its
2873
3099
  // cost: why it ended early, when it did, and whether it is whole. A
2874
3100
  // run with either is never calculated and stops catching its prompt.
@@ -2883,7 +3109,6 @@ export class ControlServer {
2883
3109
  // first step of the user's next turn by that much. Kept on the run, so a
2884
3110
  // drain of this run waits for this seal and for nobody else's — beside a
2885
3111
  // plan's report that waits for the answer too (AC11), never in its place.
2886
- const before = run.sealing;
2887
3112
  const finishing = answerText
2888
3113
  .then((answer) => {
2889
3114
  run.finishQueued = true;
@@ -2913,7 +3138,7 @@ export class ControlServer {
2913
3138
  });
2914
3139
  });
2915
3140
  });
2916
- run.sealing = before ? Promise.all([before, finishing]).then(() => undefined) : finishing;
3141
+ this.holdSeal(run, finishing);
2917
3142
  }
2918
3143
  logLine("run.finish", {
2919
3144
  run: run.runId,
@@ -2949,7 +3174,7 @@ export class ControlServer {
2949
3174
  * No watermark means the delta is unknowable, so this reports `measured:
2950
3175
  * false` and zero rather than a number that is confidently wrong.
2951
3176
  */
2952
- runCost(session, run) {
3177
+ runCost(session, run, until) {
2953
3178
  // Another host has no transcript: its numbers are the ones it handed
2954
3179
  // over with its stop, and they are looked at before any watermark — a
2955
3180
  // run of such a host never has one.
@@ -2963,7 +3188,7 @@ export class ControlServer {
2963
3188
  });
2964
3189
  return { usd: 0, measured: false };
2965
3190
  }
2966
- const cost = this.costSince(session, run.usageMark);
3191
+ const cost = this.costSince(session, run.usageMark, until);
2967
3192
  if (cost.unpriced?.length) {
2968
3193
  logLine("run.unmeasured", {
2969
3194
  run: run.runId,
@@ -2993,22 +3218,37 @@ export class ControlServer {
2993
3218
  run: run.runId,
2994
3219
  why: cost.unpriced?.length
2995
3220
  ? "the runner has no price for a model of this run"
2996
- : "the host's usage holds no tokens — the run made no model call",
2997
- models: cost.unpriced?.length ? cost.unpriced.join(",") : undefined,
3221
+ : cost.rateUnknown?.length
3222
+ ? "the host sent one total for a model that prices a long prompt at other rates, and the total is over that length — which of its requests were long cannot be told"
3223
+ : "the host's usage holds no tokens — the run made no model call",
3224
+ models: cost.unpriced?.length
3225
+ ? cost.unpriced.join(",")
3226
+ : cost.rateUnknown?.length
3227
+ ? cost.rateUnknown.join(",")
3228
+ : undefined,
2998
3229
  });
2999
3230
  return { ...cost, measured: false };
3000
3231
  }
3001
- /** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
3002
- costSince(session, mark) {
3232
+ /**
3233
+ * Transcript cost since `mark`; unmeasured (and zero) without one, or with a
3234
+ * model that has no price. Up to `until` — the mark of the session's next
3235
+ * run — when the turn's books are closed after that run has opened, and up
3236
+ * to now otherwise.
3237
+ */
3238
+ costSince(session, mark, until) {
3003
3239
  if (mark === undefined)
3004
3240
  return { usd: 0, measured: false };
3005
- return priceUsage(usageSince(session.transcriptPath, mark));
3241
+ return priceUsage(until ? usageBetween(mark, until) : usageSince(session.transcriptPath, mark));
3006
3242
  }
3007
3243
  reportExecution(session, run, durationMs, cost,
3008
3244
  /** The run was closed before its task ended for the caller (another host only). */
3009
3245
  cutShort = false,
3010
3246
  /** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
3011
- sealedBy = "stop") {
3247
+ sealedBy = "stop",
3248
+ /** When the turn was sealed, and where its tokens end ({@link closeBooks}). */
3249
+ at = {}) {
3250
+ const sealedAtMs = at.sealedAtMs ?? Date.now();
3251
+ const until = at.until;
3012
3252
  if (!isScenarioReporter(this.recorder))
3013
3253
  return;
3014
3254
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
@@ -3071,27 +3311,34 @@ export class ControlServer {
3071
3311
  // did afterwards, and its saving would read as a loss.
3072
3312
  let end = nextMark;
3073
3313
  if (state.armedBy === "intent") {
3074
- windowCost = this.costSince(session, state.usageMark);
3075
- windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
3314
+ windowCost = this.costSince(session, state.usageMark, until);
3315
+ windowMs = (state.retiredAt ?? next?.armedAt ?? sealedAtMs) - state.armedAt;
3076
3316
  end = state.retiredMark ?? nextMark;
3077
3317
  }
3078
3318
  else if (next) {
3079
3319
  windowMs = next.armedAt - run.startedAtMs;
3080
3320
  }
3081
3321
  if (end !== undefined) {
3082
- const later = this.costSince(session, end);
3322
+ const later = this.costSince(session, end, until);
3083
3323
  windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
3084
3324
  }
3085
3325
  const report = this.replay.buildReport(state, {
3086
3326
  sessionCostUsd: windowCost.usd,
3087
3327
  // A direct run the user cancelled cut its turn short: its cost is no
3088
- // baseline, and no saving is measured against it.
3089
- measured: windowCost.measured && !this.cancelledPlans.has(state) && !unsplit,
3328
+ // baseline, and no saving is measured against it. The parent's report
3329
+ // bills the whole turn, its continuations' part included, so a
3330
+ // continuation the user cancelled makes the parent's unmeasured too.
3331
+ measured: windowCost.measured &&
3332
+ !this.cancelledPlans.has(state) &&
3333
+ !chain.some((continuation) => this.cancelledPlans.has(continuation)) &&
3334
+ !unsplit,
3090
3335
  durationMs: Math.max(0, windowMs),
3091
3336
  prompt: run.input || undefined,
3092
3337
  siblings: states,
3093
3338
  runId: run.runId,
3094
- cutShort: cutShort || undefined,
3339
+ // Not for a plan whose own window had closed before the turn was cut
3340
+ // short (an intent plan that ran to its end): what it cost is known.
3341
+ cutShort: (cutShort && end === undefined) || undefined,
3095
3342
  redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
3096
3343
  chainDeriveUsd,
3097
3344
  booksOwnSaving: booksOwn(state),
@@ -3131,7 +3378,7 @@ export class ControlServer {
3131
3378
  const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3132
3379
  this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
3133
3380
  });
3134
- run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
3381
+ this.holdSeal(run, sent);
3135
3382
  return;
3136
3383
  }
3137
3384
  this.queue.push(() => recorder.reportExecution(report));
@@ -3230,7 +3477,7 @@ export class ControlServer {
3230
3477
  const run = session.run;
3231
3478
  if (!run)
3232
3479
  return;
3233
- this.sealRun(session, opts.reason);
3480
+ this.sealRun(session, opts.reason, opts.settle === true);
3234
3481
  if (opts.detach && session.run === run) {
3235
3482
  session.run = undefined;
3236
3483
  session.previous = run;
@@ -3255,7 +3502,14 @@ export class ControlServer {
3255
3502
  */
3256
3503
  async drainRun(run, flushMs) {
3257
3504
  try {
3258
- await run.sealing;
3505
+ // The seal adds to what it waits for while it settles — the books wait
3506
+ // for the turn's last message, and then for the answer's text — so this
3507
+ // waits until nothing more was added.
3508
+ for (let sealing = run.sealing;; sealing = run.sealing) {
3509
+ await sealing;
3510
+ if (run.sealing === sealing)
3511
+ break;
3512
+ }
3259
3513
  await this.queue.flush();
3260
3514
  // Bounded, so an outage cannot pin every sealed turn in memory until the
3261
3515
  // service returns; the recorder keeps sending on its own either way.
@@ -3414,6 +3668,10 @@ export class ControlServer {
3414
3668
  open.promptSeen = true;
3415
3669
  open.input = input;
3416
3670
  open.promptDay = localDay();
3671
+ // A prompt all the same: the next one is a turn of its own. Not counted,
3672
+ // the session still looked as if it had had none, so the next prompt took
3673
+ // this branch again and was recorded into this run too.
3674
+ session.prompts += 1;
3417
3675
  // This hook announces nothing, so no plan of this run may steer.
3418
3676
  open.matchExpired = true;
3419
3677
  this.retireUnannounced(session, open);
@@ -4538,6 +4796,7 @@ export class ControlServer {
4538
4796
  // session's own cwd from the hook payloads, never the daemon's.
4539
4797
  state.sessionCwd = session.cwd ?? this.opts.cwd;
4540
4798
  this.declineWithoutDeliveryTool(state);
4799
+ this.declineAfterCancel(session, state);
4541
4800
  if (!state.plan) {
4542
4801
  // A declined intent hit reports nothing: the turn's cost is not a
4543
4802
  // measurement of that scenario's task, so it is no baseline sample either.
@@ -4660,6 +4919,7 @@ export class ControlServer {
4660
4919
  scenarioId: f.scenarioId,
4661
4920
  stepIndex: f.stepIndex,
4662
4921
  ...(f.kind ? { kind: f.kind } : {}),
4922
+ ...(f.stop ? { stop: f.stop } : {}),
4663
4923
  ...(f.handoverId ? { id: f.handoverId } : {}),
4664
4924
  };
4665
4925
  // Whether this hand-over is still the turn's: its fragment due, nothing
@@ -4996,6 +5256,7 @@ export class ControlServer {
4996
5256
  // Which half the model's work after it is (the service's fallbk.md
4997
5257
  // Part 3), and the id that makes the hand-over one hit.
4998
5258
  kind: h.kind,
5259
+ ...(h.stop ? { stop: h.stop } : {}),
4999
5260
  handoverId: `ho_${randomUUID()}`,
5000
5261
  };
5001
5262
  }
@@ -5023,6 +5284,7 @@ export class ControlServer {
5023
5284
  const sent = {
5024
5285
  ...fallbackOf,
5025
5286
  ...(f.kind ? { kind: f.kind } : {}),
5287
+ ...(f.stop ? { stop: f.stop } : {}),
5026
5288
  ...(f.handoverId ? { handoverId: f.handoverId } : {}),
5027
5289
  ...(f.counted ? { counted: true } : {}),
5028
5290
  };
@@ -5484,7 +5746,21 @@ export class ControlServer {
5484
5746
  correlation.fallback.unref?.();
5485
5747
  return { pending: true };
5486
5748
  }
5487
- const builtIn = run.builtIns.get(toolUseId) ?? run.builtIns.get(`anon_${toolName}`);
5749
+ // The step this answer completes. A host that sends no `tool_use_id` is
5750
+ // paired by the tool's name, oldest call first: the pre stored its call
5751
+ // under its step index (`anon_<index>`) and this looked it up under
5752
+ // `anon_<tool name>`, which never matched, so every answer of such a host
5753
+ // was recorded at an index of its own and the pairs shifted.
5754
+ let builtInKey = toolUseId && run.builtIns.has(toolUseId) ? toolUseId : undefined;
5755
+ if (builtInKey === undefined) {
5756
+ for (const [key, candidate] of run.builtIns) {
5757
+ if (key.startsWith("anon_") && candidate.toolName === toolName) {
5758
+ builtInKey = key;
5759
+ break;
5760
+ }
5761
+ }
5762
+ }
5763
+ const builtIn = builtInKey !== undefined ? run.builtIns.get(builtInKey) : undefined;
5488
5764
  const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
5489
5765
  if (run.recording) {
5490
5766
  const output = serializeCapped(redact(payload.tool_response ?? {}));
@@ -5500,7 +5776,8 @@ export class ControlServer {
5500
5776
  });
5501
5777
  });
5502
5778
  }
5503
- run.builtIns.delete(toolUseId);
5779
+ if (builtInKey !== undefined)
5780
+ run.builtIns.delete(builtInKey);
5504
5781
  logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
5505
5782
  return { stepIndex: responseIndex };
5506
5783
  }
@@ -5562,8 +5839,18 @@ export class ControlServer {
5562
5839
  // Another host hands the run's answer and its tokens over here — it has
5563
5840
  // no transcript to read them from — and says when the run failed or its
5564
5841
  // recording is not whole.
5565
- const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
5566
- this.closeRun(session, { detach: false, reason });
5842
+ // `StopFailure` arrives on this route too: the turn ended on an API error,
5843
+ // with no answer to check and no last message to wait for. Sealed as the
5844
+ // ordinary stop it was taken for, its cut answer went to the answer check
5845
+ // and its cost was reported as a whole turn's.
5846
+ const reason = otherHost(session)
5847
+ ? this.noteHostStop(run, payload)
5848
+ : payload.hook_event_name === "StopFailure"
5849
+ ? "stop_failure"
5850
+ : "stop";
5851
+ // Claude Code writes the turn's last message after this hook: the turn's
5852
+ // cost is read once it is there (`sealRun`).
5853
+ this.closeRun(session, { detach: false, reason, settle: reason === "stop" });
5567
5854
  return {};
5568
5855
  }
5569
5856
  /**
@@ -5836,6 +6123,10 @@ export class ControlServer {
5836
6123
  // prompt may seal the turn before the step in flight has finished.
5837
6124
  const onCancel = () => {
5838
6125
  this.cancelledPlans.add(state);
6126
+ // And the same plan is not run again at once, in any session, when its
6127
+ // run sent a call ({@link declineAfterCancel}).
6128
+ if (state.scenarioId)
6129
+ this.holdAfterCancel(state.scenarioId, state);
5839
6130
  };
5840
6131
  if (signal?.aborted)
5841
6132
  onCancel();
@@ -5864,8 +6155,9 @@ export class ControlServer {
5864
6155
  * run those steps a second time.
5865
6156
  */
5866
6157
  stashCancelledNote(sessionId, state) {
5867
- const h = state.handover;
5868
- if (!h || h.delivered)
6158
+ // A hand-over's note, or — when every step ran with nobody to read the
6159
+ // results — the note about those.
6160
+ if (!this.replay.hasUndeliveredNote(state))
5869
6161
  return;
5870
6162
  const session = this.sessions.get(sessionId);
5871
6163
  if (!session)
@@ -6316,12 +6608,8 @@ export class ControlServer {
6316
6608
  });
6317
6609
  }
6318
6610
  }
6319
- /**
6320
- * The port `bir install` writes into the hook URLs, and the one `bir-hooks`
6321
- * prefers. Claude Code's `settings.json` needs a *static* URL, so the control
6322
- * server cannot be purely ephemeral: it asks for this port and falls back to an
6323
- * ephemeral one only if it is taken — in which case the installed hooks point at
6324
- * a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
6325
- */
6326
- export const DEFAULT_CONTROL_PORT = 53411;
6611
+ // The port `bir install` writes into the hook URLs now lives in control/paths.ts:
6612
+ // `bir` needed this one number, and loading this whole file for it added some
6613
+ // 50 ms to every command. Re-exported, so the server's own importers still find it.
6614
+ export { DEFAULT_CONTROL_PORT } from "./paths.js";
6327
6615
  //# sourceMappingURL=server.js.map