@basein/runner 0.2.10 → 0.2.12

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.
@@ -157,6 +157,18 @@ export interface ProjectRecord {
157
157
  * a fleet's absolute paths into pinned-npx entries.
158
158
  */
159
159
  invocation?: Invocation;
160
+ /**
161
+ * `bir scenario editing on|off` (editSteps.md D2, in the BaseIn repository):
162
+ * whether this project's `bir` MCP server also offers the tools that CHANGE
163
+ * a calculated scenario — `scenario_check`, `scenario_edit`, `scenario_undo`.
164
+ * Absent means off. The read tools are offered either way.
165
+ *
166
+ * Per project, and off by default, because the `bir` server runs in every
167
+ * session of every installed project — a fleet included — and a plan must
168
+ * not change because some agent there decided it should. The `bir scenario`
169
+ * commands ignore it: a person in a terminal is asked before each one.
170
+ */
171
+ editing?: boolean;
160
172
  }
161
173
  export interface InstalledSidecar {
162
174
  version: 1;
@@ -216,4 +228,13 @@ export declare function allocateFreeProjectPort(sidecar: InstalledSidecar, cwd:
216
228
  export declare function setProjectRecord(sidecar: InstalledSidecar, cwd: string, patch: Partial<ProjectRecord> & Pick<ProjectRecord, "port">): ProjectRecord;
217
229
  /** The bearer token this project's hooks and control server share. */
218
230
  export declare function projectToken(sidecar: InstalledSidecar, cwd: string): string | undefined;
231
+ /** Whether `bir scenario editing on` was run for `cwd`; see {@link ProjectRecord.editing}. */
232
+ export declare function editingEnabled(sidecar: InstalledSidecar, cwd: string): boolean;
233
+ /**
234
+ * Store `bir scenario editing on|off` for `cwd`, under the same normalised key
235
+ * as the replay switches. `undefined` when the directory has no record: it was
236
+ * never installed, so there is no `bir` server here to offer anything, and
237
+ * inventing a record would invent a port the hooks do not use.
238
+ */
239
+ export declare function setEditing(sidecar: InstalledSidecar, cwd: string, on: boolean): ProjectRecord | undefined;
219
240
  //# sourceMappingURL=generate.d.ts.map
@@ -198,4 +198,20 @@ export function setProjectRecord(sidecar, cwd, patch) {
198
198
  export function projectToken(sidecar, cwd) {
199
199
  return projectRecord(sidecar, cwd)?.token ?? sidecar.token;
200
200
  }
201
+ /** Whether `bir scenario editing on` was run for `cwd`; see {@link ProjectRecord.editing}. */
202
+ export function editingEnabled(sidecar, cwd) {
203
+ return projectRecord(sidecar, cwd)?.editing === true;
204
+ }
205
+ /**
206
+ * Store `bir scenario editing on|off` for `cwd`, under the same normalised key
207
+ * as the replay switches. `undefined` when the directory has no record: it was
208
+ * never installed, so there is no `bir` server here to offer anything, and
209
+ * inventing a record would invent a port the hooks do not use.
210
+ */
211
+ export function setEditing(sidecar, cwd, on) {
212
+ const record = projectRecord(sidecar, cwd);
213
+ if (!record)
214
+ return undefined;
215
+ return setProjectRecord(sidecar, cwd, { port: record.port, editing: on });
216
+ }
201
217
  //# sourceMappingURL=generate.js.map
@@ -28,7 +28,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
28
28
  import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
29
29
  import { StepIndexAllocator } from "./ordering.js";
30
30
  import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
31
- import { isHousekeeping } from "../record/housekeeping.js";
31
+ import { isHousekeepingCall } from "../record/housekeeping.js";
32
32
  import { redact } from "../record/redact.js";
33
33
  import { serializeCapped } from "../record/truncate.js";
34
34
  import { StepQueue } from "../record/queue.js";
@@ -732,7 +732,13 @@ export class ControlServer {
732
732
  // calculated scenario: its generated reasoning is injected into every
733
733
  // matched turn's steering directive, and because it is not an MCP tool it
734
734
  // drags the whole scenario out of `direct` mode. Observed in production.
735
- if (isHousekeeping(toolName)) {
735
+ // The runner's own `bir` — its MCP tools and a Bash command that only runs
736
+ // it — is housekeeping too (D13): a session that fixes a plan must never
737
+ // become a scenario that edits plans when it is replayed. Returning here
738
+ // also keeps it out of intent matching, the fragment and the plan.
739
+ if (isHousekeepingCall(toolName, modelArgs)) {
740
+ if (toolUseId)
741
+ (run.housekeepingUses ??= new Set()).add(toolUseId);
736
742
  logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
737
743
  return {};
738
744
  }
@@ -761,7 +767,7 @@ export class ControlServer {
761
767
  // Runs before anything else, because two of its four answers end the call.
762
768
  let pinned;
763
769
  if (run.replay?.plan && !run.replay.retired) {
764
- const action = await this.replay.preTool(run.replay, toolName, toolUseId);
770
+ const action = await this.replay.preTool(run.replay, toolName, toolUseId, modelArgs);
765
771
  // A hand-over inside `preTool` (an input logic that threw) must still
766
772
  // schedule the fragment, even though this call ends here.
767
773
  this.noteHandover(run);
@@ -1266,8 +1272,15 @@ export class ControlServer {
1266
1272
  const toolUseId = payload.tool_use_id ?? "";
1267
1273
  const toolName = payload.tool_name ?? "unknown";
1268
1274
  // `onToolPre` opened no step for these, so closing one here would record a
1269
- // `tool_response` with no `tool_selected` before it.
1270
- if (isHousekeeping(toolName))
1275
+ // `tool_response` with no `tool_selected` before it. The id it remembered
1276
+ // decides first; the call itself is judged again only when no step was
1277
+ // opened for it — a post whose pre never reached this server. A step the
1278
+ // pre did open (a plan pinned the model's call to a command that happens
1279
+ // to be `bir`) is closed as usual: its output is what the plan threads.
1280
+ const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
1281
+ if (skippedAtPre)
1282
+ return {};
1283
+ if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
1271
1284
  return {};
1272
1285
  // Nor for an unarmed `run_scenario`. The plan may have retired since the
1273
1286
  // pre hook, so the test is whether that hook opened a step, not the plan.
@@ -27,8 +27,79 @@
27
27
  * possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
28
28
  * real work, they belong in a recording, and reaching for one mid-replay
29
29
  * usually *is* the model doing the task another way.
30
+ *
31
+ * THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
32
+ * the one reason this file now looks at a call's input and not only its name.
33
+ * A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
34
+ * the same through the `bir` MCP server's `scenario_*` tools — is exactly the
35
+ * kind of session that gets recorded and calculated. Recorded, it becomes a
36
+ * scenario whose steps *edit plans*, and a steered or direct replay of it would
37
+ * then change a plan unattended, on a prompt nobody meant as "change the plan":
38
+ * the very thing D2 keeps the editing tools switched off in a fleet to prevent.
39
+ * And mid-replay, a model that stops to read `bir investigate` has not left the
40
+ * plan; it is reading about it.
41
+ *
42
+ * So two more kinds of call are housekeeping, and both are still narrow:
43
+ *
44
+ * - every tool of the `bir` MCP server **except `run_scenario`**, which is
45
+ * the direct plan's delivery vehicle and keeps its own handling (it is
46
+ * recorded, tagged `pinnedBy`, while its plan is live);
47
+ * - a `Bash` call whose command is **nothing but** `bir` invocations
48
+ * ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
49
+ * ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
50
+ * PowerShell tool as readily as for Bash, and a `bir scenario edit` run
51
+ * there is the same act. `npm test && bir investigate` is not: the
52
+ * `npm test` half is real work, and hiding it from the recording would be
53
+ * hiding the task. When in doubt the answer is "not housekeeping", because
54
+ * a `bir` call recorded by mistake costs a noisy step, while real work
55
+ * skipped by mistake is a plan with a hole in it.
30
56
  */
31
57
  /** Host tools that are never recorded as steps and never count as divergence. */
32
58
  export declare const HOUSEKEEPING_TOOLS: ReadonlySet<string>;
59
+ /** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
33
60
  export declare function isHousekeeping(toolName: string): boolean;
61
+ /**
62
+ * The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
63
+ * replay controller (a test holds them together); spelled out here because
64
+ * the controller imports this file.
65
+ */
66
+ export declare const RUN_SCENARIO_TOOL = "mcp__bir__run_scenario";
67
+ /**
68
+ * Whether one call is host housekeeping: never recorded as a step, never a
69
+ * divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
70
+ * a `Bash` call's `command` is read from it.
71
+ */
72
+ export declare function isHousekeepingCall(toolName: string, toolInput?: unknown): boolean;
73
+ /**
74
+ * {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
75
+ * Bash reader would get it wrong: a backslash is a path separator, not an
76
+ * escape; the escape is the backtick; `$(…)` runs code even inside double
77
+ * quotes; `&` at the start of a part is the call operator. Deliberately
78
+ * narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
79
+ * (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
80
+ * after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
81
+ * A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
82
+ * quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
83
+ * reason as there: when unsure, record it.
84
+ */
85
+ export declare function isBirOnlyPowerShell(command: string): boolean;
86
+ /**
87
+ * True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
88
+ * `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
89
+ * invocation or a plain `cd <dir>`, and at least one is `bir`.
90
+ *
91
+ * A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
92
+ * `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
93
+ * or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
94
+ * which only set `bir`'s own environment. A heredoc is read as the data it is,
95
+ * because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
96
+ * hand `bir` a logic body, and its lines are JavaScript, not commands.
97
+ *
98
+ * Anything that could run other code while looking like `bir` makes the answer
99
+ * false: a command substitution (`$(…)`, backticks) outside single quotes and
100
+ * quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
101
+ * So does a quote or heredoc left open — a parse this side is not sure of is
102
+ * not one to hide a step on.
103
+ */
104
+ export declare function isBirOnlyCommand(command: string): boolean;
34
105
  //# sourceMappingURL=housekeeping.d.ts.map
@@ -27,13 +27,430 @@
27
27
  * possibly be the user's task. `Read`, `Bash` and `Grep` are NOT here: they do
28
28
  * real work, they belong in a recording, and reaching for one mid-replay
29
29
  * usually *is* the model doing the task another way.
30
+ *
31
+ * THE RUNNER'S OWN `bir` IS HOUSEKEEPING TOO (editSteps.md D13), and that is
32
+ * the one reason this file now looks at a call's input and not only its name.
33
+ * A session that fixes a plan — `bir investigate`, then `bir scenario edit`, or
34
+ * the same through the `bir` MCP server's `scenario_*` tools — is exactly the
35
+ * kind of session that gets recorded and calculated. Recorded, it becomes a
36
+ * scenario whose steps *edit plans*, and a steered or direct replay of it would
37
+ * then change a plan unattended, on a prompt nobody meant as "change the plan":
38
+ * the very thing D2 keeps the editing tools switched off in a fleet to prevent.
39
+ * And mid-replay, a model that stops to read `bir investigate` has not left the
40
+ * plan; it is reading about it.
41
+ *
42
+ * So two more kinds of call are housekeeping, and both are still narrow:
43
+ *
44
+ * - every tool of the `bir` MCP server **except `run_scenario`**, which is
45
+ * the direct plan's delivery vehicle and keeps its own handling (it is
46
+ * recorded, tagged `pinnedBy`, while its plan is live);
47
+ * - a `Bash` call whose command is **nothing but** `bir` invocations
48
+ * ({@link isBirOnlyCommand}), and the same for a `PowerShell` call
49
+ * ({@link isBirOnlyPowerShell}) — Claude Code on Windows reaches for its
50
+ * PowerShell tool as readily as for Bash, and a `bir scenario edit` run
51
+ * there is the same act. `npm test && bir investigate` is not: the
52
+ * `npm test` half is real work, and hiding it from the recording would be
53
+ * hiding the task. When in doubt the answer is "not housekeeping", because
54
+ * a `bir` call recorded by mistake costs a noisy step, while real work
55
+ * skipped by mistake is a plan with a hole in it.
30
56
  */
57
+ import { SCENARIO_SERVER_KEY } from "../config/generate.js";
31
58
  /** Host tools that are never recorded as steps and never count as divergence. */
32
59
  export const HOUSEKEEPING_TOOLS = new Set([
33
60
  "ToolSearch",
34
61
  "TodoWrite",
35
62
  ]);
63
+ /** By name alone — the host's own bookkeeping tools. See {@link isHousekeepingCall} for the full rule. */
36
64
  export function isHousekeeping(toolName) {
37
65
  return HOUSEKEEPING_TOOLS.has(toolName);
38
66
  }
67
+ /** `mcp__bir__`: every tool the first-party `bir` MCP server offers is named under it. */
68
+ const SCENARIO_SERVER_PREFIX = `mcp__${SCENARIO_SERVER_KEY}__`;
69
+ /**
70
+ * The direct plan's delivery vehicle. Must equal `DIRECT_TOOL_NAME` in the
71
+ * replay controller (a test holds them together); spelled out here because
72
+ * the controller imports this file.
73
+ */
74
+ export const RUN_SCENARIO_TOOL = `${SCENARIO_SERVER_PREFIX}run_scenario`;
75
+ /**
76
+ * Whether one call is host housekeeping: never recorded as a step, never a
77
+ * divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
78
+ * a `Bash` call's `command` is read from it.
79
+ */
80
+ export function isHousekeepingCall(toolName, toolInput) {
81
+ if (HOUSEKEEPING_TOOLS.has(toolName))
82
+ return true;
83
+ if (toolName.startsWith(SCENARIO_SERVER_PREFIX))
84
+ return toolName !== RUN_SCENARIO_TOOL;
85
+ if (toolName === "Bash" || toolName === "PowerShell") {
86
+ const command = toolInput?.command;
87
+ if (typeof command !== "string")
88
+ return false;
89
+ return toolName === "Bash" ? isBirOnlyCommand(command) : isBirOnlyPowerShell(command);
90
+ }
91
+ return false;
92
+ }
93
+ /**
94
+ * {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
95
+ * Bash reader would get it wrong: a backslash is a path separator, not an
96
+ * escape; the escape is the backtick; `$(…)` runs code even inside double
97
+ * quotes; `&` at the start of a part is the call operator. Deliberately
98
+ * narrower than the Bash reader: parts split on `;`, `&&`, `||` and newlines
99
+ * (outside quotes); each is `bir …` (as {@link isBirInvocation} reads it, also
100
+ * after a leading `&`) or a plain `cd`/`Set-Location`/`Push-Location <dir>`.
101
+ * A pipe, a backtick, `$(` or `@(` anywhere, a brace or parenthesis outside
102
+ * quotes, an `@` starting a word, or a quote left open makes the answer false — for the same
103
+ * reason as there: when unsure, record it.
104
+ */
105
+ export function isBirOnlyPowerShell(command) {
106
+ if (command.includes("`") || command.includes("$(") || command.includes("@("))
107
+ return false;
108
+ const segments = [];
109
+ let words = [];
110
+ let word = "";
111
+ let inWord = false;
112
+ const endWord = () => {
113
+ if (inWord)
114
+ words.push(word);
115
+ word = "";
116
+ inWord = false;
117
+ };
118
+ const endSegment = () => {
119
+ endWord();
120
+ segments.push(words);
121
+ words = [];
122
+ };
123
+ for (let i = 0; i < command.length; i += 1) {
124
+ const c = command[i];
125
+ if (c === "'" || c === '"') {
126
+ const close = command.indexOf(c, i + 1);
127
+ if (close < 0)
128
+ return false;
129
+ word += command.slice(i + 1, close);
130
+ inWord = true;
131
+ i = close;
132
+ continue;
133
+ }
134
+ if (c === "\n" || c === "\r" || c === ";") {
135
+ endSegment();
136
+ continue;
137
+ }
138
+ if ((c === "&" || c === "|") && command[i + 1] === c) {
139
+ endSegment();
140
+ i += 1;
141
+ continue;
142
+ }
143
+ if (c === "|" || c === "{" || c === "}" || c === "(" || c === ")")
144
+ return false;
145
+ // `@` opens an array, a hash or a splat only at the start of a word; inside
146
+ // one (`…\node_modules\@basein\runner\…`) it is a plain character.
147
+ if (c === "@" && !inWord)
148
+ return false;
149
+ if (c === "&") {
150
+ // The call operator, as a word of its own at the start of a part; a
151
+ // redirection's `2>&1` keeps its `&` inside the word.
152
+ if (inWord && /\d?>$/.test(word)) {
153
+ word += c;
154
+ continue;
155
+ }
156
+ if (inWord || words.length > 0)
157
+ return false;
158
+ words.push("&");
159
+ continue;
160
+ }
161
+ if (c === " " || c === "\t") {
162
+ endWord();
163
+ continue;
164
+ }
165
+ word += c;
166
+ inWord = true;
167
+ }
168
+ endSegment();
169
+ let bir = false;
170
+ for (const raw of segments) {
171
+ const parts = raw[0] === "&" ? raw.slice(1) : raw;
172
+ if (parts.length === 0) {
173
+ if (raw.length > 0)
174
+ return false;
175
+ continue;
176
+ }
177
+ if (raw[0] !== "&" && /^(cd|chdir|sl|set-location|pushd|push-location)$/i.test(parts[0])) {
178
+ if (parts.length > 2 || parts.slice(1).some(isRedirect))
179
+ return false;
180
+ continue;
181
+ }
182
+ if (!isBirInvocation(parts.filter((w) => !isRedirect(w))))
183
+ return false;
184
+ bir = true;
185
+ }
186
+ return bir;
187
+ }
188
+ /**
189
+ * True when a shell command runs `bir` and nothing else: split on `&&`, `||`,
190
+ * `;`, `|`, `&` and newlines (outside quotes), every part is a `bir`
191
+ * invocation or a plain `cd <dir>`, and at least one is `bir`.
192
+ *
193
+ * A `bir` invocation is `bir` / `bir.cmd` / a path ending in `bir`, `bir.cmd`,
194
+ * `bir.js` or `bir.ps1`; `npx [-y] [-p @basein/runner…] [@basein/runner…] bir …`;
195
+ * or `node <path>/bir.js …` — each optionally after `NAME=value` assignments,
196
+ * which only set `bir`'s own environment. A heredoc is read as the data it is,
197
+ * because `bir scenario check … --input-logic - <<'EOF'` is the natural way to
198
+ * hand `bir` a logic body, and its lines are JavaScript, not commands.
199
+ *
200
+ * Anything that could run other code while looking like `bir` makes the answer
201
+ * false: a command substitution (`$(…)`, backticks) outside single quotes and
202
+ * quoted heredocs, and a subshell, group or process substitution (`(`, `{`).
203
+ * So does a quote or heredoc left open — a parse this side is not sure of is
204
+ * not one to hide a step on.
205
+ */
206
+ export function isBirOnlyCommand(command) {
207
+ const segments = splitCommand(command);
208
+ if (!segments)
209
+ return false;
210
+ let bir = false;
211
+ for (const words of segments) {
212
+ if (words.length === 0)
213
+ continue;
214
+ if (isPlainCd(words))
215
+ continue;
216
+ if (!isBirInvocation(words))
217
+ return false;
218
+ bir = true;
219
+ }
220
+ return bir;
221
+ }
222
+ /**
223
+ * The command's parts, each as its words (quotes removed, as the shell would),
224
+ * or null when it cannot be read with confidence. Redirections stay words:
225
+ * `bir … < step.js` and `bir … > out.json 2>&1` are still only `bir`.
226
+ */
227
+ function splitCommand(command) {
228
+ const n = command.length;
229
+ const segments = [];
230
+ const heredocs = [];
231
+ let words = [];
232
+ let word = "";
233
+ let inWord = false;
234
+ const endWord = () => {
235
+ if (inWord)
236
+ words.push(word);
237
+ word = "";
238
+ inWord = false;
239
+ };
240
+ const endSegment = () => {
241
+ endWord();
242
+ segments.push(words);
243
+ words = [];
244
+ };
245
+ for (let i = 0; i < n; i += 1) {
246
+ const c = command[i];
247
+ if (c === "'") {
248
+ const close = command.indexOf("'", i + 1);
249
+ if (close === -1)
250
+ return null;
251
+ word += command.slice(i + 1, close);
252
+ inWord = true;
253
+ i = close;
254
+ }
255
+ else if (c === '"') {
256
+ let j = i + 1;
257
+ for (; j < n && command[j] !== '"'; j += 1) {
258
+ const d = command[j];
259
+ if (d === "`" || (d === "$" && command[j + 1] === "("))
260
+ return null;
261
+ if (d === "\\" && j + 1 < n) {
262
+ j += 1;
263
+ // Inside double quotes a backslash escapes only these; before anything else it stays.
264
+ if (!'"\$`\n'.includes(command[j]))
265
+ word += "\\";
266
+ }
267
+ word += command[j];
268
+ }
269
+ if (j >= n)
270
+ return null;
271
+ inWord = true;
272
+ i = j;
273
+ }
274
+ else if (c === "`" || (c === "$" && command[i + 1] === "(")) {
275
+ return null;
276
+ }
277
+ else if (c === "$" && command[i + 1] === "{") {
278
+ // `${NAME}` only reads a variable; a substitution inside it was refused above.
279
+ const close = command.indexOf("}", i + 2);
280
+ if (close === -1)
281
+ return null;
282
+ const inner = command.slice(i + 2, close);
283
+ if (/`|\$\(/.test(inner))
284
+ return null;
285
+ word += command.slice(i, close + 1);
286
+ inWord = true;
287
+ i = close;
288
+ }
289
+ else if (c === "\\") {
290
+ // A backslash-newline joins lines; any other escaped character is itself.
291
+ if (command[i + 1] === "\n") {
292
+ i += 1;
293
+ }
294
+ else if (command[i + 1] === "\r" && command[i + 2] === "\n") {
295
+ i += 2;
296
+ }
297
+ else if (i + 1 < n) {
298
+ word += command[i + 1];
299
+ inWord = true;
300
+ i += 1;
301
+ }
302
+ }
303
+ else if (c === "<" && command[i + 1] === "<" && command[i + 2] === "<") {
304
+ // A here-string: one word of data, read as the words that follow.
305
+ word += "<<<";
306
+ inWord = true;
307
+ i += 2;
308
+ }
309
+ else if (c === "<" && command[i + 1] === "<") {
310
+ endWord();
311
+ let j = i + 2;
312
+ const stripTabs = command[j] === "-";
313
+ if (stripTabs)
314
+ j += 1;
315
+ while (command[j] === " " || command[j] === "\t")
316
+ j += 1;
317
+ let delimiter = "";
318
+ let literal = false;
319
+ for (; j < n; j += 1) {
320
+ const d = command[j];
321
+ if (d === "'" || d === '"') {
322
+ const close = command.indexOf(d, j + 1);
323
+ if (close === -1)
324
+ return null;
325
+ delimiter += command.slice(j + 1, close);
326
+ literal = true;
327
+ j = close;
328
+ }
329
+ else if (d === "\\" && j + 1 < n) {
330
+ delimiter += command[j + 1];
331
+ literal = true;
332
+ j += 1;
333
+ }
334
+ else if (/[\s;&|<>(){}]/.test(d)) {
335
+ break;
336
+ }
337
+ else {
338
+ delimiter += d;
339
+ }
340
+ }
341
+ if (!delimiter)
342
+ return null;
343
+ heredocs.push({ delimiter, literal, stripTabs });
344
+ words.push("<<", delimiter);
345
+ i = j - 1;
346
+ }
347
+ else if (c === "\n") {
348
+ endSegment();
349
+ // The bodies of the heredocs this line opened come next, in order.
350
+ while (heredocs.length > 0) {
351
+ const h = heredocs.shift();
352
+ let closed = false;
353
+ for (let pos = i + 1; pos <= n;) {
354
+ let eol = command.indexOf("\n", pos);
355
+ if (eol === -1)
356
+ eol = n;
357
+ let line = command.slice(pos, eol);
358
+ if (line.endsWith("\r"))
359
+ line = line.slice(0, -1);
360
+ if ((h.stripTabs ? line.replace(/^\t+/, "") : line) === h.delimiter) {
361
+ i = eol;
362
+ closed = true;
363
+ break;
364
+ }
365
+ if (!h.literal && /`|\$\(/.test(line))
366
+ return null;
367
+ pos = eol + 1;
368
+ }
369
+ if (!closed)
370
+ return null;
371
+ }
372
+ }
373
+ else if (c === ";" || c === "|") {
374
+ endSegment();
375
+ }
376
+ else if (c === "&") {
377
+ // `2>&1` and `&>file` redirect; every other `&` ends a command.
378
+ if (command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">") {
379
+ word += c;
380
+ inWord = true;
381
+ }
382
+ else {
383
+ endSegment();
384
+ }
385
+ }
386
+ else if (c === "(" || c === ")" || c === "{" || c === "}") {
387
+ // A subshell, a group or a process substitution: not something this reads.
388
+ return null;
389
+ }
390
+ else if (c === " " || c === "\t" || c === "\r") {
391
+ endWord();
392
+ }
393
+ else {
394
+ word += c;
395
+ inWord = true;
396
+ }
397
+ }
398
+ // A heredoc whose body never came.
399
+ if (heredocs.length > 0)
400
+ return null;
401
+ endSegment();
402
+ return segments;
403
+ }
404
+ /** `cd` or `cd <dir>`, and nothing more. */
405
+ function isPlainCd(words) {
406
+ return words[0] === "cd" && words.length <= 2 && !words.slice(1).some(isRedirect);
407
+ }
408
+ const isRedirect = (w) => /^\d*[<>]|^&>/.test(w);
409
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
410
+ /** The last path component, on either separator. */
411
+ const basename = (w) => w.slice(Math.max(w.lastIndexOf("/"), w.lastIndexOf("\\")) + 1);
412
+ /** `bir`, `bir.cmd`, `bir.ps1`, or any path to one of those or to `bir.js`. */
413
+ function isBirProgram(w) {
414
+ const base = basename(w);
415
+ if (/^bir(\.cmd|\.ps1)?$/i.test(base))
416
+ return true;
417
+ return /^bir\.js$/i.test(base) && base !== w;
418
+ }
419
+ const isNode = (w) => /^node(\.exe)?$/i.test(basename(w));
420
+ /** `@basein/runner`, `@basein/runner@0.2.11`, `@basein/runner@latest`. */
421
+ const isRunnerPackage = (w) => /^@basein\/runner(@[\w.+-]+)?$/.test(w);
422
+ function isBirInvocation(input) {
423
+ let words = input;
424
+ while (words.length > 0 && ASSIGNMENT.test(words[0]))
425
+ words = words.slice(1);
426
+ const first = words[0];
427
+ if (first === undefined)
428
+ return false;
429
+ if (isBirProgram(first))
430
+ return true;
431
+ if (isNode(first)) {
432
+ // Exactly `node <path>/bir.js …`: a node flag could load other code first.
433
+ const script = words[1];
434
+ return script !== undefined && /^bir\.js$/i.test(basename(script));
435
+ }
436
+ if (first === "npx") {
437
+ let i = 1;
438
+ for (; i < words.length; i += 1) {
439
+ const w = words[i];
440
+ if (w === "-y" || w === "--yes")
441
+ continue;
442
+ if ((w === "-p" || w === "--package") && isRunnerPackage(words[i + 1] ?? "")) {
443
+ i += 1;
444
+ continue;
445
+ }
446
+ if (w.startsWith("--package=") && isRunnerPackage(w.slice("--package=".length)))
447
+ continue;
448
+ break;
449
+ }
450
+ if (isRunnerPackage(words[i] ?? ""))
451
+ i += 1;
452
+ return words[i] === "bir";
453
+ }
454
+ return false;
455
+ }
39
456
  //# sourceMappingURL=housekeeping.js.map
@@ -299,7 +299,9 @@ export declare class ReplayController {
299
299
  * and hand it back through the best channel available; if even that fails,
300
300
  * abort to an ordinary turn.
301
301
  */
302
- preTool(state: ReplayState, toolName: string, toolUseId: string): Promise<PreToolAction>;
302
+ preTool(state: ReplayState, toolName: string, toolUseId: string,
303
+ /** The call's `tool_input`: whether a `Bash` call is only `bir` is read from it (D13). */
304
+ toolInput?: unknown): Promise<PreToolAction>;
303
305
  /**
304
306
  * `PostToolUse` for a call this plan pinned.
305
307
  *