@basein/runner 0.3.9 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +46 -5
  2. package/dist/auth/client.d.ts +15 -0
  3. package/dist/auth/client.js +34 -12
  4. package/dist/bin/bir-hooks.d.ts +1 -1
  5. package/dist/bin/bir-hooks.js +1 -1
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +60 -6
  8. package/dist/bin/investigate.d.ts +5 -0
  9. package/dist/bin/investigate.js +117 -15
  10. package/dist/bin/setup.d.ts +12 -0
  11. package/dist/bin/setup.js +185 -56
  12. package/dist/bin/update.d.ts +81 -0
  13. package/dist/bin/update.js +194 -0
  14. package/dist/config/adapters/claude-code.d.ts +17 -0
  15. package/dist/config/adapters/claude-code.js +28 -1
  16. package/dist/config/generate.d.ts +14 -0
  17. package/dist/control/daemon.d.ts +26 -1
  18. package/dist/control/daemon.js +53 -3
  19. package/dist/control/pin-guard.d.ts +6 -1
  20. package/dist/control/pin-guard.js +15 -1
  21. package/dist/control/recorder-keeper.js +2 -1
  22. package/dist/control/server.d.ts +200 -2
  23. package/dist/control/server.js +741 -48
  24. package/dist/control/transcript.d.ts +4 -0
  25. package/dist/control/transcript.js +4 -0
  26. package/dist/proxy/session.js +1 -0
  27. package/dist/record/housekeeping.d.ts +7 -1
  28. package/dist/record/housekeeping.js +11 -3
  29. package/dist/record/recorder.d.ts +34 -0
  30. package/dist/record/remote-recorder.d.ts +6 -0
  31. package/dist/record/remote-recorder.js +31 -4
  32. package/dist/record/switchable-recorder.js +1 -0
  33. package/dist/replay/controller.d.ts +132 -3
  34. package/dist/replay/controller.js +248 -24
  35. package/dist/replay/derive.js +2 -0
  36. package/dist/replay/handover.d.ts +13 -1
  37. package/dist/replay/handover.js +19 -0
  38. package/dist/replay/safety.d.ts +15 -4
  39. package/dist/replay/safety.js +19 -6
  40. package/dist/replay/source-run.js +2 -1
  41. package/dist/util/service-headers.d.ts +24 -0
  42. package/dist/util/service-headers.js +47 -0
  43. package/docs/BaseInstRunner.md +115 -1
  44. package/docs/calculatedReplay.md +209 -32
  45. package/docs/calculatedReplayGuide.md +133 -12
  46. package/docs/installRun.md +91 -10
  47. package/docs/quickstart.md +64 -9
  48. package/package.json +1 -1
  49. package/production.md +81 -17
  50. package/scripts/install.ps1 +25 -1
@@ -45,6 +45,9 @@ const INCIDENTS = new Set([
45
45
  "run.lossy",
46
46
  "run.unmeasured",
47
47
  "run.prompt_late",
48
+ // What a host that is not Claude Code said with its stop.
49
+ "run.incomplete",
50
+ "run.host_failed",
48
51
  ]);
49
52
  /**
50
53
  * Group journal entries into turns. A turn opens at `run.start` and closes at
@@ -77,7 +80,14 @@ export function turnsFromJournal(entries) {
77
80
  for (const e of entries) {
78
81
  if (e.event === "run.start") {
79
82
  const runId = str(e.run) ?? `unknown-${turns.length}`;
80
- const turn = { runId, sessionId: str(e.sess), startedAt: e.at, incidents: [], events: [e] };
83
+ const turn = {
84
+ runId,
85
+ sessionId: str(e.sess),
86
+ host: str(e.host),
87
+ startedAt: e.at,
88
+ incidents: [],
89
+ events: [e],
90
+ };
81
91
  turns.push(turn);
82
92
  continue;
83
93
  }
@@ -201,11 +211,45 @@ export const DECLINES = {
201
211
  cause: "the scenario has parameters and the runner had no way to derive them: no service session and no ANTHROPIC_API_KEY.",
202
212
  fix: "set BIR_AUTH_URL and `bir login` in the terminal that starts bir-hooks, or export ANTHROPIC_API_KEY there.",
203
213
  },
214
+ // Not a gate of the ladder: the control server's own verdict, when arming
215
+ // outlived the hook that would have announced the plan.
216
+ arm_timeout: {
217
+ cause: "the plan was not ready before the hook that would have announced it had to answer: the prompt hook waits 6 s in all, match included, and a tool call that starts a plan mid-task waits 1.5 s. What arming waits for is the reading of the prompt's values (the derivation). A plan that was not announced is never run.",
218
+ fix: "run the prompt again. If it keeps happening the reading is slow: the recorder's log (`bir status` names the file) shows when its `replay.derived` line landed. BIR_HOOK_DERIVE_BUDGET_MS does not help here, because the hook's own limit was reached first. To read values on this machine instead of asking the service, set BIR_DERIVE_API_KEY where Claude Code starts.",
219
+ },
220
+ runner_update_required: {
221
+ cause: "the service matched the prompt and handed over no plan: this runner is older than its plans need.",
222
+ fix: "update the runner (`bir update`), then run `bir setup` in each project that uses it; `bir doctor` says when the service is satisfied.",
223
+ },
204
224
  flatten_failed: {
205
225
  cause: "the chain could not be flattened — a sub-task it calls no longer resolves.",
206
226
  fix: "recalculate the scenario.",
207
227
  },
228
+ // The two gates of a host that is not Claude Code (a LangChain application).
229
+ host_records_only: {
230
+ cause: "the runner in that application's folder is older than replay for LangChain needs, so the LangChain package only records.",
231
+ fix: "nothing to do while replay for LangChain is not released. After that: update the runner. Until then the model does the task, and the run counts as a sample of the usual cost.",
232
+ },
233
+ host_tool_missing: {
234
+ cause: "the plan names a tool that the agent which asked does not have.",
235
+ fix: "give the agent that tool, or delete the recording so the task is recorded again; `bir scenario show <scnId>` lists the plan's tools.",
236
+ },
208
237
  };
238
+ /** Whether a turn ran in a host that is not Claude Code (see {@link Turn.host}). */
239
+ function otherHost(turn) {
240
+ return turn.host !== undefined && turn.host !== "claude-code";
241
+ }
242
+ /**
243
+ * Why a turn's cost is not measured, for a turn of a host that is not Claude
244
+ * Code. Such a host has no transcript: its adapter hands the tokens over, and
245
+ * each cause below is one the runner or the service names with a turn.
246
+ */
247
+ const HOST_UNMEASURED = "the cost of this run was not measured. Either the runner or the LangChain package is too old: update both. " +
248
+ "Or the model has no price in our table: such a run is recorded and replayed, and its saving is projected, " +
249
+ "or not shown at all when the plan was recorded on such a model too. " +
250
+ "Or the plan ended without a model call, so there were no tokens to count. " +
251
+ "Or more than one plan started in this run, or the run was closed early, or a person said no to a step: " +
252
+ "then the cost is no measure of this plan, and its saving is projected.";
209
253
  /**
210
254
  * The `missing_target` fix with what this turn knows put in: the scenario, and
211
255
  * the target the gate named (`target database not found`). The code alone once
@@ -222,6 +266,42 @@ function missingTargetFix(fix, turn) {
222
266
  text = text.replaceAll("<name>", name);
223
267
  return text;
224
268
  }
269
+ /**
270
+ * The cause and fix of a decline whose sentence shows that the *reading* of
271
+ * the prompt's values (the derivation) went wrong, and not the prompt or the
272
+ * sign-in its code is named for:
273
+ *
274
+ * - `missing_target`, `target time_min not found — derivation did not answer
275
+ * within 4000ms`: the wait ran out. Whether the prompt names the target was
276
+ * never learned, and "name it in the prompt" sent a reader to reword a
277
+ * prompt that already did (the value landed 0.6 s after the wait).
278
+ * - `no_derive_key`, `service: error: target time_min`: the service was asked
279
+ * and returned no reading. "No service session" sent a reader to sign in to
280
+ * a service that had just answered.
281
+ *
282
+ * Undefined for every other decline: its code names the cause.
283
+ */
284
+ function readingDecline(code, why) {
285
+ const waitedMs = code === "missing_target" ? /\bderivation did not answer within (\d+) ?ms/.exec(why)?.[1] : undefined;
286
+ if (waitedMs) {
287
+ return {
288
+ cause: `the plan needs a target, a value read from the prompt, and that reading (the derivation) had not answered when the runner stopped waiting for it: its wait is ${waitedMs} ms, and none while the service is cooling down after a slow or failed call. ` +
289
+ "Whether the prompt names the target was never learned. A replay never takes a target from the recording.",
290
+ fix: "nothing in the prompt needs to change: run it again. If it keeps happening the reading is slower than the wait, and the recorder's log (`bir status` names the file) has its `replay.derived` line after this decline. Give it longer: set BIR_HOOK_DERIVE_BUDGET_MS where the recorder starts, then `bir up --restart`. The prompt hook stops 6 s after the prompt arrived, whatever is set.",
291
+ };
292
+ }
293
+ // Gate 7 writes `<reason>: target <name>`, and `no_derive_key` itself as the
294
+ // reason when there was nobody to ask (replay/controller.ts).
295
+ const reason = code === "no_derive_key" ? why.replace(/: target \S+$/, "") : "";
296
+ if (reason && reason !== "no_derive_key") {
297
+ return {
298
+ cause: `the plan needs a target, a value read from the prompt. The service, which does that reading for a runner with no key of its own, was asked and returned none (\`${reason}\`). ` +
299
+ "A replay never takes a target from the recording.",
300
+ fix: "nothing to sign in to or set up here: the runner asked the service, and the reading failed there or on the way. Run the prompt again. `service: error` and `service: timeout` are the service's own model call failing or passing its limit (RUNNER_DERIVE_TIMEOUT_MS there, 6 s by default); `rate_limited`, `unaffordable`, `disabled` and `no_key` are settings of the service. To read values on this machine instead, set BIR_DERIVE_API_KEY where Claude Code starts.",
301
+ };
302
+ }
303
+ return undefined;
304
+ }
225
305
  export const LOW_SAVING_PCT = 0.3;
226
306
  const usd = (v) => `$${v.toFixed(4)}`;
227
307
  const pct = (v) => `${(v * 100).toFixed(1)}%`;
@@ -239,12 +319,14 @@ export function localFindings(turn) {
239
319
  else if (turn.decision?.verdict === "no-steer") {
240
320
  const code = turn.decision.code ?? "";
241
321
  const known = DECLINES[code];
322
+ const reading = readingDecline(code, turn.decision.why ?? "");
242
323
  out.push({
243
324
  code: `declined_${code || "unknown"}`,
244
325
  severity: code === "not_ready" && !turn.matched.scenarioId ? "info" : "problem",
245
326
  title: `Matched ${turn.matched.runId} but declined to steer: ${turn.decision.why ?? code}.`,
246
- cause: known?.cause ?? "the runner's log line carries the reason text.",
247
- fix: (known && code === "missing_target" ? missingTargetFix(known.fix, turn) : known?.fix) ??
327
+ cause: reading?.cause ?? known?.cause ?? "the runner's log line carries the reason text.",
328
+ fix: reading?.fix ??
329
+ (known && code === "missing_target" ? missingTargetFix(known.fix, turn) : known?.fix) ??
248
330
  (turn.matched.scenarioId
249
331
  ? "read the `replay.decision` line above."
250
332
  : "the matched recording has no scenario yet — calculate it, or let the service pick it up after enough hits."),
@@ -266,7 +348,10 @@ export function localFindings(turn) {
266
348
  const live = coverage
267
349
  .map((c, i) => (c === "live" ? (tools[i] ?? `step ${i}`) : undefined))
268
350
  .filter((t) => Boolean(t));
269
- if (turn.plan.mode === "steer" && live.length > 0) {
351
+ // Not for a turn of another host: there every step is one only the host
352
+ // can run, and stepping through them is the full mode — its adapter
353
+ // answers in the model's place, so no model call is paid per step.
354
+ if (turn.plan.mode === "steer" && live.length > 0 && !otherHost(turn)) {
270
355
  const lowSaving = savedPct(turn) != null && savedPct(turn) < LOW_SAVING_PCT;
271
356
  const names = [...new Set(live)];
272
357
  // One of the runner's own built-ins steered only because it was off the
@@ -310,22 +395,36 @@ export function localFindings(turn) {
310
395
  .map(([k, v]) => `${k}=${String(v)}`)
311
396
  .join(" "),
312
397
  fix: i.event === "run.unmeasured"
313
- ? "the turn's cost could not be read from the transcript, so its report is unmeasured; run bir-hooks where the Claude Code transcript is readable."
398
+ ? otherHost(turn)
399
+ ? HOST_UNMEASURED
400
+ : "the turn's cost could not be read from the transcript, or the runner has no price for the model: update it. Its report is unmeasured; run bir-hooks where the Claude Code transcript is readable."
314
401
  : i.event === "replay.diverge" || i.event === "replay.handover"
315
402
  ? "the model left the script or the runner handed the turn back; the service section shows what the fallback cost."
316
- : "see the guide's troubleshooting table for this line.",
403
+ : i.event === "run.incomplete"
404
+ ? "the recording is not the whole run, so it is never calculated and no longer catches its prompt; run the task again to record it whole."
405
+ : i.event === "run.host_failed"
406
+ ? "the application's run failed; its recording is closed with that error and never becomes a plan."
407
+ : "see the guide's troubleshooting table for this line.",
317
408
  });
318
409
  }
319
410
  const r = turn.reported;
320
411
  if (r) {
321
412
  if (r.measured === false) {
322
- out.push({
323
- code: "unmeasured",
324
- severity: "warn",
325
- title: "The execution report is unmeasured: the session's tokens could not be read.",
326
- cause: "Without the transcript delta the report carries the derivation cost alone, and `baseline − cost` overstates the saving.",
327
- fix: "Run bir-hooks on the machine and user that own the Claude Code transcript; `run.unmeasured` names what was missing.",
328
- });
413
+ out.push(otherHost(turn)
414
+ ? {
415
+ code: "unmeasured",
416
+ severity: "warn",
417
+ title: "The execution report is unmeasured.",
418
+ cause: HOST_UNMEASURED,
419
+ fix: "Update the runner and the LangChain package; `run.unmeasured` names what was missing, and the model when it has no price.",
420
+ }
421
+ : {
422
+ code: "unmeasured",
423
+ severity: "warn",
424
+ title: "The execution report is unmeasured: the session's tokens could not be read, or the runner has no price for the model.",
425
+ cause: "Without the transcript delta the report carries the derivation cost alone, and `baseline − cost` overstates the saving.",
426
+ fix: "Run bir-hooks on the machine and user that own the Claude Code transcript, or update the runner when it has no price for the model; `run.unmeasured` names what was missing.",
427
+ });
329
428
  }
330
429
  const p = savedPct(turn);
331
430
  const cost = r.derive + r.session + r.fallback;
@@ -340,12 +439,15 @@ export function localFindings(turn) {
340
439
  });
341
440
  }
342
441
  else if (p != null && p < LOW_SAVING_PCT && r.outcome === "steered_full") {
442
+ // In another host a steer plan is not the mode that pays for the whole
443
+ // turn: its adapter makes the plan's calls, and the model is called once.
444
+ const paysTheTurn = turn.plan?.mode === "steer" && !otherHost(turn);
343
445
  out.push({
344
446
  code: "saving_low",
345
447
  severity: "warn",
346
448
  title: `Saved ${pct(p)}: ${usd(r.savedUsd ?? 0)} of a ${usd((r.savedUsd ?? 0) + cost)} baseline.`,
347
- cause: `The live session cost ${usd(r.session)} of that baseline${turn.plan?.mode === "steer" ? " — steer mode pays for the whole turn." : "."}`,
348
- fix: turn.plan?.mode === "steer"
449
+ cause: `The live session cost ${usd(r.session)} of that baseline${paysTheTurn ? " — steer mode pays for the whole turn." : "."}`,
450
+ fix: paysTheTurn
349
451
  ? "Direct mode is the fix (see steer_mode for what kept this plan out of it)."
350
452
  : "Compare the baseline with a few unsteered runs (BIR_REPLAY=0); a single cheap source run makes any replay look poor.",
351
453
  evidence: { ...r, costUsd: cost, savedPct: p },
@@ -53,7 +53,19 @@ export interface SetupArgs {
53
53
  * replaced (see {@link runSetup}, step 4).
54
54
  */
55
55
  portExplicit?: boolean;
56
+ /**
57
+ * `--host langchain`: set the folder up for an application of another host
58
+ * than Claude Code. Nothing of Claude Code's is wired — no hooks, no wrapped
59
+ * server, nothing under `.claude/` or in `~/.claude.json` — see
60
+ * {@link setupForHost}.
61
+ */
62
+ host?: string;
56
63
  }
64
+ /** The hosts `bir setup --host` knows, and what each one's adapter is called. */
65
+ export declare const SETUP_HOSTS: Readonly<Record<string, {
66
+ title: string;
67
+ lines: readonly string[];
68
+ }>>;
57
69
  /**
58
70
  * The project directory every later step keys by: the PHYSICAL path on POSIX.
59
71
  *
package/dist/bin/setup.js CHANGED
@@ -32,6 +32,7 @@
32
32
  * `cd <project>` and `bir setup`. The second needs no token; the session is
33
33
  * cached.
34
34
  */
35
+ import { randomBytes } from "node:crypto";
35
36
  import { existsSync, realpathSync, statSync } from "node:fs";
36
37
  import { homedir } from "node:os";
37
38
  import { delimiter, join, parse as parsePath, resolve as resolvePath } from "node:path";
@@ -39,13 +40,23 @@ import { pidAlive, readDiscovery } from "../control/discovery.js";
39
40
  import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
40
41
  import { activeSessions, compareVersions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
41
42
  import { normalizePath } from "../control/paths.js";
42
- import { allowRules, BIR_TOOLS_RULE, claudeCodePaths } from "../config/adapters/claude-code.js";
43
- import { allocateReplacementPort, editingEnabled, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
43
+ import { allowRules, BIR_TOOLS_RULE, claudeCodePaths, claudeCodeSetUp } from "../config/adapters/claude-code.js";
44
+ import { allocateReplacementPort, editingEnabled, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, settleReplaySwitch, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
44
45
  import { resolveServers } from "../config/resolve.js";
45
46
  import { EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
46
47
  import { errText } from "../util/log.js";
47
48
  import { offSource } from "../util/off-switch.js";
48
49
  import { packageVersion } from "../util/version.js";
50
+ /** The hosts `bir setup --host` knows, and what each one's adapter is called. */
51
+ export const SETUP_HOSTS = {
52
+ langchain: {
53
+ title: "a LangChain agent",
54
+ lines: [
55
+ "from langchain_basic2202 import Basic2202Middleware",
56
+ "agent = create_agent(model, tools, middleware=[Basic2202Middleware()])",
57
+ ],
58
+ },
59
+ };
49
60
  /**
50
61
  * The project directory every later step keys by: the PHYSICAL path on POSIX.
51
62
  *
@@ -101,6 +112,163 @@ export function consoleUrlFor(apiUrl) {
101
112
  return undefined;
102
113
  }
103
114
  }
115
+ /**
116
+ * Start `project`'s recorder in the background, or keep the one that runs —
117
+ * replacing one that has no sign-in, sits on another port than the project's,
118
+ * or was started with another replay switch, whenever no session is mid-run.
119
+ * Throws when it does not come up.
120
+ */
121
+ async function ensureRecorder(project, deps, env) {
122
+ const { out } = deps;
123
+ // probeIdle: a recorder too old to say whether its "open" turn is real
124
+ // (0.2.12 never closes an interrupted one) is replaced when two /health
125
+ // probes a few seconds apart show it idle (control/daemon.ts). Setup has
126
+ // the time; the SessionStart hook does not ask for it.
127
+ const ensureOpts = {
128
+ hooksScript: deps.hooksScript,
129
+ env,
130
+ restartOnSkew: true,
131
+ probeIdle: true,
132
+ };
133
+ let ensured = await ensureDaemon(project, ensureOpts);
134
+ if (!ensured.started &&
135
+ ensured.status.health.recording === false &&
136
+ activeSessions(ensured.status.health) === 0) {
137
+ // A recorder that came up before there was a sign-in (a `bir up` on a
138
+ // fresh machine, a session that expired) has nowhere to send steps.
139
+ // There is one now, and nothing is mid-run: replace it rather than
140
+ // tell the person to.
141
+ out(" the running recorder has no sign-in; replacing it");
142
+ await stopDaemon(project);
143
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
144
+ ensured = { ...ensured, restarted: true };
145
+ }
146
+ const port = projectRecord(readSidecar(), project)?.port;
147
+ if (port !== undefined &&
148
+ !ensured.status.info.url.endsWith(`:${port}`) &&
149
+ activeSessions(ensured.status.health) === 0) {
150
+ // A recorder still on the port this project had before (moved above,
151
+ // or by `--port`): the hooks now post to the new one, so it would
152
+ // receive nothing. Nothing is mid-run; replace it.
153
+ out(` the running recorder is on ${ensured.status.info.url}, not port ${port}; replacing it`);
154
+ await stopDaemon(project);
155
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
156
+ ensured = { ...ensured, restarted: true };
157
+ }
158
+ // The replay switch is read once, when a recorder starts: one started
159
+ // before this setup turned replay off (--no-replay) or on would go on
160
+ // doing what it did. `source: "env"` is BIR_REPLAY where it started,
161
+ // which outranks the switch — a restart from here would not carry it.
162
+ const wantReplay = projectRecord(readSidecar(), project)?.replay?.enabled !== false;
163
+ const running = ensured.status.health.replay;
164
+ if (typeof running?.enabled === "boolean" && running.enabled !== wantReplay && running.source !== "env") {
165
+ if (activeSessions(ensured.status.health) === 0) {
166
+ out(` the running recorder has replay ${running.enabled ? "on" : "off"}; replacing it`);
167
+ await stopDaemon(project);
168
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
169
+ ensured = { ...ensured, restarted: true };
170
+ }
171
+ else {
172
+ out(` the running recorder keeps replay ${running.enabled ? "on" : "off"} for the session open now; ` +
173
+ "`bir up --restart` once it ends applies the change");
174
+ }
175
+ }
176
+ return ensured;
177
+ }
178
+ /**
179
+ * `bir setup --host langchain`: the folder, for an application of another
180
+ * host than Claude Code (the service's integratioLangChane.md, step LR7).
181
+ *
182
+ * Such a host needs none of what `bir setup` wires for Claude Code: its
183
+ * adapter runs inside the application, sees every tool call itself, and finds
184
+ * the recorder by the folder's control file. So nothing is written under
185
+ * `.claude/` or into `~/.claude.json`, and Claude Code is not asked for. What
186
+ * the folder does need is an entry of its own in the runner's settings:
187
+ *
188
+ * - a fixed port and token. Without one the recorder takes the machine's
189
+ * default port — which can be the fixed port of another project, whose
190
+ * recorder then cannot start — and the machine's shared token, or a new
191
+ * random one at every start;
192
+ * - its replay switch: on, or off with `--no-replay`. `bir replay on|off`
193
+ * work here as everywhere.
194
+ *
195
+ * Then the recorder is started — with the small environment, so the
196
+ * application's keys do not reach the process step code runs in
197
+ * (control/daemon.ts, `smallEnv`) — and the lines to add to the agent are
198
+ * printed.
199
+ *
200
+ * A folder that is already set up for Claude Code keeps that set-up as it
201
+ * is: its hooks, wrapped servers, port, token and replay switch. Only the
202
+ * entry notes that such an application runs here too.
203
+ */
204
+ async function setupForHost(args, deps, ctx) {
205
+ const { out, err } = deps;
206
+ const { project, email, env } = ctx;
207
+ const host = SETUP_HOSTS[args.host];
208
+ const sidecar = readSidecar();
209
+ const before = projectRecord(sidecar, project);
210
+ const withClaudeCode = claudeCodeSetUp(project, sidecar);
211
+ const port = before?.port ?? (await allocateFreeProjectPort(sidecar, project));
212
+ setProjectRecord(sidecar, project, {
213
+ port,
214
+ token: before?.token ?? randomBytes(32).toString("hex"),
215
+ hosts: [...new Set([...(before?.hosts ?? []), args.host])],
216
+ // No line was added to the repository's exclude file for this folder, so
217
+ // an uninstall has none to take out.
218
+ ...(before ? {} : { excludeAdded: false }),
219
+ });
220
+ if (!withClaudeCode)
221
+ settleReplaySwitch(sidecar, project, args.replay ? "wanted" : "refused", false);
222
+ writeSidecar(sidecar);
223
+ if (withClaudeCode) {
224
+ out(" this folder is set up for Claude Code too: its hooks, wrapped servers, port and token stay as they are");
225
+ }
226
+ let ensured;
227
+ if (args.daemon) {
228
+ try {
229
+ ensured = await ensureRecorder(project, deps, env);
230
+ }
231
+ catch (e) {
232
+ err(`the recorder did not start: ${errText(e)}`);
233
+ err("The folder's entry is in place. Fix that, then run `bir up` here.");
234
+ return 1;
235
+ }
236
+ }
237
+ const health = ensured?.status.health;
238
+ const recording = health ? health.recording !== false : undefined;
239
+ const replay = health?.replay;
240
+ const policy = projectRecord(readSidecar(), project)?.replay;
241
+ const replayOn = replay?.source === "env" ? replay.enabled !== false : policy?.enabled !== false;
242
+ out();
243
+ out(`Signed in as ${email}`);
244
+ if (ensured) {
245
+ const { info } = ensured.status;
246
+ out(`Recorder ${ensured.started ? (ensured.restarted ? "restarted" : "started") : "already running"} for ${project} ` +
247
+ `(${info.url}, ${info.daemon ? "background" : "in a terminal"})`);
248
+ if (info.logFile)
249
+ out(` log ${info.logFile}`);
250
+ }
251
+ else {
252
+ out(`Recorder not started for ${project} (--no-daemon); the application starts it, or run \`bir up\` here`);
253
+ }
254
+ out(replayOn
255
+ ? " replay on — `bir replay off` here makes the application only record"
256
+ : replay?.source === "env"
257
+ ? " replay off — BIR_REPLAY=0 where the recorder started"
258
+ : " replay off — the application only records; `bir replay on` here turns it on");
259
+ const off = offSource(project);
260
+ if (off) {
261
+ out(` !! but the runner is OFF (${off === "env" ? "BIR_OFF=1 in this environment" : off === "global" ? "`bir off`, every project" : "`bir off --here`"}): nothing is recorded or replayed until \`bir on${off === "project" ? " --here" : ""}\``);
262
+ }
263
+ if (recording === false) {
264
+ out(" !! the recorder has no session to record with — run `bir login`; it starts recording by itself after that");
265
+ }
266
+ out(`Add this to your agent (${host.title}):`);
267
+ for (const line of host.lines)
268
+ out(` ${line}`);
269
+ out("Any time: bir doctor bir investigate bir off (out of the way, at once) bir uninstall (undo)");
270
+ return recording === false ? 1 : 0;
271
+ }
104
272
  export async function runSetup(args, deps) {
105
273
  const { out, err } = deps;
106
274
  const env = deps.env ?? process.env;
@@ -111,6 +279,11 @@ export async function runSetup(args, deps) {
111
279
  delete env[SETUP_TOKEN_ENV];
112
280
  if (env === process.env)
113
281
  delete process.env[SETUP_TOKEN_ENV];
282
+ if (args.host !== undefined && !SETUP_HOSTS[args.host]) {
283
+ err(`--host ${args.host || "(nothing)"}: not a host this runner knows. Known: ${Object.keys(SETUP_HOSTS).join(", ")}.`);
284
+ err("Leave --host out to set the folder up for Claude Code.");
285
+ return 1;
286
+ }
114
287
  // ── 1. the service ───────────────────────────────────────────────────────
115
288
  // `||`, not `??`: an environment that *sets* BIR_AUTH_URL to nothing (a
116
289
  // wrapper script, a CI job) means "not set", not "the empty address".
@@ -227,7 +400,10 @@ export async function runSetup(args, deps) {
227
400
  }
228
401
  // ── 4. the project ───────────────────────────────────────────────────────
229
402
  chdir(project);
230
- if (!args.portExplicit) {
403
+ // `--host` in a folder that is set up for Claude Code too rewrites no hook,
404
+ // so it must not move the port those hooks name.
405
+ const keepsHooks = args.host !== undefined && claudeCodeSetUp(project, readSidecar());
406
+ if (!args.portExplicit && !keepsHooks) {
231
407
  // A port chosen at the first install is kept on later ones — the hooks name
232
408
  // it — but this machine may no longer let it be bound: Hyper-V, WSL and
233
409
  // Docker reserve blocks inside the dynamic range, and those move across
@@ -254,6 +430,11 @@ export async function runSetup(args, deps) {
254
430
  }
255
431
  }
256
432
  }
433
+ // Another host than Claude Code: nothing below is wired — no install, no
434
+ // hooks, no scenario server. The folder gets an entry and a recorder.
435
+ if (args.host !== undefined) {
436
+ return setupForHost({ ...args, host: args.host }, deps, { project, email: session.user.email, env });
437
+ }
257
438
  if (args.allowServers !== undefined) {
258
439
  // Stored before install so the recorder started below reads it at start.
259
440
  const sidecar = readSidecar();
@@ -271,59 +452,7 @@ export async function runSetup(args, deps) {
271
452
  if (args.daemon) {
272
453
  out("==> Starting the recorder in the background");
273
454
  try {
274
- // probeIdle: a recorder too old to say whether its "open" turn is real
275
- // (0.2.12 never closes an interrupted one) is replaced when two /health
276
- // probes a few seconds apart show it idle (control/daemon.ts). Setup has
277
- // the time; the SessionStart hook does not ask for it.
278
- const ensureOpts = {
279
- hooksScript: deps.hooksScript,
280
- env,
281
- restartOnSkew: true,
282
- probeIdle: true,
283
- };
284
- ensured = await ensureDaemon(project, ensureOpts);
285
- if (!ensured.started &&
286
- ensured.status.health.recording === false &&
287
- activeSessions(ensured.status.health) === 0) {
288
- // A recorder that came up before there was a sign-in (a `bir up` on a
289
- // fresh machine, a session that expired) has nowhere to send steps.
290
- // There is one now, and nothing is mid-run: replace it rather than
291
- // tell the person to.
292
- out(" the running recorder has no sign-in; replacing it");
293
- await stopDaemon(project);
294
- ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
295
- ensured = { ...ensured, restarted: true };
296
- }
297
- const port = projectRecord(readSidecar(), project)?.port;
298
- if (port !== undefined &&
299
- !ensured.status.info.url.endsWith(`:${port}`) &&
300
- activeSessions(ensured.status.health) === 0) {
301
- // A recorder still on the port this project had before (moved above,
302
- // or by `--port`): the hooks now post to the new one, so it would
303
- // receive nothing. Nothing is mid-run; replace it.
304
- out(` the running recorder is on ${ensured.status.info.url}, not port ${port}; replacing it`);
305
- await stopDaemon(project);
306
- ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
307
- ensured = { ...ensured, restarted: true };
308
- }
309
- // The replay switch is read once, when a recorder starts: one started
310
- // before this setup turned replay off (--no-replay) or on would go on
311
- // doing what it did. `source: "env"` is BIR_REPLAY where it started,
312
- // which outranks the switch — a restart from here would not carry it.
313
- const wantReplay = projectRecord(readSidecar(), project)?.replay?.enabled !== false;
314
- const running = ensured.status.health.replay;
315
- if (typeof running?.enabled === "boolean" && running.enabled !== wantReplay && running.source !== "env") {
316
- if (activeSessions(ensured.status.health) === 0) {
317
- out(` the running recorder has replay ${running.enabled ? "on" : "off"}; replacing it`);
318
- await stopDaemon(project);
319
- ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
320
- ensured = { ...ensured, restarted: true };
321
- }
322
- else {
323
- out(` the running recorder keeps replay ${running.enabled ? "on" : "off"} for the session open now; ` +
324
- "`bir up --restart` once it ends applies the change");
325
- }
326
- }
455
+ ensured = await ensureRecorder(project, deps, env);
327
456
  }
328
457
  catch (e) {
329
458
  err(`the recorder did not start: ${errText(e)}`);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `bir update` — put the newest version where THIS copy of the runner is.
3
+ *
4
+ * There are two ways the runner gets onto a machine, and each keeps it in a
5
+ * different place:
6
+ *
7
+ * - the console's install line (scripts/install.sh, install.ps1) installs it
8
+ * into `~/.baseinstrunner/npm`, with the npm that sits beside the Node its
9
+ * launchers name. `npm i -g` never writes there: typed by hand it adds a
10
+ * second copy in npm's own prefix and leaves the one the launchers start
11
+ * as old as before, and which copy a shell then finds depends on its PATH;
12
+ * - `npm install -g @basein/runner`, into npm's own prefix.
13
+ *
14
+ * So the command that updates the runner depends on where the runner is, and
15
+ * the copy that is running is the one that knows. A copy under
16
+ * `~/.baseinstrunner/npm` repeats what the install line did: the same Node,
17
+ * the npm beside it, `--prefix` that folder. Any other installed copy runs
18
+ * `npm install -g`, after asking npm where it keeps its global packages: when
19
+ * this copy is not there (pnpm, yarn, another Node's prefix), npm would write
20
+ * the second copy this command exists to prevent, so nothing is installed.
21
+ *
22
+ * It replaces the package and nothing else. `bir setup` in each project is
23
+ * what moves the wiring and replaces an older recorder, and it is the next
24
+ * thing the output names.
25
+ *
26
+ * NOT TESTED on Windows against a live session: npm replaces the folder this
27
+ * process and the recorder were loaded from. Neither keeps a file of it open,
28
+ * and the install line has always done the same replacement, but if npm ends
29
+ * with EPERM or EBUSY the answer is to close Claude Code and run it again.
30
+ */
31
+ export interface UpdateDeps {
32
+ out: (line?: string) => void;
33
+ err: (line: string) => void;
34
+ /** This package's folder. Default: found from this module. */
35
+ packageRoot?: string;
36
+ /** The person's home folder. Default: `os.homedir()` (never `BIR_HOME`: the install scripts do not read it). */
37
+ home?: string;
38
+ /** Decides the layout of an npm prefix. Default: this platform's. */
39
+ platform?: NodeJS.Platform;
40
+ /** The Node that runs this `bir`. Default: `process.execPath`. */
41
+ execPath?: string;
42
+ /** The flags that Node was started with. Default: `process.execArgv`. */
43
+ execArgv?: string[];
44
+ /** The `npm-cli.js` a copy under `~/.baseinstrunner/npm` runs. Default: the one beside `execPath`. */
45
+ npmCli?: string;
46
+ /** Run a command with its output shown. Returns its exit code; null when it never started or was killed. */
47
+ run?: (command: string, args: string[]) => number | null;
48
+ /** Run a command and return what it printed. Undefined when it failed. */
49
+ capture?: (command: string, args: string[]) => string | undefined;
50
+ }
51
+ export type UpdatePlan =
52
+ /** A copy the install line made: npm beside its Node, into the private prefix. */
53
+ {
54
+ kind: "private";
55
+ root: string;
56
+ command: string;
57
+ args: string[];
58
+ }
59
+ /** A copy `npm install -g` made. */
60
+ | {
61
+ kind: "npm";
62
+ root: string;
63
+ command: string;
64
+ args: string[];
65
+ }
66
+ /** Nothing this command can update; `why` says what to do instead. */
67
+ | {
68
+ kind: "refused";
69
+ why: string;
70
+ };
71
+ /** The folder of this package: the nearest `package.json` that names it. */
72
+ export declare function packageRoot(from?: string): string | undefined;
73
+ /** Where a global install into `prefix` keeps this package: npm adds `lib` everywhere but on Windows. */
74
+ export declare function packageDirIn(prefix: string, platform?: NodeJS.Platform): string;
75
+ /** The `npm-cli.js` that ships beside a Node binary, where the install scripts look for it. */
76
+ export declare function npmCliBeside(execPath: string, platform?: NodeJS.Platform): string;
77
+ /** Decide what `bir update` runs. Changes nothing. */
78
+ export declare function planUpdate(deps?: Pick<UpdateDeps, "packageRoot" | "home" | "platform" | "execPath" | "execArgv" | "npmCli">): UpdatePlan;
79
+ /** `bir update`. Returns the exit code: 1 when nothing was updated. */
80
+ export declare function runUpdate(deps: UpdateDeps): number;
81
+ //# sourceMappingURL=update.d.ts.map