@basein/runner 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,8 +201,8 @@ what to do when they register, so there is one switch and no second copy of it:
201
201
  | `BIR_REPLAY=1` | Enable replay. Nothing below matters until it is set. |
202
202
  | `BIR_REPLAY_ALLOW_SERVERS` | Server keys eligible for **direct** execution. Unset means every wrapped server; setting it is the recommendation. |
203
203
  | `BIR_MIN_STEER_SIMILARITY` | Minimum match similarity to replay (default `0.92`, above the service's own `0.9` detection threshold). |
204
- | `ANTHROPIC_API_KEY` | Enables parameter derivation. Without it, replay uses the scenario's recorded sample values — free, and often still correct. |
205
- | `BIR_DERIVE_MODEL` | Derivation model (default `claude-haiku-4-5-20251001`). |
204
+ | `ANTHROPIC_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
205
+ | `BIR_DERIVE_MODEL` | Derivation model, when you set a key of your own (default `claude-haiku-4-5-20251001`). |
206
206
  | `BIR_MATCH_BUDGET_MS` | How long the prompt hook waits for a match (default `2500`). |
207
207
  | `BIR_DERIVE_BUDGET_MS` | How long the first `PreToolUse` waits for parameters (default `8000`). |
208
208
  | `BIR_REPLAY_BUDGET_MS` / `BIR_STEP_TIMEOUT_MS` | Whole-plan and per-step ceilings for direct execution (default `120000` / `60000`). |
@@ -27,9 +27,11 @@
27
27
  * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
28
28
  * execution. Unset means every wrapped server
29
29
  * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
30
- * ANTHROPIC_API_KEY enables parameter derivation. Without it, replay
31
- * uses the scenario's recorded sample values — free,
32
- * and often still correct
30
+ * ANTHROPIC_API_KEY OPTIONAL. Derivation — reading what this turn acts
31
+ * on — is done by the service for a signed-in
32
+ * runner. Set this only to keep that reading on this
33
+ * machine; signed out and unset, a scenario with a
34
+ * target does not run (segmented.md R-PARAM-5)
33
35
  * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
36
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
37
  * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
@@ -27,9 +27,11 @@
27
27
  * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
28
28
  * execution. Unset means every wrapped server
29
29
  * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
30
- * ANTHROPIC_API_KEY enables parameter derivation. Without it, replay
31
- * uses the scenario's recorded sample values — free,
32
- * and often still correct
30
+ * ANTHROPIC_API_KEY OPTIONAL. Derivation — reading what this turn acts
31
+ * on — is done by the service for a signed-in
32
+ * runner. Set this only to keep that reading on this
33
+ * machine; signed out and unset, a scenario with a
34
+ * target does not run (segmented.md R-PARAM-5)
33
35
  * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
36
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
37
  * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
@@ -182,7 +184,13 @@ function buildReplayOptions(auth) {
182
184
  logLine("replay.enabled", {
183
185
  minSimilarity: opts.minSimilarity,
184
186
  allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
185
- derive: process.env.ANTHROPIC_API_KEY ? "anthropic" : "recorded sample values",
187
+ // Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
188
+ // startup because the third state is a replay that silently never runs.
189
+ derive: process.env.ANTHROPIC_API_KEY
190
+ ? "this machine (ANTHROPIC_API_KEY)"
191
+ : auth.session && auth.baseUrl
192
+ ? "the service"
193
+ : "recorded sample values — sign in, or a scenario with a target will not run",
186
194
  intentMatch: opts.intentMatch?.enabled ? "on" : "off",
187
195
  why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
188
196
  });
package/dist/bin/bir.js CHANGED
@@ -600,7 +600,20 @@ async function doctor(args) {
600
600
  if (replay?.enabled) {
601
601
  out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
602
602
  `minSimilarity=${replay.minSimilarity ?? "?"}`);
603
- out(` derive=${replay.deriveKey ? "anthropic" : "recorded sample values (no ANTHROPIC_API_KEY)"}`);
603
+ // An older control server sends only `deriveKey`; read it as the two states
604
+ // it could describe then.
605
+ const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
606
+ const derive = via === "key"
607
+ ? "this machine (ANTHROPIC_API_KEY)"
608
+ : via === "service"
609
+ ? "the service (no key needed here)"
610
+ : "recorded sample values — scenarios with a target will NOT run";
611
+ out(` derive=${derive}`);
612
+ if (via === "samples") {
613
+ notes.push("nothing can read what this turn acts on: sign in with `bir login` so the " +
614
+ "service derives, or set ANTHROPIC_API_KEY to derive here. Until then a " +
615
+ "matched scenario with a target is declined and the agent does the task");
616
+ }
604
617
  const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
605
618
  if (idle.length > 0) {
606
619
  notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
@@ -212,6 +212,15 @@ export class ControlServer {
212
212
  minSimilarity: this.opts.replay?.minSimilarity ?? null,
213
213
  allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
214
214
  deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
215
+ // Who reads the turn for its parameters (segmented.md R-PARAM-5). The
216
+ // ordinary answer is `service`; a key here is an override; `samples`
217
+ // means no scenario with a target can run, which is the one state an
218
+ // operator must be able to see without reading a log.
219
+ deriveVia: (this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY)
220
+ ? "key"
221
+ : this.opts.replay?.authUrl && this.opts.replay?.authToken
222
+ ? "service"
223
+ : "samples",
215
224
  pollingProxies: this.replay.work.pollingServers(),
216
225
  },
217
226
  lossy: this.lossy,
@@ -54,7 +54,10 @@ export interface ReplayOptions {
54
54
  /** `BIR_REPLAY_ALLOW_SERVERS`; undefined means every wrapped server. */
55
55
  allowServers?: ReadonlySet<string>;
56
56
  budgets?: Partial<ReplayBudgets>;
57
- /** For the lazy source-run fetch and nothing else. */
57
+ /**
58
+ * The service: the lazy source-run fetch, and the parameter derivation this
59
+ * runner no longer needs a key of its own for (segmented.md R-PARAM-5).
60
+ */
58
61
  authUrl?: string;
59
62
  authToken?: () => string;
60
63
  /** Injected by tests. */
@@ -378,6 +381,17 @@ export declare class ReplayController {
378
381
  * directive the moment the match lands; the first `PreToolUse` — or
379
382
  * `/scenario/run`, which has no hook timeout at all — is where the wait lands.
380
383
  */
384
+ /**
385
+ * Where to ask the service to read this turn (segmented.md R-PARAM-5).
386
+ *
387
+ * Undefined when there is nobody to ask — an unauthenticated session, or one
388
+ * whose token has gone. `derive` then falls back to the recorded samples, and
389
+ * a scenario with a target declines, exactly as a keyless runner always did.
390
+ *
391
+ * The token is read here rather than captured, because the recorder refreshes
392
+ * it as a session outlives it.
393
+ */
394
+ private deriveService;
381
395
  private startDerivation;
382
396
  /**
383
397
  * Divergence (§8). Execute the remaining steps for real, then deliver.
@@ -246,10 +246,13 @@ export class ReplayController {
246
246
  return decline(`target ${first ?? "(unknown)"} not found — ${errText(err)}`, "missing_target");
247
247
  }
248
248
  if (!result.derived) {
249
- // No key: settings can still take their recorded values, but a target
250
- // is a guess nobody is allowed to make (R-PARAM-5).
249
+ // Nobody read the turn — no key here and no service to ask, or the
250
+ // service declined. Settings can still take their recorded values, but a
251
+ // target is a guess nobody is allowed to make (R-PARAM-5). The reason
252
+ // travels into the line, because "it did not run" without one is how
253
+ // this stayed invisible before.
251
254
  const first = firstTargetKey(scenario.paramsObject);
252
- return decline(`no_derive_key: target ${first ?? "(unknown)"}`, "no_derive_key");
255
+ return decline(`${result.reason ?? "no_derive_key"}: target ${first ?? "(unknown)"}`, "no_derive_key");
253
256
  }
254
257
  if (result.missing.length > 0) {
255
258
  return decline(`target ${result.missing[0]} not found`, "missing_target");
@@ -886,6 +889,22 @@ export class ReplayController {
886
889
  * directive the moment the match lands; the first `PreToolUse` — or
887
890
  * `/scenario/run`, which has no hook timeout at all — is where the wait lands.
888
891
  */
892
+ /**
893
+ * Where to ask the service to read this turn (segmented.md R-PARAM-5).
894
+ *
895
+ * Undefined when there is nobody to ask — an unauthenticated session, or one
896
+ * whose token has gone. `derive` then falls back to the recorded samples, and
897
+ * a scenario with a target declines, exactly as a keyless runner always did.
898
+ *
899
+ * The token is read here rather than captured, because the recorder refreshes
900
+ * it as a session outlives it.
901
+ */
902
+ deriveService(scenarioId) {
903
+ const token = this.opts.authToken?.();
904
+ if (!this.opts.authUrl || !token)
905
+ return undefined;
906
+ return { baseUrl: this.opts.authUrl, token, scenarioId };
907
+ }
889
908
  startDerivation(scenario, prompt, state, ctx = {}) {
890
909
  const derive = this.opts.deriveImpl ?? deriveParameters;
891
910
  return derive({
@@ -893,6 +912,9 @@ export class ReplayController {
893
912
  intent: scenario.intent ?? "",
894
913
  paramsObject: scenario.paramsObject,
895
914
  apiKey: this.opts.apiKey ?? process.env.ANTHROPIC_API_KEY,
915
+ // Where to ask when there is no key here, which is the ordinary case
916
+ // (segmented.md R-PARAM-5).
917
+ service: this.deriveService(scenario.id),
896
918
  fetchImpl: this.opts.fetchImpl,
897
919
  liveCall: ctx.liveCall,
898
920
  recentResults: ctx.recentResults,
@@ -903,7 +925,12 @@ export class ReplayController {
903
925
  scenario: scenario.id,
904
926
  params: Object.keys(r.params).length,
905
927
  costUsd: r.costUsd.toFixed(4),
906
- source: r.derived ? "prompt" : "recorded samples",
928
+ source: r.derived
929
+ ? r.via === "service"
930
+ ? "the service"
931
+ : "prompt"
932
+ : "recorded samples",
933
+ why: r.derived ? undefined : r.reason,
907
934
  missing: r.missing.length > 0 ? r.missing.join(",") : undefined,
908
935
  sampled: r.sampled.length > 0 ? r.sampled.join(",") : undefined,
909
936
  });
@@ -10,11 +10,20 @@
10
10
  * most sessions never arm. This is ~40 lines of `fetch` and it keeps the package
11
11
  * at zero runtime dependencies.
12
12
  *
13
- * NO KEY IS A SUPPORTED CONFIGURATION. Without `ANTHROPIC_API_KEY` the extraction
14
- * is skipped entirely and every parameter takes the value it had in the recorded
15
- * run — a plain replay of the recorded parameters, at zero cost. For a scenario
16
- * whose parameters rarely change that is a complete, free replay; for one that
17
- * keys off the prompt, it is why you want the key.
13
+ * NO KEY IS THE ORDINARY CONFIGURATION (segmented.md R-PARAM-5). Without
14
+ * `ANTHROPIC_API_KEY` the reading is done by the **service**, over one authorized
15
+ * POST to `/scenarios/:id/derive`, under the key it already uses to calculate
16
+ * scenarios and to ask its live question. That is the path nearly every session
17
+ * takes: the people this runs for are inside Claude Code on a subscription and
18
+ * have no API key to give it.
19
+ *
20
+ * A key here is an override, not a requirement. It keeps the reading on this
21
+ * machine — the prompt never reaches the service — which is what an in-house
22
+ * deployment wants, and it is one round trip faster.
23
+ *
24
+ * NEITHER is still supported and still means the same thing it always did:
25
+ * settings take their recorded samples and a scenario with a target declines,
26
+ * because a target is never guessed.
18
27
  *
19
28
  * The prompt and the parsing tolerances mirror RRepeat's
20
29
  * `deriveParametersFromScenarioPayload` exactly, so the two produce the same
@@ -34,12 +43,27 @@ export interface DeriveContext {
34
43
  liveCall?: LiveCall;
35
44
  recentResults?: string[];
36
45
  }
46
+ /**
47
+ * The service, which derives when this runner has no key of its own
48
+ * (R-PARAM-5). All three fields or none: a session that is not signed in has
49
+ * nowhere to ask.
50
+ */
51
+ export interface DeriveService {
52
+ /** `BIR_AUTH_URL`, the same base the recorder reports to. */
53
+ baseUrl: string;
54
+ /** A live access token. Read late — the recorder refreshes it mid-session. */
55
+ token: string;
56
+ /** Whose parameters to read. The service takes the schema from its own row. */
57
+ scenarioId: string;
58
+ }
37
59
  export interface DeriveOptions extends DeriveContext {
38
60
  prompt: string;
39
61
  intent: string;
40
62
  paramsObject: ParamsSchema | null;
41
- /** Absent → sample values, at zero cost. */
63
+ /** An override that keeps the reading on this machine. Absent → the service. */
42
64
  apiKey?: string;
65
+ /** Used when there is no `apiKey`. Absent too → recorded samples. */
66
+ service?: DeriveService;
43
67
  model?: string;
44
68
  signal?: AbortSignal;
45
69
  /** Injected by tests. Defaults to global `fetch`. */
@@ -50,6 +74,10 @@ export interface DeriveResult {
50
74
  costUsd: number;
51
75
  /** False when the values came from the recorded samples rather than the model. */
52
76
  derived: boolean;
77
+ /** Who read the turn. For the audit line, and for nothing else. */
78
+ via?: "key" | "service" | "samples";
79
+ /** Why nothing was derived, when the service said. */
80
+ reason?: string;
53
81
  model?: string;
54
82
  inputTokens?: number;
55
83
  outputTokens?: number;
@@ -10,11 +10,20 @@
10
10
  * most sessions never arm. This is ~40 lines of `fetch` and it keeps the package
11
11
  * at zero runtime dependencies.
12
12
  *
13
- * NO KEY IS A SUPPORTED CONFIGURATION. Without `ANTHROPIC_API_KEY` the extraction
14
- * is skipped entirely and every parameter takes the value it had in the recorded
15
- * run — a plain replay of the recorded parameters, at zero cost. For a scenario
16
- * whose parameters rarely change that is a complete, free replay; for one that
17
- * keys off the prompt, it is why you want the key.
13
+ * NO KEY IS THE ORDINARY CONFIGURATION (segmented.md R-PARAM-5). Without
14
+ * `ANTHROPIC_API_KEY` the reading is done by the **service**, over one authorized
15
+ * POST to `/scenarios/:id/derive`, under the key it already uses to calculate
16
+ * scenarios and to ask its live question. That is the path nearly every session
17
+ * takes: the people this runs for are inside Claude Code on a subscription and
18
+ * have no API key to give it.
19
+ *
20
+ * A key here is an override, not a requirement. It keeps the reading on this
21
+ * machine — the prompt never reaches the service — which is what an in-house
22
+ * deployment wants, and it is one round trip faster.
23
+ *
24
+ * NEITHER is still supported and still means the same thing it always did:
25
+ * settings take their recorded samples and a scenario with a target declines,
26
+ * because a target is never guessed.
18
27
  *
19
28
  * The prompt and the parsing tolerances mirror RRepeat's
20
29
  * `deriveParametersFromScenarioPayload` exactly, so the two produce the same
@@ -120,14 +129,20 @@ export async function deriveParameters(opts) {
120
129
  if (keys.length === 0)
121
130
  return { params: {}, costUsd: 0, derived: false, missing: [], sampled: [] };
122
131
  const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
123
- // No key is a supported configuration, and `derived: false` is how the caller
124
- // tells it apart from a real extraction: it declines any scenario that has a
125
- // target and arms an all-settings one on its recorded values (R-PARAM-5).
132
+ // No key of our own is the ordinary case: the service reads the turn instead,
133
+ // under the key it already spends on this account (R-PARAM-5).
134
+ if (!apiKey && opts.service)
135
+ return deriveViaService(opts, opts.service, schema, keys);
136
+ // No key and nowhere to ask — an unauthenticated session, or a deployment that
137
+ // turned service derivation off. `derived: false` is how the caller tells this
138
+ // apart from a real extraction: it declines any scenario that has a target and
139
+ // arms an all-settings one on its recorded values.
126
140
  if (!apiKey)
127
141
  return {
128
142
  params: sampleValues(schema),
129
143
  costUsd: 0,
130
144
  derived: false,
145
+ via: "samples",
131
146
  missing: [],
132
147
  sampled: keys,
133
148
  };
@@ -213,6 +228,7 @@ export async function deriveParameters(opts) {
213
228
  params: parsed,
214
229
  costUsd,
215
230
  derived: true,
231
+ via: "key",
216
232
  model,
217
233
  inputTokens,
218
234
  outputTokens,
@@ -220,4 +236,88 @@ export async function deriveParameters(opts) {
220
236
  sampled,
221
237
  };
222
238
  }
239
+ /**
240
+ * Ask the service to read the turn (segmented.md R-PARAM-5).
241
+ *
242
+ * One authorized POST. The body carries the turn — the prompt, and whatever else
243
+ * this turn has said about its target — and never the schema: the service reads
244
+ * that from the scenario row it owns, so a stale copy here cannot change what is
245
+ * asked for.
246
+ *
247
+ * Never throws, and never worse than no key. A service that is off, out of
248
+ * budget, too slow or simply down answers `derived: false`, and so does every
249
+ * failure on this side, which is precisely the keyless behaviour this replaced:
250
+ * settings stand on their samples, and a target does not run.
251
+ */
252
+ async function deriveViaService(opts, service, schema, keys) {
253
+ const samples = (reason) => ({
254
+ params: sampleValues(schema),
255
+ costUsd: 0,
256
+ derived: false,
257
+ via: "service",
258
+ reason,
259
+ missing: [],
260
+ sampled: keys,
261
+ });
262
+ const doFetch = opts.fetchImpl ?? fetch;
263
+ const url = `${service.baseUrl.replace(/\/+$/, "")}/scenarios/${service.scenarioId}/derive`;
264
+ let body;
265
+ try {
266
+ const res = await doFetch(url, {
267
+ method: "POST",
268
+ headers: {
269
+ "content-type": "application/json",
270
+ authorization: `Bearer ${service.token}`,
271
+ },
272
+ body: JSON.stringify({
273
+ prompt: opts.prompt,
274
+ liveCall: opts.liveCall,
275
+ recentResults: opts.recentResults,
276
+ }),
277
+ signal: opts.signal,
278
+ });
279
+ if (!res.ok)
280
+ return samples(`service HTTP ${res.status}`);
281
+ body = (await res.json());
282
+ }
283
+ catch (err) {
284
+ return samples(err instanceof Error ? err.message : String(err));
285
+ }
286
+ if (!body || typeof body !== "object")
287
+ return samples("service sent no answer");
288
+ if (body.derived !== true)
289
+ return samples(`service: ${body.reason ?? "declined"}`);
290
+ // Normalised again on this side, over *our* copy of the schema. The service
291
+ // answers in the same shape, but the plan runs on these values and the gate
292
+ // reads this `missing`: an older or newer service must not be able to leave a
293
+ // target unaccounted for.
294
+ const params = { ...(body.params ?? {}) };
295
+ const missing = [];
296
+ const sampled = [];
297
+ for (const key of keys) {
298
+ const value = params[key];
299
+ if (value !== null && value !== undefined)
300
+ continue;
301
+ if (isTarget(schema, key)) {
302
+ params[key] = null;
303
+ missing.push(key);
304
+ }
305
+ else {
306
+ params[key] = schema[key].sampleValue;
307
+ sampled.push(key);
308
+ }
309
+ }
310
+ return {
311
+ params,
312
+ // What the service spent on this turn, reported back on the execution so a
313
+ // replay's cost stays the whole truth about it (R-MONEY-2). The account is
314
+ // not billed for it; the service books it on its own ledger as well.
315
+ costUsd: typeof body.costUsd === "number" ? body.costUsd : 0,
316
+ derived: true,
317
+ via: "service",
318
+ model: body.model,
319
+ missing,
320
+ sampled,
321
+ };
322
+ }
223
323
  //# sourceMappingURL=derive.js.map
@@ -853,7 +853,8 @@ fail because of BaseInstRunner.**
853
853
  | Failure | Behaviour |
854
854
  |---|---|
855
855
  | `getMatch()` slower than the match budget | No plan. Ordinary turn. One `replay.decision` line |
856
- | No `ANTHROPIC_API_KEY` | Derivation skipped; the scenario's recorded `sampleValue`s are used. `deriveCostUsd: 0` |
856
+ | No `ANTHROPIC_API_KEY` | The service derives instead (segmented.md R-PARAM-5). Signed out as well: settings take their recorded `sampleValue`s, a scenario with a target declines `no_derive_key`, `deriveCostUsd: 0` |
857
+ | The service declines, is slow, or cannot be reached | The same, with its reason carried into the `replay.derived` and `replay.decision` lines. Never a throw |
857
858
  | Derivation call fails or times out | Same as above, plus a `replay.derive_failed` line |
858
859
  | `toolInputLogic` throws | Divergence (§8) — never a crash |
859
860
  | `toolOutputLogic` throws | Retire the plan, abort to an ordinary turn, outcome `failed` |
@@ -982,7 +983,8 @@ export async function deriveParameters(opts: {
982
983
  prompt: string;
983
984
  intent: string;
984
985
  paramsObject: Record<string, { sampleValue: unknown; description: string }> | null;
985
- apiKey?: string; // ANTHROPIC_API_KEY
986
+ apiKey?: string; // ANTHROPIC_API_KEY — an override
987
+ service?: { baseUrl: string; token: string; scenarioId: string }; // the default path
986
988
  model?: string; // default claude-haiku-4-5-20251001
987
989
  signal?: AbortSignal;
988
990
  }): Promise<{ params: Record<string, unknown>; costUsd: number; derived: boolean }>;
@@ -992,11 +994,16 @@ Behaviour, matching RRepeat's `deriveParametersFromScenarioPayload` exactly so t
992
994
  same parameters from the same prompt:
993
995
 
994
996
  - No `paramsObject` keys → `{}`, `costUsd: 0`.
995
- - **No `apiKey` → return every key's recorded `sampleValue`**, `costUsd: 0`, `derived: false`. A plain
996
- replay of the recorded parameters, free, and the documented default for anyone without a key.
997
- - Otherwise `POST https://api.anthropic.com/v1/messages` with the same extraction prompt, tolerant
998
- JSON extraction (first balanced `{…}`, `undefined` → `null`), and any `null`/missing key filled
999
- from its `sampleValue`.
997
+ - **No `apiKey` but a `service` → `POST {baseUrl}/scenarios/{id}/derive`** with the turn (prompt,
998
+ live call, recent results) and a bearer token, `derived` as the service answered. This is the
999
+ ordinary path: nearly every session runs inside Claude Code on a subscription and has no key of
1000
+ its own (segmented.md R-PARAM-5). The answer is re-normalised here against this runner's own copy
1001
+ of the schema, so the gate reads a `missing` computed from what will actually run.
1002
+ - **Neither → return every key's recorded `sampleValue`**, `costUsd: 0`, `derived: false`. Settings
1003
+ stand on their samples; a scenario with a target declines rather than run on a stale one.
1004
+ - With an `apiKey`, `POST https://api.anthropic.com/v1/messages` with the same extraction prompt,
1005
+ tolerant JSON extraction (first balanced `{…}`, `undefined` → `null`), and any `null`/missing
1006
+ **setting** filled from its `sampleValue` — a `null` target is named in `missing` instead.
1000
1007
 
1001
1008
  Keeping this dependency-free is not purity. `bir-proxy` is spawned inside the host's process tree for
1002
1009
  *every* wrapped server; an npm install that pulls a model SDK into that path is a startup-latency and
@@ -1041,7 +1048,8 @@ bir replay --scenario <scnId> --prompt "…" [--dry] alias; the Tier 2 / deb
1041
1048
  source run's *recorded* outputs — no real tools, no side effects, one Haiku call. Without `--dry`,
1042
1049
  `bir replay` runs the same plan through the same executor against the live proxies. `bir doctor`
1043
1050
  gains a `replay` block: on/off, the effective `BIR_REPLAY_ALLOW_SERVERS` allowlist,
1044
- `BIR_MIN_STEER_SIMILARITY`, and whether an `ANTHROPIC_API_KEY` was found (§13.2, mitigation 3).
1051
+ `BIR_MIN_STEER_SIMILARITY`, and who derives — this machine, the service, or nobody, which is the
1052
+ state in which a scenario with a target never runs (§13.2, mitigation 3).
1045
1053
 
1046
1054
  ---
1047
1055
 
@@ -34,7 +34,7 @@ end-to-end smoke test.
34
34
  | 3 | `SIMILARITY_DETECTION_ENABLED=true` on the server (default) | otherwise no prompt ever matches |
35
35
  | 4 | BaseInstRunnerMCP built and installed in your project | `bir status` |
36
36
  | 5 | Tier 1 — the hooks wired and `bir-hooks` running | `bir doctor` |
37
- | 6 | *(replay only)* `ANTHROPIC_API_KEY` in the `bir-hooks` environment | optional — see §5.3 |
37
+ | 6 | *(replay only)* signed in, so the service can read what each request acts on | `bir doctor` — `derive=the service`; an `ANTHROPIC_API_KEY` here replaces it, see §5.3 |
38
38
 
39
39
  Tier 1 is not optional for replay. A match is a match on **the prompt**, and a standalone proxy never
40
40
  sees one (design §1.1). If `bir doctor` says `tier: standalone`, replay cannot arm, and that is the
@@ -229,7 +229,7 @@ bir doctor
229
229
 
230
230
  ```
231
231
  replay ON servers=chrome-devtools,fleet-api minSimilarity=0.92
232
- derive=claude-haiku-4-5-20251001 (ANTHROPIC_API_KEY present)
232
+ derive=the service (no key needed here)
233
233
  control http://127.0.0.1:53411 sess=birsess_…
234
234
  wrapped chrome-devtools ✓ proxy pid 41822 fleet-api ✓ proxy pid 41823
235
235
  recording yes (https://your-basein-service)
@@ -246,8 +246,8 @@ there is exactly one switch and it cannot get out of step with itself.
246
246
  | `BIR_REPLAY` | *(unset)* | `1` enables replay. Nothing below matters until it is set |
247
247
  | `BIR_REPLAY_ALLOW_SERVERS` | *(all wrapped)* | Comma-separated server keys eligible for **direct** execution. **Set this** |
248
248
  | `BIR_MIN_STEER_SIMILARITY` | `0.92` | Below this a match is detected but not replayed (§8) |
249
- | `ANTHROPIC_API_KEY` | *(unset)* | Enables parameter derivation. Without it, replay uses the scenario's **recorded sample values** — free, and often still correct |
250
- | `BIR_DERIVE_MODEL` | `claude-haiku-4-5-20251001` | The derivation model |
249
+ | `ANTHROPIC_API_KEY` | *(unset)* | **Not required.** Derivation — reading what this request acts on — is done by the service on its key for a signed-in runner. Set this to keep the reading on this machine instead: the prompt then never leaves it, and it is one round trip faster. Signed out *and* unset, only a scenario with nothing to work out replays |
250
+ | `BIR_DERIVE_MODEL` | `claude-haiku-4-5-20251001` | The derivation model, when this machine does the reading |
251
251
  | `BIR_MATCH_BUDGET_MS` | `2500` | How long the prompt hook waits for a match before giving up |
252
252
  | `BIR_DERIVE_BUDGET_MS` | `8000` | How long the first `PreToolUse` waits for parameters |
253
253
  | `BIR_REPLAY_BUDGET_MS` | `120000` | Whole-plan ceiling for direct execution |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",