@basein/runner 0.2.8 → 0.2.10

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.
@@ -10,9 +10,9 @@ bare to recording, how it gets updated, and what to do when there is no registry
10
10
  to reach. For one person setting up one machine, [quickstart.md](quickstart.md)
11
11
  is shorter and assumes nothing.
12
12
 
13
- **Status.** Everything here is implemented and verified except §7: headless
14
- authentication is still a stopgap, and it is the one open item that needs a
15
- service-side decision rather than a change here.
13
+ **Status.** Everything here is implemented except the last item of §7: a
14
+ non-interactive grant for machines with no person attached needs a service-side
15
+ decision rather than a change here.
16
16
 
17
17
  ---
18
18
 
@@ -22,10 +22,10 @@ service-side decision rather than a change here.
22
22
  |---|---|---|
23
23
  | Runtime dependencies | **none** | The tarball is self-contained. No lockfile resolution, no transitive CVEs, no native builds, and offline install just works. |
24
24
  | Module format | pure ESM (`"type": "module"`) | No dual-build gymnastics. |
25
- | Node floor | `>=20` | One prerequisite to check on a runner. |
25
+ | Node floor | `>=20` | The one prerequisite — and the served line installs a private copy when the machine has none, or only one the hook must not pin: a version-managed, Homebrew or snap Node, or one with no npm beside it (§5.1). |
26
26
  | Build | `tsc` → `dist/`, `prepublishOnly` wired | `npm publish` cannot ship a stale `dist/`. |
27
27
  | Bins | `bir`, `bir-proxy`, `bir-hooks`, `bir-scenario`, all with `#!/usr/bin/env node` | npm creates the shims, including `.cmd`/`.ps1` on Windows. |
28
- | Tarball | 97 files, 230 kB | `dist/` + types + docs. Sourcemaps and `docs/next.md` are excluded. |
28
+ | Tarball | 120 files, ~390 kB | `dist/` + types + docs + `scripts/` (the install scripts, so a tarball can bootstrap an offline machine). Sourcemaps and `docs/next.md` are excluded. |
29
29
 
30
30
  A zero-dependency, pure-JS, four-bin package is the best-behaved thing npm
31
31
  distributes. The delivery does not need to be clever.
@@ -34,7 +34,7 @@ distributes. The delivery does not need to be clever.
34
34
 
35
35
  `files` carries two negations:
36
36
 
37
- - `!dist/**/*.map` — 88 files, 287 kB. The maps carry no `sourcesContent`, and
37
+ - `!dist/**/*.map` — 106 files, ~490 kB. The maps carry no `sourcesContent`, and
38
38
  `src/` is not published, so they cannot resolve a single stack frame. They were
39
39
  bytes that did nothing on every install and every `npx` cold start.
40
40
  - `!docs/next.md` — a working scratchpad, not documentation.
@@ -59,7 +59,7 @@ documentation exists.
59
59
 
60
60
  The package is public; the repository is private. Those are separate decisions
61
61
  and this project makes them differently. Nothing operational leaks either way —
62
- the service is gated by `BIR_AUTH_URL` plus credentials.
62
+ the service is gated by its address plus credentials.
63
63
 
64
64
  ---
65
65
 
@@ -154,31 +154,159 @@ Three invocation modes, one flag each:
154
154
 
155
155
  | Mode | Flag | Entry |
156
156
  |---|---|---|
157
- | **npx** | *(default)* | `npx -y -p @basein/runner@0.1.0 bir-proxy …` |
158
- | **global** | `--global` | `<node> <prefix>/node_modules/@basein/runner/dist/bin/bir-proxy.js …` |
157
+ | **npx** | *(default)* | `npx -y -p @basein/runner@0.2.9 bir-proxy …` |
158
+ | **global** | `--global` | `<node> <prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js …` (Windows: `<prefix>/node_modules/@basein/runner/…`) |
159
159
  | **local** | `--local` | `<node> <this checkout>/dist/bin/bir-proxy.js …` |
160
160
 
161
+ `<node>` is the absolute path of the Node that ran the install, symlinks
162
+ resolved. Under the served line (§5.1) that is the machine's own Node when it
163
+ qualifies (20 or newer, not under a version manager, npm beside it), else the
164
+ private Node in `~/.baseinstrunner/node/v<V>/`; `<prefix>` is
165
+ `~/.baseinstrunner/npm` either way. By hand it is whatever `npm i -g` used.
166
+ npm's global layout differs by platform:
167
+ the package is at `<prefix>/lib/node_modules/@basein/runner` on macOS and Linux
168
+ and at `<prefix>/node_modules/@basein/runner` on Windows.
169
+
161
170
  **The npx spec is pinned.** It was not, and that was the one real defect in the
162
171
  original packaging story: two runners provisioned a week apart could run
163
172
  different proxy versions against the same service, with nothing in a recording to
164
173
  say which.
165
174
 
166
- **`--global` is what a fleet wants**, and it is what the bootstrap scripts pass.
167
- It removes the registry from the hot path entirely: no resolution before the MCP
168
- server can start, no dependence on the MCP host seeing the same `PATH` as the
169
- shell that installed things — which GUI-launched hosts on Windows do not — and
170
- it works with no network at all.
175
+ **`--global` is what a fleet wants**, and it is what `bir setup` uses: from an
176
+ installed package it runs `bir install --global --replay` in-process (from a
177
+ checkout, `--local`, since `--global` refuses one). It removes the registry from the hot
178
+ path entirely: no resolution before the MCP server can start, no dependence on
179
+ the MCP host seeing the same `PATH` as the shell that installed things — which
180
+ GUI-launched hosts on Windows do not — and it works with no network at all.
171
181
 
172
182
  It also survives upgrades. The path
173
- `<prefix>/node_modules/@basein/runner/dist/bin/bir-proxy.js` contains no version,
174
- so `npm i -g @basein/runner@0.2.0` changes what that path points at without
175
- changing the path. **A pinned-npx install, by contrast, must re-run `bir install`
176
- on every upgrade** to move the pin.
183
+ `<prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js` contains no
184
+ version, so `npm i -g @basein/runner@<version>` changes what that path points at
185
+ without changing the path. **A pinned-npx install, by contrast, must re-run
186
+ `bir install` on every upgrade** to move the pin.
187
+
188
+ A flag-less `bir install` keeps the invocation the project was installed with
189
+ (`invocation` in its record, §4.3): a `bir install --replay` after `bir setup`
190
+ keeps the absolute paths and the command SessionStart hook instead of quietly
191
+ turning every entry into a pinned-npx one.
192
+
193
+ **An entry whose runner moved is re-wrapped.** `bir install` compares each
194
+ wrapped entry — its Node path, its proxy path, or its pinned npx version — with
195
+ the package it is running from, and regenerates any that disagree from the
196
+ stashed original: `~ fleet — re-wrapping: its runner moved (was …)`. The
197
+ scenario entry gets the same check. That is what lets `bir setup` be the upgrade
198
+ path even when the package changed prefix (from the machine's npm prefix to the
199
+ private one) or the Node moved (a version manager, a reinstall), instead of
200
+ printing "already wrapped" about an entry the host would keep spawning from a
201
+ path that is gone.
177
202
 
178
203
  `--global` refuses to run from a checkout: it asserts a path that outlives the
179
204
  shell, and a checkout's `dist/` moves with the checkout. Use `--local` when you
180
205
  mean the checkout on purpose.
181
206
 
207
+ ### 4.1 Hooks go to `.claude/settings.local.json`
208
+
209
+ Claude Code's hooks are written to `<project>/.claude/settings.local.json`, not
210
+ to the `.claude/settings.json` a team commits. They carry a bearer token and an
211
+ absolute path to this machine's Node; neither belongs in a teammate's clone.
212
+ Inside a git repository `bir` adds the line `.claude/settings.local.json` to
213
+ `.git/info/exclude` — the person's own, untracked list, never a `.gitignore` the
214
+ team would see in a diff — and says so. A worktree's `gitdir:` pointer is
215
+ followed. Outside a repository, or when the exclude file cannot be written, that
216
+ is a note, not a failed install.
217
+
218
+ An install that finds `bir` hooks in a legacy `.claude/settings.json` removes
219
+ them from there on the way (`- hooks removed from … (they live in
220
+ settings.local.json now)`). `bir uninstall` cleans both files and takes the
221
+ `.claude/settings.local.json` line out of `.git/info/exclude` again. It restores
222
+ a file byte-for-byte only when nothing else edited it since the install; a file
223
+ it created — usually `settings.local.json` itself — stays behind, empty of our
224
+ entries.
225
+
226
+ With `--replay`, `bir install` does two more things: the `bir` scenario server
227
+ goes into `.mcp.json` (project scope — never `~/.claude.json`, whose per-project
228
+ map Claude Code keys with forward slashes, so an entry written from a Windows
229
+ cwd lands under a key it never reads), and `bir` is merged into
230
+ `enabledMcpjsonServers` in `settings.local.json`, so Claude Code never shows its
231
+ approval dialog for the scenario server. A "No" there leaves a direct replay
232
+ with nowhere to deliver its results, and nothing to say so.
233
+ `bir uninstall --replay` removes only the scenario server; the proxies, the
234
+ hooks and the pre-approval stay as they are.
235
+
236
+ ### 4.2 SessionStart is a command hook
237
+
238
+ For `--global` and `--local` installs the SessionStart hook is a command hook in
239
+ Claude Code's exec form — `command` spawned directly with `args` as its argument
240
+ vector, no shell in between, so a path with a space needs no quoting and it runs
241
+ the same under Git Bash, PowerShell and cmd.exe:
242
+
243
+ ```json
244
+ { "type": "command", "command": "<node>", "args": ["<dist>/bin/bir-hooks.js", "ensure"], "timeout": 30 }
245
+ ```
246
+
247
+ `bir-hooks ensure` reads the SessionStart payload from stdin, starts the
248
+ recorder for this directory in the background when none answers `/health`, and
249
+ relays the payload to it. It *replaces* the HTTP SessionStart hook rather than
250
+ sitting beside it: Claude Code runs every hook of an event in parallel, and an
251
+ HTTP hook racing the daemon it depends on would fail on every first session of
252
+ the day. It prints nothing on stdout — which Claude Code adds to the model's
253
+ context — unless the control server answered with something, and it always exits
254
+ 0: a recorder that would not start is one line on stderr and a session that
255
+ records Tier 2, exactly what happened before the hook existed.
256
+
257
+ Every other event (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`,
258
+ `SessionEnd`, …) stays an HTTP hook aimed at the control server, with the
259
+ project's bearer token in its `Authorization` header.
260
+
261
+ A pinned-npx install keeps the HTTP SessionStart hook: resolving a package
262
+ inside a hook is too slow to start a recorder from, so those installs need a
263
+ recorder somebody started (`bir up`).
264
+
265
+ `bir doctor` checks that the hook's `command` still exists and, when it does
266
+ not, reports:
267
+
268
+ ```
269
+ the SessionStart hook in <file> points at a Node that is gone (<command>) — run `bir setup` here again
270
+ ```
271
+
272
+ That is the failure a version manager's per-shell directory produces, and it is
273
+ why the bootstrap script installs a private Node rather than pin one that lives
274
+ under `fnm_multishells` or `.nvm` (§5.1).
275
+
276
+ ### 4.3 One port and one token per project
277
+
278
+ `~/.baseinstrunner/installed.json` keeps a record per project directory:
279
+
280
+ ```
281
+ projects[<normalised cwd>] = { port, token, replay, invocation }
282
+ ```
283
+
284
+ **Port.** The first project on a machine gets 53411; the next gets the lowest
285
+ port above it that no other project holds *and* that is free right now — it is
286
+ bound once to check, because 53411 sits inside the ranges Hyper-V and WSL
287
+ reserve on Windows, and a recorder told to use an excluded port exits while every
288
+ session records Tier 2. Once recorded the port is kept; the hooks name it.
289
+ `bir setup --port <n>` (or `bir install --port <n>`) overrides. A *background*
290
+ recorder — the one the hook, `bir up` or `bir setup` starts — for a project with
291
+ a recorded port (or with `BIR_CONTROL_PORT` set) does not fall back to an
292
+ ephemeral one: it exits 1 with `control.port_busy` on its log, whose `fix` reads
293
+ "port N is held by another program or excluded by the system — run `bir setup
294
+ --port <another>` in this directory", and the hook turns that into one stderr
295
+ line, rather than let the project's hooks post to whatever else is listening
296
+ there. `bir-hooks` run by hand in a terminal still falls back to an ephemeral
297
+ port, and says so on the same log line. Two projects used to share 53411 and one
298
+ project's steps landed in the other's run; this is the fix.
299
+
300
+ **Invocation.** `global`, `local` or `npx` — what the entries were generated
301
+ with, so a later flag-less `bir install` keeps the same shape (§4).
302
+
303
+ **Token.** Generated once per project, so a token in one project's settings
304
+ file opens that project's recorder and no other. A record written by 0.2.8 has
305
+ none and falls back to the machine-wide `token` in the same file.
306
+
307
+ **Replay.** The switches `bir replay on|off|allow …` and
308
+ `bir setup --allow-servers` store, read by the recorder at start (§5.1).
309
+
182
310
  ---
183
311
 
184
312
  ## 5. Provisioning a runner
@@ -188,72 +316,194 @@ runs":
188
316
 
189
317
  | Scope | How often | What it does |
190
318
  |---|---|---|
191
- | **machine** | once | Node ≥ 20, and the package installed globally |
192
- | **account** | once per user | `BIR_AUTH_URL`, then `bir login` (approved in a browser) → `~/.baseinstrunner/credentials.json` (0600) |
193
- | **project** | once per repo | `bir install --global` rewrites that project's MCP entries and wires the hooks |
194
- | **session** | while recording | `bir-hooks` running, discovered by `sha256(normalised cwd)` |
319
+ | **machine** | once | Node ≥ 20 and the package. The served line uses the machine's Node when it qualifies and otherwise puts a private one under `~/.baseinstrunner/node/`; the package goes under `~/.baseinstrunner/npm/`, and `bir` / `bir-hooks` launchers in `~/.baseinstrunner/bin/`, first on the user PATH |
320
+ | **account** | once per user | the sign-in → `~/.baseinstrunner/credentials.json` (0600); the service address → `~/.baseinstrunner/config.json`. `bir setup` stores both; so does `bir login` |
321
+ | **project** | once per repo | `bir setup` in that directory: `bir install --global --replay` (`--local` from a checkout) rewrites the MCP entries, wires the hooks into `.claude/settings.local.json`, records the port, token and invocation |
322
+ | **session** | while recording | the recorder — `bir-hooks` as a background process — started by the SessionStart hook, discovered by `sha256(normalised cwd)` |
195
323
 
196
- That cwd keying is why `bir-hooks` must start in the *same directory* the agent
324
+ That cwd keying is why the recorder must run in the *same directory* the agent
197
325
  session runs in. A control server started elsewhere is invisible, and the run
198
- silently degrades to Tier 2.
326
+ silently degrades to Tier 2. It is also why the hook is what starts it: the hook
327
+ already runs in the right directory, with the right environment, at exactly the
328
+ right moment.
199
329
 
200
330
  ### 5.1 One command
201
331
 
202
- [`scripts/install-runner.ps1`](../scripts/install-runner.ps1) and
203
- [`scripts/install-runner.sh`](../scripts/install-runner.sh) do machine, account
204
- and project scope together:
332
+ The console's *Set up the runner* page shows one line, with a fresh one-time
333
+ setup token filled in, to paste into a terminal opened in the project folder:
205
334
 
206
335
  ```powershell
207
- .\scripts\install-runner.ps1 C:\work\repo -AuthUrl https://basein.example.com
336
+ $env:BIR_SETUP_TOKEN="<token>"; irm https://api.bi2202.com/install.ps1 | iex
208
337
  ```
209
338
 
210
- ```bash
211
- BIR_AUTH_URL=https://basein.example.com ./scripts/install-runner.sh ~/work/repo
339
+ ```cmd
340
+ powershell -NoProfile -Command "$env:BIR_SETUP_TOKEN='<token>'; irm https://api.bi2202.com/install.ps1 | iex"
212
341
  ```
213
342
 
214
- Both are idempotent, both validate everything checkable **before** changing
215
- anything (a failed run leaves no half-provisioned machine), both skip `bir login`
216
- when a session is already cached, and both accept a local tarball
217
- (`-Tarball` / `BIR_TARBALL`) instead of the registry.
343
+ ```bash
344
+ curl -fsSL https://api.bi2202.com/install.sh | BIR_SETUP_TOKEN="<token>" sh
345
+ ```
218
346
 
219
- Two things they deliberately do not do: supervise `bir-hooks`, and automate
220
- authentication. See §5.2 and §7.
347
+ The first is Windows PowerShell, the second the same line from cmd.exe (a
348
+ terminal opened from Start is often cmd), the third macOS and Linux. Without the
349
+ variable the line still installs everything; `bir setup` then opens the browser
350
+ for the device flow — even under the scripts, where its output is a pipe — and
351
+ over SSH prints the link instead (§7).
352
+
353
+ The scripts are [`scripts/install.ps1`](../scripts/install.ps1) and
354
+ [`scripts/install.sh`](../scripts/install.sh); they replace
355
+ `install-runner.ps1` and `install-runner.sh`, and they ship in the tarball. The
356
+ API serves copies at `/install.ps1` and `/install.sh` with two placeholders
357
+ filled in: `__BIR_AUTH_URL__` (the service's `PUBLIC_API_URL`, else the origin
358
+ the request arrived on) and `__BIR_VERSION__` (`RUNNER_VERSION`, default
359
+ `latest`). Run straight from the repository or the tarball the placeholders are
360
+ unreplaced, which counts as unset: `BIR_AUTH_URL` must then be in the
361
+ environment, and the version is `latest`.
362
+
363
+ What they do, in order, the same on every platform:
364
+
365
+ 0. **Stop before any download** if the current directory is the home folder or
366
+ a drive root: "This is your home folder. Open a terminal in the project you
367
+ start Claude Code in (cd there) and paste the line again." Nothing is
368
+ installed, nobody is signed in and the token is unused, so the fix is
369
+ exactly that — `cd` to the project and paste the *same* line
370
+ (`BIR_PROJECT=<dir>` names the project instead; the script `cd`s there
371
+ first). Move `BIR_SETUP_TOKEN` out of the environment into a local variable,
372
+ so npm, the Claude Code installer and the `claude` the person starts next
373
+ never see it.
374
+ 1. **Node.** A Node ≥ 20 already on PATH is used by its real binary — unless
375
+ that binary is one the hook must not pin: under a version manager's
376
+ per-shell or per-version directory (nvm, fnm, Volta, asdf, mise and nodenv
377
+ on every platform; Scoop on Windows) or, on macOS and Linux, a Homebrew Node
378
+ (`*/Cellar/*` — a `brew upgrade` moves the path the hook pinned) or a snap
379
+ one. Both scripts also insist on npm's `npm-cli.js` beside the binary
380
+ (`lib/node_modules/npm/bin/` on macOS and Linux, `node_modules\npm\bin\` on
381
+ Windows). Otherwise a private Node is installed instead: pinned version,
382
+ digest embedded in the script and checked, unpacked to
383
+ `~/.baseinstrunner/node/v<V>/`, never put on PATH. The SessionStart hook
384
+ pins an absolute node path, and a path that changes with the shell is a
385
+ hook that fails every session. `BIR_NODE_ARCHIVE=<file>` uses a downloaded
386
+ archive instead of nodejs.org.
387
+ 2. **The package.** `@basein/runner@<version>` into the private prefix
388
+ `~/.baseinstrunner/npm`, always — never the machine's npm prefix, which is
389
+ root-owned under a `.pkg` or apt Node and per-version under nvm, Volta, Scoop
390
+ or asdf, where a global install vanishes on the next switch.
391
+ `BIR_TARBALL=<file.tgz>` instead of the registry. Launchers go into
392
+ `~/.baseinstrunner/bin`, which is put first on the user PATH; in PowerShell
393
+ `bir` then resolves to `bir.cmd`, so there is no execution-policy change,
394
+ ever. The PowerShell window the line was pasted into gets `bir` in place —
395
+ the script's last two lines are "bir works in every new terminal (and in
396
+ this window when it is PowerShell); `bir doctor` checks the setup" and the
397
+ cmd hint below — but the cmd variant
398
+ runs the script in a child `powershell.exe`, so a cmd window sees `bir` only
399
+ once it is a new window, or after the `set PATH=…` line the script prints
400
+ for it. On macOS and Linux the script prints the
401
+ `export PATH="$HOME/.baseinstrunner/bin:$PATH"` line for the terminal it ran
402
+ in (with `~/.local/bin` added when it just installed Claude Code); every new
403
+ terminal has it from the profile.
404
+ 3. **Claude Code**, through its official installer, when `claude` is not on
405
+ PATH. `BIR_NO_CLAUDE=1` skips it. It needs a Claude subscription and asks to
406
+ sign in the first time it starts.
407
+ 4. **`bir setup --auth-url <url> --project <cwd>`**, with the token in that
408
+ child's environment only. It signs in, stores the service address, runs
409
+ `bir install --global --replay`, starts the recorder in the background, and
410
+ prints a summary that names the account, the wrapped servers (`(no MCP
411
+ servers here)` and "steer mode only" mean none in *any* scope — `.mcp.json`,
412
+ and the user and local scopes of `~/.claude.json`), the recorder's pid and
413
+ log file, and the one thing left to do: `claude`.
414
+
415
+ Both scripts are idempotent, neither prompts (under `curl | sh` stdin is the
416
+ script itself), and `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` and
417
+ `NODE_EXTRA_CA_CERTS` pass through — to the scripts' own downloads and to every
418
+ node and npm they run. The one fetch they cannot reach is the `irm` that fetches
419
+ the script itself: Windows PowerShell 5.1's `irm` uses the system (WinINET)
420
+ proxy, not `HTTPS_PROXY`. `bir setup` exits 1 when the recorder reports it has
421
+ no session to record with, so a provisioning script can gate on the exit code.
422
+
423
+ The scripts never reach `bir setup` from the home folder (step 0). `bir setup`
424
+ run *by hand* there, with the runner already installed, is different: it keeps
425
+ the sign-in, wires nothing, prints the two commands left — `cd <project>` and
426
+ `bir setup` — and exits 0. The second needs no token; the session is cached.
427
+ That is also how a second project on the same machine is added.
428
+
429
+ Fleet switches on `bir setup`: `--allow-servers a,b` stores the servers replay
430
+ may call unattended for this project; `bir replay allow|on|off|status` changes
431
+ it later. Both live in the project record (§4.3), so a recorder the hook starts
432
+ — with Claude Code's environment, not the operator's terminal — keeps them;
433
+ `BIR_REPLAY`, `BIR_REPLAY_ALLOW_SERVERS` and `BIR_MIN_STEER_SIMILARITY` in the
434
+ recorder's environment still override, and `bir doctor` says which source is in
435
+ effect. `--no-replay` records without the scenario server; `--no-daemon` wires
436
+ everything and starts nothing; `--no-browser` prints the sign-in link without
437
+ opening one.
438
+
439
+ Two things the scripts deliberately do not do: supervise the recorder (the hook
440
+ does, §5.2) and mint the token (the console does, §7).
221
441
 
222
442
  The PowerShell script is **pure ASCII** on purpose. Windows PowerShell 5.1
223
443
  decodes a BOM-less `.ps1` using the machine's ANSI codepage, and PowerShell
224
444
  accepts curly quotes as string delimiters — so a UTF-8 em-dash in a comment can
225
445
  decode into a quote and swallow the rest of the file. It parsed fine on one
226
- machine and failed on another before this was fixed. Keep it ASCII.
446
+ machine and failed on another before this was fixed. Keep it ASCII. It also
447
+ never calls `exit` — under `iex` that closes the person's window — and it sets
448
+ strict mode and `$ErrorActionPreference` inside a function so neither leaks into
449
+ the caller's session.
450
+
451
+ ### 5.2 Keeping the recorder alive
452
+
453
+ Not needed. The SessionStart hook is `bir-hooks ensure` (§4.2): every Claude
454
+ Code session in the project starts the recorder when there is none, in the
455
+ background, so a recorder that died or was never started is running by the first
456
+ prompt. Nothing has to stay open. `bir up` starts it by hand, `bir up --restart`
457
+ replaces it, `bir down` stops it — it asks first (`POST /control/stop`, which
458
+ finishes the run being written), signals only a pid that answered `/health` with
459
+ the token, and says "forced" when it had to — and `bir status` and `bir doctor`
460
+ show its pid and log. Two Claude Code windows opened together take a lock, so
461
+ only one spawns.
462
+
463
+ On POSIX the daemon is a detached spawn with its stdio ignored. On Windows it is
464
+ launched through `Start-Process -WindowStyle Hidden` in a short-lived
465
+ `powershell.exe`, because a node started under a PowerShell layer carries an
466
+ extra copy of its stdout pipe and a daemon that inherited it would hold that pipe
467
+ open for days.
468
+
469
+ Its audit lines go to `~/.baseinstrunner/logs/<project-key>.log`: the daemon
470
+ opens the file itself, appends, and rolls it to `.1` when it starts and the
471
+ file is past 8 MB. `bir status` names the file. The old shape still works —
472
+ `bir-hooks` in a terminal writes them to that terminal — and is what the hook
473
+ does for you.
227
474
 
228
- ### 5.2 Keeping `bir-hooks` alive
475
+ ```bash
476
+ grep version.skew ~/.baseinstrunner/logs/*.log
477
+ grep run.lossy ~/.baseinstrunner/logs/*.log
478
+ ```
229
479
 
230
- One process per project cwd. On a headless runner, supervise it rather than
231
- leaving it in a shell.
480
+ What the hook does not give you is restart-on-crash mid-session, which the
481
+ proxies already survive (they fall to Tier 2 and say so). A service manager is
482
+ optional. If you want one — a machine that records SDK sessions with nobody
483
+ starting Claude Code, or a recorder up before the first session — have it run
484
+ `bir up` in the project directory at logon, and nothing else: `bir up` returns
485
+ once the recorder answers and leaves it running, so a supervisor must not expect
486
+ a long-lived process. The three recipes below are recipes — nothing the package
487
+ ships or its tests cover.
232
488
 
233
489
  **Windows** — a Scheduled Task at logon, with *Start in* set to the project
234
- directory:
490
+ directory (`bir.cmd` is the launcher the served line writes; a by-hand install
491
+ has `bir` wherever npm put it):
235
492
 
236
493
  ```powershell
237
- $action = New-ScheduledTaskAction -Execute 'bir-hooks' -WorkingDirectory 'C:\work\repo'
494
+ $action = New-ScheduledTaskAction -Execute "$env:USERPROFILE\.baseinstrunner\bin\bir.cmd" -Argument 'up' -WorkingDirectory 'C:\work\repo'
238
495
  $trigger = New-ScheduledTaskTrigger -AtLogOn
239
- Register-ScheduledTask -TaskName 'bir-hooks (repo)' -Action $action -Trigger $trigger
496
+ Register-ScheduledTask -TaskName 'bir up (repo)' -Action $action -Trigger $trigger
240
497
  ```
241
498
 
242
- **macOS** — a launchd agent at `~/Library/LaunchAgents/com.basein.hooks.plist`,
243
- with `WorkingDirectory` set to the project and `StandardErrorPath` to a log file,
244
- then `launchctl load` it.
245
-
246
- **Linux** — a systemd user unit with `WorkingDirectory=`, plus
247
- `loginctl enable-linger "$USER"` so it survives logout.
499
+ **macOS** — a launchd agent at `~/Library/LaunchAgents/com.basein.up.plist`
500
+ running `$HOME/.baseinstrunner/bin/bir up` with `WorkingDirectory` set to the
501
+ project and `RunAtLoad` (not `KeepAlive`), then `launchctl load` it.
248
502
 
249
- Whichever you use, redirect stderr to a file. Every decision is one `key=value`
250
- line on stderr and there is no file sink to configure, so the supervisor's
251
- redirection *is* the sink:
252
-
253
- ```bash
254
- grep version.skew ~/.baseinstrunner/audit.log
255
- grep run.lossy ~/.baseinstrunner/audit.log
256
- ```
503
+ **Linux** — a systemd user unit, `Type=oneshot` with `RemainAfterExit=yes` (so
504
+ the unit does not stop and take the recorder down with it), `ExecStart=%h/.baseinstrunner/bin/bir up`,
505
+ `WorkingDirectory=` the project, plus `loginctl enable-linger "$USER"` so it
506
+ runs without a login session.
257
507
 
258
508
  ### 5.3 Verify
259
509
 
@@ -263,25 +513,54 @@ bir doctor --json # exits non-zero if it is not working right now
263
513
 
264
514
  `bir doctor` asks the control server which proxies actually registered; it never
265
515
  reads the config file back. That makes it the right gate for a provisioning
266
- script. `bir status` only describes intent.
516
+ script. `bir status` only describes intent. `bir doctor` also says when there is
517
+ no recorder ("no control server for this directory — recording will be Tier 2
518
+ (MCP only); `bir up` starts one, and the SessionStart hook starts one with each
519
+ session"), when the recorder has nowhere to record to ("the recorder is running
520
+ but has nowhere to send steps — run `bir setup` (or set BIR_AUTH_URL and
521
+ `bir login`), then `bir up --restart`"), and when the SessionStart hook names a
522
+ Node that is gone (§4.2).
267
523
 
268
524
  ---
269
525
 
270
526
  ## 6. Machines with no registry
271
527
 
272
528
  Zero dependencies makes offline install trivial — no `npm ci --offline`, no
273
- vendored `node_modules`:
529
+ vendored `node_modules`. The install script ships in the tarball, takes a local
530
+ tarball instead of the registry and a local Node archive instead of nodejs.org,
531
+ and can skip the Claude Code installer:
274
532
 
275
533
  ```bash
276
534
  # on a connected machine
277
- npm pack @basein/runner@0.1.0 # or download the Release asset
535
+ npm pack @basein/runner@<version> # or download the Release asset
536
+ tar -xzf basein-runner-<version>.tgz package/scripts
537
+ # plus the Node archive for the runner's platform from nodejs.org/dist, if the
538
+ # runner has no Node ≥ 20 (the script names the pinned version, 24.21.0)
278
539
 
279
540
  # on the runner
280
- BIR_TARBALL=./basein-runner-0.1.0.tgz ./scripts/install-runner.sh ~/work/repo
541
+ BIR_AUTH_URL=https://api.example.com \
542
+ BIR_TARBALL=./basein-runner-<version>.tgz \
543
+ BIR_NODE_ARCHIVE=./node-v24.21.0-linux-x64.tar.gz \
544
+ BIR_NO_CLAUDE=1 sh ./package/scripts/install.sh
281
545
  ```
282
546
 
283
- The bootstrap scripts already pass `--global`, so the resulting configs contain
284
- absolute paths and never reach for a registry. Nothing further is needed.
547
+ ```powershell
548
+ $env:BIR_AUTH_URL='https://api.example.com'; $env:BIR_TARBALL='.\basein-runner-<version>.tgz'; $env:BIR_NODE_ARCHIVE='.\node-v24.21.0-win-x64.zip'; $env:BIR_NO_CLAUDE=1; Get-Content -Raw .\package\scripts\install.ps1 | iex
549
+ ```
550
+
551
+ `<version>` is whatever the registry holds (`npm view @basein/runner version`).
552
+ The PowerShell line pipes the file through `iex`, as the served line does: a
553
+ `.ps1` invoked by path is subject to the execution policy, and a default client
554
+ (Restricted) refuses it with "running scripts is disabled". The script is
555
+ written to be run that way — everything inside one function, no `exit`.
556
+
557
+ `BIR_AUTH_URL` is required here because the raw script's placeholder is
558
+ unreplaced (§5.1). Claude Code has to come from somewhere too; `BIR_NO_CLAUDE=1`
559
+ only stops the script from fetching it. The service itself must be reachable —
560
+ "no registry" is not "no network" — or the sign-in has nothing to talk to.
561
+
562
+ `bir setup` passes `--global`, so the resulting configs contain absolute paths
563
+ and never reach for a registry. Nothing further is needed.
285
564
 
286
565
  Note that a private repository's Release assets require a GitHub token to fetch,
287
566
  so the tarball has to be carried to the runner by whatever means you already use.
@@ -292,30 +571,80 @@ so the tarball has to be carried to the runner by whatever means you already use
292
571
 
293
572
  `bir login` signs in through the browser — the OAuth 2.0 device grant (RFC 8628,
294
573
  [loginWeb.md](loginWeb.md)) — and caches
295
- `{accessToken, refreshToken, accessExpiresAt, user}` at
296
- `~/.baseinstrunner/credentials.json`, mode 0600.
574
+ `{accessToken, refreshToken, accessExpiresAt, user, authUrl}` at
575
+ `~/.baseinstrunner/credentials.json`, mode 0600. `bir setup` does the same
576
+ sign-in and stores the service address beside it in
577
+ `~/.baseinstrunner/config.json`, so no process afterwards needs `BIR_AUTH_URL`
578
+ (it still overrides when set).
579
+
580
+ **The setup token.** The served line carries `BIR_SETUP_TOKEN`: a one-time token
581
+ minted by the console's *Set up the runner* page — ten-minute TTL, single use,
582
+ hashed at rest, and minting a new one retires the person's earlier unused ones.
583
+ It travels as an environment-variable assignment rather than an argument so that
584
+ it is never on a command line, where a process listing would show it; the
585
+ variable's *name* is what keeps the pasted line out of PSReadLine's on-disk
586
+ history, which drops lines containing "token" (a `--token` argument would be
587
+ dropped just the same). The script moves it out of the environment as its first
588
+ statement and hands it to `bir setup` through that child's environment only;
589
+ `bir setup` deletes it again the moment it has read it, so the recorder it
590
+ starts never holds it. By hand: `bir setup --token <t>` or
591
+ `bir login --token <t>`. It signs in one person on one machine with no browser
592
+ step; it is not a fleet credential.
593
+
594
+ What `bir setup` says about a token that is not fresh:
595
+
596
+ - pasted a second time after a successful paste — "the setup token was already
597
+ used; keeping the sign-in from before (<email>)", and it goes on; that is the
598
+ upgrade path (§8) and the retry after a failure past the sign-in;
599
+ - used or expired, with no cached sign-in to keep — "this setup token has
600
+ expired or was already used — make a new one on the console's Set up the
601
+ runner page and paste it within ten minutes";
602
+ - not a token at all — "that is not a valid setup token — copy the whole token
603
+ from the console's Set up the runner page".
604
+
605
+ "sign-in failed: …" is reserved for the unexpected, and "not signed in — run
606
+ `bir setup` again (or `bir login`)." for a sign-in that ended with no session.
607
+
608
+ **Credentials are bound to the service that issued them.** `credentials.json`
609
+ carries `authUrl`, and `authenticate()` refuses to use or refresh a session
610
+ against another origin — `auth.url_mismatch` on the audit log, and the runner
611
+ records nothing rather than send a session where it was not minted. A file from
612
+ before the binding is trusted as before. So a copied `credentials.json` works
613
+ only where `config.json` (or `BIR_AUTH_URL`) names the same service, and a stale
614
+ `BIR_AUTH_URL` in a profile file now means "not recording" — `bir setup` warns
615
+ when the environment names a different address than the one being set up, or
616
+ than the stored one (a bare `bir setup` in a second project included), and says
617
+ to remove the variable.
297
618
 
298
619
  **For a machine with a person attached, headless is no longer a problem.** The
299
620
  runner prints a link and a short code and waits; the link opens on *any* device,
300
621
  so a box with no browser, no display, or only an SSH session is signed in from a
301
622
  phone. Nothing is typed into the terminal, so nothing lands in a shell history or
302
- a CI transcript. `bir login --no-browser` skips even trying to open one, which is
303
- the right flag in a provisioning script (it is also the default over SSH).
623
+ a CI transcript. `bir login --no-browser` (or `bir setup --no-browser`) skips
624
+ even trying to open one, which is the right flag in a provisioning script (over
625
+ SSH it is the default; a pipe on stdout is not — under the install scripts
626
+ `bir setup` still opens the browser, because somebody just pasted the line). The
627
+ setup token removes even the waiting.
304
628
 
305
629
  For machines with **no person attached**, in order of preference:
306
630
 
307
631
  1. **A service account, signed in once.** Approve the device code for a dedicated
308
- account, then copy `credentials.json` to each machine at 0600. The rotating
309
- refresh token keeps it alive and nothing ever prompts. It is still a shared
310
- long-lived secret, so never a person's account.
311
- 2. **`BIR_AUTH_DISABLE=1`** for runners that should record nothing. Everything
312
- else still works.
632
+ account, then copy `credentials.json` to each machine at 0600, together with
633
+ a `config.json` naming the same service. The rotating refresh token keeps it
634
+ alive and nothing ever prompts. It is still a shared long-lived secret, so
635
+ never a person's account.
636
+ 2. **No service address at all** for runners that should record nothing: no
637
+ address in `~/.baseinstrunner/config.json` and no `BIR_AUTH_URL`. The
638
+ recorder then starts with a null recorder (`recorder.disabled` on its log)
639
+ and everything else still works; `bir up` and `bir doctor` exit 1 to say so.
640
+ `BIR_AUTH_DISABLE=1` is *not* that: it fakes a session and records with a
641
+ bogus token — a local-dev and CI switch, not a fleet one.
313
642
  3. **Ask the service for a non-interactive grant** — a long-lived runner token,
314
643
  created in the console and read from an env var. This remains the right answer
315
- for a large fleet and is still a service-side change; the device grant makes
316
- it less urgent, not unnecessary.
644
+ for a large fleet and is still a service-side change; the device grant and the
645
+ setup token make it less urgent, not unnecessary.
317
646
 
318
- `bir logout` now revokes the session server-side before clearing the file, so
647
+ `bir logout` revokes the session server-side before clearing the file, so
319
648
  decommissioning a runner ends its access rather than merely tidying the disk.
320
649
 
321
650
  `BIR_HOME` relocates all of this if state belongs somewhere other than
@@ -325,45 +654,52 @@ decommissioning a runner ends its access rather than merely tidying the disk.
325
654
 
326
655
  ## 8. Updating a runner
327
656
 
328
- Re-run the bootstrap script with the new version. It is idempotent, it will not
329
- prompt for a login it already has, and `bir install` will not double-wrap:
330
-
331
- ```powershell
332
- .\scripts\install-runner.ps1 C:\work\repo -Version 0.2.0 -AuthUrl https://basein.example.com
333
- ```
657
+ Paste the line again — the same line, used token and all. It installs the
658
+ version the service serves (`RUNNER_VERSION`, default `latest`), keeps the
659
+ sign-in it already has ("the setup token was already used; keeping the sign-in
660
+ from before (<email>)"), and runs `bir setup`, which re-wraps whatever moved and
661
+ restarts the recorder. For a machine installed by hand: the new package, then
662
+ `bir setup` in each recorded project.
334
663
 
335
664
  ```bash
336
- BIR_VERSION=0.2.0 BIR_AUTH_URL=https://basein.example.com ./scripts/install-runner.sh ~/work/repo
665
+ npm i -g @basein/runner@<version> && cd ~/work/repo && bir setup
337
666
  ```
338
667
 
339
- Then two rules:
668
+ Two rules used to follow, and neither is a step now:
340
669
 
341
- - **Restart `bir-hooks`.** A running process is still the old code. This is the
342
- step people forget.
343
- - **Re-run `bir install` only if the entry shape changed** — or always, since it
344
- is idempotent. With `--global` entries an upgrade needs no re-install at all
345
- (§4). With pinned-npx entries it always does.
670
+ - **Restarting the recorder.** A running process is still the old code, and
671
+ this was the step people forgot. `bir setup`, `bir up` and the SessionStart
672
+ hook all restart a running recorder whose version is not the package's —
673
+ provided no session is mid-run. A recorder still serving an open Claude Code
674
+ session is left alone; `bir setup` says so ("recorder 0.2.8 is still serving
675
+ an open Claude Code session; it restarts to 0.2.9 with the next session") and
676
+ the next session's hook does it. `bir up --restart` forces it now.
677
+ - **Re-running `bir install`.** `bir setup` does. A `--global` entry whose path
678
+ did not change needs nothing (§4); one whose runner moved — a new prefix, a
679
+ new Node — is re-wrapped from the stashed original; a pinned-npx entry always
680
+ has its pin moved.
346
681
 
347
- Rolling back is the same command with the older version.
682
+ Rolling back: install the older version by hand and run `bir setup`.
348
683
 
349
684
  ### The half-upgraded machine
350
685
 
351
686
  This is the update failure that looks like success: the package is new, but a
352
- proxy is still the old code because a config points elsewhere or `bir-hooks` was
353
- never restarted. Everything keeps working, and quietly runs code you did not
354
- ship.
687
+ proxy is still the old code because a config points elsewhere, or the recorder
688
+ is a process that outlived the upgrade. Everything keeps working, and quietly
689
+ runs code you did not ship.
355
690
 
356
- A proxy now reports its version when it registers, the control server compares it
357
- with its own, and a mismatch is one line on the audit log:
691
+ The restart-on-skew above closes the common case. For the rest, a proxy reports
692
+ its version when it registers, the control server compares it with its own, and
693
+ a mismatch is one line on the audit log:
358
694
 
359
695
  ```
360
696
  [bir] … version.skew server=fleet proxy=0.0.9 hooks=0.1.0 fix="restart bir-hooks after upgrading, …"
361
697
  ```
362
698
 
363
- `bir doctor --json` carries the same facts structurally — `hooksVersion` at the
364
- top and a `version` on each entry of `registeredProxies` — so a provisioning
365
- script can assert agreement rather than grep a log. A proxy too old to report a
366
- version is not treated as skewed: silence, not a false alarm.
699
+ `bir doctor --json` carries the same facts structurally — under `health`,
700
+ `hooksVersion` and a `version` on each entry of `registeredProxies` — so a
701
+ provisioning script can assert agreement rather than grep a log. A proxy too old
702
+ to report a version is not treated as skewed: silence, not a false alarm.
367
703
 
368
704
  ---
369
705
 
@@ -372,10 +708,11 @@ version is not treated as skewed: silence, not a false alarm.
372
708
  CI does this on three platforms. To do it by hand on a clean machine:
373
709
 
374
710
  ```bash
375
- npm run build && npm test # 233 tests
376
- npm run test:smoke # real processes: hooks + proxy + scenario over stdio
711
+ npm run build && npm test # ~400 tests (npm runs pretest first)
712
+ npm run test:smoke # real processes: hooks + proxy + scenario over stdio;
713
+ # needs the build and the pretest above
377
714
  npm pack && npm i -g ./basein-runner-*.tgz
378
- cd /tmp/scratch && bir install --global && bir-hooks &
715
+ cd /tmp/scratch && bir setup --auth-url <api> --token <t> # or BIR_AUTH_URL=<api> bir login, then bir install --global && bir up
379
716
  bir doctor --json # must exit 0
380
717
  bir uninstall # must restore the config byte-for-byte
381
718
  ```
@@ -385,7 +722,12 @@ crosses process boundaries, which is exactly where a bad `files` list, a missing
385
722
  shebang, or a broken bin mapping shows up.
386
723
 
387
724
  Sandbox a manual run with `BIR_HOME` and `BIR_CLAUDE_JSON` so it cannot touch
388
- your real Claude Code configuration.
725
+ your real Claude Code configuration. A fresh `BIR_HOME` has no sign-in, which is
726
+ why the recipe signs in: a bare `bir install --global && bir up` there starts a
727
+ recorder with nothing to record to, `bir up` ends with "it has no session to
728
+ record with" and exit 1, and `bir doctor --json` exits 1 for the same reason.
729
+ `bir uninstall` restores byte-for-byte only what nothing else edited since the
730
+ install; a file it created stays behind, empty of our entries.
389
731
 
390
732
  ---
391
733
 
@@ -395,18 +737,22 @@ your real Claude Code configuration.
395
737
  |---|---|---|
396
738
  | 1 | Name settled; `PACKAGE_NAME` aligned | done |
397
739
  | 2 | `repository` + `publishConfig`; provenance deferred (private repo) | done |
398
- | 3 | Tarball trimmed — no dead sourcemaps, no scratchpad | done |
399
- | 4 | npx spec pinned; `--global` added | done |
740
+ | 3 | Tarball trimmed — no dead sourcemaps, no scratchpad; `scripts/` ships | done |
741
+ | 4 | npx spec pinned; `--global` added; moved runners re-wrapped | done |
400
742
  | 5 | CI and release workflows, three platforms | done |
401
- | 6 | `scripts/install-runner.{ps1,sh}` | done |
402
- | 7 | Version-skew detection on registration | done |
743
+ | 6 | `scripts/install.{ps1,sh}`, served by the API at `/install.ps1` and `/install.sh` (replace `install-runner.*`) | done |
744
+ | 7 | Version-skew detection on registration; recorder restarts itself between sessions | done |
403
745
  | 8 | UTF-8 BOM tolerated in MCP configs | done |
404
746
  | 9 | npm org `basein`, `NPM_TOKEN` secret | done |
405
- | 10 | Tag `v0.1.0` to publish | ready |
406
- | 11 | Non-interactive auth grant for fleets | **needs the service** |
407
- | 12 | Migrate to trusted publishing before Jan 2027 (§3.3) | scheduled |
408
-
409
- Item 11 is the only one that needs somebody else.
747
+ | 10 | `bir setup`: sign-in, wrap, replay, recorder in one command | done |
748
+ | 11 | Background recorder started by the SessionStart hook (`bir-hooks ensure`); `bir up` / `bir down`; log per project | done |
749
+ | 12 | Hooks in `.claude/settings.local.json` + `.git/info/exclude`; one port and token per project | done |
750
+ | 13 | Setup token (`BIR_SETUP_TOKEN`) for the first sign-in; credentials bound to their service; address in `config.json` | done |
751
+ | 14 | Tag `v0.2.9` to publish | ready |
752
+ | 15 | Non-interactive auth grant for fleets | **needs the service** |
753
+ | 16 | Migrate to trusted publishing before Jan 2027 (§3.3) | scheduled |
754
+
755
+ Item 15 is the only one that needs somebody else.
410
756
 
411
757
  ---
412
758