@basein/runner 0.3.25 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
@@ -0,0 +1,161 @@
1
+ /**
2
+ * reads-only — whether one call of the agent's only looks, and changes nothing.
3
+ *
4
+ * WHY THE RUNNER ASKS (B2 is11.md, 2026-11-11). A direct plan is announced
5
+ * with "call run_scenario before any other tool", and a first call that is
6
+ * not `run_scenario` used to retire it on the spot (control/server.ts). The
7
+ * rule is there for one danger: a model that has no `run_scenario` makes its
8
+ * own calls, and a divergence would then run every step, writes included,
9
+ * inside a hook nobody approves. But the call that retired the plan in is11
10
+ * was the agent reading its own notes file: it had seen another person's name
11
+ * in the note (Olga, the recording's; the request said Carlos) and checked
12
+ * first. The plan would have done the task right — its steps take the person
13
+ * from the request — and the turn cost a full agent run.
14
+ *
15
+ * So a call that only looks no longer retires a direct plan: it runs as the
16
+ * agent wrote it, and the plan waits, still announced, for `run_scenario`.
17
+ * The first call that may change something still retires it, running nothing
18
+ * (and nothing ever runs in a hook in its place): a plan run after the agent
19
+ * changed something itself could do the same work twice.
20
+ *
21
+ * WHEN IN DOUBT, IT IS A CHANGE. A call judged "only looks" that does change
22
+ * something leaves a plan armed that could then repeat that change; a change
23
+ * judged a change only ends a replay early, as every call did before. So:
24
+ *
25
+ * - Claude Code's built-ins by name: the reading ones only look, every other
26
+ * built-in (a sub-agent, an edit, an unknown new tool) is a change;
27
+ * - `Bash` by its command ({@link commandReadsOnly}), and `PowerShell` never;
28
+ * - an MCP tool by what its own server says of it (`annotations`), else by
29
+ * its name: a reading word and no writing word.
30
+ *
31
+ * The same rule as the service's `readsOnly` (correctionCheck.ts, AC13), kept
32
+ * in step by hand, stricter where a wrong "only looks" costs more here: a
33
+ * command substitution, a process substitution or an `awk` `system(` makes a
34
+ * command a change.
35
+ */
36
+ /** Claude Code's built-ins that only read. `ToolSearch` and `TodoWrite` are housekeeping (record/housekeeping.ts). */
37
+ const READ_BUILTINS = new Set([
38
+ "Read",
39
+ "Grep",
40
+ "Glob",
41
+ "LS",
42
+ "WebFetch",
43
+ "WebSearch",
44
+ "NotebookRead",
45
+ "TaskOutput",
46
+ "BashOutput",
47
+ // The conversation's own: a question to the person, plan mode in or out.
48
+ // Nothing outside the conversation changes, and the plan may still be
49
+ // what the person says yes to.
50
+ "AskUserQuestion",
51
+ "EnterPlanMode",
52
+ "ExitPlanMode",
53
+ ]);
54
+ // Not `query`, `select` or `check`: a database query, a selected option, a
55
+ // check-in can change something.
56
+ const READ_WORDS = new Set([
57
+ "list", "get", "search", "find", "read", "fetch", "lookup", "count", "view", "show",
58
+ "describe", "retrieve", "browse", "peek", "inspect", "snapshot",
59
+ ]);
60
+ /**
61
+ * A word that may change something: a name holding one writes, whatever else
62
+ * it says (`get_or_create_contact`, `find_and_replace`). Some are nouns too
63
+ * (`get_schedule`): such a look is then taken for a change, which only ends a
64
+ * replay early, never lets a change through.
65
+ */
66
+ const WRITE_WORDS = new Set([
67
+ "create", "add", "set", "update", "upsert", "insert", "put", "post", "send", "delete",
68
+ "remove", "mark", "replace", "archive", "move", "assign", "book", "cancel", "write",
69
+ "edit", "modify", "save", "submit", "approve", "reject", "close", "open", "merge",
70
+ "push", "publish", "upload", "invite", "schedule", "reply", "forward", "transfer",
71
+ "pay", "refund", "notify", "run", "execute", "exec", "apply", "reset", "start", "stop",
72
+ "kill", "enable", "disable", "grant", "revoke", "rename", "tag", "label", "comment",
73
+ ]);
74
+ /** A tool name's words: `getJiraIssue` → get, jira, issue; `calendar_search_events` → calendar, search, events. */
75
+ function nameWords(name) {
76
+ return name
77
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
78
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
79
+ .toLowerCase()
80
+ .split(/[^a-z\d]+/)
81
+ .filter(Boolean);
82
+ }
83
+ /**
84
+ * Whether this call only looks. `toolInput` is the hook's `tool_input` (only a
85
+ * `Bash` call's `command` is read from it); `hints` is what the tool's own
86
+ * server listed for it, when it listed anything.
87
+ */
88
+ export function readsOnlyCall(toolName, toolInput, hints) {
89
+ if (toolName === "Bash") {
90
+ const command = toolInput?.command;
91
+ return typeof command === "string" && commandReadsOnly(command);
92
+ }
93
+ if (READ_BUILTINS.has(toolName))
94
+ return true;
95
+ // Every other built-in — Write, Edit, PowerShell, a sub-agent, one added
96
+ // after this list was written — may change something.
97
+ if (!toolName.startsWith("mcp__"))
98
+ return false;
99
+ // A server's word beats any guess from the name.
100
+ if (hints?.readOnlyHint === false || hints?.destructiveHint === true)
101
+ return false;
102
+ if (hints?.readOnlyHint === true)
103
+ return true;
104
+ const words = nameWords(toolName.replace(/^mcp__.+?__/, ""));
105
+ if (words.some((w) => WRITE_WORDS.has(w)))
106
+ return false;
107
+ return words.some((w) => READ_WORDS.has(w));
108
+ }
109
+ /** Commands that only look (`ls`, `cat`, `grep …`). */
110
+ const READ_COMMANDS = new Set([
111
+ "ls", "dir", "cat", "type", "head", "tail", "less", "more", "grep", "rg", "egrep", "fgrep",
112
+ "find", "wc", "pwd", "echo", "printf", "date", "whoami", "which", "where", "stat", "file",
113
+ "du", "df", "tree", "jq", "sort", "uniq", "cut", "awk", "diff", "cmp", "basename", "dirname",
114
+ "realpath", "readlink", "true", "false", "test", "ps", "uname", "hostname", "id", "nproc",
115
+ "free", "uptime", "node --version", "npm --version",
116
+ ]);
117
+ const READ_GIT = new Set([
118
+ "status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "blame", "remote",
119
+ "describe", "config --get", "grep", "shortlog", "tag --list",
120
+ ]);
121
+ /**
122
+ * Whether a shell command only looks: every part of it is a looking command,
123
+ * nothing is written to a file, and nothing runs code it does not show.
124
+ */
125
+ export function commandReadsOnly(command) {
126
+ const text = command.trim();
127
+ if (!text)
128
+ return false;
129
+ // Code that runs out of sight: a substitution (`$(…)`, backticks), a
130
+ // process substitution, awk's `system(`.
131
+ if (/`|\$\(|<\(|>\(|\bsystem\s*\(/.test(text))
132
+ return false;
133
+ // A redirect into a file (not into /dev/null, not 2>&1), tee, or an in-place edit changes something.
134
+ if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)\S/.test(text) ||
135
+ /&>{1,2}\s*(?!\/dev\/null)\S/.test(text) ||
136
+ /\btee\b/.test(text) ||
137
+ /\bsed\b[^|;&]*\s(-i|--in-place)/.test(text)) {
138
+ return false;
139
+ }
140
+ if (/\bfind\b[^|;&]*\s-(delete|exec|execdir|ok|okdir|fprint|fprintf|fls)\b/.test(text))
141
+ return false;
142
+ // A job sent to the background (`cmd &`): not something this reads.
143
+ if (/(?<![>&])&(?![>&])/.test(text.replace(/&&/g, "")))
144
+ return false;
145
+ const parts = text
146
+ .split(/\|\||&&|[|;\n]/)
147
+ .map((p) => p.trim())
148
+ .filter(Boolean);
149
+ if (parts.length === 0)
150
+ return false;
151
+ return parts.every((part) => {
152
+ const words = part.replace(/^(\w+=\S+\s+)+/, "").split(/\s+/);
153
+ const cmd = (words[0] ?? "").replace(/^.*[\\/]/, "");
154
+ if (cmd === "git")
155
+ return READ_GIT.has(words[1] ?? "") || READ_GIT.has(`${words[1] ?? ""} ${words[2] ?? ""}`);
156
+ if (cmd === "sed")
157
+ return !/\s(-i|--in-place)/.test(part);
158
+ return READ_COMMANDS.has(cmd) || READ_COMMANDS.has(`${cmd} ${words[1] ?? ""}`);
159
+ });
160
+ }
161
+ //# sourceMappingURL=reads-only.js.map
@@ -49,7 +49,30 @@ const WRITE_BUILTINS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
49
49
  * with the server.
50
50
  */
51
51
  const MCP_SHELL_WORD = /(?:^|[_.-])(?:shell|bash|sh|zsh|ssh|exec|execute|command|cmd|terminal|powershell|pwsh)(?:$|[_.-])/i;
52
- const MCP_COMMAND_WORD = /(?:^|[_.-])(?:sql|tsql|query|exec|execute|shell|bash|sh|zsh|ssh|command|cmd|script|eval|terminal|psql|mysql|mssql|sqlite|duckdb|postgres|postgresql|powershell|pwsh)(?:$|[_.-])/i;
52
+ const MCP_COMMAND_WORD = /(?:^|[_.-])(?:sql|tsql|query|exec|execute|shell|bash|sh|zsh|ssh|command|cmd|script|eval|cypher|terminal|psql|mysql|mssql|sqlite|duckdb|postgres|postgresql|powershell|pwsh)(?:$|[_.-])/i;
53
+ /**
54
+ * A tool whose input is code runs it as surely as a shell runs a command:
55
+ * `run_code`, `execute_python`, `code_interpreter`, `python_repl`, a
56
+ * `javascript_tool`, `browser_evaluate`, `evaluate_script`. Known by the words
57
+ * that say the code is run, and not by the language's name alone: as words of
58
+ * their own, `code`, `python`, `javascript` and `evaluate` made a command of
59
+ * `search_code`, `get_postal_code`, `send_verification_code`, `evaluate_answer`
60
+ * and of every tool of a `python-docs` or `claude-code` server — and with one
61
+ * such step every value of the plan was held to a command's rules, so a query
62
+ * with a quote in it handed the plan over. Whole words only, as above:
63
+ * `decode_token` and a `barcode` have the letters of `code` and are not it.
64
+ */
65
+ const MCP_CODE_RUNNER = new RegExp(`(?:^|[_.-])(?:${[
66
+ "(?:run|exec|execute|eval)[_.-](?:code|python|py|javascript|js|typescript|ts|node)",
67
+ "(?:code|python|py|javascript|js|typescript|node)[_.-](?:run|runner|exec|execute|executor|eval|interpreter|repl)",
68
+ "(?:javascript|js)[_.-]tool",
69
+ "(?:browser|page|puppeteer|playwright|frame|tab|chrome|devtools)[_.-]evaluate",
70
+ "evaluate[_.-](?:script|js|javascript|expression|code)",
71
+ ].join("|")})(?:$|[_.-])`, "i");
72
+ /** Whether a name, split into words ({@link nameWords}), says its input is run. */
73
+ function saysRun(words) {
74
+ return MCP_COMMAND_WORD.test(words) || MCP_CODE_RUNNER.test(words);
75
+ }
53
76
  /**
54
77
  * A tool or server name as delimited words: camelCase, PascalCase and acronym
55
78
  * runs are split with `_`, so `executeSql` reads as `execute_Sql`, `runSQL` as
@@ -66,6 +89,18 @@ function mcpNameSays(toolName, words) {
66
89
  return false;
67
90
  return words.test(nameWords(mcp.toolName)) || words.test(nameWords(mcp.serverName));
68
91
  }
92
+ /**
93
+ * Whether an MCP tool's name, its server's, or the two read together say its
94
+ * input is run: `mcp__python__run` says it only with both (`python_run`).
95
+ */
96
+ function mcpRunsInput(toolName) {
97
+ const mcp = parseQualifiedName(toolName);
98
+ if (!mcp)
99
+ return false;
100
+ const tool = nameWords(mcp.toolName);
101
+ const server = nameWords(mcp.serverName);
102
+ return saysRun(tool) || saysRun(server) || MCP_CODE_RUNNER.test(`${server}_${tool}`);
103
+ }
69
104
  /**
70
105
  * Characters and sequences that end a quoted string or begin another command
71
106
  * in a POSIX shell, PowerShell or SQL. Deliberately wide: an apostrophe in a
@@ -105,13 +140,13 @@ export function sinkOf(toolName, host) {
105
140
  return "command";
106
141
  if (host.write?.includes(name))
107
142
  return "write";
108
- return plainNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
143
+ return saysRun(nameWords(name)) ? "command" : undefined;
109
144
  }
110
145
  if (SHELL_BUILTINS.has(name))
111
146
  return "command";
112
147
  if (WRITE_BUILTINS.has(name))
113
148
  return "write";
114
- return mcpNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
149
+ return mcpRunsInput(name) ? "command" : undefined;
115
150
  }
116
151
  /** Whether this tool's input is run or written: a shell, a file edit, a query. */
117
152
  export function isCommandSink(toolName) {
@@ -189,8 +224,11 @@ const SQL_BOOLEAN_WORD = /\b(?:or|and|union)\b/gi;
189
224
  * keeps it.
190
225
  */
191
226
  function offShape(value, sample) {
192
- // A number stays a number: `1 OR 1=1` is not a user id.
193
- if (typeof sample === "number") {
227
+ // A number stays a number: `1 OR 1=1` is not a user id. A number kept as
228
+ // text (`"42"`) is a number all the same: with that sample, `"… WHERE
229
+ // user_id = " + parameters.v` took the value `user_id` and matched every
230
+ // row, where the sample 42 refused it.
231
+ if (typeof sample === "number" || (typeof sample === "string" && NUMERIC.test(sample))) {
194
232
  if (typeof value === "number")
195
233
  return Number.isFinite(value) ? undefined : String(value);
196
234
  if (typeof value !== "string" || !NUMERIC.test(value))
@@ -41,12 +41,14 @@
41
41
  * `initialize` got a 405 and the server showed as failed every session).
42
42
  * A stream that ends — cleanly or not — is noticed, the requests riding it
43
43
  * are answered, and it is re-established with backoff; the new session is
44
- * re-initialised with the host's own `initialize` before anything else.
44
+ * re-initialised with the host's own `initialize` before anything else,
45
+ * and a stream whose re-initialisation fails is a reconnect that failed.
45
46
  * 4. **Session expiry is survivable.** A 404 to a request that carried a
46
47
  * session means the server forgot us (a redeploy, an idle timeout). The
47
48
  * spec says start a new session; the proxy does it for the host — replays
48
49
  * the host's cached `initialize` under a `bir:` id, sends `initialized`,
49
- * and retries the request once.
50
+ * and retries the request once. A replay that fails is tried again when
51
+ * the next request meets the same 404.
50
52
  * 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
51
53
  * bounds the connect only; how long a tool may take is Claude Code's call,
52
54
  * and its cancellation reaches the server immediately (rule 2). What no
@@ -200,7 +202,8 @@ export declare class HttpUpstreamClient extends BaseUpstreamClient {
200
202
  /**
201
203
  * Replay the host's `initialize` (under a `bir:` id) and `initialized`.
202
204
  * `news`: say it on stderr when it worked — after a forgotten session or an
203
- * outage; after a routine reconnect it is detail. A failure is always said.
205
+ * outage; after a routine reconnect it is detail. A failure is said, unless
206
+ * `failureSaid`: it is one more of an outage whose first failure was.
204
207
  */
205
208
  private reinitialize;
206
209
  /**
@@ -41,12 +41,14 @@
41
41
  * `initialize` got a 405 and the server showed as failed every session).
42
42
  * A stream that ends — cleanly or not — is noticed, the requests riding it
43
43
  * are answered, and it is re-established with backoff; the new session is
44
- * re-initialised with the host's own `initialize` before anything else.
44
+ * re-initialised with the host's own `initialize` before anything else,
45
+ * and a stream whose re-initialisation fails is a reconnect that failed.
45
46
  * 4. **Session expiry is survivable.** A 404 to a request that carried a
46
47
  * session means the server forgot us (a redeploy, an idle timeout). The
47
48
  * spec says start a new session; the proxy does it for the host — replays
48
49
  * the host's cached `initialize` under a `bir:` id, sends `initialized`,
49
- * and retries the request once.
50
+ * and retries the request once. A replay that fails is tried again when
51
+ * the next request meets the same 404.
50
52
  * 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
51
53
  * bounds the connect only; how long a tool may take is Claude Code's call,
52
54
  * and its cancellation reaches the server immediately (rule 2). What no
@@ -72,7 +74,7 @@
72
74
  * recovery, a session the server forgot, and a stream that keeps dropping as
73
75
  * soon as it opens (once an hour per server at most).
74
76
  */
75
- import { readFrames } from "../jsonrpc/framing.js";
77
+ import { readFrames, receivedText, rememberText } from "../jsonrpc/framing.js";
76
78
  import { paramsObject } from "../jsonrpc/types.js";
77
79
  import { logDetail, logEvery, logLine, logOnce, errText } from "../util/log.js";
78
80
  import { BaseUpstreamClient, BIR_ID_PREFIX, isBirId } from "./client.js";
@@ -341,7 +343,11 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
341
343
  accept: "application/json, text/event-stream",
342
344
  ...this.sessionHeaders(method === "initialize"),
343
345
  ...this.headers,
344
- }, JSON.stringify(msg), controller.signal);
346
+ },
347
+ // The host's own line when this is the message as the relay read it
348
+ // (jsonrpc/framing.ts): serialised again, an integer past 2^53 in a
349
+ // tool's arguments reached the server rounded.
350
+ receivedText(msg) ?? JSON.stringify(msg), controller.signal);
345
351
  }
346
352
  catch (err) {
347
353
  clearTimeout(abandonTimer);
@@ -516,8 +522,16 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
516
522
  if (Array.isArray(parsed))
517
523
  for (const item of parsed)
518
524
  this.deliver(item);
519
- else
525
+ else {
526
+ // One message, and this is its text: the relay writes it to the host as
527
+ // the server wrote it rather than as JavaScript read it (framing.ts).
528
+ // Without the white space around it — a body often ends in a newline —
529
+ // and only when it is one line; a batch's members have no text of their
530
+ // own, and are serialised as before.
531
+ if (parsed && typeof parsed === "object")
532
+ rememberText(parsed, raw.trim());
520
533
  this.deliver(parsed);
534
+ }
521
535
  return true;
522
536
  }
523
537
  /** Hand one inbound JSON-RPC message to the relay (or to a `bir:` waiter). */
@@ -601,7 +615,18 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
601
615
  if (this.transport === "http") {
602
616
  this.sessionId = undefined;
603
617
  this.resetStandingStream();
604
- return this.reinitialize();
618
+ if (await this.reinitialize())
619
+ return true;
620
+ // No new session: a 503 from an instance still starting, a network
621
+ // error, no answer in time. Left without an id, every later request
622
+ // went out bare, the server answered 400 or 404, and none of that
623
+ // read as "forgotten" any more — errors for the rest of the host's
624
+ // session, with the proxy up. Holding the forgotten id again makes
625
+ // the next request's 404 start another attempt. An id this attempt
626
+ // was handed is dropped with it: its handshake never completed.
627
+ if (!this.dead)
628
+ this.sessionId = staleKey;
629
+ return false;
605
630
  }
606
631
  // Legacy: the session *is* the event stream. Drop it and open a new one;
607
632
  // reconnect() replays the handshake. A reconnect already under way (the
@@ -634,9 +659,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
634
659
  /**
635
660
  * Replay the host's `initialize` (under a `bir:` id) and `initialized`.
636
661
  * `news`: say it on stderr when it worked — after a forgotten session or an
637
- * outage; after a routine reconnect it is detail. A failure is always said.
662
+ * outage; after a routine reconnect it is detail. A failure is said, unless
663
+ * `failureSaid`: it is one more of an outage whose first failure was.
638
664
  */
639
- async reinitialize(news = true) {
665
+ async reinitialize(news = true, failureSaid = false) {
640
666
  if (this.initParams === undefined || this.dead)
641
667
  return false;
642
668
  this.reinitCount += 1;
@@ -663,7 +689,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
663
689
  return false;
664
690
  if (!reply || !("result" in reply)) {
665
691
  const error = reply ? paramsObject(reply.error).message : "no answer";
666
- logLine("upstream.reinitialize_failed", { server: this.label, error: String(error ?? "error") });
692
+ (failureSaid ? logDetail : logLine)("upstream.reinitialize_failed", {
693
+ server: this.label,
694
+ error: String(error ?? "error"),
695
+ });
667
696
  return false;
668
697
  }
669
698
  this.noteProtocolVersion(reply.result);
@@ -856,15 +885,27 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
856
885
  return false;
857
886
  }
858
887
  const recovered = this.reconnectFailures > 0;
888
+ // A new event stream is a new, uninitialised session: replay the host's
889
+ // handshake before anything else is let through. Until that has worked
890
+ // the reconnect has not: counted as done on the stream alone, a replay
891
+ // the server refused (it was still starting) left every later request
892
+ // posted to a session nobody had initialised, and nothing ever tried
893
+ // again. So the stream is dropped and the attempt counts as failed —
894
+ // backoff, and the next request pulls a new one forward.
895
+ if (this.initParams !== undefined && !(await this.reinitialize(recovered || afterExpiry, recovered))) {
896
+ if (this.dead)
897
+ return false;
898
+ this.reconnectFailures += 1;
899
+ this.onSseStreamLost(this.generation, "re-initialise failed", true);
900
+ this.streamAbort?.abort();
901
+ this.scheduleReconnect();
902
+ return false;
903
+ }
859
904
  (recovered ? logLine : logDetail)("upstream.stream_reconnected", {
860
905
  server: this.label,
861
906
  failedAttempts: this.reconnectFailures,
862
907
  });
863
908
  this.reconnectFailures = 0;
864
- // A new event stream is a new, uninitialised session: replay the host's
865
- // handshake before anything else is let through.
866
- if (this.initParams !== undefined)
867
- await this.reinitialize(recovered || afterExpiry);
868
909
  return true;
869
910
  })();
870
911
  this.connecting = attempt;
@@ -22,6 +22,7 @@
22
22
  * {@link StdioUpstreamClient.shutdown} closes stdin first (the MCP-spec way to
23
23
  * ask a server to exit), waits briefly, and only then kills the whole tree.
24
24
  */
25
+ import { type ChildProcessWithoutNullStreams } from "node:child_process";
25
26
  import { type JsonRpcMessage } from "../jsonrpc/types.js";
26
27
  import { BaseUpstreamClient } from "./client.js";
27
28
  export interface StdioUpstreamOptions {
@@ -80,6 +81,12 @@ export declare function whichSync(command: string, env?: NodeJS.ProcessEnv): str
80
81
  * On POSIX this is the identity. On Windows a batch file is wrapped in `cmd.exe`.
81
82
  */
82
83
  export declare function resolveCommand(command: string, args: string[], env?: NodeJS.ProcessEnv): ResolvedCommand;
84
+ /**
85
+ * Kill `child` and everything it started. On Windows that takes `taskkill /T`
86
+ * (a plain kill terminates one process: `cmd.exe`, not the server behind it);
87
+ * elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
88
+ */
89
+ export declare function killTree(child: ChildProcessWithoutNullStreams): Promise<void>;
83
90
  export declare class StdioUpstreamClient extends BaseUpstreamClient {
84
91
  readonly ready: Promise<void>;
85
92
  private child?;
@@ -161,7 +161,7 @@ function waitForExit(child, ms) {
161
161
  * (a plain kill terminates one process: `cmd.exe`, not the server behind it);
162
162
  * elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
163
163
  */
164
- async function killTree(child) {
164
+ export async function killTree(child) {
165
165
  const pid = child.pid;
166
166
  if (pid === undefined)
167
167
  return;
@@ -19,11 +19,19 @@
19
19
  * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
20
  * being a symlink, so the rename goes to the link's real target.
21
21
  *
22
- * The existing file's mode is kept; `mode` applies to a new file.
22
+ * The existing file's mode is kept; `mode` applies to a new file, and to an
23
+ * existing one too with `replaceMode`.
23
24
  */
24
25
  export interface AtomicWriteOptions {
25
26
  /** Mode for a file that does not exist yet. An existing file keeps its own. */
26
27
  mode?: number;
28
+ /**
29
+ * `mode` for an existing file as well: a file a credential goes in. Kept,
30
+ * the old file's mode went to the new one, and when the old file was not
31
+ * ours to chmod first (root's 0644, after `sudo claude`), the token went
32
+ * into a file that is ours and readable by every account.
33
+ */
34
+ replaceMode?: boolean;
27
35
  }
28
36
  export declare function writeFileAtomic(path: string, data: string | Uint8Array, opts?: AtomicWriteOptions): void;
29
37
  //# sourceMappingURL=atomic-write.d.ts.map
@@ -19,7 +19,8 @@
19
19
  * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
20
  * being a symlink, so the rename goes to the link's real target.
21
21
  *
22
- * The existing file's mode is kept; `mode` applies to a new file.
22
+ * The existing file's mode is kept; `mode` applies to a new file, and to an
23
+ * existing one too with `replaceMode`.
23
24
  */
24
25
  import { chmodSync, lstatSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
25
26
  import { randomBytes } from "node:crypto";
@@ -42,11 +43,13 @@ function resolveTarget(path) {
42
43
  export function writeFileAtomic(path, data, opts = {}) {
43
44
  const target = resolveTarget(path);
44
45
  let mode = opts.mode;
45
- try {
46
- mode = statSync(target).mode & 0o777;
47
- }
48
- catch {
49
- /* new file */
46
+ if (!(opts.replaceMode && mode !== undefined)) {
47
+ try {
48
+ mode = statSync(target).mode & 0o777;
49
+ }
50
+ catch {
51
+ /* new file */
52
+ }
50
53
  }
51
54
  const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
52
55
  try {
@@ -35,6 +35,13 @@ export declare const MAX_COOLDOWN_MS = 60000;
35
35
  * and never answers, holding a socket and a chain slot the whole time.
36
36
  */
37
37
  export declare const DEFAULT_SERVICE_TIMEOUT_MS = 10000;
38
+ /**
39
+ * The longest delay a Node timer takes. Given more, `setTimeout` warns and
40
+ * waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
41
+ * the timeout off, ended every request at once. Every delay that comes from
42
+ * the environment or from a caller is held to this.
43
+ */
44
+ export declare const MAX_TIMER_MS = 2147483647;
38
45
  export declare function serviceTimeoutMs(): number;
39
46
  export interface HealthSnapshot {
40
47
  /** False while a cool-down is running: hot paths do not wait. */
@@ -36,9 +36,16 @@ export const MAX_COOLDOWN_MS = 60_000;
36
36
  * and never answers, holding a socket and a chain slot the whole time.
37
37
  */
38
38
  export const DEFAULT_SERVICE_TIMEOUT_MS = 10_000;
39
+ /**
40
+ * The longest delay a Node timer takes. Given more, `setTimeout` warns and
41
+ * waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
42
+ * the timeout off, ended every request at once. Every delay that comes from
43
+ * the environment or from a caller is held to this.
44
+ */
45
+ export const MAX_TIMER_MS = 2_147_483_647;
39
46
  export function serviceTimeoutMs() {
40
47
  const n = Number(process.env.BIR_SERVICE_TIMEOUT_MS);
41
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_SERVICE_TIMEOUT_MS;
48
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMER_MS) : DEFAULT_SERVICE_TIMEOUT_MS;
42
49
  }
43
50
  export class ServiceHealth {
44
51
  failures = 0;
@@ -170,7 +177,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
170
177
  const winner = await Promise.race([
171
178
  pending,
172
179
  new Promise((resolve) => {
173
- timer = setTimeout(() => resolve(timedOut), budgetMs);
180
+ timer = setTimeout(() => resolve(timedOut), Math.min(budgetMs, MAX_TIMER_MS));
174
181
  timer.unref?.();
175
182
  }),
176
183
  ]);
@@ -197,7 +204,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
197
204
  */
198
205
  export function timeoutSignal(ms = serviceTimeoutMs(), parent) {
199
206
  const controller = new AbortController();
200
- const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), ms);
207
+ const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), Math.min(ms, MAX_TIMER_MS));
201
208
  timer.unref?.();
202
209
  if (parent) {
203
210
  if (parent.aborted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
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
@@ -523,7 +523,7 @@ Over 2-minute runs with one turn every 8 s (92 hooks):
523
523
 
524
524
  - **Backlog.** The backlog is bounded: 500 sends or 32 MiB, oldest steps dropped first. A queued send still facing a cool-down after 60 s is dropped unsent, not sent into the outage.
525
525
  - **Flush.** A flush waits at most 2 s (`BIR_FLUSH_TIMEOUT_MS`), and not at all while the service is cooling down.
526
- - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet).
526
+ - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet). Three sends are different, because the service takes a second copy of each without harm: a run's finish and a patch (an update that keeps what it has), and an execution report that carries its ticket (a second copy answers `200 duplicate`). From 0.3.26 each of these goes back to the head of its lane when it fails with no answer or an unhealthy one (502, 504, a reset), up to 3 sends in all, and it is exempt from the 60 s give-up. Before, a run whose finish was lost never ended (never embedded, matched or calculated), and a turn whose report was lost was never booked. Any other 4xx stays final.
527
527
 
528
528
  **Size caps on uploads.**
529
529
 
@@ -1174,7 +1174,7 @@ Defaults were checked in `src/`.
1174
1174
  - **Two sessions in one project.** Direct replays for a session whose own proxy has not yet bound hand over instead of running.
1175
1175
  - **Revive gaps.** `bir-scenario` does not revive in its first 12 s; those first hooks are left to the SessionStart hook. A session with no wrapped server and no `bir` server is not revived until `/clear` or a new session. The revive window was not measured with real Claude Code.
1176
1176
  - **Direct-run worst case.** The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack. It is reached only if every logic body hits its limit, and sits at Claude Code's 2-minute MCP auto-background. Setting `BIR_REPLAY_BUDGET_MS=120000` (the old default) makes it 140 s.
1177
- - **Retries are conservative.** A send lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids.
1177
+ - **Retries are conservative.** A step or run create lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids. (A finish, a patch and a ticketed report are sent up to 3 times from 0.3.26: the service takes a second copy of each without harm, §6.5. The service now answers a repeated run or step id with 409.)
1178
1178
  - **Healthy-service prompt cost.** With an instant local service the prompt hook is about 6 ms slower than 0.2.12 (about 17 vs 11 ms median); the cause was not isolated. Under 503 or slow answers the recorder also keeps more idle keep-alive sockets. Both are small, but visible in measurements.
1179
1179
  - **A host other than Claude Code is not checked end to end.** What §3.2 describes ran on a bench only: a real LangChain agent against the real recorder, with a stand-in service and a fake model (`examples/langchain-proof/`). A real model, the real service and the production server have not been tried. The service's part is built and not deployed, and the Python package is not on PyPI. What that means for a LangChain application today is in `langChainGuide.md`, part 5.
1180
1180
  - **An interrupted or lossy Claude Code run is not marked.** Only a run of another host is finished with an `error` or with the `incomplete` mark today. A Claude Code run that was interrupted or lost steps is finished as before.
@@ -65,6 +65,11 @@ function Invoke-BirInstall {
65
65
  # child's environment only, set right before the spawn and removed after.
66
66
  $setupToken = $env:BIR_SETUP_TOKEN
67
67
  if ($setupToken) { Remove-Item -Path Env:BIR_SETUP_TOKEN -ErrorAction SilentlyContinue }
68
+ # The uninstall switch the same way: read once and taken out of the session,
69
+ # so pasting the ordinary install line later in this window installs, and
70
+ # does not uninstall again.
71
+ $uninstall = ($env:BIR_UNINSTALL -eq '1')
72
+ if ($null -ne $env:BIR_UNINSTALL) { Remove-Item -Path Env:BIR_UNINSTALL -ErrorAction SilentlyContinue }
68
73
 
69
74
  # Inside the function on purpose: under `irm | iex` these would otherwise
70
75
  # change the session the person keeps working in.
@@ -262,7 +267,14 @@ function Invoke-BirInstall {
262
267
  Write-Note "node v$Version already at $exe"
263
268
  return $exe
264
269
  }
265
- # Half an extraction or a foreign file: removed, never extracted over.
270
+ }
271
+ # Half an extraction, a foreign file, or a folder whose node.exe is gone
272
+ # (an antivirus quarantine takes just that): removed, never extracted
273
+ # over. Whenever the folder is there, not only when node.exe is - a
274
+ # Move-Item onto a folder that exists moves the new one INSIDE it, so
275
+ # node.exe never appeared where it is looked for and every retry
276
+ # failed, 80 MB heavier. install.sh removes it the same way.
277
+ if (Test-Path -LiteralPath $nodeDir) {
266
278
  if ($dryRun) { Write-Would "remove the unusable $nodeDir" }
267
279
  else {
268
280
  try { Remove-Item -LiteralPath $nodeDir -Recurse -Force }
@@ -459,7 +471,7 @@ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wP
459
471
  # before its files go. Every wrapped MCP server and every hook names a
460
472
  # file under ~\.baseinstrunner; deleting that first would break them
461
473
  # all, in every project, with no `bir` left to repair it.
462
- if ($env:BIR_UNINSTALL -eq '1') {
474
+ if ($uninstall) {
463
475
  Write-Step 'Removing the runner from every project on this machine'
464
476
  $birCmd = Join-Path $birHome 'bin\bir.cmd'
465
477
  if (-not (Test-Path -LiteralPath $birCmd -PathType Leaf)) {