@basein/runner 0.3.9 → 0.3.11

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 (50) hide show
  1. package/README.md +47 -6
  2. package/dist/auth/client.d.ts +15 -0
  3. package/dist/auth/client.js +34 -12
  4. package/dist/bin/bir-hooks.d.ts +1 -1
  5. package/dist/bin/bir-hooks.js +1 -1
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +60 -6
  8. package/dist/bin/investigate.d.ts +5 -0
  9. package/dist/bin/investigate.js +117 -15
  10. package/dist/bin/setup.d.ts +12 -0
  11. package/dist/bin/setup.js +185 -56
  12. package/dist/bin/update.d.ts +81 -0
  13. package/dist/bin/update.js +194 -0
  14. package/dist/config/adapters/claude-code.d.ts +18 -1
  15. package/dist/config/adapters/claude-code.js +30 -3
  16. package/dist/config/generate.d.ts +14 -0
  17. package/dist/control/daemon.d.ts +26 -1
  18. package/dist/control/daemon.js +53 -3
  19. package/dist/control/pin-guard.d.ts +6 -1
  20. package/dist/control/pin-guard.js +15 -1
  21. package/dist/control/recorder-keeper.js +2 -1
  22. package/dist/control/server.d.ts +205 -2
  23. package/dist/control/server.js +766 -56
  24. package/dist/control/transcript.d.ts +4 -0
  25. package/dist/control/transcript.js +4 -0
  26. package/dist/proxy/session.js +1 -0
  27. package/dist/record/housekeeping.d.ts +7 -1
  28. package/dist/record/housekeeping.js +11 -3
  29. package/dist/record/recorder.d.ts +34 -0
  30. package/dist/record/remote-recorder.d.ts +6 -0
  31. package/dist/record/remote-recorder.js +31 -4
  32. package/dist/record/switchable-recorder.js +1 -0
  33. package/dist/replay/controller.d.ts +139 -3
  34. package/dist/replay/controller.js +248 -24
  35. package/dist/replay/derive.js +2 -0
  36. package/dist/replay/handover.d.ts +13 -1
  37. package/dist/replay/handover.js +19 -0
  38. package/dist/replay/safety.d.ts +15 -4
  39. package/dist/replay/safety.js +19 -6
  40. package/dist/replay/source-run.js +2 -1
  41. package/dist/util/service-headers.d.ts +24 -0
  42. package/dist/util/service-headers.js +47 -0
  43. package/docs/BaseInstRunner.md +115 -1
  44. package/docs/calculatedReplay.md +209 -32
  45. package/docs/calculatedReplayGuide.md +133 -12
  46. package/docs/installRun.md +91 -10
  47. package/docs/quickstart.md +64 -9
  48. package/package.json +1 -1
  49. package/production.md +83 -19
  50. package/scripts/install.ps1 +25 -1
package/README.md CHANGED
@@ -59,7 +59,9 @@ cd the-project-you-record-from
59
59
  bir setup --auth-url https://api.bi2202.com # everything below, in one go
60
60
  ```
61
61
 
62
- or the pieces `bir setup` is made of:
62
+ or the pieces `bir setup` is made of (on a machine with no stored address yet,
63
+ `bir login` needs `BIR_AUTH_URL=https://api.bi2202.com` set: it does not read
64
+ `--auth-url`):
63
65
 
64
66
  ```bash
65
67
  bir login # once; approve it in the browser (or --token <t> from the console)
@@ -152,7 +154,11 @@ work through (not recording, a proxy not registered yet, the service
152
154
  unreachable): exit 0, or 1 with `--strict`. `--quick` does not call the service.
153
155
  `bir status` also shows the recorder's view of the service, the off switch, a
154
156
  port the recorder could not take, a STALE local copy, and the other projects set
155
- up on this machine.
157
+ up on this machine. In a folder set up for another host
158
+ ([A LangChain application](#a-langchain-application)) both add a `Host` line:
159
+ it names the host and says that no Claude Code hooks are needed there, or, in
160
+ a folder set up for both, names Claude Code too. `bir status --json` carries
161
+ `hosts`, and `bir doctor` does not report missing hooks in such a folder.
156
162
 
157
163
  ### Any other MCP client
158
164
 
@@ -164,6 +170,37 @@ bir install --config /path/to/that/client/mcp.json
164
170
  bir wrap --server-name my-server -- node my-server.js # prints an entry to paste
165
171
  ```
166
172
 
173
+ ### A LangChain application
174
+
175
+ A Python program whose agent is built with LangChain's `create_agent` is set up
176
+ in another way: nothing of Claude Code is wired. In the folder the application
177
+ starts in:
178
+
179
+ ```bash
180
+ bir setup --host langchain # a machine with no stored address: add --auth-url https://api.bi2202.com
181
+ ```
182
+
183
+ It signs in, gives the folder an entry of its own in
184
+ `~/.baseinstrunner/installed.json` — a fixed port, a token, and replay on
185
+ (`--no-replay` stores it off; `bir replay on` / `off` work there as anywhere) —
186
+ starts the recorder in the background, and prints the two lines of Python to
187
+ add to the agent. It writes nothing under `.claude/` and nothing into
188
+ `~/.claude.json`: no hooks, no wrapped servers, no `bir` server. A folder
189
+ already set up for Claude Code keeps that set-up as it is. While a folder is
190
+ not set up for Claude Code as well, its recorder is started with a small
191
+ environment — `PATH`, the home and temp folders, what the system needs to
192
+ start a process, the proxy and certificate settings, and the `BIR_*`
193
+ variables — so the application's keys and passwords are not in the process that
194
+ runs scenario code unsandboxed ([What it will not do](#what-it-will-not-do)).
195
+ A runner that does not know `--host` (0.3.9 and older) prints its usage and
196
+ exits 2; it sets nothing up.
197
+
198
+ Those two lines load a LangChain middleware from the Python package
199
+ `langchain-basic2202`, which reports every call to the recorder. That package
200
+ and the service's LangChain part are needed too, and neither is out yet. The
201
+ how-to is [langChainGuide.md](langChainGuide.md) in the repository's root; it
202
+ is not in the npm package.
203
+
167
204
  ### Taking it out
168
205
 
169
206
  ```bash
@@ -177,7 +214,9 @@ leaves it running switched off for this project: restart that session, then
177
214
  `bir down` (`--keep-recorder` leaves the recorder alone). While anything here
178
215
  still runs through the runner it exits 1 with `Not everything could be undone`
179
216
  and a list; fix what it names and run it again. `--server <name>` takes out only
180
- that server, and `--replay` only the scenario server, switching replay off.
217
+ that server, and `--replay` only the scenario server, switching replay off. In
218
+ a folder set up for another host it removes the folder's entry — its port,
219
+ token and replay switch — and stops its recorder.
181
220
 
182
221
  Remove the package (`npm rm -g @basein/runner`, or delete `~/.baseinstrunner`,
183
222
  where the install line's copy lives) only after `bir uninstall --all` has
@@ -244,7 +283,8 @@ between the two is not guaranteed.
244
283
  else. Calculated replay — running a matched prompt's scenario instead of
245
284
  rediscovering it — ships, but needs `bir install --replay`, which `bir setup`
246
285
  runs unless given `--no-replay`. Once installed it is on; `bir replay off` or
247
- `BIR_REPLAY=0` turns it off, for the reason in the next section.
286
+ `BIR_REPLAY=0` turns it off from the recorder's next start
287
+ (`bir up --restart`), for the reason in the next section.
248
288
  - **Change what the model does.** Tool descriptions are never edited. The
249
289
  model-visible changes are one declared schema key (below), the first-party
250
290
  `bir` server's `run_scenario` (only with `--replay`), and replay's own
@@ -448,7 +488,7 @@ what to do when they register, so there is one switch and no second copy of it:
448
488
  | `ANTHROPIC_API_KEY` | Not used by the background recorder, which has Claude Code's environment and would bill your own key — unless `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand in a terminal uses that terminal's key. |
449
489
  | `BIR_DERIVE_MODEL` | Derivation model, when you set a key of your own (default `claude-haiku-4-5-20251001`). |
450
490
  | `BIR_MATCH_BUDGET_MS` | How long the prompt hook waits for a match (default `3000`). After a match slower than `BIR_MATCH_SLOW_MS` (default `2000`), or one that timed out, prompts stop waiting for 5 min, doubling up to 30 min; a fast match ends that. |
451
- | `BIR_DERIVE_BUDGET_MS` / `BIR_HOOK_DERIVE_BUDGET_MS` | How long a direct run, and a hook, waits for parameters (default `8000` / `4000`; a hook waits the smaller). |
491
+ | `BIR_DERIVE_BUDGET_MS` / `BIR_HOOK_DERIVE_BUDGET_MS` | How long a direct run, and a hook, waits for parameters (default `8000` / `8000`; a hook waits the smaller, and the prompt hook never past its own 7.5 s). |
452
492
  | `BIR_PRETOOL_REPLAY_BUDGET_MS` | Everything replay does inside one `PreToolUse` (default `9000`, at most `10000`). |
453
493
  | `BIR_REPLAY_BUDGET_MS` / `BIR_STEP_TIMEOUT_MS` | Whole-plan and per-step ceilings for direct execution (default `100000` / `60000`; `BIR_REPLAY_PLAN_BUDGET_MS` also sets the first). A direct run's parameter wait plus its plan are held under 270 s. |
454
494
  | `BIR_LOGIC_TIMEOUT_MS` | How long one scenario logic body may run before it is stopped and the plan hands over (default `1000`, at most `10000`). |
@@ -485,8 +525,9 @@ The governing rule: **a host session never fails because of BaseInstRunner.**
485
525
  | No recorder running at session start | the SessionStart hook starts one in the background; it always exits 0 and never holds the start longer than about 11.5 s |
486
526
  | Control server unreachable at startup | steps buffered ≤ 10 s, then Tier 2 (passthrough: nothing); the proxy keeps looking every 15 s and binds when one answers |
487
527
  | Control server dies mid-session | the proxy records to a Tier 2 run of its own (flagged lossy), starts the recorder again in the background (not after `bir down`), and binds to it once it answers |
488
- | A hook the recorder cannot answer in time | every route has a hard deadline (prompt 7 s, `PreToolUse` 12 s, the others 1–2 s) and answers `{}`; so does a hook with a stale token or a malformed body |
528
+ | A hook the recorder cannot answer in time | every route has a hard deadline (prompt 8.5 s, `PreToolUse` 12 s, the others 1–2 s) and answers `{}`; so does a hook with a stale token or a malformed body |
489
529
  | BaseIn service slow or down | hooks stop waiting on it after one failure (a cool-down of 5 s, doubling to 60 s); sends go on in the background, retried once only when they cannot have arrived (429, 503, a refused connection); past 500 queued sends or 32 MiB the oldest steps are dropped |
530
+ | BaseIn service answers `429`: the account is over one of its limits | the send — a run's create, a step, a report — goes once more, at most 2 s later, and is then dropped, with `recorder.send_failed … HTTP 429` on the log (one line per run). A dropped step is a hole in its recording; a run whose create was refused runs without its plan; a `429` the second try did not cure starts the cool-down above. The limits are settings of the service, per account — by default 120 runs and 3,000 steps a minute (a tool call is two steps), 10 title requests a minute, and 20 Calculate, Prefer and Recalculate requests in any one hour — and are built there, not deployed yet |
490
531
  | Upstream fails to start, or exits | calls in flight are answered (`MCP server "<name>" is not available`, `isError: true` for a `tools/call`), then `bir-proxy` exits with the server's own status, so Claude Code shows the server as failed, as it would unwrapped. `BIR_PROXY_STAY_UP=1` keeps the proxy up answering that instead |
491
532
  | Remote upstream unreachable or refusing | the request gets a short error (`upstream unreachable: …`, `upstream requires sign-in (HTTP 401)`), never the server's body; only connecting is bounded (10 s), never how long a tool runs |
492
533
  | Result over 128 MiB | the call ends with an error saying the result was too large to deliver, instead of hanging |
@@ -197,6 +197,21 @@ export declare const AUTH_URL_HINT: string;
197
197
  * strict about the content type, which is what separates a service from a page.
198
198
  */
199
199
  export declare function describeAuthService(baseUrl: string, fetchImpl?: typeof fetch): Promise<string | undefined>;
200
+ /** What `GET /health` said about the service at an address. */
201
+ export interface AuthServiceView {
202
+ /** One line saying what answered instead of a BaseIn service; absent when one did. */
203
+ problem?: string;
204
+ /** The newest contract the service speaks (util/service-headers.ts). Absent from an older service. */
205
+ contract?: number;
206
+ /** The lowest contract a runner needs to be handed a plan. Absent from an older service. */
207
+ minRunner?: number;
208
+ }
209
+ /**
210
+ * {@link describeAuthService}, plus the two numbers a service puts on its
211
+ * `/health` so a runner can tell that it is too old to be handed a plan
212
+ * (`bir doctor` says so). An answer without them is an older service.
213
+ */
214
+ export declare function inspectAuthService(baseUrl: string, fetchImpl?: typeof fetch): Promise<AuthServiceView>;
200
215
  /**
201
216
  * Authenticate and return a valid session, or `undefined` when none can be
202
217
  * obtained. Callers degrade to not-recording rather than failing the session.
@@ -46,6 +46,7 @@ import { configDir, configPath, ensureDir } from "../control/paths.js";
46
46
  import { join } from "node:path";
47
47
  import { logLine, errText } from "../util/log.js";
48
48
  import { packageVersion } from "../util/version.js";
49
+ import { serviceHeaders } from "../util/service-headers.js";
49
50
  import { writeFileAtomic } from "../util/atomic-write.js";
50
51
  import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
51
52
  /** A non-2xx answer from the service, with its status. */
@@ -301,6 +302,7 @@ export async function normalizeAuthUrl(baseUrl, fetchImpl = fetch) {
301
302
  // timeout (10-20 s) and a host that accepts and never answers cost 300 s.
302
303
  res = await fetchImpl(`${base}/health`, {
303
304
  method: "GET",
305
+ headers: serviceHeaders(),
304
306
  redirect: "manual",
305
307
  signal: timeoutSignal(PROBE_TIMEOUT_MS),
306
308
  });
@@ -375,9 +377,18 @@ function causeText(err) {
375
377
  * strict about the content type, which is what separates a service from a page.
376
378
  */
377
379
  export async function describeAuthService(baseUrl, fetchImpl = fetch) {
380
+ return (await inspectAuthService(baseUrl, fetchImpl)).problem;
381
+ }
382
+ /**
383
+ * {@link describeAuthService}, plus the two numbers a service puts on its
384
+ * `/health` so a runner can tell that it is too old to be handed a plan
385
+ * (`bir doctor` says so). An answer without them is an older service.
386
+ */
387
+ export async function inspectAuthService(baseUrl, fetchImpl = fetch) {
388
+ const problem = (text) => ({ problem: text });
378
389
  const base = baseUrl.replace(/\/+$/, "");
379
390
  if (!base)
380
- return "no URL configured";
391
+ return problem("no URL configured");
381
392
  // Not reported to the health verdict, unlike every other request here: this
382
393
  // is asked of *candidate* addresses (a website someone pasted, a typo), and
383
394
  // their failure says nothing about the service the runner actually uses.
@@ -385,26 +396,37 @@ export async function describeAuthService(baseUrl, fetchImpl = fetch) {
385
396
  try {
386
397
  res = await fetchImpl(`${base}/health`, {
387
398
  method: "GET",
388
- headers: { accept: "application/json" },
399
+ headers: { accept: "application/json", ...serviceHeaders() },
389
400
  redirect: "manual",
390
401
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
391
402
  });
392
403
  }
393
404
  catch (err) {
394
- return `unreachable (${causeText(err)})`;
405
+ return problem(`unreachable (${causeText(err)})`);
395
406
  }
396
407
  if (res.status >= 300 && res.status < 400) {
397
- return `redirects to ${res.headers.get("location") ?? "somewhere else"} — use the final address`;
408
+ return problem(`redirects to ${res.headers.get("location") ?? "somewhere else"} — use the final address`);
398
409
  }
399
410
  const type = (res.headers.get("content-type") ?? "").toLowerCase();
400
411
  if (!res.ok) {
401
- return `GET /health answered HTTP ${res.status}${type.includes("html") ? " with a web page" : ""}`;
412
+ return problem(`GET /health answered HTTP ${res.status}${type.includes("html") ? " with a web page" : ""}`);
402
413
  }
403
414
  if (!type.includes("json")) {
404
415
  const what = type.includes("html") ? "a web page" : type ? `'${type}'` : "no content type";
405
- return `GET /health answered ${what} instead of JSON — that is a website, not the BaseIn API`;
416
+ return problem(`GET /health answered ${what} instead of JSON — that is a website, not the BaseIn API`);
406
417
  }
407
- return undefined;
418
+ // Still loose about the body: one that cannot be read, or has neither
419
+ // number, is a service that answers — an older one.
420
+ let body;
421
+ try {
422
+ body = JSON.parse(await res.text());
423
+ }
424
+ catch {
425
+ return {};
426
+ }
427
+ const b = (body && typeof body === "object" ? body : {});
428
+ const count = (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : undefined);
429
+ return { contract: count(b.contract), minRunner: count(b.minRunner) };
408
430
  }
409
431
  function prompt(question) {
410
432
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -463,7 +485,7 @@ async function postJson(url, body) {
463
485
  // seconds, not minutes.
464
486
  res = await fetch(url, {
465
487
  method: "POST",
466
- headers: { "content-type": "application/json" },
488
+ headers: { "content-type": "application/json", ...serviceHeaders() },
467
489
  body: JSON.stringify(body),
468
490
  signal: timeoutSignal(serviceTimeoutMs()),
469
491
  });
@@ -604,7 +626,7 @@ export async function startDeviceCode(authUrl, client, fetchImpl = fetch) {
604
626
  try {
605
627
  res = await fetchImpl(`${authUrl}/auth/device/code`, {
606
628
  method: "POST",
607
- headers: { "content-type": "application/json" },
629
+ headers: { "content-type": "application/json", ...serviceHeaders() },
608
630
  body: JSON.stringify({ clientName: client.name, clientVersion: client.version }),
609
631
  signal: timeoutSignal(serviceTimeoutMs()),
610
632
  });
@@ -666,7 +688,7 @@ export async function pollDeviceToken(authUrl, deviceCode, opts) {
666
688
  // the request in flight rather than after the service answers it.
667
689
  res = await fetchImpl(`${authUrl}/auth/device/token`, {
668
690
  method: "POST",
669
- headers: { "content-type": "application/json" },
691
+ headers: { "content-type": "application/json", ...serviceHeaders() },
670
692
  body: JSON.stringify({ deviceCode }),
671
693
  signal: timeoutSignal(serviceTimeoutMs(), opts.signal),
672
694
  });
@@ -832,7 +854,7 @@ export async function tokenLogin(authUrl, token, opts = {}) {
832
854
  try {
833
855
  res = await fetchImpl(`${authUrl}/auth/device/token`, {
834
856
  method: "POST",
835
- headers: { "content-type": "application/json" },
857
+ headers: { "content-type": "application/json", ...serviceHeaders() },
836
858
  body: JSON.stringify({ deviceCode: token.trim() }),
837
859
  signal: timeoutSignal(serviceTimeoutMs()),
838
860
  });
@@ -942,7 +964,7 @@ export async function logout(authUrl) {
942
964
  try {
943
965
  const res = await fetch(`${authUrl}/auth/logout`, {
944
966
  method: "POST",
945
- headers: { "content-type": "application/json" },
967
+ headers: { "content-type": "application/json", ...serviceHeaders() },
946
968
  body: JSON.stringify({ refreshToken: cached.refreshToken }),
947
969
  signal: timeoutSignal(LOGOUT_TIMEOUT_MS),
948
970
  });
@@ -91,7 +91,7 @@
91
91
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 3000)
92
92
  * BIR_DERIVE_BUDGET_MS derivation wait before a direct run (default 8000);
93
93
  * inside a hook it is at most BIR_HOOK_DERIVE_BUDGET_MS
94
- * (default 4000)
94
+ * (default 8000)
95
95
  * BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
96
96
  * reads, so a target named by an earlier step can be
97
97
  * found (default 5; 0 off)
@@ -91,7 +91,7 @@
91
91
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 3000)
92
92
  * BIR_DERIVE_BUDGET_MS derivation wait before a direct run (default 8000);
93
93
  * inside a hook it is at most BIR_HOOK_DERIVE_BUDGET_MS
94
- * (default 4000)
94
+ * (default 8000)
95
95
  * BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
96
96
  * reads, so a target named by an earlier step can be
97
97
  * found (default 5; 0 off)
package/dist/bin/bir.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * bir — the command line (Phase 7).
4
4
  *
5
5
  * bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
6
+ * bir setup --host langchain ← the folder of a LangChain application: an entry and a recorder, no Claude Code wiring
6
7
  * bir up [--restart] | bir down
7
8
  * bir off [--here] | bir on [--here] ← the kill switch: inert at once, nothing uninstalled
8
9
  * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
@@ -11,6 +12,7 @@
11
12
  * bir doctor [--quick]
12
13
  * bir wrap --server-name <key> -- <command> [args…]
13
14
  * bir login | logout
15
+ * bir update ← the newest version, into the copy that is running (update.ts)
14
16
  * bir --version
15
17
  *
16
18
  * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
package/dist/bin/bir.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * bir — the command line (Phase 7).
4
4
  *
5
5
  * bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
6
+ * bir setup --host langchain ← the folder of a LangChain application: an entry and a recorder, no Claude Code wiring
6
7
  * bir up [--restart] | bir down
7
8
  * bir off [--here] | bir on [--here] ← the kill switch: inert at once, nothing uninstalled
8
9
  * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
@@ -11,6 +12,7 @@
11
12
  * bir doctor [--quick]
12
13
  * bir wrap --server-name <key> -- <command> [args…]
13
14
  * bir login | logout
15
+ * bir update ← the newest version, into the copy that is running (update.ts)
14
16
  * bir --version
15
17
  *
16
18
  * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
@@ -52,11 +54,12 @@ import { claudeUserSettingsPath, managedMcpPolicyBlocker, mcpjsonApprovals } fro
52
54
  import { checkOverrides, recordServerName } from "../config/overrides.js";
53
55
  import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
54
56
  import { runSetup } from "./setup.js";
57
+ import { runUpdate } from "./update.js";
55
58
  import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
56
- import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
59
+ import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
57
60
  import { writeFileAtomic } from "../util/atomic-write.js";
58
61
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
59
- import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
62
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, inspectAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
60
63
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
61
64
  import { errText, logLine } from "../util/log.js";
62
65
  import { journalPath, readJournal } from "../util/journal.js";
@@ -64,6 +67,7 @@ import { investigateCommand } from "./investigate.js";
64
67
  import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.js";
65
68
  import { loadCredentials } from "../auth/client.js";
66
69
  import { packageVersion } from "../util/version.js";
70
+ import { runnerOutdated, serviceHeaders } from "../util/service-headers.js";
67
71
  const VERSION = packageVersion();
68
72
  /**
69
73
  * The npx SessionStart hook's own budget, passed as `ensure --budget-ms <n>`
@@ -82,6 +86,8 @@ function usage(code = 0) {
82
86
  Commands:
83
87
  setup the one command: sign in, wire this project, start the recorder, and give
84
88
  Claude Code every bir tool (run_scenario, investigate, scenario_*) with no prompt
89
+ (--host langchain: for a LangChain application instead — sign in, give the
90
+ folder its own port and token, start the recorder; nothing of Claude Code is wired)
85
91
  up start the recorder for this project in the background (--restart: replace it)
86
92
  down stop it (open sessions here then show a hook error until they restart;
87
93
  \`bir off --here\` is the quiet way to take it out of the way)
@@ -99,8 +105,10 @@ Commands:
99
105
  wrap print a proxied entry for one server (any MCP client)
100
106
  login sign in to the BaseIn service (opens your browser)
101
107
  logout revoke this machine's session and forget it
108
+ update install the newest version over this copy of bir, wherever it was installed
109
+ (the console's install line or npm); then run \`bir setup\` in each project
102
110
 
103
- scenario list recorded runs and their calculated scenarios
111
+ scenario list recorded runs and their calculated scenarios
104
112
  scenario show <runId|scnId> [--step <n>]
105
113
  a scenario: intent, params, steps (--step: just that one)
106
114
  scenario calc <runId|scnId> [--force [--discard-edits]]
@@ -176,6 +184,8 @@ Options:
176
184
  --auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
177
185
  --project <dir> setup: the directory to record (default: this one)
178
186
  --no-replay setup: wire recording only; add no scenario server
187
+ --host <name> setup: the host the folder's agent runs in, when it is not Claude Code
188
+ (langchain). Writes nothing under .claude/ and nothing into ~/.claude.json
179
189
  --allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
180
190
  --no-daemon setup: wire everything, start nothing
181
191
  --restart up: replace a running recorder
@@ -292,6 +302,11 @@ function parseArgs(argv) {
292
302
  case "--no-replay":
293
303
  args.noReplay = true;
294
304
  break;
305
+ case "--host":
306
+ args.host = (argv[++i] ?? "").trim().toLowerCase();
307
+ if (!args.host)
308
+ usage(2);
309
+ break;
295
310
  case "--allow-servers":
296
311
  args.allowServers = argv[++i];
297
312
  if (args.allowServers === undefined)
@@ -1746,11 +1761,16 @@ function uninstallProject(cwd, sidecar, opts) {
1746
1761
  const myHookFiles = (sidecar.hookFiles ?? []).filter((f) => fileProjectDir(f) === here);
1747
1762
  const hookedNow = [paths.settingsLocal, paths.settings].filter((f) => hasBirHooks(f));
1748
1763
  const before = runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude);
1764
+ // A folder set up for another host (`bir setup --host langchain`) has no
1765
+ // hooks and no wrapped server, and still has something to undo: its entry,
1766
+ // and the recorder the caller stops. It goes past this point.
1767
+ const hostEntry = !narrow && otherHosts(cwd, sidecar).length > 0;
1749
1768
  if (mine.length === 0 &&
1750
1769
  myHookFiles.length === 0 &&
1751
1770
  hookedNow.length === 0 &&
1752
1771
  before.leftovers.length === 0 &&
1753
- before.unreadable.length === 0) {
1772
+ before.unreadable.length === 0 &&
1773
+ !hostEntry) {
1754
1774
  return { nothing: true, remaining: [] };
1755
1775
  }
1756
1776
  // 1. Byte-for-byte: a project file we wrote, nobody touched since, and whose
@@ -1946,6 +1966,9 @@ function uninstallProject(cwd, sidecar, opts) {
1946
1966
  const record = projectRecord(sidecar, cwd);
1947
1967
  if (sidecar.projects)
1948
1968
  delete sidecar.projects[here];
1969
+ if (record?.hosts?.length) {
1970
+ out(` ← this folder's entry for ${record.hosts.join(", ")} removed (its port, token and replay switch)`);
1971
+ }
1949
1972
  if (record?.excludeAdded !== false && existsSync(cwd) && !sharesRepository(cwd, sidecar)) {
1950
1973
  const exclude = removeGitExclude(cwd);
1951
1974
  if (exclude)
@@ -2686,8 +2709,11 @@ async function status(args) {
2686
2709
  const portProblem = openPortProblem(cwd, health ? portOf(discovery?.url) : undefined, hookPorts.length === 1 ? hookPorts[0] : undefined);
2687
2710
  // Recorders a reinstall on another port left serving older windows (LC-1).
2688
2711
  const superseded = await supersededRecorders(projectDir);
2712
+ // Hosts other than Claude Code this folder was set up for (`bir setup --host`).
2713
+ const hosts = args.configPath ? [] : otherHosts(projectDir, sidecar);
2689
2714
  const report = {
2690
2715
  cwd,
2716
+ ...(hosts.length ? { hosts } : {}),
2691
2717
  authUrl: resolveAuthUrl() || null,
2692
2718
  controlServer: discovery ? { url: discovery.url, pid: discovery.pid, answering: health !== undefined } : null,
2693
2719
  discoveryFile: discoveryPath(cwd),
@@ -2719,6 +2745,11 @@ async function status(args) {
2719
2745
  return 0;
2720
2746
  }
2721
2747
  out(`Directory : ${report.cwd}`);
2748
+ if (hosts.length) {
2749
+ out(`Host : set up for ${hosts.join(", ")}${claudeCodeSetUp(projectDir, sidecar)
2750
+ ? ", and for Claude Code"
2751
+ : " — its adapter reports every call, so no Claude Code hooks or wrapped servers are needed here"}`);
2752
+ }
2722
2753
  out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
2723
2754
  out(`Control : ${discovery
2724
2755
  ? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})${health ? "" : " — not answering; `bir doctor` says more"}`
@@ -2883,6 +2914,8 @@ async function doctor(args) {
2883
2914
  }
2884
2915
  const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
2885
2916
  const hooks = hookFacts(paths);
2917
+ // Hosts other than Claude Code this folder was set up for (`bir setup --host`).
2918
+ const hosts = otherHosts(projectDir, sidecar);
2886
2919
  for (const file of hooks.unreadable) {
2887
2920
  warnings.push(`${file} is not plain JSON, so its hooks could not be checked (and \`bir setup\` will not rewrite it)`);
2888
2921
  }
@@ -2907,6 +2940,12 @@ async function doctor(args) {
2907
2940
  notes.push("the recorder is not running; the SessionStart hook starts it with the next session (`bir up` starts it now)" +
2908
2941
  (wantWrapped.length && !off ? ` — ${wantWrapped.join(", ")} register with it then` : ""));
2909
2942
  }
2943
+ else if (hosts.length > 0) {
2944
+ // Set up for another host (`bir setup --host langchain`): its adapter runs
2945
+ // inside the application and reports every call itself, so there are no
2946
+ // hooks to miss — and it starts the recorder when the application runs.
2947
+ notes.push(`the recorder is not running; the ${hosts.join(", ")} application starts it when it next runs (\`bir up\` starts it now)`);
2948
+ }
2910
2949
  else {
2911
2950
  notes.push("no hooks here: only MCP calls are recorded (standalone, Tier 2) — `bir setup` wires the hooks");
2912
2951
  }
@@ -3105,11 +3144,19 @@ async function doctor(args) {
3105
3144
  // until a password has been posted to it and it says `405`. A warning, not a
3106
3145
  // problem: sessions do not use this shell. `--quick` skips the network call.
3107
3146
  const shellAuthUrl = resolveAuthUrl();
3108
- const shellAuthProblem = shellAuthUrl && !args.quick ? await describeAuthService(shellAuthUrl) : undefined;
3147
+ const shellService = shellAuthUrl && !args.quick ? await inspectAuthService(shellAuthUrl) : undefined;
3148
+ const shellAuthProblem = shellService?.problem;
3109
3149
  if (shellAuthProblem) {
3110
3150
  warnings.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
3111
3151
  `\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
3112
3152
  }
3153
+ // THE SERVICE HANDS PLANS ONLY TO A NEWER RUNNER. Its `/health` names the
3154
+ // lowest contract it serves (util/service-headers.ts). Under it, every match
3155
+ // comes back with no plan (`runner_update_required`) and the agent does each
3156
+ // task: sessions work and recording goes on, but nothing is replayed.
3157
+ const outdated = runnerOutdated(shellService);
3158
+ if (outdated)
3159
+ warnings.push(outdated);
3113
3160
  // THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
3114
3161
  // per-shell directory, an uninstalled Node, a moved private Node: the hook
3115
3162
  // then fails on every session and the recorder is never started, which
@@ -3318,6 +3365,9 @@ async function doctor(args) {
3318
3365
  else if (health) {
3319
3366
  out("Replay : off");
3320
3367
  }
3368
+ if (hosts.length) {
3369
+ out(`Host : ${hosts.join(", ")}${claudeCodeSetUp(projectDir, sidecar) ? ", and Claude Code" : " (no Claude Code hooks are needed here)"}`);
3370
+ }
3321
3371
  out(`Wrapped in config : ${wantWrapped.join(", ") || "(none)"}`);
3322
3372
  out(`Registered proxies: ${[...registered].join(", ") || "(none)"}`);
3323
3373
  out(`Control server : ${discovery?.url ?? "not running"}`);
@@ -3381,6 +3431,7 @@ async function service(method, path, body) {
3381
3431
  headers: {
3382
3432
  "content-type": "application/json",
3383
3433
  authorization: `Bearer ${session.accessToken}`,
3434
+ ...serviceHeaders(),
3384
3435
  },
3385
3436
  body: body === undefined ? undefined : JSON.stringify(body),
3386
3437
  signal: timeoutSignal(serviceTimeoutMs()),
@@ -3710,7 +3761,7 @@ async function revokeRefreshToken(configured, refreshToken) {
3710
3761
  const authUrl = await normalizeAuthUrl(configured);
3711
3762
  const res = await fetch(`${authUrl}/auth/logout`, {
3712
3763
  method: "POST",
3713
- headers: { "content-type": "application/json" },
3764
+ headers: { "content-type": "application/json", ...serviceHeaders() },
3714
3765
  body: JSON.stringify({ refreshToken }),
3715
3766
  signal: timeoutSignal(LOGOUT_REVOKE_BUDGET_MS),
3716
3767
  });
@@ -3814,6 +3865,7 @@ async function main() {
3814
3865
  daemon: !args.noDaemon,
3815
3866
  allowServers: args.allowServers,
3816
3867
  portExplicit: args.portExplicit,
3868
+ host: args.host,
3817
3869
  }, {
3818
3870
  out,
3819
3871
  err: (line) => process.stderr.write(`[bir] ${line}\n`),
@@ -3904,6 +3956,8 @@ async function main() {
3904
3956
  case "off":
3905
3957
  case "on":
3906
3958
  return offCommand(args, args.command === "off");
3959
+ case "update":
3960
+ return runUpdate({ out, err: (line) => process.stderr.write(`[bir] ${line}\n`) });
3907
3961
  case "login": {
3908
3962
  // Settle the URL the way every other command does, then refuse to go on
3909
3963
  // when what is there is not the service. "Signed in" must mean the service
@@ -21,6 +21,11 @@ import type { JournalEntry } from "../util/journal.js";
21
21
  export interface Turn {
22
22
  runId: string;
23
23
  sessionId?: string;
24
+ /**
25
+ * The host the turn ran in, when its session said it is not Claude Code
26
+ * (`langchain`): `run.start` names it. Absent for a Claude Code turn.
27
+ */
28
+ host?: string;
24
29
  startedAt: string;
25
30
  finishedAt?: string;
26
31
  prompt?: string;