@basein/runner 0.3.7 → 0.3.8

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.
package/README.md CHANGED
@@ -319,15 +319,19 @@ prompt you would have seen — only a call that plausibly is the step, though (t
319
319
  same command, the same file, the same kind of arguments); any other call runs as
320
320
  written and is asked about as usual. A *directly executed* step never reaches the
321
321
  permission system at all — it is dispatched to a `bir-proxy` and called on a
322
- connection that is already open — and `run_scenario`, the call that starts the
322
+ connection that is already open, or, for Claude Code's own `Read`, `Glob`,
323
+ `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself in the project
324
+ folder — and `run_scenario`, the call that starts the
323
325
  run, is answered `allow` while the recorder has announced the plan. In plan mode
324
326
  nothing is announced and nothing is approved. Set `BIR_REPLAY_ALLOW_SERVERS` to
325
327
  the servers you are content to have called unattended (it also covers a steered
326
- plan's recovery), and read [the guide's §5.1](docs/calculatedReplayGuide.md)
328
+ plan's recovery), `BIR_BUILTIN_TOOLS` and `BIR_BASH_ALLOW` to the built-ins and
329
+ commands, and read [the guide's §5.1](docs/calculatedReplayGuide.md)
327
330
  before enabling it anywhere that matters.
328
331
 
329
- What it buys: on a scenario whose every step is a wrapped MCP tool, the whole
330
- sequence runs through connections the proxies already hold — **zero model tokens**
332
+ What it buys: on a scenario whose every step is a wrapped MCP tool or one of
333
+ those built-ins, the whole sequence runs through connections the proxies already
334
+ hold, or on the recorder itself — **zero model tokens**
331
335
  — and the model's only job is one tool call that reads the results. Such a plan
332
336
  is announced only while a `bir` server has told the recorder it is connected,
333
337
  and runs only through `run_scenario`: if the model calls anything else first,
@@ -438,8 +442,8 @@ what to do when they register, so there is one switch and no second copy of it:
438
442
  |---|---|
439
443
  | `BIR_REPLAY` | `0` turns replay off and any other value on, over the project's stored switch (`bir replay on` / `off`; a first plain `bir install` stores off). Nothing below matters while it is off. |
440
444
  | `BIR_REPLAY_ALLOW_SERVERS` | Server keys bir may call unattended: **direct** execution, and a steered plan's recovery. Unset means the list `bir replay allow` stored for the project, else every wrapped server; narrowing it is the recommendation. |
441
- | `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — in the session's own project directory, and confined to it. Default `Read,Glob,Grep` (read-only). Add `Edit,Write,Bash` only deliberately: a runner-side step never reaches Claude Code's permission prompts. `none` turns it off, and a built-in step hands the turn back as before. |
442
- | `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, as Claude Code's do (variables and functions do not persist, also as in Claude Code). |
445
+ | `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — in the session's own project directory, and confined to it. Default all six: `Read,Glob,Grep,Edit,Write,Bash` (up to 0.3.7 only the read-only `Read,Glob,Grep`). A runner-side step never reaches Claude Code's permission prompts, so leave off the list any tool you want the model to call itself. `none` turns it off, and built-in steps are steered in the session. |
446
+ | `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run. Unset means any command (`*`); a list such as `git,npm test` narrows it; `none` allows no command, and `Bash` steps are then steered in the session. A plan's Bash steps share a working directory, as Claude Code's do (variables and functions do not persist, also as in Claude Code). |
443
447
  | `BIR_DERIVE_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this (an Anthropic key) only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
444
448
  | `ANTHROPIC_API_KEY` | Not used by the background recorder, which has Claude Code's environment and would bill your own key — unless `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand in a terminal uses that terminal's key. |
445
449
  | `BIR_DERIVE_MODEL` | Derivation model, when you set a key of your own (default `claude-haiku-4-5-20251001`). |
@@ -64,15 +64,15 @@
64
64
  * BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
65
65
  * itself during a replay, in the session's own
66
66
  * project directory and confined to it
67
- * (replay/builtins.ts). Default Read,Glob,Grep —
68
- * the read-only set. Add Edit,Write,Bash only
69
- * deliberately: a runner-side step never reaches
70
- * the host's permission prompts. `none` turns
71
- * built-ins off, and then a built-in step hands
72
- * the turn back, as before
67
+ * (replay/builtins.ts). Default all six:
68
+ * Read,Glob,Grep,Edit,Write,Bash. A runner-side
69
+ * step never reaches the host's permission
70
+ * prompts; Read,Glob,Grep is the read-only set
71
+ * (the default up to 0.3.7). `none` turns
72
+ * built-ins off, and their steps are steered
73
73
  * BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
74
- * (`*` for all). Unset, Bash never executes on the
75
- * runner even when BIR_BUILTIN_TOOLS lists it
74
+ * (`*` for all). Unset means `*`; `none` means no
75
+ * command, and Bash steps are steered
76
76
  * BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
77
77
  * on — is done by the service for a signed-in
78
78
  * runner. Set this (an Anthropic key) only to keep
@@ -64,15 +64,15 @@
64
64
  * BIR_BUILTIN_TOOLS comma-separated built-ins the runner may execute
65
65
  * itself during a replay, in the session's own
66
66
  * project directory and confined to it
67
- * (replay/builtins.ts). Default Read,Glob,Grep —
68
- * the read-only set. Add Edit,Write,Bash only
69
- * deliberately: a runner-side step never reaches
70
- * the host's permission prompts. `none` turns
71
- * built-ins off, and then a built-in step hands
72
- * the turn back, as before
67
+ * (replay/builtins.ts). Default all six:
68
+ * Read,Glob,Grep,Edit,Write,Bash. A runner-side
69
+ * step never reaches the host's permission
70
+ * prompts; Read,Glob,Grep is the read-only set
71
+ * (the default up to 0.3.7). `none` turns
72
+ * built-ins off, and their steps are steered
73
73
  * BIR_BASH_ALLOW command prefixes a runner-side Bash step may run
74
- * (`*` for all). Unset, Bash never executes on the
75
- * runner even when BIR_BUILTIN_TOOLS lists it
74
+ * (`*` for all). Unset means `*`; `none` means no
75
+ * command, and Bash steps are steered
76
76
  * BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
77
77
  * on — is done by the service for a signed-in
78
78
  * runner. Set this (an Anthropic key) only to keep
@@ -17,6 +17,7 @@
17
17
  * declined and what mode armed; the service knows the baseline, the ledger and
18
18
  * whether a run ever became a recording. Both lists are printed, worst first.
19
19
  */
20
+ import { EXECUTABLE_BUILTINS } from "../replay/builtins.js";
20
21
  import { ReadClock, jsonText, readWithin, serviceProblem, underMcp, unreachable } from "./service-read.js";
21
22
  const num = (v) => {
22
23
  if (v === undefined || v === null || v === "")
@@ -267,12 +268,25 @@ export function localFindings(turn) {
267
268
  .filter((t) => Boolean(t));
268
269
  if (turn.plan.mode === "steer" && live.length > 0) {
269
270
  const lowSaving = savedPct(turn) != null && savedPct(turn) < LOW_SAVING_PCT;
271
+ const names = [...new Set(live)];
272
+ // One of the runner's own built-ins steered only because it was off the
273
+ // list (or the turn ran on a runner older than 0.3.8); anything else can
274
+ // run directly only behind a wrapped MCP server.
275
+ const offList = names.filter((n) => EXECUTABLE_BUILTINS.has(n));
276
+ const elsewhere = names.filter((n) => !EXECUTABLE_BUILTINS.has(n));
277
+ const fixes = [];
278
+ if (offList.length > 0) {
279
+ fixes.push(`The runner runs ${offList.join(", ")} itself when ${offList.length === 1 ? "it is" : "they are"} on \`BIR_BUILTIN_TOOLS\` (unset means all six, from runner 0.3.8) and, for Bash, \`BIR_BASH_ALLOW\` is not \`none\`: fix those where the recorder starts, then \`bir up --restart\`.`);
280
+ }
281
+ if (elsewhere.length > 0) {
282
+ fixes.push(`Put the data ${elsewhere.join(", ")} ${elsewhere.length === 1 ? "reaches" : "reach"} behind an MCP server, wrap it (\`bir install --server <name>\`), and record the prompt again so its steps are MCP calls.`);
283
+ }
270
284
  out.push({
271
285
  code: "steer_mode",
272
286
  severity: lowSaving ? "warn" : "info",
273
- title: `The plan armed in steer mode: ${live.length} of ${coverage.length} steps are reachable only inside the session (${[...new Set(live)].join(", ")}).`,
274
- cause: "In steer mode the runner pins each call's inputs and auto-approves it, but the model still emits every tool call, reads every result and writes the answer — so the whole live turn is still paid for. Direct mode, where the model makes no tool call, needs every step to be a wrapped MCP server tool.",
275
- fix: "Put the data those tools reach behind an MCP server, wrap it (`bir install --server <name>`), and record the prompt again so its steps are MCP calls.",
287
+ title: `The plan armed in steer mode: ${live.length} of ${coverage.length} steps are reachable only inside the session (${names.join(", ")}).`,
288
+ cause: "In steer mode the runner pins each call's inputs and auto-approves it, but the model still emits every tool call, reads every result and writes the answer — so the whole live turn is still paid for. Direct mode, where the model makes no tool call, needs every step to be a wrapped MCP server tool or a built-in the runner runs itself (Read, Glob, Grep, Edit, Write, Bash).",
289
+ fix: fixes.join(" "),
276
290
  evidence: { coverage, tools },
277
291
  });
278
292
  }
@@ -280,8 +294,8 @@ export function localFindings(turn) {
280
294
  out.push({
281
295
  code: "direct_mode",
282
296
  severity: "info",
283
- title: `The plan armed in direct mode: ${turn.plan.steps ?? coverage.length} steps ran on the proxies, no model in the loop.`,
284
- cause: "Every step is a wrapped MCP tool this session has a proxy for.",
297
+ title: `The plan armed in direct mode: ${turn.plan.steps ?? coverage.length} steps ran on the proxies and the runner, no model in the loop.`,
298
+ cause: "Every step is a wrapped MCP tool this session has a proxy for, or a built-in the runner runs itself.",
285
299
  fix: "",
286
300
  });
287
301
  }
@@ -332,7 +346,7 @@ export function localFindings(turn) {
332
346
  title: `Saved ${pct(p)}: ${usd(r.savedUsd ?? 0)} of a ${usd((r.savedUsd ?? 0) + cost)} baseline.`,
333
347
  cause: `The live session cost ${usd(r.session)} of that baseline${turn.plan?.mode === "steer" ? " — steer mode pays for the whole turn." : "."}`,
334
348
  fix: turn.plan?.mode === "steer"
335
- ? "Direct mode is the fix: wrap the servers so every step is an MCP call (see steer_mode)."
349
+ ? "Direct mode is the fix (see steer_mode for what kept this plan out of it)."
336
350
  : "Compare the baseline with a few unsteered runs (BIR_REPLAY=0); a single cheap source run makes any replay look poor.",
337
351
  evidence: { ...r, costUsd: cost, savedPct: p },
338
352
  });
@@ -175,9 +175,9 @@ function buildReplayOptions(keeper, policy, key) {
175
175
  ? parseAllowList(policy.allowServers.join(","))
176
176
  : undefined;
177
177
  // Which built-ins the runner may execute itself (replay/builtins.ts):
178
- // read-only by default, `BIR_BUILTIN_TOOLS` to change, `BIR_BASH_ALLOW`
179
- // before a `Bash` step may run at all. Environment-only knobs, like
180
- // `BIR_REPLAY_RECORDED_OUTPUTS`.
178
+ // all six by default, `BIR_BUILTIN_TOOLS` to narrow them, `BIR_BASH_ALLOW`
179
+ // to narrow the commands a `Bash` step may run. Environment-only knobs,
180
+ // like `BIR_REPLAY_RECORDED_OUTPUTS`.
181
181
  const builtinTools = parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
182
182
  const bashAllow = parseBashAllow(process.env.BIR_BASH_ALLOW);
183
183
  const opts = {
@@ -239,7 +239,7 @@ function buildReplayOptions(keeper, policy, key) {
239
239
  // Which built-ins may run on the runner itself, and whether Bash may at
240
240
  // all — loud for the same reason as the rest of this line.
241
241
  builtins: builtinTools.size > 0 ? [...builtinTools].join(",") : "(none)",
242
- bashAllow: builtinTools.has("Bash") ? (bashAllow ? bashAllow.join(",") : "(unset — Bash never runs here)") : undefined,
242
+ bashAllow: builtinTools.has("Bash") ? (bashAllow.length > 0 ? bashAllow.join(",") : "(none — Bash steps are steered)") : undefined,
243
243
  // Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
244
244
  // startup because the third state is a replay that silently never runs.
245
245
  derive: key.source ??
@@ -31,7 +31,7 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
- import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
34
+ import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
36
  import { redact } from "../record/redact.js";
37
37
  import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateStrings } from "../record/truncate.js";
@@ -2346,15 +2346,18 @@ export class ControlServer {
2346
2346
  const agentId = payload.agent_id ?? session.agentId;
2347
2347
  // The real intent, read from every line sharing this message's id
2348
2348
  // (segmented.md R-INTENT-3). `context` stays the *written* text only, with
2349
- // its agent prefix: thinking is embedded but never stored as text
2350
- // (R-INTENT-2), so it rides separately in `intent`.
2349
+ // its agent prefix, so thinking rides separately in `intent`.
2351
2350
  const intent = intentForToolUse(session.transcriptPath, toolUseId, this.intentSources);
2352
2351
  const reasoning = intent?.source === "text" ? intent.text : "";
2353
2352
  // The prefix rides on whether there is a subagent, not on whether it said
2354
2353
  // anything: a subagent's steps must be tellable apart even when it narrated
2355
2354
  // nothing. What it never carries is thinking (R-INTENT-2).
2356
2355
  const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
2357
- const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
2356
+ // Sent whether or not there was written text: the service keeps it for the
2357
+ // recording page (R-INTENT-2, amended 2026-09-29), and still embeds the
2358
+ // written text first. The probe below reads `intent`, unchanged.
2359
+ const thinking = thinkingForToolUse(session.transcriptPath, toolUseId, this.intentSources);
2360
+ const thinkingIntent = thinking ? { text: thinking, source: "thinking" } : undefined;
2358
2361
  // ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
2359
2362
  // Runs before anything else, because two of its four answers end the call.
2360
2363
  // Not in plan mode (nothing is approved on the user's behalf there), not
@@ -78,6 +78,15 @@ export interface ToolUseIntent {
78
78
  * next rung tried. It governs only what may be *sent*; it never stops a probe.
79
79
  */
80
80
  export declare function intentForToolUse(transcriptPath: string | undefined, toolUseId: string, sources?: ReadonlySet<IntentSource>): ToolUseIntent | undefined;
81
+ /**
82
+ * The thinking `toolUseId` was chosen in, whether or not the agent also wrote
83
+ * text. The service keeps it with the step so the recording page can show it
84
+ * (segmented.md R-INTENT-2, amended 2026-09-29); {@link intentForToolUse}
85
+ * returns thinking only in place of missing text, which is what matching wants.
86
+ *
87
+ * `sources` is `BIR_INTENT_SOURCES`: without `thinking` in it, none is sent.
88
+ */
89
+ export declare function thinkingForToolUse(transcriptPath: string | undefined, toolUseId: string, sources?: ReadonlySet<IntentSource>): string | undefined;
81
90
  /** The assistant text that accompanied `toolUseId`, or "" when unavailable. */
82
91
  export declare function contextForToolUse(transcriptPath: string | undefined, toolUseId: string): string;
83
92
  /** The last assistant text in the transcript — the turn's final answer. */
@@ -92,8 +92,9 @@ const MAX_INDEXES = 16;
92
92
  const MAX_MESSAGES = 128;
93
93
  /**
94
94
  * Characters of one message's text, and separately of its thinking, kept.
95
- * Text becomes a step's `context`; thinking is only embedded, and the intent
96
- * probe sends 4,000 characters of either.
95
+ * Text becomes a step's `context`; thinking goes with the step as its
96
+ * `intent`, capped again at 8 KB on the way out, and the intent probe sends
97
+ * 4,000 characters of either.
97
98
  */
98
99
  const MAX_MESSAGE_CHARS = 16 * 1024;
99
100
  /** `tool_use_id`s remembered, newest last. */
@@ -443,11 +444,7 @@ export function recentToolResults(transcriptPath, n = 5, maxBytes = 2048) {
443
444
  * next rung tried. It governs only what may be *sent*; it never stops a probe.
444
445
  */
445
446
  export function intentForToolUse(transcriptPath, toolUseId, sources = new Set(["text", "thinking", "tool"])) {
446
- if (!transcriptPath || !toolUseId)
447
- return undefined;
448
- const idx = indexFor(transcriptPath);
449
- const holder = idx?.toolUses.get(toolUseId);
450
- const message = holder ? idx?.messages.get(holder.message) : undefined;
447
+ const message = messageFor(transcriptPath, toolUseId);
451
448
  if (!message)
452
449
  return undefined;
453
450
  if (sources.has("text")) {
@@ -462,6 +459,28 @@ export function intentForToolUse(transcriptPath, toolUseId, sources = new Set(["
462
459
  }
463
460
  return undefined;
464
461
  }
462
+ /**
463
+ * The thinking `toolUseId` was chosen in, whether or not the agent also wrote
464
+ * text. The service keeps it with the step so the recording page can show it
465
+ * (segmented.md R-INTENT-2, amended 2026-09-29); {@link intentForToolUse}
466
+ * returns thinking only in place of missing text, which is what matching wants.
467
+ *
468
+ * `sources` is `BIR_INTENT_SOURCES`: without `thinking` in it, none is sent.
469
+ */
470
+ export function thinkingForToolUse(transcriptPath, toolUseId, sources = new Set(["text", "thinking", "tool"])) {
471
+ if (!sources.has("thinking"))
472
+ return undefined;
473
+ const thinking = messageFor(transcriptPath, toolUseId)?.thinkings.join("\n").trim();
474
+ return thinking || undefined;
475
+ }
476
+ /** The message that emitted `toolUseId`: its text and thinking, gathered across its lines. */
477
+ function messageFor(transcriptPath, toolUseId) {
478
+ if (!transcriptPath || !toolUseId)
479
+ return undefined;
480
+ const idx = indexFor(transcriptPath);
481
+ const holder = idx?.toolUses.get(toolUseId);
482
+ return holder ? idx?.messages.get(holder.message) : undefined;
483
+ }
465
484
  /** The assistant text that accompanied `toolUseId`, or "" when unavailable. */
466
485
  export function contextForToolUse(transcriptPath, toolUseId) {
467
486
  if (!transcriptPath || !toolUseId)
@@ -62,7 +62,7 @@ export declare const TEXT_CAPS: {
62
62
  readonly answer: number;
63
63
  /** A step's written reasoning. */
64
64
  readonly context: number;
65
- /** A step's thinking text, sent only to be embedded. */
65
+ /** A step's thinking text: embedded, and kept for the recording page. */
66
66
  readonly intent: number;
67
67
  /** A tool's error text when it is not JSON. */
68
68
  readonly toolError: number;
@@ -66,7 +66,7 @@ export const TEXT_CAPS = {
66
66
  answer: 64 * 1024,
67
67
  /** A step's written reasoning. */
68
68
  context: 8 * 1024,
69
- /** A step's thinking text, sent only to be embedded. */
69
+ /** A step's thinking text: embedded, and kept for the recording page. */
70
70
  intent: 8 * 1024,
71
71
  /** A tool's error text when it is not JSON. */
72
72
  toolError: 8 * 1024,
@@ -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
@@ -36,14 +39,15 @@
36
39
  * best effort and may drift from a future host's, the same caveat the
37
40
  * hook-view threading fallback already carries ("shapes may differ").
38
41
  */
42
+ /** The built-ins {@link runBuiltin} actually implements. */
43
+ export declare const EXECUTABLE_BUILTINS: ReadonlySet<string>;
39
44
  /**
40
45
  * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
41
- * unset: the read-only set. Reading a file the session itself would read
42
- * without a prompt is the risk floor; anything that writes or runs is not.
46
+ * unset: every one it implements, so a chain of built-ins arms `direct` just
47
+ * as a chain of wrapped MCP calls does. Up to 0.3.7 this was the read-only
48
+ * set (`Read,Glob,Grep`).
43
49
  */
44
50
  export declare const DEFAULT_BUILTIN_TOOLS: readonly string[];
45
- /** The built-ins {@link runBuiltin} actually implements. */
46
- export declare const EXECUTABLE_BUILTINS: ReadonlySet<string>;
47
51
  /**
48
52
  * Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
49
53
  * it is the whole list — `none` (or an empty value) turns built-ins off
@@ -54,10 +58,11 @@ export declare const EXECUTABLE_BUILTINS: ReadonlySet<string>;
54
58
  export declare function parseBuiltinTools(value: string | undefined): ReadonlySet<string>;
55
59
  /**
56
60
  * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
57
- * everything. Undefined (unset or blank) means `Bash` never executes on the
58
- * runner — the flag is the opt-in, exactly like the tool list itself.
61
+ * everything. Unset means everything, because `Bash` is on the default list.
62
+ * Set, it is the whole list, as `BIR_BUILTIN_TOOLS` is: `none` (or an empty
63
+ * value) allows no command.
59
64
  */
60
- export declare function parseBashAllow(value: string | undefined): readonly string[] | undefined;
65
+ export declare function parseBashAllow(value: string | undefined): readonly string[];
61
66
  /**
62
67
  * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
63
68
  * mid-word must end the command's word too: `git` allows `git status` and
@@ -92,7 +97,7 @@ export interface BuiltinContext {
92
97
  cwd: string;
93
98
  /** The step's clock, the same budget an MCP dispatch would get. */
94
99
  timeoutMs?: number;
95
- /** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). */
100
+ /** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). Absent or empty, no command runs. */
96
101
  bashAllow?: readonly string[];
97
102
  /**
98
103
  * Where this plan's *previous* `Bash` step left its shell — the host's own
@@ -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>;
@@ -475,7 +475,7 @@ export declare class ReplayController {
475
475
  /** See {@link ReplayOptions.builtinTools}. */
476
476
  private readonly builtinTools;
477
477
  /** See {@link ReplayOptions.bashAllow}. */
478
- private readonly bashAllow?;
478
+ private readonly bashAllow;
479
479
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
480
480
  private readonly pinWaiters;
481
481
  /**
@@ -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).
@@ -2067,10 +2073,10 @@ export class ReplayController {
2067
2073
  // An allow-listed built-in runs *here*, against the session's own
2068
2074
  // project directory (replay/builtins.ts). Everything else about it —
2069
2075
  // 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`).
2076
+ // runner-side implementation, a path that leaves the project, a `Bash`
2077
+ // command `BIR_BASH_ALLOW` does not allow — stays the old contract:
2078
+ // `not_run`, so the session can do the step itself or its recorded
2079
+ // output can stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2074
2080
  if (this.builtinTools.has(name) && state.sessionCwd) {
2075
2081
  return this.executeBuiltin(name, input, state, opts.deadline);
2076
2082
  }
@@ -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.
@@ -45,7 +45,7 @@ this design has a ladder whose cheapest rung is free — and the more of a stack
45
45
  |---|---|---|
46
46
  | Runs MCP tools itself | No (`replay.md` §12: *"rrepeat never executes tools — not even MCP"*) | **Yes**, on the connection it already owns |
47
47
  | Divergence fallback | A second Agent SDK session — the dominant cost | Direct `tools/call` through the proxies — **$0** |
48
- | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | Read-only ones (`Read`, `Glob`, `Grep`) run on the runner itself, jailed to the project (`BIR_BUILTIN_TOOLS`); `Edit`/`Write`/`Bash` opt-in, else steered |
48
+ | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash` run on the runner itself, jailed to the project (`BIR_BUILTIN_TOOLS`, `BIR_BASH_ALLOW`); any other built-in is steered |
49
49
  | Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
50
50
 
51
51
  ---
@@ -252,21 +252,26 @@ export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<st
252
252
  | — | `none` | nowhere | declines at gate 5 |
253
253
 
254
254
  `live` covers three genuinely different cases and treats them identically, correctly: a built-in
255
- off the `BIR_BUILTIN_TOOLS` allow-list (by default everything but `Read`, `Glob`, `Grep` — so
256
- `Bash`, `Edit`, `Write`, `Task`, `WebFetch`), an MCP server the user chose not to wrap, and
255
+ off the `BIR_BUILTIN_TOOLS` allow-list (by default everything but the six the runner implements —
256
+ so `Task`, `WebFetch`; up to 0.3.7 also `Bash`, `Edit`, `Write`), an MCP server the user chose not to wrap, and
257
257
  `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
258
258
  be wrapped (v1 design §1). None of them can be reached except through the session.
259
259
 
260
260
  A built-in **on** the allow-list runs on the runner, in the session's own project directory and
261
261
  confined to it — a path that resolves outside the project, symlinks included, is refused and the
262
- step is `not_run`, the clean hand-over. `Bash` needs a second opt-in on top (`BIR_BASH_ALLOW`,
263
- command prefixes), because a runner-side step never reaches the host's permission prompts. The
262
+ step is `not_run`, the clean hand-over. `Bash` has a second switch on top (`BIR_BASH_ALLOW`,
263
+ command prefixes; unset means any command, `none` keeps `Bash` in the session), because a
264
+ runner-side step never reaches the host's permission prompts. The
264
265
  results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
265
266
  and what their `toolOutputLogic` was authored against; they pass through the same
266
267
  `serializeCapped(redact(…))` as every recorded payload. Bash keeps the host's own semantics a
267
268
  recorded chain was written against: each call is a fresh `-l` shell (variables and functions do
268
269
  not persist), while the **working directory carries over** from the plan's previous Bash step —
269
- `cd sub` then `cat file` replays as it ran.
270
+ `cd sub` then `cat file` replays as it ran. Two Bash calls the host runs differently are never
271
+ started on the runner, and are `not_run` instead: one with `run_in_background` (there is no shell
272
+ here to leave it in), and one whose own `timeout` is longer than the step's clock
273
+ (`BIR_STEP_TIMEOUT_MS`, clipped to what the plan has left) — cut off part-way, an `npm i -g` or a
274
+ migration can leave things half done.
270
275
 
271
276
  A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
272
277
  diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
@@ -234,7 +234,10 @@ When replay arms:
234
234
  when it plausibly *is* the step — the same command, the same file, an argument in common (design
235
235
  §7) — and never in plan mode.
236
236
  - **Direct steps are not visible to the permission system at all.** They are dispatched straight to a
237
- `bir-proxy`, which calls the upstream on a connection it already holds. Claude Code never sees them.
237
+ `bir-proxy`, which calls the upstream on a connection it already holds — or, for Claude Code's own
238
+ `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself in the project
239
+ folder, any `Bash` command included (`BIR_BUILTIN_TOOLS`, `BIR_BASH_ALLOW`; up to 0.3.7 only the
240
+ first three). Claude Code never sees them.
238
241
  The `run_scenario` call that starts them is answered `allow` too, while the runner has announced a
239
242
  direct plan; your own deny or ask rules for it still apply.
240
243
 
@@ -243,7 +246,9 @@ That is what makes replay fast, and it is the whole risk. Two things follow:
243
246
  1. Only enable it in a project whose scenarios you have dry-replayed and read (§3.1).
244
247
  2. Run `bir replay allow <servers>` (§5.3). An allowlist of the servers you are comfortable having
245
248
  called unattended is cheap insurance, and it is the difference between "replay reads a dashboard"
246
- and "replay can do anything any wrapped server can do".
249
+ and "replay can do anything any wrapped server can do". The same goes for built-ins:
250
+ `BIR_BUILTIN_TOOLS=Read,Glob,Grep` keeps writes and commands with the model, and
251
+ `BIR_BASH_ALLOW=git,npm test` (for example) limits the commands the recorder runs itself.
247
252
 
248
253
  Nothing else is approved on your behalf. Outside a matched scenario's own steps, a wrapped MCP call
249
254
  keeps its permission prompt, as it would without the runner (`bir doctor` says so in a note);
@@ -354,8 +359,8 @@ so there is exactly one switch and it cannot get out of step with itself.
354
359
  | `BIR_STEP_TIMEOUT_MS` | `60000` | Per-step ceiling for one direct `tools/call`, clipped to what the plan has left |
355
360
  | `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
356
361
  | `BIR_REPLAY_RECORDED_OUTPUTS` | *(unset)* | `1` lets a step that could not be sent (a built-in off `BIR_BUILTIN_TOOLS`, no proxy, a server off the allowlist) stand on its recorded output instead of stopping the plan. Never for a timeout or an upstream error |
357
- | `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's own project directory and confined to it. A chain whose only non-MCP steps are on this list arms `direct`. Add `Edit,Write,Bash` only deliberately — a runner-side step never reaches the host's permission prompts. `none` restores the old behaviour: every built-in hands the turn back |
358
- | `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's |
362
+ | `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, in the session's own project directory and confined to it. A chain whose only non-MCP steps are on this list arms `direct`. A runner-side step never reaches the host's permission prompts, so leave off any tool you want the model to call itself; `Read,Glob,Grep` is the read-only set (the default up to 0.3.7). `none`: every built-in step is steered in the session |
363
+ | `BIR_BASH_ALLOW` | *(unset: any command)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered in the session. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's. A call with `run_in_background`, or with its own `timeout` longer than the step's clock (`BIR_STEP_TIMEOUT_MS`), is never started on the runner: the plan hands over in front of it |
359
364
  | `BIR_INTENT_ARM` | *(unset)* | `1` lets a match found mid-turn arm a plan in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500); `0` never waits; unset follows `BIR_SEGMENT_ARM` |
360
365
  | `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
361
366
 
@@ -553,7 +558,7 @@ matches. A prompt that missed both is matched once more on its task line (`PROMP
553
558
  | `replay.vetoed … the model's first call was …, not run_scenario` | A direct plan, and the model reached for another tool first — usually because it has no `run_scenario` | As for `no_delivery_tool`. Nothing ran; the model did the task |
554
559
  | `replay.pin_declined` on most steered turns | The model's calls to the expected tool are not the step — another command, another file — so nothing is pinned; if it then moves on, `replay.pin_abandoned` retires the plan | Harmless for those calls, which ran as the model wrote them. Persistent declines on one step usually mean its arguments cannot be shown in the directive (they depend on an earlier step's output, or the command runs to several lines), and the model writes its own |
555
560
  | `why="no step is executable"` | None of the scenario's tools is a wrapped MCP server and none is a built-in reachable in this session | Wrap the servers the scenario uses (`bir install --server …`) and recalculate |
556
- | `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in off `BIR_BUILTIN_TOOLS` (default `Read,Glob,Grep` — so usually `Edit`, `Write` or `Bash`), an unwrapped MCP server, or `claude-in-chrome` | `bir status` shows what is wrapped; the `plan.armed` line's `coverage=` names the `live` step. Widen `BIR_BUILTIN_TOOLS` (and `BIR_BASH_ALLOW`) only deliberately. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
561
+ | `plan.armed mode=steer` where you expected `direct` | At least one step is a built-in off `BIR_BUILTIN_TOOLS` (by default one the runner does not implement, such as `WebFetch` or `Task`; a `Bash` step too when `BIR_BASH_ALLOW=none`), an unwrapped MCP server, or `claude-in-chrome`. Before 0.3.8 the default list was `Read,Glob,Grep`, so `Edit`, `Write` and `Bash` steered too | `bir status` shows what is wrapped; the `plan.armed` line's `coverage=` names the `live` step. `claude-in-chrome` is `scope: "dynamic"` and can never be wrapped |
557
562
  | Model ignores the directive and diverges every turn | Steering is advisory — the model chooses; `bir` only pins arguments | Expected occasionally. Persistent divergence usually means the scenario's tools do not fit the live task; check `intent` |
558
563
  | A `replay.thread` line with no `emitted=` (under `BIR_VERBOSE=1`) | `toolOutputLogic` derived nothing — usually the output shape changed since recording | Dry-replay (§3.1). If the dry run emits and the live run does not, the tool's output shape has drifted; recalculate |
559
564
  | `replay.thread_fallback` on a wrapped step | The proxy did not report within the grace window | Check that proxy is alive (`bir doctor`). Repeats mean a slow or dying upstream |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/production.md CHANGED
@@ -991,8 +991,8 @@ Defaults were checked in `src/`.
991
991
  | `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
992
992
  | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
993
993
  | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
994
- | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it. Add `Edit,Write,Bash` deliberately — runner-side steps bypass the host's permission prompts. `none` turns it off. |
995
- | `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. A plan's Bash steps share a working directory, as the host's do; variables do not persist, also as the host's. |
994
+ | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it (up to 0.3.7 the default was `Read,Glob,Grep`). Runner-side steps bypass the host's permission prompts; leave a tool off to have the model call it. `none` turns it off. |
995
+ | `BIR_BASH_ALLOW` | recorder | unset (any command) | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered. A plan's Bash steps share a working directory, as the host's do; variables do not persist, also as the host's. |
996
996
  | `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
997
997
  | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
998
998
  | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |