@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
@@ -62,7 +62,7 @@ export const DEFAULT_BUDGETS = {
62
62
  deriveMs: 8_000,
63
63
  planMs: 100_000,
64
64
  stepMs: 60_000,
65
- hookDeriveMs: 4_000,
65
+ hookDeriveMs: 8_000,
66
66
  preToolMs: 9_000,
67
67
  };
68
68
  /**
@@ -311,15 +311,21 @@ export class ReplayController {
311
311
  * Always returns a state — a declined match still has a ticket to redeem and a
312
312
  * baseline sample to contribute, and losing that is how a savings ledger ends
313
313
  * up with a denominator nobody measured.
314
+ *
315
+ * `host` is given for a session whose host is not Claude Code, and is kept
316
+ * on the state (see {@link HostContext}).
314
317
  */
315
- async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}) {
318
+ async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
316
319
  const state = {
317
320
  scenarioId: match.scenarioId,
318
321
  ticket: match.executionTicket,
319
322
  similarity: match.similarity,
320
323
  matchedRunId: match.runId,
321
324
  mode: "none",
322
- wrapped,
325
+ // No proxy of this folder sees another host's calls, whatever a tool is
326
+ // named: every output is threaded from the host's own report.
327
+ wrapped: host ? new Set() : wrapped,
328
+ host,
323
329
  pinned: new Map(),
324
330
  outcome: "not_steered",
325
331
  deriveCostUsd: 0,
@@ -364,6 +370,18 @@ export class ReplayController {
364
370
  // Gate 2 — replay enabled.
365
371
  if (!this.enabled)
366
372
  return decline("BIR_REPLAY=0", "replay_disabled");
373
+ // And asked for. An adapter that only records never asks for a plan, and
374
+ // one started for it anyway would have nobody to step through it — while
375
+ // every call its model made itself would be read against the plan.
376
+ if (host?.recordsOnly) {
377
+ return decline(`the ${host.app} adapter of this run only records — it asked for no plan with its prompt`, "host_records_only");
378
+ }
379
+ // The service matched the prompt and handed over nothing, because this
380
+ // runner is older than its plans need (its RUNNER_MIN_CONTRACT). Its own
381
+ // code, ahead of "no scenario": the fix is an update, not a calculation.
382
+ if (match.withheld === "runner_update_required") {
383
+ return decline("the service hands this runner no plan until it is updated — run `bir update`", "runner_update_required");
384
+ }
367
385
  // Gate 3 — a ready scenario with steps.
368
386
  const scenario = match.scenario;
369
387
  if (!match.scenarioId || !isReadyScenario(scenario)) {
@@ -428,7 +446,13 @@ export class ReplayController {
428
446
  // built-in on `BIR_BUILTIN_TOOLS` counts as reachable: the runner
429
447
  // executes it itself (replay/builtins.ts), so a chain that starts with a
430
448
  // `Read` no longer forces steer mode.
431
- const mode = modeFor(planned.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools);
449
+ //
450
+ // Another host's plan is always `steer`, every step `live`: its tools are
451
+ // in its own process, and a tool it happens to call `Read` or
452
+ // `mcp__fleet__list` is not the runner's to run or a proxy's to reach.
453
+ const allowServers = host ? undefined : this.opts.allowServers;
454
+ const builtinTools = host ? undefined : this.builtinTools;
455
+ const mode = modeFor(planned.map((e) => e.step), state.wrapped, allowServers, builtinTools);
432
456
  if (mode === "none")
433
457
  return decline("no step is executable", "coverage");
434
458
  // Tool results feed derivation as context, and they are whatever the agent
@@ -441,11 +465,11 @@ export class ReplayController {
441
465
  // target only a result names declines more often — one replay, never a
442
466
  // command nobody asked for.
443
467
  let deriveCtx = ctx;
444
- if (ctx.recentResults?.length && sinkOfPlan(planned) === "command") {
468
+ if (ctx.recentResults?.length && sinkOfPlan(planned, host?.sinks) === "command") {
445
469
  deriveCtx = { liveCall: ctx.liveCall };
446
470
  logLine("replay.derive_context", {
447
471
  scenario: scenario.id,
448
- shell: hasShellStep(planned) || undefined,
472
+ shell: hasShellStep(planned, host?.sinks) || undefined,
449
473
  why: "a step's input is run by a shell or a database (or its tool is named for one, such as *_query) — " +
450
474
  "recent tool results are not read for its parameters, so a target only they name is not found",
451
475
  });
@@ -527,7 +551,7 @@ export class ReplayController {
527
551
  // same check still runs where the values settle later (`readyFor`).
528
552
  // It travels as `missing_target`: the value read for a target could not
529
553
  // be used, and no new code reaches a service that may not know it.
530
- const sink = sinkOfPlan(planned);
554
+ const sink = sinkOfPlan(planned, host?.sinks);
531
555
  if (result?.derived && sink) {
532
556
  const bad = unsafeParameter(result.params, scenario.paramsObject, sink);
533
557
  if (bad) {
@@ -590,6 +614,7 @@ export class ReplayController {
590
614
  run: match.runId,
591
615
  scenario: scenario.id,
592
616
  mode,
617
+ host: host?.app,
593
618
  steps: entries.length,
594
619
  stopsAt: stopAt,
595
620
  by: armedBy === "intent" ? "intent" : undefined,
@@ -602,11 +627,99 @@ export class ReplayController {
602
627
  unusable: flat.stopReason?.reason,
603
628
  // Which steps the service told this plan to stop in front of, and why.
604
629
  stops: stopLines(entries),
605
- coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers, this.builtinTools).join(","),
630
+ coverage: coverageOf(entries.map((e) => e.step), state.wrapped, allowServers, builtinTools).join(","),
606
631
  tools: entries.map((e) => e.step.toolName ?? "").join(","),
607
632
  });
608
633
  return state;
609
634
  }
635
+ /**
636
+ * The armed plan as data: the steps it will run, in order, and where it
637
+ * stops when that is before its end. For a host that steps through the plan
638
+ * itself and must not have to pick the tool names out of a directive
639
+ * written for a model — a reworded sentence would break every adapter in
640
+ * the field. Built from the same entries the directive lists.
641
+ */
642
+ planData(state) {
643
+ const plan = state.plan;
644
+ if (!plan || state.retired)
645
+ return undefined;
646
+ const steps = plan
647
+ .allEntries()
648
+ .slice(0, plan.stepCount)
649
+ .map((entry, stepIndex) => ({ stepIndex, toolName: entry.step.toolName ?? "" }));
650
+ return {
651
+ scenarioId: state.scenarioId,
652
+ mode: state.mode,
653
+ steps,
654
+ ...(plan.stopsEarly() ? { stopAt: plan.stepCount } : {}),
655
+ };
656
+ }
657
+ /**
658
+ * Take back a plan that armed and has run nothing, as a decline.
659
+ *
660
+ * For what `arm` could not know: which tools the agent of another host
661
+ * holds. Such a host shows its tools only when its model is about to be
662
+ * called, with the first request for a step, and a plan that names a tool
663
+ * the agent lacks cannot be finished there. It ends before any step runs,
664
+ * booked like any gate's decline: `not_steered` under `code`, no step
665
+ * marked failed or missing — either would count against a plan that is
666
+ * not at fault. Returns false, changing nothing, once a step has run.
667
+ */
668
+ declineArmed(state, code, why) {
669
+ if (!state.plan || state.retired)
670
+ return false;
671
+ if (state.stepsPinned > 0 || state.pinned.size > 0 || state.stepResults.size > 0)
672
+ return false;
673
+ state.declined = code;
674
+ logLine("replay.decision", {
675
+ verdict: "no-steer",
676
+ code,
677
+ run: state.matchedRunId,
678
+ scenario: state.scenarioId ?? undefined,
679
+ similarity: state.similarity.toFixed(3),
680
+ kind: state.kind,
681
+ why,
682
+ });
683
+ state.plan = undefined;
684
+ state.mode = "none";
685
+ state.stepsPlanned = 0;
686
+ this.retire(state, undefined);
687
+ return true;
688
+ }
689
+ /**
690
+ * Hand a live plan over for something its host did, not the plan: the
691
+ * model made a call of its own while the plan was live (`own_call`), the
692
+ * run was closed with the plan still live (`closed`), or the host was
693
+ * answered without a step's arguments because they were not ready in time
694
+ * (`late`).
695
+ *
696
+ * Only for a host that is not Claude Code, where a call the plan did not
697
+ * hand out is by definition the model's own — it is never pinned and never
698
+ * answered with a bundle. No step is marked failed, so nothing is counted
699
+ * against the plan; the outcome is `fell_back` when a step ran and `failed`
700
+ * when none did, as for every hand-over. The note is the caller's to
701
+ * deliver ({@link takeNote}). Returns false when there was no live plan.
702
+ */
703
+ hostHandOver(state, why) {
704
+ const plan = state.plan;
705
+ if (!plan || state.retired)
706
+ return false;
707
+ const position = plan.currentStepIndex;
708
+ this.handOver(state, {
709
+ kind: "step_failed",
710
+ step: plan.currentStep(),
711
+ entry: plan.currentEntry(),
712
+ position,
713
+ error: why === "own_call"
714
+ ? "the model made a call of its own, so the scenario stopped"
715
+ : why === "late"
716
+ ? "its arguments were not ready in time"
717
+ : "the run ended before this step",
718
+ delivery: "above",
719
+ stopReason: "not_run",
720
+ });
721
+ return true;
722
+ }
610
723
  /**
611
724
  * The directive to inject via `additionalContext`, or undefined when declined.
612
725
  *
@@ -650,6 +763,10 @@ export class ReplayController {
650
763
  const plan = state.plan;
651
764
  if (!plan || state.retired || state.mode !== "steer")
652
765
  return false;
766
+ // Another host reads the plan as data and is handed each step's arguments
767
+ // when it asks for them: there is no preview to wait for.
768
+ if (state.host)
769
+ return false;
653
770
  if (plan.parametersSettled)
654
771
  return true;
655
772
  const planned = plan.allEntries().slice(0, plan.stepCount);
@@ -695,9 +812,13 @@ export class ReplayController {
695
812
  * Deliver text to the model in place of the tool call it was about to make:
696
813
  * as genuine command output for `Bash`, as the denial reason otherwise — the
697
814
  * two channels a divergence bundle already uses.
815
+ *
816
+ * `otherHost` is a session whose host is not Claude Code: a tool it calls
817
+ * `Bash` is its own, and its command is never rewritten. The text is always
818
+ * the refusal reason there.
698
819
  */
699
- deliverInstead(text, toolName) {
700
- if (toolName === "Bash") {
820
+ deliverInstead(text, toolName, otherHost = false) {
821
+ if (toolName === "Bash" && !otherHost) {
701
822
  // A short command, whatever the text's size (bash-channel.ts): a long
702
823
  // heredoc is cut by the shell, and a long output by the host.
703
824
  const command = bashCommandFor(truncate(text, BASH_CHANNEL_MAX));
@@ -750,7 +871,8 @@ export class ReplayController {
750
871
  // `bir scenario show` the model runs while a Bash step is expected must
751
872
  // run as `bir`, never be swapped for the plan's command (D13). And before
752
873
  // the wait for the parameters, which a `ToolSearch` has no use for.
753
- if (isHousekeepingCall(toolName, toolInput)) {
874
+ const otherHost = state.host !== undefined;
875
+ if (isHousekeepingCall(toolName, toolInput, otherHost)) {
754
876
  logDetail("replay.housekeeping", {
755
877
  tool: toolName,
756
878
  step: `${plan.currentStepIndex}/${plan.stepCount}`,
@@ -797,7 +919,7 @@ export class ReplayController {
797
919
  stopReason: "not_run",
798
920
  });
799
921
  const note = this.takeNote(state);
800
- return note ? this.deliverInstead(note, toolName) : { kind: "abort" };
922
+ return note ? this.deliverInstead(note, toolName, otherHost) : { kind: "abort" };
801
923
  }
802
924
  // ONE CALL AT A TIME. A pin moves the cursor only when its output threads,
803
925
  // which for a wrapped step is the proxy's report — landing either side of
@@ -871,11 +993,29 @@ export class ReplayController {
871
993
  });
872
994
  const note = this.takeNote(state);
873
995
  if (note)
874
- return this.deliverInstead(note, toolName);
996
+ return this.deliverInstead(note, toolName, otherHost);
875
997
  }
876
998
  return { kind: "abort" };
877
999
  }
878
1000
  }
1001
+ if (state.host) {
1002
+ // Another host steps through the plan by asking for each step in turn,
1003
+ // so a request for anything but the next step is its adapter out of
1004
+ // step with the plan. Nothing can be recovered by running the rest
1005
+ // here — only the host can run its tools — so the plan stops, and the
1006
+ // model is told which steps ran. No step is marked failed.
1007
+ this.handOver(state, {
1008
+ kind: "step_failed",
1009
+ step: plan.currentStep(),
1010
+ entry: plan.currentEntry(),
1011
+ position: plan.currentStepIndex,
1012
+ error: `the scenario's next step is ${expected ?? "none"}, and the host asked for ${toolName}`,
1013
+ delivery: "above",
1014
+ stopReason: "not_run",
1015
+ });
1016
+ const note = this.takeNote(state);
1017
+ return note ? this.deliverInstead(note, toolName, true) : { kind: "abort" };
1018
+ }
879
1019
  return await this.diverge(state, toolName, `expected ${expected ?? "no more tools"}, model called ${toolName}`, deadline, hook.signal);
880
1020
  }
881
1021
  /**
@@ -927,7 +1067,9 @@ export class ReplayController {
927
1067
  if (inFlight.size > 0)
928
1068
  state.lingering = inFlight;
929
1069
  const note = this.takeNote(state);
930
- return note ? this.deliverInstead(note, toolName) : { kind: "passthrough" };
1070
+ return note
1071
+ ? this.deliverInstead(note, toolName, state.host !== undefined)
1072
+ : { kind: "passthrough" };
931
1073
  }
932
1074
  /**
933
1075
  * Wait until the plan's outstanding pins thread, or until waiting is no
@@ -1055,6 +1197,24 @@ export class ReplayController {
1055
1197
  // the step: a called segment numbers its steps from zero too, and without
1056
1198
  // this its step 0 replaced the caller's (segmented.md R-CALL-13).
1057
1199
  const entry = plan.allEntries()[pin.stepIndex];
1200
+ // THE HOST STOPPED THE STEP, and says so. Read before any failure: a
1201
+ // person's "no" arrives as an error result, and booked as one it would be
1202
+ // counted against a plan that did nothing wrong — after enough of them the
1203
+ // service stops serving the step. Nothing threads either way: after
1204
+ // `rejected` the tool never ran, and after `edited` its result answers a
1205
+ // call the plan did not plan, which its later steps were not computed for.
1206
+ if (how.hostStop && step) {
1207
+ this.hostStopped(state, pin, step, entry, output, how.hostStop);
1208
+ this.handOver(state, {
1209
+ kind: "step_failed",
1210
+ step,
1211
+ entry,
1212
+ position: pin.stepIndex,
1213
+ delivery: "above",
1214
+ stopReason: how.hostStop === "edited" ? "host_edited" : "host_rejected",
1215
+ });
1216
+ return false;
1217
+ }
1058
1218
  // The output threads into the step at the cursor. One pin at a time
1059
1219
  // (`preTool`) keeps that the pin's own step; if it ever is not, threading
1060
1220
  // would feed one step's output to another's logic, so the plan stops.
@@ -1226,6 +1386,11 @@ export class ReplayController {
1226
1386
  const step = plan.allSteps()[pin.stepIndex];
1227
1387
  if (!step)
1228
1388
  return;
1389
+ if (how.hostStop) {
1390
+ // The host stopped it: neither a failure nor work the plan did.
1391
+ this.hostStopped(state, pin, step, plan.allEntries()[pin.stepIndex], output, how.hostStop);
1392
+ return;
1393
+ }
1229
1394
  const ms = Date.now() - pin.pinnedAt;
1230
1395
  // Read the same way as a live pin's (`pinFailure`). A call that was sent
1231
1396
  // and not seen through says so in its verdict: it may have done its work.
@@ -1260,6 +1425,39 @@ export class ReplayController {
1260
1425
  why: "a step pinned before the hand-over finished — counted, not threaded",
1261
1426
  });
1262
1427
  }
1428
+ /**
1429
+ * Record a step its host stopped ({@link PostToolOptions.hostStop}) as
1430
+ * `skipped`, in plain words, and remember the stop for the report. Never
1431
+ * `failed`: the service counts failures against the plan. And not counted
1432
+ * in `stepsPinned`: the plan's own call did not run.
1433
+ */
1434
+ hostStopped(state, pin, step, entry, output, stop) {
1435
+ const edited = stop === "edited";
1436
+ // After `rejected` nothing later can change what the report may claim.
1437
+ if (state.hostStop !== "rejected")
1438
+ state.hostStop = stop;
1439
+ logLine("replay.step_host_stopped", {
1440
+ scenario: state.scenarioId ?? undefined,
1441
+ n: step.stepIndex,
1442
+ tool: step.toolName ?? undefined,
1443
+ stop,
1444
+ why: edited
1445
+ ? "a person changed this call before it ran — it ran with their arguments, and the plan stops there"
1446
+ : "the host did not run this step — a person said no or answered in its place, or a limit blocked it",
1447
+ });
1448
+ this.recordStep(state, {
1449
+ step,
1450
+ entry,
1451
+ // The plan's own arguments are what a refused call was asked with; a
1452
+ // changed call ran with other ones, which only the host knows.
1453
+ input: edited ? undefined : pin.input,
1454
+ output: answerOf(output),
1455
+ outcome: "skipped",
1456
+ stage: "tool_call",
1457
+ error: edited ? "a person changed this call" : "the host did not run this step",
1458
+ ms: Date.now() - pin.pinnedAt,
1459
+ });
1460
+ }
1263
1461
  /** Whether a pinned call's output is threaded from the proxy's report (§7.2). */
1264
1462
  threadsFromProxy(state, toolUseId) {
1265
1463
  const pin = state.pinned.get(toolUseId) ?? state.lingering?.get(toolUseId);
@@ -1529,8 +1727,14 @@ export class ReplayController {
1529
1727
  stepsPlanned: state.stepsPlanned,
1530
1728
  stepsPinned: state.stepsPinned,
1531
1729
  // A run its caller cancelled ended the turn early: its cost is no
1532
- // measurement of the task, whatever the transcript says.
1533
- measured: d.measured && state.callerCancelled !== true,
1730
+ // measurement of the task, whatever the transcript says. So is one a
1731
+ // person said no in: the model is told not to run that step, the task
1732
+ // ends short of itself, and a verified saving booked against it would
1733
+ // be the cost of the work nobody did.
1734
+ measured: d.measured &&
1735
+ state.callerCancelled !== true &&
1736
+ state.hostStop !== "rejected" &&
1737
+ d.cutShort !== true,
1534
1738
  pricingVersion: PRICING_VERSION,
1535
1739
  prompt: d.prompt,
1536
1740
  steps,
@@ -1559,7 +1763,14 @@ export class ReplayController {
1559
1763
  // wrong before — a declined prompt scenario followed by an intent-armed
1560
1764
  // plan, whose "baseline" was really the turn cut short at the second arm.
1561
1765
  // A lone plan, armed or declined, sends nothing: the default is eligible.
1562
- baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined)
1766
+ //
1767
+ // Nor is a turn its host stopped a step of, or one that was cut short.
1768
+ // After a person's "no" the task ended early; after a changed call that
1769
+ // was the plan's first step, the turn made one model call fewer than
1770
+ // the task needs — the one the plan's call replaced.
1771
+ baselineEligible: d.siblings?.some((s) => s !== state && s.plan !== undefined) ||
1772
+ state.hostStop !== undefined ||
1773
+ d.cutShort === true
1563
1774
  ? false
1564
1775
  : undefined,
1565
1776
  };
@@ -1915,7 +2126,7 @@ export class ReplayController {
1915
2126
  // done its work, and the model must know before it does the task itself.
1916
2127
  if (stop?.reason === "uncertain" && stop.flatIndex !== undefined) {
1917
2128
  const note = this.stopNote(state, plan, stop, "none");
1918
- return this.deliverInstead(note, toolName);
2129
+ return this.deliverInstead(note, toolName, state.host !== undefined);
1919
2130
  }
1920
2131
  return { kind: "abort" };
1921
2132
  }
@@ -1949,7 +2160,7 @@ export class ReplayController {
1949
2160
  // Bash-clean delivery: the model reaches for `Bash` when it cannot call a
1950
2161
  // scripted tool, and a genuine command output is trusted where a `deny`
1951
2162
  // reason is read as adversarial interception.
1952
- const action = this.deliverInstead(text, toolName);
2163
+ const action = this.deliverInstead(text, toolName, state.host !== undefined);
1953
2164
  logDetail("replay.inject", { channel: action.kind, bytes: text.length });
1954
2165
  return action;
1955
2166
  }
@@ -2033,9 +2244,11 @@ export class ReplayController {
2033
2244
  ? "a second call arrived while the pinned step was still running — the agent continues from here"
2034
2245
  : h.stopReason === "budget" || h.stopReason === "cancelled"
2035
2246
  ? "the run was cut short before this step — the agent continues from here"
2036
- : h.stopReason === "not_run" || h.stopReason === "uncertain"
2037
- ? "a step produced no result here — the agent continues from here"
2038
- : "a step broke — the agent continues from here",
2247
+ : h.stopReason === "host_rejected" || h.stopReason === "host_edited"
2248
+ ? "the host stopped this step itself — no fault of the plan; the agent continues from here"
2249
+ : h.stopReason === "not_run" || h.stopReason === "uncertain"
2250
+ ? "a step produced no result here — the agent continues from here"
2251
+ : "a step broke — the agent continues from here",
2039
2252
  });
2040
2253
  }
2041
2254
  /**
@@ -2052,6 +2265,9 @@ export class ReplayController {
2052
2265
  noteFor(state, h) {
2053
2266
  const plan = state.plan;
2054
2267
  const executed = h.ran ?? (h.ranThisStep ? h.position + 1 : h.position);
2268
+ // A step its host stopped was dealt with there — refused, answered, or
2269
+ // run with a person's arguments — so it is not among what is left to do.
2270
+ const handled = h.ranThisStep === true || h.stopReason === "host_rejected" || h.stopReason === "host_edited";
2055
2271
  let params;
2056
2272
  try {
2057
2273
  params = plan?.parameters;
@@ -2082,7 +2298,7 @@ export class ReplayController {
2082
2298
  inFlight: h.inFlight,
2083
2299
  ranThisStep: h.ranThisStep,
2084
2300
  totalSteps: plan?.totalSteps ?? state.stepsPlanned,
2085
- remainingTools: plan ? plan.toolsFrom(h.ranThisStep ? h.position + 1 : h.position) : [],
2301
+ remainingTools: plan ? plan.toolsFrom(handled ? h.position + 1 : h.position) : [],
2086
2302
  params,
2087
2303
  delivery: h.delivery,
2088
2304
  });
@@ -2103,6 +2319,12 @@ export class ReplayController {
2103
2319
  executeStep(state, opts = {}) {
2104
2320
  return async (step, input) => {
2105
2321
  const name = step.toolName ?? "";
2322
+ // Another host's tools are in its own process, whatever they are
2323
+ // named: one it calls `Bash` or `mcp__fleet__list` is not the runner's
2324
+ // built-in or a proxy's upstream. Nothing of such a plan runs here.
2325
+ if (state.host) {
2326
+ throw new StepRunError(`${name || "this step"} runs in the host's own process`, "not_run");
2327
+ }
2106
2328
  const mcp = parseQualifiedName(name);
2107
2329
  if (!mcp) {
2108
2330
  // An allow-listed built-in runs *here*, against the session's own
@@ -2254,7 +2476,9 @@ export class ReplayController {
2254
2476
  }
2255
2477
  }
2256
2478
  }
2257
- const sink = opts.adHoc ? undefined : sinkOfPlan(plan.allEntries().slice(0, plan.stepCount));
2479
+ const sink = opts.adHoc
2480
+ ? undefined
2481
+ : sinkOfPlan(plan.allEntries().slice(0, plan.stepCount), state.host?.sinks);
2258
2482
  if (sink) {
2259
2483
  const bad = unsafeParameter(plan.derivedValues, plan.paramsSchema, sink);
2260
2484
  if (bad) {
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { redactText } from "../record/redact.js";
34
34
  import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
35
+ import { serviceHeaders } from "../util/service-headers.js";
35
36
  import { calculateCostUsd } from "./pricing.js";
36
37
  export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
37
38
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
@@ -311,6 +312,7 @@ async function deriveViaService(opts, service, schema, keys) {
311
312
  headers: {
312
313
  "content-type": "application/json",
313
314
  authorization: `Bearer ${service.token}`,
315
+ ...serviceHeaders(),
314
316
  },
315
317
  body: JSON.stringify({
316
318
  prompt: outgoingPrompt(opts.prompt),
@@ -45,7 +45,19 @@ export type HandoverStopReason =
45
45
  /** The run was cancelled before it. */
46
46
  | "cancelled"
47
47
  /** Another of the plan's calls was still in flight (steer mode runs one at a time). */
48
- | "concurrent";
48
+ | "concurrent"
49
+ /**
50
+ * A host that is not Claude Code wrote the step's result itself, and its
51
+ * tool never ran: a person said no, a person answered in the tool's place,
52
+ * or a call limit blocked the call. Not a fault of the plan.
53
+ */
54
+ | "host_rejected"
55
+ /**
56
+ * The step's tool ran in such a host, with arguments a person chose in
57
+ * place of the plan's. Its result is real; the plan's later steps were
58
+ * computed for the call it planned, so it stops there.
59
+ */
60
+ | "host_edited";
49
61
  export interface HandoverNoteInput {
50
62
  intent: string;
51
63
  kind: FallbackKind;
@@ -56,6 +56,12 @@ export function buildHandoverNote(n) {
56
56
  // says it may have completed.
57
57
  lines.push("No step of the scenario is known to have finished; the task is yours.");
58
58
  }
59
+ else if (n.stopReason === "host_rejected" || n.stopReason === "host_edited") {
60
+ // The first step was answered by the host, or ran with a person's
61
+ // arguments: "the whole task is yours" would invite the model to do that
62
+ // step over, which the next line tells it not to.
63
+ lines.push("No step of the scenario ran as it was planned.");
64
+ }
59
65
  else {
60
66
  lines.push("No step of the scenario ran; the whole task is yours.");
61
67
  }
@@ -109,6 +115,19 @@ export function buildHandoverNote(n) {
109
115
  function stepFailedLine(n, human, tool) {
110
116
  const at = `Step ${human} of ${n.totalSteps}${tool}`;
111
117
  const detail = n.error ? truncate(n.error, MAX_ERROR_CHARS) : "";
118
+ // The two stops a host that is not Claude Code reports, first: neither is
119
+ // a failure, and neither is the model's to make good.
120
+ if (n.stopReason === "host_rejected") {
121
+ // One sentence for three causes — a person said no, a person answered in
122
+ // the tool's place, a call limit blocked the call — so it names none of
123
+ // them, and never says "do it yourself": the person may have meant no.
124
+ return (`${at} was not run by the host. The result above was written by the host, not by the tool. ` +
125
+ "Do not run the step again unless the user asks.");
126
+ }
127
+ if (n.stopReason === "host_edited") {
128
+ return (`${at} ran with arguments a person chose, not the scenario's. Its result above is real ` +
129
+ "and its side effects are in place. Do not run it again.");
130
+ }
112
131
  if (n.ranThisStep) {
113
132
  // Its tool ran and did its work; only the scenario's reading of the result
114
133
  // broke. A model told "failed" here runs the write again.
@@ -43,16 +43,27 @@ import type { ParamsSchema } from "./types.js";
43
43
  * or a database, `write` is only written into a file.
44
44
  */
45
45
  export type SinkKind = "command" | "write";
46
+ /**
47
+ * The sinks a host that is not Claude Code declared for its own tools, by
48
+ * tool name. Passing one — even an empty one — says the tool names are that
49
+ * host's: `Bash`, `Edit` and `mcp__server__tool` then mean nothing by
50
+ * themselves. A tool the host declared is what it was declared; any other is
51
+ * judged by the words of its plain name (`run_sql`, `execQuery`).
52
+ */
53
+ export interface HostSinks {
54
+ command?: readonly string[];
55
+ write?: readonly string[];
56
+ }
46
57
  /** Whether this tool's input is run by a shell. */
47
- export declare function isShellTool(toolName: string | null | undefined): boolean;
58
+ export declare function isShellTool(toolName: string | null | undefined, host?: HostSinks): boolean;
48
59
  /** How this tool consumes its input, or undefined when it is neither run nor written. */
49
- export declare function sinkOf(toolName: string | null | undefined): SinkKind | undefined;
60
+ export declare function sinkOf(toolName: string | null | undefined, host?: HostSinks): SinkKind | undefined;
50
61
  /** Whether this tool's input is run or written: a shell, a file edit, a query. */
51
62
  export declare function isCommandSink(toolName: string | null | undefined): boolean;
52
63
  /** The strictest sink among the planned steps, or undefined when there is none. */
53
- export declare function sinkOfPlan(entries: readonly FlatEntry[]): SinkKind | undefined;
64
+ export declare function sinkOfPlan(entries: readonly FlatEntry[], host?: HostSinks): SinkKind | undefined;
54
65
  /** Whether any planned step runs its input through a shell. */
55
- export declare function hasShellStep(entries: readonly FlatEntry[]): boolean;
66
+ export declare function hasShellStep(entries: readonly FlatEntry[], host?: HostSinks): boolean;
56
67
  /** Whether any planned step's input is run or written. */
57
68
  export declare function hasCommandSink(entries: readonly FlatEntry[]): boolean;
58
69
  /**
@@ -84,16 +84,29 @@ const UNSAFE = /[`$;&|<>\\"'\r\n\u0000]|--|\/\*/;
84
84
  * apostrophe, at the cost of a denied call and a model round trip each time.
85
85
  */
86
86
  const WRITE_UNSAFE = /[\r\n\u0000]/;
87
+ /** Whether a plain tool name of another host says its input is run. */
88
+ function plainNameSays(name, words) {
89
+ return words.test(nameWords(name));
90
+ }
87
91
  /** Whether this tool's input is run by a shell. */
88
- export function isShellTool(toolName) {
92
+ export function isShellTool(toolName, host) {
89
93
  const name = toolName ?? "";
94
+ if (host)
95
+ return plainNameSays(name, MCP_SHELL_WORD);
90
96
  if (SHELL_BUILTINS.has(name))
91
97
  return true;
92
98
  return mcpNameSays(name, MCP_SHELL_WORD);
93
99
  }
94
100
  /** How this tool consumes its input, or undefined when it is neither run nor written. */
95
- export function sinkOf(toolName) {
101
+ export function sinkOf(toolName, host) {
96
102
  const name = toolName ?? "";
103
+ if (host) {
104
+ if (host.command?.includes(name))
105
+ return "command";
106
+ if (host.write?.includes(name))
107
+ return "write";
108
+ return plainNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
109
+ }
97
110
  if (SHELL_BUILTINS.has(name))
98
111
  return "command";
99
112
  if (WRITE_BUILTINS.has(name))
@@ -105,10 +118,10 @@ export function isCommandSink(toolName) {
105
118
  return sinkOf(toolName) !== undefined;
106
119
  }
107
120
  /** The strictest sink among the planned steps, or undefined when there is none. */
108
- export function sinkOfPlan(entries) {
121
+ export function sinkOfPlan(entries, host) {
109
122
  let found;
110
123
  for (const e of entries) {
111
- const sink = sinkOf(e.step.toolName);
124
+ const sink = sinkOf(e.step.toolName, host);
112
125
  if (sink === "command")
113
126
  return sink;
114
127
  found ??= sink;
@@ -116,8 +129,8 @@ export function sinkOfPlan(entries) {
116
129
  return found;
117
130
  }
118
131
  /** Whether any planned step runs its input through a shell. */
119
- export function hasShellStep(entries) {
120
- return entries.some((e) => isShellTool(e.step.toolName));
132
+ export function hasShellStep(entries, host) {
133
+ return entries.some((e) => isShellTool(e.step.toolName, host));
121
134
  }
122
135
  /** Whether any planned step's input is run or written. */
123
136
  export function hasCommandSink(entries) {
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import { logDetail, logLine, errText } from "../util/log.js";
42
42
  import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
43
+ import { serviceHeaders } from "../util/service-headers.js";
43
44
  export class SourceRunCache {
44
45
  opts;
45
46
  /** run id → every recorded output of that run, in order. */
@@ -109,7 +110,7 @@ export class SourceRunCache {
109
110
  let res;
110
111
  try {
111
112
  res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
112
- headers: { authorization: `Bearer ${token}` },
113
+ headers: { authorization: `Bearer ${token}`, ...serviceHeaders() },
113
114
  signal: timeoutSignal(this.opts.timeoutMs ?? serviceTimeoutMs()),
114
115
  });
115
116
  }