@basein/runner 0.2.11 → 0.3.0

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 (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -0,0 +1,43 @@
1
+ /**
2
+ * scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
3
+ * of a `bir` it spawned for a scenario tool.
4
+ *
5
+ * Two kinds of line arrive there, mixed: `bir`'s audit lines
6
+ * (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
7
+ * and a person look, and the sentences `bir` wrote for a person
8
+ * ("[bir] not signed in — run `bir login`"), which the model needs when the
9
+ * command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
10
+ * straight on, and of the rest only the LAST so many characters are kept.
11
+ *
12
+ * WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
13
+ * 8 000 characters kept, lost exactly the line that mattered: with
14
+ * `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
15
+ * the last thing it prints — never reached the model, which got "exited with
16
+ * code 1" and nothing to act on.
17
+ *
18
+ * Its own module so it can be tested: bir-scenario starts serving on import.
19
+ */
20
+ /** `bir`'s own audit lines — for the log, not for the model. */
21
+ export declare const AUDIT_LINE: RegExp;
22
+ export interface StderrSorterOptions {
23
+ /** How much of the non-audit text is kept: the last this many characters. */
24
+ keepChars: number;
25
+ /** How much audit text is passed on, at most; the rest is dropped. */
26
+ forwardChars: number;
27
+ /** Where an audit line goes (one line, without its newline). Must not throw. */
28
+ forward: (line: string) => void;
29
+ }
30
+ export declare class StderrSorter {
31
+ private readonly opts;
32
+ private partial;
33
+ /** Where the rest of a line cut for length goes, once its start was sorted. */
34
+ private continuing;
35
+ private kept;
36
+ private forwarded;
37
+ constructor(opts: StderrSorterOptions);
38
+ push(chunk: string): void;
39
+ /** Everything that was not an audit line (the last of it), once the stream ended. */
40
+ end(): string;
41
+ private line;
42
+ }
43
+ //# sourceMappingURL=scenario-stderr.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
3
+ * of a `bir` it spawned for a scenario tool.
4
+ *
5
+ * Two kinds of line arrive there, mixed: `bir`'s audit lines
6
+ * (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
7
+ * and a person look, and the sentences `bir` wrote for a person
8
+ * ("[bir] not signed in — run `bir login`"), which the model needs when the
9
+ * command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
10
+ * straight on, and of the rest only the LAST so many characters are kept.
11
+ *
12
+ * WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
13
+ * 8 000 characters kept, lost exactly the line that mattered: with
14
+ * `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
15
+ * the last thing it prints — never reached the model, which got "exited with
16
+ * code 1" and nothing to act on.
17
+ *
18
+ * Its own module so it can be tested: bir-scenario starts serving on import.
19
+ */
20
+ /** `bir`'s own audit lines — for the log, not for the model. */
21
+ export const AUDIT_LINE = /^\[bir\] \d{4}-\d\d-\d\dT/;
22
+ export class StderrSorter {
23
+ opts;
24
+ partial = "";
25
+ /** Where the rest of a line cut for length goes, once its start was sorted. */
26
+ continuing;
27
+ kept = "";
28
+ forwarded = 0;
29
+ constructor(opts) {
30
+ this.opts = opts;
31
+ }
32
+ push(chunk) {
33
+ const lines = `${this.partial}${chunk}`.split("\n");
34
+ this.partial = lines.pop() ?? "";
35
+ for (const line of lines) {
36
+ this.line(line);
37
+ this.continuing = undefined;
38
+ }
39
+ // A "line" with no end in sight is sorted in pieces rather than held whole.
40
+ if (this.partial.length > this.opts.keepChars) {
41
+ this.line(this.partial);
42
+ this.partial = "";
43
+ }
44
+ }
45
+ /** Everything that was not an audit line (the last of it), once the stream ended. */
46
+ end() {
47
+ if (this.partial !== "")
48
+ this.line(this.partial);
49
+ this.partial = "";
50
+ this.continuing = undefined;
51
+ return this.kept;
52
+ }
53
+ line(raw) {
54
+ const line = raw.replace(/\r$/, "");
55
+ const kind = this.continuing ?? (AUDIT_LINE.test(line) ? "audit" : "kept");
56
+ this.continuing = kind;
57
+ if (line.trim() === "")
58
+ return;
59
+ if (kind === "audit") {
60
+ if (this.forwarded >= this.opts.forwardChars)
61
+ return;
62
+ this.forwarded += line.length + 1;
63
+ try {
64
+ this.opts.forward(line);
65
+ }
66
+ catch {
67
+ // The log is gone; nothing to do about that here.
68
+ }
69
+ return;
70
+ }
71
+ this.kept += `${line}\n`;
72
+ if (this.kept.length > this.opts.keepChars)
73
+ this.kept = this.kept.slice(-this.opts.keepChars);
74
+ }
75
+ }
76
+ //# sourceMappingURL=scenario-stderr.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * service-read — what `bir scenario show|edits` and `bir investigate` share
3
+ * about asking the service and printing its answer, so a slow service, a
4
+ * signed-out machine or a large scenario never turns into a stall or a flood.
5
+ *
6
+ * WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
7
+ * the service address with a `/health` probe, may refresh the session, and
8
+ * then makes its request; a service (or a corporate proxy in front of it) that
9
+ * accepts the connection and never answers held every one of those for as long
10
+ * as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
11
+ * is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
12
+ * nothing was asked to change. So each one gets {@link readTimeoutMs} — a
13
+ * command that reads twice shares it between them ({@link ReadClock}) — and
14
+ * then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
15
+ * bounded here — abandoning one would leave "was it saved?" unanswerable — and
16
+ * the MCP server bounds the whole command instead.
17
+ *
18
+ * WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
19
+ * commands with `BIR_MCP=1` and hands their `--json` output to the model.
20
+ * Indentation is for a person at a terminal; in a model's context it is a
21
+ * quarter of the tokens and none of the meaning.
22
+ */
23
+ /** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
24
+ export declare const DEFAULT_READ_TIMEOUT_MS = 15000;
25
+ /**
26
+ * The most any timer here is set for from the environment. Node turns a delay
27
+ * past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
28
+ * used to make every read give up at once; ten minutes is already far longer
29
+ * than anybody waits for a debugging tool.
30
+ */
31
+ export declare const MAX_ENV_TIMEOUT_MS = 600000;
32
+ /**
33
+ * A number of milliseconds from the environment: positive and at most
34
+ * {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
35
+ * the default is the value known to work.
36
+ */
37
+ export declare function envTimeoutMs(name: string, fallback: number): number;
38
+ export declare function readTimeoutMs(): number;
39
+ /** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
40
+ export declare function underMcp(): boolean;
41
+ /** `--json` output: indented for a person, compact for a model. */
42
+ export declare function jsonText(value: unknown): string;
43
+ /**
44
+ * `pending`, or `{ error }` when it takes longer than `ms`. The request itself
45
+ * is not cancelled — `bir` exits as soon as the command returns, which is what
46
+ * ends it — and a late rejection is observed here, so it can never surface as
47
+ * an unhandled one. `statedMs` is the bound the answer names, when `ms` is
48
+ * only what was left of it ({@link ReadClock}).
49
+ */
50
+ export declare function readWithin<T>(pending: Promise<T | {
51
+ error: string;
52
+ }>, ms?: number, statedMs?: number): Promise<T | {
53
+ error: string;
54
+ }>;
55
+ /**
56
+ * One command's reads of the service, sharing one bound. A command that reads
57
+ * twice — `scenario edits` on a `run_` id finds the scenario, then its
58
+ * history; `investigate` tries the matched run after a "no" — used to give
59
+ * each read the whole bound, so a service that answered the first slowly and
60
+ * held the second took twice as long as the `bir` MCP server allows a
61
+ * reading tool, and was killed there with none of the answer the command
62
+ * already had (the journal half of `investigate`, say). Sharing the bound
63
+ * keeps the whole command inside it, and the timeout still names the bound,
64
+ * not the scrap of it the last read had left.
65
+ */
66
+ export declare class ReadClock {
67
+ readonly totalMs: number;
68
+ private readonly deadline;
69
+ constructor(totalMs?: number);
70
+ /** What is left of the bound, in ms (0 once it is spent). */
71
+ remaining(): number;
72
+ /** {@link readWithin}, with what is left of the command's bound. */
73
+ within<T>(pending: Promise<T | {
74
+ error: string;
75
+ }>): Promise<T | {
76
+ error: string;
77
+ }>;
78
+ }
79
+ /** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
80
+ export declare function unreachable(error: string): boolean;
81
+ /**
82
+ * A failure to reach the service, in words a person (or a model) can act on.
83
+ * The transport's own words — "fetch failed", "This operation was aborted" —
84
+ * say nothing about what to do, so they are replaced; the sentences `bir`
85
+ * itself wrote ("not signed in — run `bir login`") are already the answer.
86
+ */
87
+ export declare function serviceProblem(error: string): string;
88
+ //# sourceMappingURL=service-read.d.ts.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * service-read — what `bir scenario show|edits` and `bir investigate` share
3
+ * about asking the service and printing its answer, so a slow service, a
4
+ * signed-out machine or a large scenario never turns into a stall or a flood.
5
+ *
6
+ * WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
7
+ * the service address with a `/health` probe, may refresh the session, and
8
+ * then makes its request; a service (or a corporate proxy in front of it) that
9
+ * accepts the connection and never answers held every one of those for as long
10
+ * as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
11
+ * is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
12
+ * nothing was asked to change. So each one gets {@link readTimeoutMs} — a
13
+ * command that reads twice shares it between them ({@link ReadClock}) — and
14
+ * then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
15
+ * bounded here — abandoning one would leave "was it saved?" unanswerable — and
16
+ * the MCP server bounds the whole command instead.
17
+ *
18
+ * WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
19
+ * commands with `BIR_MCP=1` and hands their `--json` output to the model.
20
+ * Indentation is for a person at a terminal; in a model's context it is a
21
+ * quarter of the tokens and none of the meaning.
22
+ */
23
+ /** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
24
+ export const DEFAULT_READ_TIMEOUT_MS = 15_000;
25
+ /**
26
+ * The most any timer here is set for from the environment. Node turns a delay
27
+ * past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
28
+ * used to make every read give up at once; ten minutes is already far longer
29
+ * than anybody waits for a debugging tool.
30
+ */
31
+ export const MAX_ENV_TIMEOUT_MS = 600_000;
32
+ /**
33
+ * A number of milliseconds from the environment: positive and at most
34
+ * {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
35
+ * the default is the value known to work.
36
+ */
37
+ export function envTimeoutMs(name, fallback) {
38
+ const n = Number(process.env[name]);
39
+ return Number.isFinite(n) && n > 0 && n <= MAX_ENV_TIMEOUT_MS ? n : fallback;
40
+ }
41
+ export function readTimeoutMs() {
42
+ return envTimeoutMs("BIR_READ_TIMEOUT_MS", DEFAULT_READ_TIMEOUT_MS);
43
+ }
44
+ /** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
45
+ export function underMcp() {
46
+ return process.env.BIR_MCP === "1";
47
+ }
48
+ /** `--json` output: indented for a person, compact for a model. */
49
+ export function jsonText(value) {
50
+ return underMcp() ? JSON.stringify(value) : JSON.stringify(value, null, 2);
51
+ }
52
+ /**
53
+ * `pending`, or `{ error }` when it takes longer than `ms`. The request itself
54
+ * is not cancelled — `bir` exits as soon as the command returns, which is what
55
+ * ends it — and a late rejection is observed here, so it can never surface as
56
+ * an unhandled one. `statedMs` is the bound the answer names, when `ms` is
57
+ * only what was left of it ({@link ReadClock}).
58
+ */
59
+ export async function readWithin(pending, ms = readTimeoutMs(), statedMs = ms) {
60
+ pending.catch(() => undefined);
61
+ let timer;
62
+ const late = new Promise((resolve) => {
63
+ timer = setTimeout(() => resolve({ error: `the BaseIn service did not answer within ${seconds(statedMs)} s` }), Math.max(0, ms));
64
+ timer.unref?.();
65
+ });
66
+ try {
67
+ return await Promise.race([pending, late]);
68
+ }
69
+ catch (err) {
70
+ return { error: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ finally {
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ /**
78
+ * One command's reads of the service, sharing one bound. A command that reads
79
+ * twice — `scenario edits` on a `run_` id finds the scenario, then its
80
+ * history; `investigate` tries the matched run after a "no" — used to give
81
+ * each read the whole bound, so a service that answered the first slowly and
82
+ * held the second took twice as long as the `bir` MCP server allows a
83
+ * reading tool, and was killed there with none of the answer the command
84
+ * already had (the journal half of `investigate`, say). Sharing the bound
85
+ * keeps the whole command inside it, and the timeout still names the bound,
86
+ * not the scrap of it the last read had left.
87
+ */
88
+ export class ReadClock {
89
+ totalMs;
90
+ deadline;
91
+ constructor(totalMs = readTimeoutMs()) {
92
+ this.totalMs = totalMs;
93
+ this.deadline = Date.now() + totalMs;
94
+ }
95
+ /** What is left of the bound, in ms (0 once it is spent). */
96
+ remaining() {
97
+ return Math.max(0, this.deadline - Date.now());
98
+ }
99
+ /** {@link readWithin}, with what is left of the command's bound. */
100
+ within(pending) {
101
+ return readWithin(pending, this.remaining(), this.totalMs);
102
+ }
103
+ }
104
+ /** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
105
+ export function unreachable(error) {
106
+ return /did not answer within|fetch failed|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|socket|network|aborted|timed out|UND_ERR/i.test(error);
107
+ }
108
+ /**
109
+ * A failure to reach the service, in words a person (or a model) can act on.
110
+ * The transport's own words — "fetch failed", "This operation was aborted" —
111
+ * say nothing about what to do, so they are replaced; the sentences `bir`
112
+ * itself wrote ("not signed in — run `bir login`") are already the answer.
113
+ */
114
+ export function serviceProblem(error) {
115
+ if (/BIR_AUTH_URL is not set/.test(error)) {
116
+ return "no BaseIn service is configured on this machine — run `bir setup` or `bir login`";
117
+ }
118
+ if (/did not answer within/.test(error))
119
+ return error;
120
+ if (unreachable(error))
121
+ return "the BaseIn service could not be reached — check the network, or try again later";
122
+ return error;
123
+ }
124
+ function seconds(ms) {
125
+ const s = ms / 1000;
126
+ return Number.isInteger(s) ? String(s) : s.toFixed(1);
127
+ }
128
+ //# sourceMappingURL=service-read.js.map
@@ -43,7 +43,26 @@ export interface SetupArgs {
43
43
  daemon: boolean;
44
44
  /** `--allow-servers a,b`: the servers replay may call unattended, stored for this project. */
45
45
  allowServers?: string;
46
+ /**
47
+ * `--port` was given: the port is the person's, and is never moved. Without
48
+ * it a recorded port this machine no longer lets the recorder bind is
49
+ * replaced (see {@link runSetup}, step 4).
50
+ */
51
+ portExplicit?: boolean;
46
52
  }
53
+ /**
54
+ * The project directory every later step keys by: the PHYSICAL path on POSIX.
55
+ *
56
+ * After `chdir`, `process.cwd()` — which install keys the sidecar by and the
57
+ * recorder keys its discovery file by — is the resolved path. Keying setup's own
58
+ * steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
59
+ * made it wait 15 s for a discovery file that was written under the other
60
+ * spelling, report "the recorder did not start" while it ran, and store
61
+ * `--allow-servers` where the recorder never looks. Windows keeps the path as
62
+ * given: its cwd is not resolved through junctions, and resolving would only
63
+ * change the drive letter's case.
64
+ */
65
+ export declare function canonicalProject(dir: string, platform?: NodeJS.Platform): string;
47
66
  export interface SetupDeps {
48
67
  out: (line?: string) => void;
49
68
  err: (line: string) => void;
package/dist/bin/setup.js CHANGED
@@ -28,16 +28,41 @@
28
28
  * `cd <project>` and `bir setup`. The second needs no token; the session is
29
29
  * cached.
30
30
  */
31
- import { existsSync, statSync } from "node:fs";
31
+ import { existsSync, realpathSync, statSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { delimiter, join, parse as parsePath, resolve as resolvePath } from "node:path";
34
+ import { pidAlive, readDiscovery } from "../control/discovery.js";
34
35
  import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
35
- import { activeSessions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
36
+ import { activeSessions, compareVersions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
36
37
  import { normalizePath } from "../control/paths.js";
37
- import { isWrapped, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
38
+ import { allocateReplacementPort, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
38
39
  import { resolveServers } from "../config/resolve.js";
39
40
  import { errText } from "../util/log.js";
41
+ import { offSource } from "../util/off-switch.js";
40
42
  import { packageVersion } from "../util/version.js";
43
+ /**
44
+ * The project directory every later step keys by: the PHYSICAL path on POSIX.
45
+ *
46
+ * After `chdir`, `process.cwd()` — which install keys the sidecar by and the
47
+ * recorder keys its discovery file by — is the resolved path. Keying setup's own
48
+ * steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
49
+ * made it wait 15 s for a discovery file that was written under the other
50
+ * spelling, report "the recorder did not start" while it ran, and store
51
+ * `--allow-servers` where the recorder never looks. Windows keeps the path as
52
+ * given: its cwd is not resolved through junctions, and resolving would only
53
+ * change the drive letter's case.
54
+ */
55
+ export function canonicalProject(dir, platform = process.platform) {
56
+ const logical = resolvePath(dir);
57
+ if (platform === "win32")
58
+ return logical;
59
+ try {
60
+ return realpathSync(logical);
61
+ }
62
+ catch {
63
+ return logical;
64
+ }
65
+ }
41
66
  /** True when `claude` (Claude Code) is on PATH or in its native install location. */
42
67
  export function claudeOnPath(env = process.env) {
43
68
  const names = process.platform === "win32" ? ["claude.exe", "claude.cmd", "claude"] : ["claude"];
@@ -93,7 +118,18 @@ export async function runSetup(args, deps) {
93
118
  const problem = await describeAuthService(baseUrl);
94
119
  if (problem) {
95
120
  err(`${baseUrl} is not a BaseIn service: ${problem}.`);
96
- err(AUTH_URL_HINT);
121
+ if (/unreachable/.test(problem) && proxyConfigured(env) && !process.execArgv.includes("--use-env-proxy")) {
122
+ // Behind a proxy-only network the likeliest cause, and not the address:
123
+ // Node's fetch ignores HTTPS_PROXY unless it is started with this flag.
124
+ // NO_PROXY too: with the flag, the runner's own calls to 127.0.0.1 would
125
+ // go to the proxy as well, and install leaves the flag out without it.
126
+ err("HTTPS_PROXY/HTTP_PROXY is set, but this node does not use it without --use-env-proxy: set " +
127
+ `NO_PROXY=${LOOPBACK_NO_PROXY} (plus your own entries) and run ` +
128
+ "`node --use-env-proxy <path to bir.js> setup …` (the install line does this for you).");
129
+ }
130
+ else {
131
+ err(AUTH_URL_HINT);
132
+ }
97
133
  return 1;
98
134
  }
99
135
  const envUrl = (env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
@@ -107,7 +143,7 @@ export async function runSetup(args, deps) {
107
143
  err(`BIR_AUTH_URL in this environment is ${envUrl}, but ${args.authUrl ? "this sets up" : "the stored address is"} ${args.authUrl ? baseUrl : storedUrl}.`);
108
144
  err("Remove that variable (check your shell profile), or the recorder will not record.");
109
145
  }
110
- const project = resolvePath(args.project ?? process.cwd());
146
+ const project = canonicalProject(args.project ?? process.cwd());
111
147
  if (!existsSync(project) || !statSync(project).isDirectory()) {
112
148
  err(`Project directory not found: ${project}`);
113
149
  return 1;
@@ -185,6 +221,33 @@ export async function runSetup(args, deps) {
185
221
  }
186
222
  // ── 4. the project ───────────────────────────────────────────────────────
187
223
  chdir(project);
224
+ if (!args.portExplicit) {
225
+ // A port chosen at the first install is kept on later ones — the hooks name
226
+ // it — but this machine may no longer let it be bound: Hyper-V, WSL and
227
+ // Docker reserve blocks inside the dynamic range, and those move across
228
+ // reboots. A recorder told to use it exits, and every hook of every session
229
+ // then fails. Setup is the upgrade and repair path, so it moves the project
230
+ // to a free port and lets install rewrite the hooks and entries with it —
231
+ // unless the port is busy because THIS project's recorder is on it.
232
+ const sidecar = readSidecar();
233
+ const recorded = projectRecord(sidecar, project);
234
+ if (recorded) {
235
+ // Age does not matter here, only a live pid: a recorder whose file went
236
+ // stale over a long sleep (or an older runner's, whose heartbeat is
237
+ // slower) still holds the port, and moving away from it would leave
238
+ // two recorders and hooks pointing at the new one.
239
+ const ours = readDiscovery(project, { ignoreAge: true });
240
+ const heldByUs = Boolean(ours && pidAlive(ours.pid) && ours.url.endsWith(`:${recorded.port}`));
241
+ if (!heldByUs && !(await probeFreePort(recorded.port))) {
242
+ const next = await allocateReplacementPort(sidecar, project, recorded.port);
243
+ if (next !== undefined) {
244
+ setProjectRecord(sidecar, project, { port: next });
245
+ writeSidecar(sidecar);
246
+ out(` port ${recorded.port} is not available on this machine; this project moves to ${next}`);
247
+ }
248
+ }
249
+ }
250
+ }
188
251
  if (args.allowServers !== undefined) {
189
252
  // Stored before install so the recorder started below reads it at start.
190
253
  const sidecar = readSidecar();
@@ -202,11 +265,17 @@ export async function runSetup(args, deps) {
202
265
  if (args.daemon) {
203
266
  out("==> Starting the recorder in the background");
204
267
  try {
205
- ensured = await ensureDaemon(project, {
268
+ // probeIdle: a recorder too old to say whether its "open" turn is real
269
+ // (0.2.12 never closes an interrupted one) is replaced when two /health
270
+ // probes a few seconds apart show it idle (control/daemon.ts). Setup has
271
+ // the time; the SessionStart hook does not ask for it.
272
+ const ensureOpts = {
206
273
  hooksScript: deps.hooksScript,
207
274
  env,
208
275
  restartOnSkew: true,
209
- });
276
+ probeIdle: true,
277
+ };
278
+ ensured = await ensureDaemon(project, ensureOpts);
210
279
  if (!ensured.started &&
211
280
  ensured.status.health.recording === false &&
212
281
  activeSessions(ensured.status.health) === 0) {
@@ -219,6 +288,36 @@ export async function runSetup(args, deps) {
219
288
  ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
220
289
  ensured = { ...ensured, restarted: true };
221
290
  }
291
+ const port = projectRecord(readSidecar(), project)?.port;
292
+ if (port !== undefined &&
293
+ !ensured.status.info.url.endsWith(`:${port}`) &&
294
+ activeSessions(ensured.status.health) === 0) {
295
+ // A recorder still on the port this project had before (moved above,
296
+ // or by `--port`): the hooks now post to the new one, so it would
297
+ // receive nothing. Nothing is mid-run; replace it.
298
+ out(` the running recorder is on ${ensured.status.info.url}, not port ${port}; replacing it`);
299
+ await stopDaemon(project);
300
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
301
+ ensured = { ...ensured, restarted: true };
302
+ }
303
+ // The replay switch is read once, when a recorder starts: one started
304
+ // before this setup turned replay off (--no-replay) or on would go on
305
+ // doing what it did. `source: "env"` is BIR_REPLAY where it started,
306
+ // which outranks the switch — a restart from here would not carry it.
307
+ const wantReplay = projectRecord(readSidecar(), project)?.replay?.enabled !== false;
308
+ const running = ensured.status.health.replay;
309
+ if (typeof running?.enabled === "boolean" && running.enabled !== wantReplay && running.source !== "env") {
310
+ if (activeSessions(ensured.status.health) === 0) {
311
+ out(` the running recorder has replay ${running.enabled ? "on" : "off"}; replacing it`);
312
+ await stopDaemon(project);
313
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
314
+ ensured = { ...ensured, restarted: true };
315
+ }
316
+ else {
317
+ out(` the running recorder keeps replay ${running.enabled ? "on" : "off"} for the session open now; ` +
318
+ "`bir up --restart` once it ends applies the change");
319
+ }
320
+ }
222
321
  }
223
322
  catch (e) {
224
323
  err(`the recorder did not start: ${errText(e)}`);
@@ -227,40 +326,67 @@ export async function runSetup(args, deps) {
227
326
  }
228
327
  }
229
328
  // ── 6. the summary ───────────────────────────────────────────────────────
230
- const wrapped = resolveServers(project)
231
- .filter((s) => s.name !== SCENARIO_SERVER_KEY && isWrapped(s.config))
232
- .map((s) => s.name);
329
+ const live = resolveServers(project).filter((s) => s.name !== SCENARIO_SERVER_KEY);
330
+ const wrapped = live.filter((s) => isWrapped(s.config)).map((s) => s.name);
331
+ // Servers install left as Claude Code runs them (not approved there yet, a
332
+ // remote it signs in itself, …) are still here: "no MCP servers" would lie.
333
+ const unwrapped = live.filter((s) => !isWrapped(s.config)).map((s) => s.name);
233
334
  const health = ensured?.status.health;
234
335
  const replay = health?.replay;
235
336
  const recording = health ? health.recording !== false : undefined;
236
337
  const policy = projectRecord(readSidecar(), project)?.replay;
237
338
  const allow = replay?.allowServers ?? policy?.allowServers ?? null;
339
+ // The stored switch, which every recorder here reads at start — unless the
340
+ // one that answered was started with BIR_REPLAY, which outranks it.
341
+ const replayOn = replay?.source === "env" ? replay.enabled !== false : policy?.enabled !== false;
238
342
  out();
239
- out(`Done. Recording${args.replay ? " and replay are" : " is"} on for ${project}`);
343
+ out(`Done. Recording${args.replay && replayOn ? " and replay are" : " is"} on for ${project}`);
344
+ // The kill switch outranks all of it, and setup leaves it as the person set
345
+ // it: turning the runner back on is theirs to decide, not an upgrade's.
346
+ const off = offSource(project);
347
+ if (off) {
348
+ out(` !! but the runner is OFF (${off === "env" ? "BIR_OFF=1 in this environment" : off === "global" ? "`bir off`, every project" : "`bir off --here`"}): nothing is recorded or replayed until \`bir on${off === "project" ? " --here" : ""}\``);
349
+ }
240
350
  out(` account ${session.user.email} (${baseUrl})`);
241
- out(` wrapped ${wrapped.length ? wrapped.join(", ") : "(no MCP servers here)"}`);
351
+ out(` wrapped ${wrapped.length ? wrapped.join(", ") : unwrapped.length ? `(none — ${unwrapped.join(", ")} left as they are, see above)` : "(no MCP servers here)"}`);
242
352
  if (!args.replay) {
243
- out(" replay not installed (--no-replay)");
353
+ const scenarioLeft = resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
354
+ out(replayOn
355
+ ? " replay ON despite --no-replay: BIR_REPLAY is set where the recorder started"
356
+ : ` replay off (--no-replay) — \`bir replay on\` turns it on${scenarioLeft ? "; the scenario server an earlier setup added stays (`bir uninstall --replay` removes it)" : ""}`);
357
+ }
358
+ else if (!replayOn) {
359
+ out(replay?.source === "env"
360
+ ? " replay off — BIR_REPLAY=0 where the recorder started"
361
+ : " replay off here (`bir replay off`) — `bir replay on` turns it on");
244
362
  }
245
363
  else if (wrapped.length === 0) {
246
- out(" replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
364
+ out(unwrapped.length
365
+ ? " replay steer mode only — none of this project's MCP servers is wrapped (why is above); `bir setup` again once one can be, for direct replay"
366
+ : " replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
247
367
  }
248
368
  else {
249
- const on = replay ? replay.enabled !== false : policy?.enabled !== false;
250
- out(` replay ${on ? "on" : "off"} — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
369
+ out(` replay on — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
251
370
  }
252
371
  if (ensured) {
253
372
  const { info, health: h } = ensured.status;
254
373
  const theirs = String(h.hooksVersion ?? info.version ?? "");
255
374
  out(` recorder ${ensured.started ? (ensured.restarted ? "restarted" : "started") : "already running"} in the background (pid ${info.pid})` +
256
375
  " — it starts itself with every Claude Code session");
257
- if (theirs && theirs !== packageVersion()) {
258
- out(` recorder ${theirs} is still serving an open Claude Code session; it restarts to ${packageVersion()} with the next session`);
376
+ // Only for an OLDER recorder: a newer one is kept on purpose
377
+ // (daemon.newer_kept) and nothing about it waits on a restart.
378
+ if (theirs && compareVersions(theirs, packageVersion()) === -1) {
379
+ // Said as it is: nothing replaces it by itself while that session runs —
380
+ // the next session's start keeps it too, as long as it reports a turn
381
+ // open — and until it is replaced it behaves as ${theirs} does (0.2.12:
382
+ // approves every wrapped MCP call, ignores `bir off`).
383
+ out(` it is still ${theirs}, serving a Claude Code session that is mid-turn; it keeps running as ${theirs} ` +
384
+ `until \`bir up --restart\` (run it once that session ends) replaces it with ${packageVersion()}`);
259
385
  }
260
386
  if (info.logFile)
261
387
  out(` log ${info.logFile}`);
262
388
  if (recording === false) {
263
- out(" !! the recorder has no session to record with — run `bir login`, then `bir up --restart`");
389
+ out(" !! the recorder has no session to record with — run `bir login`; it starts recording by itself after that");
264
390
  }
265
391
  }
266
392
  else {
@@ -280,7 +406,7 @@ export async function runSetup(args, deps) {
280
406
  out(' first time in this folder: say Yes to "trust this folder"');
281
407
  out(" just installed Claude Code? it asks you to sign in to Claude (it needs a Claude subscription)");
282
408
  out(` give it a task that takes 4+ tool calls — it appears ${console_ ? `at ${console_}/recordings` : "in the console's Recordings"} within a minute of finishing`);
283
- out("Any time: bir doctor bir investigate bir down (stop) bir uninstall (undo)");
409
+ out("Any time: bir doctor bir investigate bir off (out of the way, at once) bir uninstall (undo)");
284
410
  return recording === false ? 1 : 0;
285
411
  }
286
412
  //# sourceMappingURL=setup.js.map