@basein/runner 0.3.16 → 0.3.18

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 (38) hide show
  1. package/dist/bin/bir-scenario.d.ts +5 -1
  2. package/dist/bin/bir-scenario.js +155 -5
  3. package/dist/bin/bir.js +2 -2
  4. package/dist/bin/scenario-edit.d.ts +7 -0
  5. package/dist/bin/scenario-edit.js +9 -2
  6. package/dist/bin/scenario-liveness.d.ts +12 -1
  7. package/dist/bin/scenario-liveness.js +8 -4
  8. package/dist/bin/setup.js +3 -3
  9. package/dist/control/client.d.ts +5 -0
  10. package/dist/control/client.js +7 -0
  11. package/dist/control/ensure-hook.d.ts +14 -3
  12. package/dist/control/ensure-hook.js +18 -5
  13. package/dist/control/server.d.ts +165 -2
  14. package/dist/control/server.js +754 -24
  15. package/dist/control/transcript.d.ts +37 -0
  16. package/dist/control/transcript.js +112 -0
  17. package/dist/proxy/intercept.js +13 -0
  18. package/dist/proxy/session.d.ts +23 -0
  19. package/dist/proxy/session.js +71 -3
  20. package/dist/record/recorder.d.ts +154 -0
  21. package/dist/record/recorder.js +9 -0
  22. package/dist/record/remote-recorder.d.ts +61 -1
  23. package/dist/record/remote-recorder.js +303 -20
  24. package/dist/record/switchable-recorder.js +6 -0
  25. package/dist/replay/bundle.d.ts +8 -0
  26. package/dist/replay/bundle.js +20 -6
  27. package/dist/replay/controller.d.ts +33 -1
  28. package/dist/replay/controller.js +137 -12
  29. package/dist/replay/derive.d.ts +15 -0
  30. package/dist/replay/derive.js +5 -0
  31. package/dist/replay/plan.d.ts +90 -1
  32. package/dist/replay/plan.js +232 -10
  33. package/dist/replay/source-run.d.ts +2 -2
  34. package/dist/replay/source-run.js +3 -3
  35. package/dist/replay/targets.d.ts +40 -1
  36. package/dist/replay/targets.js +105 -3
  37. package/dist/replay/types.d.ts +6 -5
  38. package/package.json +1 -1
@@ -26,19 +26,20 @@
26
26
  import { createServer } from "node:http";
27
27
  import { createHash, randomBytes, randomUUID } from "node:crypto";
28
28
  import { statSync } from "node:fs";
29
- import { join } from "node:path";
29
+ import { join, resolve as resolvePath } from "node:path";
30
30
  import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
- import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
34
+ import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, 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
- import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, isServiceProber, } from "../record/recorder.js";
41
- import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
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";
42
+ import { localDay } from "../replay/derive.js";
42
43
  import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
43
44
  import { logDetail, logLine, logOnce, errText } from "../util/log.js";
44
45
  import { journal } from "../util/journal.js";
@@ -48,7 +49,7 @@ import { awaitWithin, serviceHealth } from "../util/service-health.js";
48
49
  import { offSource } from "../util/off-switch.js";
49
50
  import { claudeJsonPath, resolveServers } from "../config/resolve.js";
50
51
  import { SCENARIO_SERVER_KEY, isScenarioServer, projectToken, readSidecar } from "../config/generate.js";
51
- import { installedPath } from "./paths.js";
52
+ import { controlKey, installedPath } from "./paths.js";
52
53
  /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
53
54
  const PROXY_REPORT_GRACE_MS = 1_500;
54
55
  /**
@@ -149,6 +150,218 @@ const DELIVERY_TOOL_REFRESH_MS = 5 * 60_000;
149
150
  * is never near this; a process that died without a word is caught by its pid.
150
151
  */
151
152
  const DELIVERY_HELLO_TTL_MS = 30 * 60_000;
153
+ /** A tool list the service took is sent again after this long (AC12): one it lost comes back. */
154
+ const TOOL_LIST_RESEND_MS = 6 * 60 * 60_000;
155
+ /** One whose send failed is not sent again sooner than this: a refusing service is not sent it at every session start. */
156
+ const TOOL_LIST_RETRY_MS = 10 * 60_000;
157
+ /** What the service keeps of a list (its agentTools.ts): beyond it a list would only be cut there, or refused. */
158
+ const TOOL_LIST_MAX = 300;
159
+ const TOOL_DESCRIPTION_MAX = 4_000;
160
+ const TOOL_SCHEMA_MAX = 16_000;
161
+ const TOOL_NAME_MAX = 300;
162
+ /** One send's body at most: the service takes up to 5 MB. */
163
+ const TOOL_LIST_BODY_MAX = 2_000_000;
164
+ /** Every string of a schema with secret-shaped runs taken out. Keys stay: a property named `token` is no secret. */
165
+ function redactStrings(value, depth = 0) {
166
+ if (depth > 24)
167
+ return null;
168
+ if (typeof value === "string")
169
+ return redactText(value);
170
+ if (Array.isArray(value))
171
+ return value.map((v) => redactStrings(v, depth + 1));
172
+ if (value && typeof value === "object") {
173
+ const out = {};
174
+ for (const [k, v] of Object.entries(value)) {
175
+ Object.defineProperty(out, k, { value: redactStrings(v, depth + 1), enumerable: true, writable: true, configurable: true });
176
+ }
177
+ return out;
178
+ }
179
+ return value;
180
+ }
181
+ /** A tool's `annotations.title`, at most this long. */
182
+ const TOOL_TITLE_MAX = 200;
183
+ /** The four hints of MCP `annotations` the service reads, all booleans. */
184
+ const TOOL_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"];
185
+ /**
186
+ * What a server says of one tool (MCP `annotations`; the service's review of
187
+ * calcscenarioautocorrect.md, items 4 and 16), as the service takes it: its
188
+ * `title`, redacted as a description is and cut to {@link TOOL_TITLE_MAX},
189
+ * and the four hints when each is a boolean. Anything else a server put there
190
+ * is dropped; undefined when nothing is left.
191
+ */
192
+ function annotationsOf(raw) {
193
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
194
+ return undefined;
195
+ const a = raw;
196
+ const out = {};
197
+ if (typeof a.title === "string" && a.title.trim())
198
+ out.title = redactText(a.title.trim()).slice(0, TOOL_TITLE_MAX);
199
+ for (const hint of TOOL_HINTS) {
200
+ if (typeof a[hint] === "boolean")
201
+ out[hint] = a[hint];
202
+ }
203
+ return Object.keys(out).length > 0 ? out : undefined;
204
+ }
205
+ /**
206
+ * The project a run or a tool list belongs to (the service's review of
207
+ * calcscenarioautocorrect.md, item 16): the first 16 hex characters of the
208
+ * sha256 of its folder, made absolute and — on Windows — lower-cased with
209
+ * forward slashes, as the discovery file's key is (paths.ts, `controlKey`).
210
+ * The service only compares it, so a plan's recording finds its own
211
+ * project's tools and never another project's server of the same name.
212
+ */
213
+ export function projectKey(dir) {
214
+ return controlKey(resolvePath(dir));
215
+ }
216
+ /**
217
+ * The plans that need their owner are asked of the service at most this
218
+ * often per account (`GET /scenarios/attention`, review item 8): a session
219
+ * start, a `/clear` and a resume each show the line, and none of them waits
220
+ * on the service again within it.
221
+ */
222
+ const ATTENTION_CACHE_MS = 10 * 60_000;
223
+ /** How long a session start waits for that answer. A slower one only fills the cache for the next. */
224
+ const ATTENTION_WAIT_MS = 1_500;
225
+ /**
226
+ * How long "nothing can be said" is kept instead — no answer, a failure, a
227
+ * service failing just then, an older one: a minute, so a session start
228
+ * during a short outage does not keep the line from the next ten minutes'.
229
+ */
230
+ const ATTENTION_RETRY_MS = 60_000;
231
+ /** A `report_wrong_result` call's `PreToolUse` is matched to its report for this long. */
232
+ const REPORT_CALL_TTL_MS = 10 * 60_000;
233
+ /** Without such a match, the one session whose latest plan ran this recently is the one meant. */
234
+ const REPORT_FALLBACK_MS = 30 * 60_000;
235
+ /** What the agent may say a replay got wrong, in characters. */
236
+ const REPORT_REASON_MAX = 2_000;
237
+ /** Why a plan is held, in the words of the session-start line. A correction waiting says that instead. */
238
+ const HELD_WHY = {
239
+ checking: ", while its result is checked",
240
+ rejected: ", the agent did not take its result",
241
+ rejected_again: ", the agent did not take its result again",
242
+ verifying: ", until the agent's next run checks its correction",
243
+ verify_failed: ", its correction did not match the agent's run",
244
+ verify_stale: ", its correction is still not checked",
245
+ };
246
+ /** A plan's title as the line quotes it: one line, no quote marks or control characters, 80 characters at most. */
247
+ function lineTitle(title) {
248
+ const one = title
249
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, " ")
250
+ .replace(/["“”]/g, "'")
251
+ .replace(/\s+/g, " ")
252
+ .trim();
253
+ return one.length > 80 ? `${one.slice(0, 79)}…` : one;
254
+ }
255
+ /** A link the line may show: http(s), with nothing in it a terminal would read as anything but a link. */
256
+ function lineUrl(url) {
257
+ const u = url.trim();
258
+ return u.length <= 500 && /^https?:\/\/[^\s"'<>\u0000-\u001f\u007f]+$/.test(u) ? u : undefined;
259
+ }
260
+ /** "3 days", "5 hours", "20 minutes" since `iso`; "" when it is not a time in the past. */
261
+ function heldFor(iso, now) {
262
+ const ms = now - Date.parse(iso);
263
+ if (!Number.isFinite(ms) || ms < 0)
264
+ return "";
265
+ const minutes = Math.max(1, Math.floor(ms / 60_000));
266
+ if (minutes < 60)
267
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
268
+ const hours = Math.floor(minutes / 60);
269
+ if (hours < 48)
270
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
271
+ return `${Math.floor(hours / 24)} days`;
272
+ }
273
+ /**
274
+ * The one line a session start shows the person (review item 8): which plan
275
+ * waits, for how long, why, and where to look —
276
+ * `bir: a plan needs you — "Check engaged users and reassign leads" held for 3 days, a correction is waiting: <url>`
277
+ * — and with more than one, how many, and the first one's page. A plan that
278
+ * is not held, whose correction waits, says only that.
279
+ */
280
+ export function attentionText(plans, now = Date.now()) {
281
+ const first = plans[0];
282
+ if (!first)
283
+ return undefined;
284
+ const url = lineUrl(first.url);
285
+ const where = url ? `: ${url}` : "";
286
+ if (plans.length > 1)
287
+ return `bir: ${plans.length} plans need you${where}`;
288
+ const title = lineTitle(first.title);
289
+ const name = title ? ` "${title}"` : "";
290
+ if (!first.heldSince) {
291
+ return first.correction === "proposed"
292
+ ? `bir: a plan needs you —${name} has a correction waiting${where}`
293
+ : `bir: a plan needs you —${name}${where}`;
294
+ }
295
+ const held = heldFor(first.heldSince, now);
296
+ const why = first.correction === "proposed" ? ", a correction is waiting" : first.reason ? HELD_WHY[first.reason] : "";
297
+ return `bir: a plan needs you —${name} held${held ? ` for ${held}` : ""}${why}${where}`;
298
+ }
299
+ /**
300
+ * A server's tool list as the service takes it (AC12): each tool's name as the
301
+ * agent calls it, its description and input schema with secret-shaped text
302
+ * taken out, and what its server says of it (`annotations`, review item 4),
303
+ * within what the service keeps; a body too large loses schemas first, from
304
+ * the end, then descriptions.
305
+ */
306
+ function toolListForService(serverName, raw) {
307
+ const tools = [];
308
+ for (const item of raw) {
309
+ if (tools.length >= TOOL_LIST_MAX)
310
+ break;
311
+ if (!item || typeof item !== "object" || Array.isArray(item))
312
+ continue;
313
+ const t = item;
314
+ if (typeof t.name !== "string" || !t.name.trim())
315
+ continue;
316
+ const qualifiedName = qualifyToolName(serverName, t.name);
317
+ if (qualifiedName.length > TOOL_NAME_MAX)
318
+ continue;
319
+ const description = typeof t.description === "string" && t.description.trim()
320
+ ? redactText(t.description).slice(0, TOOL_DESCRIPTION_MAX)
321
+ : undefined;
322
+ let inputSchema;
323
+ if (t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema)) {
324
+ const cleaned = redactStrings(t.inputSchema);
325
+ const text = JSON.stringify(cleaned);
326
+ if (text !== undefined && text.length <= TOOL_SCHEMA_MAX)
327
+ inputSchema = cleaned;
328
+ }
329
+ const annotations = annotationsOf(t.annotations);
330
+ tools.push({
331
+ name: t.name,
332
+ qualifiedName,
333
+ ...(description ? { description } : {}),
334
+ ...(inputSchema ? { inputSchema } : {}),
335
+ ...(annotations ? { annotations } : {}),
336
+ });
337
+ }
338
+ let size = JSON.stringify(tools).length;
339
+ for (let i = tools.length - 1; i >= 0 && size > TOOL_LIST_BODY_MAX; i--) {
340
+ const schema = tools[i].inputSchema;
341
+ if (schema) {
342
+ size -= JSON.stringify(schema).length;
343
+ delete tools[i].inputSchema;
344
+ }
345
+ }
346
+ for (let i = tools.length - 1; i >= 0 && size > TOOL_LIST_BODY_MAX; i--) {
347
+ const description = tools[i].description;
348
+ if (description) {
349
+ size -= description.length;
350
+ delete tools[i].description;
351
+ }
352
+ }
353
+ return tools;
354
+ }
355
+ /** The account a recorder sends for, when it says. */
356
+ function accountOf(recorder) {
357
+ const fn = recorder.accountId;
358
+ try {
359
+ return typeof fn === "function" ? fn.call(recorder) : undefined;
360
+ }
361
+ catch {
362
+ return undefined;
363
+ }
364
+ }
152
365
  /** At most this many `bir-scenario` processes are remembered, newest kept. */
153
366
  const DELIVERY_HELLO_MAX = 256;
154
367
  /*
@@ -263,6 +476,29 @@ function positiveEnv(name, fallback) {
263
476
  const n = Number(process.env[name]);
264
477
  return Number.isFinite(n) && n > 0 ? n : fallback;
265
478
  }
479
+ /**
480
+ * A recorded row's metadata, with only what it has (review item 7): a plan
481
+ * that pinned it, the call's id — `""` is no id — and `isError` only when
482
+ * true. Undefined when there is nothing, so such a row is the body it always
483
+ * was.
484
+ */
485
+ function stepMarks(m) {
486
+ const out = {};
487
+ if (m.pinnedBy)
488
+ out.pinnedBy = m.pinnedBy;
489
+ if (m.callId)
490
+ out.callId = m.callId.slice(0, 200);
491
+ if (m.isError === true)
492
+ out.isError = true;
493
+ return Object.keys(out).length > 0 ? out : undefined;
494
+ }
495
+ /** Whether a tool's answer, as the hook saw it, is an MCP result that says it failed (`isError: true`). */
496
+ function resultSaysError(response) {
497
+ return (response !== null &&
498
+ typeof response === "object" &&
499
+ !Array.isArray(response) &&
500
+ response.isError === true);
501
+ }
266
502
  /** A relayed flag that says yes: `true`, `1`, `"1"` or `"true"` (any case). */
267
503
  function isOn(value) {
268
504
  if (value === true || value === 1)
@@ -434,6 +670,20 @@ export class ControlServer {
434
670
  proxySessions = new Map();
435
671
  /** `run_scenario` pres not yet consumed by a `/scenario/run`, oldest first. */
436
672
  directCalls = [];
673
+ /**
674
+ * `report_wrong_result` pres not yet consumed by a `/scenario/dispute`,
675
+ * oldest first (review item 19): how a report from `bir-scenario`, which is
676
+ * never told its session, finds the session — and so the plan — it is about.
677
+ */
678
+ reportCalls = [];
679
+ /** Plans the agent said were wrong, and the service's answer — on its way, or in. */
680
+ disputed = new WeakMap();
681
+ /**
682
+ * The plans that need their owner, by account, as the service last listed
683
+ * them (`GET /scenarios/attention`, review item 8), and the ask still on its
684
+ * way. Null is "nothing can be said": no service, an older one, a failure.
685
+ */
686
+ attention = new Map();
437
687
  /** Plans a `/scenario/run` is executing right now: one run per plan. */
438
688
  runningPlans = new Set();
439
689
  /**
@@ -966,6 +1216,9 @@ export class ControlServer {
966
1216
  case "/proxy/result":
967
1217
  this.send(res, 200, this.onProxyResult(body));
968
1218
  return;
1219
+ case "/proxy/tools":
1220
+ this.send(res, 200, this.onProxyTools(body));
1221
+ return;
969
1222
  case "/scenario/run": {
970
1223
  // Like a long poll, a run can outlive its caller: bir-scenario drops
971
1224
  // the request when the host cancels the call or its timeout fires.
@@ -992,6 +1245,9 @@ export class ControlServer {
992
1245
  case "/scenario/hello":
993
1246
  this.send(res, 200, this.onScenarioHello(body));
994
1247
  return;
1248
+ case "/scenario/dispute":
1249
+ this.send(res, 200, await this.onScenarioDispute(body));
1250
+ return;
995
1251
  default:
996
1252
  this.send(res, 404, { error: "not_found" });
997
1253
  return;
@@ -1283,7 +1539,7 @@ export class ControlServer {
1283
1539
  async dispatchHook(route, payload, onLate) {
1284
1540
  switch (route) {
1285
1541
  case "/session/start":
1286
- return this.onSessionStart(payload);
1542
+ return await this.onSessionStart(payload);
1287
1543
  case "/session/prompt": {
1288
1544
  // What a cancelled direct run left this session to tell (see
1289
1545
  // `SessionState.stashedNote`). Claimed before the wait, so the
@@ -1639,8 +1895,24 @@ export class ControlServer {
1639
1895
  lossy: this.lossy,
1640
1896
  sessionId: session.sessionId,
1641
1897
  cwd: session.cwd ?? this.opts.cwd,
1898
+ // The project's key (review item 16): what the tool lists of this
1899
+ // project carry too, so the service offers a correction of this run's
1900
+ // plan the tools of this project only. See {@link projectKeyHere}.
1901
+ project: this.projectKeyHere(),
1642
1902
  };
1643
1903
  }
1904
+ /**
1905
+ * The project key of everything this recorder sends (review item 16): its
1906
+ * runs and its tool lists. The folder this recorder serves — the one its
1907
+ * discovery file is keyed by and its hooks are installed in — and not a
1908
+ * session's `cwd`: that follows the agent's `cd` from turn to turn, and a
1909
+ * tool list is sent when its proxy starts, before any of its calls ties it
1910
+ * to a session. The service matches a plan's recording to its tools by
1911
+ * equality, so both sides must hash the same folder.
1912
+ */
1913
+ projectKeyHere() {
1914
+ return projectKey(this.opts.cwd);
1915
+ }
1644
1916
  /**
1645
1917
  * A similar-prompt hit means the service created no run, so every later step
1646
1918
  * post would 404. Stop recording it — that half is v1 and is mandatory, and
@@ -1771,7 +2043,7 @@ export class ControlServer {
1771
2043
  // Awaited: a scenario with a target derives before the directive goes
1772
2044
  // out, so a plan that cannot find its target is never delivered
1773
2045
  // (segmented.md R-PARAM-4). Inside the prompt's own budget.
1774
- const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt") }, this.hostContext(session, run)));
2046
+ const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt"), agentDay: this.agentDayFor(session) }, this.hostContext(session, run)));
1775
2047
  const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1776
2048
  const state = await this.withinBudget(arming, remaining);
1777
2049
  if (state === undefined)
@@ -1813,9 +2085,15 @@ export class ControlServer {
1813
2085
  state.sessionCwd = session.cwd ?? this.opts.cwd;
1814
2086
  // The service offered to record this turn as the recording's newest run:
1815
2087
  // should the agent redo the task after the plan, the plan's own calls go
1816
- // into that run first, so they are kept whole as they run (AC2).
1817
- if (match.repeat?.runId)
2088
+ // into that run first, so they are kept whole as they run (AC2). And in a
2089
+ // Claude Code session, should the agent only answer, they go whole beside
2090
+ // its answer (review item 12), where the report has 3,800 bytes of each.
2091
+ if (match.repeat?.runId || !otherHost(session))
1818
2092
  state.keepCalls = true;
2093
+ // A bundle that forbids every tool call makes one exception for `bir`'s
2094
+ // report tool, where this session has it (review item 19).
2095
+ if (!otherHost(session))
2096
+ state.reportTool = () => this.reportToolServed();
1819
2097
  this.declineWithoutDeliveryTool(state);
1820
2098
  // A steer plan with a shell or file-write step can only be followed if the
1821
2099
  // directive shows its computed arguments (the pin guard holds those steps
@@ -2009,6 +2287,20 @@ export class ControlServer {
2009
2287
  return false;
2010
2288
  return this.liveHellos(Date.now()).some(([, h]) => h.tools >= 1 && !h.off);
2011
2289
  }
2290
+ /**
2291
+ * Whether a running `bir-scenario` here serves `report_wrong_result` right
2292
+ * now (review item 19): one that is not off said so in its hello. What a
2293
+ * bundle delivered beside a call of the agent's own (a divergence) is laid
2294
+ * out by: such a bundle forbids every tool call but this one. A direct
2295
+ * plan's bundle goes by its own `bir-scenario`'s word instead, which came
2296
+ * with its `/scenario/run` (see {@link onScenarioRun}). Configured is not
2297
+ * connected, as for the delivery tool.
2298
+ */
2299
+ reportToolServed() {
2300
+ if (!this.deliveryConfigured())
2301
+ return false;
2302
+ return this.liveHellos(Date.now()).some(([, h]) => h.report === true && !h.off);
2303
+ }
2012
2304
  /**
2013
2305
  * Whether this project's configuration has the `bir` server, as far as
2014
2306
  * anything here knows: a hook that named one of its tools, the caller's
@@ -2049,6 +2341,73 @@ export class ControlServer {
2049
2341
  }
2050
2342
  return out.sort((a, b) => b[1].at - a[1].at);
2051
2343
  }
2344
+ /**
2345
+ * The tool list of each wrapped server last sent to the service, by
2346
+ * account, project and server (AC12; review item 16): its hash, when, and
2347
+ * whether the service took it (null while the send is on its way).
2348
+ */
2349
+ toolListsSent = new Map();
2350
+ /**
2351
+ * `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
2352
+ * the service's autocorrect.md AC12). Its names, descriptions, input
2353
+ * schemas and what it says of each tool (`annotations`) go to the service —
2354
+ * secret-shaped text taken out, and within what the service keeps — with
2355
+ * the project they are of ({@link projectKeyHere}, the key every run here
2356
+ * carries). Sent when the list changed since that server's last one for
2357
+ * this account and project, when the last send failed ten minutes ago, or
2358
+ * six hours after the last one the service took. `bir off`, and a session
2359
+ * started with BIR_OFF=1, send nothing, as for a step. Always `{ ok: true }`;
2360
+ * never slow.
2361
+ */
2362
+ onProxyTools(body) {
2363
+ if (offSource(this.opts.cwd))
2364
+ return { ok: true, off: true };
2365
+ const boundTo = typeof body.proxyId === "string" ? this.proxySessions.get(body.proxyId) : undefined;
2366
+ if (this.isSessionOff(boundTo))
2367
+ return { ok: true, off: true };
2368
+ const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
2369
+ if (!serverName || !Array.isArray(body.tools) || !isToolListReporter(this.recorder))
2370
+ return { ok: true };
2371
+ const tools = toolListForService(serverName, body.tools);
2372
+ // A server still starting may list nothing: that replaces no list.
2373
+ if (tools.length === 0)
2374
+ return { ok: true };
2375
+ const project = this.projectKeyHere();
2376
+ const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
2377
+ const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
2378
+ const now = Date.now();
2379
+ const last = this.toolListsSent.get(key);
2380
+ if (last &&
2381
+ last.hash === hash &&
2382
+ (last.ok === null || now - last.at < (last.ok ? TOOL_LIST_RESEND_MS : TOOL_LIST_RETRY_MS))) {
2383
+ return { ok: true };
2384
+ }
2385
+ const entry = { hash, at: now, ok: null };
2386
+ this.toolListsSent.delete(key);
2387
+ this.toolListsSent.set(key, entry);
2388
+ while (this.toolListsSent.size > 512) {
2389
+ const oldest = this.toolListsSent.keys().next().value;
2390
+ if (oldest === undefined)
2391
+ break;
2392
+ this.toolListsSent.delete(oldest);
2393
+ }
2394
+ let sent;
2395
+ try {
2396
+ sent = Promise.resolve(this.recorder.reportTools(serverName, tools, project));
2397
+ }
2398
+ catch {
2399
+ sent = Promise.resolve(false);
2400
+ }
2401
+ void sent
2402
+ .catch(() => false)
2403
+ .then((ok) => {
2404
+ if (this.toolListsSent.get(key) !== entry)
2405
+ return;
2406
+ entry.ok = ok === true;
2407
+ entry.at = Date.now();
2408
+ });
2409
+ return { ok: true };
2410
+ }
2052
2411
  /**
2053
2412
  * `POST /scenario/hello` — a running `bir-scenario` saying what it serves
2054
2413
  * (K3): on `initialize`, on `tools/list`, when its off state flips and every
@@ -2064,10 +2423,13 @@ export class ControlServer {
2064
2423
  const tools = Number.isFinite(toolsRaw) && toolsRaw > 0 ? Math.floor(toolsRaw) : 0;
2065
2424
  const off = body.off === true;
2066
2425
  const version = typeof body.version === "string" ? body.version.slice(0, 40) : undefined;
2426
+ // It serves `report_wrong_result` (runner 0.3.18 on; review item 19). An
2427
+ // older `bir-scenario` says nothing of it, and serves none.
2428
+ const report = body.report === true;
2067
2429
  const before = this.scenarioHellos.get(pid);
2068
2430
  // Re-inserted, so the map's order is oldest hello first.
2069
2431
  this.scenarioHellos.delete(pid);
2070
- this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version });
2432
+ this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version, ...(report ? { report } : {}) });
2071
2433
  while (this.scenarioHellos.size > DELIVERY_HELLO_MAX) {
2072
2434
  const oldest = this.scenarioHellos.keys().next().value;
2073
2435
  if (oldest === undefined)
@@ -2088,6 +2450,138 @@ export class ControlServer {
2088
2450
  }
2089
2451
  return { ok: true };
2090
2452
  }
2453
+ /** Remember a `report_wrong_result` pre (see {@link reportCalls}): a few, and only for a while. */
2454
+ noteReportCall(sessionId, toolUseId) {
2455
+ const now = Date.now();
2456
+ this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS && c.toolUseId !== toolUseId);
2457
+ this.reportCalls.push({ sessionId, toolUseId, at: now });
2458
+ if (this.reportCalls.length > 64)
2459
+ this.reportCalls = this.reportCalls.slice(-64);
2460
+ }
2461
+ /**
2462
+ * `POST /scenario/dispute` — the agent said, through `bir`'s
2463
+ * `report_wrong_result`, that a replay's results do not answer its request
2464
+ * (the service's review of calcscenarioautocorrect.md, item 19): the plan
2465
+ * took the wrong half of an "if … otherwise", or acted on the wrong item. A
2466
+ * wrong half is caught only when the agent says so, and until now there was
2467
+ * no way to say it but in an answer nobody might read.
2468
+ *
2469
+ * WHOSE PLAN. The session is the one whose `PreToolUse` for the tool carried
2470
+ * the request's `toolUseId` (`bir-scenario` forwards the host's id for the
2471
+ * call, as for `run_scenario`); without one, the one session where such a
2472
+ * call is waiting; without that either, the one session whose plan ran in
2473
+ * the last half hour. Never a guess between two. The plan is the newest one
2474
+ * that ran in that session's latest turn with a plan: this turn's, or the
2475
+ * one before it.
2476
+ *
2477
+ * WHAT GOES. `POST /scenarios/:scenarioId/dispute` with the reason, `by:
2478
+ * "agent"`, and the turn's execution ticket as `executionId` — the id its
2479
+ * report books under when the turn ends, so the service may well have the
2480
+ * ticket and not yet the row. Once per plan and turn: a second report of the
2481
+ * same plan is answered from the first. Nothing here undoes anything.
2482
+ *
2483
+ * Answers `{ ok, held?, why? }` for `bir-scenario` to put in one sentence.
2484
+ * Never throws, and is never slow beyond the service's own bound.
2485
+ */
2486
+ async onScenarioDispute(body) {
2487
+ if (offSource(this.opts.cwd))
2488
+ return { ok: false, why: "off" };
2489
+ const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, REPORT_REASON_MAX) : "";
2490
+ if (!reason)
2491
+ return { ok: false, why: "no_reason" };
2492
+ const session = this.sessionForDispute(body);
2493
+ if (!session || this.isSessionOff(session.sessionId))
2494
+ return { ok: false, why: "no_plan" };
2495
+ const state = this.disputedPlan(session);
2496
+ if (!state?.scenarioId) {
2497
+ logLine("replay.dispute_unplaced", {
2498
+ sess: session.sessionId,
2499
+ why: "the agent reported a wrong replay, and no plan ran in this session's latest turns — nothing is sent",
2500
+ });
2501
+ return { ok: false, why: "no_plan" };
2502
+ }
2503
+ const scenarioId = state.scenarioId;
2504
+ // Once per plan and turn: a second report — also one sent while the first
2505
+ // is on its way — is answered from the first. One that was not taken may
2506
+ // be tried again.
2507
+ const earlier = this.disputed.get(state);
2508
+ if (earlier) {
2509
+ const first = await earlier;
2510
+ return first.ok
2511
+ ? { ok: true, held: first.held === true, why: "already_reported", scenarioId }
2512
+ : { ok: false, held: false, ...(first.why ? { why: first.why } : {}), scenarioId };
2513
+ }
2514
+ const recorder = this.recorder;
2515
+ if (!isScenarioDisputer(recorder))
2516
+ return { ok: false, why: "no_service", scenarioId };
2517
+ const pending = recorder
2518
+ .disputeScenario(scenarioId, { reason, ...(state.ticket ? { executionId: state.ticket } : {}) }, { timeoutMs: 10_000 })
2519
+ .catch(() => ({ ok: false, why: "service_error" }));
2520
+ this.disputed.set(state, pending);
2521
+ const answer = await pending;
2522
+ if (!answer.ok && this.disputed.get(state) === pending)
2523
+ this.disputed.delete(state);
2524
+ logLine("replay.disputed", {
2525
+ sess: session.sessionId,
2526
+ scenario: scenarioId,
2527
+ ticket: state.ticket,
2528
+ ok: answer.ok,
2529
+ held: answer.held === true || undefined,
2530
+ why: answer.ok
2531
+ ? "the agent said this replay's results do not answer its request — reported to the service"
2532
+ : `the agent's report that this replay was wrong was not taken: ${answer.why ?? "no reason given"}`,
2533
+ });
2534
+ return {
2535
+ ok: answer.ok,
2536
+ held: answer.held === true,
2537
+ ...(answer.why ? { why: answer.why } : {}),
2538
+ scenarioId,
2539
+ };
2540
+ }
2541
+ /** The session a `/scenario/dispute` is about (see {@link onScenarioDispute}), or undefined. */
2542
+ sessionForDispute(body) {
2543
+ const now = Date.now();
2544
+ this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS);
2545
+ const toolUseId = typeof body.toolUseId === "string" && body.toolUseId ? body.toolUseId : undefined;
2546
+ let pick = toolUseId ? this.reportCalls.find((c) => c.toolUseId === toolUseId) : undefined;
2547
+ if (!pick && !toolUseId && new Set(this.reportCalls.map((c) => c.sessionId)).size === 1) {
2548
+ pick = this.reportCalls[this.reportCalls.length - 1];
2549
+ }
2550
+ if (pick) {
2551
+ const chosen = pick;
2552
+ this.reportCalls = this.reportCalls.filter((c) => c !== chosen);
2553
+ const session = this.sessions.get(chosen.sessionId);
2554
+ if (session)
2555
+ return session;
2556
+ }
2557
+ // No pre seen for it — a hook that did not arrive. The one session whose
2558
+ // plan ran lately, or none: two would be a guess.
2559
+ const recent = [...this.sessions.values()].filter((s) => {
2560
+ if (otherHost(s))
2561
+ return false;
2562
+ const state = this.disputedPlan(s);
2563
+ return state !== undefined && now - state.armedAt < REPORT_FALLBACK_MS;
2564
+ });
2565
+ return recent.length === 1 ? recent[0] : undefined;
2566
+ }
2567
+ /**
2568
+ * The plan a report of a wrong replay is about: the newest that ran — a
2569
+ * step of it did its work — in the session's latest turn with one, this
2570
+ * turn or the one before it.
2571
+ */
2572
+ disputedPlan(session) {
2573
+ for (const run of [session.run, session.previous]) {
2574
+ if (!run)
2575
+ continue;
2576
+ const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
2577
+ for (let i = states.length - 1; i >= 0; i -= 1) {
2578
+ const s = states[i];
2579
+ if (s.scenarioId && (s.stepsPinned > 0 || s.ranToEnd === true))
2580
+ return s;
2581
+ }
2582
+ }
2583
+ return undefined;
2584
+ }
2091
2585
  /**
2092
2586
  * Re-read whether this project configures the `bir` server — only when
2093
2587
  * ~/.claude.json or .mcp.json changed since the last read. A file the host is
@@ -2159,6 +2653,40 @@ export class ControlServer {
2159
2653
  recordsOnly: this.recordsOnly(session, run) || undefined,
2160
2654
  };
2161
2655
  }
2656
+ /**
2657
+ * The day this session's agent was told it is (issue 1212), read from its
2658
+ * transcript each time it is asked. A session of another host has none.
2659
+ */
2660
+ agentDayFor(session) {
2661
+ if (otherHost(session))
2662
+ return undefined;
2663
+ return () => agentDayOf(session.transcriptPath);
2664
+ }
2665
+ /**
2666
+ * What a run's finish says of its agent's day (the service's review of
2667
+ * calcscenarioautocorrect.md, item 1): the line of the session's system
2668
+ * prompt that gives the date — never Claude Code's own date reminder, which
2669
+ * is only this machine's day — and this machine's day when the turn's prompt
2670
+ * came. The service checks a corrected plan against this run, and reads its
2671
+ * prompt with this day, as a replay would have. Undefined with neither.
2672
+ */
2673
+ runAgentDay(session, run) {
2674
+ let said;
2675
+ if (!otherHost(session)) {
2676
+ try {
2677
+ const day = agentDayOf(session.transcriptPath);
2678
+ if (day?.source === "system" && day.line)
2679
+ said = day.line.slice(0, 400);
2680
+ }
2681
+ catch {
2682
+ // An unreadable transcript says no day; the machine's still goes.
2683
+ }
2684
+ }
2685
+ const local = run.promptDay;
2686
+ if (!said && !local)
2687
+ return undefined;
2688
+ return { ...(said ? { said } : {}), ...(local ? { local } : {}) };
2689
+ }
2162
2690
  /**
2163
2691
  * The earlier tool results the reading of new values may look at: this
2164
2692
  * prompt's own, from the transcript — and so none for a plan that is
@@ -2294,7 +2822,7 @@ export class ControlServer {
2294
2822
  // A matched turn records nothing, but it still owes the ledger a number —
2295
2823
  // including when it declined to steer, which is a *baseline* sample and is
2296
2824
  // what every saving is measured against (docs/calculatedReplay.md §11).
2297
- this.reportExecution(session, run, durationMs, cost, cutShort);
2825
+ this.reportExecution(session, run, durationMs, cost, cutShort, reason);
2298
2826
  // The plan's calls kept for a redo went into the repeat when it opened
2299
2827
  // (AC2); nothing reads them after the turn, and they can run to megabytes
2300
2828
  // on a sealed run the session keeps as its previous one.
@@ -2335,8 +2863,10 @@ export class ControlServer {
2335
2863
  // Resolve the answer *outside* the queue: the wait below is up to two
2336
2864
  // seconds, and the queue is serial — blocking it here would delay the
2337
2865
  // first step of the user's next turn by that much. Kept on the run, so a
2338
- // drain of this run waits for this seal and for nobody else's.
2339
- run.sealing = answerText
2866
+ // drain of this run waits for this seal and for nobody else's — beside a
2867
+ // plan's report that waits for the answer too (AC11), never in its place.
2868
+ const before = run.sealing;
2869
+ const finishing = answerText
2340
2870
  .then((answer) => {
2341
2871
  run.finishQueued = true;
2342
2872
  this.queue.push(() => {
@@ -2352,13 +2882,20 @@ export class ControlServer {
2352
2882
  // "LLM $/run", and it is the service's fallback baseline for a
2353
2883
  // scenario that has no samples yet. A run recorded at $0 makes
2354
2884
  // every later saving measured against nothing.
2885
+ //
2886
+ // The agent's day is read here, at the finish (review item 1):
2887
+ // its host writes a system prompt's "Today's date is …" into the
2888
+ // session only after the prompt hook.
2889
+ const agentDay = this.runAgentDay(session, run);
2355
2890
  this.recorder.finishRun(run.runId, answer || undefined, {
2356
2891
  durationMs: recordedMs,
2357
2892
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
2358
2893
  ...closing,
2894
+ ...(agentDay ? { agentDay } : {}),
2359
2895
  });
2360
2896
  });
2361
2897
  });
2898
+ run.sealing = before ? Promise.all([before, finishing]).then(() => undefined) : finishing;
2362
2899
  }
2363
2900
  logLine("run.finish", {
2364
2901
  run: run.runId,
@@ -2451,7 +2988,9 @@ export class ControlServer {
2451
2988
  }
2452
2989
  reportExecution(session, run, durationMs, cost,
2453
2990
  /** The run was closed before its task ended for the caller (another host only). */
2454
- cutShort = false) {
2991
+ cutShort = false,
2992
+ /** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
2993
+ sealedBy = "stop") {
2455
2994
  if (!isScenarioReporter(this.recorder))
2456
2995
  return;
2457
2996
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
@@ -2515,6 +3054,35 @@ export class ControlServer {
2515
3054
  billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
2516
3055
  });
2517
3056
  }
3057
+ // The plan ran to its end, and no redo of the model's own was recorded:
3058
+ // the model only answered. Its answer goes with the report, so that the
3059
+ // service can tell whether it took the plan's result (the service's
3060
+ // autocorrect.md AC11). Read off the queue, as a recording's answer is —
3061
+ // the wait is up to two seconds, and the queue is serial — and kept on
3062
+ // the run, so a drain of this run waits for it. Only after an ordinary
3063
+ // stop, and only the text written after the turn's last call: a turn cut
3064
+ // short has no answer, and an older text is none. Claude Code only: the
3065
+ // service corrects no other host's plan.
3066
+ if (state === prompt &&
3067
+ state.ranToEnd === true &&
3068
+ !redoneBy &&
3069
+ report.outcome === "steered_full" &&
3070
+ sealedBy === "stop" &&
3071
+ run.lastToolUseId !== undefined &&
3072
+ !otherHost(session)) {
3073
+ // The plan's calls whole, beside the answer (review item 12). Taken
3074
+ // now — the seal lets go of them as soon as this returns — and read
3075
+ // only once there is an answer to send them with, off the hook's path.
3076
+ const kept = this.replay.turnCallsOf(state);
3077
+ const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
3078
+ .catch(() => "")
3079
+ .then((answer) => {
3080
+ const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3081
+ this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
3082
+ });
3083
+ run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
3084
+ return;
3085
+ }
2518
3086
  this.queue.push(() => recorder.reportExecution(report));
2519
3087
  });
2520
3088
  // The prompt match that landed too late (see `noteLateMatch`). Not when a
@@ -2537,6 +3105,44 @@ export class ControlServer {
2537
3105
  this.queue.push(() => recorder.reportExecution(report));
2538
3106
  }
2539
3107
  }
3108
+ /**
3109
+ * The plan's own calls of the turn, whole, as an answered turn's report
3110
+ * carries them (the service's review of calcscenarioautocorrect.md, item
3111
+ * 12): what each step sent and what came back, as they were kept for the
3112
+ * turn (replay/controller.ts, `TurnCall`) — in a recording's own form,
3113
+ * redacted and capped at 64 KiB apiece, as a redo's copies are (AC2) —
3114
+ * where the report's own copies have 3,800 bytes. The calls of a called sub-task are
3115
+ * left out: their step numbers are the sub-task's own, and would read as the
3116
+ * plan's steps of the same number. `kept` is what the turn kept, taken while
3117
+ * the seal still had it; this runs after the answer's wait, off the hook's
3118
+ * path. The recorder cuts them to what one report may carry when it sends
3119
+ * them.
3120
+ */
3121
+ answerCallsOf(scenarioId, kept) {
3122
+ const calls = [];
3123
+ for (const c of kept) {
3124
+ if (scenarioId && c.scenarioId !== scenarioId)
3125
+ continue;
3126
+ let input = {};
3127
+ try {
3128
+ const parsed = JSON.parse(c.input);
3129
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3130
+ input = parsed;
3131
+ }
3132
+ catch {
3133
+ // Kept serialized and capped: it was JSON. Nothing to read otherwise.
3134
+ }
3135
+ const failed = c.error !== undefined;
3136
+ calls.push({
3137
+ stepIndex: c.stepIndex,
3138
+ toolName: c.toolName,
3139
+ input,
3140
+ output: (failed ? c.error : c.output) ?? "",
3141
+ ...(failed ? { isError: true } : {}),
3142
+ });
3143
+ }
3144
+ return calls;
3145
+ }
2540
3146
  /**
2541
3147
  * Seal the session's run now, and drain it to the service in the background.
2542
3148
  *
@@ -2602,7 +3208,7 @@ export class ControlServer {
2602
3208
  }
2603
3209
  }
2604
3210
  // ── hook events ──────────────────────────────────────────────────────────
2605
- onSessionStart(payload) {
3211
+ async onSessionStart(payload) {
2606
3212
  const session = this.ensureSession(payload);
2607
3213
  // A resumed session is a live one again, whatever its SessionEnd said.
2608
3214
  session.endedAt = undefined;
@@ -2619,7 +3225,79 @@ export class ControlServer {
2619
3225
  // in the background, whether the service answers again.
2620
3226
  if (isServiceProber(this.recorder))
2621
3227
  this.recorder.probeIfFailing();
2622
- return {};
3228
+ // A plan held for its owner saves nothing until somebody looks (review
3229
+ // item 8): one line for the person, never for the model. `bir off` and
3230
+ // BIR_OFF=1 never reach here — their session start is answered `{}`.
3231
+ const line = await this.attentionLine(session, payload);
3232
+ return line ? { systemMessage: line } : {};
3233
+ }
3234
+ /**
3235
+ * The line a session start shows the person when plans of this account
3236
+ * wait for their owner (`GET /scenarios/attention`; the service's review of
3237
+ * calcscenarioautocorrect.md, item 8), or undefined. A `systemMessage`:
3238
+ * Claude Code shows it to the person and adds nothing to the model's
3239
+ * context. Asked at most once in {@link ATTENTION_CACHE_MS} per account (in
3240
+ * {@link ATTENTION_RETRY_MS} when it could say nothing), waited for at most
3241
+ * {@link ATTENTION_WAIT_MS}, and never while the service is failing; any
3242
+ * failure says nothing. Not after a compaction — the
3243
+ * session is mid-work, and was told at its start — and never in a session
3244
+ * of another host, whose adapter shows a person nothing.
3245
+ */
3246
+ async attentionLine(session, payload) {
3247
+ if (payload.source === "compact" || otherHost(session))
3248
+ return undefined;
3249
+ const recorder = this.recorder;
3250
+ if (!isAttentionReader(recorder))
3251
+ return undefined;
3252
+ const account = accountOf(recorder);
3253
+ if (!account)
3254
+ return undefined;
3255
+ let plans = null;
3256
+ try {
3257
+ plans = await this.attentionFor(account, () => recorder.scenarioAttention({ timeoutMs: ATTENTION_WAIT_MS }));
3258
+ }
3259
+ catch {
3260
+ return undefined;
3261
+ }
3262
+ if (!plans || plans.length === 0)
3263
+ return undefined;
3264
+ const line = attentionText(plans);
3265
+ if (line)
3266
+ logLine("session.attention", { sess: session.sessionId, plans: plans.length, why: "plans wait for their owner — said to the person" });
3267
+ return line;
3268
+ }
3269
+ /**
3270
+ * This account's plans that need their owner: the cached answer while it is
3271
+ * fresh, else the service's, waited for no longer than
3272
+ * {@link ATTENTION_WAIT_MS}. An answer that comes later still fills the
3273
+ * cache for the next session start. One ask at a time per account.
3274
+ */
3275
+ async attentionFor(account, ask) {
3276
+ const cached = this.attention.get(account);
3277
+ const fresh = cached?.plans === null ? ATTENTION_RETRY_MS : ATTENTION_CACHE_MS;
3278
+ if (cached && !cached.pending && Date.now() - cached.at < fresh)
3279
+ return cached.plans;
3280
+ let pending = cached?.pending;
3281
+ if (!pending) {
3282
+ const asked = Promise.resolve()
3283
+ .then(ask)
3284
+ .catch(() => null);
3285
+ pending = asked;
3286
+ this.attention.set(account, { at: cached?.at ?? 0, plans: cached?.plans ?? null, pending: asked });
3287
+ void asked.then((plans) => {
3288
+ if (this.attention.get(account)?.pending !== asked)
3289
+ return;
3290
+ this.attention.set(account, { at: Date.now(), plans });
3291
+ });
3292
+ // A recorder that signs in as one account after another keeps a few, not all.
3293
+ while (this.attention.size > 16) {
3294
+ const oldest = this.attention.keys().next().value;
3295
+ if (oldest === undefined || oldest === account)
3296
+ break;
3297
+ this.attention.delete(oldest);
3298
+ }
3299
+ }
3300
+ return (await this.withinBudget(pending, ATTENTION_WAIT_MS)) ?? null;
2623
3301
  }
2624
3302
  async onPrompt(payload) {
2625
3303
  const session = this.ensureSession(payload);
@@ -2667,6 +3345,7 @@ export class ControlServer {
2667
3345
  // steps to gain a prompt is the wrong trade — and say so.
2668
3346
  open.promptSeen = true;
2669
3347
  open.input = input;
3348
+ open.promptDay = localDay();
2670
3349
  // This hook announces nothing, so no plan of this run may steer.
2671
3350
  open.matchExpired = true;
2672
3351
  this.retireUnannounced(session, open);
@@ -2686,6 +3365,7 @@ export class ControlServer {
2686
3365
  const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
2687
3366
  run.promptSeen = true;
2688
3367
  run.input = input;
3368
+ run.promptDay = localDay();
2689
3369
  session.prompts += 1;
2690
3370
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
2691
3371
  // With replay off there is nothing this hook could say, so it says nothing
@@ -2781,6 +3461,11 @@ export class ControlServer {
2781
3461
  if (toolUseId)
2782
3462
  (run.housekeepingUses ??= new Set()).add(toolUseId);
2783
3463
  logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
3464
+ // The agent is about to say a replay was wrong (review item 19): which
3465
+ // session asks is known here, and only here — `bir-scenario` is never
3466
+ // told its session. Its `/scenario/dispute` finds it by this id.
3467
+ if (toolName === REPORT_TOOL_NAME && toolUseId)
3468
+ this.noteReportCall(session.sessionId, toolUseId);
2784
3469
  // `bir setup` lets Claude Code run every `bir` tool without asking — but a
2785
3470
  // change saved or taken back with `force`, past the check that refused
2786
3471
  // it, is the person's decision (editSteps.md D3), so Claude Code asks
@@ -3117,13 +3802,15 @@ export class ControlServer {
3117
3802
  : undefined;
3118
3803
  if (run.recording) {
3119
3804
  this.noteOwnCall(run, pinnedBy);
3805
+ // The host's id for the call ties this row to its answer (review item 7).
3806
+ const metadata = stepMarks({ pinnedBy, callId: toolUseId });
3120
3807
  this.queue.push(() => {
3121
3808
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3122
3809
  toolName,
3123
3810
  toolInput: serializeCapped(redact(args)),
3124
3811
  context: context || undefined,
3125
3812
  intent: thinkingIntent,
3126
- metadata: pinnedBy ? { pinnedBy } : undefined,
3813
+ metadata,
3127
3814
  });
3128
3815
  });
3129
3816
  }
@@ -3263,11 +3950,12 @@ export class ControlServer {
3263
3950
  run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
3264
3951
  if (run.recording) {
3265
3952
  const toolInput = serializeCapped(redact(action.input));
3953
+ const metadata = stepMarks({ pinnedBy, callId: toolUseId });
3266
3954
  this.queue.push(() => {
3267
3955
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3268
3956
  toolName,
3269
3957
  toolInput,
3270
- metadata: pinnedBy ? { pinnedBy } : undefined,
3958
+ metadata,
3271
3959
  });
3272
3960
  });
3273
3961
  }
@@ -3350,12 +4038,14 @@ export class ControlServer {
3350
4038
  if (run.recording) {
3351
4039
  this.noteOwnCall(run);
3352
4040
  const toolInput = serializeCapped(redact(modelArgs));
4041
+ const metadata = stepMarks({ callId: toolUseId });
3353
4042
  this.queue.push(() => {
3354
4043
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3355
4044
  toolName,
3356
4045
  toolInput,
3357
4046
  context: words.context || undefined,
3358
4047
  intent: words.thinking,
4048
+ metadata,
3359
4049
  });
3360
4050
  });
3361
4051
  }
@@ -3812,7 +4502,11 @@ export class ControlServer {
3812
4502
  // seconds, and this call is waiting. A plan not armed in time is dropped
3813
4503
  // unused — it was never announced, so it never steers — and its ticket is
3814
4504
  // swept like any unredeemed one (R-OUT-12).
3815
- const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", { liveCall: { toolName, toolInput }, recentResults: this.recentResultsFor(session, "call") }, this.hostContext(session, run)));
4505
+ const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
4506
+ liveCall: { toolName, toolInput },
4507
+ recentResults: this.recentResultsFor(session, "call"),
4508
+ agentDay: this.agentDayFor(session),
4509
+ }, this.hostContext(session, run)));
3816
4510
  const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
3817
4511
  if (state === undefined)
3818
4512
  return undefined; // it threw, and said so
@@ -4214,17 +4908,20 @@ export class ControlServer {
4214
4908
  // service's row schema takes a number or nothing.
4215
4909
  ...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
4216
4910
  };
4911
+ // One id on both rows, and the failure marked (review item 7).
4912
+ const callId = newCallId();
4913
+ const failed = call.error !== undefined;
4217
4914
  this.queue.push(() => {
4218
4915
  this.recorder.recordToolSelected(id, pair.selected, {
4219
4916
  toolName: call.toolName,
4220
4917
  toolInput: call.input,
4221
- metadata: { byPlan },
4918
+ metadata: { byPlan, callId },
4222
4919
  });
4223
4920
  this.recorder.recordToolResponse(id, pair.response, {
4224
4921
  toolName: call.toolName,
4225
- toolOutput: call.error === undefined ? call.output : undefined,
4922
+ toolOutput: failed ? undefined : call.output,
4226
4923
  toolError: call.error,
4227
- metadata: { byPlan },
4924
+ metadata: failed ? { byPlan, callId, isError: true } : { byPlan, callId },
4228
4925
  });
4229
4926
  });
4230
4927
  }
@@ -4282,6 +4979,9 @@ export class ControlServer {
4282
4979
  context.push(stashed);
4283
4980
  const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
4284
4981
  if (run && !run.finished) {
4982
+ // The answer, if one comes, is written after this call (AC11).
4983
+ if (typeof payload.tool_use_id === "string" && payload.tool_use_id)
4984
+ run.lastToolUseId = payload.tool_use_id;
4285
4985
  this.noteHandover(run);
4286
4986
  const note = this.replay.takeNote(run.replay);
4287
4987
  if (note)
@@ -4490,11 +5190,15 @@ export class ControlServer {
4490
5190
  const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
4491
5191
  if (run.recording) {
4492
5192
  const output = serializeCapped(redact(payload.tool_response ?? {}));
5193
+ // Its call's id, and whether it failed — the host said so, or the
5194
+ // tool's own MCP result did (review item 7).
5195
+ const metadata = stepMarks({ callId: toolUseId, isError: failed || resultSaysError(payload.tool_response) });
4493
5196
  this.queue.push(() => {
4494
5197
  this.recorder.recordToolResponse(run.runId, responseIndex, {
4495
5198
  toolName,
4496
5199
  toolOutput: failed ? undefined : output,
4497
5200
  toolError: failed ? this.errorText(payload) : undefined,
5201
+ metadata,
4498
5202
  });
4499
5203
  });
4500
5204
  }
@@ -4818,6 +5522,13 @@ export class ControlServer {
4818
5522
  const hostPid = Number(body.ppid);
4819
5523
  if (Number.isInteger(hostPid) && hostPid > 0)
4820
5524
  this.replay.work.bindSiblings(hostPid, call.sessionId);
5525
+ // The `bir-scenario` asking is this session's own: whether it serves the
5526
+ // report tool is its word, not any hello's (review item 19). An older one
5527
+ // says nothing, and serves none.
5528
+ if (!otherHost(this.sessions.get(call.sessionId))) {
5529
+ const reports = body.reports === true;
5530
+ state.reportTool = () => reports;
5531
+ }
4821
5532
  this.runningPlans.add(state);
4822
5533
  // The session is busy for as long as its plan runs, and sends no hook
4823
5534
  // meanwhile: the host is waiting on this very call.
@@ -5002,6 +5713,8 @@ export class ControlServer {
5002
5713
  result: report.result,
5003
5714
  isError: report.isError,
5004
5715
  errorMessage: report.errorMessage,
5716
+ // The id a hook injected, when one did; `record` mints one otherwise.
5717
+ callId: report.callId,
5005
5718
  });
5006
5719
  logDetail("proxy.step", {
5007
5720
  run: run.runId,
@@ -5253,6 +5966,9 @@ export class ControlServer {
5253
5966
  // from the map (segmented.md R-INTENT-7).
5254
5967
  pinnedBy: correlation.pinnedBy,
5255
5968
  intent: correlation.intent,
5969
+ // The host's id for the call when it named one, else the one minted for
5970
+ // it here: what ties the call to its answer (review item 7).
5971
+ callId: correlation.toolUseId || correlation.callId,
5256
5972
  });
5257
5973
  logDetail("proxy.step.merged", {
5258
5974
  run: run.runId,
@@ -5262,7 +5978,16 @@ export class ControlServer {
5262
5978
  });
5263
5979
  return pair;
5264
5980
  }
5265
- /** The one place a tool step pair is written. Redaction and caps live here. */
5981
+ /**
5982
+ * The one place a tool step pair is written. Redaction and caps live here.
5983
+ *
5984
+ * Both rows carry the call's id (`metadata.callId`, review item 7) — the
5985
+ * caller's, or one minted here for a call no hook named — and the answer
5986
+ * of a call that failed says so (`metadata.isError`). The service pairs a
5987
+ * call with its answer by that id, where it used to pair them by tool name
5988
+ * in the order they came: two calls of one tool at once, answered out of
5989
+ * order, took each other's answers.
5990
+ */
5266
5991
  record(run, pair, d) {
5267
5992
  if (!run.recording)
5268
5993
  return;
@@ -5272,18 +5997,23 @@ export class ControlServer {
5272
5997
  const toolError = d.isError
5273
5998
  ? (d.errorMessage ?? serializeCapped(redact(d.result)))
5274
5999
  : undefined;
6000
+ const callId = d.callId || newCallId();
6001
+ // Failed, as its reporter said or as its own MCP result says — a hook's
6002
+ // fallback view carries the result only. The row is otherwise as it was.
6003
+ const failedMark = d.isError || resultSaysError(d.result);
5275
6004
  this.queue.push(() => {
5276
6005
  this.recorder.recordToolSelected(run.runId, pair.selected, {
5277
6006
  toolName: d.toolName,
5278
6007
  toolInput,
5279
6008
  context: d.context,
5280
6009
  intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
5281
- metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
6010
+ metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy, callId } : { callId },
5282
6011
  });
5283
6012
  this.recorder.recordToolResponse(run.runId, pair.response, {
5284
6013
  toolName: d.toolName,
5285
6014
  toolOutput,
5286
6015
  toolError,
6016
+ metadata: failedMark ? { callId, isError: true } : { callId },
5287
6017
  });
5288
6018
  });
5289
6019
  }