@basein/runner 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +80 -0
  45. package/dist/replay/bundle.js +455 -24
  46. package/dist/replay/controller.d.ts +64 -1
  47. package/dist/replay/controller.js +238 -38
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/plan.d.ts +15 -0
  52. package/dist/replay/plan.js +48 -30
  53. package/dist/replay/pricing.d.ts +61 -11
  54. package/dist/replay/pricing.js +135 -42
  55. package/dist/replay/safety.js +43 -5
  56. package/dist/upstream/http-client.d.ts +6 -3
  57. package/dist/upstream/http-client.js +54 -13
  58. package/dist/upstream/stdio-client.d.ts +7 -0
  59. package/dist/upstream/stdio-client.js +1 -1
  60. package/dist/util/atomic-write.d.ts +9 -1
  61. package/dist/util/atomic-write.js +9 -6
  62. package/dist/util/service-health.d.ts +7 -0
  63. package/dist/util/service-health.js +10 -3
  64. package/docs/calculatedReplay.md +32 -0
  65. package/package.json +1 -1
  66. package/production.md +2 -2
  67. package/scripts/install.ps1 +14 -2
@@ -41,12 +41,14 @@
41
41
  * `initialize` got a 405 and the server showed as failed every session).
42
42
  * A stream that ends — cleanly or not — is noticed, the requests riding it
43
43
  * are answered, and it is re-established with backoff; the new session is
44
- * re-initialised with the host's own `initialize` before anything else.
44
+ * re-initialised with the host's own `initialize` before anything else,
45
+ * and a stream whose re-initialisation fails is a reconnect that failed.
45
46
  * 4. **Session expiry is survivable.** A 404 to a request that carried a
46
47
  * session means the server forgot us (a redeploy, an idle timeout). The
47
48
  * spec says start a new session; the proxy does it for the host — replays
48
49
  * the host's cached `initialize` under a `bir:` id, sends `initialized`,
49
- * and retries the request once.
50
+ * and retries the request once. A replay that fails is tried again when
51
+ * the next request meets the same 404.
50
52
  * 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
51
53
  * bounds the connect only; how long a tool may take is Claude Code's call,
52
54
  * and its cancellation reaches the server immediately (rule 2). What no
@@ -72,7 +74,7 @@
72
74
  * recovery, a session the server forgot, and a stream that keeps dropping as
73
75
  * soon as it opens (once an hour per server at most).
74
76
  */
75
- import { readFrames } from "../jsonrpc/framing.js";
77
+ import { readFrames, receivedText, rememberText } from "../jsonrpc/framing.js";
76
78
  import { paramsObject } from "../jsonrpc/types.js";
77
79
  import { logDetail, logEvery, logLine, logOnce, errText } from "../util/log.js";
78
80
  import { BaseUpstreamClient, BIR_ID_PREFIX, isBirId } from "./client.js";
@@ -341,7 +343,11 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
341
343
  accept: "application/json, text/event-stream",
342
344
  ...this.sessionHeaders(method === "initialize"),
343
345
  ...this.headers,
344
- }, JSON.stringify(msg), controller.signal);
346
+ },
347
+ // The host's own line when this is the message as the relay read it
348
+ // (jsonrpc/framing.ts): serialised again, an integer past 2^53 in a
349
+ // tool's arguments reached the server rounded.
350
+ receivedText(msg) ?? JSON.stringify(msg), controller.signal);
345
351
  }
346
352
  catch (err) {
347
353
  clearTimeout(abandonTimer);
@@ -516,8 +522,16 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
516
522
  if (Array.isArray(parsed))
517
523
  for (const item of parsed)
518
524
  this.deliver(item);
519
- else
525
+ else {
526
+ // One message, and this is its text: the relay writes it to the host as
527
+ // the server wrote it rather than as JavaScript read it (framing.ts).
528
+ // Without the white space around it — a body often ends in a newline —
529
+ // and only when it is one line; a batch's members have no text of their
530
+ // own, and are serialised as before.
531
+ if (parsed && typeof parsed === "object")
532
+ rememberText(parsed, raw.trim());
520
533
  this.deliver(parsed);
534
+ }
521
535
  return true;
522
536
  }
523
537
  /** Hand one inbound JSON-RPC message to the relay (or to a `bir:` waiter). */
@@ -601,7 +615,18 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
601
615
  if (this.transport === "http") {
602
616
  this.sessionId = undefined;
603
617
  this.resetStandingStream();
604
- return this.reinitialize();
618
+ if (await this.reinitialize())
619
+ return true;
620
+ // No new session: a 503 from an instance still starting, a network
621
+ // error, no answer in time. Left without an id, every later request
622
+ // went out bare, the server answered 400 or 404, and none of that
623
+ // read as "forgotten" any more — errors for the rest of the host's
624
+ // session, with the proxy up. Holding the forgotten id again makes
625
+ // the next request's 404 start another attempt. An id this attempt
626
+ // was handed is dropped with it: its handshake never completed.
627
+ if (!this.dead)
628
+ this.sessionId = staleKey;
629
+ return false;
605
630
  }
606
631
  // Legacy: the session *is* the event stream. Drop it and open a new one;
607
632
  // reconnect() replays the handshake. A reconnect already under way (the
@@ -634,9 +659,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
634
659
  /**
635
660
  * Replay the host's `initialize` (under a `bir:` id) and `initialized`.
636
661
  * `news`: say it on stderr when it worked — after a forgotten session or an
637
- * outage; after a routine reconnect it is detail. A failure is always said.
662
+ * outage; after a routine reconnect it is detail. A failure is said, unless
663
+ * `failureSaid`: it is one more of an outage whose first failure was.
638
664
  */
639
- async reinitialize(news = true) {
665
+ async reinitialize(news = true, failureSaid = false) {
640
666
  if (this.initParams === undefined || this.dead)
641
667
  return false;
642
668
  this.reinitCount += 1;
@@ -663,7 +689,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
663
689
  return false;
664
690
  if (!reply || !("result" in reply)) {
665
691
  const error = reply ? paramsObject(reply.error).message : "no answer";
666
- logLine("upstream.reinitialize_failed", { server: this.label, error: String(error ?? "error") });
692
+ (failureSaid ? logDetail : logLine)("upstream.reinitialize_failed", {
693
+ server: this.label,
694
+ error: String(error ?? "error"),
695
+ });
667
696
  return false;
668
697
  }
669
698
  this.noteProtocolVersion(reply.result);
@@ -856,15 +885,27 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
856
885
  return false;
857
886
  }
858
887
  const recovered = this.reconnectFailures > 0;
888
+ // A new event stream is a new, uninitialised session: replay the host's
889
+ // handshake before anything else is let through. Until that has worked
890
+ // the reconnect has not: counted as done on the stream alone, a replay
891
+ // the server refused (it was still starting) left every later request
892
+ // posted to a session nobody had initialised, and nothing ever tried
893
+ // again. So the stream is dropped and the attempt counts as failed —
894
+ // backoff, and the next request pulls a new one forward.
895
+ if (this.initParams !== undefined && !(await this.reinitialize(recovered || afterExpiry, recovered))) {
896
+ if (this.dead)
897
+ return false;
898
+ this.reconnectFailures += 1;
899
+ this.onSseStreamLost(this.generation, "re-initialise failed", true);
900
+ this.streamAbort?.abort();
901
+ this.scheduleReconnect();
902
+ return false;
903
+ }
859
904
  (recovered ? logLine : logDetail)("upstream.stream_reconnected", {
860
905
  server: this.label,
861
906
  failedAttempts: this.reconnectFailures,
862
907
  });
863
908
  this.reconnectFailures = 0;
864
- // A new event stream is a new, uninitialised session: replay the host's
865
- // handshake before anything else is let through.
866
- if (this.initParams !== undefined)
867
- await this.reinitialize(recovered || afterExpiry);
868
909
  return true;
869
910
  })();
870
911
  this.connecting = attempt;
@@ -22,6 +22,7 @@
22
22
  * {@link StdioUpstreamClient.shutdown} closes stdin first (the MCP-spec way to
23
23
  * ask a server to exit), waits briefly, and only then kills the whole tree.
24
24
  */
25
+ import { type ChildProcessWithoutNullStreams } from "node:child_process";
25
26
  import { type JsonRpcMessage } from "../jsonrpc/types.js";
26
27
  import { BaseUpstreamClient } from "./client.js";
27
28
  export interface StdioUpstreamOptions {
@@ -80,6 +81,12 @@ export declare function whichSync(command: string, env?: NodeJS.ProcessEnv): str
80
81
  * On POSIX this is the identity. On Windows a batch file is wrapped in `cmd.exe`.
81
82
  */
82
83
  export declare function resolveCommand(command: string, args: string[], env?: NodeJS.ProcessEnv): ResolvedCommand;
84
+ /**
85
+ * Kill `child` and everything it started. On Windows that takes `taskkill /T`
86
+ * (a plain kill terminates one process: `cmd.exe`, not the server behind it);
87
+ * elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
88
+ */
89
+ export declare function killTree(child: ChildProcessWithoutNullStreams): Promise<void>;
83
90
  export declare class StdioUpstreamClient extends BaseUpstreamClient {
84
91
  readonly ready: Promise<void>;
85
92
  private child?;
@@ -161,7 +161,7 @@ function waitForExit(child, ms) {
161
161
  * (a plain kill terminates one process: `cmd.exe`, not the server behind it);
162
162
  * elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
163
163
  */
164
- async function killTree(child) {
164
+ export async function killTree(child) {
165
165
  const pid = child.pid;
166
166
  if (pid === undefined)
167
167
  return;
@@ -19,11 +19,19 @@
19
19
  * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
20
  * being a symlink, so the rename goes to the link's real target.
21
21
  *
22
- * The existing file's mode is kept; `mode` applies to a new file.
22
+ * The existing file's mode is kept; `mode` applies to a new file, and to an
23
+ * existing one too with `replaceMode`.
23
24
  */
24
25
  export interface AtomicWriteOptions {
25
26
  /** Mode for a file that does not exist yet. An existing file keeps its own. */
26
27
  mode?: number;
28
+ /**
29
+ * `mode` for an existing file as well: a file a credential goes in. Kept,
30
+ * the old file's mode went to the new one, and when the old file was not
31
+ * ours to chmod first (root's 0644, after `sudo claude`), the token went
32
+ * into a file that is ours and readable by every account.
33
+ */
34
+ replaceMode?: boolean;
27
35
  }
28
36
  export declare function writeFileAtomic(path: string, data: string | Uint8Array, opts?: AtomicWriteOptions): void;
29
37
  //# sourceMappingURL=atomic-write.d.ts.map
@@ -19,7 +19,8 @@
19
19
  * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
20
  * being a symlink, so the rename goes to the link's real target.
21
21
  *
22
- * The existing file's mode is kept; `mode` applies to a new file.
22
+ * The existing file's mode is kept; `mode` applies to a new file, and to an
23
+ * existing one too with `replaceMode`.
23
24
  */
24
25
  import { chmodSync, lstatSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
25
26
  import { randomBytes } from "node:crypto";
@@ -42,11 +43,13 @@ function resolveTarget(path) {
42
43
  export function writeFileAtomic(path, data, opts = {}) {
43
44
  const target = resolveTarget(path);
44
45
  let mode = opts.mode;
45
- try {
46
- mode = statSync(target).mode & 0o777;
47
- }
48
- catch {
49
- /* new file */
46
+ if (!(opts.replaceMode && mode !== undefined)) {
47
+ try {
48
+ mode = statSync(target).mode & 0o777;
49
+ }
50
+ catch {
51
+ /* new file */
52
+ }
50
53
  }
51
54
  const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
52
55
  try {
@@ -35,6 +35,13 @@ export declare const MAX_COOLDOWN_MS = 60000;
35
35
  * and never answers, holding a socket and a chain slot the whole time.
36
36
  */
37
37
  export declare const DEFAULT_SERVICE_TIMEOUT_MS = 10000;
38
+ /**
39
+ * The longest delay a Node timer takes. Given more, `setTimeout` warns and
40
+ * waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
41
+ * the timeout off, ended every request at once. Every delay that comes from
42
+ * the environment or from a caller is held to this.
43
+ */
44
+ export declare const MAX_TIMER_MS = 2147483647;
38
45
  export declare function serviceTimeoutMs(): number;
39
46
  export interface HealthSnapshot {
40
47
  /** False while a cool-down is running: hot paths do not wait. */
@@ -36,9 +36,16 @@ export const MAX_COOLDOWN_MS = 60_000;
36
36
  * and never answers, holding a socket and a chain slot the whole time.
37
37
  */
38
38
  export const DEFAULT_SERVICE_TIMEOUT_MS = 10_000;
39
+ /**
40
+ * The longest delay a Node timer takes. Given more, `setTimeout` warns and
41
+ * waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
42
+ * the timeout off, ended every request at once. Every delay that comes from
43
+ * the environment or from a caller is held to this.
44
+ */
45
+ export const MAX_TIMER_MS = 2_147_483_647;
39
46
  export function serviceTimeoutMs() {
40
47
  const n = Number(process.env.BIR_SERVICE_TIMEOUT_MS);
41
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_SERVICE_TIMEOUT_MS;
48
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMER_MS) : DEFAULT_SERVICE_TIMEOUT_MS;
42
49
  }
43
50
  export class ServiceHealth {
44
51
  failures = 0;
@@ -170,7 +177,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
170
177
  const winner = await Promise.race([
171
178
  pending,
172
179
  new Promise((resolve) => {
173
- timer = setTimeout(() => resolve(timedOut), budgetMs);
180
+ timer = setTimeout(() => resolve(timedOut), Math.min(budgetMs, MAX_TIMER_MS));
174
181
  timer.unref?.();
175
182
  }),
176
183
  ]);
@@ -197,7 +204,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
197
204
  */
198
205
  export function timeoutSignal(ms = serviceTimeoutMs(), parent) {
199
206
  const controller = new AbortController();
200
- const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), ms);
207
+ const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), Math.min(ms, MAX_TIMER_MS));
201
208
  timer.unref?.();
202
209
  if (parent) {
203
210
  if (parent.aborted) {
@@ -771,6 +771,38 @@ Delivery, best channel first. (A direct plan's own results go through `run_scena
771
771
  (`assembleBundle`/`truncate`, portable verbatim from `payload-executor.ts`). Works for any tool,
772
772
  and is read as adversarial — hence last.
773
773
 
774
+ **How a bundle reads** ([`bundle.ts`](../src/replay/bundle.ts); the same in a direct run's answer,
775
+ §6). The agent pays once to take the bundle in, as new input, and on a long plan that was most of a
776
+ replayed task's bill — so nothing in it repeats what the model already has. Each result is shown as
777
+ its text, not as MCP's `CallToolResult` JSON (the envelope and its second layer of escaping told the
778
+ model nothing), with `(error)` when `isError` is set; a result with anything but text — an image, a
779
+ resource, structured content — is shown as it came. Consecutive calls to one tool share a section,
780
+ `--- Steps a-b: tool (n calls) ---`, with one numbered line per call, `n: input -> result`. Input
781
+ fields with the same value in every call are said once above them (`In every call: {…}`), and so is
782
+ an answer every call got (`Every call's result: …`). A step on its own, and a step left out, keep
783
+ `--- Step n: tool ---`. Nothing is dropped, and each result keeps its fair share of the cap. On
784
+ B2's length tests this made bundles 27% shorter, 33% at 50–60 steps.
785
+
786
+ **A run that went well reads briefly** (`AssembleOptions.brief`, asked for by a direct run, §6). When
787
+ every step ran live and succeeded and the agent is to answer now, its job is to see that the steps
788
+ were right and say what was done — not to read every word of every result. So the header says every
789
+ call succeeded; a result up to 300 characters is shown whole; a longer one that is JSON keeps every
790
+ record and field, and only its text longer than 40 characters is cut, with "…" (`cutLongText`: the
791
+ IDs, dates, senders and names the agent checks the steps against are short; an e-mail's body is
792
+ not). A long result in words is shown whole — it may be the very answer. Showing only the values the
793
+ plan took from a long result was tried first: it left the agent unable to check the plan's choices
794
+ ("I couldn't confirm these were the newest") in one reply in six on B2's length tests, and once it
795
+ reported a correct run as wrong. The plan's summary (its response model) is said in a few words
796
+ (short lists whole, long ones counted), in the bundle, above the footer — so `bir-scenario` gets
797
+ `responseModel: {}` and does not append it again — and is left out when it only says it was left
798
+ out. The footer asks for
799
+ the answer in a few sentences: the reply is output, the dearest tokens an agent spends. Anything
800
+ that went wrong — a step that reported an error, a run that stopped short, a recorded stand-in, a
801
+ sub-task the agent carries on with — gets the full bundle above, because that is what the agent
802
+ works from next. For the same reason a direct run's directive (`steeringDirective`) no longer lists
803
+ the planned steps: the model calls none of them, and the answer names every call and says whether
804
+ the run was whole. An operator's `bir replay` and a divergence bundle are always full.
805
+
774
806
  **Tier 2 — abort.** Composition itself threw (`replay.compose_failed`). Retire the plan, return
775
807
  `{}`, continue as an ordinary turn. Outcome `failed`.
776
808
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/production.md CHANGED
@@ -523,7 +523,7 @@ Over 2-minute runs with one turn every 8 s (92 hooks):
523
523
 
524
524
  - **Backlog.** The backlog is bounded: 500 sends or 32 MiB, oldest steps dropped first. A queued send still facing a cool-down after 60 s is dropped unsent, not sent into the outage.
525
525
  - **Flush.** A flush waits at most 2 s (`BIR_FLUSH_TIMEOUT_MS`), and not at all while the service is cooling down.
526
- - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet).
526
+ - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet). Three sends are different, because the service takes a second copy of each without harm: a run's finish and a patch (an update that keeps what it has), and an execution report that carries its ticket (a second copy answers `200 duplicate`). From 0.3.26 each of these goes back to the head of its lane when it fails with no answer or an unhealthy one (502, 504, a reset), up to 3 sends in all, and it is exempt from the 60 s give-up. Before, a run whose finish was lost never ended (never embedded, matched or calculated), and a turn whose report was lost was never booked. Any other 4xx stays final.
527
527
 
528
528
  **Size caps on uploads.**
529
529
 
@@ -1174,7 +1174,7 @@ Defaults were checked in `src/`.
1174
1174
  - **Two sessions in one project.** Direct replays for a session whose own proxy has not yet bound hand over instead of running.
1175
1175
  - **Revive gaps.** `bir-scenario` does not revive in its first 12 s; those first hooks are left to the SessionStart hook. A session with no wrapped server and no `bir` server is not revived until `/clear` or a new session. The revive window was not measured with real Claude Code.
1176
1176
  - **Direct-run worst case.** The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack. It is reached only if every logic body hits its limit, and sits at Claude Code's 2-minute MCP auto-background. Setting `BIR_REPLAY_BUDGET_MS=120000` (the old default) makes it 140 s.
1177
- - **Retries are conservative.** A send lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids.
1177
+ - **Retries are conservative.** A step or run create lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids. (A finish, a patch and a ticketed report are sent up to 3 times from 0.3.26: the service takes a second copy of each without harm, §6.5. The service now answers a repeated run or step id with 409.)
1178
1178
  - **Healthy-service prompt cost.** With an instant local service the prompt hook is about 6 ms slower than 0.2.12 (about 17 vs 11 ms median); the cause was not isolated. Under 503 or slow answers the recorder also keeps more idle keep-alive sockets. Both are small, but visible in measurements.
1179
1179
  - **A host other than Claude Code is not checked end to end.** What §3.2 describes ran on a bench only: a real LangChain agent against the real recorder, with a stand-in service and a fake model (`examples/langchain-proof/`). A real model, the real service and the production server have not been tried. The service's part is built and not deployed, and the Python package is not on PyPI. What that means for a LangChain application today is in `langChainGuide.md`, part 5.
1180
1180
  - **An interrupted or lossy Claude Code run is not marked.** Only a run of another host is finished with an `error` or with the `incomplete` mark today. A Claude Code run that was interrupted or lost steps is finished as before.
@@ -65,6 +65,11 @@ function Invoke-BirInstall {
65
65
  # child's environment only, set right before the spawn and removed after.
66
66
  $setupToken = $env:BIR_SETUP_TOKEN
67
67
  if ($setupToken) { Remove-Item -Path Env:BIR_SETUP_TOKEN -ErrorAction SilentlyContinue }
68
+ # The uninstall switch the same way: read once and taken out of the session,
69
+ # so pasting the ordinary install line later in this window installs, and
70
+ # does not uninstall again.
71
+ $uninstall = ($env:BIR_UNINSTALL -eq '1')
72
+ if ($null -ne $env:BIR_UNINSTALL) { Remove-Item -Path Env:BIR_UNINSTALL -ErrorAction SilentlyContinue }
68
73
 
69
74
  # Inside the function on purpose: under `irm | iex` these would otherwise
70
75
  # change the session the person keeps working in.
@@ -262,7 +267,14 @@ function Invoke-BirInstall {
262
267
  Write-Note "node v$Version already at $exe"
263
268
  return $exe
264
269
  }
265
- # Half an extraction or a foreign file: removed, never extracted over.
270
+ }
271
+ # Half an extraction, a foreign file, or a folder whose node.exe is gone
272
+ # (an antivirus quarantine takes just that): removed, never extracted
273
+ # over. Whenever the folder is there, not only when node.exe is - a
274
+ # Move-Item onto a folder that exists moves the new one INSIDE it, so
275
+ # node.exe never appeared where it is looked for and every retry
276
+ # failed, 80 MB heavier. install.sh removes it the same way.
277
+ if (Test-Path -LiteralPath $nodeDir) {
266
278
  if ($dryRun) { Write-Would "remove the unusable $nodeDir" }
267
279
  else {
268
280
  try { Remove-Item -LiteralPath $nodeDir -Recurse -Force }
@@ -459,7 +471,7 @@ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wP
459
471
  # before its files go. Every wrapped MCP server and every hook names a
460
472
  # file under ~\.baseinstrunner; deleting that first would break them
461
473
  # all, in every project, with no `bir` left to repair it.
462
- if ($env:BIR_UNINSTALL -eq '1') {
474
+ if ($uninstall) {
463
475
  Write-Step 'Removing the runner from every project on this machine'
464
476
  $birCmd = Join-Path $birHome 'bin\bir.cmd'
465
477
  if (-not (Test-Path -LiteralPath $birCmd -PathType Leaf)) {