@basein/runner 0.3.9 → 0.3.11

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 (50) hide show
  1. package/README.md +47 -6
  2. package/dist/auth/client.d.ts +15 -0
  3. package/dist/auth/client.js +34 -12
  4. package/dist/bin/bir-hooks.d.ts +1 -1
  5. package/dist/bin/bir-hooks.js +1 -1
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +60 -6
  8. package/dist/bin/investigate.d.ts +5 -0
  9. package/dist/bin/investigate.js +117 -15
  10. package/dist/bin/setup.d.ts +12 -0
  11. package/dist/bin/setup.js +185 -56
  12. package/dist/bin/update.d.ts +81 -0
  13. package/dist/bin/update.js +194 -0
  14. package/dist/config/adapters/claude-code.d.ts +18 -1
  15. package/dist/config/adapters/claude-code.js +30 -3
  16. package/dist/config/generate.d.ts +14 -0
  17. package/dist/control/daemon.d.ts +26 -1
  18. package/dist/control/daemon.js +53 -3
  19. package/dist/control/pin-guard.d.ts +6 -1
  20. package/dist/control/pin-guard.js +15 -1
  21. package/dist/control/recorder-keeper.js +2 -1
  22. package/dist/control/server.d.ts +205 -2
  23. package/dist/control/server.js +766 -56
  24. package/dist/control/transcript.d.ts +4 -0
  25. package/dist/control/transcript.js +4 -0
  26. package/dist/proxy/session.js +1 -0
  27. package/dist/record/housekeeping.d.ts +7 -1
  28. package/dist/record/housekeeping.js +11 -3
  29. package/dist/record/recorder.d.ts +34 -0
  30. package/dist/record/remote-recorder.d.ts +6 -0
  31. package/dist/record/remote-recorder.js +31 -4
  32. package/dist/record/switchable-recorder.js +1 -0
  33. package/dist/replay/controller.d.ts +139 -3
  34. package/dist/replay/controller.js +248 -24
  35. package/dist/replay/derive.js +2 -0
  36. package/dist/replay/handover.d.ts +13 -1
  37. package/dist/replay/handover.js +19 -0
  38. package/dist/replay/safety.d.ts +15 -4
  39. package/dist/replay/safety.js +19 -6
  40. package/dist/replay/source-run.js +2 -1
  41. package/dist/util/service-headers.d.ts +24 -0
  42. package/dist/util/service-headers.js +47 -0
  43. package/docs/BaseInstRunner.md +115 -1
  44. package/docs/calculatedReplay.md +209 -32
  45. package/docs/calculatedReplayGuide.md +133 -12
  46. package/docs/installRun.md +91 -10
  47. package/docs/quickstart.md +64 -9
  48. package/package.json +1 -1
  49. package/production.md +83 -19
  50. package/scripts/install.ps1 +25 -1
@@ -33,12 +33,12 @@ import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
34
  import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
- import { redact } from "../record/redact.js";
37
- import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateStrings } from "../record/truncate.js";
36
+ import { redact, redactText } from "../record/redact.js";
37
+ import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
38
38
  import { StepQueue } from "../record/queue.js";
39
39
  import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
40
40
  import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
41
- import { calculateCostUsd } from "../replay/pricing.js";
41
+ import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
42
42
  import { logDetail, logLine, logOnce, errText } from "../util/log.js";
43
43
  import { journal } from "../util/journal.js";
44
44
  import { packageVersion } from "../util/version.js";
@@ -64,7 +64,9 @@ const PROXY_REPORT_GRACE_MS = 1_500;
64
64
  */
65
65
  const HOOK_DEADLINE_MS = {
66
66
  "/session/start": 2_000,
67
- "/session/prompt": 7_000,
67
+ // A second past {@link PROMPT_WAIT_MS}, and a second and a half inside the
68
+ // 10 s Claude Code gives the hook.
69
+ "/session/prompt": 8_500,
68
70
  "/tool/pre": 12_000,
69
71
  "/tool/post": 2_000,
70
72
  "/session/agent": 2_000,
@@ -79,10 +81,19 @@ const PREVIEW_RESERVE_MS = 500;
79
81
  /**
80
82
  * What `UserPromptSubmit` may spend on replay: the similar-prompt match plus
81
83
  * any derivation a matched scenario with a target needs before its directive
82
- * can go out (segmented.md R-PARAM-4). Below the route's 7 s deadline, so the
83
- * decision to steer is always taken here and never by the guard.
84
+ * can go out (segmented.md R-PARAM-4). Below the route's 8.5 s deadline, so
85
+ * the decision to steer is always taken here and never by the guard.
86
+ *
87
+ * LONGER THAN THE SERVICE MAY TAKE TO READ, ON PURPOSE. The service gives a
88
+ * reading 6 s (its RUNNER_DERIVE_TIMEOUT_MS) and answers either way, so with
89
+ * the match and the round trip an answer can be 6.5 s after the prompt. At
90
+ * 6 s this wait ended before that answer could arrive: on 2026-10-08 a
91
+ * reading that took 6.0 s armed its plan 0.3 s after the prompt had been
92
+ * answered, and a good 20-step plan was thrown away (`arm_timeout`) for a
93
+ * turn the agent then spent 39 s on. The wait is only ever this long while a
94
+ * matched plan's values are still being read.
84
95
  */
85
- const PROMPT_WAIT_MS = 6_000;
96
+ const PROMPT_WAIT_MS = 7_500;
86
97
  /**
87
98
  * How long a `PreToolUse` waits for the intent probe when that probe may arm a
88
99
  * plan in the call's place (`BIR_INTENT_ARM_BUDGET_MS`). A clear match or a
@@ -257,6 +268,137 @@ function isOn(value) {
257
268
  return true;
258
269
  return typeof value === "string" && ["1", "true"].includes(value.trim().toLowerCase());
259
270
  }
271
+ /** The longest text a host may name itself, its version, its adapter or its scope by. */
272
+ const HOST_TEXT_MAX = 64;
273
+ /**
274
+ * The host a payload names, or undefined when it names none: a `host` whose
275
+ * `app` is missing, empty or not a text is ignored, and the session is a
276
+ * Claude Code session as before. Only the four known keys are kept. `app`,
277
+ * `version` and `adapter` are cut to {@link HOST_TEXT_MAX}; a scope is never
278
+ * cut — see {@link badScope}.
279
+ */
280
+ function hostOf(payload) {
281
+ const h = payload.host;
282
+ if (!h || typeof h !== "object")
283
+ return undefined;
284
+ const app = typeof h.app === "string" ? h.app.trim().toLowerCase().slice(0, HOST_TEXT_MAX) : "";
285
+ if (!app)
286
+ return undefined;
287
+ const host = { app };
288
+ if (typeof h.version === "string" && h.version)
289
+ host.version = h.version.slice(0, HOST_TEXT_MAX);
290
+ if (typeof h.adapter === "string" && h.adapter)
291
+ host.adapter = h.adapter.slice(0, HOST_TEXT_MAX);
292
+ if (typeof h.scope === "string" && h.scope)
293
+ host.scope = h.scope;
294
+ return host;
295
+ }
296
+ /**
297
+ * Whether a payload's host names a scope the recorder cannot keep as it is:
298
+ * there, but empty, longer than {@link HOST_TEXT_MAX} or not a text.
299
+ *
300
+ * Such a scope is never cut and never dropped. The scope is part of what
301
+ * keeps one tenant's plans away from another's: cut or dropped, the run
302
+ * would land in another scope's pool.
303
+ */
304
+ function badScope(payload) {
305
+ const h = payload.host;
306
+ if (!h || typeof h !== "object" || !hostOf(payload))
307
+ return false;
308
+ if (h.scope === undefined)
309
+ return false;
310
+ return typeof h.scope !== "string" || h.scope.length === 0 || h.scope.length > HOST_TEXT_MAX;
311
+ }
312
+ /** Whether `session` said its host is not Claude Code. */
313
+ function otherHost(session) {
314
+ return session?.host !== undefined && session.host.app !== "claude-code";
315
+ }
316
+ /**
317
+ * The `framework` text of a run in a session of another host — its `app`, or
318
+ * `app:scope` when it named a scope — and undefined for Claude Code, whose
319
+ * runs keep the recorder's own framework. Up to 129 characters.
320
+ */
321
+ function frameworkOf(session) {
322
+ if (!otherHost(session))
323
+ return undefined;
324
+ const host = session.host;
325
+ return host.scope ? `${host.app}:${host.scope}` : host.app;
326
+ }
327
+ /** What a host may hand over of one text: a transcript index keeps no more of a message. */
328
+ const HOST_TEXT_CHARS = 16 * 1024;
329
+ /** Tool names a host may declare as sinks, or as held by its agent. */
330
+ const HOST_NAMES_MAX = 4_096;
331
+ /** Models one run's usage may name. */
332
+ const HOST_USAGE_MAX = 32;
333
+ /** A payload text as the recorder takes it: a non-empty string, cut to `max` characters. */
334
+ function hostText(value, max = HOST_TEXT_CHARS) {
335
+ if (typeof value !== "string")
336
+ return undefined;
337
+ const text = value.trim();
338
+ return text ? text.slice(0, max) : undefined;
339
+ }
340
+ /** A payload's list of tool names, or undefined when it is not a list. */
341
+ function hostNames(value) {
342
+ if (!Array.isArray(value))
343
+ return undefined;
344
+ const names = [];
345
+ for (const item of value.slice(0, HOST_NAMES_MAX)) {
346
+ // A name alone, or `{ name }`, which is how an agent's tools are listed.
347
+ const name = typeof item === "string" ? item : item?.name;
348
+ if (typeof name === "string" && name)
349
+ names.push(name.slice(0, 200));
350
+ }
351
+ return names;
352
+ }
353
+ /** The sinks a host declared with its prompt. Always an object: see `HostSinks`. */
354
+ function hostSinksOf(payload) {
355
+ const sinks = payload.sinks;
356
+ if (!sinks || typeof sinks !== "object")
357
+ return {};
358
+ return { command: hostNames(sinks.command) ?? [], write: hostNames(sinks.write) ?? [] };
359
+ }
360
+ /**
361
+ * A payload's `usage`, under the names the price table takes. The host sends
362
+ * Claude's own names, the ones a transcript line carries, and they are mapped
363
+ * here as the transcript's are folded: a missing count is 0. Undefined when
364
+ * the payload carries no usable list — which is "no usage", never zero.
365
+ */
366
+ function hostUsageOf(payload) {
367
+ if (!Array.isArray(payload.usage))
368
+ return undefined;
369
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
370
+ const out = [];
371
+ for (const item of payload.usage.slice(0, HOST_USAGE_MAX)) {
372
+ if (!item || typeof item !== "object")
373
+ continue;
374
+ const u = item;
375
+ out.push({
376
+ model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
377
+ inputTokens: count(u.input_tokens),
378
+ outputTokens: count(u.output_tokens),
379
+ cacheReadTokens: count(u.cache_read_input_tokens),
380
+ cacheCreationTokens: count(u.cache_creation_input_tokens),
381
+ });
382
+ }
383
+ return out;
384
+ }
385
+ /**
386
+ * Price a turn's token usage. Measured only when tokens were used and every
387
+ * model that used them has a price: a model outside the table costs "$0",
388
+ * and a zero called measured is booked as a saving nobody made.
389
+ */
390
+ function priceUsage(usage) {
391
+ const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
392
+ const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
393
+ return { usd, measured: usage.length > 0 && unpriced.length === 0, unpriced };
394
+ }
395
+ /**
396
+ * What `/health` answers as `hostFields`: how much of another host's protocol
397
+ * this recorder understands. An adapter reads it to know which fields it may
398
+ * rely on — with 1 it records; from 2 it may also ask for a plan and step
399
+ * through it, because the checks a replay in another host needs are here.
400
+ */
401
+ export const HOST_FIELDS = 2;
260
402
  /** One MCP call, as seen from up to two sides. */
261
403
  /**
262
404
  * Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
@@ -327,6 +469,13 @@ export class ControlServer {
327
469
  * SessionStart.
328
470
  */
329
471
  offSessions = new Map();
472
+ /**
473
+ * Sessions of another host that named a scope the recorder cannot keep as
474
+ * it is (see {@link badScope}) → when last heard. Nothing of such a session
475
+ * is recorded or replayed: every hook of it is answered `{}`. Kept apart
476
+ * from {@link sessions}, so it never holds a run or takes a proxy's step.
477
+ */
478
+ refusedSessions = new Map();
330
479
  /**
331
480
  * Proxy ids that registered with *this* recorder (UPG-7). A poll from any
332
481
  * other id is a proxy from before this recorder started — a 0.2.12 proxy
@@ -421,6 +570,7 @@ export class ControlServer {
421
570
  if (!this.lossy)
422
571
  logLine("run.lossy", { why: "step queue overflowed", dropped });
423
572
  this.lossy = true;
573
+ this.markOpenRunsLossy();
424
574
  },
425
575
  onError: (err) => logLine("control.step_failed", { error: errText(err) }),
426
576
  });
@@ -472,7 +622,7 @@ export class ControlServer {
472
622
  this.heldPolls.clear();
473
623
  for (const session of this.sessions.values()) {
474
624
  if (session.run && !session.run.finished) {
475
- this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close });
625
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close, reason: "recorder_closed" });
476
626
  }
477
627
  }
478
628
  // Bounded: a recorder being stopped must stop, and a service that is down
@@ -567,6 +717,9 @@ export class ControlServer {
567
717
  url: this.address?.url,
568
718
  wrappedServers: [...this.wrapped],
569
719
  hooksVersion: packageVersion(),
720
+ // How much of another host's protocol this recorder understands: what
721
+ // an adapter reads before it relies on a field (see HOST_FIELDS).
722
+ hostFields: HOST_FIELDS,
570
723
  registeredProxies: [...this.registrations].map(([serverName, r]) => ({
571
724
  serverName,
572
725
  pid: r.pid,
@@ -947,6 +1100,9 @@ export class ControlServer {
947
1100
  bytes: err.bytes,
948
1101
  why: "read and discarded, answered {} — this event is not recorded",
949
1102
  });
1103
+ // Whose event it was went unread with the body: every run open now
1104
+ // may be the one that lost it.
1105
+ this.markOpenRunsLossy();
950
1106
  answer({});
951
1107
  return;
952
1108
  }
@@ -964,6 +1120,12 @@ export class ControlServer {
964
1120
  answer({});
965
1121
  return;
966
1122
  }
1123
+ // A session of another host that named a scope the recorder cannot
1124
+ // keep: nothing of it is recorded or replayed.
1125
+ if (this.hostRefused(payload)) {
1126
+ answer({});
1127
+ return;
1128
+ }
967
1129
  const out = await this.dispatchHook(route, payload, (fn) => {
968
1130
  late = fn;
969
1131
  });
@@ -1055,8 +1217,48 @@ export class ControlServer {
1055
1217
  }
1056
1218
  this.offSessions.set(sessionId, { at: Date.now() });
1057
1219
  const session = this.sessions.get(sessionId);
1058
- if (session?.run && !session.run.finished)
1059
- this.closeRun(session, { detach: true });
1220
+ if (session?.run && !session.run.finished) {
1221
+ this.closeRun(session, { detach: true, reason: "session_off" });
1222
+ }
1223
+ }
1224
+ /**
1225
+ * Whether this hook belongs to a session of another host whose scope the
1226
+ * recorder cannot keep as it is (see {@link badScope}), and so is answered
1227
+ * `{}` unprocessed — every hook of it, like a session that is off. One
1228
+ * line says so, once for the session.
1229
+ *
1230
+ * A session that already has a host keeps it: a later payload cannot turn
1231
+ * a run that is being recorded into one that is not.
1232
+ */
1233
+ hostRefused(payload) {
1234
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
1235
+ if (sessionId && this.sessions.get(sessionId)?.host)
1236
+ return false;
1237
+ if (badScope(payload)) {
1238
+ if (!sessionId || !this.refusedSessions.has(sessionId)) {
1239
+ logLine("session.host_refused", {
1240
+ sess: sessionId,
1241
+ host: hostOf(payload)?.app,
1242
+ why: `its scope is empty, longer than ${HOST_TEXT_MAX} characters or not a text — a scope is never cut or dropped, ` +
1243
+ "because the run would land in another scope's plans; nothing of this session is recorded or replayed",
1244
+ fix: `give the adapter a scope of 1 to ${HOST_TEXT_MAX} characters, or none`,
1245
+ });
1246
+ }
1247
+ if (sessionId)
1248
+ this.refusedSessions.set(sessionId, Date.now());
1249
+ return true;
1250
+ }
1251
+ if (!sessionId || !this.refusedSessions.has(sessionId))
1252
+ return false;
1253
+ this.refusedSessions.set(sessionId, Date.now());
1254
+ return true;
1255
+ }
1256
+ /** Flag every run open right now as having lost steps (see `RunState.lossy`). */
1257
+ markOpenRunsLossy() {
1258
+ for (const session of this.sessions.values()) {
1259
+ if (session.run && !session.run.finished)
1260
+ session.run.lossy = true;
1261
+ }
1060
1262
  }
1061
1263
  /** Whether `sessionId` is a session started with `BIR_OFF=1`. */
1062
1264
  isSessionOff(sessionId) {
@@ -1223,6 +1425,11 @@ export class ControlServer {
1223
1425
  session.transcriptPath = payload.transcript_path;
1224
1426
  if (payload.cwd)
1225
1427
  session.cwd = payload.cwd;
1428
+ // The first host a session names is the one it keeps. Any route may carry
1429
+ // it: a recorder that restarted in the middle of a run learns it from the
1430
+ // run's next hook, and so opens no Claude Code run for it.
1431
+ if (!session.host)
1432
+ session.host = hostOf(payload);
1226
1433
  return session;
1227
1434
  }
1228
1435
  /**
@@ -1287,7 +1494,7 @@ export class ControlServer {
1287
1494
  idleMin: Math.round((now - session.lastSeenAt) / 60_000),
1288
1495
  why: "no hook for this session in a long time — the turn was interrupted and never resumed",
1289
1496
  });
1290
- this.closeRun(session, { detach: false });
1497
+ this.closeRun(session, { detach: false, reason: "idle" });
1291
1498
  }
1292
1499
  if (session.run && !session.run.finished)
1293
1500
  continue;
@@ -1327,6 +1534,16 @@ export class ControlServer {
1327
1534
  if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid))
1328
1535
  this.scenarioHellos.delete(pid);
1329
1536
  }
1537
+ for (const [sessionId, at] of this.refusedSessions) {
1538
+ if (now - at >= t.forgetMs)
1539
+ this.refusedSessions.delete(sessionId);
1540
+ }
1541
+ if (this.refusedSessions.size > OFF_SESSIONS_MAX) {
1542
+ const oldest = [...this.refusedSessions].sort((a, b) => a[1] - b[1]);
1543
+ for (const [sessionId] of oldest.slice(0, this.refusedSessions.size - OFF_SESSIONS_MAX)) {
1544
+ this.refusedSessions.delete(sessionId);
1545
+ }
1546
+ }
1330
1547
  }
1331
1548
  /** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
1332
1549
  forgetSession(session) {
@@ -1339,15 +1556,23 @@ export class ControlServer {
1339
1556
  }
1340
1557
  logDetail("session.forgotten", { sess: session.sessionId });
1341
1558
  }
1342
- /** Open a run for `session`, or return the open one. */
1343
- ensureRun(session, input = "") {
1559
+ /**
1560
+ * Open a run for `session`, or return the open one.
1561
+ *
1562
+ * `asked` is what the prompt that opens the run asked for, in a session of
1563
+ * another host: whether its adapter wants a plan (`HookPayload.replay`).
1564
+ */
1565
+ ensureRun(session, input = "", asked = {}) {
1344
1566
  if (session.run && !session.run.finished)
1345
1567
  return session.run;
1346
1568
  // The sealed run stays reachable for a moment: a report or a post that
1347
1569
  // arrives just after its turn ended is that turn's, not this one's.
1348
1570
  if (session.run)
1349
1571
  session.previous = session.run;
1350
- const runId = this.recorder.startRun(input, this.runMetadata(session));
1572
+ // A run of another host says so in its `framework`: the service keeps a
1573
+ // plan inside the host, and the scope, that recorded it.
1574
+ const framework = frameworkOf(session);
1575
+ const runId = this.recorder.startRun(input, this.runMetadata(session), framework ? { framework } : undefined);
1351
1576
  const run = {
1352
1577
  runId,
1353
1578
  input,
@@ -1364,9 +1589,20 @@ export class ControlServer {
1364
1589
  // Taken here, beside `startedAtMs`, so the cost window and the duration
1365
1590
  // window are the same window (docs/calculatedReplay.md §11.4).
1366
1591
  usageMark: markTranscriptUsage(session.transcriptPath),
1592
+ // Written here and not by `onPrompt` after this returns: the match
1593
+ // watched below asks at once whether replay is on for this run
1594
+ // (`armFromMatch`), and a flag set afterwards would come too late —
1595
+ // every run of another host would then only record.
1596
+ hostReplay: asked.replay === true ? true : undefined,
1367
1597
  };
1368
1598
  session.run = run;
1369
- logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
1599
+ logLine("run.start", {
1600
+ run: runId,
1601
+ sess: session.sessionId,
1602
+ tier: "bound",
1603
+ // Named only for another host: `bir investigate` reads it from here.
1604
+ host: otherHost(session) ? session.host?.app : undefined,
1605
+ });
1370
1606
  // Journal-only: the prompt is what `bir investigate` lists a turn by. Never
1371
1607
  // on stderr, and never more than a preview.
1372
1608
  if (input)
@@ -1379,8 +1615,10 @@ export class ControlServer {
1379
1615
  runMetadata(session) {
1380
1616
  return {
1381
1617
  recorder: "baseinstrunner",
1618
+ // Which runner made the recording: the service keeps it with the run.
1619
+ runnerVersion: packageVersion(),
1382
1620
  tier: "bound",
1383
- host: this.opts.host ?? { app: "claude-code" },
1621
+ host: session.host ?? this.opts.host ?? { app: "claude-code" },
1384
1622
  wrappedServers: [...this.wrapped],
1385
1623
  // Known-at-start value. A run that turns lossy later says so on the audit
1386
1624
  // log; carrying it here too would need a metadata patch the service does
@@ -1441,8 +1679,11 @@ export class ControlServer {
1441
1679
  * one `replay.decision` line says why (docs/calculatedReplay.md §10).
1442
1680
  */
1443
1681
  async armFromMatch(session, run, pending, prompt, startedAt) {
1444
- if (!this.replay.enabled)
1682
+ // Off for the recorder, or — in a session of another host — not asked
1683
+ // for by this run's adapter: either way nothing can be announced.
1684
+ if (!this.replay.enabled || this.recordsOnly(session, run)) {
1445
1685
  return this.noteDisabledMatch(session, run, pending, prompt);
1686
+ }
1446
1687
  const promptWaitMs = this.promptWaitMs();
1447
1688
  const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
1448
1689
  if (Date.now() < this.matchGate.holdUntil) {
@@ -1513,9 +1754,7 @@ export class ControlServer {
1513
1754
  // Awaited: a scenario with a target derives before the directive goes
1514
1755
  // out, so a plan that cannot find its target is never delivered
1515
1756
  // (segmented.md R-PARAM-4). Inside the prompt's own budget.
1516
- const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
1517
- recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1518
- }));
1757
+ const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt") }, this.hostContext(session, run)));
1519
1758
  const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1520
1759
  const state = await this.withinBudget(arming, remaining);
1521
1760
  if (state === undefined)
@@ -1540,12 +1779,12 @@ export class ControlServer {
1540
1779
  });
1541
1780
  }
1542
1781
  if (state) {
1543
- this.shelve(session, run, state);
1782
+ this.shelve(session, run, state, "arm_unannounced");
1544
1783
  }
1545
1784
  else {
1546
1785
  void arming.then((late) => {
1547
1786
  if (late)
1548
- this.shelve(session, run, late);
1787
+ this.shelve(session, run, late, "arm_timeout");
1549
1788
  });
1550
1789
  }
1551
1790
  return undefined;
@@ -1567,7 +1806,9 @@ export class ControlServer {
1567
1806
  await this.replay.settleForPreview(state, settleMs);
1568
1807
  // The argument preview runs logic; it spends what is left of the prompt's
1569
1808
  // own budget, never time on top of it (under 10 ms left, none is shown).
1570
- const previewMs = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1809
+ // None at all for another host: its adapter reads the plan as data, and
1810
+ // no model reads this text.
1811
+ const previewMs = otherHost(session) ? 0 : Math.max(0, promptWaitMs - (Date.now() - startedAt));
1571
1812
  const directive = state.plan ? this.replay.directiveFor(state, { previewMs }) : undefined;
1572
1813
  if (state.plan && !directive) {
1573
1814
  // A plan with nothing to announce is a plan the model is never told of.
@@ -1642,7 +1883,7 @@ export class ControlServer {
1642
1883
  if (!match || run.finished)
1643
1884
  return undefined;
1644
1885
  // Declined at its first gate, before derivation: no transcript is read for it.
1645
- const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt"));
1886
+ const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {}, this.hostContext(session, run)));
1646
1887
  if (!state || run.finished || run.replays.includes(state))
1647
1888
  return undefined;
1648
1889
  if (state.plan) {
@@ -1830,6 +2071,55 @@ export class ControlServer {
1830
2071
  return undefined;
1831
2072
  });
1832
2073
  }
2074
+ /**
2075
+ * Whether `run` only records: a run of another host whose adapter did not
2076
+ * ask for a plan with its prompt. Treated as a run with replay off in the
2077
+ * three places that ask (`onPrompt`, `armFromMatch`, `tryIntentMatch`).
2078
+ *
2079
+ * An adapter that only records never sends `replay`. Without this the
2080
+ * recorder would start a plan for every prompt that matches one, read the
2081
+ * model's own calls against it, and book a replay nobody ran. A run another
2082
+ * hook opened carries no flag either, and only records.
2083
+ */
2084
+ recordsOnly(session, run) {
2085
+ return otherHost(session) && run.hostReplay !== true;
2086
+ }
2087
+ /**
2088
+ * What {@link ReplayController.arm} is told of `session` when its host is
2089
+ * not Claude Code, and undefined when it is.
2090
+ */
2091
+ hostContext(session, run) {
2092
+ if (!otherHost(session))
2093
+ return undefined;
2094
+ return {
2095
+ app: session.host.app,
2096
+ sinks: session.hostSinks ?? {},
2097
+ recordsOnly: this.recordsOnly(session, run) || undefined,
2098
+ };
2099
+ }
2100
+ /**
2101
+ * The earlier tool results the reading of new values may look at: this
2102
+ * prompt's own, from the transcript — and so none for a plan that is
2103
+ * started at the prompt (segmented.md R-PARAM-2a).
2104
+ *
2105
+ * `at` is where the plan is being started, and at the prompt a transcript
2106
+ * has nothing to give. The prompt was only just submitted, so it has no
2107
+ * results of its own; and the transcript does not hold it yet, so what the
2108
+ * transcript calls "this prompt's results" are still the *previous*
2109
+ * prompt's. They used to be sent as this task's, and they answered for it:
2110
+ * a second prompt that named one person ran on the person the first
2111
+ * prompt's results were full of.
2112
+ *
2113
+ * A session of another host has no transcript, and a plan starts there at
2114
+ * the prompt only: none either. Its adapter used to hand the conversation's
2115
+ * earlier results over with the prompt (`recent_results`) — the same
2116
+ * mistake, made on purpose. An adapter that still sends them is not read.
2117
+ */
2118
+ recentResultsFor(session, at) {
2119
+ if (at === "prompt" || otherHost(session))
2120
+ return [];
2121
+ return recentToolResults(session.transcriptPath, this.deriveRecentResults);
2122
+ }
1833
2123
  /**
1834
2124
  * The prompt hook answered without a directive: retire any prompt-armed plan
1835
2125
  * of `run` that could still steer. `armFromMatch` arms one live only while
@@ -1849,7 +2139,7 @@ export class ControlServer {
1849
2139
  scenario: state.scenarioId ?? undefined,
1850
2140
  why: "the prompt was answered without this plan's directive — retired unannounced, so never run",
1851
2141
  });
1852
- this.shelve(session, run, state);
2142
+ this.shelve(session, run, state, "arm_unannounced");
1853
2143
  }
1854
2144
  }
1855
2145
  /**
@@ -1857,14 +2147,20 @@ export class ControlServer {
1857
2147
  * told of it, so it must never steer or diverge a call: it is kept only as
1858
2148
  * what it now is — a matched turn the agent does itself, which still owes the
1859
2149
  * ledger its baseline sample (§11.1) — exactly like a declined match.
2150
+ *
2151
+ * `why` is the code the report carries when the plan was only late. Without
2152
+ * it the service got a matched turn that was not steered and no reason, and
2153
+ * its console could only say that the runner had given none. A plan a gate
2154
+ * declined keeps the gate's own code.
1860
2155
  */
1861
- shelve(session, run, late) {
2156
+ shelve(session, run, late, why) {
1862
2157
  if (late.plan && !late.retired) {
1863
2158
  logDetail("replay.shelved", {
1864
2159
  run: run.runId,
1865
2160
  scenario: late.scenarioId ?? undefined,
1866
2161
  why: "armed without its directive reaching the model — retired, never steers",
1867
2162
  });
2163
+ late.declined ??= why;
1868
2164
  }
1869
2165
  late.sessionId = session.sessionId;
1870
2166
  late.plan = undefined;
@@ -1889,11 +2185,30 @@ export class ControlServer {
1889
2185
  * routinely left half-open, which is what happened in practice.
1890
2186
  *
1891
2187
  * Idempotent: the second caller sees `finished` and does nothing.
2188
+ *
2189
+ * `reason` says why the run is closed. It decides something only in a
2190
+ * session of another host (see {@link CloseReason}).
1892
2191
  */
1893
- sealRun(session) {
2192
+ sealRun(session, reason = "stop") {
1894
2193
  const run = session.run;
1895
2194
  if (!run || run.finished)
1896
2195
  return;
2196
+ const other = otherHost(session);
2197
+ // A run of another host that is closed before its turn's work ended: it
2198
+ // failed, went quiet, or its session ended under it. A LangChain run does
2199
+ // not always end with a stop — nothing calls an end hook when it fails.
2200
+ const early = other && reason !== "stop";
2201
+ // Such a run's cost says nothing of what the task costs. Nor does that of
2202
+ // a run closed — in any way — with its plan still live. That plan is
2203
+ // handed over first, before the report is built, so the report says what
2204
+ // happened: `fell_back` when a step ran, `failed` when none did, and not
2205
+ // `not_steered`, as if no plan had started.
2206
+ let cutShort = early;
2207
+ if (other) {
2208
+ const live = this.livePlan(run);
2209
+ if (live && this.replay.hostHandOver(live, "closed"))
2210
+ cutShort = true;
2211
+ }
1897
2212
  run.finished = true;
1898
2213
  for (const correlation of run.correlations.values()) {
1899
2214
  if (correlation.fallback)
@@ -1917,20 +2232,39 @@ export class ControlServer {
1917
2232
  // A matched turn records nothing, but it still owes the ledger a number —
1918
2233
  // including when it declined to steer, which is a *baseline* sample and is
1919
2234
  // what every saving is measured against (docs/calculatedReplay.md §11).
1920
- this.reportExecution(session, run, durationMs, cost);
2235
+ this.reportExecution(session, run, durationMs, cost, cutShort);
1921
2236
  // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
1922
2237
  // and duration are the model's own work, not the steps the scenario did.
2238
+ // Another host hands over one number for the whole run, and it is the
2239
+ // fragment's: the plan's own steps made no model call, so all of it was
2240
+ // spent after the hand-over.
1923
2241
  const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
1924
- const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
2242
+ const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
1925
2243
  const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
1926
2244
  if (run.recording) {
1927
2245
  const transcriptPath = session.transcriptPath;
2246
+ // The answer is the host's when it handed one over, and the
2247
+ // transcript's otherwise — never both. A run that ended early has none:
2248
+ // whatever text there is, it is not the answer to the prompt.
2249
+ const answerText = early
2250
+ ? Promise.resolve("")
2251
+ : run.hostAnswer !== undefined
2252
+ ? Promise.resolve(run.hostAnswer)
2253
+ : settledLastAssistantText(transcriptPath).catch(() => "");
2254
+ // What the finish of another host's run says beside its answer and its
2255
+ // cost: why it ended early, when it did, and whether it is whole. A
2256
+ // run with either is never calculated and stops catching its prompt.
2257
+ const closing = other
2258
+ ? {
2259
+ error: early ? (run.hostFailed ?? `turn ended early: ${reason}`) : undefined,
2260
+ incomplete: { lossy: run.lossy === true },
2261
+ }
2262
+ : undefined;
1928
2263
  // Resolve the answer *outside* the queue: the wait below is up to two
1929
2264
  // seconds, and the queue is serial — blocking it here would delay the
1930
2265
  // first step of the user's next turn by that much. Kept on the run, so a
1931
2266
  // drain of this run waits for this seal and for nobody else's.
1932
- run.sealing = settledLastAssistantText(transcriptPath)
1933
- .catch(() => "")
2267
+ run.sealing = answerText
1934
2268
  .then((answer) => {
1935
2269
  run.finishQueued = true;
1936
2270
  this.queue.push(() => {
@@ -1949,6 +2283,7 @@ export class ControlServer {
1949
2283
  this.recorder.finishRun(run.runId, answer || undefined, {
1950
2284
  durationMs: recordedMs,
1951
2285
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
2286
+ ...closing,
1952
2287
  });
1953
2288
  });
1954
2289
  });
@@ -1961,6 +2296,9 @@ export class ControlServer {
1961
2296
  measured: cost.measured,
1962
2297
  lossy: this.lossy,
1963
2298
  recorded: run.recording,
2299
+ // Only for another host, and only when the run did not end with an
2300
+ // ordinary stop.
2301
+ ended: early ? reason : undefined,
1964
2302
  });
1965
2303
  }
1966
2304
  /**
@@ -1985,6 +2323,11 @@ export class ControlServer {
1985
2323
  * false` and zero rather than a number that is confidently wrong.
1986
2324
  */
1987
2325
  runCost(session, run) {
2326
+ // Another host has no transcript: its numbers are the ones it handed
2327
+ // over with its stop, and they are looked at before any watermark — a
2328
+ // run of such a host never has one.
2329
+ if (otherHost(session))
2330
+ return this.hostCost(run);
1988
2331
  const measurable = run.usageMark !== undefined;
1989
2332
  if (!measurable) {
1990
2333
  logLine("run.unmeasured", {
@@ -1993,21 +2336,57 @@ export class ControlServer {
1993
2336
  });
1994
2337
  return { usd: 0, measured: false };
1995
2338
  }
1996
- return this.costSince(session, run.usageMark);
2339
+ const cost = this.costSince(session, run.usageMark);
2340
+ if (cost.unpriced?.length) {
2341
+ logLine("run.unmeasured", {
2342
+ run: run.runId,
2343
+ why: "the runner has no price for a model of this turn — update the runner",
2344
+ models: cost.unpriced.join(","),
2345
+ });
2346
+ }
2347
+ return cost;
2348
+ }
2349
+ /**
2350
+ * What a run of another host cost, from the tokens its adapter handed over
2351
+ * with the stop. Measured only when at least one entry has tokens and every
2352
+ * model in the list has a price; with no usage at all, not measured — and
2353
+ * one `run.unmeasured` line says which.
2354
+ */
2355
+ hostCost(run) {
2356
+ const usage = run.hostUsage;
2357
+ if (!usage || usage.length === 0) {
2358
+ logLine("run.unmeasured", { run: run.runId, why: "the host sent no usage" });
2359
+ return { usd: 0, measured: false };
2360
+ }
2361
+ const cost = priceUsage(usage);
2362
+ const used = usage.some((u) => u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens > 0);
2363
+ if (used && cost.measured)
2364
+ return cost;
2365
+ logLine("run.unmeasured", {
2366
+ run: run.runId,
2367
+ why: cost.unpriced?.length
2368
+ ? "the runner has no price for a model of this run"
2369
+ : "the host's usage holds no tokens — the run made no model call",
2370
+ models: cost.unpriced?.length ? cost.unpriced.join(",") : undefined,
2371
+ });
2372
+ return { ...cost, measured: false };
1997
2373
  }
1998
- /** Transcript cost since `mark`; unmeasured (and zero) without one. */
2374
+ /** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
1999
2375
  costSince(session, mark) {
2000
2376
  if (mark === undefined)
2001
2377
  return { usd: 0, measured: false };
2002
- const deltas = usageSince(session.transcriptPath, mark);
2003
- const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
2004
- return { usd, measured: deltas.length > 0 };
2378
+ return priceUsage(usageSince(session.transcriptPath, mark));
2005
2379
  }
2006
- reportExecution(session, run, durationMs, cost) {
2380
+ reportExecution(session, run, durationMs, cost,
2381
+ /** The run was closed before its task ended for the caller (another host only). */
2382
+ cutShort = false) {
2007
2383
  if (!isScenarioReporter(this.recorder))
2008
2384
  return;
2009
2385
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
2010
2386
  const recorder = this.recorder;
2387
+ // Another host hands over one number for the whole run. It cannot be
2388
+ // split between two plans, so with more than one neither is measured.
2389
+ const unsplit = otherHost(session) && states.length > 1;
2011
2390
  // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
2012
2391
  // for its own window of the transcript — from when it armed to when the next
2013
2392
  // one did — so the turn's tokens are split between them, never counted twice.
@@ -2039,11 +2418,12 @@ export class ControlServer {
2039
2418
  sessionCostUsd: windowCost.usd,
2040
2419
  // A direct run the user cancelled cut its turn short: its cost is no
2041
2420
  // baseline, and no saving is measured against it.
2042
- measured: windowCost.measured && !this.cancelledPlans.has(state),
2421
+ measured: windowCost.measured && !this.cancelledPlans.has(state) && !unsplit,
2043
2422
  durationMs: Math.max(0, windowMs),
2044
2423
  prompt: run.input || undefined,
2045
2424
  siblings: states,
2046
2425
  runId: run.runId,
2426
+ cutShort: cutShort || undefined,
2047
2427
  });
2048
2428
  if (!report)
2049
2429
  return;
@@ -2076,7 +2456,7 @@ export class ControlServer {
2076
2456
  const run = session.run;
2077
2457
  if (!run)
2078
2458
  return;
2079
- this.sealRun(session);
2459
+ this.sealRun(session, opts.reason);
2080
2460
  if (opts.detach && session.run === run) {
2081
2461
  session.run = undefined;
2082
2462
  session.previous = run;
@@ -2145,7 +2525,7 @@ export class ControlServer {
2145
2525
  // → Stop is the only honest run boundary. The previous turn was
2146
2526
  // interrupted — Stop does not fire on Esc — so it is sealed here, and its
2147
2527
  // drain runs behind this prompt rather than in front of it.
2148
- this.closeRun(session, { detach: true });
2528
+ this.closeRun(session, { detach: true, reason: "interrupted" });
2149
2529
  }
2150
2530
  else if (open && open.ordering.next === 0) {
2151
2531
  // A run was opened early by a proxy step (the host spawned MCP servers
@@ -2184,16 +2564,24 @@ export class ControlServer {
2184
2564
  });
2185
2565
  return {};
2186
2566
  }
2187
- const run = this.ensureRun(session, input);
2567
+ const other = otherHost(session);
2568
+ if (other) {
2569
+ // What a host hands over with its prompt, kept before the run opens:
2570
+ // the match watched from there may start a plan at once, and the places
2571
+ // that do have no prompt payload in reach.
2572
+ session.hostSinks = hostSinksOf(payload);
2573
+ }
2574
+ const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
2188
2575
  run.promptSeen = true;
2189
2576
  run.input = input;
2190
2577
  session.prompts += 1;
2191
2578
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
2192
2579
  // With replay off there is nothing this hook could say, so it says nothing
2193
2580
  // at once: the match still stops recording on a hit (v1), in the
2194
- // background, and no prompt waits on the service for it.
2581
+ // background, and no prompt waits on the service for it. The same for a
2582
+ // run of another host whose adapter asked for no plan.
2195
2583
  const watch = this.watchForMatch(session, run, input);
2196
- if (!this.replay.enabled)
2584
+ if (!this.replay.enabled || this.recordsOnly(session, run))
2197
2585
  return {};
2198
2586
  // Plan mode is the user asking to see a plan before anything runs. A
2199
2587
  // replay would run tools, auto-approved: nothing is announced, and a plan
@@ -2225,12 +2613,18 @@ export class ControlServer {
2225
2613
  this.retireUnannounced(session, run);
2226
2614
  return {};
2227
2615
  }
2228
- return {
2616
+ const answer = {
2229
2617
  hookSpecificOutput: {
2230
2618
  hookEventName: "UserPromptSubmit",
2231
2619
  additionalContext: directive,
2232
2620
  },
2233
2621
  };
2622
+ // Another host steps through the plan itself, so it is told the plan as
2623
+ // data too: the directive is a text written for a model. At the top
2624
+ // level, where `noPlan` and `declined` are — `forEvent` can drop
2625
+ // `hookSpecificOutput` as a whole. A Claude Code session gets no new key.
2626
+ const plan = other && run.replay ? this.replay.planData(run.replay) : undefined;
2627
+ return plan ? { ...answer, plan } : answer;
2234
2628
  }
2235
2629
  /**
2236
2630
  * `PreToolUse`. Two shapes of answer:
@@ -2250,6 +2644,10 @@ export class ControlServer {
2250
2644
  */
2251
2645
  async onToolPre(payload, onLate) {
2252
2646
  const session = this.ensureSession(payload);
2647
+ // A session of another host has a route of its own: its tools are the
2648
+ // host's, and everything below reads a tool's name as Claude Code's.
2649
+ if (otherHost(session))
2650
+ return this.onHostToolPre(session, payload, onLate);
2253
2651
  const run = this.ensureRun(session);
2254
2652
  const toolName = payload.tool_name ?? "unknown";
2255
2653
  const toolUseId = payload.tool_use_id ?? "";
@@ -2605,6 +3003,238 @@ export class ControlServer {
2605
3003
  }
2606
3004
  return { stepIndex: pair.selected };
2607
3005
  }
3006
+ /**
3007
+ * `/tool/pre` in a session whose host is not Claude Code — a LangChain
3008
+ * application, say (the service's integratioLangChane.md). Two kinds of
3009
+ * request, told apart by `scripted`:
3010
+ *
3011
+ * - **The adapter asks for the plan's next step** (`scripted: true`). It
3012
+ * is answering in the model's place and wants the step's arguments; the
3013
+ * host then runs its own tool with them. See {@link onScriptedPre}.
3014
+ * - **The model's own call.** Recorded, never pinned. See {@link onOwnPre}.
3015
+ *
3016
+ * ONLY A PROMPT OPENS A RUN for such a host. A hook that finds none open
3017
+ * opens none and answers `{}`: the run failed and its adapter closed it,
3018
+ * or the two-hour limit sealed it, or the recorder restarted under it. What
3019
+ * arrives afterwards — the application going on from its checkpoint, a
3020
+ * retry middleware trying the call again — must not become a second
3021
+ * recording with an empty prompt.
3022
+ */
3023
+ async onHostToolPre(session, payload, onLate) {
3024
+ const scripted = payload.scripted === true;
3025
+ const toolName = hostText(payload.tool_name, 200) ?? "unknown";
3026
+ const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
3027
+ const run = session.run && !session.run.finished ? session.run : undefined;
3028
+ if (!run) {
3029
+ logDetail("tool.pre.skipped", {
3030
+ sess: session.sessionId,
3031
+ tool: toolName,
3032
+ why: "no open run — only a prompt opens one for this host",
3033
+ });
3034
+ // A request for a step is told so in words: an empty answer is also
3035
+ // what a recorder that is slow, or switched off, gives.
3036
+ return scripted ? { noPlan: true } : {};
3037
+ }
3038
+ // The runner's own `bir` server is housekeeping in every host.
3039
+ if (isHousekeepingCall(toolName, payload.tool_input, true)) {
3040
+ if (toolUseId)
3041
+ (run.housekeepingUses ??= new Set()).add(toolUseId);
3042
+ logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
3043
+ return {};
3044
+ }
3045
+ return scripted
3046
+ ? this.onScriptedPre(run, toolName, toolUseId, payload, onLate)
3047
+ : this.onOwnPre(session, run, toolName, toolUseId, payload);
3048
+ }
3049
+ /**
3050
+ * The adapter of another host asks for the plan's next step. The answer
3051
+ * either pins the call — `allow`, with the step's computed arguments — or
3052
+ * carries no pin, which ends the plan for the adapter. It then says why:
3053
+ *
3054
+ * - `noPlan`: the recorder holds no live plan for the run. It ran to its
3055
+ * end, was handed over, or the recorder restarted while the run waited
3056
+ * for a person. Nothing is recorded: no call follows this request.
3057
+ * - `declined`: the plan names a tool the agent does not hold.
3058
+ * - a refusal whose reason is the hand-over note, when the plan stopped
3059
+ * in this very request.
3060
+ */
3061
+ async onScriptedPre(run, toolName, toolUseId, payload, onLate) {
3062
+ const live = this.livePlan(run);
3063
+ if (!live)
3064
+ return { noPlan: true };
3065
+ // A call cannot be pinned without an id to join its result by.
3066
+ if (!toolUseId)
3067
+ return {};
3068
+ // THE TOOLS THE AGENT HOLDS ride on the first request for a step: a host
3069
+ // shows them only when its model is about to be called, never at the
3070
+ // prompt. The recorder judges them, not the adapter — one that ended the
3071
+ // plan by itself would leave a live plan here that nobody steps through.
3072
+ const missing = this.missingHostTool(live, payload);
3073
+ if (missing !== undefined &&
3074
+ this.replay.declineArmed(live, "host_tool_missing", `the plan calls ${missing}, a tool the agent that asked does not hold — it cannot be finished there, so no step of it is started`)) {
3075
+ return { declined: "host_tool_missing" };
3076
+ }
3077
+ let overran = false;
3078
+ const answeredLate = new AbortController();
3079
+ onLate?.(() => {
3080
+ overran = true;
3081
+ answeredLate.abort();
3082
+ return {};
3083
+ });
3084
+ const action = await this.replay.preTool(live, toolName, toolUseId, {}, { signal: answeredLate.signal });
3085
+ if (overran) {
3086
+ // The deadline guard answered `{}` — no pin — and the adapter took the
3087
+ // plan as ended. A pin decided now never reached it: the plan ends here
3088
+ // too, and what it would have said waits for the model's next result.
3089
+ live.pinned.delete(toolUseId);
3090
+ if (action.kind === "deny")
3091
+ run.handback = action.reason;
3092
+ else
3093
+ this.replay.hostHandOver(live, "late");
3094
+ this.noteHandover(run);
3095
+ return {};
3096
+ }
3097
+ // A hand-over inside `preTool` must still schedule the fragment.
3098
+ this.noteHandover(run);
3099
+ if (action.kind === "deny") {
3100
+ return {
3101
+ hookSpecificOutput: {
3102
+ hookEventName: "PreToolUse",
3103
+ permissionDecision: "deny",
3104
+ permissionDecisionReason: action.reason,
3105
+ },
3106
+ };
3107
+ }
3108
+ // `passthrough`, `abort`: the plan ended while this request waited.
3109
+ if (action.kind !== "pin")
3110
+ return { noPlan: true };
3111
+ // Recorded as any step the hook alone observes — in a recording, which a
3112
+ // matched run is not — tagged with the plan that pinned it. The same id
3113
+ // asked for twice is one step.
3114
+ const known = run.builtIns.get(toolUseId);
3115
+ const pair = known
3116
+ ? { selected: known.stepIndex, response: known.responseIndex }
3117
+ : run.ordering.allocatePair();
3118
+ const pinnedBy = live.scenarioId ?? undefined;
3119
+ if (!known) {
3120
+ run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
3121
+ if (run.recording) {
3122
+ const toolInput = serializeCapped(redact(action.input));
3123
+ this.queue.push(() => {
3124
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
3125
+ toolName,
3126
+ toolInput,
3127
+ metadata: pinnedBy ? { pinnedBy } : undefined,
3128
+ });
3129
+ });
3130
+ }
3131
+ }
3132
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
3133
+ return {
3134
+ stepIndex: pair.selected,
3135
+ hookSpecificOutput: {
3136
+ hookEventName: "PreToolUse",
3137
+ permissionDecision: "allow",
3138
+ updatedInput: action.input,
3139
+ },
3140
+ };
3141
+ }
3142
+ /**
3143
+ * The first tool the plan will call that the agent does not hold, or
3144
+ * undefined when it holds them all — or did not say which it holds.
3145
+ */
3146
+ missingHostTool(state, payload) {
3147
+ const held = hostNames(payload.tools);
3148
+ const plan = held ? this.replay.planData(state) : undefined;
3149
+ if (!held || !plan)
3150
+ return undefined;
3151
+ const names = new Set(held);
3152
+ return plan.steps.find((step) => !names.has(step.toolName))?.toolName;
3153
+ }
3154
+ /**
3155
+ * The model's own call, in a session of another host: any `/tool/pre`
3156
+ * without `scripted`.
3157
+ *
3158
+ * NEVER PINNED, and never answered with a bundle. In Claude Code a call to
3159
+ * the plan's next tool is pinned, because there the model is the one who
3160
+ * makes the plan's calls. Here the adapter makes them, so a call the model
3161
+ * made is the model's whatever tool it names: answered with the plan's
3162
+ * arguments, the tool would run with arguments the model never wrote, and
3163
+ * the model would read a result that does not belong to the call it sees.
3164
+ *
3165
+ * A plan that is still live is handed over. The call runs as the model
3166
+ * wrote it — the note is not given in its place — and the note goes out
3167
+ * with this call's `/tool/post`. The recording of the model's own work
3168
+ * after the hand-over (the fragment) opens with this very call: opened one
3169
+ * call later, the call that left the plan would be in no recording.
3170
+ */
3171
+ async onOwnPre(session, run, toolName, toolUseId, payload) {
3172
+ const modelArgs = payload.tool_input ?? {};
3173
+ // A tool that pauses itself passes its hook again when the run resumes:
3174
+ // the same call announced twice is one step, not two.
3175
+ const announced = toolUseId ? run.builtIns.get(toolUseId) : undefined;
3176
+ if (announced) {
3177
+ logDetail("tool.pre.repeated", { run: run.runId, tool: toolName, step: announced.stepIndex });
3178
+ return { stepIndex: announced.stepIndex };
3179
+ }
3180
+ const live = this.livePlan(run);
3181
+ if (live && this.replay.hostHandOver(live, "own_call")) {
3182
+ logLine("replay.own_call", {
3183
+ run: run.runId,
3184
+ scenario: live.scenarioId ?? undefined,
3185
+ tool: toolName,
3186
+ why: "the model made a call of its own while the plan was live — the plan is handed over, and the call runs as written",
3187
+ });
3188
+ }
3189
+ this.noteHandover(run);
3190
+ const words = this.hostWords(payload);
3191
+ // Counted, never waited on, and never arming: see `tryIntentMatch`.
3192
+ await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
3193
+ if (run.fragment?.state === "due" && !run.pendingDirective) {
3194
+ await this.openFragment(session, run, words.context, toolName, modelArgs);
3195
+ }
3196
+ // The hook is the only observer of every call of such a host: nothing of
3197
+ // it goes through a proxy of this folder, whatever a tool is named.
3198
+ const pair = run.ordering.allocatePair();
3199
+ run.builtIns.set(toolUseId || `anon_${pair.selected}`, {
3200
+ stepIndex: pair.selected,
3201
+ responseIndex: pair.response,
3202
+ toolName,
3203
+ });
3204
+ if (run.recording) {
3205
+ const toolInput = serializeCapped(redact(modelArgs));
3206
+ this.queue.push(() => {
3207
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
3208
+ toolName,
3209
+ toolInput,
3210
+ context: words.context || undefined,
3211
+ intent: words.thinking,
3212
+ });
3213
+ });
3214
+ }
3215
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
3216
+ return { stepIndex: pair.selected };
3217
+ }
3218
+ /**
3219
+ * What the model wrote and reasoned before a call, as its host handed it
3220
+ * over with the call — the values {@link intentForToolUse} and
3221
+ * {@link thinkingForToolUse} read from a transcript, under the same switch:
3222
+ * a source `BIR_INTENT_SOURCES` turned off is not taken from the payload
3223
+ * either. `context` is the written text only; thinking rides apart.
3224
+ */
3225
+ hostWords(payload) {
3226
+ const written = this.intentSources.has("text") ? hostText(payload.context) : undefined;
3227
+ const reasoned = this.intentSources.has("thinking") ? hostText(payload.thinking) : undefined;
3228
+ return {
3229
+ intent: written
3230
+ ? { text: written, source: "text" }
3231
+ : reasoned
3232
+ ? { text: reasoned, source: "thinking" }
3233
+ : undefined,
3234
+ context: written ?? "",
3235
+ thinking: reasoned ? { text: reasoned, source: "thinking" } : undefined,
3236
+ };
3237
+ }
2608
3238
  /** This run's plan, when it is armed, still live, and announced to the model. */
2609
3239
  livePlan(run) {
2610
3240
  const state = run.replay;
@@ -2915,6 +3545,10 @@ export class ControlServer {
2915
3545
  const opts = this.replay.intentMatch;
2916
3546
  if (!this.replay.enabled || !opts.enabled)
2917
3547
  return undefined;
3548
+ // A run of another host that only records is a run with replay off: no
3549
+ // probe is sent, so it adds nothing to the step counts either.
3550
+ if (this.recordsOnly(session, run))
3551
+ return undefined;
2918
3552
  if (!isIntentMatcher(this.recorder))
2919
3553
  return undefined;
2920
3554
  if (this.replay.isDirectTool(toolName))
@@ -2953,6 +3587,7 @@ export class ControlServer {
2953
3587
  .map((s) => s.scenarioId)
2954
3588
  .filter((id) => typeof id === "string");
2955
3589
  const startedAt = Date.now();
3590
+ const framework = frameworkOf(session);
2956
3591
  const probe = this.recorder
2957
3592
  .matchIntent({
2958
3593
  text,
@@ -2969,13 +3604,19 @@ export class ControlServer {
2969
3604
  ranThrough: this.ranThroughFor(run),
2970
3605
  ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
2971
3606
  supportsCalls: true,
3607
+ // Left out for Claude Code: a missing framework means Claude Code to
3608
+ // the service, which matches a step only inside its own host.
3609
+ ...(framework ? { framework } : {}),
2972
3610
  },
2973
3611
  // The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
2974
3612
  // itself — waited on or not — so an abandoned probe releases its socket
2975
3613
  // rather than holding it for as long as the service takes to answer.
2976
3614
  { timeoutMs: opts.budgetMs })
2977
3615
  .catch(() => null);
2978
- if (!this.intentArming()) {
3616
+ // In a session of another host a probe only counts: a plan is started
3617
+ // there by the prompt, and stepped through by an adapter that was told of
3618
+ // it then. Nothing could announce one that armed in the middle of a run.
3619
+ if (!this.intentArming() || otherHost(session)) {
2979
3620
  // Counted, never waited on: whatever the answer says is bookkeeping.
2980
3621
  void probe.then((late) => {
2981
3622
  if (late)
@@ -3017,10 +3658,7 @@ export class ControlServer {
3017
3658
  // seconds, and this call is waiting. A plan not armed in time is dropped
3018
3659
  // unused — it was never announced, so it never steers — and its ticket is
3019
3660
  // swept like any unredeemed one (R-OUT-12).
3020
- const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
3021
- liveCall: { toolName, toolInput },
3022
- recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
3023
- }));
3661
+ 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)));
3024
3662
  const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
3025
3663
  if (state === undefined)
3026
3664
  return undefined; // it threw, and said so
@@ -3233,7 +3871,9 @@ export class ControlServer {
3233
3871
  return;
3234
3872
  const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
3235
3873
  const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
3236
- const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
3874
+ // The fragment is a run of the same host as the turn it continues.
3875
+ const framework = frameworkOf(session);
3876
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
3237
3877
  for (const c of run.correlations.values()) {
3238
3878
  c.settled = true;
3239
3879
  if (c.fallback)
@@ -3362,6 +4002,17 @@ export class ControlServer {
3362
4002
  const session = this.ensureSession(payload);
3363
4003
  const toolUseId = payload.tool_use_id ?? "";
3364
4004
  const toolName = payload.tool_name ?? "unknown";
4005
+ const other = otherHost(session);
4006
+ // Only a prompt opens a run for another host (see `onHostToolPre`): a
4007
+ // result that finds none open is a closed run's, and is not recorded.
4008
+ if (other && !(session.run && !session.run.finished)) {
4009
+ logDetail("tool.post.skipped", {
4010
+ sess: session.sessionId,
4011
+ tool: toolName,
4012
+ why: "no open run — only a prompt opens one for this host",
4013
+ });
4014
+ return {};
4015
+ }
3365
4016
  if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
3366
4017
  this.birToolSeen = true;
3367
4018
  // Every completion, pinned or not, before anything below can return: it
@@ -3412,7 +4063,7 @@ export class ControlServer {
3412
4063
  const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
3413
4064
  if (skippedAtPre)
3414
4065
  return {};
3415
- if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
4066
+ if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input, other))
3416
4067
  return {};
3417
4068
  // Nor for an unarmed `run_scenario`. The plan may have retired since the
3418
4069
  // pre hook, so the test is whether that hook opened a step, not the plan.
@@ -3427,12 +4078,29 @@ export class ControlServer {
3427
4078
  // recorded, and only fall back to the hook's view if no report arrives.
3428
4079
  const replay = run.replay;
3429
4080
  const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
4081
+ // The result of a call the adapter of another host made for the plan,
4082
+ // which the recorder does not hold as pinned: the plan is gone — the run
4083
+ // was sealed and opened again, or the recorder restarted. Nothing of it
4084
+ // is recorded: it was never the model's call.
4085
+ if (other && payload.scripted === true && !pinnedHere) {
4086
+ logDetail("tool.post.skipped", { run: run.runId, tool: toolName, why: "a scripted call this recorder did not pin" });
4087
+ return {};
4088
+ }
3430
4089
  const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
3431
4090
  if (pinnedHere && replay) {
4091
+ // What another host says it did to the step itself, read before any
4092
+ // failure: a person's "no" arrives as an error result, and is no fault
4093
+ // of the plan (controller.ts, `PostToolOptions.hostStop`).
4094
+ const hostStop = other && (payload.host_stop === "rejected" || payload.host_stop === "edited")
4095
+ ? payload.host_stop
4096
+ : undefined;
3432
4097
  // The host's verdict, which outranks anything read from the output: a
3433
4098
  // step that failed must never thread as if it worked (controller.ts).
3434
- const hostFailure = failed ? this.errorText(payload) : undefined;
3435
- if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
4099
+ const hostFailure = failed && !hostStop ? this.errorText(payload) : undefined;
4100
+ if (hostStop) {
4101
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), undefined, { hostStop });
4102
+ }
4103
+ else if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
3436
4104
  correlation.threadToolUseId = toolUseId;
3437
4105
  correlation.threadFallback = setTimeout(() => {
3438
4106
  if (!this.replay.isPinned(replay, toolUseId))
@@ -3554,9 +4222,48 @@ export class ControlServer {
3554
4222
  // arriving. A later prompt in the same session opens a fresh run, exactly as
3555
4223
  // it did before. The drain that follows is background work: it is what
3556
4224
  // lets the recorder forget the run once the service has it.
3557
- this.closeRun(session, { detach: false });
4225
+ //
4226
+ // Another host hands the run's answer and its tokens over here — it has
4227
+ // no transcript to read them from — and says when the run failed or its
4228
+ // recording is not whole.
4229
+ const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
4230
+ this.closeRun(session, { detach: false, reason });
3558
4231
  return {};
3559
4232
  }
4233
+ /**
4234
+ * Keep what another host handed over with its stop — on the run, not on the
4235
+ * session: a later prompt of the session opens a new run, which must not
4236
+ * inherit an answer or a cost. Returns why the run is closed.
4237
+ *
4238
+ * - `final_answer`, `usage`: the answer and the tokens (see `sealRun`,
4239
+ * `hostCost`).
4240
+ * - `incomplete`: the recording is not the whole run — the adapter lost
4241
+ * calls, a person changed one, or one was answered in its tool's place.
4242
+ * The run is flagged lossy, so its finish carries the service's mark; the
4243
+ * mark has no place for a text, which goes to the log only.
4244
+ * - `failed`: the run failed. It is closed with that text as its error
4245
+ * and no final answer. An exception's text can hold a password or a
4246
+ * user's data: it is cleaned and cut like every text that is sent.
4247
+ */
4248
+ noteHostStop(run, payload) {
4249
+ run.hostAnswer = typeof payload.final_answer === "string" && payload.final_answer ? payload.final_answer : undefined;
4250
+ run.hostUsage = hostUsageOf(payload);
4251
+ const incomplete = hostText(payload.incomplete, 500);
4252
+ if (incomplete !== undefined) {
4253
+ run.lossy = true;
4254
+ logLine("run.incomplete", {
4255
+ run: run.runId,
4256
+ why: redactText(incomplete),
4257
+ effect: "its recording is marked incomplete: never calculated, and it no longer catches its prompt",
4258
+ });
4259
+ }
4260
+ if (payload.failed === undefined || payload.failed === null || payload.failed === false)
4261
+ return "stop";
4262
+ const failed = hostText(payload.failed, 2_000);
4263
+ run.hostFailed = failed ? redactText(failed) : "the host said the run failed";
4264
+ logLine("run.host_failed", { run: run.runId, error: run.hostFailed });
4265
+ return "host_failed";
4266
+ }
3560
4267
  /**
3561
4268
  * `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
3562
4269
  * waiting on it to exit, and this process outlives the session, so the run
@@ -3570,7 +4277,7 @@ export class ControlServer {
3570
4277
  if (sessionId === session.sessionId)
3571
4278
  this.proxySessions.delete(proxyId);
3572
4279
  }
3573
- this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd });
4280
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd, reason: "session_end" });
3574
4281
  // Forgotten a little later by the reaper, not now: a late report or post
3575
4282
  // for its last turn must still find that turn rather than open a new one.
3576
4283
  session.endedAt = Date.now();
@@ -4062,7 +4769,10 @@ export class ControlServer {
4062
4769
  const known = bound ? this.sessions.get(bound) : undefined;
4063
4770
  if (known)
4064
4771
  return known;
4065
- const recent = this.recentSessions();
4772
+ // Never a session of another host: none of its calls goes through a
4773
+ // proxy, and a proxy's step must not open a run there — only its own
4774
+ // prompt does (see `onHostToolPre`).
4775
+ const recent = this.recentSessions().filter((s) => !otherHost(s));
4066
4776
  const active = recent.find((s) => this.isActive(s));
4067
4777
  if (active)
4068
4778
  return active;