@basein/runner 0.3.25 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +62 -0
- package/dist/replay/controller.js +218 -32
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/replay/pricing.js
CHANGED
|
@@ -15,70 +15,163 @@
|
|
|
15
15
|
* numbers are comparable. For that row, right now, they are not.
|
|
16
16
|
*
|
|
17
17
|
* Bump the version — on **every** side — whenever a row changes.
|
|
18
|
+
*
|
|
19
|
+
* WHAT A PRICE DEPENDS ON BESIDE THE MODEL (Anthropic's pricing page, read
|
|
20
|
+
* 2026-11-11). Until this version a turn was priced from four token counts per
|
|
21
|
+
* model, and three things the bill reads were not looked at:
|
|
22
|
+
*
|
|
23
|
+
* - WHICH CACHE WAS WRITTEN. A 5-minute cache write bills at 1.25x input, a
|
|
24
|
+
* 1-hour one at 2x. Claude Code writes the 1-hour cache: in the transcripts
|
|
25
|
+
* this was checked against, every cache write was a 1-hour one, and each was
|
|
26
|
+
* priced 37.5% too low.
|
|
27
|
+
* - THE REQUEST'S OWN PROMPT LENGTH, on Claude Haiku 5.5: a request whose prompt
|
|
28
|
+
* (input, cache reads and cache writes together) is over 100,000 tokens pays
|
|
29
|
+
* five times the price. Each request is priced on its own.
|
|
30
|
+
* - HOW THE REQUEST RAN: fast mode bills at twice the standard prices, and
|
|
31
|
+
* US-only inference at 1.1x.
|
|
32
|
+
*
|
|
33
|
+
* So a usage now says these of itself ({@link TokenUsage}), and whoever folds
|
|
34
|
+
* requests into totals keeps the requests that are priced differently apart
|
|
35
|
+
* ({@link requestRates}).
|
|
18
36
|
*/
|
|
19
37
|
/** Identifies {@link MODEL_PRICING}. Must equal the service's constant. */
|
|
20
|
-
export const PRICING_VERSION = "2026-
|
|
38
|
+
export const PRICING_VERSION = "2026-11-11";
|
|
21
39
|
/**
|
|
22
|
-
* A 5-minute cache write bills at 1.25x
|
|
23
|
-
*
|
|
24
|
-
*
|
|
40
|
+
* A rate card from its list prices. A 5-minute cache write bills at 1.25x
|
|
41
|
+
* input and a 1-hour one at 2x, on every model. A cache read bills at 0.1x
|
|
42
|
+
* input, except on the models whose list price says otherwise (0.05x on Opus
|
|
43
|
+
* 5.5 and Sonnet 5.5; 0.025x on Fable and Mythos 5.1 and 5.2), which pass
|
|
44
|
+
* their read price in dollars.
|
|
25
45
|
*/
|
|
26
46
|
const priced = (input, output, cacheRead = input * 0.1) => ({
|
|
27
47
|
input,
|
|
28
48
|
output,
|
|
29
49
|
cacheRead,
|
|
30
50
|
cacheWrite: input * 1.25,
|
|
51
|
+
cacheWrite1h: input * 2,
|
|
31
52
|
});
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"claude-
|
|
40
|
-
|
|
41
|
-
"claude-
|
|
42
|
-
"claude-
|
|
43
|
-
"claude-
|
|
44
|
-
"claude-
|
|
45
|
-
"claude-
|
|
46
|
-
|
|
53
|
+
const model = (rates, longPrompt) => longPrompt ? { rates, longPrompt } : { rates };
|
|
54
|
+
/**
|
|
55
|
+
* A Map, not an object: a model's name comes from a transcript or from another
|
|
56
|
+
* host's payload, and `table["constructor"]` on an object is a function, which
|
|
57
|
+
* priced a turn at NaN.
|
|
58
|
+
*/
|
|
59
|
+
const MODEL_PRICING = new Map([
|
|
60
|
+
["claude-haiku-4-5", model(priced(1.0, 5.0))],
|
|
61
|
+
// Priced by prompt length: up to 100,000 tokens, and over.
|
|
62
|
+
["claude-haiku-5-5", model(priced(0.1, 0.5), { overTokens: 100_000, rates: priced(0.5, 2.5) })],
|
|
63
|
+
["claude-sonnet-4", model(priced(3.0, 15.0))],
|
|
64
|
+
["claude-sonnet-4-0", model(priced(3.0, 15.0))],
|
|
65
|
+
["claude-sonnet-4-5", model(priced(3.0, 15.0))],
|
|
66
|
+
["claude-sonnet-4-6", model(priced(3.0, 15.0))],
|
|
67
|
+
["claude-sonnet-5", model(priced(2.0, 10.0))],
|
|
68
|
+
["claude-sonnet-5-5", model(priced(2.0, 10.0, 0.1))],
|
|
69
|
+
["claude-opus-4", model(priced(15.0, 75.0))],
|
|
70
|
+
["claude-opus-4-0", model(priced(15.0, 75.0))],
|
|
71
|
+
["claude-opus-4-1", model(priced(15.0, 75.0))],
|
|
72
|
+
["claude-opus-4-5", model(priced(5.0, 25.0))],
|
|
73
|
+
["claude-opus-4-6", model(priced(5.0, 25.0))],
|
|
74
|
+
["claude-opus-4-7", model(priced(5.0, 25.0))],
|
|
75
|
+
["claude-opus-4-8", model(priced(5.0, 25.0))],
|
|
76
|
+
["claude-opus-5", model(priced(5.0, 25.0))],
|
|
77
|
+
["claude-opus-5-5", model(priced(4.0, 20.0, 0.2))],
|
|
78
|
+
["claude-fable-5", model(priced(10.0, 50.0))],
|
|
79
|
+
["claude-fable-5-1", model(priced(10.0, 50.0, 0.25))],
|
|
80
|
+
["claude-fable-5-2", model(priced(8.0, 40.0, 0.2))],
|
|
81
|
+
["claude-mythos-5", model(priced(10.0, 50.0))],
|
|
82
|
+
["claude-mythos-5-1", model(priced(10.0, 50.0, 0.25))],
|
|
83
|
+
["claude-mythos-5-2", model(priced(8.0, 40.0, 0.2))],
|
|
84
|
+
]);
|
|
85
|
+
/** Fast mode bills every kind of token at twice the standard price. */
|
|
86
|
+
const FAST_FACTOR = 2;
|
|
87
|
+
/** US-only inference (`inference_geo: "us"`) bills every kind of token at 1.1x. */
|
|
88
|
+
const US_ONLY_FACTOR = 1.1;
|
|
89
|
+
/**
|
|
90
|
+
* What may follow a model's name and still be that model: a dated snapshot
|
|
91
|
+
* (`-20251001`, `@20251001`), a cloud's version tag (`-v1:0`), `-latest`.
|
|
92
|
+
*/
|
|
93
|
+
const SNAPSHOT_SUFFIX = /^(?:[-@]\d{8})?(?:-v\d+(?::\d+)?)?(?:-latest)?$/;
|
|
47
94
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
95
|
+
* The table's row for a name: the name itself, or the name with a provider
|
|
96
|
+
* prefix (`anthropic/`, `us.anthropic.`), a host's tag (`[1m]`) or a snapshot
|
|
97
|
+
* suffix taken off.
|
|
98
|
+
*
|
|
99
|
+
* NEVER A NEWER VERSION PRICED AS AN OLDER ONE. This used to take the longest
|
|
100
|
+
* row that prefixed the name, "so a new snapshot never silently prices at $0".
|
|
101
|
+
* A new *version* is not a snapshot: `claude-opus-5-5` was priced as Opus 5
|
|
102
|
+
* until its row came, and `claude-fable-5-2` as Fable 5 — a quarter too dear
|
|
103
|
+
* on input and output, five times on cache reads — and each such turn was
|
|
104
|
+
* reported as measured. A version with no row has no price: the turn is
|
|
105
|
+
* reported unmeasured, and says which model to add (`run.unmeasured`).
|
|
51
106
|
*/
|
|
52
|
-
|
|
53
|
-
const exact = MODEL_PRICING
|
|
107
|
+
function pricedModel(name) {
|
|
108
|
+
const exact = MODEL_PRICING.get(name);
|
|
54
109
|
if (exact)
|
|
55
110
|
return exact;
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
61
|
-
// Longest declared family that prefixes the name — so a dated snapshot of a
|
|
62
|
-
// known model prices as that model rather than as nothing.
|
|
111
|
+
const at = name.indexOf("claude-");
|
|
112
|
+
const bare = (at >= 0 ? name.slice(at) : name).replace(/\[[^\]]*\]$/, "");
|
|
113
|
+
const known = MODEL_PRICING.get(bare);
|
|
114
|
+
if (known)
|
|
115
|
+
return known;
|
|
63
116
|
let best;
|
|
64
|
-
for (const key of
|
|
65
|
-
if (bare.startsWith(key)
|
|
117
|
+
for (const key of MODEL_PRICING.keys()) {
|
|
118
|
+
if (!bare.startsWith(key) || !SNAPSHOT_SUFFIX.test(bare.slice(key.length)))
|
|
119
|
+
continue;
|
|
120
|
+
if (!best || key.length > best.length)
|
|
66
121
|
best = key;
|
|
67
122
|
}
|
|
68
|
-
return best ? MODEL_PRICING
|
|
123
|
+
return best ? MODEL_PRICING.get(best) : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A model's rate card, or undefined when the table has no row for it. With
|
|
127
|
+
* `longPrompt`, the card of a request over the model's prompt-length
|
|
128
|
+
* threshold, for a model that has one.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveModelPricing(name, opts = {}) {
|
|
131
|
+
const row = pricedModel(name);
|
|
132
|
+
if (!row)
|
|
133
|
+
return undefined;
|
|
134
|
+
return opts.longPrompt && row.longPrompt ? row.longPrompt.rates : row.rates;
|
|
135
|
+
}
|
|
136
|
+
export function hasModelPricing(name) {
|
|
137
|
+
return pricedModel(name) !== undefined;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The flags of ONE request, from its own `usage` as the API reports it: its
|
|
141
|
+
* prompt length (input, cache reads and cache writes together), its `speed`
|
|
142
|
+
* and its `inference_geo`. Only what is so is set, so two requests priced
|
|
143
|
+
* alike give equal flags — and {@link rateKey} the same key.
|
|
144
|
+
*/
|
|
145
|
+
export function requestRates(name, request) {
|
|
146
|
+
const flags = {};
|
|
147
|
+
const over = pricedModel(name)?.longPrompt?.overTokens;
|
|
148
|
+
if (over !== undefined && request.promptTokens > over)
|
|
149
|
+
flags.longPrompt = true;
|
|
150
|
+
if (request.speed === "fast")
|
|
151
|
+
flags.fast = true;
|
|
152
|
+
if (request.inferenceGeo === "us")
|
|
153
|
+
flags.usOnly = true;
|
|
154
|
+
return flags;
|
|
69
155
|
}
|
|
70
|
-
|
|
71
|
-
|
|
156
|
+
/** A short text that is equal for two sets of flags exactly when they price alike. "" for none. */
|
|
157
|
+
export function rateKey(flags) {
|
|
158
|
+
return `${flags.longPrompt ? "|long" : ""}${flags.fast ? "|fast" : ""}${flags.usOnly ? "|us" : ""}`;
|
|
72
159
|
}
|
|
73
160
|
/** Cost in USD. An unpriced model yields 0 — the caller reports `measured: false`. */
|
|
74
|
-
export function calculateCostUsd(
|
|
75
|
-
const p = resolveModelPricing(
|
|
161
|
+
export function calculateCostUsd(name, usage) {
|
|
162
|
+
const p = resolveModelPricing(name, usage);
|
|
76
163
|
if (!p)
|
|
77
164
|
return 0;
|
|
165
|
+
const writes = Math.max(0, usage.cacheCreationTokens ?? 0);
|
|
166
|
+
const writes1h = Math.min(writes, Math.max(0, usage.cacheCreation1hTokens ?? 0));
|
|
167
|
+
const factor = (usage.fast ? FAST_FACTOR : 1) * (usage.usOnly ? US_ONLY_FACTOR : 1);
|
|
78
168
|
const perToken = 1e-6;
|
|
79
|
-
return (
|
|
80
|
-
|
|
81
|
-
(usage.
|
|
82
|
-
|
|
169
|
+
return (factor *
|
|
170
|
+
perToken *
|
|
171
|
+
(usage.inputTokens * p.input +
|
|
172
|
+
usage.outputTokens * p.output +
|
|
173
|
+
(usage.cacheReadTokens ?? 0) * p.cacheRead +
|
|
174
|
+
(writes - writes1h) * p.cacheWrite +
|
|
175
|
+
writes1h * p.cacheWrite1h));
|
|
83
176
|
}
|
|
84
177
|
//# sourceMappingURL=pricing.js.map
|
package/dist/replay/safety.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
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", {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.26",
|
|
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
|
|
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.
|