@basein/runner 0.3.17 → 0.3.19

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 (51) hide show
  1. package/dist/bin/bir-hooks.d.ts +11 -8
  2. package/dist/bin/bir-hooks.js +11 -8
  3. package/dist/bin/bir-scenario.d.ts +5 -1
  4. package/dist/bin/bir-scenario.js +155 -5
  5. package/dist/bin/bir.js +22 -9
  6. package/dist/bin/investigate.d.ts +9 -0
  7. package/dist/bin/investigate.js +26 -12
  8. package/dist/bin/scenario-edit.d.ts +7 -0
  9. package/dist/bin/scenario-edit.js +19 -2
  10. package/dist/bin/scenario-liveness.d.ts +12 -1
  11. package/dist/bin/scenario-liveness.js +8 -4
  12. package/dist/bin/setup.js +3 -3
  13. package/dist/control/client.d.ts +5 -0
  14. package/dist/control/client.js +7 -0
  15. package/dist/control/ensure-hook.d.ts +14 -3
  16. package/dist/control/ensure-hook.js +18 -5
  17. package/dist/control/recorder-main.d.ts +15 -0
  18. package/dist/control/recorder-main.js +33 -8
  19. package/dist/control/server.d.ts +192 -13
  20. package/dist/control/server.js +819 -41
  21. package/dist/control/transcript.d.ts +19 -0
  22. package/dist/control/transcript.js +57 -0
  23. package/dist/proxy/intercept.js +13 -0
  24. package/dist/proxy/session.d.ts +23 -0
  25. package/dist/proxy/session.js +71 -3
  26. package/dist/record/recorder.d.ts +156 -2
  27. package/dist/record/recorder.js +9 -0
  28. package/dist/record/remote-recorder.d.ts +59 -1
  29. package/dist/record/remote-recorder.js +297 -20
  30. package/dist/record/switchable-recorder.js +6 -0
  31. package/dist/replay/bundle.d.ts +8 -0
  32. package/dist/replay/bundle.js +20 -6
  33. package/dist/replay/condition-words.d.ts +28 -0
  34. package/dist/replay/condition-words.js +59 -0
  35. package/dist/replay/controller.d.ts +78 -7
  36. package/dist/replay/controller.js +318 -62
  37. package/dist/replay/derive.d.ts +15 -0
  38. package/dist/replay/derive.js +5 -0
  39. package/dist/replay/handover.d.ts +7 -0
  40. package/dist/replay/handover.js +89 -51
  41. package/dist/replay/plan.d.ts +101 -5
  42. package/dist/replay/plan.js +260 -18
  43. package/dist/replay/source-run.d.ts +2 -2
  44. package/dist/replay/source-run.js +3 -3
  45. package/dist/replay/targets.d.ts +40 -1
  46. package/dist/replay/targets.js +105 -3
  47. package/dist/replay/types.d.ts +25 -8
  48. package/docs/calculatedReplay.md +45 -5
  49. package/docs/calculatedReplayGuide.md +13 -4
  50. package/package.json +1 -1
  51. package/production.md +21 -7
@@ -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
  /**
@@ -696,7 +946,9 @@ export class ControlServer {
696
946
  lastStepHit: s.run.intent.lastStepHit ?? null,
697
947
  }
698
948
  : null,
699
- fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
949
+ fragment: s.run?.fragment
950
+ ? { state: s.run.fragment.state, ...(s.run.fragment.decision ? { decision: true } : {}) }
951
+ : null,
700
952
  // The service's offer to record the turn as a recording's newest run,
701
953
  // and what became of it (newestRun.md).
702
954
  repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
@@ -714,6 +966,10 @@ export class ControlServer {
714
966
  // be invisible: an operator has to be able to see which of the two this
715
967
  // machine is doing without reading a log file.
716
968
  segmentArm: this.replay.segmentArm,
969
+ // And whether a step's intent may start a plan in its own PreToolUse,
970
+ // which means waiting for the probe there (it follows segmentArm unless
971
+ // BIR_INTENT_ARM says otherwise). For `bir doctor`.
972
+ intentArm: this.intentArming(),
717
973
  authUrl: resolveAuthUrl() || null,
718
974
  sessionId: this.sessionId,
719
975
  pid: process.pid,
@@ -966,6 +1222,9 @@ export class ControlServer {
966
1222
  case "/proxy/result":
967
1223
  this.send(res, 200, this.onProxyResult(body));
968
1224
  return;
1225
+ case "/proxy/tools":
1226
+ this.send(res, 200, this.onProxyTools(body));
1227
+ return;
969
1228
  case "/scenario/run": {
970
1229
  // Like a long poll, a run can outlive its caller: bir-scenario drops
971
1230
  // the request when the host cancels the call or its timeout fires.
@@ -992,6 +1251,9 @@ export class ControlServer {
992
1251
  case "/scenario/hello":
993
1252
  this.send(res, 200, this.onScenarioHello(body));
994
1253
  return;
1254
+ case "/scenario/dispute":
1255
+ this.send(res, 200, await this.onScenarioDispute(body));
1256
+ return;
995
1257
  default:
996
1258
  this.send(res, 404, { error: "not_found" });
997
1259
  return;
@@ -1283,7 +1545,7 @@ export class ControlServer {
1283
1545
  async dispatchHook(route, payload, onLate) {
1284
1546
  switch (route) {
1285
1547
  case "/session/start":
1286
- return this.onSessionStart(payload);
1548
+ return await this.onSessionStart(payload);
1287
1549
  case "/session/prompt": {
1288
1550
  // What a cancelled direct run left this session to tell (see
1289
1551
  // `SessionState.stashedNote`). Claimed before the wait, so the
@@ -1639,8 +1901,24 @@ export class ControlServer {
1639
1901
  lossy: this.lossy,
1640
1902
  sessionId: session.sessionId,
1641
1903
  cwd: session.cwd ?? this.opts.cwd,
1904
+ // The project's key (review item 16): what the tool lists of this
1905
+ // project carry too, so the service offers a correction of this run's
1906
+ // plan the tools of this project only. See {@link projectKeyHere}.
1907
+ project: this.projectKeyHere(),
1642
1908
  };
1643
1909
  }
1910
+ /**
1911
+ * The project key of everything this recorder sends (review item 16): its
1912
+ * runs and its tool lists. The folder this recorder serves — the one its
1913
+ * discovery file is keyed by and its hooks are installed in — and not a
1914
+ * session's `cwd`: that follows the agent's `cd` from turn to turn, and a
1915
+ * tool list is sent when its proxy starts, before any of its calls ties it
1916
+ * to a session. The service matches a plan's recording to its tools by
1917
+ * equality, so both sides must hash the same folder.
1918
+ */
1919
+ projectKeyHere() {
1920
+ return projectKey(this.opts.cwd);
1921
+ }
1644
1922
  /**
1645
1923
  * A similar-prompt hit means the service created no run, so every later step
1646
1924
  * post would 404. Stop recording it — that half is v1 and is mandatory, and
@@ -1771,7 +2049,7 @@ export class ControlServer {
1771
2049
  // Awaited: a scenario with a target derives before the directive goes
1772
2050
  // out, so a plan that cannot find its target is never delivered
1773
2051
  // (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)));
2052
+ 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
2053
  const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1776
2054
  const state = await this.withinBudget(arming, remaining);
1777
2055
  if (state === undefined)
@@ -1813,10 +2091,22 @@ export class ControlServer {
1813
2091
  state.sessionCwd = session.cwd ?? this.opts.cwd;
1814
2092
  // The service offered to record this turn as the recording's newest run:
1815
2093
  // 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)
2094
+ // into that run first, so they are kept whole as they run (AC2). And in a
2095
+ // Claude Code session, should the agent only answer, they go whole beside
2096
+ // its answer (review item 12), where the report has 3,800 bytes of each.
2097
+ if (match.repeat?.runId || !otherHost(session))
1818
2098
  state.keepCalls = true;
2099
+ // A bundle that forbids every tool call makes one exception for `bir`'s
2100
+ // report tool, where this session has it (review item 19).
2101
+ if (!otherHost(session))
2102
+ state.reportTool = () => this.reportToolServed();
1819
2103
  this.declineWithoutDeliveryTool(state);
2104
+ // A plan that stops at its decision is billed only up to the stop (the
2105
+ // service's segmentsbreakdown.md SB-26): its hand-over takes this mark. A
2106
+ // plan of the prompt without one keeps the window it always had.
2107
+ if (!otherHost(session) && this.replay.stopsAtDecision(state)) {
2108
+ state.markUsage = () => markTranscriptUsage(session.transcriptPath);
2109
+ }
1820
2110
  // A steer plan with a shell or file-write step can only be followed if the
1821
2111
  // directive shows its computed arguments (the pin guard holds those steps
1822
2112
  // to their exact shape), so give its parameters a moment to settle —
@@ -2009,6 +2299,20 @@ export class ControlServer {
2009
2299
  return false;
2010
2300
  return this.liveHellos(Date.now()).some(([, h]) => h.tools >= 1 && !h.off);
2011
2301
  }
2302
+ /**
2303
+ * Whether a running `bir-scenario` here serves `report_wrong_result` right
2304
+ * now (review item 19): one that is not off said so in its hello. What a
2305
+ * bundle delivered beside a call of the agent's own (a divergence) is laid
2306
+ * out by: such a bundle forbids every tool call but this one. A direct
2307
+ * plan's bundle goes by its own `bir-scenario`'s word instead, which came
2308
+ * with its `/scenario/run` (see {@link onScenarioRun}). Configured is not
2309
+ * connected, as for the delivery tool.
2310
+ */
2311
+ reportToolServed() {
2312
+ if (!this.deliveryConfigured())
2313
+ return false;
2314
+ return this.liveHellos(Date.now()).some(([, h]) => h.report === true && !h.off);
2315
+ }
2012
2316
  /**
2013
2317
  * Whether this project's configuration has the `bir` server, as far as
2014
2318
  * anything here knows: a hook that named one of its tools, the caller's
@@ -2049,6 +2353,73 @@ export class ControlServer {
2049
2353
  }
2050
2354
  return out.sort((a, b) => b[1].at - a[1].at);
2051
2355
  }
2356
+ /**
2357
+ * The tool list of each wrapped server last sent to the service, by
2358
+ * account, project and server (AC12; review item 16): its hash, when, and
2359
+ * whether the service took it (null while the send is on its way).
2360
+ */
2361
+ toolListsSent = new Map();
2362
+ /**
2363
+ * `POST /proxy/tools` — a proxy's server listed its tools (`tools/list`;
2364
+ * the service's autocorrect.md AC12). Its names, descriptions, input
2365
+ * schemas and what it says of each tool (`annotations`) go to the service —
2366
+ * secret-shaped text taken out, and within what the service keeps — with
2367
+ * the project they are of ({@link projectKeyHere}, the key every run here
2368
+ * carries). Sent when the list changed since that server's last one for
2369
+ * this account and project, when the last send failed ten minutes ago, or
2370
+ * six hours after the last one the service took. `bir off`, and a session
2371
+ * started with BIR_OFF=1, send nothing, as for a step. Always `{ ok: true }`;
2372
+ * never slow.
2373
+ */
2374
+ onProxyTools(body) {
2375
+ if (offSource(this.opts.cwd))
2376
+ return { ok: true, off: true };
2377
+ const boundTo = typeof body.proxyId === "string" ? this.proxySessions.get(body.proxyId) : undefined;
2378
+ if (this.isSessionOff(boundTo))
2379
+ return { ok: true, off: true };
2380
+ const serverName = typeof body.serverName === "string" ? body.serverName.trim().slice(0, 200) : "";
2381
+ if (!serverName || !Array.isArray(body.tools) || !isToolListReporter(this.recorder))
2382
+ return { ok: true };
2383
+ const tools = toolListForService(serverName, body.tools);
2384
+ // A server still starting may list nothing: that replaces no list.
2385
+ if (tools.length === 0)
2386
+ return { ok: true };
2387
+ const project = this.projectKeyHere();
2388
+ const key = `${accountOf(this.recorder) ?? ""}\u0000${project}\u0000${serverName}`;
2389
+ const hash = createHash("sha256").update(JSON.stringify(tools)).digest("hex");
2390
+ const now = Date.now();
2391
+ const last = this.toolListsSent.get(key);
2392
+ if (last &&
2393
+ last.hash === hash &&
2394
+ (last.ok === null || now - last.at < (last.ok ? TOOL_LIST_RESEND_MS : TOOL_LIST_RETRY_MS))) {
2395
+ return { ok: true };
2396
+ }
2397
+ const entry = { hash, at: now, ok: null };
2398
+ this.toolListsSent.delete(key);
2399
+ this.toolListsSent.set(key, entry);
2400
+ while (this.toolListsSent.size > 512) {
2401
+ const oldest = this.toolListsSent.keys().next().value;
2402
+ if (oldest === undefined)
2403
+ break;
2404
+ this.toolListsSent.delete(oldest);
2405
+ }
2406
+ let sent;
2407
+ try {
2408
+ sent = Promise.resolve(this.recorder.reportTools(serverName, tools, project));
2409
+ }
2410
+ catch {
2411
+ sent = Promise.resolve(false);
2412
+ }
2413
+ void sent
2414
+ .catch(() => false)
2415
+ .then((ok) => {
2416
+ if (this.toolListsSent.get(key) !== entry)
2417
+ return;
2418
+ entry.ok = ok === true;
2419
+ entry.at = Date.now();
2420
+ });
2421
+ return { ok: true };
2422
+ }
2052
2423
  /**
2053
2424
  * `POST /scenario/hello` — a running `bir-scenario` saying what it serves
2054
2425
  * (K3): on `initialize`, on `tools/list`, when its off state flips and every
@@ -2064,10 +2435,13 @@ export class ControlServer {
2064
2435
  const tools = Number.isFinite(toolsRaw) && toolsRaw > 0 ? Math.floor(toolsRaw) : 0;
2065
2436
  const off = body.off === true;
2066
2437
  const version = typeof body.version === "string" ? body.version.slice(0, 40) : undefined;
2438
+ // It serves `report_wrong_result` (runner 0.3.18 on; review item 19). An
2439
+ // older `bir-scenario` says nothing of it, and serves none.
2440
+ const report = body.report === true;
2067
2441
  const before = this.scenarioHellos.get(pid);
2068
2442
  // Re-inserted, so the map's order is oldest hello first.
2069
2443
  this.scenarioHellos.delete(pid);
2070
- this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version });
2444
+ this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version, ...(report ? { report } : {}) });
2071
2445
  while (this.scenarioHellos.size > DELIVERY_HELLO_MAX) {
2072
2446
  const oldest = this.scenarioHellos.keys().next().value;
2073
2447
  if (oldest === undefined)
@@ -2088,6 +2462,138 @@ export class ControlServer {
2088
2462
  }
2089
2463
  return { ok: true };
2090
2464
  }
2465
+ /** Remember a `report_wrong_result` pre (see {@link reportCalls}): a few, and only for a while. */
2466
+ noteReportCall(sessionId, toolUseId) {
2467
+ const now = Date.now();
2468
+ this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS && c.toolUseId !== toolUseId);
2469
+ this.reportCalls.push({ sessionId, toolUseId, at: now });
2470
+ if (this.reportCalls.length > 64)
2471
+ this.reportCalls = this.reportCalls.slice(-64);
2472
+ }
2473
+ /**
2474
+ * `POST /scenario/dispute` — the agent said, through `bir`'s
2475
+ * `report_wrong_result`, that a replay's results do not answer its request
2476
+ * (the service's review of calcscenarioautocorrect.md, item 19): the plan
2477
+ * took the wrong half of an "if … otherwise", or acted on the wrong item. A
2478
+ * wrong half is caught only when the agent says so, and until now there was
2479
+ * no way to say it but in an answer nobody might read.
2480
+ *
2481
+ * WHOSE PLAN. The session is the one whose `PreToolUse` for the tool carried
2482
+ * the request's `toolUseId` (`bir-scenario` forwards the host's id for the
2483
+ * call, as for `run_scenario`); without one, the one session where such a
2484
+ * call is waiting; without that either, the one session whose plan ran in
2485
+ * the last half hour. Never a guess between two. The plan is the newest one
2486
+ * that ran in that session's latest turn with a plan: this turn's, or the
2487
+ * one before it.
2488
+ *
2489
+ * WHAT GOES. `POST /scenarios/:scenarioId/dispute` with the reason, `by:
2490
+ * "agent"`, and the turn's execution ticket as `executionId` — the id its
2491
+ * report books under when the turn ends, so the service may well have the
2492
+ * ticket and not yet the row. Once per plan and turn: a second report of the
2493
+ * same plan is answered from the first. Nothing here undoes anything.
2494
+ *
2495
+ * Answers `{ ok, held?, why? }` for `bir-scenario` to put in one sentence.
2496
+ * Never throws, and is never slow beyond the service's own bound.
2497
+ */
2498
+ async onScenarioDispute(body) {
2499
+ if (offSource(this.opts.cwd))
2500
+ return { ok: false, why: "off" };
2501
+ const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, REPORT_REASON_MAX) : "";
2502
+ if (!reason)
2503
+ return { ok: false, why: "no_reason" };
2504
+ const session = this.sessionForDispute(body);
2505
+ if (!session || this.isSessionOff(session.sessionId))
2506
+ return { ok: false, why: "no_plan" };
2507
+ const state = this.disputedPlan(session);
2508
+ if (!state?.scenarioId) {
2509
+ logLine("replay.dispute_unplaced", {
2510
+ sess: session.sessionId,
2511
+ why: "the agent reported a wrong replay, and no plan ran in this session's latest turns — nothing is sent",
2512
+ });
2513
+ return { ok: false, why: "no_plan" };
2514
+ }
2515
+ const scenarioId = state.scenarioId;
2516
+ // Once per plan and turn: a second report — also one sent while the first
2517
+ // is on its way — is answered from the first. One that was not taken may
2518
+ // be tried again.
2519
+ const earlier = this.disputed.get(state);
2520
+ if (earlier) {
2521
+ const first = await earlier;
2522
+ return first.ok
2523
+ ? { ok: true, held: first.held === true, why: "already_reported", scenarioId }
2524
+ : { ok: false, held: false, ...(first.why ? { why: first.why } : {}), scenarioId };
2525
+ }
2526
+ const recorder = this.recorder;
2527
+ if (!isScenarioDisputer(recorder))
2528
+ return { ok: false, why: "no_service", scenarioId };
2529
+ const pending = recorder
2530
+ .disputeScenario(scenarioId, { reason, ...(state.ticket ? { executionId: state.ticket } : {}) }, { timeoutMs: 10_000 })
2531
+ .catch(() => ({ ok: false, why: "service_error" }));
2532
+ this.disputed.set(state, pending);
2533
+ const answer = await pending;
2534
+ if (!answer.ok && this.disputed.get(state) === pending)
2535
+ this.disputed.delete(state);
2536
+ logLine("replay.disputed", {
2537
+ sess: session.sessionId,
2538
+ scenario: scenarioId,
2539
+ ticket: state.ticket,
2540
+ ok: answer.ok,
2541
+ held: answer.held === true || undefined,
2542
+ why: answer.ok
2543
+ ? "the agent said this replay's results do not answer its request — reported to the service"
2544
+ : `the agent's report that this replay was wrong was not taken: ${answer.why ?? "no reason given"}`,
2545
+ });
2546
+ return {
2547
+ ok: answer.ok,
2548
+ held: answer.held === true,
2549
+ ...(answer.why ? { why: answer.why } : {}),
2550
+ scenarioId,
2551
+ };
2552
+ }
2553
+ /** The session a `/scenario/dispute` is about (see {@link onScenarioDispute}), or undefined. */
2554
+ sessionForDispute(body) {
2555
+ const now = Date.now();
2556
+ this.reportCalls = this.reportCalls.filter((c) => now - c.at < REPORT_CALL_TTL_MS);
2557
+ const toolUseId = typeof body.toolUseId === "string" && body.toolUseId ? body.toolUseId : undefined;
2558
+ let pick = toolUseId ? this.reportCalls.find((c) => c.toolUseId === toolUseId) : undefined;
2559
+ if (!pick && !toolUseId && new Set(this.reportCalls.map((c) => c.sessionId)).size === 1) {
2560
+ pick = this.reportCalls[this.reportCalls.length - 1];
2561
+ }
2562
+ if (pick) {
2563
+ const chosen = pick;
2564
+ this.reportCalls = this.reportCalls.filter((c) => c !== chosen);
2565
+ const session = this.sessions.get(chosen.sessionId);
2566
+ if (session)
2567
+ return session;
2568
+ }
2569
+ // No pre seen for it — a hook that did not arrive. The one session whose
2570
+ // plan ran lately, or none: two would be a guess.
2571
+ const recent = [...this.sessions.values()].filter((s) => {
2572
+ if (otherHost(s))
2573
+ return false;
2574
+ const state = this.disputedPlan(s);
2575
+ return state !== undefined && now - state.armedAt < REPORT_FALLBACK_MS;
2576
+ });
2577
+ return recent.length === 1 ? recent[0] : undefined;
2578
+ }
2579
+ /**
2580
+ * The plan a report of a wrong replay is about: the newest that ran — a
2581
+ * step of it did its work — in the session's latest turn with one, this
2582
+ * turn or the one before it.
2583
+ */
2584
+ disputedPlan(session) {
2585
+ for (const run of [session.run, session.previous]) {
2586
+ if (!run)
2587
+ continue;
2588
+ const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
2589
+ for (let i = states.length - 1; i >= 0; i -= 1) {
2590
+ const s = states[i];
2591
+ if (s.scenarioId && (s.stepsPinned > 0 || s.ranToEnd === true))
2592
+ return s;
2593
+ }
2594
+ }
2595
+ return undefined;
2596
+ }
2091
2597
  /**
2092
2598
  * Re-read whether this project configures the `bir` server — only when
2093
2599
  * ~/.claude.json or .mcp.json changed since the last read. A file the host is
@@ -2159,6 +2665,40 @@ export class ControlServer {
2159
2665
  recordsOnly: this.recordsOnly(session, run) || undefined,
2160
2666
  };
2161
2667
  }
2668
+ /**
2669
+ * The day this session's agent was told it is (issue 1212), read from its
2670
+ * transcript each time it is asked. A session of another host has none.
2671
+ */
2672
+ agentDayFor(session) {
2673
+ if (otherHost(session))
2674
+ return undefined;
2675
+ return () => agentDayOf(session.transcriptPath);
2676
+ }
2677
+ /**
2678
+ * What a run's finish says of its agent's day (the service's review of
2679
+ * calcscenarioautocorrect.md, item 1): the line of the session's system
2680
+ * prompt that gives the date — never Claude Code's own date reminder, which
2681
+ * is only this machine's day — and this machine's day when the turn's prompt
2682
+ * came. The service checks a corrected plan against this run, and reads its
2683
+ * prompt with this day, as a replay would have. Undefined with neither.
2684
+ */
2685
+ runAgentDay(session, run) {
2686
+ let said;
2687
+ if (!otherHost(session)) {
2688
+ try {
2689
+ const day = agentDayOf(session.transcriptPath);
2690
+ if (day?.source === "system" && day.line)
2691
+ said = day.line.slice(0, 400);
2692
+ }
2693
+ catch {
2694
+ // An unreadable transcript says no day; the machine's still goes.
2695
+ }
2696
+ }
2697
+ const local = run.promptDay;
2698
+ if (!said && !local)
2699
+ return undefined;
2700
+ return { ...(said ? { said } : {}), ...(local ? { local } : {}) };
2701
+ }
2162
2702
  /**
2163
2703
  * The earlier tool results the reading of new values may look at: this
2164
2704
  * prompt's own, from the transcript — and so none for a plan that is
@@ -2354,10 +2894,16 @@ export class ControlServer {
2354
2894
  // "LLM $/run", and it is the service's fallback baseline for a
2355
2895
  // scenario that has no samples yet. A run recorded at $0 makes
2356
2896
  // every later saving measured against nothing.
2897
+ //
2898
+ // The agent's day is read here, at the finish (review item 1):
2899
+ // its host writes a system prompt's "Today's date is …" into the
2900
+ // session only after the prompt hook.
2901
+ const agentDay = this.runAgentDay(session, run);
2357
2902
  this.recorder.finishRun(run.runId, answer || undefined, {
2358
2903
  durationMs: recordedMs,
2359
2904
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
2360
2905
  ...closing,
2906
+ ...(agentDay ? { agentDay } : {}),
2361
2907
  });
2362
2908
  });
2363
2909
  });
@@ -2487,11 +3033,21 @@ export class ControlServer {
2487
3033
  // that finished in three steps would be billed for everything the agent
2488
3034
  // did afterwards, and its saving would read as a loss.
2489
3035
  let end = nextMark;
3036
+ // A plan of the prompt stopped at its decision after a step ran: its
3037
+ // window ends at the hand-over (the service's segmentsbreakdown.md
3038
+ // SB-26), and the agent's decision and the half it took are not on its
3039
+ // bill. Every other plan of the prompt keeps its window: the answer is
3040
+ // still the plan's work.
3041
+ const atDecision = state.handover?.kind === "decision" && state.retiredMark !== undefined;
2490
3042
  if (state.armedBy === "intent") {
2491
3043
  windowCost = this.costSince(session, state.usageMark);
2492
3044
  windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
2493
3045
  end = state.retiredMark ?? nextMark;
2494
3046
  }
3047
+ else if (atDecision) {
3048
+ windowMs = (state.retiredAt ?? Date.now()) - run.startedAtMs;
3049
+ end = state.retiredMark;
3050
+ }
2495
3051
  else if (next) {
2496
3052
  windowMs = next.armedAt - run.startedAtMs;
2497
3053
  }
@@ -2513,11 +3069,15 @@ export class ControlServer {
2513
3069
  });
2514
3070
  if (!report)
2515
3071
  return;
2516
- if (state.armedBy === "intent") {
3072
+ if (state.armedBy === "intent" || atDecision) {
2517
3073
  logDetail("replay.done", {
2518
3074
  scenario: state.scenarioId ?? undefined,
2519
3075
  windowMs: Math.max(0, windowMs),
2520
- billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
3076
+ billedTo: atDecision
3077
+ ? "its decision"
3078
+ : state.retiredMark !== undefined
3079
+ ? "its own retire"
3080
+ : "the next arm or the turn",
2521
3081
  });
2522
3082
  }
2523
3083
  // The plan ran to its end, and no redo of the model's own was recorded:
@@ -2536,10 +3096,15 @@ export class ControlServer {
2536
3096
  sealedBy === "stop" &&
2537
3097
  run.lastToolUseId !== undefined &&
2538
3098
  !otherHost(session)) {
3099
+ // The plan's calls whole, beside the answer (review item 12). Taken
3100
+ // now — the seal lets go of them as soon as this returns — and read
3101
+ // only once there is an answer to send them with, off the hook's path.
3102
+ const kept = this.replay.turnCallsOf(state);
2539
3103
  const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
2540
3104
  .catch(() => "")
2541
3105
  .then((answer) => {
2542
- this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer } : report));
3106
+ const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
3107
+ this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
2543
3108
  });
2544
3109
  run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
2545
3110
  return;
@@ -2566,6 +3131,44 @@ export class ControlServer {
2566
3131
  this.queue.push(() => recorder.reportExecution(report));
2567
3132
  }
2568
3133
  }
3134
+ /**
3135
+ * The plan's own calls of the turn, whole, as an answered turn's report
3136
+ * carries them (the service's review of calcscenarioautocorrect.md, item
3137
+ * 12): what each step sent and what came back, as they were kept for the
3138
+ * turn (replay/controller.ts, `TurnCall`) — in a recording's own form,
3139
+ * redacted and capped at 64 KiB apiece, as a redo's copies are (AC2) —
3140
+ * where the report's own copies have 3,800 bytes. The calls of a called sub-task are
3141
+ * left out: their step numbers are the sub-task's own, and would read as the
3142
+ * plan's steps of the same number. `kept` is what the turn kept, taken while
3143
+ * the seal still had it; this runs after the answer's wait, off the hook's
3144
+ * path. The recorder cuts them to what one report may carry when it sends
3145
+ * them.
3146
+ */
3147
+ answerCallsOf(scenarioId, kept) {
3148
+ const calls = [];
3149
+ for (const c of kept) {
3150
+ if (scenarioId && c.scenarioId !== scenarioId)
3151
+ continue;
3152
+ let input = {};
3153
+ try {
3154
+ const parsed = JSON.parse(c.input);
3155
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3156
+ input = parsed;
3157
+ }
3158
+ catch {
3159
+ // Kept serialized and capped: it was JSON. Nothing to read otherwise.
3160
+ }
3161
+ const failed = c.error !== undefined;
3162
+ calls.push({
3163
+ stepIndex: c.stepIndex,
3164
+ toolName: c.toolName,
3165
+ input,
3166
+ output: (failed ? c.error : c.output) ?? "",
3167
+ ...(failed ? { isError: true } : {}),
3168
+ });
3169
+ }
3170
+ return calls;
3171
+ }
2569
3172
  /**
2570
3173
  * Seal the session's run now, and drain it to the service in the background.
2571
3174
  *
@@ -2631,7 +3234,7 @@ export class ControlServer {
2631
3234
  }
2632
3235
  }
2633
3236
  // ── hook events ──────────────────────────────────────────────────────────
2634
- onSessionStart(payload) {
3237
+ async onSessionStart(payload) {
2635
3238
  const session = this.ensureSession(payload);
2636
3239
  // A resumed session is a live one again, whatever its SessionEnd said.
2637
3240
  session.endedAt = undefined;
@@ -2648,7 +3251,79 @@ export class ControlServer {
2648
3251
  // in the background, whether the service answers again.
2649
3252
  if (isServiceProber(this.recorder))
2650
3253
  this.recorder.probeIfFailing();
2651
- return {};
3254
+ // A plan held for its owner saves nothing until somebody looks (review
3255
+ // item 8): one line for the person, never for the model. `bir off` and
3256
+ // BIR_OFF=1 never reach here — their session start is answered `{}`.
3257
+ const line = await this.attentionLine(session, payload);
3258
+ return line ? { systemMessage: line } : {};
3259
+ }
3260
+ /**
3261
+ * The line a session start shows the person when plans of this account
3262
+ * wait for their owner (`GET /scenarios/attention`; the service's review of
3263
+ * calcscenarioautocorrect.md, item 8), or undefined. A `systemMessage`:
3264
+ * Claude Code shows it to the person and adds nothing to the model's
3265
+ * context. Asked at most once in {@link ATTENTION_CACHE_MS} per account (in
3266
+ * {@link ATTENTION_RETRY_MS} when it could say nothing), waited for at most
3267
+ * {@link ATTENTION_WAIT_MS}, and never while the service is failing; any
3268
+ * failure says nothing. Not after a compaction — the
3269
+ * session is mid-work, and was told at its start — and never in a session
3270
+ * of another host, whose adapter shows a person nothing.
3271
+ */
3272
+ async attentionLine(session, payload) {
3273
+ if (payload.source === "compact" || otherHost(session))
3274
+ return undefined;
3275
+ const recorder = this.recorder;
3276
+ if (!isAttentionReader(recorder))
3277
+ return undefined;
3278
+ const account = accountOf(recorder);
3279
+ if (!account)
3280
+ return undefined;
3281
+ let plans = null;
3282
+ try {
3283
+ plans = await this.attentionFor(account, () => recorder.scenarioAttention({ timeoutMs: ATTENTION_WAIT_MS }));
3284
+ }
3285
+ catch {
3286
+ return undefined;
3287
+ }
3288
+ if (!plans || plans.length === 0)
3289
+ return undefined;
3290
+ const line = attentionText(plans);
3291
+ if (line)
3292
+ logLine("session.attention", { sess: session.sessionId, plans: plans.length, why: "plans wait for their owner — said to the person" });
3293
+ return line;
3294
+ }
3295
+ /**
3296
+ * This account's plans that need their owner: the cached answer while it is
3297
+ * fresh, else the service's, waited for no longer than
3298
+ * {@link ATTENTION_WAIT_MS}. An answer that comes later still fills the
3299
+ * cache for the next session start. One ask at a time per account.
3300
+ */
3301
+ async attentionFor(account, ask) {
3302
+ const cached = this.attention.get(account);
3303
+ const fresh = cached?.plans === null ? ATTENTION_RETRY_MS : ATTENTION_CACHE_MS;
3304
+ if (cached && !cached.pending && Date.now() - cached.at < fresh)
3305
+ return cached.plans;
3306
+ let pending = cached?.pending;
3307
+ if (!pending) {
3308
+ const asked = Promise.resolve()
3309
+ .then(ask)
3310
+ .catch(() => null);
3311
+ pending = asked;
3312
+ this.attention.set(account, { at: cached?.at ?? 0, plans: cached?.plans ?? null, pending: asked });
3313
+ void asked.then((plans) => {
3314
+ if (this.attention.get(account)?.pending !== asked)
3315
+ return;
3316
+ this.attention.set(account, { at: Date.now(), plans });
3317
+ });
3318
+ // A recorder that signs in as one account after another keeps a few, not all.
3319
+ while (this.attention.size > 16) {
3320
+ const oldest = this.attention.keys().next().value;
3321
+ if (oldest === undefined || oldest === account)
3322
+ break;
3323
+ this.attention.delete(oldest);
3324
+ }
3325
+ }
3326
+ return (await this.withinBudget(pending, ATTENTION_WAIT_MS)) ?? null;
2652
3327
  }
2653
3328
  async onPrompt(payload) {
2654
3329
  const session = this.ensureSession(payload);
@@ -2696,6 +3371,7 @@ export class ControlServer {
2696
3371
  // steps to gain a prompt is the wrong trade — and say so.
2697
3372
  open.promptSeen = true;
2698
3373
  open.input = input;
3374
+ open.promptDay = localDay();
2699
3375
  // This hook announces nothing, so no plan of this run may steer.
2700
3376
  open.matchExpired = true;
2701
3377
  this.retireUnannounced(session, open);
@@ -2715,6 +3391,7 @@ export class ControlServer {
2715
3391
  const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
2716
3392
  run.promptSeen = true;
2717
3393
  run.input = input;
3394
+ run.promptDay = localDay();
2718
3395
  session.prompts += 1;
2719
3396
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
2720
3397
  // With replay off there is nothing this hook could say, so it says nothing
@@ -2810,6 +3487,11 @@ export class ControlServer {
2810
3487
  if (toolUseId)
2811
3488
  (run.housekeepingUses ??= new Set()).add(toolUseId);
2812
3489
  logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
3490
+ // The agent is about to say a replay was wrong (review item 19): which
3491
+ // session asks is known here, and only here — `bir-scenario` is never
3492
+ // told its session. Its `/scenario/dispute` finds it by this id.
3493
+ if (toolName === REPORT_TOOL_NAME && toolUseId)
3494
+ this.noteReportCall(session.sessionId, toolUseId);
2813
3495
  // `bir setup` lets Claude Code run every `bir` tool without asking — but a
2814
3496
  // change saved or taken back with `force`, past the check that refused
2815
3497
  // it, is the person's decision (editSteps.md D3), so Claude Code asks
@@ -3040,8 +3722,12 @@ export class ControlServer {
3040
3722
  ? undefined
3041
3723
  : await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
3042
3724
  if (armed) {
3043
- if (!overran)
3725
+ if (!overran) {
3726
+ // The plan answers in this call's place: the agent's work from here
3727
+ // is that plan's, never the other half after a decision (SB-16).
3728
+ this.dropDecisionFragment(run, run.replay);
3044
3729
  return armed;
3730
+ }
3045
3731
  const late = run.replay;
3046
3732
  if (late && late.armedBy === "intent" && late.plan && !late.retired) {
3047
3733
  logLine("replay.decision", {
@@ -3058,9 +3744,14 @@ export class ControlServer {
3058
3744
  // 3. No scenario took over: the model's own work after a hand-over is
3059
3745
  // recorded as a fragment, starting with this call.
3060
3746
  // Not for the call an intent match just armed a plan beside: the plan
3061
- // runs next, and the fragment opens on the model's own work after it.
3062
- if (run.fragment?.state === "due" && !run.pendingDirective) {
3063
- await this.openFragment(session, run, reasoning, toolName, modelArgs);
3747
+ // runs next, and the fragment opens on the model's own work after it
3748
+ // — unless it was scheduled at a decision, whose fragment is the
3749
+ // other half only when the agent's own call opens it (SB-16).
3750
+ if (run.fragment?.state === "due") {
3751
+ if (!run.pendingDirective)
3752
+ await this.openFragment(session, run, reasoning, toolName, modelArgs);
3753
+ else
3754
+ this.dropDecisionFragment(run, run.pendingDirective.state);
3064
3755
  }
3065
3756
  }
3066
3757
  // The arguments that will actually run — which is what must be correlated and
@@ -3146,13 +3837,15 @@ export class ControlServer {
3146
3837
  : undefined;
3147
3838
  if (run.recording) {
3148
3839
  this.noteOwnCall(run, pinnedBy);
3840
+ // The host's id for the call ties this row to its answer (review item 7).
3841
+ const metadata = stepMarks({ pinnedBy, callId: toolUseId });
3149
3842
  this.queue.push(() => {
3150
3843
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3151
3844
  toolName,
3152
3845
  toolInput: serializeCapped(redact(args)),
3153
3846
  context: context || undefined,
3154
3847
  intent: thinkingIntent,
3155
- metadata: pinnedBy ? { pinnedBy } : undefined,
3848
+ metadata,
3156
3849
  });
3157
3850
  });
3158
3851
  }
@@ -3292,11 +3985,12 @@ export class ControlServer {
3292
3985
  run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
3293
3986
  if (run.recording) {
3294
3987
  const toolInput = serializeCapped(redact(action.input));
3988
+ const metadata = stepMarks({ pinnedBy, callId: toolUseId });
3295
3989
  this.queue.push(() => {
3296
3990
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3297
3991
  toolName,
3298
3992
  toolInput,
3299
- metadata: pinnedBy ? { pinnedBy } : undefined,
3993
+ metadata,
3300
3994
  });
3301
3995
  });
3302
3996
  }
@@ -3379,12 +4073,14 @@ export class ControlServer {
3379
4073
  if (run.recording) {
3380
4074
  this.noteOwnCall(run);
3381
4075
  const toolInput = serializeCapped(redact(modelArgs));
4076
+ const metadata = stepMarks({ callId: toolUseId });
3382
4077
  this.queue.push(() => {
3383
4078
  this.recorder.recordToolSelected(run.runId, pair.selected, {
3384
4079
  toolName,
3385
4080
  toolInput,
3386
4081
  context: words.context || undefined,
3387
4082
  intent: words.thinking,
4083
+ metadata,
3388
4084
  });
3389
4085
  });
3390
4086
  }
@@ -3653,14 +4349,19 @@ export class ControlServer {
3653
4349
  return [...out];
3654
4350
  }
3655
4351
  /**
3656
- * Per recording a plan of this turn came from, how far that plan got
3657
- * (segmented.md R-OUT-6).
4352
+ * Per plan of this turn, its recording and how far the plan got in its own
4353
+ * chain (segmented.md R-OUT-6).
3658
4354
  *
3659
4355
  * The server uses it to refuse a segment that starts *before* where this turn
3660
4356
  * already is: re-running steps the turn has just done is worse than not
3661
- * replaying at all. The largest position per recording wins, and a plan
3662
- * declined for anything but a known-bad first step reports nothing — it ran
3663
- * none of the recording, so it blocks none of it.
4357
+ * replaying at all. The position is the plan's own: the step it handed over
4358
+ * at (its decision included), or its last step when it ran to its end. The
4359
+ * server maps it onto the recording — a segment's first position, a
4360
+ * decision's step before it — and takes the largest per recording (the
4361
+ * service's segmentsbreakdown.md D18). One entry per plan, so that two plans
4362
+ * of one recording, whose own positions are on different scales, never
4363
+ * hide each other. A plan declined for anything but a known-bad first step
4364
+ * reports nothing — it ran none of the recording, so it blocks none of it.
3664
4365
  *
3665
4366
  * Rebuilt on every probe rather than cached: a plan that hands over between
3666
4367
  * two calls changes the answer.
@@ -3693,15 +4394,15 @@ export class ControlServer {
3693
4394
  }
3694
4395
  if (position === undefined)
3695
4396
  continue;
3696
- const seen = best.get(recording);
4397
+ const seen = best.get(state.scenarioId);
3697
4398
  if (!seen || position > seen.position) {
3698
- best.set(recording, { position, scenarioId: state.scenarioId });
4399
+ best.set(state.scenarioId, { runId: recording, position });
3699
4400
  }
3700
4401
  }
3701
- return [...best].map(([runId, v]) => ({
3702
- runId,
4402
+ return [...best].map(([scenarioId, v]) => ({
4403
+ runId: v.runId,
3703
4404
  position: v.position,
3704
- scenarioId: v.scenarioId,
4405
+ scenarioId,
3705
4406
  }));
3706
4407
  }
3707
4408
  /**
@@ -3785,7 +4486,10 @@ export class ControlServer {
3785
4486
  stepPosition: run.ordering.next,
3786
4487
  excludeRuns: this.excludeRunsFor(run),
3787
4488
  ranThrough: this.ranThroughFor(run),
3788
- ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
4489
+ // Segments are asked for only where one can arm: a probe that is
4490
+ // waited on, in Claude Code (below). Any other probe gets the
4491
+ // service's `segmentWouldArm` instead, logged as observe-only.
4492
+ ...(this.replay.segmentArm && this.intentArming() && !otherHost(session) ? { acceptSegments: true } : {}),
3789
4493
  supportsCalls: true,
3790
4494
  // Left out for Claude Code: a missing framework means Claude Code to
3791
4495
  // the service, which matches a step only inside its own host.
@@ -3841,7 +4545,11 @@ export class ControlServer {
3841
4545
  // seconds, and this call is waiting. A plan not armed in time is dropped
3842
4546
  // unused — it was never announced, so it never steers — and its ticket is
3843
4547
  // 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)));
4548
+ const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
4549
+ liveCall: { toolName, toolInput },
4550
+ recentResults: this.recentResultsFor(session, "call"),
4551
+ agentDay: this.agentDayFor(session),
4552
+ }, this.hostContext(session, run)));
3845
4553
  const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
3846
4554
  if (state === undefined)
3847
4555
  return undefined; // it threw, and said so
@@ -3869,6 +4577,9 @@ export class ControlServer {
3869
4577
  // the jail it may not leave (replay/builtins.ts). The session's own cwd
3870
4578
  // from the hook payloads, never the daemon's.
3871
4579
  state.sessionCwd = session.cwd ?? this.opts.cwd;
4580
+ // The request this plan serves, for a note at its decision (SB-18): the
4581
+ // turn's prompt. `arm` was given this step's intent instead.
4582
+ state.request = run.input || undefined;
3872
4583
  this.declineWithoutDeliveryTool(state);
3873
4584
  if (!state.plan) {
3874
4585
  // A declined intent hit reports nothing: the turn's cost is not a
@@ -4034,7 +4745,35 @@ export class ControlServer {
4034
4745
  const h = state?.handover;
4035
4746
  if (!h || !state?.scenarioId)
4036
4747
  return;
4037
- run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
4748
+ run.fragment = {
4749
+ scenarioId: state.scenarioId,
4750
+ stepIndex: h.stepIndex,
4751
+ state: "due",
4752
+ ...(h.kind === "decision" ? { decision: true } : {}),
4753
+ };
4754
+ }
4755
+ /**
4756
+ * Drop a fragment scheduled at a decision (the service's
4757
+ * segmentsbreakdown.md SB-16) when a plan the intent check armed takes the
4758
+ * agent's next call: what follows is that plan's half, not the other one,
4759
+ * and recorded as the other half it would become a wrong segment C. Cleared
4760
+ * rather than ended, so a later hand-over of that plan schedules its own
4761
+ * fragment, as any plan's does in a turn with none. Any other fragment is
4762
+ * left as it is.
4763
+ */
4764
+ dropDecisionFragment(run, by) {
4765
+ const f = run.fragment;
4766
+ if (!f || f.state !== "due" || !f.decision)
4767
+ return;
4768
+ run.fragment = undefined;
4769
+ logLine("run.fragment_dropped", {
4770
+ run: run.runId,
4771
+ scenario: f.scenarioId,
4772
+ step: f.stepIndex,
4773
+ by: by?.scenarioId ?? undefined,
4774
+ kind: by?.kind,
4775
+ why: "a plan the intent check armed takes the agent's next step after the decision — that work is not the other half",
4776
+ });
4038
4777
  }
4039
4778
  /**
4040
4779
  * Open the fragment run and record from this call on (fallbk.md D7).
@@ -4054,9 +4793,14 @@ export class ControlServer {
4054
4793
  return;
4055
4794
  const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
4056
4795
  const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
4796
+ // The agent's own call after a plan's decision (SB-16): this runner's word
4797
+ // that the work that follows is the agent's choice, not a plan's half.
4798
+ // The service reads it beside its own mark of the fragment (the
4799
+ // service's segmentsbreakdown.md D14).
4800
+ const decisionOf = f.decision ? { scenarioId: f.scenarioId, stepIndex: f.stepIndex } : undefined;
4057
4801
  // The fragment is a run of the same host as the turn it continues.
4058
4802
  const framework = frameworkOf(session);
4059
- const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
4803
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf, ...(decisionOf ? { decisionOf } : {}) }, framework ? { fallbackOf, framework } : { fallbackOf });
4060
4804
  for (const c of run.correlations.values()) {
4061
4805
  c.settled = true;
4062
4806
  if (c.fallback)
@@ -4073,6 +4817,7 @@ export class ControlServer {
4073
4817
  run: id,
4074
4818
  scenario: f.scenarioId,
4075
4819
  step: f.stepIndex,
4820
+ decision: f.decision || undefined,
4076
4821
  why: "recording the agent's own work after the scenario handed over",
4077
4822
  });
4078
4823
  if (!isRunCreationAware(this.recorder))
@@ -4243,17 +4988,20 @@ export class ControlServer {
4243
4988
  // service's row schema takes a number or nothing.
4244
4989
  ...(call.chainRevision !== null ? { chainRevision: call.chainRevision } : {}),
4245
4990
  };
4991
+ // One id on both rows, and the failure marked (review item 7).
4992
+ const callId = newCallId();
4993
+ const failed = call.error !== undefined;
4246
4994
  this.queue.push(() => {
4247
4995
  this.recorder.recordToolSelected(id, pair.selected, {
4248
4996
  toolName: call.toolName,
4249
4997
  toolInput: call.input,
4250
- metadata: { byPlan },
4998
+ metadata: { byPlan, callId },
4251
4999
  });
4252
5000
  this.recorder.recordToolResponse(id, pair.response, {
4253
5001
  toolName: call.toolName,
4254
- toolOutput: call.error === undefined ? call.output : undefined,
5002
+ toolOutput: failed ? undefined : call.output,
4255
5003
  toolError: call.error,
4256
- metadata: { byPlan },
5004
+ metadata: failed ? { byPlan, callId, isError: true } : { byPlan, callId },
4257
5005
  });
4258
5006
  });
4259
5007
  }
@@ -4522,11 +5270,15 @@ export class ControlServer {
4522
5270
  const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
4523
5271
  if (run.recording) {
4524
5272
  const output = serializeCapped(redact(payload.tool_response ?? {}));
5273
+ // Its call's id, and whether it failed — the host said so, or the
5274
+ // tool's own MCP result did (review item 7).
5275
+ const metadata = stepMarks({ callId: toolUseId, isError: failed || resultSaysError(payload.tool_response) });
4525
5276
  this.queue.push(() => {
4526
5277
  this.recorder.recordToolResponse(run.runId, responseIndex, {
4527
5278
  toolName,
4528
5279
  toolOutput: failed ? undefined : output,
4529
5280
  toolError: failed ? this.errorText(payload) : undefined,
5281
+ metadata,
4530
5282
  });
4531
5283
  });
4532
5284
  }
@@ -4850,6 +5602,13 @@ export class ControlServer {
4850
5602
  const hostPid = Number(body.ppid);
4851
5603
  if (Number.isInteger(hostPid) && hostPid > 0)
4852
5604
  this.replay.work.bindSiblings(hostPid, call.sessionId);
5605
+ // The `bir-scenario` asking is this session's own: whether it serves the
5606
+ // report tool is its word, not any hello's (review item 19). An older one
5607
+ // says nothing, and serves none.
5608
+ if (!otherHost(this.sessions.get(call.sessionId))) {
5609
+ const reports = body.reports === true;
5610
+ state.reportTool = () => reports;
5611
+ }
4853
5612
  this.runningPlans.add(state);
4854
5613
  // The session is busy for as long as its plan runs, and sends no hook
4855
5614
  // meanwhile: the host is waiting on this very call.
@@ -5034,6 +5793,8 @@ export class ControlServer {
5034
5793
  result: report.result,
5035
5794
  isError: report.isError,
5036
5795
  errorMessage: report.errorMessage,
5796
+ // The id a hook injected, when one did; `record` mints one otherwise.
5797
+ callId: report.callId,
5037
5798
  });
5038
5799
  logDetail("proxy.step", {
5039
5800
  run: run.runId,
@@ -5285,6 +6046,9 @@ export class ControlServer {
5285
6046
  // from the map (segmented.md R-INTENT-7).
5286
6047
  pinnedBy: correlation.pinnedBy,
5287
6048
  intent: correlation.intent,
6049
+ // The host's id for the call when it named one, else the one minted for
6050
+ // it here: what ties the call to its answer (review item 7).
6051
+ callId: correlation.toolUseId || correlation.callId,
5288
6052
  });
5289
6053
  logDetail("proxy.step.merged", {
5290
6054
  run: run.runId,
@@ -5294,7 +6058,16 @@ export class ControlServer {
5294
6058
  });
5295
6059
  return pair;
5296
6060
  }
5297
- /** The one place a tool step pair is written. Redaction and caps live here. */
6061
+ /**
6062
+ * The one place a tool step pair is written. Redaction and caps live here.
6063
+ *
6064
+ * Both rows carry the call's id (`metadata.callId`, review item 7) — the
6065
+ * caller's, or one minted here for a call no hook named — and the answer
6066
+ * of a call that failed says so (`metadata.isError`). The service pairs a
6067
+ * call with its answer by that id, where it used to pair them by tool name
6068
+ * in the order they came: two calls of one tool at once, answered out of
6069
+ * order, took each other's answers.
6070
+ */
5298
6071
  record(run, pair, d) {
5299
6072
  if (!run.recording)
5300
6073
  return;
@@ -5304,18 +6077,23 @@ export class ControlServer {
5304
6077
  const toolError = d.isError
5305
6078
  ? (d.errorMessage ?? serializeCapped(redact(d.result)))
5306
6079
  : undefined;
6080
+ const callId = d.callId || newCallId();
6081
+ // Failed, as its reporter said or as its own MCP result says — a hook's
6082
+ // fallback view carries the result only. The row is otherwise as it was.
6083
+ const failedMark = d.isError || resultSaysError(d.result);
5307
6084
  this.queue.push(() => {
5308
6085
  this.recorder.recordToolSelected(run.runId, pair.selected, {
5309
6086
  toolName: d.toolName,
5310
6087
  toolInput,
5311
6088
  context: d.context,
5312
6089
  intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
5313
- metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
6090
+ metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy, callId } : { callId },
5314
6091
  });
5315
6092
  this.recorder.recordToolResponse(run.runId, pair.response, {
5316
6093
  toolName: d.toolName,
5317
6094
  toolOutput,
5318
6095
  toolError,
6096
+ metadata: failedMark ? { callId, isError: true } : { callId },
5319
6097
  });
5320
6098
  });
5321
6099
  }