@basein/runner 0.3.19 → 0.3.21

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.
@@ -18,7 +18,7 @@ import { capText, DEFAULT_MAX_PAYLOAD_BYTES, REPORT_PAYLOAD_MAX_BYTES, serialize
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";
21
- import { BuiltinNotRunnable, BuiltinTimeout, parseBashAllow, parseBuiltinTools, runBuiltin, } from "./builtins.js";
21
+ import { BuiltinNotRunnable, BuiltinTimeout, FILE_BUILTINS, parseBashAllow, parseBuiltinTools, parsePathAllow, runBuiltin, } from "./builtins.js";
22
22
  import { BASH_CHANNEL_MAX, MAX_REPLAY_REASON, testWords, truncate } from "./bundle.js";
23
23
  import { coverageOf, modeFor, reachOf } from "./coverage.js";
24
24
  import { deriveParameters, localDay, } from "./derive.js";
@@ -216,6 +216,8 @@ export class ReplayController {
216
216
  builtinTools;
217
217
  /** See {@link ReplayOptions.bashAllow}. */
218
218
  bashAllow;
219
+ /** See {@link ReplayOptions.pathAllow}. */
220
+ pathAllow;
219
221
  /** Calls waiting for a plan's pins to change (see `awaitPins`). */
220
222
  pinWaiters = new WeakMap();
221
223
  /**
@@ -277,13 +279,17 @@ export class ReplayController {
277
279
  this.recordedFallback =
278
280
  opts.recordedFallback ?? process.env.BIR_REPLAY_RECORDED_OUTPUTS === "1";
279
281
  this.bashAllow = opts.bashAllow ?? parseBashAllow(process.env.BIR_BASH_ALLOW);
282
+ this.pathAllow = opts.pathAllow ?? parsePathAllow(process.env.BIR_PATH_ALLOW);
280
283
  const builtins = opts.builtinTools ?? parseBuiltinTools(process.env.BIR_BUILTIN_TOOLS);
281
- // A `Bash` that may run no command is not reachable here. Left on the
282
- // list, a chain of Bash steps would arm `direct` and stop at its first.
283
- this.builtinTools =
284
- this.bashAllow.length > 0 || !builtins.has("Bash")
285
- ? builtins
286
- : new Set([...builtins].filter((name) => name !== "Bash"));
284
+ // A `Bash` that may run no command, or a file tool that may touch no
285
+ // folder, is not reachable here. Left on the list, a chain of such steps
286
+ // would arm `direct` and stop at its first.
287
+ const reachable = (name) => name === "Bash"
288
+ ? this.bashAllow.length > 0
289
+ : !FILE_BUILTINS.has(name) || this.pathAllow.length > 0;
290
+ this.builtinTools = [...builtins].every(reachable)
291
+ ? builtins
292
+ : new Set([...builtins].filter(reachable));
287
293
  }
288
294
  /**
289
295
  * Whether a handed-out segment may actually arm (segmented.md R-OUT-10).
@@ -538,7 +544,7 @@ export class ReplayController {
538
544
  // command nobody asked for.
539
545
  let deriveCtx = ctx;
540
546
  if (ctx.recentResults?.length && sinkOfPlan(planned, host?.sinks) === "command") {
541
- deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay };
547
+ deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay, atDecision: ctx.atDecision };
542
548
  logLine("replay.derive_context", {
543
549
  scenario: scenario.id,
544
550
  shell: hasShellStep(planned, host?.sinks) || undefined,
@@ -2352,6 +2358,8 @@ export class ReplayController {
2352
2358
  fetchImpl: this.opts.fetchImpl,
2353
2359
  liveCall: ctx.liveCall,
2354
2360
  recentResults: ctx.recentResults,
2361
+ // A reading for a scenario that follows a plan's decision (offer1.md O4).
2362
+ ...(ctx.atDecision ? { atDecision: ctx.atDecision } : {}),
2355
2363
  // The request itself ends too. Without this a hung service or model held
2356
2364
  // the socket for up to 300 s after every wait on it had given up. Long
2357
2365
  // enough to cover both waits that can use its answer (the arm and the
@@ -2871,11 +2879,12 @@ export class ReplayController {
2871
2879
  }
2872
2880
  const mcp = parseQualifiedName(name);
2873
2881
  if (!mcp) {
2874
- // An allow-listed built-in runs *here*, against the session's own
2882
+ // An allow-listed built-in runs *here*, from the session's own
2875
2883
  // project directory (replay/builtins.ts). Everything else about it —
2876
2884
  // no session cwd (`bir replay`, an older server), off the list, no
2877
- // runner-side implementation, a path that leaves the project, a `Bash`
2878
- // command `BIR_BASH_ALLOW` does not allow — stays the old contract:
2885
+ // runner-side implementation, a path outside the folders
2886
+ // `BIR_PATH_ALLOW` names, a `Bash` command `BIR_BASH_ALLOW` does not
2887
+ // allow — stays the old contract:
2879
2888
  // `not_run`, so the session can do the step itself or its recorded
2880
2889
  // output can stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
2881
2890
  if (this.builtinTools.has(name) && state.sessionCwd) {
@@ -2937,6 +2946,9 @@ export class ReplayController {
2937
2946
  cwd,
2938
2947
  timeoutMs,
2939
2948
  bashAllow: this.bashAllow,
2949
+ // Any folder unless `BIR_PATH_ALLOW` names some, as the agent's own
2950
+ // tools would reach it (up to 0.3.20, the project directory alone).
2951
+ pathAllow: this.pathAllow,
2940
2952
  // The plan's Bash steps share a working directory, as the host's do.
2941
2953
  bashCwd: state.bashCwd,
2942
2954
  onBashCwd: (pwd) => {
@@ -53,6 +53,16 @@ export interface DeriveContext {
53
53
  * into the session only after the prompt hook (issue 1212).
54
54
  */
55
55
  agentDay?: () => AgentDay | undefined;
56
+ /**
57
+ * A reading for a scenario that can follow a plan's decision, made while the
58
+ * agent decides (the service's offer1.md O4): the plan, and its decision
59
+ * step. With it the service reads this turn's results without a live call,
60
+ * and the prompt as the user's request. Only the service reads it.
61
+ */
62
+ atDecision?: {
63
+ scenarioId: string;
64
+ stepIndex: number;
65
+ };
56
66
  }
57
67
  /**
58
68
  * The service, which derives when this runner has no key of its own
@@ -347,6 +347,9 @@ async function deriveViaService(opts, service, schema, keys) {
347
347
  // 2023-11-30 …"), which the service turns into the day it reads from
348
348
  // (issue 1212). Older services ignore it.
349
349
  ...(opts.todaySaid ? { todaySaid: opts.todaySaid } : {}),
350
+ // A reading made at a plan's decision (offer1.md O4): the service then
351
+ // takes this turn's results with no live call. Older services ignore it.
352
+ ...(opts.atDecision ? { atDecision: opts.atDecision } : {}),
350
353
  }),
351
354
  signal: opts.signal ?? timeoutSignal(),
352
355
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether a tool call may change something, judged by its name alone — the
3
+ * runner's copy of the service's own rule (BaseIn src/scenarios/correctionCheck.ts,
4
+ * `readsOnly`, `commandReadsOnly`, `callMayChange`), kept word for word so the
5
+ * two never disagree about the same call.
6
+ *
7
+ * Used where a call after a plan's decision is the agent starting a way of its
8
+ * own (the service's offer1.md O7), and where a plan started after a call
9
+ * would make it a second time (O8). When in doubt, a call is a change: that
10
+ * only ends a set of scenarios early or keeps a plan from starting, never lets
11
+ * a second write through.
12
+ */
13
+ /**
14
+ * A tool that only reads: Claude Code's read-only tools, then the name — a
15
+ * reading word and no writing word (`calendar_search_events`, `getJiraIssue`;
16
+ * `run_sql` no: `run` writes). A name with both (`get_or_create_contact`)
17
+ * changes something.
18
+ */
19
+ export declare function readsOnly(toolName: string): boolean;
20
+ /** Whether a shell command only looks: every part of it is a looking command, and nothing is written to a file. */
21
+ export declare function commandReadsOnly(command: string): boolean;
22
+ /**
23
+ * Whether one call may change something. A Bash call changes nothing only
24
+ * when its command is there and only looks; any other tool is a change unless
25
+ * {@link readsOnly} says it only reads — a tool it cannot tell about counts as
26
+ * a change. `input` is the call's arguments, as an object or as JSON text.
27
+ */
28
+ export declare function callMayChange(toolName: string, input: unknown): boolean;
29
+ //# sourceMappingURL=reads-only.d.ts.map
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Whether a tool call may change something, judged by its name alone — the
3
+ * runner's copy of the service's own rule (BaseIn src/scenarios/correctionCheck.ts,
4
+ * `readsOnly`, `commandReadsOnly`, `callMayChange`), kept word for word so the
5
+ * two never disagree about the same call.
6
+ *
7
+ * Used where a call after a plan's decision is the agent starting a way of its
8
+ * own (the service's offer1.md O7), and where a plan started after a call
9
+ * would make it a second time (O8). When in doubt, a call is a change: that
10
+ * only ends a set of scenarios early or keeps a plan from starting, never lets
11
+ * a second write through.
12
+ */
13
+ /** Built-in tools that only read (Claude Code's). */
14
+ const READ_TOOLS = new Set([
15
+ "Read",
16
+ "Grep",
17
+ "Glob",
18
+ "LS",
19
+ "WebFetch",
20
+ "WebSearch",
21
+ "NotebookRead",
22
+ "TodoWrite",
23
+ "TaskOutput",
24
+ "BashOutput",
25
+ ]);
26
+ /** Built-in tools that change files or run commands (Claude Code's). */
27
+ const CHANGING_BUILTINS = new Set([
28
+ "Write",
29
+ "Edit",
30
+ "MultiEdit",
31
+ "NotebookEdit",
32
+ "Bash",
33
+ "KillShell",
34
+ ]);
35
+ // Not `query`, `select` or `check`: a database query, a selected option, a
36
+ // check-in can change something.
37
+ const READ_WORDS = new Set([
38
+ "list",
39
+ "get",
40
+ "search",
41
+ "find",
42
+ "read",
43
+ "fetch",
44
+ "lookup",
45
+ "count",
46
+ "view",
47
+ "show",
48
+ "describe",
49
+ "retrieve",
50
+ "browse",
51
+ "peek",
52
+ "inspect",
53
+ "snapshot",
54
+ ]);
55
+ /**
56
+ * A word that may change something: a name holding one writes, whatever else
57
+ * it says (`get_or_create_contact`, `find_and_replace`).
58
+ */
59
+ const WRITE_WORDS = new Set([
60
+ "create",
61
+ "add",
62
+ "set",
63
+ "update",
64
+ "upsert",
65
+ "insert",
66
+ "put",
67
+ "post",
68
+ "send",
69
+ "delete",
70
+ "remove",
71
+ "mark",
72
+ "replace",
73
+ "archive",
74
+ "move",
75
+ "assign",
76
+ "book",
77
+ "cancel",
78
+ "write",
79
+ "edit",
80
+ "modify",
81
+ "save",
82
+ "submit",
83
+ "approve",
84
+ "reject",
85
+ "close",
86
+ "open",
87
+ "merge",
88
+ "push",
89
+ "publish",
90
+ "upload",
91
+ "invite",
92
+ "schedule",
93
+ "reply",
94
+ "forward",
95
+ "transfer",
96
+ "pay",
97
+ "refund",
98
+ "notify",
99
+ "run",
100
+ "execute",
101
+ "exec",
102
+ "apply",
103
+ "reset",
104
+ "start",
105
+ "stop",
106
+ "kill",
107
+ "enable",
108
+ "disable",
109
+ "grant",
110
+ "revoke",
111
+ "rename",
112
+ "tag",
113
+ "label",
114
+ "comment",
115
+ ]);
116
+ /** A tool name's words: `getJiraIssue` → get, jira, issue; `calendar_search_events` → calendar, search, events. */
117
+ function nameWords(name) {
118
+ return name
119
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
120
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
121
+ .toLowerCase()
122
+ .split(/[^a-z\d]+/)
123
+ .filter(Boolean);
124
+ }
125
+ /**
126
+ * A tool that only reads: Claude Code's read-only tools, then the name — a
127
+ * reading word and no writing word (`calendar_search_events`, `getJiraIssue`;
128
+ * `run_sql` no: `run` writes). A name with both (`get_or_create_contact`)
129
+ * changes something.
130
+ */
131
+ export function readsOnly(toolName) {
132
+ if (READ_TOOLS.has(toolName))
133
+ return true;
134
+ if (CHANGING_BUILTINS.has(toolName))
135
+ return false;
136
+ const words = nameWords(toolName.replace(/^mcp__.+?__/, ""));
137
+ if (words.some((w) => WRITE_WORDS.has(w)))
138
+ return false;
139
+ return words.some((w) => READ_WORDS.has(w));
140
+ }
141
+ /** Commands that only look (`ls`, `git status`, `grep …`). */
142
+ const READ_COMMANDS = new Set([
143
+ "ls",
144
+ "dir",
145
+ "cat",
146
+ "type",
147
+ "head",
148
+ "tail",
149
+ "less",
150
+ "more",
151
+ "grep",
152
+ "rg",
153
+ "egrep",
154
+ "fgrep",
155
+ "find",
156
+ "wc",
157
+ "pwd",
158
+ "echo",
159
+ "printf",
160
+ "date",
161
+ "whoami",
162
+ "which",
163
+ "where",
164
+ "env",
165
+ "printenv",
166
+ "stat",
167
+ "file",
168
+ "du",
169
+ "df",
170
+ "tree",
171
+ "jq",
172
+ "sort",
173
+ "uniq",
174
+ "cut",
175
+ "awk",
176
+ "diff",
177
+ "cmp",
178
+ "basename",
179
+ "dirname",
180
+ "realpath",
181
+ "readlink",
182
+ "true",
183
+ "false",
184
+ "test",
185
+ "sleep",
186
+ "ps",
187
+ "uname",
188
+ "hostname",
189
+ "id",
190
+ "nproc",
191
+ "free",
192
+ "uptime",
193
+ "node --version",
194
+ "npm --version",
195
+ ]);
196
+ const READ_GIT = new Set([
197
+ "status",
198
+ "log",
199
+ "diff",
200
+ "show",
201
+ "branch",
202
+ "rev-parse",
203
+ "ls-files",
204
+ "blame",
205
+ "remote",
206
+ "describe",
207
+ "config --get",
208
+ "grep",
209
+ "shortlog",
210
+ "tag --list",
211
+ ]);
212
+ /** Whether a shell command only looks: every part of it is a looking command, and nothing is written to a file. */
213
+ export function commandReadsOnly(command) {
214
+ const text = command.trim();
215
+ if (!text)
216
+ return true;
217
+ // A redirect into a file (not into /dev/null, not 2>&1), tee, or an in-place edit changes something.
218
+ if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)\S/.test(text) || /\btee\b/.test(text) || /\bsed\b[^|;&]*\s-i\b/.test(text)) {
219
+ return false;
220
+ }
221
+ if (/\bfind\b[^|;&]*\s-(delete|exec|execdir|ok)\b/.test(text))
222
+ return false;
223
+ const parts = text
224
+ .split(/\|\||&&|[|;\n]/)
225
+ .map((p) => p.trim())
226
+ .filter(Boolean);
227
+ return parts.every((part) => {
228
+ const words = part.replace(/^(\w+=\S+\s+)+/, "").split(/\s+/);
229
+ const cmd = (words[0] ?? "").replace(/^.*[\\/]/, "");
230
+ if (cmd === "git")
231
+ return READ_GIT.has(words[1] ?? "") || READ_GIT.has(`${words[1] ?? ""} ${words[2] ?? ""}`);
232
+ if (cmd === "sed" || cmd === "awk")
233
+ return !/\s-i\b/.test(part);
234
+ return READ_COMMANDS.has(cmd) || READ_COMMANDS.has(`${cmd} ${words[1] ?? ""}`);
235
+ });
236
+ }
237
+ /**
238
+ * Whether one call may change something. A Bash call changes nothing only
239
+ * when its command is there and only looks; any other tool is a change unless
240
+ * {@link readsOnly} says it only reads — a tool it cannot tell about counts as
241
+ * a change. `input` is the call's arguments, as an object or as JSON text.
242
+ */
243
+ export function callMayChange(toolName, input) {
244
+ if (toolName === "Bash") {
245
+ let value = input;
246
+ if (typeof value === "string") {
247
+ try {
248
+ value = JSON.parse(value);
249
+ }
250
+ catch {
251
+ value = undefined;
252
+ }
253
+ }
254
+ const command = value && typeof value === "object" && typeof value.command === "string"
255
+ ? value.command
256
+ : "";
257
+ return command.trim() === "" || !commandReadsOnly(command);
258
+ }
259
+ return !readsOnly(toolName);
260
+ }
261
+ //# sourceMappingURL=reads-only.js.map
@@ -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`, `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. In a host that is not Claude Code a tool with one of these names is the application's own, and the runner runs none of them (§7.4) |
48
+ | Reaches built-ins (`Bash`, `Read`) | Only by steering the live session | `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash` run on the runner itself, on any path the agent could reach unless `BIR_PATH_ALLOW` names folders (`BIR_BUILTIN_TOOLS`, `BIR_BASH_ALLOW`, `BIR_PATH_ALLOW`); any other built-in is steered. In a host that is not Claude Code a tool with one of these names is the application's own, and the runner runs none of them (§7.4) |
49
49
  | Needs `@anthropic-ai/*` at runtime | Yes | No (§16.3) |
50
50
 
51
51
  ---
@@ -265,7 +265,7 @@ export function modeFor(steps: SerializedScenarioStep[], wrapped: ReadonlySet<st
265
265
 
266
266
  | Every step | Mode | Where the tools run | Model spend |
267
267
  |---|---|---|---|
268
- | a wrapped MCP tool, or a built-in on `BIR_BUILTIN_TOOLS` | **`direct`** (§6) | The proxies, on connections already open; allow-listed built-ins on the runner itself, jailed to the session's project directory (replay/builtins.ts) | one Haiku derivation + one turn that reads the results |
268
+ | a wrapped MCP tool, or a built-in on `BIR_BUILTIN_TOOLS` | **`direct`** (§6) | The proxies, on connections already open; allow-listed built-ins on the runner itself, from the session's project directory (replay/builtins.ts) | one Haiku derivation + one turn that reads the results |
269
269
  | anything else, or mixed | **`steer`** (§7) | The live session, inputs pinned | one Haiku derivation + the live turn |
270
270
  | — | `none` | nowhere | declines at gate 5 |
271
271
 
@@ -275,11 +275,17 @@ so `Task`, `WebFetch`; up to 0.3.7 also `Bash`, `Edit`, `Write`), an MCP server
275
275
  `claude-in-chrome` — which is `scope: "dynamic"`, appears in no config file, and therefore can never
276
276
  be wrapped (v1 design §1). None of them can be reached except through the session.
277
277
 
278
- A built-in **on** the allow-list runs on the runner, in the session's own project directory and
279
- confined to it — a path that resolves outside the project, symlinks included, is refused and the
280
- step is `not_run`, the clean hand-over. `Bash` has a second switch on top (`BIR_BASH_ALLOW`,
281
- command prefixes; unset means any command, `none` keeps `Bash` in the session), because a
282
- runner-side step never reaches the host's permission prompts. The
278
+ A built-in **on** the allow-list runs on the runner, from the session's own project directory: a
279
+ relative path resolves against it, as the host's tools resolve it, and an absolute path is taken
280
+ as it is, inside the project or not — the agent's own tools reach any folder its session allows,
281
+ and the recorded call was one of them (up to 0.3.20 every path was confined to the project, and a
282
+ plan that read the agent's memory file under `~/.claude` handed the whole task back). The file
283
+ tools have a switch of their own (`BIR_PATH_ALLOW`, folders; unset means any, `project` brings
284
+ back the confinement, `project,~/.claude` adds a folder, `none` keeps them in the session); under
285
+ a list, a path outside it, symlinks included, is refused and the step is `not_run`, the clean
286
+ hand-over. `Bash` has its switch too (`BIR_BASH_ALLOW`, command prefixes; unset means any command,
287
+ `none` keeps `Bash` in the session), because a runner-side step never reaches the host's
288
+ permission prompts. The
283
289
  results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
284
290
  and what their `toolOutputLogic` was authored against; they pass through the same
285
291
  `serializeCapped(redact(…))` as every recorded payload. Bash keeps the host's own semantics a
@@ -241,8 +241,10 @@ When replay arms:
241
241
  §7) — and never in plan mode.
242
242
  - **Direct steps are not visible to the permission system at all.** They are dispatched straight to a
243
243
  `bir-proxy`, which calls the upstream on a connection it already holds — or, for Claude Code's own
244
- `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself in the project
245
- folder, any `Bash` command included (`BIR_BUILTIN_TOOLS`, `BIR_BASH_ALLOW`; up to 0.3.7 only the
244
+ `Read`, `Glob`, `Grep`, `Edit`, `Write` and `Bash`, run by the recorder itself from the project
245
+ folder, on any file the agent could reach (from 0.3.21; up to 0.3.20 only files inside the
246
+ project) and any `Bash` command included (`BIR_BUILTIN_TOOLS`, `BIR_PATH_ALLOW`,
247
+ `BIR_BASH_ALLOW`; up to 0.3.7 only the
246
248
  first three). Claude Code never sees them.
247
249
  The `run_scenario` call that starts them is answered `allow` too, while the runner has announced a
248
250
  direct plan; your own deny or ask rules for it still apply.
@@ -253,7 +255,8 @@ That is what makes replay fast, and it is the whole risk. Two things follow:
253
255
  2. Run `bir replay allow <servers>` (§5.3). An allowlist of the servers you are comfortable having
254
256
  called unattended is cheap insurance, and it is the difference between "replay reads a dashboard"
255
257
  and "replay can do anything any wrapped server can do". The same goes for built-ins:
256
- `BIR_BUILTIN_TOOLS=Read,Glob,Grep` keeps writes and commands with the model, and
258
+ `BIR_BUILTIN_TOOLS=Read,Glob,Grep` keeps writes and commands with the model,
259
+ `BIR_PATH_ALLOW=project` keeps the file tools inside the project folder, and
257
260
  `BIR_BASH_ALLOW=git,npm test` (for example) limits the commands the recorder runs itself.
258
261
 
259
262
  Nothing else is approved on your behalf. Outside a matched scenario's own steps, a wrapped MCP call
@@ -368,8 +371,9 @@ so there is exactly one switch and it cannot get out of step with itself.
368
371
  | `BIR_STEP_TIMEOUT_MS` | `60000` | Per-step ceiling for one direct `tools/call`, clipped to what the plan has left |
369
372
  | `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
370
373
  | `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 |
371
- | `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. Claude Code sessions only: in a LangChain application a tool with one of these names is the application's own, and the runner runs none (§6.5) |
374
+ | `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, from the session's own project directory (which folders the file tools may touch: `BIR_PATH_ALLOW`). 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. Claude Code sessions only: in a LangChain application a tool with one of these names is the application's own, and the runner runs none (§6.5) |
372
375
  | `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 |
376
+ | `BIR_PATH_ALLOW` | *(unset: any folder, from 0.3.21)* | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools reach it — a plan that reads the agent's memory file under `~/.claude` runs. `project` is the session's project folder alone (the rule up to 0.3.20); add folders after a comma, `~` for the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is refused and the plan hands over in front of it. `none` means no folder, and the file steps are steered in the session. `Bash` is `BIR_BASH_ALLOW`'s |
373
377
  | `BIR_SEGMENT_ARM` | *(unset: on, from 0.3.19)* | A segment the service hands out runs mid-task: a recurring sub-task, or a half of a task after a plan's decision. `0` is observe-only: what would have armed is logged (`replay.segment_would_arm`) and nothing is replaced. Up to 0.3.18 observe-only was the default. A recorder started before an update keeps its old default until `bir up --restart` |
374
378
  | `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, and then nothing starts mid-task, segments included: what would have armed is logged; unset follows `BIR_SEGMENT_ARM`, so from 0.3.19 it is on unless `BIR_SEGMENT_ARM=0` |
375
379
  | `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
@@ -495,7 +499,10 @@ Everything replay decides is one line. `grep` for these:
495
499
  | `replay.step_not_taken` | A step's test said this request does not need it, and nothing was sent for it ([calculatedReplay.md](calculatedReplay.md) §9.2). `plan.armed … tests=` lists the tested steps by their place in the plan: `4:hand_over,11:skip` |
496
500
  | `replay.diverge` / `replay.compose` | Off-script, and the recovery |
497
501
  | `replay.handover` | The plan stopped and handed the rest of the task to the model; `kind=` and `step=` say where. `kind=decision` (0.3.19) is a plan that ran the steps before its decision and stopped there, as planned (`via=divergence` when a divergence's bundle ran up to it) |
498
- | `run.fragment … decision=true` / `run.fragment_dropped` | After a plan's decision: the agent's own work from its next call is recorded as a fragment, from which the service makes the other half / a plan the intent check armed took that call instead, so nothing is recorded as the other half |
502
+ | `run.fragment … decision=true` / `run.fragment_dropped` | After a plan's decision: the agent's own work from its next call is recorded as a fragment, from which the service makes a calculated scenario of that way / a plan the intent check armed, or a next scenario, took that call instead, so nothing is recorded as the agent's own |
503
+ | `replay.next_ready` / `replay.next_hit` / `replay.next_miss` | 0.3.20: the calculated scenarios that can follow a plan's decision, armed while the agent decided / the agent's call was one's first step, value for value, so it runs in the call's place / none was used: `reason=other_values` (the same first tool with other values), `own_way` (a call that may change something and starts none of them), `not_ready` (the one that starts with that tool was not armed in time), `calls_limit` (5 calls started none), `turn_ended`. A call that only reads ends nothing |
504
+ | `run.fragment_closed` | 0.3.20: a scenario that follows the decision took over after the agent's own looks had opened the fragment; the fragment ends there, with what the agent did itself |
505
+ | `replay.decision … code=would_repeat` | 0.3.20: a plan an intent match armed after the agent's call would have made that call again (the same tool that may change something, or for a shell the same command), so it was not started (never twice) |
499
506
  | `replay.vetoed` | A direct plan retired without running anything: `run_scenario` was not the first call, or never arrived |
500
507
  | `replay.run_ambiguous` | Two sessions had `run_scenario` pending and the request named neither; nothing ran |
501
508
  | `replay.cancelled` / `replay.note_kept` | A direct run's caller went away (Esc); no further step started, and the note is kept for the model's next call |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
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
@@ -601,11 +601,24 @@ Truncation is always marked with the original size.
601
601
  - A plan whose chain has a decision keeps these protections when it stops earlier for another reason (a parked or failed step, a judgement, the budget). Its note has no intent line and no parameters, and lists the remaining steps only up to the decision. Its directive leaves the intent out. An agent sent on to the recorded half's steps would start that half's segment, and the plan would have decided after all.
602
602
  - The report is `fell_back` (or `failed` when no step ran) with `fallbackKind: "decision"` and `fallbackStepIndex` the decision step. The decision step gets no verdict, so nothing is counted against it.
603
603
  - When a step ran, the hand-over takes the plan's usage mark: the plan is billed up to the stop, and the agent's decision and the half it took are not on its bill. This holds for a plan the prompt started and for one an intent match started. Every other hand-over still takes no mark.
604
- - The agent's own next call opens the fragment, marked `metadata.decisionOf = {scenarioId, stepIndex}`: the service makes the other half of the task from it. When a plan the intent check armed takes that call instead (a segment of one half), the fragment is dropped (`run.fragment_dropped`): what follows is not the other half.
604
+ - The agent's own next call opens the fragment, marked `metadata.decisionOf = {scenarioId, stepIndex, request}` (`request`, the turn's prompt, redacted and cut to 4,000 characters, from 0.3.20): the service makes a calculated scenario of that way from it, calculated with the request (the service's offer1.md O2). When a plan the intent check armed, or a next scenario (below), takes that call instead, the fragment is dropped (`run.fragment_dropped`).
605
605
  - `ranThrough` sends one entry per plan, with the plan's own position (the decision step itself for a decision). The service maps it onto the recording.
606
606
  - `bir replay` stops in front of a decision and says so (`stopped before step N …: the plan's decision`).
607
607
  - A runner up to 0.3.18 reads the unknown kind as `known_bad_step`: it stops there too, but its note and report name a failure.
608
608
 
609
+ **What can follow a decision (0.3.20, the service's offer1.md).** The first time a way is taken after a decision, the agent does it and its fragment becomes a calculated scenario of that decision. The next time:
610
+
611
+ - When a plan stops at its decision, the runner asks the service once, in the background, which calculated scenarios can follow it (`POST /scenarios/:planId/next`, body `{turnId, supportsCalls}`; at most 4, each handed out with its own ticket and `key: "decision"`). An older service, a 404 or any failure is "none". It arms each one while the agent reads the note and decides: its values are read from the turn's request and this turn's results (the reading carries `atDecision: {scenarioId, stepIndex}`), and settled. `replay.next_ready` says how many are ready.
612
+ - The agent's next calls are checked against them before the intent probe. A call that is a next scenario's first step, **value for value** (every argument the step's own; the pin guard's "plausibly the step" is not enough), is a hit: it is answered in its place with that scenario's directive (`replay.next_hit`), the agent calls `run_scenario`, and the scenario runs every step, that one included, once. It is booked on its own ticket. A scenario whose first step is inside a sub-task (a call row) can never be the agent's call in place: it is never compared.
613
+ - Each scenario is checkable as soon as the service answers; its arm runs on its own. A call waits only for the arm of a scenario that starts with the call's own tool (at most 6 s, `BIR_NEXT_HIT_WAIT_MS`), and not at all for the others. While the service has not answered yet, a call waits for that answer only, at most 1 s.
614
+ - When the background reading left a value of the first step empty, the call waits while it is read once more with the agent's call as `liveCall`; the service fills such a value from the call. A value read differently is never read again: an e-mail to carlos is never the recorded e-mail to olga.
615
+ - **A call that only reads keeps the set** (by the service's own rule, mirrored in `src/replay/reads-only.ts`: Claude Code's read tools, a name with a reading word and no writing word, a Bash command that only looks). The set ends unused (`replay.next_miss`) at the agent's first call that may change something and is no hit: `other_values` when the call has a scenario's first tool with other values, `own_way` when it starts none of them — the agent is doing a way of its own, and no scenario may start in the middle of it — and `not_ready` when the scenario that starts with that tool was not armed in time. It also ends after 5 calls that start none (`calls_limit`), or at the turn's end. A scenario not armed in time for a call that only reads misses that call only. Unused ones never run and are never reported; the agent's own work is recorded as the fragment, as before.
616
+ - A later decision in the same turn starts a new set: what could follow the earlier one is dropped, never checked again.
617
+ - A hit after the agent's own looks had opened the fragment ends that fragment there (`run.fragment_closed`, finished with what the agent did itself): the scenario's own calls, pinned in steer mode, are never recorded into it.
618
+ - `/health` shows the set per session: `next: {entries, armed, calls, ended}`.
619
+
620
+ **Never twice (0.3.20, offer1.md O8).** A plan an intent match arms after the agent's call — not in its place — whose steps call the same tool is not started: it would make that call a second time (issue fx2: the agent's e-mail, then the plan's own). `replay.decision code=would_repeat`. A tool that only reads, by the same rule as above (`crm_search_customers`, `Read`), may repeat. For Bash and PowerShell the call is its command: only a step that would run that very command counts.
621
+
609
622
  **Measured cost.**
610
623
 
611
624
  - A turn's cost is measured only when tokens were found and every model of the turn has a price in the runner's table. This holds for every host, Claude Code too. A model outside the table used to cost $0, and that zero was called measured.
@@ -1058,8 +1071,9 @@ Defaults were checked in `src/`.
1058
1071
  | `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
1059
1072
  | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
1060
1073
  | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
1061
- | `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. Not in a session of another host (§3.2): the runner runs no step itself there, whatever a tool is named. |
1074
+ | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep,Edit,Write,Bash` | Built-ins the runner executes itself during a replay, from the session's project directory; which folders the file tools may touch is `BIR_PATH_ALLOW` (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. Not in a session of another host (§3.2): the runner runs no step itself there, whatever a tool is named. |
1062
1075
  | `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. |
1076
+ | `BIR_PATH_ALLOW` | recorder | unset (any folder) | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools (from 0.3.21; up to 0.3.20 the project directory alone). `project` is the session's project directory, `~` the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is `not_run` (hand-over). `none` means no folder, and file steps are steered. |
1063
1077
  | `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, and never for a plan started at the prompt: a prompt just submitted has no results yet, and an earlier prompt's are not its own. So in a session of another host, where a plan starts at the prompt only, none are read. |
1064
1078
  | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
1065
1079
  | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |