@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
package/dist/bin/bir.js CHANGED
@@ -2,6 +2,8 @@
2
2
  /**
3
3
  * bir — the command line (Phase 7).
4
4
  *
5
+ * bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
6
+ * bir up [--restart] | bir down
5
7
  * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
6
8
  * bir uninstall [--config <path>]
7
9
  * bir status
@@ -10,6 +12,16 @@
10
12
  * bir login | logout
11
13
  * bir --version
12
14
  *
15
+ * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId> [--force [--discard-edits]]
16
+ * bir scenario check|edit <runId|scnId> --step <n> --input-logic <file|-> … ← fix one step
17
+ * bir scenario edits | undo <runId|scnId> [--edit <sedit_id>]
18
+ * bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
19
+ * bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
20
+ * bir investigate [<id>] | list | executions
21
+ *
22
+ * The `scenario` words that read or change one step live in scenario-edit.ts
23
+ * (editSteps.md in the BaseIn repository); `investigate` in investigate.ts.
24
+ *
13
25
  * `bir install` is reversible by construction: every file it edits is stashed
14
26
  * verbatim first, so `bir uninstall` restores it byte-for-byte unless somebody
15
27
  * else edited it in the meantime (in which case it repairs the entries and says
@@ -18,21 +30,24 @@
18
30
  * from `serverInfo`, which must stay the upstream's (Phase 3).
19
31
  */
20
32
  import { randomBytes } from "node:crypto";
21
- import { existsSync, writeFileSync } from "node:fs";
33
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
22
34
  import { dirname, join, resolve as resolvePath } from "node:path";
23
35
  import { fileURLToPath } from "node:url";
24
36
  import { ControlClient } from "../control/client.js";
25
37
  import { readDiscovery } from "../control/discovery.js";
26
- import { configDir, discoveryPath } from "../control/paths.js";
27
- import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
28
- import { isRemote, resolveServers } from "../config/resolve.js";
29
- import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
38
+ import { ensureDaemon, stopDaemon } from "../control/daemon.js";
39
+ import { configDir, discoveryPath, normalizePath } from "../control/paths.js";
40
+ import { allocateFreeProjectPort, isScenarioServer, isWrapped, nodeFlagsToCarry, PACKAGE_NAME, packageSpec, projectRecord, readSidecar, scenarioEntry, setProjectRecord, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
41
+ import { runSetup } from "./setup.js";
42
+ import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
43
+ import { buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ensureGitExclude, fileForScope, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
30
44
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
31
- import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
45
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
32
46
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
33
47
  import { errText } from "../util/log.js";
34
48
  import { journalPath, readJournal } from "../util/journal.js";
35
49
  import { investigateCommand } from "./investigate.js";
50
+ import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.js";
36
51
  import { loadCredentials } from "../auth/client.js";
37
52
  import { packageVersion } from "../util/version.js";
38
53
  const VERSION = packageVersion();
@@ -43,6 +58,9 @@ function usage(code = 0) {
43
58
  out(`bir ${VERSION} — a recording MCP proxy
44
59
 
45
60
  Commands:
61
+ setup the one command: sign in, wire this project, start the recorder
62
+ up start the recorder for this project in the background (--restart: replace it)
63
+ down stop it
46
64
  install wrap this project's MCP servers and wire Claude Code's hooks
47
65
  uninstall restore everything install changed
48
66
  status what is installed for this directory
@@ -52,10 +70,24 @@ Commands:
52
70
  logout revoke this machine's session and forget it
53
71
 
54
72
  scenario list recorded runs and their calculated scenarios
55
- scenario show <runId> a run's scenario: intent, params, steps
56
- scenario calc <runId> [--force] calculate (or re-derive) a run's scenario
73
+ scenario show <runId|scnId> [--step <n>]
74
+ a scenario: intent, params, steps (--step: just that one)
75
+ scenario calc <runId> [--force [--discard-edits]]
76
+ calculate (or re-derive) a run's scenario
77
+ scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
78
+ try a change to one step against the recording; saves nothing
79
+ scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
80
+ [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
81
+ the same check, and save the change if it passes
82
+ scenario edits <runId|scnId> a scenario's hand edits, newest first
83
+ scenario undo <runId|scnId> [--edit <sedit_id>]
84
+ put a step back as it was before its newest edit
85
+ scenario editing on|off|status whether this project's bir MCP server offers the tools
86
+ that change a scenario (restart Claude Code after)
57
87
  scenario replay <scnId> --prompt "…" [--dry]
58
88
  replay --scenario <scnId> --prompt "…" [--dry]
89
+ replay status | on | off | allow <a,b> | allow all
90
+ this project's replay switches, kept for the background recorder
59
91
 
60
92
  investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
61
93
  it did, what it cost, what to fix — journal + service
@@ -69,17 +101,36 @@ Options:
69
101
  --local invoke this checkout's bir-proxy instead of npx (development)
70
102
  --global invoke the installed package by absolute path instead of npx
71
103
  (no registry round trip, no PATH lookup — what a fleet wants)
72
- --no-hooks do not touch .claude/settings.json (Tier 2 recording only)
104
+ --no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
73
105
  --no-correlation never relax tool schemas; join steps on argument fingerprints
74
106
  --replay install/remove the scenario server, enabling calculated replay
75
- --port <n> control-server port to write into the hook URLs (default ${DEFAULT_CONTROL_PORT})
76
- --json machine-readable output for status / doctor
107
+ --port <n> install/setup: control-server port to write into the hook URLs
108
+ (default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
109
+ --json machine-readable output for status / doctor / scenario / investigate
110
+ (scenario check|edit|edits|undo: the service's answer, unchanged)
77
111
  --dry replay against recorded outputs only; run no real tools
112
+ --force scenario calc: recalculate in place · scenario edit: save although
113
+ the check refused it (needs --note; only for a recording that was wrong)
114
+ --step <n> scenario show|check|edit: the step, by the stepIndex \`show\` prints
115
+ --input-logic <file|-> scenario check|edit: the new input logic, from a file or stdin (-)
116
+ --output-logic <file|-> scenario check|edit: the new output logic, from a file or stdin (-)
117
+ --freeze scenario edit: mark the step "needs a judgement" (runs hand over before it)
118
+ --unfreeze scenario check|edit: ask to remove that mark; the check decides
119
+ --note <text> scenario edit|undo: why, kept in the history (required with --force)
120
+ --revision <n> scenario edit|undo: only if the plan is still at this chainRevision
121
+ --edit <id> scenario undo: the sedit_ id to undo (default: the newest that can be)
122
+ --discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
78
123
  --no-browser login: print the link and code, open nothing (SSH, headless)
79
124
  --token <value> login: redeem a one-time setup token from the console (no browser)
80
125
  --password login: use the old email/password prompt (deprecated)
81
126
  --user <ref> investigate executions: another account, by email or id (admin)
82
- --limit <n> investigate: how many turns / executions to show`);
127
+ --limit <n> investigate: how many turns / executions to show
128
+ --auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
129
+ --project <dir> setup: the directory to record (default: this one)
130
+ --no-replay setup: wire recording only; add no scenario server
131
+ --allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
132
+ --no-daemon setup: wire everything, start nothing
133
+ --restart up: replace a running recorder`);
83
134
  process.exit(code);
84
135
  }
85
136
  function parseArgs(argv) {
@@ -98,6 +149,13 @@ function parseArgs(argv) {
98
149
  dry: false,
99
150
  force: false,
100
151
  password: false,
152
+ noReplay: false,
153
+ noDaemon: false,
154
+ restart: false,
155
+ portExplicit: false,
156
+ freeze: false,
157
+ unfreeze: false,
158
+ discardEdits: false,
101
159
  };
102
160
  for (let i = 1; i < argv.length; i += 1) {
103
161
  const arg = argv[i];
@@ -153,6 +211,31 @@ function parseArgs(argv) {
153
211
  break;
154
212
  case "--port":
155
213
  args.port = Number(argv[++i]);
214
+ args.portExplicit = true;
215
+ break;
216
+ case "--auth-url":
217
+ args.authUrl = argv[++i];
218
+ if (!args.authUrl)
219
+ usage(2);
220
+ break;
221
+ case "--project":
222
+ args.project = argv[++i];
223
+ if (!args.project)
224
+ usage(2);
225
+ break;
226
+ case "--no-replay":
227
+ args.noReplay = true;
228
+ break;
229
+ case "--allow-servers":
230
+ args.allowServers = argv[++i];
231
+ if (args.allowServers === undefined)
232
+ usage(2);
233
+ break;
234
+ case "--no-daemon":
235
+ args.noDaemon = true;
236
+ break;
237
+ case "--restart":
238
+ args.restart = true;
156
239
  break;
157
240
  case "--json":
158
241
  args.json = true;
@@ -169,6 +252,53 @@ function parseArgs(argv) {
169
252
  args.limit = n;
170
253
  break;
171
254
  }
255
+ case "--step": {
256
+ // Digits only: `Number("")` is 0 and `Number("0x4")` is 4, and a step
257
+ // index nobody typed is a step nobody meant to change.
258
+ const raw = argv[++i] ?? "";
259
+ if (!/^\d+$/.test(raw))
260
+ usage(2);
261
+ args.step = Number(raw);
262
+ break;
263
+ }
264
+ case "--input-logic":
265
+ // A value flag, so a bare `-` (stdin) is taken here and never reaches
266
+ // the default branch, which refuses anything that starts with a dash.
267
+ args.inputLogic = argv[++i];
268
+ if (!args.inputLogic)
269
+ usage(2);
270
+ break;
271
+ case "--output-logic":
272
+ args.outputLogic = argv[++i];
273
+ if (!args.outputLogic)
274
+ usage(2);
275
+ break;
276
+ case "--freeze":
277
+ args.freeze = true;
278
+ break;
279
+ case "--unfreeze":
280
+ args.unfreeze = true;
281
+ break;
282
+ case "--note":
283
+ args.note = argv[++i];
284
+ if (args.note === undefined)
285
+ usage(2);
286
+ break;
287
+ case "--edit":
288
+ args.edit = argv[++i];
289
+ if (!args.edit)
290
+ usage(2);
291
+ break;
292
+ case "--revision": {
293
+ const raw = argv[++i] ?? "";
294
+ if (!/^\d+$/.test(raw) || Number(raw) <= 0)
295
+ usage(2);
296
+ args.revision = Number(raw);
297
+ break;
298
+ }
299
+ case "--discard-edits":
300
+ args.discardEdits = true;
301
+ break;
172
302
  default:
173
303
  if (arg.startsWith("-"))
174
304
  usage(2);
@@ -176,6 +306,10 @@ function parseArgs(argv) {
176
306
  args.positionals.push(arg);
177
307
  }
178
308
  }
309
+ if (args.freeze && args.unfreeze) {
310
+ process.stderr.write("[bir] --freeze and --unfreeze are mutually exclusive\n");
311
+ process.exit(2);
312
+ }
179
313
  return args;
180
314
  }
181
315
  /** Absolute path to this package's built `bir-proxy.js`, for `--local`. */
@@ -186,6 +320,27 @@ function localProxyPath() {
186
320
  function localScenarioPath() {
187
321
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-scenario.js");
188
322
  }
323
+ /** Absolute path to this package's built `bir-hooks.js`, for the SessionStart hook. */
324
+ function localHooksPath() {
325
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
326
+ }
327
+ /** True when this `bir` runs from an installed package rather than a checkout. */
328
+ function runsFromInstalledPackage() {
329
+ return /[\\/]node_modules[\\/]/.test(localProxyPath());
330
+ }
331
+ /**
332
+ * The node the SessionStart hook will run. The real path, not the one this
333
+ * process was started through: a version manager's per-shell shim directory
334
+ * is gone when that shell is, and a hook that names it fails every session.
335
+ */
336
+ function hookNodePath() {
337
+ try {
338
+ return realpathSync(process.execPath);
339
+ }
340
+ catch {
341
+ return process.execPath;
342
+ }
343
+ }
189
344
  /**
190
345
  * Which invocation the generated entries use — and the guard that makes
191
346
  * `--global` mean something.
@@ -199,11 +354,20 @@ function localScenarioPath() {
199
354
  * that works today and breaks after a `git clean`. Hence the node_modules test:
200
355
  * it is the one cheap signal that says "somebody installed this".
201
356
  */
202
- function resolveInvocation(args) {
357
+ function resolveInvocation(args, remembered) {
203
358
  if (args.local && args.global) {
204
359
  process.stderr.write("[bir] --local and --global are mutually exclusive\n");
205
360
  process.exit(2);
206
361
  }
362
+ // Neither flag: what this project was installed with last time. `bir setup`
363
+ // installs with --global, and a later bare `bir install --replay` here must
364
+ // not quietly turn every entry into a pinned-npx one and drop the
365
+ // self-starting SessionStart hook with it.
366
+ if (!args.global && !args.local && remembered) {
367
+ if (remembered === "global" && !runsFromInstalledPackage())
368
+ return "local";
369
+ return remembered;
370
+ }
207
371
  if (!args.global)
208
372
  return args.local ? "local" : "npx";
209
373
  const proxy = localProxyPath();
@@ -229,13 +393,20 @@ function noteWritten(sidecar, file, text) {
229
393
  sidecar.files[file] = backup;
230
394
  }
231
395
  // ── install ────────────────────────────────────────────────────────────────
232
- function install(args) {
233
- const invocation = resolveInvocation(args);
396
+ async function install(args) {
234
397
  const cwd = process.cwd();
235
398
  const sidecar = readSidecar();
399
+ const invocation = resolveInvocation(args, projectRecord(sidecar, cwd)?.invocation);
236
400
  sidecar.token ??= randomBytes(32).toString("hex");
237
- sidecar.controlPort = args.port;
238
- const controlUrl = `http://127.0.0.1:${args.port}`;
401
+ // One port per project (generate.ts): an explicit --port is honoured, else
402
+ // the one this directory already has, else the lowest free one no other has.
403
+ const port = args.portExplicit ? args.port : await allocateFreeProjectPort(sidecar, cwd);
404
+ sidecar.controlPort = port;
405
+ // One token per project too: a token in this project's settings file opens
406
+ // this project's recorder, not every recorder on the machine.
407
+ const token = projectRecord(sidecar, cwd)?.token ?? randomBytes(32).toString("hex");
408
+ setProjectRecord(sidecar, cwd, { port, token, invocation });
409
+ const controlUrl = `http://127.0.0.1:${port}`;
239
410
  const entries = args.configPath
240
411
  ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
241
412
  name,
@@ -245,18 +416,52 @@ function install(args) {
245
416
  }))
246
417
  : resolveServers(cwd);
247
418
  const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
248
- if (wanted.length === 0) {
419
+ if (wanted.length === 0 && (args.configPath || !args.hooks)) {
420
+ // An explicit config file, or proxies-only: with no server there is nothing
421
+ // this install could change.
249
422
  out("No MCP servers found for this directory. Nothing to wrap.");
250
423
  if (!args.configPath) {
251
424
  out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
252
425
  }
253
426
  return 0;
254
427
  }
428
+ if (wanted.length === 0) {
429
+ // Hooks alone are a recorder: built-in tools, the prompt and the answer are
430
+ // all theirs, and a scenario over built-in steps steers a session the same
431
+ // way. Stopping here used to leave such a project with nothing at all.
432
+ out("No MCP servers to wrap here (looked in ~/.claude.json and ./.mcp.json).");
433
+ out(" Hooks are still wired: built-in tools and prompts are recorded.");
434
+ }
255
435
  const paths = claudeCodePaths(cwd);
436
+ /**
437
+ * Does a generated entry still name the runner that is running now? After
438
+ * an upgrade, a Node moved by a version manager, or a switch from the
439
+ * machine's npm prefix to the private one, the old absolute path (or the old
440
+ * pinned npx version) is what the host would keep spawning — and "already
441
+ * wrapped" is the last thing that should be printed about it.
442
+ */
443
+ const nodeFlags = nodeFlagsToCarry();
444
+ const stillCurrent = (config, scriptPath) => {
445
+ if (isRemote(config))
446
+ return false;
447
+ const argv = config.args ?? [];
448
+ if (invocation === "npx")
449
+ return config.command === "npx" && argv.includes(packageSpec(VERSION));
450
+ const at = argv.indexOf(scriptPath);
451
+ if (config.command !== process.execPath || at < 0)
452
+ return false;
453
+ // The node flags in front of the script must be the ones this run carries.
454
+ return argv.slice(0, at).join(" ") === nodeFlags.join(" ");
455
+ };
256
456
  let wrapped = 0;
257
457
  for (const entry of wanted) {
258
458
  if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
259
- out(` = ${entry.name} — the scenario server (calculated replay)`);
459
+ if (!args.replay || stillCurrent(entry.config, localScenarioPath())) {
460
+ out(` = ${entry.name} — the scenario server (calculated replay)`);
461
+ }
462
+ else {
463
+ out(` ~ ${entry.name} — re-writing the scenario server: its runner moved`);
464
+ }
260
465
  continue;
261
466
  }
262
467
  if (entry.name === SCENARIO_SERVER_KEY) {
@@ -277,15 +482,18 @@ function install(args) {
277
482
  // spawns), so `env` is present in practice; the cast is only to narrow the
278
483
  // remote-or-stdio union.
279
484
  const current = entry.config.env?.BIR_CONTROL_URL;
280
- const stale = args.hooks && !args.configPath && current !== controlUrl;
485
+ const urlStale = args.hooks && !args.configPath && current !== controlUrl;
486
+ const pathStale = !stillCurrent(entry.config, localProxyPath());
281
487
  const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
282
- if (!stale || !stashed) {
488
+ if (!(urlStale || pathStale) || !stashed) {
283
489
  out(` = ${entry.name} — already wrapped`);
284
490
  continue;
285
491
  }
492
+ const was = `${entry.config.command} ${(entry.config.args ?? [])[0] ?? ""}`.trim();
286
493
  entry.config = stashed;
287
- out(` ~ ${entry.name} — re-wrapping: its control URL was ` +
288
- `${current ?? "unset"}, now ${controlUrl}`);
494
+ out(urlStale
495
+ ? ` ~ ${entry.name} — re-wrapping: its control URL was ${current ?? "unset"}, now ${controlUrl}`
496
+ : ` ~ ${entry.name} — re-wrapping: its runner moved (was ${was})`);
289
497
  }
290
498
  // Phase 7.3: writing anywhere but the winning scope changes nothing.
291
499
  if (entry.shadowed.length > 0) {
@@ -299,6 +507,7 @@ function install(args) {
299
507
  invocation,
300
508
  proxyPath: invocation === "npx" ? undefined : localProxyPath(),
301
509
  version: VERSION,
510
+ nodeFlags,
302
511
  });
303
512
  let file;
304
513
  let text;
@@ -336,6 +545,7 @@ function install(args) {
336
545
  scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
337
546
  version: VERSION,
338
547
  controlUrl: args.hooks ? controlUrl : undefined,
548
+ nodeFlags,
339
549
  });
340
550
  // PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
341
551
  //
@@ -373,17 +583,47 @@ function install(args) {
373
583
  };
374
584
  out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
375
585
  }
586
+ // SessionStart starts the recorder itself when this directory has none
587
+ // (control/ensure-hook.ts) — but only when the binary can be named by an
588
+ // absolute path. A pinned-npx install keeps the HTTP hook: resolving a
589
+ // package inside a hook is too slow to start a recorder from.
590
+ const ensure = invocation === "npx" ? undefined : { node: hookNodePath(), hooksScript: localHooksPath(), nodeFlags };
376
591
  if (args.hooks && !args.configPath) {
377
- backupFile(sidecar, paths.settings);
378
- const text = installHooks(paths.settings, buildHooksBlock(controlUrl, sidecar.token));
379
- noteWritten(sidecar, paths.settings, text);
380
- sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settings])];
381
- out(` + hooks → ${paths.settings} (control server on ${controlUrl})`);
592
+ // Hooks live in `.claude/settings.local.json` — the person's own file, the
593
+ // one Claude Code keeps out of git — not in the `settings.json` a team
594
+ // commits. They carry a bearer token and an absolute path to this machine's
595
+ // node; a teammate's clone wants neither. An older install put them in the
596
+ // shared file; take them out of there on the way.
597
+ if (hasBirHooks(paths.settings)) {
598
+ backupFile(sidecar, paths.settings);
599
+ const cleaned = uninstallHooks(paths.settings);
600
+ if (cleaned !== undefined)
601
+ noteWritten(sidecar, paths.settings, cleaned);
602
+ out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
603
+ }
604
+ backupFile(sidecar, paths.settingsLocal);
605
+ let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
606
+ if (args.replay) {
607
+ // Pre-approve our own scenario server. Claude Code asks before it uses a
608
+ // server from `.mcp.json`, and a "No" there leaves a direct replay with
609
+ // nowhere to deliver its results — and nothing to say so.
610
+ text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
611
+ }
612
+ noteWritten(sidecar, paths.settingsLocal, text);
613
+ sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
614
+ out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}${ensure ? "; the recorder starts itself with each session" : ""})`);
615
+ const exclude = ensureGitExclude(cwd);
616
+ if (exclude)
617
+ out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude}`);
382
618
  }
383
619
  writeSidecar(sidecar);
384
620
  out();
385
621
  out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
386
- if (args.hooks && !args.configPath) {
622
+ if (args.hooks && !args.configPath && ensure) {
623
+ out("Next: start your session here as usual. The recorder starts itself with it;");
624
+ out("`bir up` starts it now, `bir down` stops it, `bir doctor` checks it.");
625
+ }
626
+ else if (args.hooks && !args.configPath) {
387
627
  out("Next: run `bir-hooks` in this directory, then start your session.");
388
628
  out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
389
629
  }
@@ -392,8 +632,8 @@ function install(args) {
392
632
  }
393
633
  if (args.replay) {
394
634
  out();
395
- out("Calculated replay is INSTALLED and ON by default once `bir-hooks` runs. Set");
396
- out("BIR_REPLAY=0 where you run `bir-hooks` to turn it off — and read");
635
+ out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
636
+ out("`bir replay allow <servers>` narrows what it may call unattended — read");
397
637
  out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
398
638
  out("and a directly executed one never reaches the permission system at all.");
399
639
  }
@@ -470,12 +710,24 @@ function uninstall(args) {
470
710
  continue;
471
711
  if (existsSync(settings)) {
472
712
  uninstallHooks(settings);
713
+ disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
473
714
  out(` ← hooks removed from ${settings}`);
474
715
  }
475
716
  }
717
+ // An install older than 0.2.9 wrote to the shared settings file; make sure
718
+ // nothing of ours is left there either, whether or not it was recorded.
719
+ if (!restoredWhole.has(paths.settings) && hasBirHooks(paths.settings)) {
720
+ uninstallHooks(paths.settings);
721
+ out(` ← hooks removed from ${paths.settings}`);
722
+ }
476
723
  sidecar.hookFiles = [];
477
724
  sidecar.files = {};
725
+ if (sidecar.projects)
726
+ delete sidecar.projects[normalizePath(cwd)];
478
727
  writeSidecar(sidecar);
728
+ const exclude = removeGitExclude(cwd);
729
+ if (exclude)
730
+ out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
479
731
  out();
480
732
  out("Uninstalled.");
481
733
  return 0;
@@ -511,8 +763,12 @@ function status(args) {
511
763
  return 0;
512
764
  }
513
765
  out(`Directory : ${report.cwd}`);
514
- out(`BaseIn : ${report.authUrl ?? "(BIR_AUTH_URL not set — nothing is recorded)"}`);
515
- out(`Control : ${discovery ? `${discovery.url} (pid ${discovery.pid})` : "not running"}`);
766
+ out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
767
+ out(`Control : ${discovery
768
+ ? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
769
+ : "not running — `bir up` starts it"}`);
770
+ if (discovery?.logFile)
771
+ out(`Log : ${discovery.logFile}`);
516
772
  out("Servers :");
517
773
  if (report.servers.length === 0)
518
774
  out(" (none configured for this directory)");
@@ -526,6 +782,7 @@ function status(args) {
526
782
  async function doctor(args) {
527
783
  const cwd = process.cwd();
528
784
  const problems = [];
785
+ const notes = [];
529
786
  const entries = resolveServers(cwd);
530
787
  const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
531
788
  const discovery = readDiscovery(cwd);
@@ -536,13 +793,24 @@ async function doctor(args) {
536
793
  problems.push(`control server at ${discovery.url} did not answer /health`);
537
794
  }
538
795
  else {
539
- problems.push("no control server for this directory — recording will be Tier 2 (MCP only)");
796
+ problems.push("no control server for this directory — recording will be Tier 2 (MCP only); " +
797
+ "`bir up` starts one, and the SessionStart hook starts one with each session");
540
798
  }
541
799
  const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
800
+ // A recorder with no session yet is the normal state right after `bir setup`
801
+ // and between sessions: the proxies are Claude Code's child processes, so
802
+ // there is nothing to register until a session starts. Reporting that as a
803
+ // problem made the first `bir doctor` a person ever ran exit non-zero on a
804
+ // machine where nothing was wrong.
805
+ const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
542
806
  // The guarantee check: a *registered* proxy. Not the config file (which only
543
807
  // says what should happen) and not `serverInfo` (which stays the upstream's).
544
- for (const name of wantWrapped) {
545
- if (!registered.has(name)) {
808
+ const unregistered = wantWrapped.filter((name) => !registered.has(name));
809
+ if (unregistered.length > 0 && idleRecorder) {
810
+ notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
811
+ }
812
+ else {
813
+ for (const name of unregistered) {
546
814
  problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
547
815
  }
548
816
  }
@@ -558,14 +826,16 @@ async function doctor(args) {
558
826
  // its own `BIR_AUTH_URL` is not evidence about either, and treating it as
559
827
  // evidence produced a confident "nothing will be recorded" on a session that
560
828
  // was recording perfectly. Ask the recorder; it is the only process that knows.
561
- const notes = [];
562
829
  if (health) {
563
830
  if (health.recording === false) {
564
- problems.push("the recorder is running but has nowhere to send steps — set BIR_AUTH_URL " +
565
- "and run `bir login` in the terminal where you start `bir-hooks`");
831
+ problems.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
832
+ "(or set BIR_AUTH_URL and `bir login`), then `bir up --restart`");
566
833
  }
567
834
  else {
568
- notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
835
+ notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
836
+ }
837
+ if (discovery?.daemon) {
838
+ notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
569
839
  }
570
840
  // Whether a recurring sub-task the service found may actually run mid-task
571
841
  // (segmented.md R-LIFE-8). Observe-only is the default and must be visible
@@ -599,6 +869,29 @@ async function doctor(args) {
599
869
  problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
600
870
  `\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
601
871
  }
872
+ // THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
873
+ // per-shell directory, an uninstalled Node, a moved private Node: the hook
874
+ // then fails on every session and the recorder is never started, which
875
+ // records Tier 2 and looks like nothing at all.
876
+ const paths = claudeCodePaths(cwd);
877
+ for (const file of [paths.settingsLocal, paths.settings]) {
878
+ let parsed;
879
+ try {
880
+ parsed = parseJsonFile(file);
881
+ }
882
+ catch {
883
+ continue;
884
+ }
885
+ for (const matcher of parsed?.hooks?.SessionStart ?? []) {
886
+ for (const hook of matcher.hooks ?? []) {
887
+ if (hook.type !== "command" || !hook.command || !isBirHook(hook))
888
+ continue;
889
+ if (!existsSync(hook.command)) {
890
+ problems.push(`the SessionStart hook in ${file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
891
+ }
892
+ }
893
+ }
894
+ }
602
895
  // THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
603
896
  // at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
604
897
  // `bir` entry that starts with `npx` has to resolve and unpack the package
@@ -633,7 +926,8 @@ async function doctor(args) {
633
926
  const replay = health?.replay;
634
927
  if (replay?.enabled) {
635
928
  out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
636
- `minSimilarity=${replay.minSimilarity ?? "?"}`);
929
+ `minSimilarity=${replay.minSimilarity ?? "?"}` +
930
+ (replay.source ? ` source=${replay.source}` : ""));
637
931
  // An older control server sends only `deriveKey`; read it as the two states
638
932
  // it could describe then.
639
933
  const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
@@ -649,7 +943,7 @@ async function doctor(args) {
649
943
  "matched scenario with a target is declined and the agent does the task");
650
944
  }
651
945
  const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
652
- if (idle.length > 0) {
946
+ if (idle.length > 0 && !idleRecorder) {
653
947
  notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
654
948
  "their steps will fall back to recorded outputs");
655
949
  }
@@ -755,51 +1049,59 @@ async function scenarioCommand(args) {
755
1049
  }
756
1050
  return 0;
757
1051
  }
758
- if (sub === "show") {
759
- if (!target) {
760
- out("usage: bir scenario show <runId>");
761
- return 2;
762
- }
763
- const { status, body } = await service("GET", `/recordings/runs/${target}/scenario`);
764
- if (status === 404) {
765
- out("No scenario for that run yet — `bir scenario calc <runId>` first.");
766
- return 1;
767
- }
768
- if (status !== 200) {
769
- out(`Could not read the scenario (HTTP ${status}).`);
770
- return 1;
771
- }
772
- out(JSON.stringify(body, null, 2));
773
- return 0;
774
- }
775
- if (sub === "calc") {
776
- if (!target) {
777
- out("usage: bir scenario calc <runId> [--force]");
778
- return 2;
779
- }
780
- const { status, body } = await service("POST", `/recordings/runs/${target}/calculate`, args.force ? { force: true } : {});
781
- if (status === 202) {
782
- const b = body;
783
- out(`Calculating ${b.scenarioId ?? ""} — poll with \`bir scenario show ${target}\`.`);
784
- return 0;
785
- }
786
- if (status === 409) {
787
- out("That run already has a scenario. Re-derive it in place with --force.");
788
- return 1;
789
- }
790
- if (status === 503) {
791
- out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
792
- return 1;
793
- }
794
- out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
795
- return 1;
1052
+ // show, calc, and everything that reads or changes one step (editSteps.md).
1053
+ if (SCENARIO_EDIT_SUBCOMMANDS.has(sub)) {
1054
+ return scenarioEditCommand({
1055
+ positionals: args.positionals,
1056
+ json: args.json,
1057
+ force: args.force,
1058
+ step: args.step,
1059
+ inputLogic: args.inputLogic,
1060
+ outputLogic: args.outputLogic,
1061
+ freeze: args.freeze,
1062
+ unfreeze: args.unfreeze,
1063
+ note: args.note,
1064
+ edit: args.edit,
1065
+ revision: args.revision,
1066
+ discardEdits: args.discardEdits,
1067
+ }, {
1068
+ cwd: process.cwd(),
1069
+ out,
1070
+ service: async (method, path, body) => {
1071
+ try {
1072
+ return await service(method, path, body);
1073
+ }
1074
+ catch (err) {
1075
+ return { error: errText(err) };
1076
+ }
1077
+ },
1078
+ readFile: (path) => readFileSync(resolvePath(process.cwd(), path)),
1079
+ readStdin,
1080
+ });
796
1081
  }
797
1082
  if (sub === "replay") {
798
1083
  return replayCommand({ ...args, scenarioId: args.scenarioId ?? target });
799
1084
  }
800
- out("usage: bir scenario <list|show|calc|replay> …");
1085
+ out("usage: bir scenario <list|show|calc|check|edit|edits|undo|editing|replay> …");
801
1086
  return 2;
802
1087
  }
1088
+ /**
1089
+ * All of stdin, for `--input-logic -`. A person at a terminal gets told how to
1090
+ * end it; a pipe never sees the line (it goes to stderr, and only on a TTY).
1091
+ */
1092
+ function readStdin() {
1093
+ if (process.stdin.isTTY) {
1094
+ process.stderr.write("[bir] reading the logic from stdin — end it with Ctrl-D (Ctrl-Z, Enter on Windows)\n");
1095
+ }
1096
+ return new Promise((resolve, reject) => {
1097
+ const chunks = [];
1098
+ process.stdin.on("data", (chunk) => {
1099
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
1100
+ });
1101
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks)));
1102
+ process.stdin.on("error", reject);
1103
+ });
1104
+ }
803
1105
  /**
804
1106
  * `--dry` evaluates the scenario's stored logic against the **source run's
805
1107
  * recorded outputs**, server-side: no real tools, no side effects, one Haiku
@@ -810,7 +1112,63 @@ async function scenarioCommand(args) {
810
1112
  * Without `--dry` the same plan runs against the live proxies through the
811
1113
  * control server — the Tier 2 and debugging path (docs/calculatedReplay.md §12.1).
812
1114
  */
1115
+ /**
1116
+ * `bir replay status | on | off | allow <a,b> | allow all` — this project's
1117
+ * replay switches, stored in the sidecar so a recorder started by the
1118
+ * SessionStart hook keeps them (generate.ts, `ReplayPolicy`).
1119
+ */
1120
+ function replayPolicyCommand(args) {
1121
+ const cwd = process.cwd();
1122
+ const sidecar = readSidecar();
1123
+ const record = projectRecord(sidecar, cwd);
1124
+ const word = args.positionals[0];
1125
+ const describe = (policy) => {
1126
+ out(`Replay policy for ${cwd}`);
1127
+ out(` replay ${policy?.enabled === false ? "off" : "on"}`);
1128
+ out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
1129
+ out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
1130
+ const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
1131
+ if (overrides.length) {
1132
+ out(` (set in this shell's environment, which a recorder started from it would prefer: ${overrides.join(", ")})`);
1133
+ }
1134
+ if (!record)
1135
+ out(" (this directory has not been installed — `bir setup` here first)");
1136
+ out("Restart the recorder to apply a change: `bir up --restart`.");
1137
+ };
1138
+ if (word === "status") {
1139
+ describe(record?.replay);
1140
+ return 0;
1141
+ }
1142
+ if (!record) {
1143
+ out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
1144
+ return 1;
1145
+ }
1146
+ const next = { ...(record.replay ?? {}) };
1147
+ if (word === "on")
1148
+ delete next.enabled;
1149
+ else if (word === "off")
1150
+ next.enabled = false;
1151
+ else if (word === "allow") {
1152
+ const list = args.positionals[1];
1153
+ if (!list) {
1154
+ out("usage: bir replay allow <server,server> | bir replay allow all");
1155
+ return 2;
1156
+ }
1157
+ if (list === "all")
1158
+ delete next.allowServers;
1159
+ else
1160
+ next.allowServers = list.split(",").map((s) => s.trim()).filter(Boolean);
1161
+ }
1162
+ setProjectRecord(sidecar, cwd, { port: record.port, replay: next });
1163
+ writeSidecar(sidecar);
1164
+ describe(next);
1165
+ return 0;
1166
+ }
813
1167
  async function replayCommand(args) {
1168
+ const word = args.positionals[0];
1169
+ if (word === "status" || word === "on" || word === "off" || word === "allow") {
1170
+ return replayPolicyCommand(args);
1171
+ }
814
1172
  const scenarioId = args.scenarioId ?? args.positionals[0];
815
1173
  if (!scenarioId || !args.prompt) {
816
1174
  out('usage: bir replay --scenario <scnId> --prompt "…" [--dry]');
@@ -972,6 +1330,65 @@ async function main() {
972
1330
  }
973
1331
  },
974
1332
  });
1333
+ case "setup": {
1334
+ // The invocation is decided here, not asked: an installed package gets
1335
+ // absolute paths (--global), a checkout points at itself (--local).
1336
+ const installed = runsFromInstalledPackage();
1337
+ return runSetup({
1338
+ authUrl: args.authUrl,
1339
+ token: args.token,
1340
+ project: args.project,
1341
+ browser: args.browser,
1342
+ replay: !args.noReplay,
1343
+ daemon: !args.noDaemon,
1344
+ allowServers: args.allowServers,
1345
+ }, {
1346
+ out,
1347
+ err: (line) => process.stderr.write(`[bir] ${line}\n`),
1348
+ install: ({ replay }) => install({ ...args, global: installed, local: !installed, replay, hooks: true, servers: [] }),
1349
+ hooksScript: localHooksPath(),
1350
+ });
1351
+ }
1352
+ case "up": {
1353
+ const cwd = process.cwd();
1354
+ if (args.restart)
1355
+ await stopDaemon(cwd);
1356
+ const { status, started, restarted } = await ensureDaemon(cwd, {
1357
+ hooksScript: localHooksPath(),
1358
+ restartOnSkew: true,
1359
+ });
1360
+ const { info } = status;
1361
+ out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
1362
+ out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
1363
+ if (info.logFile)
1364
+ out(` log ${info.logFile}`);
1365
+ if (status.health.recording === false) {
1366
+ out(" !! it has no session to record with — run `bir setup` or `bir login`, then `bir up --restart`");
1367
+ return 1;
1368
+ }
1369
+ return 0;
1370
+ }
1371
+ case "down": {
1372
+ const cwd = process.cwd();
1373
+ const before = readDiscovery(cwd);
1374
+ if (!before) {
1375
+ out("No recorder is running for this directory.");
1376
+ return 0;
1377
+ }
1378
+ const result = await stopDaemon(cwd);
1379
+ if (result.stale) {
1380
+ out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
1381
+ return 0;
1382
+ }
1383
+ if (!result.stopped) {
1384
+ out(`The recorder (pid ${before.pid}) did not stop.`);
1385
+ return 1;
1386
+ }
1387
+ out(result.forced
1388
+ ? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
1389
+ : `Recorder stopped (pid ${before.pid}).`);
1390
+ return 0;
1391
+ }
975
1392
  case "login": {
976
1393
  // Settle the URL the way every other command does, then refuse to go on
977
1394
  // when what is there is not the service. "Signed in" must mean the service
@@ -1041,6 +1458,9 @@ async function main() {
1041
1458
  }
1042
1459
  if (!session)
1043
1460
  return 1;
1461
+ // From here on no terminal needs BIR_AUTH_URL: the address a session was
1462
+ // just issued by is the one every process should use.
1463
+ rememberAuthUrl(baseUrl);
1044
1464
  out(`Signed in to ${baseUrl} as ${session.user.email}.`);
1045
1465
  return 0;
1046
1466
  }