@basein/runner 0.3.9 → 0.3.10

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 +46 -5
  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 +17 -0
  15. package/dist/config/adapters/claude-code.js +28 -1
  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 +200 -2
  23. package/dist/control/server.js +741 -48
  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 +132 -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 +81 -17
  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";
@@ -257,6 +257,137 @@ function isOn(value) {
257
257
  return true;
258
258
  return typeof value === "string" && ["1", "true"].includes(value.trim().toLowerCase());
259
259
  }
260
+ /** The longest text a host may name itself, its version, its adapter or its scope by. */
261
+ const HOST_TEXT_MAX = 64;
262
+ /**
263
+ * The host a payload names, or undefined when it names none: a `host` whose
264
+ * `app` is missing, empty or not a text is ignored, and the session is a
265
+ * Claude Code session as before. Only the four known keys are kept. `app`,
266
+ * `version` and `adapter` are cut to {@link HOST_TEXT_MAX}; a scope is never
267
+ * cut — see {@link badScope}.
268
+ */
269
+ function hostOf(payload) {
270
+ const h = payload.host;
271
+ if (!h || typeof h !== "object")
272
+ return undefined;
273
+ const app = typeof h.app === "string" ? h.app.trim().toLowerCase().slice(0, HOST_TEXT_MAX) : "";
274
+ if (!app)
275
+ return undefined;
276
+ const host = { app };
277
+ if (typeof h.version === "string" && h.version)
278
+ host.version = h.version.slice(0, HOST_TEXT_MAX);
279
+ if (typeof h.adapter === "string" && h.adapter)
280
+ host.adapter = h.adapter.slice(0, HOST_TEXT_MAX);
281
+ if (typeof h.scope === "string" && h.scope)
282
+ host.scope = h.scope;
283
+ return host;
284
+ }
285
+ /**
286
+ * Whether a payload's host names a scope the recorder cannot keep as it is:
287
+ * there, but empty, longer than {@link HOST_TEXT_MAX} or not a text.
288
+ *
289
+ * Such a scope is never cut and never dropped. The scope is part of what
290
+ * keeps one tenant's plans away from another's: cut or dropped, the run
291
+ * would land in another scope's pool.
292
+ */
293
+ function badScope(payload) {
294
+ const h = payload.host;
295
+ if (!h || typeof h !== "object" || !hostOf(payload))
296
+ return false;
297
+ if (h.scope === undefined)
298
+ return false;
299
+ return typeof h.scope !== "string" || h.scope.length === 0 || h.scope.length > HOST_TEXT_MAX;
300
+ }
301
+ /** Whether `session` said its host is not Claude Code. */
302
+ function otherHost(session) {
303
+ return session?.host !== undefined && session.host.app !== "claude-code";
304
+ }
305
+ /**
306
+ * The `framework` text of a run in a session of another host — its `app`, or
307
+ * `app:scope` when it named a scope — and undefined for Claude Code, whose
308
+ * runs keep the recorder's own framework. Up to 129 characters.
309
+ */
310
+ function frameworkOf(session) {
311
+ if (!otherHost(session))
312
+ return undefined;
313
+ const host = session.host;
314
+ return host.scope ? `${host.app}:${host.scope}` : host.app;
315
+ }
316
+ /** What a host may hand over of one text: a transcript index keeps no more of a message. */
317
+ const HOST_TEXT_CHARS = 16 * 1024;
318
+ /** Tool names a host may declare as sinks, or as held by its agent. */
319
+ const HOST_NAMES_MAX = 4_096;
320
+ /** Models one run's usage may name. */
321
+ const HOST_USAGE_MAX = 32;
322
+ /** A payload text as the recorder takes it: a non-empty string, cut to `max` characters. */
323
+ function hostText(value, max = HOST_TEXT_CHARS) {
324
+ if (typeof value !== "string")
325
+ return undefined;
326
+ const text = value.trim();
327
+ return text ? text.slice(0, max) : undefined;
328
+ }
329
+ /** A payload's list of tool names, or undefined when it is not a list. */
330
+ function hostNames(value) {
331
+ if (!Array.isArray(value))
332
+ return undefined;
333
+ const names = [];
334
+ for (const item of value.slice(0, HOST_NAMES_MAX)) {
335
+ // A name alone, or `{ name }`, which is how an agent's tools are listed.
336
+ const name = typeof item === "string" ? item : item?.name;
337
+ if (typeof name === "string" && name)
338
+ names.push(name.slice(0, 200));
339
+ }
340
+ return names;
341
+ }
342
+ /** The sinks a host declared with its prompt. Always an object: see `HostSinks`. */
343
+ function hostSinksOf(payload) {
344
+ const sinks = payload.sinks;
345
+ if (!sinks || typeof sinks !== "object")
346
+ return {};
347
+ return { command: hostNames(sinks.command) ?? [], write: hostNames(sinks.write) ?? [] };
348
+ }
349
+ /**
350
+ * A payload's `usage`, under the names the price table takes. The host sends
351
+ * Claude's own names, the ones a transcript line carries, and they are mapped
352
+ * here as the transcript's are folded: a missing count is 0. Undefined when
353
+ * the payload carries no usable list — which is "no usage", never zero.
354
+ */
355
+ function hostUsageOf(payload) {
356
+ if (!Array.isArray(payload.usage))
357
+ return undefined;
358
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
359
+ const out = [];
360
+ for (const item of payload.usage.slice(0, HOST_USAGE_MAX)) {
361
+ if (!item || typeof item !== "object")
362
+ continue;
363
+ const u = item;
364
+ out.push({
365
+ model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
366
+ inputTokens: count(u.input_tokens),
367
+ outputTokens: count(u.output_tokens),
368
+ cacheReadTokens: count(u.cache_read_input_tokens),
369
+ cacheCreationTokens: count(u.cache_creation_input_tokens),
370
+ });
371
+ }
372
+ return out;
373
+ }
374
+ /**
375
+ * Price a turn's token usage. Measured only when tokens were used and every
376
+ * model that used them has a price: a model outside the table costs "$0",
377
+ * and a zero called measured is booked as a saving nobody made.
378
+ */
379
+ function priceUsage(usage) {
380
+ const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
381
+ const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
382
+ return { usd, measured: usage.length > 0 && unpriced.length === 0, unpriced };
383
+ }
384
+ /**
385
+ * What `/health` answers as `hostFields`: how much of another host's protocol
386
+ * this recorder understands. An adapter reads it to know which fields it may
387
+ * rely on — with 1 it records; from 2 it may also ask for a plan and step
388
+ * through it, because the checks a replay in another host needs are here.
389
+ */
390
+ export const HOST_FIELDS = 2;
260
391
  /** One MCP call, as seen from up to two sides. */
261
392
  /**
262
393
  * Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
@@ -327,6 +458,13 @@ export class ControlServer {
327
458
  * SessionStart.
328
459
  */
329
460
  offSessions = new Map();
461
+ /**
462
+ * Sessions of another host that named a scope the recorder cannot keep as
463
+ * it is (see {@link badScope}) → when last heard. Nothing of such a session
464
+ * is recorded or replayed: every hook of it is answered `{}`. Kept apart
465
+ * from {@link sessions}, so it never holds a run or takes a proxy's step.
466
+ */
467
+ refusedSessions = new Map();
330
468
  /**
331
469
  * Proxy ids that registered with *this* recorder (UPG-7). A poll from any
332
470
  * other id is a proxy from before this recorder started — a 0.2.12 proxy
@@ -421,6 +559,7 @@ export class ControlServer {
421
559
  if (!this.lossy)
422
560
  logLine("run.lossy", { why: "step queue overflowed", dropped });
423
561
  this.lossy = true;
562
+ this.markOpenRunsLossy();
424
563
  },
425
564
  onError: (err) => logLine("control.step_failed", { error: errText(err) }),
426
565
  });
@@ -472,7 +611,7 @@ export class ControlServer {
472
611
  this.heldPolls.clear();
473
612
  for (const session of this.sessions.values()) {
474
613
  if (session.run && !session.run.finished) {
475
- this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close });
614
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close, reason: "recorder_closed" });
476
615
  }
477
616
  }
478
617
  // Bounded: a recorder being stopped must stop, and a service that is down
@@ -567,6 +706,9 @@ export class ControlServer {
567
706
  url: this.address?.url,
568
707
  wrappedServers: [...this.wrapped],
569
708
  hooksVersion: packageVersion(),
709
+ // How much of another host's protocol this recorder understands: what
710
+ // an adapter reads before it relies on a field (see HOST_FIELDS).
711
+ hostFields: HOST_FIELDS,
570
712
  registeredProxies: [...this.registrations].map(([serverName, r]) => ({
571
713
  serverName,
572
714
  pid: r.pid,
@@ -947,6 +1089,9 @@ export class ControlServer {
947
1089
  bytes: err.bytes,
948
1090
  why: "read and discarded, answered {} — this event is not recorded",
949
1091
  });
1092
+ // Whose event it was went unread with the body: every run open now
1093
+ // may be the one that lost it.
1094
+ this.markOpenRunsLossy();
950
1095
  answer({});
951
1096
  return;
952
1097
  }
@@ -964,6 +1109,12 @@ export class ControlServer {
964
1109
  answer({});
965
1110
  return;
966
1111
  }
1112
+ // A session of another host that named a scope the recorder cannot
1113
+ // keep: nothing of it is recorded or replayed.
1114
+ if (this.hostRefused(payload)) {
1115
+ answer({});
1116
+ return;
1117
+ }
967
1118
  const out = await this.dispatchHook(route, payload, (fn) => {
968
1119
  late = fn;
969
1120
  });
@@ -1055,8 +1206,48 @@ export class ControlServer {
1055
1206
  }
1056
1207
  this.offSessions.set(sessionId, { at: Date.now() });
1057
1208
  const session = this.sessions.get(sessionId);
1058
- if (session?.run && !session.run.finished)
1059
- this.closeRun(session, { detach: true });
1209
+ if (session?.run && !session.run.finished) {
1210
+ this.closeRun(session, { detach: true, reason: "session_off" });
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Whether this hook belongs to a session of another host whose scope the
1215
+ * recorder cannot keep as it is (see {@link badScope}), and so is answered
1216
+ * `{}` unprocessed — every hook of it, like a session that is off. One
1217
+ * line says so, once for the session.
1218
+ *
1219
+ * A session that already has a host keeps it: a later payload cannot turn
1220
+ * a run that is being recorded into one that is not.
1221
+ */
1222
+ hostRefused(payload) {
1223
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
1224
+ if (sessionId && this.sessions.get(sessionId)?.host)
1225
+ return false;
1226
+ if (badScope(payload)) {
1227
+ if (!sessionId || !this.refusedSessions.has(sessionId)) {
1228
+ logLine("session.host_refused", {
1229
+ sess: sessionId,
1230
+ host: hostOf(payload)?.app,
1231
+ why: `its scope is empty, longer than ${HOST_TEXT_MAX} characters or not a text — a scope is never cut or dropped, ` +
1232
+ "because the run would land in another scope's plans; nothing of this session is recorded or replayed",
1233
+ fix: `give the adapter a scope of 1 to ${HOST_TEXT_MAX} characters, or none`,
1234
+ });
1235
+ }
1236
+ if (sessionId)
1237
+ this.refusedSessions.set(sessionId, Date.now());
1238
+ return true;
1239
+ }
1240
+ if (!sessionId || !this.refusedSessions.has(sessionId))
1241
+ return false;
1242
+ this.refusedSessions.set(sessionId, Date.now());
1243
+ return true;
1244
+ }
1245
+ /** Flag every run open right now as having lost steps (see `RunState.lossy`). */
1246
+ markOpenRunsLossy() {
1247
+ for (const session of this.sessions.values()) {
1248
+ if (session.run && !session.run.finished)
1249
+ session.run.lossy = true;
1250
+ }
1060
1251
  }
1061
1252
  /** Whether `sessionId` is a session started with `BIR_OFF=1`. */
1062
1253
  isSessionOff(sessionId) {
@@ -1223,6 +1414,11 @@ export class ControlServer {
1223
1414
  session.transcriptPath = payload.transcript_path;
1224
1415
  if (payload.cwd)
1225
1416
  session.cwd = payload.cwd;
1417
+ // The first host a session names is the one it keeps. Any route may carry
1418
+ // it: a recorder that restarted in the middle of a run learns it from the
1419
+ // run's next hook, and so opens no Claude Code run for it.
1420
+ if (!session.host)
1421
+ session.host = hostOf(payload);
1226
1422
  return session;
1227
1423
  }
1228
1424
  /**
@@ -1287,7 +1483,7 @@ export class ControlServer {
1287
1483
  idleMin: Math.round((now - session.lastSeenAt) / 60_000),
1288
1484
  why: "no hook for this session in a long time — the turn was interrupted and never resumed",
1289
1485
  });
1290
- this.closeRun(session, { detach: false });
1486
+ this.closeRun(session, { detach: false, reason: "idle" });
1291
1487
  }
1292
1488
  if (session.run && !session.run.finished)
1293
1489
  continue;
@@ -1327,6 +1523,16 @@ export class ControlServer {
1327
1523
  if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid))
1328
1524
  this.scenarioHellos.delete(pid);
1329
1525
  }
1526
+ for (const [sessionId, at] of this.refusedSessions) {
1527
+ if (now - at >= t.forgetMs)
1528
+ this.refusedSessions.delete(sessionId);
1529
+ }
1530
+ if (this.refusedSessions.size > OFF_SESSIONS_MAX) {
1531
+ const oldest = [...this.refusedSessions].sort((a, b) => a[1] - b[1]);
1532
+ for (const [sessionId] of oldest.slice(0, this.refusedSessions.size - OFF_SESSIONS_MAX)) {
1533
+ this.refusedSessions.delete(sessionId);
1534
+ }
1535
+ }
1330
1536
  }
1331
1537
  /** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
1332
1538
  forgetSession(session) {
@@ -1339,15 +1545,23 @@ export class ControlServer {
1339
1545
  }
1340
1546
  logDetail("session.forgotten", { sess: session.sessionId });
1341
1547
  }
1342
- /** Open a run for `session`, or return the open one. */
1343
- ensureRun(session, input = "") {
1548
+ /**
1549
+ * Open a run for `session`, or return the open one.
1550
+ *
1551
+ * `asked` is what the prompt that opens the run asked for, in a session of
1552
+ * another host: whether its adapter wants a plan (`HookPayload.replay`).
1553
+ */
1554
+ ensureRun(session, input = "", asked = {}) {
1344
1555
  if (session.run && !session.run.finished)
1345
1556
  return session.run;
1346
1557
  // The sealed run stays reachable for a moment: a report or a post that
1347
1558
  // arrives just after its turn ended is that turn's, not this one's.
1348
1559
  if (session.run)
1349
1560
  session.previous = session.run;
1350
- const runId = this.recorder.startRun(input, this.runMetadata(session));
1561
+ // A run of another host says so in its `framework`: the service keeps a
1562
+ // plan inside the host, and the scope, that recorded it.
1563
+ const framework = frameworkOf(session);
1564
+ const runId = this.recorder.startRun(input, this.runMetadata(session), framework ? { framework } : undefined);
1351
1565
  const run = {
1352
1566
  runId,
1353
1567
  input,
@@ -1364,9 +1578,20 @@ export class ControlServer {
1364
1578
  // Taken here, beside `startedAtMs`, so the cost window and the duration
1365
1579
  // window are the same window (docs/calculatedReplay.md §11.4).
1366
1580
  usageMark: markTranscriptUsage(session.transcriptPath),
1581
+ // Written here and not by `onPrompt` after this returns: the match
1582
+ // watched below asks at once whether replay is on for this run
1583
+ // (`armFromMatch`), and a flag set afterwards would come too late —
1584
+ // every run of another host would then only record.
1585
+ hostReplay: asked.replay === true ? true : undefined,
1367
1586
  };
1368
1587
  session.run = run;
1369
- logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
1588
+ logLine("run.start", {
1589
+ run: runId,
1590
+ sess: session.sessionId,
1591
+ tier: "bound",
1592
+ // Named only for another host: `bir investigate` reads it from here.
1593
+ host: otherHost(session) ? session.host?.app : undefined,
1594
+ });
1370
1595
  // Journal-only: the prompt is what `bir investigate` lists a turn by. Never
1371
1596
  // on stderr, and never more than a preview.
1372
1597
  if (input)
@@ -1379,8 +1604,10 @@ export class ControlServer {
1379
1604
  runMetadata(session) {
1380
1605
  return {
1381
1606
  recorder: "baseinstrunner",
1607
+ // Which runner made the recording: the service keeps it with the run.
1608
+ runnerVersion: packageVersion(),
1382
1609
  tier: "bound",
1383
- host: this.opts.host ?? { app: "claude-code" },
1610
+ host: session.host ?? this.opts.host ?? { app: "claude-code" },
1384
1611
  wrappedServers: [...this.wrapped],
1385
1612
  // Known-at-start value. A run that turns lossy later says so on the audit
1386
1613
  // log; carrying it here too would need a metadata patch the service does
@@ -1441,8 +1668,11 @@ export class ControlServer {
1441
1668
  * one `replay.decision` line says why (docs/calculatedReplay.md §10).
1442
1669
  */
1443
1670
  async armFromMatch(session, run, pending, prompt, startedAt) {
1444
- if (!this.replay.enabled)
1671
+ // Off for the recorder, or — in a session of another host — not asked
1672
+ // for by this run's adapter: either way nothing can be announced.
1673
+ if (!this.replay.enabled || this.recordsOnly(session, run)) {
1445
1674
  return this.noteDisabledMatch(session, run, pending, prompt);
1675
+ }
1446
1676
  const promptWaitMs = this.promptWaitMs();
1447
1677
  const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
1448
1678
  if (Date.now() < this.matchGate.holdUntil) {
@@ -1513,9 +1743,7 @@ export class ControlServer {
1513
1743
  // Awaited: a scenario with a target derives before the directive goes
1514
1744
  // out, so a plan that cannot find its target is never delivered
1515
1745
  // (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
- }));
1746
+ 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
1747
  const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1520
1748
  const state = await this.withinBudget(arming, remaining);
1521
1749
  if (state === undefined)
@@ -1567,7 +1795,9 @@ export class ControlServer {
1567
1795
  await this.replay.settleForPreview(state, settleMs);
1568
1796
  // The argument preview runs logic; it spends what is left of the prompt's
1569
1797
  // 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));
1798
+ // None at all for another host: its adapter reads the plan as data, and
1799
+ // no model reads this text.
1800
+ const previewMs = otherHost(session) ? 0 : Math.max(0, promptWaitMs - (Date.now() - startedAt));
1571
1801
  const directive = state.plan ? this.replay.directiveFor(state, { previewMs }) : undefined;
1572
1802
  if (state.plan && !directive) {
1573
1803
  // A plan with nothing to announce is a plan the model is never told of.
@@ -1642,7 +1872,7 @@ export class ControlServer {
1642
1872
  if (!match || run.finished)
1643
1873
  return undefined;
1644
1874
  // 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"));
1875
+ const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {}, this.hostContext(session, run)));
1646
1876
  if (!state || run.finished || run.replays.includes(state))
1647
1877
  return undefined;
1648
1878
  if (state.plan) {
@@ -1830,6 +2060,55 @@ export class ControlServer {
1830
2060
  return undefined;
1831
2061
  });
1832
2062
  }
2063
+ /**
2064
+ * Whether `run` only records: a run of another host whose adapter did not
2065
+ * ask for a plan with its prompt. Treated as a run with replay off in the
2066
+ * three places that ask (`onPrompt`, `armFromMatch`, `tryIntentMatch`).
2067
+ *
2068
+ * An adapter that only records never sends `replay`. Without this the
2069
+ * recorder would start a plan for every prompt that matches one, read the
2070
+ * model's own calls against it, and book a replay nobody ran. A run another
2071
+ * hook opened carries no flag either, and only records.
2072
+ */
2073
+ recordsOnly(session, run) {
2074
+ return otherHost(session) && run.hostReplay !== true;
2075
+ }
2076
+ /**
2077
+ * What {@link ReplayController.arm} is told of `session` when its host is
2078
+ * not Claude Code, and undefined when it is.
2079
+ */
2080
+ hostContext(session, run) {
2081
+ if (!otherHost(session))
2082
+ return undefined;
2083
+ return {
2084
+ app: session.host.app,
2085
+ sinks: session.hostSinks ?? {},
2086
+ recordsOnly: this.recordsOnly(session, run) || undefined,
2087
+ };
2088
+ }
2089
+ /**
2090
+ * The earlier tool results the reading of new values may look at: this
2091
+ * prompt's own, from the transcript — and so none for a plan that is
2092
+ * started at the prompt (segmented.md R-PARAM-2a).
2093
+ *
2094
+ * `at` is where the plan is being started, and at the prompt a transcript
2095
+ * has nothing to give. The prompt was only just submitted, so it has no
2096
+ * results of its own; and the transcript does not hold it yet, so what the
2097
+ * transcript calls "this prompt's results" are still the *previous*
2098
+ * prompt's. They used to be sent as this task's, and they answered for it:
2099
+ * a second prompt that named one person ran on the person the first
2100
+ * prompt's results were full of.
2101
+ *
2102
+ * A session of another host has no transcript, and a plan starts there at
2103
+ * the prompt only: none either. Its adapter used to hand the conversation's
2104
+ * earlier results over with the prompt (`recent_results`) — the same
2105
+ * mistake, made on purpose. An adapter that still sends them is not read.
2106
+ */
2107
+ recentResultsFor(session, at) {
2108
+ if (at === "prompt" || otherHost(session))
2109
+ return [];
2110
+ return recentToolResults(session.transcriptPath, this.deriveRecentResults);
2111
+ }
1833
2112
  /**
1834
2113
  * The prompt hook answered without a directive: retire any prompt-armed plan
1835
2114
  * of `run` that could still steer. `armFromMatch` arms one live only while
@@ -1889,11 +2168,30 @@ export class ControlServer {
1889
2168
  * routinely left half-open, which is what happened in practice.
1890
2169
  *
1891
2170
  * Idempotent: the second caller sees `finished` and does nothing.
2171
+ *
2172
+ * `reason` says why the run is closed. It decides something only in a
2173
+ * session of another host (see {@link CloseReason}).
1892
2174
  */
1893
- sealRun(session) {
2175
+ sealRun(session, reason = "stop") {
1894
2176
  const run = session.run;
1895
2177
  if (!run || run.finished)
1896
2178
  return;
2179
+ const other = otherHost(session);
2180
+ // A run of another host that is closed before its turn's work ended: it
2181
+ // failed, went quiet, or its session ended under it. A LangChain run does
2182
+ // not always end with a stop — nothing calls an end hook when it fails.
2183
+ const early = other && reason !== "stop";
2184
+ // Such a run's cost says nothing of what the task costs. Nor does that of
2185
+ // a run closed — in any way — with its plan still live. That plan is
2186
+ // handed over first, before the report is built, so the report says what
2187
+ // happened: `fell_back` when a step ran, `failed` when none did, and not
2188
+ // `not_steered`, as if no plan had started.
2189
+ let cutShort = early;
2190
+ if (other) {
2191
+ const live = this.livePlan(run);
2192
+ if (live && this.replay.hostHandOver(live, "closed"))
2193
+ cutShort = true;
2194
+ }
1897
2195
  run.finished = true;
1898
2196
  for (const correlation of run.correlations.values()) {
1899
2197
  if (correlation.fallback)
@@ -1917,20 +2215,39 @@ export class ControlServer {
1917
2215
  // A matched turn records nothing, but it still owes the ledger a number —
1918
2216
  // including when it declined to steer, which is a *baseline* sample and is
1919
2217
  // what every saving is measured against (docs/calculatedReplay.md §11).
1920
- this.reportExecution(session, run, durationMs, cost);
2218
+ this.reportExecution(session, run, durationMs, cost, cutShort);
1921
2219
  // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
1922
2220
  // and duration are the model's own work, not the steps the scenario did.
2221
+ // Another host hands over one number for the whole run, and it is the
2222
+ // fragment's: the plan's own steps made no model call, so all of it was
2223
+ // spent after the hand-over.
1923
2224
  const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
1924
- const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
2225
+ const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
1925
2226
  const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
1926
2227
  if (run.recording) {
1927
2228
  const transcriptPath = session.transcriptPath;
2229
+ // The answer is the host's when it handed one over, and the
2230
+ // transcript's otherwise — never both. A run that ended early has none:
2231
+ // whatever text there is, it is not the answer to the prompt.
2232
+ const answerText = early
2233
+ ? Promise.resolve("")
2234
+ : run.hostAnswer !== undefined
2235
+ ? Promise.resolve(run.hostAnswer)
2236
+ : settledLastAssistantText(transcriptPath).catch(() => "");
2237
+ // What the finish of another host's run says beside its answer and its
2238
+ // cost: why it ended early, when it did, and whether it is whole. A
2239
+ // run with either is never calculated and stops catching its prompt.
2240
+ const closing = other
2241
+ ? {
2242
+ error: early ? (run.hostFailed ?? `turn ended early: ${reason}`) : undefined,
2243
+ incomplete: { lossy: run.lossy === true },
2244
+ }
2245
+ : undefined;
1928
2246
  // Resolve the answer *outside* the queue: the wait below is up to two
1929
2247
  // seconds, and the queue is serial — blocking it here would delay the
1930
2248
  // first step of the user's next turn by that much. Kept on the run, so a
1931
2249
  // drain of this run waits for this seal and for nobody else's.
1932
- run.sealing = settledLastAssistantText(transcriptPath)
1933
- .catch(() => "")
2250
+ run.sealing = answerText
1934
2251
  .then((answer) => {
1935
2252
  run.finishQueued = true;
1936
2253
  this.queue.push(() => {
@@ -1949,6 +2266,7 @@ export class ControlServer {
1949
2266
  this.recorder.finishRun(run.runId, answer || undefined, {
1950
2267
  durationMs: recordedMs,
1951
2268
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
2269
+ ...closing,
1952
2270
  });
1953
2271
  });
1954
2272
  });
@@ -1961,6 +2279,9 @@ export class ControlServer {
1961
2279
  measured: cost.measured,
1962
2280
  lossy: this.lossy,
1963
2281
  recorded: run.recording,
2282
+ // Only for another host, and only when the run did not end with an
2283
+ // ordinary stop.
2284
+ ended: early ? reason : undefined,
1964
2285
  });
1965
2286
  }
1966
2287
  /**
@@ -1985,6 +2306,11 @@ export class ControlServer {
1985
2306
  * false` and zero rather than a number that is confidently wrong.
1986
2307
  */
1987
2308
  runCost(session, run) {
2309
+ // Another host has no transcript: its numbers are the ones it handed
2310
+ // over with its stop, and they are looked at before any watermark — a
2311
+ // run of such a host never has one.
2312
+ if (otherHost(session))
2313
+ return this.hostCost(run);
1988
2314
  const measurable = run.usageMark !== undefined;
1989
2315
  if (!measurable) {
1990
2316
  logLine("run.unmeasured", {
@@ -1993,21 +2319,57 @@ export class ControlServer {
1993
2319
  });
1994
2320
  return { usd: 0, measured: false };
1995
2321
  }
1996
- return this.costSince(session, run.usageMark);
2322
+ const cost = this.costSince(session, run.usageMark);
2323
+ if (cost.unpriced?.length) {
2324
+ logLine("run.unmeasured", {
2325
+ run: run.runId,
2326
+ why: "the runner has no price for a model of this turn — update the runner",
2327
+ models: cost.unpriced.join(","),
2328
+ });
2329
+ }
2330
+ return cost;
2331
+ }
2332
+ /**
2333
+ * What a run of another host cost, from the tokens its adapter handed over
2334
+ * with the stop. Measured only when at least one entry has tokens and every
2335
+ * model in the list has a price; with no usage at all, not measured — and
2336
+ * one `run.unmeasured` line says which.
2337
+ */
2338
+ hostCost(run) {
2339
+ const usage = run.hostUsage;
2340
+ if (!usage || usage.length === 0) {
2341
+ logLine("run.unmeasured", { run: run.runId, why: "the host sent no usage" });
2342
+ return { usd: 0, measured: false };
2343
+ }
2344
+ const cost = priceUsage(usage);
2345
+ const used = usage.some((u) => u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens > 0);
2346
+ if (used && cost.measured)
2347
+ return cost;
2348
+ logLine("run.unmeasured", {
2349
+ run: run.runId,
2350
+ why: cost.unpriced?.length
2351
+ ? "the runner has no price for a model of this run"
2352
+ : "the host's usage holds no tokens — the run made no model call",
2353
+ models: cost.unpriced?.length ? cost.unpriced.join(",") : undefined,
2354
+ });
2355
+ return { ...cost, measured: false };
1997
2356
  }
1998
- /** Transcript cost since `mark`; unmeasured (and zero) without one. */
2357
+ /** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
1999
2358
  costSince(session, mark) {
2000
2359
  if (mark === undefined)
2001
2360
  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 };
2361
+ return priceUsage(usageSince(session.transcriptPath, mark));
2005
2362
  }
2006
- reportExecution(session, run, durationMs, cost) {
2363
+ reportExecution(session, run, durationMs, cost,
2364
+ /** The run was closed before its task ended for the caller (another host only). */
2365
+ cutShort = false) {
2007
2366
  if (!isScenarioReporter(this.recorder))
2008
2367
  return;
2009
2368
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
2010
2369
  const recorder = this.recorder;
2370
+ // Another host hands over one number for the whole run. It cannot be
2371
+ // split between two plans, so with more than one neither is measured.
2372
+ const unsplit = otherHost(session) && states.length > 1;
2011
2373
  // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
2012
2374
  // for its own window of the transcript — from when it armed to when the next
2013
2375
  // one did — so the turn's tokens are split between them, never counted twice.
@@ -2039,11 +2401,12 @@ export class ControlServer {
2039
2401
  sessionCostUsd: windowCost.usd,
2040
2402
  // A direct run the user cancelled cut its turn short: its cost is no
2041
2403
  // baseline, and no saving is measured against it.
2042
- measured: windowCost.measured && !this.cancelledPlans.has(state),
2404
+ measured: windowCost.measured && !this.cancelledPlans.has(state) && !unsplit,
2043
2405
  durationMs: Math.max(0, windowMs),
2044
2406
  prompt: run.input || undefined,
2045
2407
  siblings: states,
2046
2408
  runId: run.runId,
2409
+ cutShort: cutShort || undefined,
2047
2410
  });
2048
2411
  if (!report)
2049
2412
  return;
@@ -2076,7 +2439,7 @@ export class ControlServer {
2076
2439
  const run = session.run;
2077
2440
  if (!run)
2078
2441
  return;
2079
- this.sealRun(session);
2442
+ this.sealRun(session, opts.reason);
2080
2443
  if (opts.detach && session.run === run) {
2081
2444
  session.run = undefined;
2082
2445
  session.previous = run;
@@ -2145,7 +2508,7 @@ export class ControlServer {
2145
2508
  // → Stop is the only honest run boundary. The previous turn was
2146
2509
  // interrupted — Stop does not fire on Esc — so it is sealed here, and its
2147
2510
  // drain runs behind this prompt rather than in front of it.
2148
- this.closeRun(session, { detach: true });
2511
+ this.closeRun(session, { detach: true, reason: "interrupted" });
2149
2512
  }
2150
2513
  else if (open && open.ordering.next === 0) {
2151
2514
  // A run was opened early by a proxy step (the host spawned MCP servers
@@ -2184,16 +2547,24 @@ export class ControlServer {
2184
2547
  });
2185
2548
  return {};
2186
2549
  }
2187
- const run = this.ensureRun(session, input);
2550
+ const other = otherHost(session);
2551
+ if (other) {
2552
+ // What a host hands over with its prompt, kept before the run opens:
2553
+ // the match watched from there may start a plan at once, and the places
2554
+ // that do have no prompt payload in reach.
2555
+ session.hostSinks = hostSinksOf(payload);
2556
+ }
2557
+ const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
2188
2558
  run.promptSeen = true;
2189
2559
  run.input = input;
2190
2560
  session.prompts += 1;
2191
2561
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
2192
2562
  // With replay off there is nothing this hook could say, so it says nothing
2193
2563
  // at once: the match still stops recording on a hit (v1), in the
2194
- // background, and no prompt waits on the service for it.
2564
+ // background, and no prompt waits on the service for it. The same for a
2565
+ // run of another host whose adapter asked for no plan.
2195
2566
  const watch = this.watchForMatch(session, run, input);
2196
- if (!this.replay.enabled)
2567
+ if (!this.replay.enabled || this.recordsOnly(session, run))
2197
2568
  return {};
2198
2569
  // Plan mode is the user asking to see a plan before anything runs. A
2199
2570
  // replay would run tools, auto-approved: nothing is announced, and a plan
@@ -2225,12 +2596,18 @@ export class ControlServer {
2225
2596
  this.retireUnannounced(session, run);
2226
2597
  return {};
2227
2598
  }
2228
- return {
2599
+ const answer = {
2229
2600
  hookSpecificOutput: {
2230
2601
  hookEventName: "UserPromptSubmit",
2231
2602
  additionalContext: directive,
2232
2603
  },
2233
2604
  };
2605
+ // Another host steps through the plan itself, so it is told the plan as
2606
+ // data too: the directive is a text written for a model. At the top
2607
+ // level, where `noPlan` and `declined` are — `forEvent` can drop
2608
+ // `hookSpecificOutput` as a whole. A Claude Code session gets no new key.
2609
+ const plan = other && run.replay ? this.replay.planData(run.replay) : undefined;
2610
+ return plan ? { ...answer, plan } : answer;
2234
2611
  }
2235
2612
  /**
2236
2613
  * `PreToolUse`. Two shapes of answer:
@@ -2250,6 +2627,10 @@ export class ControlServer {
2250
2627
  */
2251
2628
  async onToolPre(payload, onLate) {
2252
2629
  const session = this.ensureSession(payload);
2630
+ // A session of another host has a route of its own: its tools are the
2631
+ // host's, and everything below reads a tool's name as Claude Code's.
2632
+ if (otherHost(session))
2633
+ return this.onHostToolPre(session, payload, onLate);
2253
2634
  const run = this.ensureRun(session);
2254
2635
  const toolName = payload.tool_name ?? "unknown";
2255
2636
  const toolUseId = payload.tool_use_id ?? "";
@@ -2605,6 +2986,238 @@ export class ControlServer {
2605
2986
  }
2606
2987
  return { stepIndex: pair.selected };
2607
2988
  }
2989
+ /**
2990
+ * `/tool/pre` in a session whose host is not Claude Code — a LangChain
2991
+ * application, say (the service's integratioLangChane.md). Two kinds of
2992
+ * request, told apart by `scripted`:
2993
+ *
2994
+ * - **The adapter asks for the plan's next step** (`scripted: true`). It
2995
+ * is answering in the model's place and wants the step's arguments; the
2996
+ * host then runs its own tool with them. See {@link onScriptedPre}.
2997
+ * - **The model's own call.** Recorded, never pinned. See {@link onOwnPre}.
2998
+ *
2999
+ * ONLY A PROMPT OPENS A RUN for such a host. A hook that finds none open
3000
+ * opens none and answers `{}`: the run failed and its adapter closed it,
3001
+ * or the two-hour limit sealed it, or the recorder restarted under it. What
3002
+ * arrives afterwards — the application going on from its checkpoint, a
3003
+ * retry middleware trying the call again — must not become a second
3004
+ * recording with an empty prompt.
3005
+ */
3006
+ async onHostToolPre(session, payload, onLate) {
3007
+ const scripted = payload.scripted === true;
3008
+ const toolName = hostText(payload.tool_name, 200) ?? "unknown";
3009
+ const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
3010
+ const run = session.run && !session.run.finished ? session.run : undefined;
3011
+ if (!run) {
3012
+ logDetail("tool.pre.skipped", {
3013
+ sess: session.sessionId,
3014
+ tool: toolName,
3015
+ why: "no open run — only a prompt opens one for this host",
3016
+ });
3017
+ // A request for a step is told so in words: an empty answer is also
3018
+ // what a recorder that is slow, or switched off, gives.
3019
+ return scripted ? { noPlan: true } : {};
3020
+ }
3021
+ // The runner's own `bir` server is housekeeping in every host.
3022
+ if (isHousekeepingCall(toolName, payload.tool_input, true)) {
3023
+ if (toolUseId)
3024
+ (run.housekeepingUses ??= new Set()).add(toolUseId);
3025
+ logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
3026
+ return {};
3027
+ }
3028
+ return scripted
3029
+ ? this.onScriptedPre(run, toolName, toolUseId, payload, onLate)
3030
+ : this.onOwnPre(session, run, toolName, toolUseId, payload);
3031
+ }
3032
+ /**
3033
+ * The adapter of another host asks for the plan's next step. The answer
3034
+ * either pins the call — `allow`, with the step's computed arguments — or
3035
+ * carries no pin, which ends the plan for the adapter. It then says why:
3036
+ *
3037
+ * - `noPlan`: the recorder holds no live plan for the run. It ran to its
3038
+ * end, was handed over, or the recorder restarted while the run waited
3039
+ * for a person. Nothing is recorded: no call follows this request.
3040
+ * - `declined`: the plan names a tool the agent does not hold.
3041
+ * - a refusal whose reason is the hand-over note, when the plan stopped
3042
+ * in this very request.
3043
+ */
3044
+ async onScriptedPre(run, toolName, toolUseId, payload, onLate) {
3045
+ const live = this.livePlan(run);
3046
+ if (!live)
3047
+ return { noPlan: true };
3048
+ // A call cannot be pinned without an id to join its result by.
3049
+ if (!toolUseId)
3050
+ return {};
3051
+ // THE TOOLS THE AGENT HOLDS ride on the first request for a step: a host
3052
+ // shows them only when its model is about to be called, never at the
3053
+ // prompt. The recorder judges them, not the adapter — one that ended the
3054
+ // plan by itself would leave a live plan here that nobody steps through.
3055
+ const missing = this.missingHostTool(live, payload);
3056
+ if (missing !== undefined &&
3057
+ 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`)) {
3058
+ return { declined: "host_tool_missing" };
3059
+ }
3060
+ let overran = false;
3061
+ const answeredLate = new AbortController();
3062
+ onLate?.(() => {
3063
+ overran = true;
3064
+ answeredLate.abort();
3065
+ return {};
3066
+ });
3067
+ const action = await this.replay.preTool(live, toolName, toolUseId, {}, { signal: answeredLate.signal });
3068
+ if (overran) {
3069
+ // The deadline guard answered `{}` — no pin — and the adapter took the
3070
+ // plan as ended. A pin decided now never reached it: the plan ends here
3071
+ // too, and what it would have said waits for the model's next result.
3072
+ live.pinned.delete(toolUseId);
3073
+ if (action.kind === "deny")
3074
+ run.handback = action.reason;
3075
+ else
3076
+ this.replay.hostHandOver(live, "late");
3077
+ this.noteHandover(run);
3078
+ return {};
3079
+ }
3080
+ // A hand-over inside `preTool` must still schedule the fragment.
3081
+ this.noteHandover(run);
3082
+ if (action.kind === "deny") {
3083
+ return {
3084
+ hookSpecificOutput: {
3085
+ hookEventName: "PreToolUse",
3086
+ permissionDecision: "deny",
3087
+ permissionDecisionReason: action.reason,
3088
+ },
3089
+ };
3090
+ }
3091
+ // `passthrough`, `abort`: the plan ended while this request waited.
3092
+ if (action.kind !== "pin")
3093
+ return { noPlan: true };
3094
+ // Recorded as any step the hook alone observes — in a recording, which a
3095
+ // matched run is not — tagged with the plan that pinned it. The same id
3096
+ // asked for twice is one step.
3097
+ const known = run.builtIns.get(toolUseId);
3098
+ const pair = known
3099
+ ? { selected: known.stepIndex, response: known.responseIndex }
3100
+ : run.ordering.allocatePair();
3101
+ const pinnedBy = live.scenarioId ?? undefined;
3102
+ if (!known) {
3103
+ run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
3104
+ if (run.recording) {
3105
+ const toolInput = serializeCapped(redact(action.input));
3106
+ this.queue.push(() => {
3107
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
3108
+ toolName,
3109
+ toolInput,
3110
+ metadata: pinnedBy ? { pinnedBy } : undefined,
3111
+ });
3112
+ });
3113
+ }
3114
+ }
3115
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
3116
+ return {
3117
+ stepIndex: pair.selected,
3118
+ hookSpecificOutput: {
3119
+ hookEventName: "PreToolUse",
3120
+ permissionDecision: "allow",
3121
+ updatedInput: action.input,
3122
+ },
3123
+ };
3124
+ }
3125
+ /**
3126
+ * The first tool the plan will call that the agent does not hold, or
3127
+ * undefined when it holds them all — or did not say which it holds.
3128
+ */
3129
+ missingHostTool(state, payload) {
3130
+ const held = hostNames(payload.tools);
3131
+ const plan = held ? this.replay.planData(state) : undefined;
3132
+ if (!held || !plan)
3133
+ return undefined;
3134
+ const names = new Set(held);
3135
+ return plan.steps.find((step) => !names.has(step.toolName))?.toolName;
3136
+ }
3137
+ /**
3138
+ * The model's own call, in a session of another host: any `/tool/pre`
3139
+ * without `scripted`.
3140
+ *
3141
+ * NEVER PINNED, and never answered with a bundle. In Claude Code a call to
3142
+ * the plan's next tool is pinned, because there the model is the one who
3143
+ * makes the plan's calls. Here the adapter makes them, so a call the model
3144
+ * made is the model's whatever tool it names: answered with the plan's
3145
+ * arguments, the tool would run with arguments the model never wrote, and
3146
+ * the model would read a result that does not belong to the call it sees.
3147
+ *
3148
+ * A plan that is still live is handed over. The call runs as the model
3149
+ * wrote it — the note is not given in its place — and the note goes out
3150
+ * with this call's `/tool/post`. The recording of the model's own work
3151
+ * after the hand-over (the fragment) opens with this very call: opened one
3152
+ * call later, the call that left the plan would be in no recording.
3153
+ */
3154
+ async onOwnPre(session, run, toolName, toolUseId, payload) {
3155
+ const modelArgs = payload.tool_input ?? {};
3156
+ // A tool that pauses itself passes its hook again when the run resumes:
3157
+ // the same call announced twice is one step, not two.
3158
+ const announced = toolUseId ? run.builtIns.get(toolUseId) : undefined;
3159
+ if (announced) {
3160
+ logDetail("tool.pre.repeated", { run: run.runId, tool: toolName, step: announced.stepIndex });
3161
+ return { stepIndex: announced.stepIndex };
3162
+ }
3163
+ const live = this.livePlan(run);
3164
+ if (live && this.replay.hostHandOver(live, "own_call")) {
3165
+ logLine("replay.own_call", {
3166
+ run: run.runId,
3167
+ scenario: live.scenarioId ?? undefined,
3168
+ tool: toolName,
3169
+ 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",
3170
+ });
3171
+ }
3172
+ this.noteHandover(run);
3173
+ const words = this.hostWords(payload);
3174
+ // Counted, never waited on, and never arming: see `tryIntentMatch`.
3175
+ await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
3176
+ if (run.fragment?.state === "due" && !run.pendingDirective) {
3177
+ await this.openFragment(session, run, words.context, toolName, modelArgs);
3178
+ }
3179
+ // The hook is the only observer of every call of such a host: nothing of
3180
+ // it goes through a proxy of this folder, whatever a tool is named.
3181
+ const pair = run.ordering.allocatePair();
3182
+ run.builtIns.set(toolUseId || `anon_${pair.selected}`, {
3183
+ stepIndex: pair.selected,
3184
+ responseIndex: pair.response,
3185
+ toolName,
3186
+ });
3187
+ if (run.recording) {
3188
+ const toolInput = serializeCapped(redact(modelArgs));
3189
+ this.queue.push(() => {
3190
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
3191
+ toolName,
3192
+ toolInput,
3193
+ context: words.context || undefined,
3194
+ intent: words.thinking,
3195
+ });
3196
+ });
3197
+ }
3198
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
3199
+ return { stepIndex: pair.selected };
3200
+ }
3201
+ /**
3202
+ * What the model wrote and reasoned before a call, as its host handed it
3203
+ * over with the call — the values {@link intentForToolUse} and
3204
+ * {@link thinkingForToolUse} read from a transcript, under the same switch:
3205
+ * a source `BIR_INTENT_SOURCES` turned off is not taken from the payload
3206
+ * either. `context` is the written text only; thinking rides apart.
3207
+ */
3208
+ hostWords(payload) {
3209
+ const written = this.intentSources.has("text") ? hostText(payload.context) : undefined;
3210
+ const reasoned = this.intentSources.has("thinking") ? hostText(payload.thinking) : undefined;
3211
+ return {
3212
+ intent: written
3213
+ ? { text: written, source: "text" }
3214
+ : reasoned
3215
+ ? { text: reasoned, source: "thinking" }
3216
+ : undefined,
3217
+ context: written ?? "",
3218
+ thinking: reasoned ? { text: reasoned, source: "thinking" } : undefined,
3219
+ };
3220
+ }
2608
3221
  /** This run's plan, when it is armed, still live, and announced to the model. */
2609
3222
  livePlan(run) {
2610
3223
  const state = run.replay;
@@ -2915,6 +3528,10 @@ export class ControlServer {
2915
3528
  const opts = this.replay.intentMatch;
2916
3529
  if (!this.replay.enabled || !opts.enabled)
2917
3530
  return undefined;
3531
+ // A run of another host that only records is a run with replay off: no
3532
+ // probe is sent, so it adds nothing to the step counts either.
3533
+ if (this.recordsOnly(session, run))
3534
+ return undefined;
2918
3535
  if (!isIntentMatcher(this.recorder))
2919
3536
  return undefined;
2920
3537
  if (this.replay.isDirectTool(toolName))
@@ -2953,6 +3570,7 @@ export class ControlServer {
2953
3570
  .map((s) => s.scenarioId)
2954
3571
  .filter((id) => typeof id === "string");
2955
3572
  const startedAt = Date.now();
3573
+ const framework = frameworkOf(session);
2956
3574
  const probe = this.recorder
2957
3575
  .matchIntent({
2958
3576
  text,
@@ -2969,13 +3587,19 @@ export class ControlServer {
2969
3587
  ranThrough: this.ranThroughFor(run),
2970
3588
  ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
2971
3589
  supportsCalls: true,
3590
+ // Left out for Claude Code: a missing framework means Claude Code to
3591
+ // the service, which matches a step only inside its own host.
3592
+ ...(framework ? { framework } : {}),
2972
3593
  },
2973
3594
  // The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
2974
3595
  // itself — waited on or not — so an abandoned probe releases its socket
2975
3596
  // rather than holding it for as long as the service takes to answer.
2976
3597
  { timeoutMs: opts.budgetMs })
2977
3598
  .catch(() => null);
2978
- if (!this.intentArming()) {
3599
+ // In a session of another host a probe only counts: a plan is started
3600
+ // there by the prompt, and stepped through by an adapter that was told of
3601
+ // it then. Nothing could announce one that armed in the middle of a run.
3602
+ if (!this.intentArming() || otherHost(session)) {
2979
3603
  // Counted, never waited on: whatever the answer says is bookkeeping.
2980
3604
  void probe.then((late) => {
2981
3605
  if (late)
@@ -3017,10 +3641,7 @@ export class ControlServer {
3017
3641
  // seconds, and this call is waiting. A plan not armed in time is dropped
3018
3642
  // unused — it was never announced, so it never steers — and its ticket is
3019
3643
  // 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
- }));
3644
+ 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
3645
  const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
3025
3646
  if (state === undefined)
3026
3647
  return undefined; // it threw, and said so
@@ -3233,7 +3854,9 @@ export class ControlServer {
3233
3854
  return;
3234
3855
  const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
3235
3856
  const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
3236
- const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
3857
+ // The fragment is a run of the same host as the turn it continues.
3858
+ const framework = frameworkOf(session);
3859
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
3237
3860
  for (const c of run.correlations.values()) {
3238
3861
  c.settled = true;
3239
3862
  if (c.fallback)
@@ -3362,6 +3985,17 @@ export class ControlServer {
3362
3985
  const session = this.ensureSession(payload);
3363
3986
  const toolUseId = payload.tool_use_id ?? "";
3364
3987
  const toolName = payload.tool_name ?? "unknown";
3988
+ const other = otherHost(session);
3989
+ // Only a prompt opens a run for another host (see `onHostToolPre`): a
3990
+ // result that finds none open is a closed run's, and is not recorded.
3991
+ if (other && !(session.run && !session.run.finished)) {
3992
+ logDetail("tool.post.skipped", {
3993
+ sess: session.sessionId,
3994
+ tool: toolName,
3995
+ why: "no open run — only a prompt opens one for this host",
3996
+ });
3997
+ return {};
3998
+ }
3365
3999
  if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
3366
4000
  this.birToolSeen = true;
3367
4001
  // Every completion, pinned or not, before anything below can return: it
@@ -3412,7 +4046,7 @@ export class ControlServer {
3412
4046
  const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
3413
4047
  if (skippedAtPre)
3414
4048
  return {};
3415
- if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
4049
+ if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input, other))
3416
4050
  return {};
3417
4051
  // Nor for an unarmed `run_scenario`. The plan may have retired since the
3418
4052
  // pre hook, so the test is whether that hook opened a step, not the plan.
@@ -3427,12 +4061,29 @@ export class ControlServer {
3427
4061
  // recorded, and only fall back to the hook's view if no report arrives.
3428
4062
  const replay = run.replay;
3429
4063
  const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
4064
+ // The result of a call the adapter of another host made for the plan,
4065
+ // which the recorder does not hold as pinned: the plan is gone — the run
4066
+ // was sealed and opened again, or the recorder restarted. Nothing of it
4067
+ // is recorded: it was never the model's call.
4068
+ if (other && payload.scripted === true && !pinnedHere) {
4069
+ logDetail("tool.post.skipped", { run: run.runId, tool: toolName, why: "a scripted call this recorder did not pin" });
4070
+ return {};
4071
+ }
3430
4072
  const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
3431
4073
  if (pinnedHere && replay) {
4074
+ // What another host says it did to the step itself, read before any
4075
+ // failure: a person's "no" arrives as an error result, and is no fault
4076
+ // of the plan (controller.ts, `PostToolOptions.hostStop`).
4077
+ const hostStop = other && (payload.host_stop === "rejected" || payload.host_stop === "edited")
4078
+ ? payload.host_stop
4079
+ : undefined;
3432
4080
  // The host's verdict, which outranks anything read from the output: a
3433
4081
  // 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)) {
4082
+ const hostFailure = failed && !hostStop ? this.errorText(payload) : undefined;
4083
+ if (hostStop) {
4084
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), undefined, { hostStop });
4085
+ }
4086
+ else if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
3436
4087
  correlation.threadToolUseId = toolUseId;
3437
4088
  correlation.threadFallback = setTimeout(() => {
3438
4089
  if (!this.replay.isPinned(replay, toolUseId))
@@ -3554,9 +4205,48 @@ export class ControlServer {
3554
4205
  // arriving. A later prompt in the same session opens a fresh run, exactly as
3555
4206
  // it did before. The drain that follows is background work: it is what
3556
4207
  // lets the recorder forget the run once the service has it.
3557
- this.closeRun(session, { detach: false });
4208
+ //
4209
+ // Another host hands the run's answer and its tokens over here — it has
4210
+ // no transcript to read them from — and says when the run failed or its
4211
+ // recording is not whole.
4212
+ const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
4213
+ this.closeRun(session, { detach: false, reason });
3558
4214
  return {};
3559
4215
  }
4216
+ /**
4217
+ * Keep what another host handed over with its stop — on the run, not on the
4218
+ * session: a later prompt of the session opens a new run, which must not
4219
+ * inherit an answer or a cost. Returns why the run is closed.
4220
+ *
4221
+ * - `final_answer`, `usage`: the answer and the tokens (see `sealRun`,
4222
+ * `hostCost`).
4223
+ * - `incomplete`: the recording is not the whole run — the adapter lost
4224
+ * calls, a person changed one, or one was answered in its tool's place.
4225
+ * The run is flagged lossy, so its finish carries the service's mark; the
4226
+ * mark has no place for a text, which goes to the log only.
4227
+ * - `failed`: the run failed. It is closed with that text as its error
4228
+ * and no final answer. An exception's text can hold a password or a
4229
+ * user's data: it is cleaned and cut like every text that is sent.
4230
+ */
4231
+ noteHostStop(run, payload) {
4232
+ run.hostAnswer = typeof payload.final_answer === "string" && payload.final_answer ? payload.final_answer : undefined;
4233
+ run.hostUsage = hostUsageOf(payload);
4234
+ const incomplete = hostText(payload.incomplete, 500);
4235
+ if (incomplete !== undefined) {
4236
+ run.lossy = true;
4237
+ logLine("run.incomplete", {
4238
+ run: run.runId,
4239
+ why: redactText(incomplete),
4240
+ effect: "its recording is marked incomplete: never calculated, and it no longer catches its prompt",
4241
+ });
4242
+ }
4243
+ if (payload.failed === undefined || payload.failed === null || payload.failed === false)
4244
+ return "stop";
4245
+ const failed = hostText(payload.failed, 2_000);
4246
+ run.hostFailed = failed ? redactText(failed) : "the host said the run failed";
4247
+ logLine("run.host_failed", { run: run.runId, error: run.hostFailed });
4248
+ return "host_failed";
4249
+ }
3560
4250
  /**
3561
4251
  * `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
3562
4252
  * waiting on it to exit, and this process outlives the session, so the run
@@ -3570,7 +4260,7 @@ export class ControlServer {
3570
4260
  if (sessionId === session.sessionId)
3571
4261
  this.proxySessions.delete(proxyId);
3572
4262
  }
3573
- this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd });
4263
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd, reason: "session_end" });
3574
4264
  // Forgotten a little later by the reaper, not now: a late report or post
3575
4265
  // for its last turn must still find that turn rather than open a new one.
3576
4266
  session.endedAt = Date.now();
@@ -4062,7 +4752,10 @@ export class ControlServer {
4062
4752
  const known = bound ? this.sessions.get(bound) : undefined;
4063
4753
  if (known)
4064
4754
  return known;
4065
- const recent = this.recentSessions();
4755
+ // Never a session of another host: none of its calls goes through a
4756
+ // proxy, and a proxy's step must not open a run there — only its own
4757
+ // prompt does (see `onHostToolPre`).
4758
+ const recent = this.recentSessions().filter((s) => !otherHost(s));
4066
4759
  const active = recent.find((s) => this.isActive(s));
4067
4760
  if (active)
4068
4761
  return active;