@basein/runner 0.3.17 → 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 +719 -21
  15. package/dist/control/transcript.d.ts +19 -0
  16. package/dist/control/transcript.js +57 -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 +146 -0
  21. package/dist/record/recorder.js +9 -0
  22. package/dist/record/remote-recorder.d.ts +59 -1
  23. package/dist/record/remote-recorder.js +297 -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, settledTextAfterToolUse, 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
@@ -2354,10 +2882,16 @@ export class ControlServer {
2354
2882
  // "LLM $/run", and it is the service's fallback baseline for a
2355
2883
  // scenario that has no samples yet. A run recorded at $0 makes
2356
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);
2357
2890
  this.recorder.finishRun(run.runId, answer || undefined, {
2358
2891
  durationMs: recordedMs,
2359
2892
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
2360
2893
  ...closing,
2894
+ ...(agentDay ? { agentDay } : {}),
2361
2895
  });
2362
2896
  });
2363
2897
  });
@@ -2536,10 +3070,15 @@ export class ControlServer {
2536
3070
  sealedBy === "stop" &&
2537
3071
  run.lastToolUseId !== undefined &&
2538
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);
2539
3077
  const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
2540
3078
  .catch(() => "")
2541
3079
  .then((answer) => {
2542
- this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer } : report));
3080
+ const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3081
+ this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
2543
3082
  });
2544
3083
  run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
2545
3084
  return;
@@ -2566,6 +3105,44 @@ export class ControlServer {
2566
3105
  this.queue.push(() => recorder.reportExecution(report));
2567
3106
  }
2568
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
+ }
2569
3146
  /**
2570
3147
  * Seal the session's run now, and drain it to the service in the background.
2571
3148
  *
@@ -2631,7 +3208,7 @@ export class ControlServer {
2631
3208
  }
2632
3209
  }
2633
3210
  // ── hook events ──────────────────────────────────────────────────────────
2634
- onSessionStart(payload) {
3211
+ async onSessionStart(payload) {
2635
3212
  const session = this.ensureSession(payload);
2636
3213
  // A resumed session is a live one again, whatever its SessionEnd said.
2637
3214
  session.endedAt = undefined;
@@ -2648,7 +3225,79 @@ export class ControlServer {
2648
3225
  // in the background, whether the service answers again.
2649
3226
  if (isServiceProber(this.recorder))
2650
3227
  this.recorder.probeIfFailing();
2651
- 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;
2652
3301
  }
2653
3302
  async onPrompt(payload) {
2654
3303
  const session = this.ensureSession(payload);
@@ -2696,6 +3345,7 @@ export class ControlServer {
2696
3345
  // steps to gain a prompt is the wrong trade — and say so.
2697
3346
  open.promptSeen = true;
2698
3347
  open.input = input;
3348
+ open.promptDay = localDay();
2699
3349
  // This hook announces nothing, so no plan of this run may steer.
2700
3350
  open.matchExpired = true;
2701
3351
  this.retireUnannounced(session, open);
@@ -2715,6 +3365,7 @@ export class ControlServer {
2715
3365
  const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
2716
3366
  run.promptSeen = true;
2717
3367
  run.input = input;
3368
+ run.promptDay = localDay();
2718
3369
  session.prompts += 1;
2719
3370
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
2720
3371
  // With replay off there is nothing this hook could say, so it says nothing
@@ -2810,6 +3461,11 @@ export class ControlServer {
2810
3461
  if (toolUseId)
2811
3462
  (run.housekeepingUses ??= new Set()).add(toolUseId);
2812
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);
2813
3469
  // `bir setup` lets Claude Code run every `bir` tool without asking — but a
2814
3470
  // change saved or taken back with `force`, past the check that refused
2815
3471
  // it, is the person's decision (editSteps.md D3), so Claude Code asks
@@ -3146,13 +3802,15 @@ export class ControlServer {
3146
3802
  : undefined;
3147
3803
  if (run.recording) {
3148
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 });
3149
3807
  this.queue.push(() => {
3150
3808
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3151
3809
  toolName,
3152
3810
  toolInput: serializeCapped(redact(args)),
3153
3811
  context: context || undefined,
3154
3812
  intent: thinkingIntent,
3155
- metadata: pinnedBy ? { pinnedBy } : undefined,
3813
+ metadata,
3156
3814
  });
3157
3815
  });
3158
3816
  }
@@ -3292,11 +3950,12 @@ export class ControlServer {
3292
3950
  run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
3293
3951
  if (run.recording) {
3294
3952
  const toolInput = serializeCapped(redact(action.input));
3953
+ const metadata = stepMarks({ pinnedBy, callId: toolUseId });
3295
3954
  this.queue.push(() => {
3296
3955
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3297
3956
  toolName,
3298
3957
  toolInput,
3299
- metadata: pinnedBy ? { pinnedBy } : undefined,
3958
+ metadata,
3300
3959
  });
3301
3960
  });
3302
3961
  }
@@ -3379,12 +4038,14 @@ export class ControlServer {
3379
4038
  if (run.recording) {
3380
4039
  this.noteOwnCall(run);
3381
4040
  const toolInput = serializeCapped(redact(modelArgs));
4041
+ const metadata = stepMarks({ callId: toolUseId });
3382
4042
  this.queue.push(() => {
3383
4043
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3384
4044
  toolName,
3385
4045
  toolInput,
3386
4046
  context: words.context || undefined,
3387
4047
  intent: words.thinking,
4048
+ metadata,
3388
4049
  });
3389
4050
  });
3390
4051
  }
@@ -3841,7 +4502,11 @@ export class ControlServer {
3841
4502
  // seconds, and this call is waiting. A plan not armed in time is dropped
3842
4503
  // unused — it was never announced, so it never steers — and its ticket is
3843
4504
  // swept like any unredeemed one (R-OUT-12).
3844
- 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)));
3845
4510
  const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
3846
4511
  if (state === undefined)
3847
4512
  return undefined; // it threw, and said so
@@ -4243,17 +4908,20 @@ export class ControlServer {
4243
4908
  // service's row schema takes a number or nothing.
4244
4909
  ...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
4245
4910
  };
4911
+ // One id on both rows, and the failure marked (review item 7).
4912
+ const callId = newCallId();
4913
+ const failed = call.error !== undefined;
4246
4914
  this.queue.push(() => {
4247
4915
  this.recorder.recordToolSelected(id, pair.selected, {
4248
4916
  toolName: call.toolName,
4249
4917
  toolInput: call.input,
4250
- metadata: { byPlan },
4918
+ metadata: { byPlan, callId },
4251
4919
  });
4252
4920
  this.recorder.recordToolResponse(id, pair.response, {
4253
4921
  toolName: call.toolName,
4254
- toolOutput: call.error === undefined ? call.output : undefined,
4922
+ toolOutput: failed ? undefined : call.output,
4255
4923
  toolError: call.error,
4256
- metadata: { byPlan },
4924
+ metadata: failed ? { byPlan, callId, isError: true } : { byPlan, callId },
4257
4925
  });
4258
4926
  });
4259
4927
  }
@@ -4522,11 +5190,15 @@ export class ControlServer {
4522
5190
  const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
4523
5191
  if (run.recording) {
4524
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) });
4525
5196
  this.queue.push(() => {
4526
5197
  this.recorder.recordToolResponse(run.runId, responseIndex, {
4527
5198
  toolName,
4528
5199
  toolOutput: failed ? undefined : output,
4529
5200
  toolError: failed ? this.errorText(payload) : undefined,
5201
+ metadata,
4530
5202
  });
4531
5203
  });
4532
5204
  }
@@ -4850,6 +5522,13 @@ export class ControlServer {
4850
5522
  const hostPid = Number(body.ppid);
4851
5523
  if (Number.isInteger(hostPid) && hostPid > 0)
4852
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
+ }
4853
5532
  this.runningPlans.add(state);
4854
5533
  // The session is busy for as long as its plan runs, and sends no hook
4855
5534
  // meanwhile: the host is waiting on this very call.
@@ -5034,6 +5713,8 @@ export class ControlServer {
5034
5713
  result: report.result,
5035
5714
  isError: report.isError,
5036
5715
  errorMessage: report.errorMessage,
5716
+ // The id a hook injected, when one did; `record` mints one otherwise.
5717
+ callId: report.callId,
5037
5718
  });
5038
5719
  logDetail("proxy.step", {
5039
5720
  run: run.runId,
@@ -5285,6 +5966,9 @@ export class ControlServer {
5285
5966
  // from the map (segmented.md R-INTENT-7).
5286
5967
  pinnedBy: correlation.pinnedBy,
5287
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,
5288
5972
  });
5289
5973
  logDetail("proxy.step.merged", {
5290
5974
  run: run.runId,
@@ -5294,7 +5978,16 @@ export class ControlServer {
5294
5978
  });
5295
5979
  return pair;
5296
5980
  }
5297
- /** 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
+ */
5298
5991
  record(run, pair, d) {
5299
5992
  if (!run.recording)
5300
5993
  return;
@@ -5304,18 +5997,23 @@ export class ControlServer {
5304
5997
  const toolError = d.isError
5305
5998
  ? (d.errorMessage ?? serializeCapped(redact(d.result)))
5306
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);
5307
6004
  this.queue.push(() => {
5308
6005
  this.recorder.recordToolSelected(run.runId, pair.selected, {
5309
6006
  toolName: d.toolName,
5310
6007
  toolInput,
5311
6008
  context: d.context,
5312
6009
  intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
5313
- metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
6010
+ metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy, callId } : { callId },
5314
6011
  });
5315
6012
  this.recorder.recordToolResponse(run.runId, pair.response, {
5316
6013
  toolName: d.toolName,
5317
6014
  toolOutput,
5318
6015
  toolError,
6016
+ metadata: failedMark ? { callId, isError: true } : { callId },
5319
6017
  });
5320
6018
  });
5321
6019
  }