@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
@@ -2,7 +2,11 @@
2
2
  /**
3
3
  * bir-hooks — the Claude Code hook receiver and the control server (Tier 1).
4
4
  *
5
- * Run it in the project you will launch `claude` from, in its own terminal:
5
+ * Normally nobody runs it: `bir install` makes it the SessionStart hook
6
+ * (`bir-hooks ensure`), which starts it in the background for the project when no
7
+ * recorder is running there, and `bir up` / `bir down` start and stop that
8
+ * background process by hand. Its audit lines then go to
9
+ * ~/.baseinstrunner/logs/<project-key>.log. It can still be run in a terminal:
6
10
  *
7
11
  * bir-hooks # start; Ctrl-C to stop
8
12
  * bir-hooks 2>&1 | tee -a ~/.baseinstrunner/audit.log
@@ -13,7 +17,8 @@
13
17
  * 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
14
18
  *
15
19
  * Environment:
16
- * BIR_AUTH_URL BaseIn auth-service (required to record anything)
20
+ * BIR_AUTH_URL BaseIn auth-service. Unset, the address `bir setup`
21
+ * stored in ~/.baseinstrunner/config.json is used
17
22
  * BIR_CONTROL_PORT preferred port (default 53411; falls back if busy)
18
23
  * BIR_CORRELATION_DECISION `allow` (default) or `ask` — see below
19
24
  * BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
@@ -61,13 +66,19 @@
61
66
  * fingerprint matching, which is lossy under identical concurrent calls.
62
67
  */
63
68
  import { hostname } from "node:os";
69
+ import { Console } from "node:console";
70
+ import { createWriteStream } from "node:fs";
64
71
  import { ControlServer, DEFAULT_CONTROL_PORT } from "../control/server.js";
65
- import { removeDiscovery, writeDiscovery } from "../control/discovery.js";
66
- import { authenticate, clearCredentials, normalizeAuthUrl } from "../auth/client.js";
72
+ import { HEARTBEAT_MS, removeDiscovery, writeDiscovery, } from "../control/discovery.js";
73
+ import { DAEMON_ENV, DAEMON_LOG_ENV } from "../control/daemon.js";
74
+ import { runEnsureHook } from "../control/ensure-hook.js";
75
+ import { ENSURE_ARG } from "../config/adapters/claude-code.js";
76
+ import { authenticate, clearCredentials, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
77
+ import { packageVersion } from "../util/version.js";
67
78
  import { NullRecorder } from "../record/recorder.js";
68
79
  import { RemoteRecorder } from "../record/remote-recorder.js";
69
80
  import { parseAllowList } from "../replay/coverage.js";
70
- import { readSidecar } from "../config/generate.js";
81
+ import { projectRecord, projectToken, readSidecar } from "../config/generate.js";
71
82
  import { resolveServers } from "../config/resolve.js";
72
83
  import { isWrapped } from "../config/generate.js";
73
84
  import { logLine, errText } from "../util/log.js";
@@ -89,9 +100,14 @@ function wrappedServersFor(cwd) {
89
100
  }
90
101
  }
91
102
  async function buildRecorder() {
92
- const configured = (process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
103
+ // BIR_AUTH_URL, or the address 'bir setup' stored. Read the same way by the
104
+ // proxies and the CLI, so a recorder started by a hook — with whatever
105
+ // environment the host had — still knows where the steps go.
106
+ const configured = resolveAuthUrl();
93
107
  if (!configured) {
94
- logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set — running without recording" });
108
+ logLine("recorder.disabled", {
109
+ why: "no service address — run 'bir setup', or set BIR_AUTH_URL; running without recording",
110
+ });
95
111
  return { recorder: new NullRecorder() };
96
112
  }
97
113
  // A plain-http URL fronted by a TLS proxy answers `308 → https`, and following
@@ -133,22 +149,26 @@ function parseIntentSources(value) {
133
149
  .filter((s) => allowed.includes(s));
134
150
  return new Set(wanted);
135
151
  }
136
- /**
137
- * Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
138
- *
139
- * The steer threshold defaults **above** the service's own detection threshold
140
- * (0.9). Detection decides "do not record this again", which is cheap — the worst
141
- * case is a lost recording. Steering decides "do not think about this again",
142
- * which is a much stronger claim, and a 0.90-similar prompt usually covers only
143
- * part of the live task.
144
- */
145
- function buildReplayOptions(auth) {
146
- const enabled = process.env.BIR_REPLAY !== "0";
147
- const allowServers = parseAllowList(process.env.BIR_REPLAY_ALLOW_SERVERS);
148
- const minSimilarity = Number(process.env.BIR_MIN_STEER_SIMILARITY);
152
+ function buildReplayOptions(auth, policy) {
153
+ // The environment still wins — an operator at a terminal is the most
154
+ // deliberate signal there is — but a recorder started by the SessionStart
155
+ // hook has Claude Code's environment, not that terminal's, and it reads the
156
+ // switches the project stored instead of forgetting them (generate.ts).
157
+ const envReplay = process.env.BIR_REPLAY;
158
+ const envAllow = process.env.BIR_REPLAY_ALLOW_SERVERS;
159
+ const envMin = Number(process.env.BIR_MIN_STEER_SIMILARITY);
160
+ const fromEnv = envReplay !== undefined || envAllow !== undefined || Number.isFinite(envMin);
161
+ const source = fromEnv ? "env" : policy ? "sidecar" : "default";
162
+ const enabled = envReplay !== undefined ? envReplay !== "0" : policy?.enabled !== false;
163
+ const allowServers = envAllow !== undefined
164
+ ? parseAllowList(envAllow)
165
+ : policy?.allowServers
166
+ ? parseAllowList(policy.allowServers.join(","))
167
+ : undefined;
168
+ const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
149
169
  const opts = {
150
170
  enabled,
151
- minSimilarity: Number.isFinite(minSimilarity) ? minSimilarity : 0.92,
171
+ minSimilarity,
152
172
  allowServers,
153
173
  budgets: {
154
174
  matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, 2_500),
@@ -194,10 +214,11 @@ function buildReplayOptions(auth) {
194
214
  ? "the service"
195
215
  : "recorded sample values — sign in, or a scenario with a target will not run",
196
216
  intentMatch: opts.intentMatch?.enabled ? "on" : "off",
217
+ source,
197
218
  why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
198
219
  });
199
220
  }
200
- return opts;
221
+ return { opts, source };
201
222
  }
202
223
  async function main() {
203
224
  const argv = process.argv.slice(2);
@@ -206,61 +227,126 @@ async function main() {
206
227
  process.stderr.write("[bir] logged out — cached credentials cleared.\n");
207
228
  return;
208
229
  }
230
+ if (argv[0] === ENSURE_ARG) {
231
+ // The SessionStart hook: start the recorder for this directory if there is
232
+ // none, then relay the payload. Always exit 0 (control/ensure-hook.ts).
233
+ process.exitCode = await runEnsureHook(process.cwd());
234
+ return;
235
+ }
209
236
  const cwd = process.cwd();
237
+ const daemon = process.env[DAEMON_ENV] === "1";
238
+ const logFile = daemon ? process.env[DAEMON_LOG_ENV] : undefined;
239
+ if (logFile) {
240
+ // A background recorder owns its log. Nothing was inherited from whoever
241
+ // started it (control/daemon.ts says why), so the audit lines — every
242
+ // `logLine` goes through `console.error` — are pointed at the file here.
243
+ const stream = createWriteStream(logFile, { flags: "a" });
244
+ globalThis.console = new Console({ stdout: stream, stderr: stream });
245
+ }
210
246
  const sidecar = readSidecar();
247
+ // The port this project's hooks were written with. An explicit BIR_CONTROL_PORT
248
+ // still wins; the per-project record is what lets two projects record at once.
249
+ const pinned = process.env.BIR_CONTROL_PORT !== undefined || projectRecord(sidecar, cwd) !== undefined;
250
+ const preferredPort = parsePort(process.env.BIR_CONTROL_PORT ??
251
+ String(projectRecord(sidecar, cwd)?.port ?? sidecar.controlPort ?? ""));
211
252
  // Open before anything logs: the journal is what `bir investigate` reads
212
253
  // later, and the startup lines are part of the story.
213
254
  const journalFile = openJournal(cwd);
214
255
  const auth = await buildRecorder();
256
+ const replay = buildReplayOptions(auth, projectRecord(sidecar, cwd)?.replay);
215
257
  const server = new ControlServer({
216
258
  recorder: auth.recorder,
217
259
  cwd,
218
- port: parsePort(process.env.BIR_CONTROL_PORT ?? String(sidecar.controlPort ?? "")),
219
- token: sidecar.token,
260
+ port: preferredPort,
261
+ // This project's token (generate.ts); the machine-wide one for a record
262
+ // written before tokens were per project.
263
+ token: projectToken(sidecar, cwd),
220
264
  wrappedServers: wrappedServersFor(cwd),
221
265
  host: { app: "claude-code" },
222
266
  correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
223
267
  noCorrelation: process.env.BIR_NO_CORRELATION === "1",
224
268
  deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
225
269
  intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
226
- replay: buildReplayOptions(auth),
270
+ replay: replay.opts,
271
+ replaySource: replay.source,
272
+ account: auth.session?.user.email,
273
+ // In the background with a port the hooks name, a fallback port is a
274
+ // recorder every hook but SessionStart would miss. Exit with a reason instead.
275
+ portFallback: !(daemon && pinned),
276
+ // 'bir down', and the SessionStart hook replacing an out-of-date recorder,
277
+ // ask over HTTP rather than sending a signal: on Windows a signal is a kill.
278
+ onStopRequested: () => void shutdown(),
227
279
  });
228
- const address = await server.listen();
229
- const discoveryFile = writeDiscovery({
280
+ let address;
281
+ try {
282
+ address = await server.listen();
283
+ }
284
+ catch (err) {
285
+ const code = err.code;
286
+ if (code === "EADDRINUSE" || code === "EACCES") {
287
+ logLine("control.port_busy", {
288
+ port: preferredPort,
289
+ code,
290
+ fix: `port ${preferredPort} is held by another program or excluded by the system — run \`bir setup --port <another>\` in this directory`,
291
+ });
292
+ process.exit(1);
293
+ }
294
+ throw err;
295
+ }
296
+ const info = {
230
297
  url: address.url,
231
298
  token: address.token,
232
299
  sessionId: address.sessionId,
233
300
  pid: process.pid,
234
301
  startedAt: Date.now(),
235
302
  cwd,
236
- });
303
+ version: packageVersion(),
304
+ ...(daemon ? { daemon: true, logFile: process.env[DAEMON_LOG_ENV] } : {}),
305
+ };
306
+ const discoveryFile = writeDiscovery(info);
307
+ // A background recorder outlives the discovery file's 24-hour freshness
308
+ // window; the heartbeat is what keeps it findable (discovery.ts).
309
+ const heartbeat = setInterval(() => {
310
+ try {
311
+ writeDiscovery({ ...info, heartbeatAt: Date.now() });
312
+ }
313
+ catch (err) {
314
+ logLine("control.heartbeat_failed", { error: errText(err) });
315
+ }
316
+ }, HEARTBEAT_MS);
317
+ heartbeat.unref();
237
318
  logLine("control.listening", {
238
319
  url: address.url,
239
320
  cwd,
240
321
  discovery: discoveryFile,
241
322
  journal: journalFile,
242
323
  machine: hostname(),
324
+ version: packageVersion(),
325
+ background: daemon ? "yes" : undefined,
243
326
  wrapped: wrappedServersFor(cwd).join(",") || "(none)",
244
327
  });
245
- if (sidecar.controlPort && sidecar.controlPort !== address.port) {
246
- // The hook URLs in settings.json name a port; if we could not take it, the
247
- // hooks will POST into the void. Say so rather than looking healthy.
248
- logLine("control.port_mismatch", {
249
- installed: sidecar.controlPort,
250
- actual: address.port,
251
- why: "hooks point at the installed port — re-run `bir install` or free that port",
252
- });
328
+ if (preferredPort !== DEFAULT_CONTROL_PORT || sidecar.controlPort || projectRecord(sidecar, cwd)) {
329
+ if (preferredPort !== address.port) {
330
+ // The hook URLs in settings.json name a port; if we could not take it,
331
+ // the hooks will POST into the void. Say so rather than looking healthy.
332
+ logLine("control.port_mismatch", {
333
+ installed: preferredPort,
334
+ actual: address.port,
335
+ why: "hooks point at the installed port — re-run `bir install` or free that port",
336
+ });
337
+ }
253
338
  }
254
339
  let closing = false;
255
- const shutdown = async () => {
340
+ async function shutdown() {
256
341
  if (closing)
257
342
  return;
258
343
  closing = true;
344
+ clearInterval(heartbeat);
259
345
  await server.close().catch((err) => logLine("control.close_failed", { error: errText(err) }));
260
346
  removeDiscovery(cwd);
261
347
  logLine("control.stopped", {});
262
348
  process.exit(0);
263
- };
349
+ }
264
350
  process.on("SIGINT", () => void shutdown());
265
351
  process.on("SIGTERM", () => void shutdown());
266
352
  process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bir-scenario — a first-party MCP server with exactly one tool
4
- * (docs/calculatedReplay.md §6.3).
3
+ * bir-scenario — the first-party `bir` MCP server (docs/calculatedReplay.md §6.3):
4
+ * `run_scenario`, plus the tools that read and fix a calculated scenario
5
+ * (editSteps.md in the BaseIn repository; docs/calculatedReplayGuide.md §9.2).
5
6
  *
6
7
  * WHY THIS EXISTS. On a fully-wrapped match the whole scenario runs server-side,
7
8
  * through proxies that are already connected, and the model's only job is to read
@@ -17,6 +18,21 @@
17
18
  * control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
18
19
  * mode 0600, bearer token — and forwards one call.
19
20
  *
21
+ * THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
22
+ * the rest are the `bir scenario …` commands, spawned from this same package
23
+ * with `--json` (D1: one path, whether a person types it or the model calls
24
+ * it). That keeps this server credential-free — `bir` signs in the way it
25
+ * always does — and means the model sees exactly the answer a person would.
26
+ * Logic bodies travel through temporary files, never argv, and a non-zero exit
27
+ * is an `isError` result that carries the output, refusal and report included.
28
+ *
29
+ * The tools that CHANGE a scenario are offered only where a person ran
30
+ * `bir scenario editing on` (D2): this server runs in every session of every
31
+ * installed project, a fleet included, and a plan must not change because some
32
+ * agent there decided it should. The switch is read when the host lists the
33
+ * tools, and again on every call, so a call to a tool that was never offered —
34
+ * or was offered before the switch went off — is refused rather than run.
35
+ *
20
36
  * STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
21
37
  * stream, and every log line goes to stderr instead.
22
38
  */