@basein/runner 0.2.8 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -22
  2. package/dist/auth/client.d.ts +40 -1
  3. package/dist/auth/client.js +77 -9
  4. package/dist/bin/bir-hooks.d.ts +18 -3
  5. package/dist/bin/bir-hooks.js +124 -38
  6. package/dist/bin/bir-scenario.d.ts +18 -2
  7. package/dist/bin/bir-scenario.js +374 -4
  8. package/dist/bin/bir.d.ts +12 -0
  9. package/dist/bin/bir.js +501 -81
  10. package/dist/bin/investigate.js +1 -1
  11. package/dist/bin/scenario-edit.d.ts +173 -0
  12. package/dist/bin/scenario-edit.js +771 -0
  13. package/dist/bin/setup.d.ts +72 -0
  14. package/dist/bin/setup.js +286 -0
  15. package/dist/config/adapters/claude-code.d.ts +90 -4
  16. package/dist/config/adapters/claude-code.js +164 -16
  17. package/dist/config/generate.d.ts +114 -1
  18. package/dist/config/generate.js +106 -3
  19. package/dist/control/client.d.ts +5 -0
  20. package/dist/control/client.js +8 -0
  21. package/dist/control/daemon.d.ts +116 -0
  22. package/dist/control/daemon.js +339 -0
  23. package/dist/control/discovery.d.ts +26 -0
  24. package/dist/control/discovery.js +41 -9
  25. package/dist/control/ensure-hook.d.ts +39 -0
  26. package/dist/control/ensure-hook.js +98 -0
  27. package/dist/control/paths.d.ts +14 -0
  28. package/dist/control/paths.js +20 -0
  29. package/dist/control/server.d.ts +28 -0
  30. package/dist/control/server.js +15 -2
  31. package/dist/proxy/session.d.ts +8 -1
  32. package/dist/proxy/session.js +28 -6
  33. package/docs/calculatedReplay.md +51 -0
  34. package/docs/calculatedReplayGuide.md +471 -74
  35. package/docs/installRun.md +457 -111
  36. package/docs/loginWeb.md +1 -1
  37. package/docs/quickstart.md +195 -158
  38. package/package.json +2 -1
  39. package/scripts/install.ps1 +669 -0
  40. package/scripts/install.sh +586 -0
@@ -53,6 +53,14 @@ export interface ControlServerOptions {
53
53
  cwd: string;
54
54
  /** Preferred port; 0 (or a busy port) falls back to an ephemeral one. */
55
55
  port?: number;
56
+ /**
57
+ * Whether a busy `port` may fall back to an ephemeral one. Default true — a
58
+ * recorder in a terminal is better on some port than not at all. A background
59
+ * recorder whose port is written into a project's hooks passes false: on a
60
+ * fallback port it would answer SessionStart and then every other hook would
61
+ * post into the void, which is worse than exiting with a reason.
62
+ */
63
+ portFallback?: boolean;
56
64
  /** Loopback bearer token. Generated when omitted. */
57
65
  token?: string;
58
66
  /** Config keys of the servers a proxy wraps. Grows as proxies register. */
@@ -90,6 +98,26 @@ export interface ControlServerOptions {
90
98
  * recording and nothing else happens.
91
99
  */
92
100
  replay?: ReplayOptions;
101
+ /**
102
+ * Where the replay switches came from — the environment, the project's
103
+ * stored policy, or the defaults. Reported on `/health` so `bir doctor` can
104
+ * say which, because "the allow-list I set is gone" has exactly one cause.
105
+ */
106
+ replaySource?: "env" | "sidecar" | "default";
107
+ /**
108
+ * Whose account the recordings land in, for `/health` and `bir doctor`. "The
109
+ * run is not in Recordings" is, more often than not, "it is in somebody
110
+ * else's", and nothing said which until now.
111
+ */
112
+ account?: string;
113
+ /**
114
+ * What 'POST /control/stop' does after answering. 'bir-hooks' passes its own
115
+ * shutdown; a test passes nothing and the route merely says it would. The
116
+ * route exists so a background recorder can be stopped the way Ctrl-C stops
117
+ * one in a terminal — finishing the run and draining the queue — rather than
118
+ * killed, which on Windows is the only other option.
119
+ */
120
+ onStopRequested?: () => void;
93
121
  }
94
122
  export interface ControlServerAddress {
95
123
  url: string;
@@ -38,6 +38,7 @@ import { calculateCostUsd } from "../replay/pricing.js";
38
38
  import { logDetail, logLine, errText } from "../util/log.js";
39
39
  import { journal } from "../util/journal.js";
40
40
  import { packageVersion } from "../util/version.js";
41
+ import { resolveAuthUrl } from "../auth/client.js";
41
42
  /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
42
43
  const PROXY_REPORT_GRACE_MS = 1_500;
43
44
  /** One MCP call, as seen from up to two sides. */
@@ -127,7 +128,7 @@ export class ControlServer {
127
128
  resolve(this.address);
128
129
  });
129
130
  };
130
- bind(preferred, preferred !== 0);
131
+ bind(preferred, preferred !== 0 && this.opts.portFallback !== false);
131
132
  });
132
133
  }
133
134
  async close() {
@@ -150,6 +151,10 @@ export class ControlServer {
150
151
  const sessions = [...this.sessions.values()].map((s) => ({
151
152
  sessionId: s.sessionId,
152
153
  runId: s.run?.runId,
154
+ // Mid-run right now. What 'ensureDaemon' reads before it dares to restart
155
+ // an out-of-date recorder: a restart between turns costs nothing, one
156
+ // during a turn loses the turn.
157
+ active: Boolean(s.run) && !s.run.finished,
153
158
  steps: s.run?.ordering.next ?? 0,
154
159
  recording: s.run?.recording ?? false,
155
160
  replay: s.run?.replay
@@ -187,12 +192,13 @@ export class ControlServer {
187
192
  tier: "bound",
188
193
  // The authoritative answer to "is anything actually being saved?".
189
194
  recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
195
+ account: this.opts.account ?? null,
190
196
  // Whether a handed-out segment may actually run mid-task, or whether the
191
197
  // runner is only watching (R-OUT-10, R-LIFE-8). Observe-only must never
192
198
  // be invisible: an operator has to be able to see which of the two this
193
199
  // machine is doing without reading a log file.
194
200
  segmentArm: this.replay.segmentArm,
195
- authUrl: process.env.BIR_AUTH_URL ?? null,
201
+ authUrl: resolveAuthUrl() || null,
196
202
  sessionId: this.sessionId,
197
203
  pid: process.pid,
198
204
  cwd: this.opts.cwd,
@@ -210,6 +216,7 @@ export class ControlServer {
210
216
  // (docs/calculatedReplay.md §13.2, mitigation 3).
211
217
  replay: {
212
218
  enabled: this.replay.enabled,
219
+ source: this.opts.replaySource ?? null,
213
220
  minSimilarity: this.opts.replay?.minSimilarity ?? null,
214
221
  allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
215
222
  deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
@@ -248,6 +255,12 @@ export class ControlServer {
248
255
  }
249
256
  const body = await this.readJson(req);
250
257
  switch (route) {
258
+ case "/control/stop":
259
+ // Answer first, then leave: the caller is polling for the pid to go.
260
+ this.send(res, 200, { ok: Boolean(this.opts.onStopRequested) });
261
+ if (this.opts.onStopRequested)
262
+ setImmediate(() => this.opts.onStopRequested?.());
263
+ return;
251
264
  case "/session/start":
252
265
  this.send(res, 200, this.onSessionStart(body));
253
266
  return;
@@ -25,7 +25,14 @@ import type { UpstreamClient } from "../upstream/client.js";
25
25
  import type { ProxyStepReport } from "../control/correlation.js";
26
26
  import { type Recorder } from "../record/recorder.js";
27
27
  /** How long a proxy waits for a control server before falling to Tier 2. */
28
- export declare const DISCOVERY_WINDOW_MS = 5000;
28
+ /**
29
+ * Ten seconds, not five: the recorder is now started by the SessionStart hook,
30
+ * which fires while the host is also spawning this proxy, and a cold start
31
+ * (sign-in refresh, the service's /health probe, then listen) has been
32
+ * measured at one to four seconds. Steps are buffered meanwhile, never
33
+ * dropped, and a host session is never waiting on this.
34
+ */
35
+ export declare const DISCOVERY_WINDOW_MS = 10000;
29
36
  export type Tier = "bound" | "standalone" | "pending";
30
37
  export interface ProxySessionOptions {
31
38
  serverName: string;
@@ -26,7 +26,7 @@ import { hostname } from "node:os";
26
26
  import { ControlClient } from "../control/client.js";
27
27
  import { resolveControl } from "../control/discovery.js";
28
28
  import { qualifyToolName } from "../control/correlation.js";
29
- import { authenticate } from "../auth/client.js";
29
+ import { authenticate, resolveAuthUrl } from "../auth/client.js";
30
30
  import { NullRecorder } from "../record/recorder.js";
31
31
  import { RemoteRecorder } from "../record/remote-recorder.js";
32
32
  import { StepQueue } from "../record/queue.js";
@@ -36,7 +36,14 @@ import { redact } from "../record/redact.js";
36
36
  import { logLine, logDetail, errText } from "../util/log.js";
37
37
  import { packageVersion } from "../util/version.js";
38
38
  /** How long a proxy waits for a control server before falling to Tier 2. */
39
- export const DISCOVERY_WINDOW_MS = 5_000;
39
+ /**
40
+ * Ten seconds, not five: the recorder is now started by the SessionStart hook,
41
+ * which fires while the host is also spawning this proxy, and a cold start
42
+ * (sign-in refresh, the service's /health probe, then listen) has been
43
+ * measured at one to four seconds. Steps are buffered meanwhile, never
44
+ * dropped, and a host session is never waiting on this.
45
+ */
46
+ export const DISCOVERY_WINDOW_MS = 10_000;
40
47
  export class ProxySession {
41
48
  serverName;
42
49
  /** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
@@ -98,6 +105,7 @@ export class ProxySession {
98
105
  return;
99
106
  }
100
107
  const window = this.opts.discoveryWindowMs ?? DISCOVERY_WINDOW_MS;
108
+ const deadline = Date.now() + window;
101
109
  const found = await resolveControl(this.opts.cwd, window);
102
110
  if (!found?.url) {
103
111
  await this.becomeStandalone(`no control server within ${window}ms`);
@@ -108,12 +116,24 @@ export class ProxySession {
108
116
  startedAt: this.startedAt,
109
117
  pid: process.pid,
110
118
  });
111
- const registered = await client.register({
119
+ const info = {
112
120
  serverName: this.serverName,
113
121
  pid: process.pid,
114
122
  cwd: this.opts.cwd,
115
123
  version: packageVersion(),
116
- });
124
+ };
125
+ // A control server that is still coming up — the SessionStart hook is
126
+ // starting it while the host spawns us — answers nothing for a moment.
127
+ // Keep trying inside the same window rather than settle for Tier 2 on the
128
+ // first refusal.
129
+ let registered = await client.register(info);
130
+ while (!registered && Date.now() < deadline) {
131
+ await new Promise((resolve) => {
132
+ const t = setTimeout(resolve, 250);
133
+ t.unref?.();
134
+ });
135
+ registered = await client.register(info);
136
+ }
117
137
  if (!registered) {
118
138
  await this.becomeStandalone("control server did not answer /proxy/register");
119
139
  return;
@@ -216,9 +236,11 @@ export class ProxySession {
216
236
  if (this.opts.recorderFactory) {
217
237
  return (await this.opts.recorderFactory()) ?? new NullRecorder();
218
238
  }
219
- const baseUrl = (process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
239
+ // BIR_AUTH_URL, or the address 'bir setup' stored — the same lookup the
240
+ // control server and the CLI make, so no two processes disagree about it.
241
+ const baseUrl = resolveAuthUrl();
220
242
  if (!baseUrl) {
221
- logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set" });
243
+ logLine("recorder.disabled", { why: "no service address — run 'bir setup', or set BIR_AUTH_URL" });
222
244
  return new NullRecorder();
223
245
  }
224
246
  // A proxy runs inside the host's process tree with its stdio bound to the
@@ -814,6 +814,23 @@ What actually constrains it:
814
814
  If that is not enough for a deployment, the mitigation is a real one and is out of scope here:
815
815
  evaluate the logic in a `node:vm` context with a frozen, minimal global. Filed as §20 question 3.
816
816
 
817
+ **A hand-edited step is the same code, and runs the same way.** Since 2026-09-25 (the service's
818
+ `editSteps.md`, not yet released) the owner — or Claude working for them — can change a step's
819
+ logic with `bir scenario edit`. The service checks the change against the recording before it saves
820
+ it, but that check is about *what the code computes*. Once saved, the step arrives in the same
821
+ payload and runs here unattended, with `new Function`, next to the same credentials, exactly like
822
+ calculated code. The provenance argument still holds: only the owner can edit, over their own token,
823
+ and anyone else — an admin included — gets `404`. What changes is that *"the model wrote it"* is no
824
+ longer always true.
825
+
826
+ The service now sandboxes its own evaluation of every logic body: one worker thread with an empty
827
+ environment and a memory cap, a fresh `node:vm` context per evaluation with a time limit and nothing
828
+ but JavaScript's own built-ins, values in and out as JSON text only. It had to: there, one account's
829
+ code runs in the process that holds every account's data and the service's secrets. **The runner
830
+ still does not sandbox.** Here the code runs on the owner's machine, for the owner's account. One
831
+ consequence to know: code that uses `fetch`, `process` or a timer throws in the service's check, so
832
+ it cannot be saved without `--force`; a forced step would run here with all of them.
833
+
817
834
  ### 13.2 Replay bypasses permission prompts
818
835
 
819
836
  This is the sharpest edge in the whole design, and it must not be buried.
@@ -1038,12 +1055,46 @@ bir uninstall --replay reverse exactly that, leaving the rest of the inst
1038
1055
 
1039
1056
  bir scenario list GET /recordings/runs runs, iterations, scenario state
1040
1057
  bir scenario show <runId> GET /recordings/runs/:id/scenario intent, params, steps, logic
1058
+ bir scenario show <scnId> GET /scenarios/:id the same, by scenario id (segments too)
1059
+ [--step <n>: one step]
1041
1060
  bir scenario calc <runId> POST /recordings/runs/:id/calculate [--force to re-derive in place]
1061
+ [--force --discard-edits: re-derive a plan with hand edits]
1042
1062
  bir scenario replay <scnId> --prompt "…" [--dry]
1043
1063
 
1064
+ bir scenario check <id> --step <n> POST /scenarios/:id/steps/:n/check try a change; writes nothing
1065
+ bir scenario edit <id> --step <n> PATCH /scenarios/:id/steps/:n the same check, then save
1066
+ bir scenario edits <id> GET /scenarios/:id/edits the history, newest first
1067
+ bir scenario undo <id> [--edit <e>] POST /scenarios/:id/edits/:e/revert put one step back
1068
+ bir scenario editing on|off|status (no route) ~/.baseinstrunner/installed.json which `bir` MCP tools are offered
1069
+
1044
1070
  bir replay --scenario <scnId> --prompt "…" [--dry] alias; the Tier 2 / debugging path (§12.1)
1045
1071
  ```
1046
1072
 
1073
+ The second group, and `show <scnId>`, `--step` and `--discard-edits`, were added on 2026-09-25 by
1074
+ the service's `editSteps.md` and are not yet released; their routes are new on the service. They
1075
+ are the one place `bir` changes a scenario, so the rules are strict:
1076
+
1077
+ - **Owner only.** Anyone else, an admin included, gets `404`, never `403`.
1078
+ - **A change is saved only if it reproduces the recording and is not a copy**, or on purpose with
1079
+ `--force --note "<why>"`. `check` and `edit` run the same check; `check` never writes.
1080
+ - `<id>` is a `run_` id, resolved to its whole-run scenario with `GET /recordings/runs/:id/scenario`,
1081
+ or a `scn_` id, used as it is. Logic goes in with `--input-logic` / `--output-logic` from a file,
1082
+ or `-` for stdin — never inline, because shell quoting mangles JavaScript. `edit` also takes
1083
+ `--freeze`, `--unfreeze`, `--note` and `--revision <n>` (the `expectedRevision` of the body).
1084
+ - `undo` without `--edit` undoes the newest edit that can be undone. Every save and every undo bumps
1085
+ the scenario's `chainRevision`.
1086
+ - `calc --force` answers `409 scenario_has_edits` on a plan with hand edits, unless
1087
+ `--discard-edits` is given.
1088
+ - Exit codes: `0` checked OK, saved or undone; `1` refused, not found, or a service error; `2`
1089
+ usage. `--json` prints the service's body unchanged.
1090
+
1091
+ The same commands are tools on the `bir` MCP server. `scenario_show`, `scenario_edits` and
1092
+ `investigate` only read, and are always offered. `scenario_check`, `scenario_edit` and
1093
+ `scenario_undo` are offered only in a project where `bir scenario editing on` was run, because that
1094
+ server runs in every session of every installed project. Each tool runs the installed `bir … --json`;
1095
+ a refusal comes back as an MCP error result carrying the report. The operator's walk-through is
1096
+ [calculatedReplayGuide.md](calculatedReplayGuide.md) §9.2.
1097
+
1047
1098
  `--dry` maps to BaseIn's `POST /scenarios/:id/replay`, which evaluates the stored logic against the
1048
1099
  source run's *recorded* outputs — no real tools, no side effects, one Haiku call. Without `--dry`,
1049
1100
  `bir replay` runs the same plan through the same executor against the live proxies. `bir doctor`