@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
@@ -31,16 +31,17 @@ 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
+ import { readsOnlyCall } from "../replay/reads-only.js";
36
37
  import { asSent, userText } from "../record/prompt-text.js";
37
38
  import { redact, redactText } from "../record/redact.js";
38
39
  import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
39
40
  import { StepQueue } from "../record/queue.js";
40
41
  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";
42
+ import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, sentACall, } from "../replay/controller.js";
42
43
  import { localDay } from "../replay/derive.js";
43
- import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
44
+ import { calculateCostUsd, hasModelPricing, requestRates } from "../replay/pricing.js";
44
45
  import { logDetail, logLine, logOnce, errText } from "../util/log.js";
45
46
  import { journal } from "../util/journal.js";
46
47
  import { packageVersion } from "../util/version.js";
@@ -106,6 +107,19 @@ const INTENT_ARM_PROBE_MS = 500;
106
107
  const INTENT_ARM_DERIVE_MS = 1_500;
107
108
  /** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
108
109
  const FRAGMENT_CREATE_MS = 300;
110
+ /**
111
+ * How long after its caller cancelled it a plan is not armed again (when some
112
+ * of its steps had run): long enough for the person to look at what the cancel
113
+ * left and ask again, short enough that a plan asked for tomorrow runs.
114
+ */
115
+ const AFTER_CANCEL_MS = 10 * 60_000;
116
+ /**
117
+ * How long a turn's books wait, after `Stop`, for Claude Code to write the
118
+ * turn's last message — whose tokens are the turn's too (`sealRun`). The wait
119
+ * the answer's text always had. `BIR_COST_SETTLE_MS` changes it; 0 is no wait.
120
+ */
121
+ const COST_SETTLE_MS = 2_000;
122
+ const COST_SETTLE_MAX_MS = 10_000;
109
123
  /**
110
124
  * How long the model's first call after a hand-over waits for the service to
111
125
  * say whether the work recorded after an earlier hand-over at that step has a
@@ -445,6 +459,24 @@ const HEALTH_SESSIONS_MAX = 50;
445
459
  const HANDBACK_NOTE = "[BaseInstRunner calculated replay] The recorded sequence is no longer being supplied: carry on with the task yourself, choosing each tool call's arguments from what you have seen so far.";
446
460
  /** What the model hears once a direct plan it never asked to run is retired. */
447
461
  const DIRECT_SKIPPED_NOTE = "[BaseInstRunner calculated replay] The recorded scenario is not being run for this request: carry on with the task yourself.";
462
+ /**
463
+ * What the model hears, once, after its first call that only looks while a
464
+ * direct plan waits for `run_scenario` (B2 is11.md): the call did not cancel
465
+ * the plan, the steps take this request's values — named, where the reading
466
+ * named them — and a change made first would. In is11 the model had checked
467
+ * its notes because the note named the recording's person.
468
+ */
469
+ function directWaitingNote(values) {
470
+ return [
471
+ "[BaseInstRunner calculated replay] The recorded sequence is still ready for this request:",
472
+ "a call that only reads does not cancel it.",
473
+ values
474
+ ? `Its steps take this request's values, not the recording's: ${values}.`
475
+ : "Its steps take this request's values, not the recording's.",
476
+ "Call mcp__bir__run_scenario next, before any call that changes something:",
477
+ "after a change of your own it is not run.",
478
+ ].join("\n");
479
+ }
448
480
  /** What a second `run_scenario` for a plan that is already running is told. */
449
481
  const ALREADY_RUNNING = "this scenario is already running for this turn — its results come back on the first run_scenario call; do not call it again or repeat its steps";
450
482
  /** Request bodies past this are read and discarded, never buffered. */
@@ -619,6 +651,14 @@ function hostSinksOf(payload) {
619
651
  * Claude's own names, the ones a transcript line carries, and they are mapped
620
652
  * here as the transcript's are folded: a missing count is 0. Undefined when
621
653
  * the payload carries no usable list — which is "no usage", never zero.
654
+ *
655
+ * A host sends one total per model, never one per request, and a model with a
656
+ * long-prompt price (Haiku 5.5: over 100,000 tokens, five times the rates) is
657
+ * priced by each request's own length. Under the threshold no request in the
658
+ * total was over it, and the short rates are right. Over it, which ones were
659
+ * cannot be told: the total is `rateUnknown`, and the run unmeasured — priced
660
+ * at the short rates, 150,000 tokens of one request came to a fifth of their
661
+ * cost, booked as measured.
622
662
  */
623
663
  function hostUsageOf(payload) {
624
664
  if (!Array.isArray(payload.usage))
@@ -629,12 +669,25 @@ function hostUsageOf(payload) {
629
669
  if (!item || typeof item !== "object")
630
670
  continue;
631
671
  const u = item;
672
+ const written = count(u.cache_creation_input_tokens);
673
+ // Claude's own split of the cache writes, when the host passes it on: a
674
+ // 1-hour write bills at 2x input, a 5-minute one at 1.25x.
675
+ const split = u.cache_creation;
676
+ const written1h = Math.min(written, count(split?.ephemeral_1h_input_tokens));
677
+ const model = typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown";
678
+ const inputTokens = count(u.input_tokens);
679
+ const cacheReadTokens = count(u.cache_read_input_tokens);
680
+ // The whole total as if it were one request's prompt: over the model's
681
+ // long-prompt threshold, one of its requests may have been.
682
+ const longMaybe = requestRates(model, { promptTokens: inputTokens + cacheReadTokens + written }).longPrompt === true;
632
683
  out.push({
633
- model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
634
- inputTokens: count(u.input_tokens),
684
+ model,
685
+ inputTokens,
635
686
  outputTokens: count(u.output_tokens),
636
- cacheReadTokens: count(u.cache_read_input_tokens),
637
- cacheCreationTokens: count(u.cache_creation_input_tokens),
687
+ cacheReadTokens,
688
+ cacheCreationTokens: written,
689
+ ...(written1h > 0 ? { cacheCreation1hTokens: written1h } : {}),
690
+ ...(longMaybe ? { rateUnknown: true } : {}),
638
691
  });
639
692
  }
640
693
  return out;
@@ -642,12 +695,19 @@ function hostUsageOf(payload) {
642
695
  /**
643
696
  * Price a turn's token usage. Measured only when tokens were used and every
644
697
  * 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.
698
+ * and a zero called measured is booked as a saving nobody made. Nor is a
699
+ * total whose rates cannot be told ({@link HostUsage.rateUnknown}).
646
700
  */
647
701
  function priceUsage(usage) {
648
702
  const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
649
703
  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 };
704
+ const rateUnknown = [...new Set(usage.filter((u) => u.rateUnknown).map((u) => u.model))];
705
+ return {
706
+ usd,
707
+ measured: usage.length > 0 && unpriced.length === 0 && rateUnknown.length === 0,
708
+ unpriced,
709
+ ...(rateUnknown.length > 0 ? { rateUnknown } : {}),
710
+ };
651
711
  }
652
712
  /**
653
713
  * What `/health` answers as `hostFields`: how much of another host's protocol
@@ -698,6 +758,15 @@ export class ControlServer {
698
758
  attention = new Map();
699
759
  /** Plans a `/scenario/run` is executing right now: one run per plan. */
700
760
  runningPlans = new Set();
761
+ /**
762
+ * Plans whose direct run its caller cancelled, by scenario id, and when
763
+ * ({@link declineAfterCancel}): the same plan is not armed again for
764
+ * {@link AFTER_CANCEL_MS} when the run sent a call, so its steps do not run
765
+ * twice. Here, not on the session: after `/clear` — a new session — or in a
766
+ * second window the same request armed the plan again, and ran every step
767
+ * a second time.
768
+ */
769
+ cancelledScenarios = new Map();
701
770
  /**
702
771
  * Steer plans whose last pin the guard declined (control/pin-guard.ts): the
703
772
  * model made the step's call its own way, so the plan is a step behind the
@@ -1565,7 +1634,9 @@ export class ControlServer {
1565
1634
  const stashed = this.claimStashedNote(payload.session_id);
1566
1635
  // The guard answering first means no directive went out: a plan that
1567
1636
  // arms after that must never steer a model that was not told of it.
1637
+ let answeredLate = false;
1568
1638
  onLate(() => {
1639
+ answeredLate = true;
1569
1640
  const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1570
1641
  if (run) {
1571
1642
  run.matchExpired = true;
@@ -1574,7 +1645,11 @@ export class ControlServer {
1574
1645
  return stashed ? this.withContext({}, "UserPromptSubmit", stashed) : {};
1575
1646
  });
1576
1647
  const out = await this.onPrompt(payload);
1577
- return stashed ? this.withContext(out, "UserPromptSubmit", stashed) : out;
1648
+ // Or the note this prompt's own match brought here: the plan was held
1649
+ // back after a cancel in another session (`declineAfterCancel`). Not
1650
+ // once the deadline has answered — it then waits for the next hook.
1651
+ const note = stashed ?? (answeredLate ? undefined : this.claimStashedNote(payload.session_id));
1652
+ return note ? this.withContext(out, "UserPromptSubmit", note) : out;
1578
1653
  }
1579
1654
  case "/tool/pre":
1580
1655
  return await this.onToolPre(payload, onLate);
@@ -1635,7 +1710,22 @@ export class ControlServer {
1635
1710
  send(res, status, body) {
1636
1711
  if (res.headersSent || res.writableEnded)
1637
1712
  return;
1638
- const text = JSON.stringify(body ?? {});
1713
+ // An answer that cannot be written as JSON (a BigInt or a cycle in a tool's
1714
+ // arguments that came back as `updatedInput`) threw here, past the route's
1715
+ // own handler, and the host's hook request was left with no answer at all
1716
+ // until its own timeout, 15 s later. A hook that answers nothing lets the
1717
+ // call go ahead as the model wrote it: said, and answered so at once.
1718
+ let text;
1719
+ try {
1720
+ text = JSON.stringify(body ?? {});
1721
+ }
1722
+ catch (err) {
1723
+ logLine("hook.unserialisable_answer", {
1724
+ error: errText(err),
1725
+ why: "the answer could not be written as JSON — answered with none, so the call goes ahead as written",
1726
+ });
1727
+ text = "{}";
1728
+ }
1639
1729
  res.writeHead(status, {
1640
1730
  "content-type": "application/json",
1641
1731
  "content-length": Buffer.byteLength(text),
@@ -1699,8 +1789,10 @@ export class ControlServer {
1699
1789
  this.sessions.set(sessionId, session);
1700
1790
  }
1701
1791
  session.lastSeenAt = Date.now();
1792
+ // Always the session's own transcript: a hook of a sub-agent that named
1793
+ // the sub-agent's file would have the turn's marks read from two files.
1702
1794
  if (payload.transcript_path)
1703
- session.transcriptPath = payload.transcript_path;
1795
+ session.transcriptPath = sessionTranscriptPath(payload.transcript_path);
1704
1796
  if (payload.cwd)
1705
1797
  session.cwd = payload.cwd;
1706
1798
  // The first host a session names is the one it keeps. Any route may carry
@@ -1880,6 +1972,10 @@ export class ControlServer {
1880
1972
  // every run of another host would then only record.
1881
1973
  hostReplay: asked.replay === true ? true : undefined,
1882
1974
  };
1975
+ // The turn before this one ends where this one starts (`RunState.endMark`).
1976
+ const before = session.run ?? session.previous;
1977
+ if (before && before.finished && before.endMark === undefined)
1978
+ before.endMark = run.usageMark;
1883
1979
  session.run = run;
1884
1980
  logLine("run.start", {
1885
1981
  run: runId,
@@ -2113,6 +2209,7 @@ export class ControlServer {
2113
2209
  if (!otherHost(session))
2114
2210
  state.reportTool = () => this.reportToolServed();
2115
2211
  this.declineWithoutDeliveryTool(state);
2212
+ this.declineAfterCancel(session, state);
2116
2213
  // A steer plan with a shell or file-write step can only be followed if the
2117
2214
  // directive shows its computed arguments (the pin guard holds those steps
2118
2215
  // to their exact shape), so give its parameters a moment to settle —
@@ -2259,6 +2356,74 @@ export class ControlServer {
2259
2356
  run.replays.push(state);
2260
2357
  return undefined;
2261
2358
  }
2359
+ /**
2360
+ * A plan whose caller cancelled it (Esc on `run_scenario`, or a timeout) a
2361
+ * few minutes ago, after at least one of its steps had run, is not armed
2362
+ * again when the same plan matches: the steps' side effects are in place
2363
+ * already, and the model is told so by the note kept for it
2364
+ * ({@link stashCancelledNote}). Armed, the plan would have run them a second
2365
+ * time — a row added twice, a mail sent twice — and the model would have had
2366
+ * the note and the instruction to run the plan in the same message.
2367
+ *
2368
+ * The mark is spent by the first match it is asked about, whichever way that
2369
+ * goes: it holds back one arming, never a stream of them, and never on a
2370
+ * plan that sent no call (there is nothing to repeat: a step left out, one
2371
+ * whose logic broke, one that could not be sent), nor one that is long
2372
+ * cancelled ({@link AFTER_CANCEL_MS}). A person who wants the plan again asks
2373
+ * again. Except while the cancelled run is still going — Esc does not stop a
2374
+ * step in flight, and the run ends after it: every match until then is held,
2375
+ * and the first one after spends the mark.
2376
+ *
2377
+ * The mark is the recorder's, not the session's: after `/clear` or in a
2378
+ * second window the same request arrives in another session. The note the
2379
+ * cancelled run left goes with the decline, to the session it is declined
2380
+ * in, when that note is still unread.
2381
+ *
2382
+ * Returns true when it declined.
2383
+ */
2384
+ declineAfterCancel(session, state) {
2385
+ const id = state.scenarioId;
2386
+ if (!state.plan || typeof id !== "string")
2387
+ return false;
2388
+ const mark = this.cancelledScenarios.get(id);
2389
+ if (!mark)
2390
+ return false;
2391
+ const running = this.runningPlans.has(mark.state);
2392
+ if (!running)
2393
+ this.cancelledScenarios.delete(id);
2394
+ if (Date.now() - mark.at > AFTER_CANCEL_MS && !running)
2395
+ return false;
2396
+ if (!running && !sentACall(mark.state))
2397
+ return false;
2398
+ logLine("replay.decision", {
2399
+ verdict: "no-steer",
2400
+ code: "after_cancel",
2401
+ scenario: id,
2402
+ sess: session.sessionId,
2403
+ why: running
2404
+ ? "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"
2405
+ : "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",
2406
+ });
2407
+ // Its note, for the model that is about to do the task by hand.
2408
+ if (!session.stashedNote && this.replay.hasUndeliveredNote(mark.state))
2409
+ session.stashedNote = mark.state;
2410
+ state.plan = undefined;
2411
+ state.mode = "none";
2412
+ state.stepsPlanned = 0;
2413
+ state.pinned.clear();
2414
+ state.declined = "after_cancel";
2415
+ return true;
2416
+ }
2417
+ /** Mark `state`'s scenario held after its caller cancelled it ({@link declineAfterCancel}). */
2418
+ holdAfterCancel(scenarioId, state) {
2419
+ const now = Date.now();
2420
+ // Marks long past their time, and not running, are dropped as new ones come.
2421
+ for (const [id, mark] of this.cancelledScenarios) {
2422
+ if (now - mark.at > AFTER_CANCEL_MS && !this.runningPlans.has(mark.state))
2423
+ this.cancelledScenarios.delete(id);
2424
+ }
2425
+ this.cancelledScenarios.set(scenarioId, { state, at: now });
2426
+ }
2262
2427
  /**
2263
2428
  * A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
2264
2429
  * with no `bir` server that is a tool the model does not have: it would say
@@ -2365,6 +2530,13 @@ export class ControlServer {
2365
2530
  * whether the service took it (null while the send is on its way).
2366
2531
  */
2367
2532
  toolListsSent = new Map();
2533
+ /**
2534
+ * What each wrapped server says of its tools (MCP `annotations`), by the
2535
+ * name the agent calls them: whether a call only looks, for a direct plan
2536
+ * waiting through it (B2 is11.md). A server's word beats any guess from the
2537
+ * name (replay/reads-only.ts). Kept from the lists proxies report.
2538
+ */
2539
+ toolHints = new Map();
2368
2540
  /**
2369
2541
  * `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
2370
2542
  * the service's autocorrect.md AC12). Its names, descriptions, input
@@ -2384,12 +2556,26 @@ export class ControlServer {
2384
2556
  if (this.isSessionOff(boundTo))
2385
2557
  return { ok: true, off: true };
2386
2558
  const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
2387
- if (!serverName || !Array.isArray(body.tools) || !isToolListReporter(this.recorder))
2559
+ if (!serverName || !Array.isArray(body.tools))
2388
2560
  return { ok: true };
2389
2561
  const tools = toolListForService(serverName, body.tools);
2390
2562
  // A server still starting may list nothing: that replaces no list.
2391
2563
  if (tools.length === 0)
2392
2564
  return { ok: true };
2565
+ for (const t of tools) {
2566
+ if (t.annotations)
2567
+ this.toolHints.set(t.qualifiedName, t.annotations);
2568
+ else
2569
+ this.toolHints.delete(t.qualifiedName);
2570
+ }
2571
+ while (this.toolHints.size > 4_096) {
2572
+ const oldest = this.toolHints.keys().next().value;
2573
+ if (oldest === undefined)
2574
+ break;
2575
+ this.toolHints.delete(oldest);
2576
+ }
2577
+ if (!isToolListReporter(this.recorder))
2578
+ return { ok: true };
2393
2579
  const project = this.projectKeyHere();
2394
2580
  const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
2395
2581
  const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
@@ -2797,7 +2983,7 @@ export class ControlServer {
2797
2983
  * `reason` says why the run is closed. It decides something only in a
2798
2984
  * session of another host (see {@link CloseReason}).
2799
2985
  */
2800
- sealRun(session, reason = "stop") {
2986
+ sealRun(session, reason = "stop", settle = false) {
2801
2987
  const run = session.run;
2802
2988
  if (!run || run.finished)
2803
2989
  return;
@@ -2811,7 +2997,16 @@ export class ControlServer {
2811
2997
  // handed over first, before the report is built, so the report says what
2812
2998
  // happened: `fell_back` when a step ran, `failed` when none did, and not
2813
2999
  // `not_steered`, as if no plan had started.
2814
- let cutShort = early;
3000
+ //
3001
+ // A turn of a Claude Code session closed by anything but its own `Stop`
3002
+ // ended before its work did too: an API error (`StopFailure`), the
3003
+ // person's next prompt on a turn still open (Esc), a session that went
3004
+ // quiet, ended or was switched off, a recorder that stopped. What it cost
3005
+ // is no measure of the task. Its plan's report is unmeasured, and the turn
3006
+ // is no sample of the usual cost: one $0.01 beside a $2.00 recording made
3007
+ // the usual cost about $1.00, and every later saving about $1.00 too small
3008
+ // (the service's imp.md B2). The recording itself is left as it was.
3009
+ let cutShort = early || (!other && reason !== "stop");
2815
3010
  if (other) {
2816
3011
  const live = this.livePlan(run);
2817
3012
  if (live && this.replay.hostHandOver(live, "closed"))
@@ -2832,15 +3027,91 @@ export class ControlServer {
2832
3027
  this.shelveUnannounced(unannounced);
2833
3028
  // A `run_scenario` still waiting for this run's plan will never get it.
2834
3029
  this.directCalls = this.directCalls.filter((c) => c.run !== run);
2835
- const durationMs = Date.now() - run.startedAtMs;
3030
+ // The turn ends now, whenever its books are closed below.
3031
+ const sealedAtMs = Date.now();
3032
+ const durationMs = sealedAtMs - run.startedAtMs;
3033
+ // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
3034
+ // and duration are the model's own work, not the steps the scenario did.
3035
+ // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
3036
+ // hands over one number for the whole run, and it is the fragment's: the
3037
+ // plan's own steps made no model call, so all of it was spent after the
3038
+ // hand-over.
3039
+ const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
3040
+ const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
3041
+ const ownWork = fragment ?? repeatAfterPlan;
3042
+ const recordedMs = ownWork?.startedAtMs ? sealedAtMs - ownWork.startedAtMs : durationMs;
3043
+ // How many prompts the transcript holds now: the turn's answer is what
3044
+ // stood before the next one (`turnAnswer`).
3045
+ const promptsAtSeal = other ? undefined : promptCount(session.transcriptPath);
3046
+ const closeBooks = () => this.closeBooks(session, run, {
3047
+ reason,
3048
+ early,
3049
+ cutShort,
3050
+ durationMs,
3051
+ recordedMs,
3052
+ sealedAtMs,
3053
+ ownWork,
3054
+ promptsAtSeal,
3055
+ });
3056
+ // THE COST IS READ ONCE THE TURN'S LAST MESSAGE IS WRITTEN. `Stop` comes
3057
+ // before Claude Code writes it, and it carries tokens of its own: read at
3058
+ // `Stop`, every turn was one model call short, and on a replay that call
3059
+ // is most of the turn (control/transcript.ts, `settledTurnEnd`). Only an
3060
+ // ordinary stop waits — an interrupted turn, a session that ended and a
3061
+ // recorder closing have no last message coming — and never on the hook's
3062
+ // path: the seal is done, and its drain waits for the books.
3063
+ const settleMs = settle && !other && run.usageMark !== undefined ? this.costSettleMs() : 0;
3064
+ if (settleMs > 0 && session.transcriptPath) {
3065
+ this.holdSeal(run, settledTurnEnd(session.transcriptPath, { timeoutMs: settleMs })
3066
+ .catch(() => false)
3067
+ .then(() => {
3068
+ try {
3069
+ closeBooks();
3070
+ }
3071
+ catch (err) {
3072
+ logLine("run.books_failed", { run: run.runId, error: errText(err) });
3073
+ }
3074
+ }));
3075
+ }
3076
+ else {
3077
+ closeBooks();
3078
+ }
3079
+ }
3080
+ /** How long a turn's books wait for its last message: `BIR_COST_SETTLE_MS`, 0 for no wait. */
3081
+ costSettleMs() {
3082
+ if (this.opts.costSettleMs !== undefined)
3083
+ return Math.max(0, this.opts.costSettleMs);
3084
+ const raw = process.env.BIR_COST_SETTLE_MS;
3085
+ const n = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
3086
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, COST_SETTLE_MAX_MS) : COST_SETTLE_MS;
3087
+ }
3088
+ /**
3089
+ * `work` joins what a drain of `run` waits for ({@link drainRun}). Never
3090
+ * rejects: a seal's waits are best effort, and say so in the log themselves.
3091
+ */
3092
+ holdSeal(run, work) {
3093
+ const done = work.then(() => undefined, () => undefined);
3094
+ run.sealing = run.sealing ? Promise.all([run.sealing, done]).then(() => undefined) : done;
3095
+ }
3096
+ /**
3097
+ * The second half of {@link sealRun}: what the turn cost, its execution
3098
+ * reports, and the finish of its recording. Run at the seal, or a moment
3099
+ * after it, once the turn's last message is in the transcript.
3100
+ */
3101
+ closeBooks(session, run, sealed) {
3102
+ const { reason, early, cutShort, durationMs, recordedMs, sealedAtMs, ownWork, promptsAtSeal } = sealed;
3103
+ const other = otherHost(session);
3104
+ // A prompt that came while the books waited opened the session's next run:
3105
+ // this turn's tokens end at that run's mark, and what follows is its own.
3106
+ const until = run.endMark;
2836
3107
  // What this turn cost the model. Computed once, used twice: it is this run's
2837
3108
  // `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
2838
3109
  // turn's execution report.
2839
- const cost = this.runCost(session, run);
3110
+ const cost = this.runCost(session, run, until);
2840
3111
  // A matched turn records nothing, but it still owes the ledger a number —
2841
3112
  // including when it declined to steer, which is a *baseline* sample and is
2842
3113
  // what every saving is measured against (docs/calculatedReplay.md §11).
2843
- this.reportExecution(session, run, durationMs, cost, cutShort, reason);
3114
+ this.reportExecution(session, run, durationMs, cost, cutShort, reason, { sealedAtMs, until });
2844
3115
  // The plan's calls kept for a redo went into the repeat when it opened
2845
3116
  // (AC2); nothing reads them after the turn, and they can run to megabytes
2846
3117
  // on a sealed run the session keeps as its previous one.
@@ -2848,27 +3119,22 @@ export class ControlServer {
2848
3119
  s.turnCalls = undefined;
2849
3120
  s.turnCallChars = 0;
2850
3121
  }
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;
3122
+ const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark, until) : cost;
2862
3123
  if (run.recording) {
2863
3124
  const transcriptPath = session.transcriptPath;
2864
3125
  // The answer is the host's when it handed one over, and the
2865
3126
  // transcript's otherwise — never both. A run that ended early has none:
2866
3127
  // whatever text there is, it is not the answer to the prompt.
3128
+ // A prompt written since the seal opened the next turn: the answer is
3129
+ // what stood before it, never what that turn has written meanwhile.
3130
+ const beforeNext = promptsAtSeal === undefined ? undefined : answerBeforePrompt(transcriptPath, promptsAtSeal + 1);
2867
3131
  const answerText = early
2868
3132
  ? Promise.resolve("")
2869
3133
  : run.hostAnswer !== undefined
2870
3134
  ? Promise.resolve(run.hostAnswer)
2871
- : settledLastAssistantText(transcriptPath).catch(() => "");
3135
+ : beforeNext !== undefined
3136
+ ? Promise.resolve(beforeNext)
3137
+ : settledLastAssistantText(transcriptPath).catch(() => "");
2872
3138
  // What the finish of another host's run says beside its answer and its
2873
3139
  // cost: why it ended early, when it did, and whether it is whole. A
2874
3140
  // run with either is never calculated and stops catching its prompt.
@@ -2883,7 +3149,6 @@ export class ControlServer {
2883
3149
  // first step of the user's next turn by that much. Kept on the run, so a
2884
3150
  // drain of this run waits for this seal and for nobody else's — beside a
2885
3151
  // plan's report that waits for the answer too (AC11), never in its place.
2886
- const before = run.sealing;
2887
3152
  const finishing = answerText
2888
3153
  .then((answer) => {
2889
3154
  run.finishQueued = true;
@@ -2913,7 +3178,7 @@ export class ControlServer {
2913
3178
  });
2914
3179
  });
2915
3180
  });
2916
- run.sealing = before ? Promise.all([before, finishing]).then(() => undefined) : finishing;
3181
+ this.holdSeal(run, finishing);
2917
3182
  }
2918
3183
  logLine("run.finish", {
2919
3184
  run: run.runId,
@@ -2949,7 +3214,7 @@ export class ControlServer {
2949
3214
  * No watermark means the delta is unknowable, so this reports `measured:
2950
3215
  * false` and zero rather than a number that is confidently wrong.
2951
3216
  */
2952
- runCost(session, run) {
3217
+ runCost(session, run, until) {
2953
3218
  // Another host has no transcript: its numbers are the ones it handed
2954
3219
  // over with its stop, and they are looked at before any watermark — a
2955
3220
  // run of such a host never has one.
@@ -2963,7 +3228,7 @@ export class ControlServer {
2963
3228
  });
2964
3229
  return { usd: 0, measured: false };
2965
3230
  }
2966
- const cost = this.costSince(session, run.usageMark);
3231
+ const cost = this.costSince(session, run.usageMark, until);
2967
3232
  if (cost.unpriced?.length) {
2968
3233
  logLine("run.unmeasured", {
2969
3234
  run: run.runId,
@@ -2993,22 +3258,37 @@ export class ControlServer {
2993
3258
  run: run.runId,
2994
3259
  why: cost.unpriced?.length
2995
3260
  ? "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,
3261
+ : cost.rateUnknown?.length
3262
+ ? "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"
3263
+ : "the host's usage holds no tokens — the run made no model call",
3264
+ models: cost.unpriced?.length
3265
+ ? cost.unpriced.join(",")
3266
+ : cost.rateUnknown?.length
3267
+ ? cost.rateUnknown.join(",")
3268
+ : undefined,
2998
3269
  });
2999
3270
  return { ...cost, measured: false };
3000
3271
  }
3001
- /** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
3002
- costSince(session, mark) {
3272
+ /**
3273
+ * Transcript cost since `mark`; unmeasured (and zero) without one, or with a
3274
+ * model that has no price. Up to `until` — the mark of the session's next
3275
+ * run — when the turn's books are closed after that run has opened, and up
3276
+ * to now otherwise.
3277
+ */
3278
+ costSince(session, mark, until) {
3003
3279
  if (mark === undefined)
3004
3280
  return { usd: 0, measured: false };
3005
- return priceUsage(usageSince(session.transcriptPath, mark));
3281
+ return priceUsage(until ? usageBetween(mark, until) : usageSince(session.transcriptPath, mark));
3006
3282
  }
3007
3283
  reportExecution(session, run, durationMs, cost,
3008
3284
  /** The run was closed before its task ended for the caller (another host only). */
3009
3285
  cutShort = false,
3010
3286
  /** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
3011
- sealedBy = "stop") {
3287
+ sealedBy = "stop",
3288
+ /** When the turn was sealed, and where its tokens end ({@link closeBooks}). */
3289
+ at = {}) {
3290
+ const sealedAtMs = at.sealedAtMs ?? Date.now();
3291
+ const until = at.until;
3012
3292
  if (!isScenarioReporter(this.recorder))
3013
3293
  return;
3014
3294
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
@@ -3071,27 +3351,34 @@ export class ControlServer {
3071
3351
  // did afterwards, and its saving would read as a loss.
3072
3352
  let end = nextMark;
3073
3353
  if (state.armedBy === "intent") {
3074
- windowCost = this.costSince(session, state.usageMark);
3075
- windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
3354
+ windowCost = this.costSince(session, state.usageMark, until);
3355
+ windowMs = (state.retiredAt ?? next?.armedAt ?? sealedAtMs) - state.armedAt;
3076
3356
  end = state.retiredMark ?? nextMark;
3077
3357
  }
3078
3358
  else if (next) {
3079
3359
  windowMs = next.armedAt - run.startedAtMs;
3080
3360
  }
3081
3361
  if (end !== undefined) {
3082
- const later = this.costSince(session, end);
3362
+ const later = this.costSince(session, end, until);
3083
3363
  windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
3084
3364
  }
3085
3365
  const report = this.replay.buildReport(state, {
3086
3366
  sessionCostUsd: windowCost.usd,
3087
3367
  // 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,
3368
+ // baseline, and no saving is measured against it. The parent's report
3369
+ // bills the whole turn, its continuations' part included, so a
3370
+ // continuation the user cancelled makes the parent's unmeasured too.
3371
+ measured: windowCost.measured &&
3372
+ !this.cancelledPlans.has(state) &&
3373
+ !chain.some((continuation) => this.cancelledPlans.has(continuation)) &&
3374
+ !unsplit,
3090
3375
  durationMs: Math.max(0, windowMs),
3091
3376
  prompt: run.input || undefined,
3092
3377
  siblings: states,
3093
3378
  runId: run.runId,
3094
- cutShort: cutShort || undefined,
3379
+ // Not for a plan whose own window had closed before the turn was cut
3380
+ // short (an intent plan that ran to its end): what it cost is known.
3381
+ cutShort: (cutShort && end === undefined) || undefined,
3095
3382
  redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
3096
3383
  chainDeriveUsd,
3097
3384
  booksOwnSaving: booksOwn(state),
@@ -3131,7 +3418,7 @@ export class ControlServer {
3131
3418
  const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3132
3419
  this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
3133
3420
  });
3134
- run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
3421
+ this.holdSeal(run, sent);
3135
3422
  return;
3136
3423
  }
3137
3424
  this.queue.push(() => recorder.reportExecution(report));
@@ -3230,7 +3517,7 @@ export class ControlServer {
3230
3517
  const run = session.run;
3231
3518
  if (!run)
3232
3519
  return;
3233
- this.sealRun(session, opts.reason);
3520
+ this.sealRun(session, opts.reason, opts.settle === true);
3234
3521
  if (opts.detach && session.run === run) {
3235
3522
  session.run = undefined;
3236
3523
  session.previous = run;
@@ -3255,7 +3542,14 @@ export class ControlServer {
3255
3542
  */
3256
3543
  async drainRun(run, flushMs) {
3257
3544
  try {
3258
- await run.sealing;
3545
+ // The seal adds to what it waits for while it settles — the books wait
3546
+ // for the turn's last message, and then for the answer's text — so this
3547
+ // waits until nothing more was added.
3548
+ for (let sealing = run.sealing;; sealing = run.sealing) {
3549
+ await sealing;
3550
+ if (run.sealing === sealing)
3551
+ break;
3552
+ }
3259
3553
  await this.queue.flush();
3260
3554
  // Bounded, so an outage cannot pin every sealed turn in memory until the
3261
3555
  // service returns; the recorder keeps sending on its own either way.
@@ -3414,6 +3708,10 @@ export class ControlServer {
3414
3708
  open.promptSeen = true;
3415
3709
  open.input = input;
3416
3710
  open.promptDay = localDay();
3711
+ // A prompt all the same: the next one is a turn of its own. Not counted,
3712
+ // the session still looked as if it had had none, so the next prompt took
3713
+ // this branch again and was recorded into this run too.
3714
+ session.prompts += 1;
3417
3715
  // This hook announces nothing, so no plan of this run may steer.
3418
3716
  open.matchExpired = true;
3419
3717
  this.retireUnannounced(session, open);
@@ -3579,6 +3877,8 @@ export class ControlServer {
3579
3877
  const live = this.livePlan(run);
3580
3878
  /** `run_scenario` for this session's own announced direct plan: allowed, see below. */
3581
3879
  let allowDirect = false;
3880
+ /** A call that only looks, which a direct plan waits through (B2 is11.md): it runs as written. */
3881
+ let waitedRead = false;
3582
3882
  if (this.replay.isDirectTool(toolName)) {
3583
3883
  // `run_scenario` with no live plan does nothing but answer "no scenario
3584
3884
  // is armed". Recorded, that answer is a failed tool call, and a recording
@@ -3607,19 +3907,43 @@ export class ControlServer {
3607
3907
  // refused (the user said no, or a rule denied it) or it failed on the way.
3608
3908
  // Refused means refused. The plan retires without running anything — it
3609
3909
  // must not come back as a divergence that runs every step unattended.
3610
- this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed");
3910
+ this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed", "run_refused");
3611
3911
  }
3612
3912
  else if (live?.mode === "direct" && !live.directRequested && !live.running && !this.runningPlans.has(live)) {
3613
- // A direct plan whose first call is not `run_scenario`, and which never
3614
- // asked for it. The likeliest reason is that the model has no such tool
3615
- // — the bir server failed to start, was disabled in /mcp, or lists
3616
- // nothing — and a divergence would then execute every step, writes
3617
- // included, inside this hook with no permission prompt, and deny the
3913
+ // A direct plan whose next call is not `run_scenario`, and which never
3914
+ // asked for it. Nothing of the plan ever runs in this hook in its place:
3915
+ // when the model has no such tool — the bir server failed to start, was
3916
+ // disabled in /mcp, or lists nothing — a divergence would execute every
3917
+ // step, writes included, with no permission prompt, and deny the
3618
3918
  // model's call: a broken delivery tool turned into an unattended run.
3619
- // The plan retires running nothing; this call runs as written, and the
3620
- // model hears on its PostToolUse that the task is its own.
3621
- this.vetoPlan(run, live, `the model's first call was ${toolName}, not run_scenario`);
3622
- run.handback = DIRECT_SKIPPED_NOTE;
3919
+ //
3920
+ // A call that only looks does not retire the plan (B2 is11.md): it runs
3921
+ // as written, and the plan waits for `run_scenario`, told to the model
3922
+ // once on this call's PostToolUse. In is11 the model checked its own
3923
+ // notes first, because the note named the recording's person, and
3924
+ // losing the plan there cost a whole agent run for a value the steps
3925
+ // take from the request anyway. The first call that may change
3926
+ // something retires it, running nothing: run after the model's own
3927
+ // change, the plan could do the same work twice. That call runs as
3928
+ // written, and the model hears on its PostToolUse that the task is its
3929
+ // own.
3930
+ if (readsOnlyCall(toolName, modelArgs, this.toolHints.get(toolName))) {
3931
+ waitedRead = true;
3932
+ live.readsWaited = (live.readsWaited ?? 0) + 1;
3933
+ if (live.readsWaited === 1) {
3934
+ run.handback = directWaitingNote(live.plan?.requestValues());
3935
+ logLine("replay.direct_waits", {
3936
+ run: run.runId,
3937
+ scenario: live.scenarioId ?? undefined,
3938
+ tool: toolName,
3939
+ why: "the model's first call only reads — it runs as written, and the plan waits for run_scenario",
3940
+ });
3941
+ }
3942
+ }
3943
+ else {
3944
+ this.vetoPlan(run, live, `the model's first call that may change something was ${toolName}, not run_scenario${live.readsWaited ? ` (after ${live.readsWaited} that only read)` : ""}`, "changed_first");
3945
+ run.handback = DIRECT_SKIPPED_NOTE;
3946
+ }
3623
3947
  }
3624
3948
  const agentId = payload.agent_id ?? session.agentId;
3625
3949
  // The real intent, read from every line sharing this message's id
@@ -3670,6 +3994,7 @@ export class ControlServer {
3670
3994
  });
3671
3995
  const action = await this.replay.preTool(steering, toolName, toolUseId, modelArgs, {
3672
3996
  signal: answeredLate.signal,
3997
+ readsOnly: waitedRead && steering === live,
3673
3998
  });
3674
3999
  if (overran) {
3675
4000
  // The host already has its answer. A divergence was answered with its
@@ -4211,20 +4536,28 @@ export class ControlServer {
4211
4536
  }
4212
4537
  /**
4213
4538
  * Retire `state` without running anything: the model's `run_scenario` was
4214
- * refused or failed. Books a baseline sample (`not_steered`) — the agent did
4215
- * the task — never a divergence.
4539
+ * refused or failed, or the model changed something itself first. Books a
4540
+ * baseline sample (`not_steered`) — the agent did the task — never a
4541
+ * divergence. `code` goes with the report, so the recording page can say
4542
+ * why the plan did not run (B2 is11.md: it said "the runner did not say
4543
+ * why", and guessed wrong).
4216
4544
  */
4217
- vetoPlan(run, state, why) {
4545
+ vetoPlan(run, state, why, code) {
4218
4546
  if (state.retired)
4219
4547
  return;
4220
4548
  state.retired = true;
4221
4549
  state.retiredAt = Date.now();
4222
4550
  state.pinned.clear();
4223
4551
  state.skipNote = undefined;
4552
+ // A continuation has a reason of its own for this, `not_taken`, which the
4553
+ // page words for a plan that follows a hand-over (restoreParkedFragment).
4554
+ if (code && !state.declined && !state.continuation)
4555
+ state.declined = code;
4224
4556
  this.directCalls = this.directCalls.filter((c) => c.state !== state);
4225
4557
  logLine("replay.vetoed", {
4226
4558
  run: run.runId,
4227
4559
  scenario: state.scenarioId ?? undefined,
4560
+ code,
4228
4561
  why: `${why} — the plan is retired without running any step`,
4229
4562
  });
4230
4563
  }
@@ -4538,6 +4871,7 @@ export class ControlServer {
4538
4871
  // session's own cwd from the hook payloads, never the daemon's.
4539
4872
  state.sessionCwd = session.cwd ?? this.opts.cwd;
4540
4873
  this.declineWithoutDeliveryTool(state);
4874
+ this.declineAfterCancel(session, state);
4541
4875
  if (!state.plan) {
4542
4876
  // A declined intent hit reports nothing: the turn's cost is not a
4543
4877
  // measurement of that scenario's task, so it is no baseline sample either.
@@ -4660,6 +4994,7 @@ export class ControlServer {
4660
4994
  scenarioId: f.scenarioId,
4661
4995
  stepIndex: f.stepIndex,
4662
4996
  ...(f.kind ? { kind: f.kind } : {}),
4997
+ ...(f.stop ? { stop: f.stop } : {}),
4663
4998
  ...(f.handoverId ? { id: f.handoverId } : {}),
4664
4999
  };
4665
5000
  // Whether this hand-over is still the turn's: its fragment due, nothing
@@ -4996,6 +5331,7 @@ export class ControlServer {
4996
5331
  // Which half the model's work after it is (the service's fallbk.md
4997
5332
  // Part 3), and the id that makes the hand-over one hit.
4998
5333
  kind: h.kind,
5334
+ ...(h.stop ? { stop: h.stop } : {}),
4999
5335
  handoverId: `ho_${randomUUID()}`,
5000
5336
  };
5001
5337
  }
@@ -5023,6 +5359,7 @@ export class ControlServer {
5023
5359
  const sent = {
5024
5360
  ...fallbackOf,
5025
5361
  ...(f.kind ? { kind: f.kind } : {}),
5362
+ ...(f.stop ? { stop: f.stop } : {}),
5026
5363
  ...(f.handoverId ? { handoverId: f.handoverId } : {}),
5027
5364
  ...(f.counted ? { counted: true } : {}),
5028
5365
  };
@@ -5381,7 +5718,7 @@ export class ControlServer {
5381
5718
  if (!this.runningPlans.has(waiting.state)) {
5382
5719
  this.vetoPlan(waiting.run, waiting.state, failed
5383
5720
  ? "run_scenario failed before it reached the runner"
5384
- : "run_scenario answered without ever reaching the runner");
5721
+ : "run_scenario answered without ever reaching the runner", "run_refused");
5385
5722
  }
5386
5723
  }
5387
5724
  }
@@ -5484,7 +5821,21 @@ export class ControlServer {
5484
5821
  correlation.fallback.unref?.();
5485
5822
  return { pending: true };
5486
5823
  }
5487
- const builtIn = run.builtIns.get(toolUseId) ?? run.builtIns.get(`anon_${toolName}`);
5824
+ // The step this answer completes. A host that sends no `tool_use_id` is
5825
+ // paired by the tool's name, oldest call first: the pre stored its call
5826
+ // under its step index (`anon_<index>`) and this looked it up under
5827
+ // `anon_<tool name>`, which never matched, so every answer of such a host
5828
+ // was recorded at an index of its own and the pairs shifted.
5829
+ let builtInKey = toolUseId && run.builtIns.has(toolUseId) ? toolUseId : undefined;
5830
+ if (builtInKey === undefined) {
5831
+ for (const [key, candidate] of run.builtIns) {
5832
+ if (key.startsWith("anon_") && candidate.toolName === toolName) {
5833
+ builtInKey = key;
5834
+ break;
5835
+ }
5836
+ }
5837
+ }
5838
+ const builtIn = builtInKey !== undefined ? run.builtIns.get(builtInKey) : undefined;
5488
5839
  const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
5489
5840
  if (run.recording) {
5490
5841
  const output = serializeCapped(redact(payload.tool_response ?? {}));
@@ -5500,7 +5851,8 @@ export class ControlServer {
5500
5851
  });
5501
5852
  });
5502
5853
  }
5503
- run.builtIns.delete(toolUseId);
5854
+ if (builtInKey !== undefined)
5855
+ run.builtIns.delete(builtInKey);
5504
5856
  logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
5505
5857
  return { stepIndex: responseIndex };
5506
5858
  }
@@ -5562,8 +5914,18 @@ export class ControlServer {
5562
5914
  // Another host hands the run's answer and its tokens over here — it has
5563
5915
  // no transcript to read them from — and says when the run failed or its
5564
5916
  // recording is not whole.
5565
- const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
5566
- this.closeRun(session, { detach: false, reason });
5917
+ // `StopFailure` arrives on this route too: the turn ended on an API error,
5918
+ // with no answer to check and no last message to wait for. Sealed as the
5919
+ // ordinary stop it was taken for, its cut answer went to the answer check
5920
+ // and its cost was reported as a whole turn's.
5921
+ const reason = otherHost(session)
5922
+ ? this.noteHostStop(run, payload)
5923
+ : payload.hook_event_name === "StopFailure"
5924
+ ? "stop_failure"
5925
+ : "stop";
5926
+ // Claude Code writes the turn's last message after this hook: the turn's
5927
+ // cost is read once it is there (`sealRun`).
5928
+ this.closeRun(session, { detach: false, reason, settle: reason === "stop" });
5567
5929
  return {};
5568
5930
  }
5569
5931
  /**
@@ -5836,6 +6198,10 @@ export class ControlServer {
5836
6198
  // prompt may seal the turn before the step in flight has finished.
5837
6199
  const onCancel = () => {
5838
6200
  this.cancelledPlans.add(state);
6201
+ // And the same plan is not run again at once, in any session, when its
6202
+ // run sent a call ({@link declineAfterCancel}).
6203
+ if (state.scenarioId)
6204
+ this.holdAfterCancel(state.scenarioId, state);
5839
6205
  };
5840
6206
  if (signal?.aborted)
5841
6207
  onCancel();
@@ -5864,8 +6230,9 @@ export class ControlServer {
5864
6230
  * run those steps a second time.
5865
6231
  */
5866
6232
  stashCancelledNote(sessionId, state) {
5867
- const h = state.handover;
5868
- if (!h || h.delivered)
6233
+ // A hand-over's note, or — when every step ran with nobody to read the
6234
+ // results — the note about those.
6235
+ if (!this.replay.hasUndeliveredNote(state))
5869
6236
  return;
5870
6237
  const session = this.sessions.get(sessionId);
5871
6238
  if (!session)
@@ -6316,12 +6683,8 @@ export class ControlServer {
6316
6683
  });
6317
6684
  }
6318
6685
  }
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;
6686
+ // The port `bir install` writes into the hook URLs now lives in control/paths.ts:
6687
+ // `bir` needed this one number, and loading this whole file for it added some
6688
+ // 50 ms to every command. Re-exported, so the server's own importers still find it.
6689
+ export { DEFAULT_CONTROL_PORT } from "./paths.js";
6327
6690
  //# sourceMappingURL=server.js.map