@basein/runner 0.3.25 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A recording MCP **proxy**. It sits between any MCP client (Claude Code, Cursor,
4
4
  Codex, an Agent SDK session) and every MCP server that client uses, executes each
5
5
  call on the client's behalf, and records the run as a reusable scenario in the
6
- BaseIn service.
6
+ Basic2202 service (called BaseIn in the runner's own messages).
7
7
 
8
8
  The design this implements is [docs/BaseInstRunner.md](docs/BaseInstRunner.md).
9
9
  This README is the operator's half: how to run it, what it records, and what it
@@ -36,22 +36,23 @@ $env:BIR_SETUP_TOKEN="<token>"; irm https://api.bi2202.com/install.ps1 | iex
36
36
  curl -fsSL https://api.bi2202.com/install.sh | BIR_SETUP_TOKEN="<token>" sh # macOS / Linux
37
37
  ```
38
38
 
39
- That installs Node if the machine has none (a private copy, never on PATH),
39
+ That installs Node if the machine has none it can use (a private copy, never on PATH),
40
40
  installs this package, installs Claude Code if it is missing, and runs
41
41
  `bir setup`: it signs in, stores the service address, wraps the project's MCP
42
42
  servers, wires Claude Code's hooks, adds the scenario server, and starts the
43
43
  recorder in the background. It ends with the one thing left to do: `claude`.
44
44
  Without a token the same line signs in through the browser. Re-running it is
45
- safe, and is also how you upgrade. To record another project: `cd` there and
45
+ safe, and is also how you upgrade (or `bir update`, then `bir setup` in each
46
+ project). To record another project: `cd` there and
46
47
  run `bir setup` (no token needed; the sign-in is kept).
47
48
 
48
- The scripts are [scripts/install.ps1](scripts/install.ps1) and
49
- [scripts/install.sh](scripts/install.sh); the API serves them with its own
50
- address filled in. Step by step, and what each line does:
51
- [docs/quickstart.md](docs/quickstart.md). For a fleet — packaging, releases,
49
+ The scripts are `scripts/install.ps1` and `scripts/install.sh` in this
50
+ package; the API serves them with its own address filled in, at
51
+ <https://api.bi2202.com/install.ps1> and <https://api.bi2202.com/install.sh>. Step by step, and what each line does:
52
+ [the handbook's Getting started](https://docs.bi2202.com/#start). For a fleet — packaging, releases,
52
53
  offline installs, updates: [docs/installRun.md](docs/installRun.md).
53
54
 
54
- By hand, or when developing BaseInstRunner itself:
55
+ By hand (Node 20 or newer), or when developing BaseInstRunner itself:
55
56
 
56
57
  ```bash
57
58
  npm i -g @basein/runner # or: npm install && npm run build, in a checkout
@@ -197,9 +198,9 @@ exits 2; it sets nothing up.
197
198
 
198
199
  Those two lines load a LangChain middleware from the Python package
199
200
  `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.
201
+ is needed too, and it is not out yet: it is not on PyPI. The
202
+ how-to is the handbook's LangChain page,
203
+ [docs.bi2202.com/#langchain](https://docs.bi2202.com/#langchain).
203
204
 
204
205
  ### Taking it out
205
206
 
@@ -287,7 +288,7 @@ between the two is not guaranteed.
287
288
  (`bir up --restart`), for the reason in the next section.
288
289
  - **Change what the model does.** Tool descriptions are never edited. The
289
290
  model-visible changes are one declared schema key (below), the first-party
290
- `bir` server's `run_scenario` (only with `--replay`), and replay's own
291
+ `bir` server's `run_scenario` and `report_wrong_result` (the agent's way to say a replay's results are wrong; both only with `--replay`), and replay's own
291
292
  `[BaseInstRunner calculated replay]` notes when a prompt matches a scenario.
292
293
  The `bir` server's three tools that only read a scenario (`scenario_show`,
293
294
  `scenario_edits`, `investigate`) and the three for changing one
@@ -298,11 +299,11 @@ between the two is not guaranteed.
298
299
  forced past the scenario check (`force`) still asks the person first. Their
299
300
  definitions ride along with every request, so `bir scenario editing off`
300
301
  takes the six away, and a later setup leaves that off. A bare
301
- `bir install --replay` lists `run_scenario` alone until
302
+ `bir install --replay` lists only `run_scenario` and `report_wrong_result` until
302
303
  `bir scenario editing on` (or `BIR_SCENARIO_TOOLS=read` in the server's
303
304
  environment, for the reading three). With the runner off it lists no tools
304
305
  at all.
305
- - **Record its own `bir` calls** (next version, not yet released). A session
306
+ - **Record its own `bir` calls.** A session
306
307
  that fixes a plan is not a task to replay. The `bir` server's tools other
307
308
  than `run_scenario`, and a `Bash` or `PowerShell` command made only of `bir`
308
309
  commands, are never recorded as steps and never count as leaving a plan.
@@ -311,7 +312,7 @@ between the two is not guaranteed.
311
312
  runs here, in the recorder's own process and context (Node's `vm`, not a
312
313
  sandbox), next to your sign-in and your wrapped servers. It has a time limit
313
314
  (`BIR_LOGIC_TIMEOUT_MS`, 1 s by default) and no memory limit.
314
- The service will sandbox its own runs of the same code (with its next deploy);
315
+ The service sandboxes its own runs of the same code;
315
316
  the runner does not. The service's check of a hand edit only judges what the
316
317
  code returns for the recording's values, so it cannot prove what the code
317
318
  does here: read a hand edit before it is saved. See
@@ -379,8 +380,7 @@ the plan is retired and nothing runs. Mixed scenarios steer the live session
379
380
  instead, pinning each step's arguments. Either way the turn reports what it
380
381
  cost, so the savings ledger measures something real.
381
382
 
382
- **Fixing a step.** Built 2026-09-25, not yet released: it needs the next
383
- `@basein/runner` version and a service deploy. A calculated step is code, and
383
+ **Fixing a step.** From runner 0.3.0. A calculated step is code, and
384
384
  code can be wrong. `bir investigate` names such a step; `bir scenario edit`
385
385
  fixes it, and the service checks the change against the recording before it
386
386
  saves it. **A change is saved only if it reproduces the recording and is not a
@@ -394,7 +394,7 @@ which `bir setup` turns on (`bir scenario editing on` in a project wired by
394
394
  [the guide's §9.2](docs/calculatedReplayGuide.md).
395
395
 
396
396
  The service can correct a plan itself too, after the agent did a planned task
397
- again its own way (built 2026-10-23, not yet released). `bir scenario edits`
397
+ again its own way (from runner 0.3.16). `bir scenario edits`
398
398
  lists such a correction beside the hand edits, under its `scor_` id. Undoing it
399
399
  puts back the whole plan from before, and the plan is then held until you
400
400
  choose "Use it again" on the recording page.
@@ -411,7 +411,7 @@ bir scenario calc scn_… --force --discard-edits # recalculate this
411
411
  bir scenario editing on # let Claude's `bir` tools change scenarios here (setup did)
412
412
  ```
413
413
 
414
- **A value no prompt names.** Built 2026-09-28, not yet released. Every
414
+ **A value no prompt names.** From runner 0.3.4. Every
415
415
  parameter is a *target* (a prompt must name it, or the plan is declined:
416
416
  `missing_target`) or a *setting* (its recorded value is used when a prompt
417
417
  names none). When the calculation made a database, a host or a working folder a
@@ -449,7 +449,7 @@ the next one:
449
449
  BIR_CORRELATION_DECISION=allow bir up --restart
450
450
  ```
451
451
 
452
- `BIR_CORRELATION_DECISION=ask` carries the id and keeps the prompt. Hosts that
452
+ `BIR_CORRELATION_DECISION=ask` carries the id and has Claude Code ask about each wrapped call, even one you allowed. Hosts that
453
453
  ignore `updatedInput` on a non-allow decision then fall back to fingerprint
454
454
  matching.
455
455
 
@@ -501,6 +501,7 @@ what to do when they register, so there is one switch and no second copy of it:
501
501
  | `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. |
502
502
  | `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`). |
503
503
  | `BIR_REPLAY_RECORDED_OUTPUTS=1` | Let a step's recorded output stand in when the step could not be sent. Unset, the plan stops at that step and hands the task back. |
504
+ | `BIR_CONTINUATIONS` | After a plan hands the task back, the agent's next call asks the service once for the plan of the work recorded after an earlier hand-over at that step, and that plan does the rest of the task (from 0.3.23). `0` turns it off, and so does `BIR_INTENT_MATCH=0`. |
504
505
 
505
506
  The `bir` server reads its own: `BIR_SCENARIO_TOOLS=read` lists its three reading
506
507
  tools where scenario editing is off, and `BIR_SCENARIO_RUN_TIMEOUT_MS` is how
@@ -531,11 +532,11 @@ The governing rule: **a host session never fails because of BaseInstRunner.**
531
532
  | Failure | Behaviour |
532
533
  |---|---|
533
534
  | 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 |
534
- | 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 |
535
+ | Control server unreachable at startup | steps buffered for about 10 s, then Tier 2 (passthrough: nothing); the proxy keeps looking every 15 s and binds when one answers |
535
536
  | 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 |
536
537
  | 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 |
537
- | 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 |
538
- | 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 |
538
+ | 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, and so is a send that has waited a minute for a service still down |
539
+ | 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 |
539
540
  | 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 |
540
541
  | 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 |
541
542
  | Result over 128 MiB | the call ends with an error saying the result was too large to deliver, instead of hanging |
@@ -551,7 +552,7 @@ The governing rule: **a host session never fails because of BaseInstRunner.**
551
552
  project, and uninstalls nothing: hooks answer with nothing within about a
552
553
  second, in sessions already open too, and nothing is recorded, replayed or sent;
553
554
  wrapped MCP servers run as plain pipes to the real server from their next start
554
- (a new session, or `/mcp` → reconnect), and keep recording until then; the `bir`
555
+ (a new session, or `/mcp` → reconnect), and record nothing until then; the `bir`
555
556
  server lists no tools. `bir off --here` does the same for one project (from a
556
557
  subdirectory, the installed project it is in), and `bir on` or `bir on --here`
557
558
  undoes it. If the problem goes away, the runner was the cause; if it stays, the
@@ -575,7 +576,7 @@ environment-variable reference, the operations runbook and the findings ledger.
575
576
 
576
577
  ```bash
577
578
  npm run build # src → dist
578
- npm test # ~1,000 tests: transcript identity, passthrough, correlation, config, replay, setup
579
+ npm test # ~1,300 tests: transcript identity, passthrough, correlation, config, replay, setup
579
580
  npm run typecheck
580
581
  npm run test:smoke # end-to-end replay with real processes (needs build + pretest first)
581
582
  ```
@@ -584,7 +585,7 @@ The core test is **transcript identity**: the same scripted conversation is run
584
585
  against a fake upstream directly and through the proxy, and every host-visible
585
586
  message must match. If a change breaks transparency, that test says so first.
586
587
 
587
- `test/smoke-replay.mjs` is the other one worth running by hand. Everything else
588
+ `test/smoke-replay.mjs` is the other one worth running by hand. Most of the suite
588
589
  runs in-process; this starts a stand-in BaseIn service, a real `bir-hooks`, a real
589
590
  `bir-proxy` wrapping a real MCP server and a real `bir-scenario`, then drives a
590
591
  two-step scenario through actual stdio. It is what catches the seams between
@@ -12,7 +12,10 @@
12
12
  *
13
13
  * 1. Load the cached session from ~/.baseinstrunner/credentials.json.
14
14
  * 2. If the access token is still valid, reuse it.
15
- * 3. Else if a refresh token is present, POST /auth/refresh (silent).
15
+ * 3. Else if a refresh token is present, POST /auth/refresh (silent). A
16
+ * refusal is checked against the file before it is believed: the service
17
+ * rotates refresh tokens, and a sibling process may have spent this one
18
+ * a moment ago and saved the session it got for it.
16
19
  * 4. Else give up and return undefined.
17
20
  *
18
21
  * It used to have a fifth step — prompt for a password — and that had to go
@@ -12,7 +12,10 @@
12
12
  *
13
13
  * 1. Load the cached session from ~/.baseinstrunner/credentials.json.
14
14
  * 2. If the access token is still valid, reuse it.
15
- * 3. Else if a refresh token is present, POST /auth/refresh (silent).
15
+ * 3. Else if a refresh token is present, POST /auth/refresh (silent). A
16
+ * refusal is checked against the file before it is believed: the service
17
+ * rotates refresh tokens, and a sibling process may have spent this one
18
+ * a moment ago and saved the session it got for it.
16
19
  * 4. Else give up and return undefined.
17
20
  *
18
21
  * It used to have a fifth step — prompt for a password — and that had to go
@@ -44,7 +47,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node
44
47
  import { hostname } from "node:os";
45
48
  import { configDir, configPath, ensureDir } from "../control/paths.js";
46
49
  import { join } from "node:path";
47
- import { logLine, errText } from "../util/log.js";
50
+ import { logDetail, logLine, errText } from "../util/log.js";
48
51
  import { packageVersion } from "../util/version.js";
49
52
  import { serviceHeaders } from "../util/service-headers.js";
50
53
  import { writeFileAtomic } from "../util/atomic-write.js";
@@ -536,6 +539,22 @@ async function tryRefresh(authUrl, refreshToken) {
536
539
  return { rejected };
537
540
  }
538
541
  }
542
+ /**
543
+ * After a refused refresh, how long to wait before the second look at
544
+ * credentials.json: the sibling that spent the token a moment ago has the
545
+ * service's answer and may not have written it yet. The recorder waits the
546
+ * same (record/remote-recorder.ts).
547
+ */
548
+ const REFUSED_RECHECK_MS = 1_500;
549
+ /**
550
+ * The session in credentials.json, when it is one for `authUrl` that carries
551
+ * another refresh token than `spent`: what a sibling's refresh, or a new
552
+ * sign-in, saved after this process read the file.
553
+ */
554
+ function savedSince(authUrl, spent) {
555
+ const stored = loadCredentials();
556
+ return stored && stored.refreshToken !== spent && issuedFor(stored, authUrl) ? stored : undefined;
557
+ }
539
558
  /**
540
559
  * Authenticate and return a valid session, or `undefined` when none can be
541
560
  * obtained. Callers degrade to not-recording rather than failing the session.
@@ -571,25 +590,75 @@ export async function authenticate(opts) {
571
590
  if (cached && isAccessValid(cached))
572
591
  return cached;
573
592
  if (cached?.refreshToken) {
574
- const { session: refreshed, rejected } = await tryRefresh(authUrl, cached.refreshToken);
593
+ // The session whose refresh token is being spent: the cached one, or the
594
+ // one a sibling wrote while ours was being refused.
595
+ let spent = cached;
596
+ let { session: refreshed, rejected } = await tryRefresh(authUrl, spent.refreshToken);
597
+ if (rejected) {
598
+ // The service rotates refresh tokens — each works once — and every
599
+ // process reads the same file. Two proxies that start together on an
600
+ // expired access token both read r1; the one whose refresh arrives
601
+ // second is refused, while the winner's session is in the file or about
602
+ // to be. Believing that refusal left this process signed out for its
603
+ // whole life beside a valid session: a proxy recorded nothing, and the
604
+ // CLI said "run `bir login`". So the file is read again — now, and once
605
+ // more when the winner has had time to write — as the recorder does
606
+ // (record/remote-recorder.ts, refresh).
607
+ let newer = savedSince(authUrl, spent.refreshToken);
608
+ if (!newer) {
609
+ await defaultSleep(REFUSED_RECHECK_MS);
610
+ newer = savedSince(authUrl, spent.refreshToken);
611
+ }
612
+ if (newer) {
613
+ logDetail("auth.adopted", { why: "another process had already refreshed" });
614
+ if (isAccessValid(newer))
615
+ return newer;
616
+ // Newer, and expired all the same. Its refresh token is not the one
617
+ // that was just refused, so it gets its one try.
618
+ spent = newer;
619
+ ({ session: refreshed, rejected } = await tryRefresh(authUrl, spent.refreshToken));
620
+ }
621
+ }
575
622
  if (!refreshed && !rejected && opts?.keepStaleOnNoAnswer) {
576
623
  logLine("auth.refresh_deferred", {
577
624
  why: "the service did not answer the refresh — starting with the expired session; it is renewed once the service is back",
578
625
  });
579
- return cached;
626
+ return spent;
580
627
  }
581
628
  if (refreshed) {
582
- try {
583
- saveCredentials(refreshed);
584
- }
585
- catch (err) {
586
- // The service has already rotated the refresh token, so the one on
587
- // disk is dead either way. Throwing here would also lose the new one
588
- // and kill the recorder at start; returning it keeps this process
589
- // recording, and the next save (a later refresh) repairs the file.
590
- logLine("auth.save_failed", { error: errText(err), why: "the new session still works in this process" });
629
+ // Saved only while the file still holds the token this refresh spent
630
+ // (the keeper's rule: control/recorder-keeper.ts, saveRefreshed). A
631
+ // refresh can take seconds, and `bir logout` or a sign-in as someone
632
+ // else may finish meanwhile; saving over that signed the machine back
633
+ // in, or put the account just left back in the file.
634
+ //
635
+ // A file that is there and cannot be read (a lock, a moment's torn
636
+ // write) is no sign-in to protect: it is written over, as the recorder
637
+ // does (record/remote-recorder.ts). Taken for a sign-out, it lost the
638
+ // session the service had just rotated to, and left the dead token on
639
+ // disk: every process signed out until `bir login`.
640
+ const onDisk = loadCredentials();
641
+ const unreadable = onDisk === undefined && existsSync(credentialsPath());
642
+ if (unreadable || onDisk?.refreshToken === spent.refreshToken) {
643
+ try {
644
+ saveCredentials(refreshed);
645
+ }
646
+ catch (err) {
647
+ // The service has already rotated the refresh token, so the one on
648
+ // disk is dead either way. Throwing here would also lose the new one
649
+ // and kill the recorder at start; returning it keeps this process
650
+ // recording, and the next save (a later refresh) repairs the file.
651
+ logLine("auth.save_failed", { error: errText(err), why: "the new session still works in this process" });
652
+ }
653
+ return refreshed;
591
654
  }
592
- return refreshed;
655
+ // Not ours to overwrite, and not ours to use either: this process would
656
+ // act for an account the machine has left. What is on disk decides.
657
+ logDetail("auth.refresh_superseded", {
658
+ why: "the sign-in on disk changed while a refresh was out; what is on disk now decides",
659
+ });
660
+ if (onDisk && issuedFor(onDisk, authUrl) && isAccessValid(onDisk))
661
+ return onDisk;
593
662
  }
594
663
  }
595
664
  // No session, and this function does not make one. Signing in is a browser
@@ -139,7 +139,7 @@ import { dirname, resolve } from "node:path";
139
139
  import { Writable } from "node:stream";
140
140
  import { ENSURE_ARG } from "../config/adapters/claude-code.js";
141
141
  import { exemptLoopbackFromProxy } from "../config/generate.js";
142
- import { REVIVE_ARG } from "../control/daemon.js";
142
+ import { REVIVE_ARG, exitLogged } from "../control/daemon.js";
143
143
  import { daemonLogPath, ensureDir } from "../control/paths.js";
144
144
  import { errText, logLine } from "../util/log.js";
145
145
  /**
@@ -263,6 +263,8 @@ void main().catch((err) => {
263
263
  process.exit(0);
264
264
  }
265
265
  logLine("control.fatal", { error: errText(err) });
266
- process.exit(1);
266
+ // Not `process.exit(1)`: the recorder's log is written in the background,
267
+ // and the line above — the reason it stopped — would be lost with the rest.
268
+ exitLogged(1);
267
269
  });
268
270
  //# sourceMappingURL=bir-hooks.js.map