@basein/runner 0.3.7 → 0.3.9

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.
@@ -10,18 +10,21 @@
10
10
  *
11
11
  * THE RULES, because this bypasses the host's permission prompts entirely:
12
12
  *
13
- * - **Default deny beyond read-only.** Which built-ins may run at all is
14
- * `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}), and its default is the
15
- * read-only set (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` are an
16
- * explicit, per-environment opt-in — a recorded `rm` must never run
17
- * because a prompt looked similar.
13
+ * - **All six by default, each a switch.** Which built-ins may run at all is
14
+ * `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}); unset, it is every one
15
+ * this module implements (up to 0.3.7 it was the read-only set,
16
+ * `Read,Glob,Grep`). The permission story is the one a steered step
17
+ * already has — its pinned call is answered `allow` too. What running it
18
+ * here changes is that no model sees the call before it runs, so an
19
+ * operator who wants one in the loop takes the tool off the list.
18
20
  * - **Path jail.** Every path is resolved against, and confined to, the
19
21
  * session's project directory ({@link jailed}), symlinks included. The
20
22
  * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
21
23
  * project (§5.1 of the change plan).
22
- * - **Bash runs nothing unless `BIR_BASH_ALLOW` says so.** A command must
23
- * match one of its prefixes ({@link bashAllowed}); unset means `Bash`
24
- * never executes here, even when allow-listed.
24
+ * - **Bash runs any command unless `BIR_BASH_ALLOW` narrows it.** Set, a
25
+ * command must match one of its prefixes ({@link bashAllowed}); `none`
26
+ * allows no command, and then `Bash` counts as off the list, so its steps
27
+ * are steered in the session (controller.ts).
25
28
  * - **Same failure contract as an MCP step.** A step that *cannot* run here
26
29
  * throws {@link BuiltinNotRunnable} (→ `not_run`: the session can still do
27
30
  * it, or a recorded output may stand in under
@@ -42,12 +45,6 @@ import { existsSync } from "node:fs";
42
45
  import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
43
46
  import { dirname, join, resolve, sep } from "node:path";
44
47
  // ── configuration ────────────────────────────────────────────────────────────
45
- /**
46
- * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
47
- * unset: the read-only set. Reading a file the session itself would read
48
- * without a prompt is the risk floor; anything that writes or runs is not.
49
- */
50
- export const DEFAULT_BUILTIN_TOOLS = ["Read", "Glob", "Grep"];
51
48
  /** The built-ins {@link runBuiltin} actually implements. */
52
49
  export const EXECUTABLE_BUILTINS = new Set([
53
50
  "Read",
@@ -57,6 +54,13 @@ export const EXECUTABLE_BUILTINS = new Set([
57
54
  "Write",
58
55
  "Bash",
59
56
  ]);
57
+ /**
58
+ * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
59
+ * unset: every one it implements, so a chain of built-ins arms `direct` just
60
+ * as a chain of wrapped MCP calls does. Up to 0.3.7 this was the read-only
61
+ * set (`Read,Glob,Grep`).
62
+ */
63
+ export const DEFAULT_BUILTIN_TOOLS = [...EXECUTABLE_BUILTINS];
60
64
  /** `read` → `Read`: the vocabulary is small enough to forgive casing. */
61
65
  const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
62
66
  /**
@@ -78,15 +82,17 @@ export function parseBuiltinTools(value) {
78
82
  }
79
83
  /**
80
84
  * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
81
- * everything. Undefined (unset or blank) means `Bash` never executes on the
82
- * runner — the flag is the opt-in, exactly like the tool list itself.
85
+ * everything. Unset means everything, because `Bash` is on the default list.
86
+ * Set, it is the whole list, as `BIR_BUILTIN_TOOLS` is: `none` (or an empty
87
+ * value) allows no command.
83
88
  */
84
89
  export function parseBashAllow(value) {
85
- const prefixes = (value ?? "")
90
+ if (value === undefined)
91
+ return ["*"];
92
+ return value
86
93
  .split(",")
87
94
  .map((s) => s.trim())
88
- .filter(Boolean);
89
- return prefixes.length > 0 ? prefixes : undefined;
95
+ .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()));
90
96
  }
91
97
  /**
92
98
  * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
@@ -485,13 +491,24 @@ async function runBash(input, ctx) {
485
491
  if (typeof command !== "string" || command.trim() === "") {
486
492
  throw new BuiltinNotRunnable("command is required");
487
493
  }
488
- if (!ctx.bashAllow) {
489
- throw new BuiltinNotRunnable("BIR_BASH_ALLOW is not set, so bir runs no Bash command unattended");
494
+ if (!ctx.bashAllow?.length) {
495
+ throw new BuiltinNotRunnable("BIR_BASH_ALLOW allows no command, so bir runs no Bash command unattended");
490
496
  }
491
497
  if (!bashAllowed(command, ctx.bashAllow)) {
492
498
  throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
493
499
  }
500
+ // What the host would run differently stays the session's, never started
501
+ // here: a command it leaves in the background (there is no shell here to
502
+ // leave it in, so it would be stopped with the step), and one that asks for
503
+ // more time than this step has. Cut off part-way, an `npm i -g` or a
504
+ // migration can leave things half done, which is worse than a hand-over.
505
+ if (input.run_in_background === true) {
506
+ throw new BuiltinNotRunnable("the command runs in the background, which only the session can do");
507
+ }
494
508
  const timeoutMs = ctx.timeoutMs ?? 120_000;
509
+ if (typeof input.timeout === "number" && input.timeout > timeoutMs) {
510
+ throw new BuiltinNotRunnable(`the command asks for up to ${Math.ceil(input.timeout / 1000)} s and this step has ${Math.floor(timeoutMs / 1000)} s, so the session runs it`);
511
+ }
495
512
  // The host's Bash semantics, which the recorded chain was written against:
496
513
  // the working directory carries over from the plan's previous Bash step,
497
514
  // variables and functions do not (a fresh `-l` shell each call).
@@ -191,17 +191,17 @@ export interface ReplayOptions {
191
191
  * `BIR_BUILTIN_TOOLS` — the built-ins the runner may execute itself, from
192
192
  * the session's own project directory (replay/builtins.ts), or stand in
193
193
  * for with a recorded output under `BIR_REPLAY_RECORDED_OUTPUTS=1`.
194
- * Omitted, the environment decides; the default is the read-only set
195
- * (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` run only when listed
196
- * explicitly — and `Bash` additionally only for commands matching
197
- * {@link ReplayOptions.bashAllow}.
194
+ * Omitted, the environment decides; the default is all six (`Read`,
195
+ * `Glob`, `Grep`, `Edit`, `Write`, `Bash`), and `Bash` runs only the
196
+ * commands {@link ReplayOptions.bashAllow} allows.
198
197
  */
199
198
  builtinTools?: ReadonlySet<string>;
200
199
  /**
201
200
  * `BIR_BASH_ALLOW` — command prefixes a runner-side `Bash` step may run
202
- * (`*` for all). Undefined means `Bash` never executes here, even when
203
- * allow-listed: runner-side execution bypasses the host's permission
204
- * prompts, so the shell needs its own explicit opt-in.
201
+ * (`*` for all). Omitted, the environment decides; the default is `*`.
202
+ * Empty allows no command, and then `Bash` counts as off
203
+ * {@link ReplayOptions.builtinTools}: its steps are steered in the session,
204
+ * rather than armed `direct` and stopped at the first one.
205
205
  */
206
206
  bashAllow?: readonly string[];
207
207
  budgets?: Partial<ReplayBudgets>;
@@ -273,6 +273,12 @@ interface PinnedCall {
273
273
  toolName: string;
274
274
  /** When it was pinned — a steered step's duration is measured across hooks. */
275
275
  pinnedAt: number;
276
+ /**
277
+ * The arguments the pin gave the call, for the step's verdict: the plan
278
+ * never sees a steered call, so this is the only place they are known when
279
+ * its result lands.
280
+ */
281
+ input?: Record<string, unknown>;
276
282
  /**
277
283
  * When the host said the call finished (`PostToolUse`), if the control
278
284
  * server told us ({@link ReplayController.hostCompleted}). A wrapped step
@@ -475,7 +481,7 @@ export declare class ReplayController {
475
481
  /** See {@link ReplayOptions.builtinTools}. */
476
482
  private readonly builtinTools;
477
483
  /** See {@link ReplayOptions.bashAllow}. */
478
- private readonly bashAllow?;
484
+ private readonly bashAllow;
479
485
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
480
486
  private readonly pinWaiters;
481
487
  /**
@@ -854,7 +860,14 @@ export declare class ReplayController {
854
860
  * never be told something the log does not also say.
855
861
  */
856
862
  private observeStep;
857
- /** Upsert one step's verdict. Later news about a step replaces earlier news. */
863
+ /**
864
+ * Upsert one step's verdict. Later news about a step replaces earlier news.
865
+ *
866
+ * The verdict keeps what the step sent and what came back, when the caller
867
+ * has them. They are this run's, which the service can learn nowhere else:
868
+ * a matched turn is not recorded, so without them the scenario's page shows
869
+ * the recording's input and output beside this run's verdict.
870
+ */
858
871
  private recordStep;
859
872
  /** The step verdicts, in chain order, for the execution report. */
860
873
  private stepResultsOf;
@@ -13,8 +13,8 @@
13
13
  */
14
14
  import { parseQualifiedName } from "../control/correlation.js";
15
15
  import { isHousekeepingCall } from "../record/housekeeping.js";
16
- import { redact } from "../record/redact.js";
17
- import { serializeCapped } from "../record/truncate.js";
16
+ import { REDACT_SCAN_MAX_CHARS, redact, redactJsonText, redactText } from "../record/redact.js";
17
+ import { capText, REPORT_PAYLOAD_MAX_BYTES, serializeCapped } from "../record/truncate.js";
18
18
  import { logDetail, logLine, errText } from "../util/log.js";
19
19
  import { serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
20
20
  import { bashCommandFor } from "./bash-channel.js";
@@ -250,8 +250,14 @@ export class ReplayController {
250
250
  this.intentMatch = { ...DEFAULT_INTENT_MATCH, ...(opts.intentMatch ?? {}) };
251
251
  this.recordedFallback =
252
252
  opts.recordedFallback ?? process.env.BIR_REPLAY_RECORDED_OUTPUTS === "1";
253
- this.builtinTools = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
254
253
  this.bashAllow = opts.bashAllow ?? parseBashAllow(process.env.BIR_BASH_ALLOW);
254
+ const builtins = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
255
+ // A `Bash` that may run no command is not reachable here. Left on the
256
+ // list, a chain of Bash steps would arm `direct` and stop at its first.
257
+ this.builtinTools =
258
+ this.bashAllow.length > 0 || !builtins.has("Bash")
259
+ ? builtins
260
+ : new Set([...builtins].filter((name) => name !== "Bash"));
255
261
  }
256
262
  /**
257
263
  * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
@@ -773,7 +779,13 @@ export class ReplayController {
773
779
  // The model does the task itself — told so in place of this call.
774
780
  const step = plan.currentStep();
775
781
  if (step && ready.blame) {
776
- this.recordStep(state, { step, outcome: "failed", stage: ready.stage, error: ready.error });
782
+ this.recordStep(state, {
783
+ step,
784
+ entry: plan.currentEntry(),
785
+ outcome: "failed",
786
+ stage: ready.stage,
787
+ error: ready.error,
788
+ });
777
789
  }
778
790
  this.handOver(state, {
779
791
  kind: "step_failed",
@@ -823,6 +835,7 @@ export class ReplayController {
823
835
  reach: this.reachFor(state, step),
824
836
  toolName,
825
837
  pinnedAt: Date.now(),
838
+ input,
826
839
  });
827
840
  logDetail("replay.pin", { n: plan.currentStepIndex, tool: toolName });
828
841
  return { kind: "pin", input, stepIndex: plan.currentStepIndex };
@@ -844,6 +857,7 @@ export class ReplayController {
844
857
  if (step) {
845
858
  this.recordStep(state, {
846
859
  step,
860
+ entry: plan.currentEntry(),
847
861
  outcome: "failed",
848
862
  stage: "tool_input_logic",
849
863
  error: errText(err),
@@ -1037,6 +1051,10 @@ export class ReplayController {
1037
1051
  // reading it after the call would judge the *next* step, and reading it
1038
1052
  // before assumes no other pin ever moved it.
1039
1053
  const step = plan.allSteps()[pin.stepIndex];
1054
+ // The entry it ran as, so its verdict is kept under the scenario that owns
1055
+ // the step: a called segment numbers its steps from zero too, and without
1056
+ // this its step 0 replaced the caller's (segmented.md R-CALL-13).
1057
+ const entry = plan.allEntries()[pin.stepIndex];
1040
1058
  // The output threads into the step at the cursor. One pin at a time
1041
1059
  // (`preTool`) keeps that the pin's own step; if it ever is not, threading
1042
1060
  // would feed one step's output to another's logic, so the plan stops.
@@ -1051,6 +1069,9 @@ export class ReplayController {
1051
1069
  `the plan's cursor is at step ${plan.currentStepIndex}, not the pinned step ${pin.stepIndex}`;
1052
1070
  const info = {
1053
1071
  step,
1072
+ entry,
1073
+ input: pin.input,
1074
+ output: answerOf(output),
1054
1075
  outcome: "failed",
1055
1076
  stage: "tool_call",
1056
1077
  error,
@@ -1062,7 +1083,7 @@ export class ReplayController {
1062
1083
  this.handOver(state, {
1063
1084
  kind: "step_failed",
1064
1085
  step,
1065
- entry: plan.allEntries()[pin.stepIndex],
1086
+ entry,
1066
1087
  position: pin.stepIndex,
1067
1088
  error,
1068
1089
  delivery: "above",
@@ -1084,6 +1105,10 @@ export class ReplayController {
1084
1105
  if (step) {
1085
1106
  this.recordStep(state, {
1086
1107
  step,
1108
+ entry,
1109
+ input: pin.input,
1110
+ // What the logic threw on — the output is usually the explanation.
1111
+ output,
1087
1112
  outcome: "failed",
1088
1113
  stage: "tool_output_logic",
1089
1114
  error: errText(err),
@@ -1119,6 +1144,9 @@ export class ReplayController {
1119
1144
  if (step && toolError) {
1120
1145
  const info = {
1121
1146
  step,
1147
+ entry,
1148
+ input: pin.input,
1149
+ output,
1122
1150
  outcome: "failed",
1123
1151
  stage: "tool_call",
1124
1152
  error: toolError,
@@ -1140,6 +1168,9 @@ export class ReplayController {
1140
1168
  if (step) {
1141
1169
  this.recordStep(state, {
1142
1170
  step,
1171
+ entry,
1172
+ input: pin.input,
1173
+ output,
1143
1174
  outcome: "executed",
1144
1175
  ms: Date.now() - pin.pinnedAt,
1145
1176
  });
@@ -1204,13 +1235,23 @@ export class ReplayController {
1204
1235
  ? `${failure.error} — it was sent, and may or may not have completed`
1205
1236
  : failure.error
1206
1237
  : toolResultError(output);
1238
+ const entry = plan.allEntries()[pin.stepIndex];
1207
1239
  if (error !== undefined) {
1208
- const info = { step, outcome: "failed", stage: "tool_call", error, ms };
1240
+ const info = {
1241
+ step,
1242
+ entry,
1243
+ input: pin.input,
1244
+ output: answerOf(output),
1245
+ outcome: "failed",
1246
+ stage: "tool_call",
1247
+ error,
1248
+ ms,
1249
+ };
1209
1250
  this.logStep(info);
1210
1251
  this.recordStep(state, info);
1211
1252
  return;
1212
1253
  }
1213
- this.recordStep(state, { step, outcome: "executed", ms });
1254
+ this.recordStep(state, { step, entry, input: pin.input, output, outcome: "executed", ms });
1214
1255
  state.stepsPinned += 1;
1215
1256
  this.upgrade(state, "fell_back");
1216
1257
  logDetail("replay.thread_late", {
@@ -2067,10 +2108,10 @@ export class ReplayController {
2067
2108
  // An allow-listed built-in runs *here*, against the session's own
2068
2109
  // project directory (replay/builtins.ts). Everything else about it —
2069
2110
  // no session cwd (`bir replay`, an older server), off the list, no
2070
- // runner-side implementation, a path that leaves the project, `Bash`
2071
- // without `BIR_BASH_ALLOW` — stays the old contract: `not_run`, so
2072
- // the session can do the step itself or its recorded output can
2073
- // stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2111
+ // runner-side implementation, a path that leaves the project, a `Bash`
2112
+ // command `BIR_BASH_ALLOW` does not allow — stays the old contract:
2113
+ // `not_run`, so the session can do the step itself or its recorded
2114
+ // output can stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2074
2115
  if (this.builtinTools.has(name) && state.sessionCwd) {
2075
2116
  return this.executeBuiltin(name, input, state, opts.deadline);
2076
2117
  }
@@ -2302,7 +2343,14 @@ export class ReplayController {
2302
2343
  this.recordStep(state, info);
2303
2344
  };
2304
2345
  }
2305
- /** Upsert one step's verdict. Later news about a step replaces earlier news. */
2346
+ /**
2347
+ * Upsert one step's verdict. Later news about a step replaces earlier news.
2348
+ *
2349
+ * The verdict keeps what the step sent and what came back, when the caller
2350
+ * has them. They are this run's, which the service can learn nowhere else:
2351
+ * a matched turn is not recorded, so without them the scenario's page shows
2352
+ * the recording's input and output beside this run's verdict.
2353
+ */
2306
2354
  recordStep(state, info) {
2307
2355
  const status = info.outcome === "executed" ? "ok" : info.outcome;
2308
2356
  // Keyed by the *flat* position, not by the step's own index: two scenarios
@@ -2319,6 +2367,16 @@ export class ReplayController {
2319
2367
  ? `${info.error.slice(0, MAX_STEP_ERROR_CHARS)}…`
2320
2368
  : info.error
2321
2369
  : undefined;
2370
+ // Payloads only for a step of the reported scenario's own chain. The
2371
+ // console still files an execution's verdicts by step number alone
2372
+ // (plan-services.md W1.9), and a called segment numbers its steps from
2373
+ // zero as well: a called step's call would be shown under the caller's
2374
+ // step of the same number — the very mix-up the payloads are there to end.
2375
+ const own = !info.entry || info.entry.depth === 0;
2376
+ // A step whose input logic threw never had an input: the `{}` the plan
2377
+ // reports there is a placeholder, not a call with no arguments.
2378
+ const sent = own && info.stage !== "tool_input_logic" ? info.input : undefined;
2379
+ const received = own ? info.output : undefined;
2322
2380
  state.stepResults.set(key, {
2323
2381
  stepIndex: info.step.stepIndex,
2324
2382
  toolName: info.step.toolName ?? "",
@@ -2328,6 +2386,8 @@ export class ReplayController {
2328
2386
  stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
2329
2387
  error,
2330
2388
  durationMs: info.ms,
2389
+ toolInput: sent === undefined ? undefined : reportPayload(serializeCapped(redact(sent)), true),
2390
+ toolOutput: received === undefined ? undefined : reportPayload(received),
2331
2391
  // The tool is gone from its server: the service repairs the plan without
2332
2392
  // waiting out the grace (plan-services.md D8, kind 2).
2333
2393
  toolMissing: info.toolMissing || undefined,
@@ -2501,6 +2561,37 @@ function pinFailure(pin, output, failed, how) {
2501
2561
  : "the tool reported an error");
2502
2562
  return { error, reason };
2503
2563
  }
2564
+ /**
2565
+ * What a call that did not thread answered with, if it answered anything.
2566
+ * `null` is a proxy that lost the call (tool-error.ts, `isNoResult`), and `{}`
2567
+ * is how a hook that carried no `tool_response` reaches here: neither is an
2568
+ * output anybody received.
2569
+ */
2570
+ function answerOf(output) {
2571
+ return isNoResult(output) || output.trim() === "{}" ? undefined : output;
2572
+ }
2573
+ /**
2574
+ * A step's input or output as its verdict carries it: redacted as a recorded
2575
+ * step's is, and cut — visibly — to what a report has room for.
2576
+ *
2577
+ * Cut as text rather than kept as JSON: these are read on the scenario's page,
2578
+ * never parsed, and the first kilobytes of the payload itself read better than
2579
+ * a preview of it inside a second layer of escapes.
2580
+ *
2581
+ * `clean` says it was redacted a moment ago — the input, serialized by the
2582
+ * caller. An output may not have been: the hook's view of a steered built-in
2583
+ * is threaded as the host gave it. So an output is redacted here, and as JSON,
2584
+ * where a secret is judged by its key as well as by its shape.
2585
+ */
2586
+ function reportPayload(json, clean = false) {
2587
+ if (clean)
2588
+ return capText(json, REPORT_PAYLOAD_MAX_BYTES);
2589
+ // Larger than any producer here makes: only what a report keeps is scanned.
2590
+ if (json.length > REDACT_SCAN_MAX_CHARS) {
2591
+ return capText(json, REPORT_PAYLOAD_MAX_BYTES, redactText);
2592
+ }
2593
+ return capText(redactJsonText(json), REPORT_PAYLOAD_MAX_BYTES);
2594
+ }
2504
2595
  /**
2505
2596
  * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
2506
2597
  *
@@ -8,14 +8,15 @@
8
8
  * only happen inside the live session.
9
9
  *
10
10
  * `live` covers three genuinely different cases and treats them identically,
11
- * correctly: a built-in (`Bash`, `Read`, `Edit`, …) outside the built-in
12
- * allow-list, an MCP server the user chose not to wrap, and `claude-in-chrome`
13
- * — which is `scope: "dynamic"`, appears in no config file, and therefore can
14
- * never be wrapped at all.
11
+ * correctly: a built-in outside the built-in allow-list (`WebFetch`, `Task`,
12
+ * or one the operator took off it), an MCP server the user chose not to wrap,
13
+ * and `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config
14
+ * file, and therefore can never be wrapped at all.
15
15
  *
16
- * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default `Read,Glob,
17
- * Grep`) is `direct`: the runner executes it itself (replay/builtins.ts), or —
18
- * under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves its recorded output. The
16
+ * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, by default all six the
17
+ * runner implements) is `direct`: the runner executes it itself
18
+ * (replay/builtins.ts), or — under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
19
+ * its recorded output. The
19
20
  * callers that ask a *different* question, "will a proxy report this call"
20
21
  * (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
21
22
  * is never proxied, whoever executes it.
@@ -8,14 +8,15 @@
8
8
  * only happen inside the live session.
9
9
  *
10
10
  * `live` covers three genuinely different cases and treats them identically,
11
- * correctly: a built-in (`Bash`, `Read`, `Edit`, …) outside the built-in
12
- * allow-list, an MCP server the user chose not to wrap, and `claude-in-chrome`
13
- * — which is `scope: "dynamic"`, appears in no config file, and therefore can
14
- * never be wrapped at all.
11
+ * correctly: a built-in outside the built-in allow-list (`WebFetch`, `Task`,
12
+ * or one the operator took off it), an MCP server the user chose not to wrap,
13
+ * and `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config
14
+ * file, and therefore can never be wrapped at all.
15
15
  *
16
- * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, default `Read,Glob,
17
- * Grep`) is `direct`: the runner executes it itself (replay/builtins.ts), or —
18
- * under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves its recorded output. The
16
+ * A built-in *on* the allow-list (`BIR_BUILTIN_TOOLS`, by default all six the
17
+ * runner implements) is `direct`: the runner executes it itself
18
+ * (replay/builtins.ts), or — under `BIR_REPLAY_RECORDED_OUTPUTS=1` — serves
19
+ * its recorded output. The
19
20
  * callers that ask a *different* question, "will a proxy report this call"
20
21
  * (`ReplayController.reachFor`), deliberately pass no allow-list: a built-in
21
22
  * is never proxied, whoever executes it.
@@ -105,6 +105,12 @@ export interface StepInfo {
105
105
  */
106
106
  entry?: FlatEntry;
107
107
  input: Record<string, unknown>;
108
+ /**
109
+ * What the step's output logic was given: the tool's answer, or for
110
+ * `recorded` the source run's output that stood in for it. Absent when the
111
+ * call produced none — `skipped`, or a failure before the tool ran.
112
+ */
113
+ output?: string;
108
114
  outcome: "executed" | "recorded" | "skipped" | "failed";
109
115
  /** Which part of the step broke. Only set for `failed`. */
110
116
  stage?: ExecutionStage;
@@ -215,8 +215,11 @@ function settingsOf(paramsObject, mapped) {
215
215
  * still abandons the whole bundle rather than shipping a half-computed one. All
216
216
  * this adds is that the failure is now *attributable* — "step 2's tool input
217
217
  * logic", rather than a stack trace the console cannot line up against a chain.
218
+ *
219
+ * `output` is what the body was given, for a stage that runs on one: a throw
220
+ * in the output logic is only readable next to the output it threw on.
218
221
  */
219
- function reportingStage(onStep, entry, stage, input, startedAt, fn) {
222
+ function reportingStage(onStep, entry, stage, input, startedAt, fn, output) {
220
223
  try {
221
224
  return fn();
222
225
  }
@@ -225,6 +228,7 @@ function reportingStage(onStep, entry, stage, input, startedAt, fn) {
225
228
  step: entry.step,
226
229
  entry,
227
230
  input,
231
+ output,
228
232
  outcome: "failed",
229
233
  stage,
230
234
  error: err instanceof Error ? err.message : String(err),
@@ -817,6 +821,7 @@ export class ScenarioReplayPlan {
817
821
  step,
818
822
  entry,
819
823
  input: computed,
824
+ output: response,
820
825
  outcome: "failed",
821
826
  stage: "tool_call",
822
827
  error: toolError,
@@ -845,7 +850,7 @@ export class ScenarioReplayPlan {
845
850
  Object.assign(resp.get(entry.frame.id) ?? {}, derived);
846
851
  }
847
852
  this.leaveFrame(entry, resp);
848
- });
853
+ }, response);
849
854
  }
850
855
  catch (err) {
851
856
  if (!stopOnFailure)
@@ -872,6 +877,7 @@ export class ScenarioReplayPlan {
872
877
  step,
873
878
  entry,
874
879
  input: computed,
880
+ output: response,
875
881
  outcome: error ? "failed" : "executed",
876
882
  stage: error ? "tool_call" : undefined,
877
883
  error,
@@ -977,6 +983,7 @@ export class ScenarioReplayPlan {
977
983
  step,
978
984
  entry,
979
985
  input: computed,
986
+ output: response,
980
987
  outcome: "failed",
981
988
  stage: "tool_call",
982
989
  error: toolError,
@@ -997,7 +1004,7 @@ export class ScenarioReplayPlan {
997
1004
  }
998
1005
  let derivedKeys;
999
1006
  try {
1000
- derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response));
1007
+ derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response), response);
1001
1008
  }
1002
1009
  catch (err) {
1003
1010
  if (!stopOnFailure)
@@ -1024,6 +1031,7 @@ export class ScenarioReplayPlan {
1024
1031
  step,
1025
1032
  entry,
1026
1033
  input: computed,
1034
+ output: response,
1027
1035
  outcome: error ? "failed" : "executed",
1028
1036
  stage: error ? "tool_call" : undefined,
1029
1037
  error,
@@ -1095,6 +1103,8 @@ export class ScenarioReplayPlan {
1095
1103
  step,
1096
1104
  entry,
1097
1105
  input: computed,
1106
+ // The stand-in is what the step's output logic runs on from here.
1107
+ output: standIn,
1098
1108
  outcome: standIn !== undefined ? "recorded" : "skipped",
1099
1109
  error,
1100
1110
  toolMissing: isMissingToolError(error) || undefined,
@@ -249,6 +249,22 @@ export interface ExecutionStepResult {
249
249
  /** Message only — never a tool payload; this is rendered in a web page. */
250
250
  error?: string;
251
251
  durationMs?: number;
252
+ /**
253
+ * What the step sent its tool on this run: the arguments its input logic
254
+ * computed, serialized and redacted as a recorded step's are, and cut to
255
+ * what a report carries. Absent when the step broke before it had any.
256
+ *
257
+ * Without the two payloads the scenario's page can only put the recording's
258
+ * sample under this run's verdict and time — a call that was not made, and
259
+ * an answer that was not given, on a card that says "passed".
260
+ */
261
+ toolInput?: string;
262
+ /**
263
+ * What the step's output logic was given, on the same terms: the tool's
264
+ * answer, or for `recorded` the source run's output that stood in for it.
265
+ * Absent on a step that got none.
266
+ */
267
+ toolOutput?: string;
252
268
  /**
253
269
  * The step's tool no longer exists on its server, read from the error text
254
270
  * (plan-services.md D8, kind 2). The service then repairs the plan without