@basein/runner 0.2.7 → 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.
- package/README.md +64 -21
- package/dist/auth/client.d.ts +40 -1
- package/dist/auth/client.js +77 -9
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +124 -38
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +362 -39
- package/dist/bin/investigate.js +5 -1
- package/dist/bin/setup.d.ts +72 -0
- package/dist/bin/setup.js +286 -0
- package/dist/config/adapters/claude-code.d.ts +90 -4
- package/dist/config/adapters/claude-code.js +164 -16
- package/dist/config/generate.d.ts +93 -1
- package/dist/config/generate.js +90 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +8 -0
- package/dist/control/daemon.d.ts +116 -0
- package/dist/control/daemon.js +339 -0
- package/dist/control/discovery.d.ts +26 -0
- package/dist/control/discovery.js +41 -9
- package/dist/control/ensure-hook.d.ts +39 -0
- package/dist/control/ensure-hook.js +98 -0
- package/dist/control/paths.d.ts +14 -0
- package/dist/control/paths.js +20 -0
- package/dist/control/server.d.ts +28 -0
- package/dist/control/server.js +22 -6
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +28 -6
- package/dist/replay/controller.d.ts +24 -1
- package/dist/replay/controller.js +76 -20
- package/dist/replay/handover.js +5 -0
- package/dist/replay/plan.d.ts +2 -0
- package/dist/replay/plan.js +53 -6
- package/dist/replay/pricing.d.ts +1 -1
- package/dist/replay/pricing.js +12 -4
- package/dist/replay/tool-error.d.ts +15 -0
- package/dist/replay/tool-error.js +17 -0
- package/dist/replay/types.d.ts +48 -1
- package/docs/calculatedReplayGuide.md +157 -68
- package/docs/installRun.md +457 -111
- package/docs/loginWeb.md +1 -1
- package/docs/quickstart.md +193 -158
- package/package.json +2 -1
- package/scripts/install.ps1 +669 -0
- 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
|
|
@@ -18,17 +20,19 @@
|
|
|
18
20
|
* from `serverInfo`, which must stay the upstream's (Phase 3).
|
|
19
21
|
*/
|
|
20
22
|
import { randomBytes } from "node:crypto";
|
|
21
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { existsSync, realpathSync, writeFileSync } from "node:fs";
|
|
22
24
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
23
25
|
import { fileURLToPath } from "node:url";
|
|
24
26
|
import { ControlClient } from "../control/client.js";
|
|
25
27
|
import { readDiscovery } from "../control/discovery.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { ensureDaemon, stopDaemon } from "../control/daemon.js";
|
|
29
|
+
import { configDir, discoveryPath, normalizePath } from "../control/paths.js";
|
|
30
|
+
import { allocateFreeProjectPort, isScenarioServer, isWrapped, nodeFlagsToCarry, PACKAGE_NAME, packageSpec, projectRecord, readSidecar, scenarioEntry, setProjectRecord, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
31
|
+
import { runSetup } from "./setup.js";
|
|
32
|
+
import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
|
|
33
|
+
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
34
|
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";
|
|
35
|
+
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
32
36
|
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
33
37
|
import { errText } from "../util/log.js";
|
|
34
38
|
import { journalPath, readJournal } from "../util/journal.js";
|
|
@@ -43,6 +47,9 @@ function usage(code = 0) {
|
|
|
43
47
|
out(`bir ${VERSION} — a recording MCP proxy
|
|
44
48
|
|
|
45
49
|
Commands:
|
|
50
|
+
setup the one command: sign in, wire this project, start the recorder
|
|
51
|
+
up start the recorder for this project in the background (--restart: replace it)
|
|
52
|
+
down stop it
|
|
46
53
|
install wrap this project's MCP servers and wire Claude Code's hooks
|
|
47
54
|
uninstall restore everything install changed
|
|
48
55
|
status what is installed for this directory
|
|
@@ -56,6 +63,8 @@ Commands:
|
|
|
56
63
|
scenario calc <runId> [--force] calculate (or re-derive) a run's scenario
|
|
57
64
|
scenario replay <scnId> --prompt "…" [--dry]
|
|
58
65
|
replay --scenario <scnId> --prompt "…" [--dry]
|
|
66
|
+
replay status | on | off | allow <a,b> | allow all
|
|
67
|
+
this project's replay switches, kept for the background recorder
|
|
59
68
|
|
|
60
69
|
investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
|
|
61
70
|
it did, what it cost, what to fix — journal + service
|
|
@@ -69,17 +78,24 @@ Options:
|
|
|
69
78
|
--local invoke this checkout's bir-proxy instead of npx (development)
|
|
70
79
|
--global invoke the installed package by absolute path instead of npx
|
|
71
80
|
(no registry round trip, no PATH lookup — what a fleet wants)
|
|
72
|
-
--no-hooks do not touch .claude/settings.json (Tier 2 recording only)
|
|
81
|
+
--no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
|
|
73
82
|
--no-correlation never relax tool schemas; join steps on argument fingerprints
|
|
74
83
|
--replay install/remove the scenario server, enabling calculated replay
|
|
75
|
-
--port <n> control-server port to write into the hook URLs
|
|
84
|
+
--port <n> install/setup: control-server port to write into the hook URLs
|
|
85
|
+
(default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
|
|
76
86
|
--json machine-readable output for status / doctor
|
|
77
87
|
--dry replay against recorded outputs only; run no real tools
|
|
78
88
|
--no-browser login: print the link and code, open nothing (SSH, headless)
|
|
79
89
|
--token <value> login: redeem a one-time setup token from the console (no browser)
|
|
80
90
|
--password login: use the old email/password prompt (deprecated)
|
|
81
91
|
--user <ref> investigate executions: another account, by email or id (admin)
|
|
82
|
-
--limit <n> investigate: how many turns / executions to show
|
|
92
|
+
--limit <n> investigate: how many turns / executions to show
|
|
93
|
+
--auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
|
|
94
|
+
--project <dir> setup: the directory to record (default: this one)
|
|
95
|
+
--no-replay setup: wire recording only; add no scenario server
|
|
96
|
+
--allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
|
|
97
|
+
--no-daemon setup: wire everything, start nothing
|
|
98
|
+
--restart up: replace a running recorder`);
|
|
83
99
|
process.exit(code);
|
|
84
100
|
}
|
|
85
101
|
function parseArgs(argv) {
|
|
@@ -98,6 +114,10 @@ function parseArgs(argv) {
|
|
|
98
114
|
dry: false,
|
|
99
115
|
force: false,
|
|
100
116
|
password: false,
|
|
117
|
+
noReplay: false,
|
|
118
|
+
noDaemon: false,
|
|
119
|
+
restart: false,
|
|
120
|
+
portExplicit: false,
|
|
101
121
|
};
|
|
102
122
|
for (let i = 1; i < argv.length; i += 1) {
|
|
103
123
|
const arg = argv[i];
|
|
@@ -153,6 +173,31 @@ function parseArgs(argv) {
|
|
|
153
173
|
break;
|
|
154
174
|
case "--port":
|
|
155
175
|
args.port = Number(argv[++i]);
|
|
176
|
+
args.portExplicit = true;
|
|
177
|
+
break;
|
|
178
|
+
case "--auth-url":
|
|
179
|
+
args.authUrl = argv[++i];
|
|
180
|
+
if (!args.authUrl)
|
|
181
|
+
usage(2);
|
|
182
|
+
break;
|
|
183
|
+
case "--project":
|
|
184
|
+
args.project = argv[++i];
|
|
185
|
+
if (!args.project)
|
|
186
|
+
usage(2);
|
|
187
|
+
break;
|
|
188
|
+
case "--no-replay":
|
|
189
|
+
args.noReplay = true;
|
|
190
|
+
break;
|
|
191
|
+
case "--allow-servers":
|
|
192
|
+
args.allowServers = argv[++i];
|
|
193
|
+
if (args.allowServers === undefined)
|
|
194
|
+
usage(2);
|
|
195
|
+
break;
|
|
196
|
+
case "--no-daemon":
|
|
197
|
+
args.noDaemon = true;
|
|
198
|
+
break;
|
|
199
|
+
case "--restart":
|
|
200
|
+
args.restart = true;
|
|
156
201
|
break;
|
|
157
202
|
case "--json":
|
|
158
203
|
args.json = true;
|
|
@@ -186,6 +231,27 @@ function localProxyPath() {
|
|
|
186
231
|
function localScenarioPath() {
|
|
187
232
|
return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-scenario.js");
|
|
188
233
|
}
|
|
234
|
+
/** Absolute path to this package's built `bir-hooks.js`, for the SessionStart hook. */
|
|
235
|
+
function localHooksPath() {
|
|
236
|
+
return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
|
|
237
|
+
}
|
|
238
|
+
/** True when this `bir` runs from an installed package rather than a checkout. */
|
|
239
|
+
function runsFromInstalledPackage() {
|
|
240
|
+
return /[\\/]node_modules[\\/]/.test(localProxyPath());
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The node the SessionStart hook will run. The real path, not the one this
|
|
244
|
+
* process was started through: a version manager's per-shell shim directory
|
|
245
|
+
* is gone when that shell is, and a hook that names it fails every session.
|
|
246
|
+
*/
|
|
247
|
+
function hookNodePath() {
|
|
248
|
+
try {
|
|
249
|
+
return realpathSync(process.execPath);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return process.execPath;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
189
255
|
/**
|
|
190
256
|
* Which invocation the generated entries use — and the guard that makes
|
|
191
257
|
* `--global` mean something.
|
|
@@ -199,11 +265,20 @@ function localScenarioPath() {
|
|
|
199
265
|
* that works today and breaks after a `git clean`. Hence the node_modules test:
|
|
200
266
|
* it is the one cheap signal that says "somebody installed this".
|
|
201
267
|
*/
|
|
202
|
-
function resolveInvocation(args) {
|
|
268
|
+
function resolveInvocation(args, remembered) {
|
|
203
269
|
if (args.local && args.global) {
|
|
204
270
|
process.stderr.write("[bir] --local and --global are mutually exclusive\n");
|
|
205
271
|
process.exit(2);
|
|
206
272
|
}
|
|
273
|
+
// Neither flag: what this project was installed with last time. `bir setup`
|
|
274
|
+
// installs with --global, and a later bare `bir install --replay` here must
|
|
275
|
+
// not quietly turn every entry into a pinned-npx one and drop the
|
|
276
|
+
// self-starting SessionStart hook with it.
|
|
277
|
+
if (!args.global && !args.local && remembered) {
|
|
278
|
+
if (remembered === "global" && !runsFromInstalledPackage())
|
|
279
|
+
return "local";
|
|
280
|
+
return remembered;
|
|
281
|
+
}
|
|
207
282
|
if (!args.global)
|
|
208
283
|
return args.local ? "local" : "npx";
|
|
209
284
|
const proxy = localProxyPath();
|
|
@@ -229,13 +304,20 @@ function noteWritten(sidecar, file, text) {
|
|
|
229
304
|
sidecar.files[file] = backup;
|
|
230
305
|
}
|
|
231
306
|
// ── install ────────────────────────────────────────────────────────────────
|
|
232
|
-
function install(args) {
|
|
233
|
-
const invocation = resolveInvocation(args);
|
|
307
|
+
async function install(args) {
|
|
234
308
|
const cwd = process.cwd();
|
|
235
309
|
const sidecar = readSidecar();
|
|
310
|
+
const invocation = resolveInvocation(args, projectRecord(sidecar, cwd)?.invocation);
|
|
236
311
|
sidecar.token ??= randomBytes(32).toString("hex");
|
|
237
|
-
|
|
238
|
-
|
|
312
|
+
// One port per project (generate.ts): an explicit --port is honoured, else
|
|
313
|
+
// the one this directory already has, else the lowest free one no other has.
|
|
314
|
+
const port = args.portExplicit ? args.port : await allocateFreeProjectPort(sidecar, cwd);
|
|
315
|
+
sidecar.controlPort = port;
|
|
316
|
+
// One token per project too: a token in this project's settings file opens
|
|
317
|
+
// this project's recorder, not every recorder on the machine.
|
|
318
|
+
const token = projectRecord(sidecar, cwd)?.token ?? randomBytes(32).toString("hex");
|
|
319
|
+
setProjectRecord(sidecar, cwd, { port, token, invocation });
|
|
320
|
+
const controlUrl = `http://127.0.0.1:${port}`;
|
|
239
321
|
const entries = args.configPath
|
|
240
322
|
? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
|
|
241
323
|
name,
|
|
@@ -245,18 +327,52 @@ function install(args) {
|
|
|
245
327
|
}))
|
|
246
328
|
: resolveServers(cwd);
|
|
247
329
|
const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
|
|
248
|
-
if (wanted.length === 0) {
|
|
330
|
+
if (wanted.length === 0 && (args.configPath || !args.hooks)) {
|
|
331
|
+
// An explicit config file, or proxies-only: with no server there is nothing
|
|
332
|
+
// this install could change.
|
|
249
333
|
out("No MCP servers found for this directory. Nothing to wrap.");
|
|
250
334
|
if (!args.configPath) {
|
|
251
335
|
out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
|
|
252
336
|
}
|
|
253
337
|
return 0;
|
|
254
338
|
}
|
|
339
|
+
if (wanted.length === 0) {
|
|
340
|
+
// Hooks alone are a recorder: built-in tools, the prompt and the answer are
|
|
341
|
+
// all theirs, and a scenario over built-in steps steers a session the same
|
|
342
|
+
// way. Stopping here used to leave such a project with nothing at all.
|
|
343
|
+
out("No MCP servers to wrap here (looked in ~/.claude.json and ./.mcp.json).");
|
|
344
|
+
out(" Hooks are still wired: built-in tools and prompts are recorded.");
|
|
345
|
+
}
|
|
255
346
|
const paths = claudeCodePaths(cwd);
|
|
347
|
+
/**
|
|
348
|
+
* Does a generated entry still name the runner that is running now? After
|
|
349
|
+
* an upgrade, a Node moved by a version manager, or a switch from the
|
|
350
|
+
* machine's npm prefix to the private one, the old absolute path (or the old
|
|
351
|
+
* pinned npx version) is what the host would keep spawning — and "already
|
|
352
|
+
* wrapped" is the last thing that should be printed about it.
|
|
353
|
+
*/
|
|
354
|
+
const nodeFlags = nodeFlagsToCarry();
|
|
355
|
+
const stillCurrent = (config, scriptPath) => {
|
|
356
|
+
if (isRemote(config))
|
|
357
|
+
return false;
|
|
358
|
+
const argv = config.args ?? [];
|
|
359
|
+
if (invocation === "npx")
|
|
360
|
+
return config.command === "npx" && argv.includes(packageSpec(VERSION));
|
|
361
|
+
const at = argv.indexOf(scriptPath);
|
|
362
|
+
if (config.command !== process.execPath || at < 0)
|
|
363
|
+
return false;
|
|
364
|
+
// The node flags in front of the script must be the ones this run carries.
|
|
365
|
+
return argv.slice(0, at).join(" ") === nodeFlags.join(" ");
|
|
366
|
+
};
|
|
256
367
|
let wrapped = 0;
|
|
257
368
|
for (const entry of wanted) {
|
|
258
369
|
if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
|
|
259
|
-
|
|
370
|
+
if (!args.replay || stillCurrent(entry.config, localScenarioPath())) {
|
|
371
|
+
out(` = ${entry.name} — the scenario server (calculated replay)`);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
out(` ~ ${entry.name} — re-writing the scenario server: its runner moved`);
|
|
375
|
+
}
|
|
260
376
|
continue;
|
|
261
377
|
}
|
|
262
378
|
if (entry.name === SCENARIO_SERVER_KEY) {
|
|
@@ -277,15 +393,18 @@ function install(args) {
|
|
|
277
393
|
// spawns), so `env` is present in practice; the cast is only to narrow the
|
|
278
394
|
// remote-or-stdio union.
|
|
279
395
|
const current = entry.config.env?.BIR_CONTROL_URL;
|
|
280
|
-
const
|
|
396
|
+
const urlStale = args.hooks && !args.configPath && current !== controlUrl;
|
|
397
|
+
const pathStale = !stillCurrent(entry.config, localProxyPath());
|
|
281
398
|
const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
|
|
282
|
-
if (!
|
|
399
|
+
if (!(urlStale || pathStale) || !stashed) {
|
|
283
400
|
out(` = ${entry.name} — already wrapped`);
|
|
284
401
|
continue;
|
|
285
402
|
}
|
|
403
|
+
const was = `${entry.config.command} ${(entry.config.args ?? [])[0] ?? ""}`.trim();
|
|
286
404
|
entry.config = stashed;
|
|
287
|
-
out(
|
|
288
|
-
|
|
405
|
+
out(urlStale
|
|
406
|
+
? ` ~ ${entry.name} — re-wrapping: its control URL was ${current ?? "unset"}, now ${controlUrl}`
|
|
407
|
+
: ` ~ ${entry.name} — re-wrapping: its runner moved (was ${was})`);
|
|
289
408
|
}
|
|
290
409
|
// Phase 7.3: writing anywhere but the winning scope changes nothing.
|
|
291
410
|
if (entry.shadowed.length > 0) {
|
|
@@ -299,6 +418,7 @@ function install(args) {
|
|
|
299
418
|
invocation,
|
|
300
419
|
proxyPath: invocation === "npx" ? undefined : localProxyPath(),
|
|
301
420
|
version: VERSION,
|
|
421
|
+
nodeFlags,
|
|
302
422
|
});
|
|
303
423
|
let file;
|
|
304
424
|
let text;
|
|
@@ -336,6 +456,7 @@ function install(args) {
|
|
|
336
456
|
scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
|
|
337
457
|
version: VERSION,
|
|
338
458
|
controlUrl: args.hooks ? controlUrl : undefined,
|
|
459
|
+
nodeFlags,
|
|
339
460
|
});
|
|
340
461
|
// PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
|
|
341
462
|
//
|
|
@@ -373,17 +494,47 @@ function install(args) {
|
|
|
373
494
|
};
|
|
374
495
|
out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
|
|
375
496
|
}
|
|
497
|
+
// SessionStart starts the recorder itself when this directory has none
|
|
498
|
+
// (control/ensure-hook.ts) — but only when the binary can be named by an
|
|
499
|
+
// absolute path. A pinned-npx install keeps the HTTP hook: resolving a
|
|
500
|
+
// package inside a hook is too slow to start a recorder from.
|
|
501
|
+
const ensure = invocation === "npx" ? undefined : { node: hookNodePath(), hooksScript: localHooksPath(), nodeFlags };
|
|
376
502
|
if (args.hooks && !args.configPath) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
503
|
+
// Hooks live in `.claude/settings.local.json` — the person's own file, the
|
|
504
|
+
// one Claude Code keeps out of git — not in the `settings.json` a team
|
|
505
|
+
// commits. They carry a bearer token and an absolute path to this machine's
|
|
506
|
+
// node; a teammate's clone wants neither. An older install put them in the
|
|
507
|
+
// shared file; take them out of there on the way.
|
|
508
|
+
if (hasBirHooks(paths.settings)) {
|
|
509
|
+
backupFile(sidecar, paths.settings);
|
|
510
|
+
const cleaned = uninstallHooks(paths.settings);
|
|
511
|
+
if (cleaned !== undefined)
|
|
512
|
+
noteWritten(sidecar, paths.settings, cleaned);
|
|
513
|
+
out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
|
|
514
|
+
}
|
|
515
|
+
backupFile(sidecar, paths.settingsLocal);
|
|
516
|
+
let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
|
|
517
|
+
if (args.replay) {
|
|
518
|
+
// Pre-approve our own scenario server. Claude Code asks before it uses a
|
|
519
|
+
// server from `.mcp.json`, and a "No" there leaves a direct replay with
|
|
520
|
+
// nowhere to deliver its results — and nothing to say so.
|
|
521
|
+
text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
|
|
522
|
+
}
|
|
523
|
+
noteWritten(sidecar, paths.settingsLocal, text);
|
|
524
|
+
sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
|
|
525
|
+
out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}${ensure ? "; the recorder starts itself with each session" : ""})`);
|
|
526
|
+
const exclude = ensureGitExclude(cwd);
|
|
527
|
+
if (exclude)
|
|
528
|
+
out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude}`);
|
|
382
529
|
}
|
|
383
530
|
writeSidecar(sidecar);
|
|
384
531
|
out();
|
|
385
532
|
out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
|
|
386
|
-
if (args.hooks && !args.configPath) {
|
|
533
|
+
if (args.hooks && !args.configPath && ensure) {
|
|
534
|
+
out("Next: start your session here as usual. The recorder starts itself with it;");
|
|
535
|
+
out("`bir up` starts it now, `bir down` stops it, `bir doctor` checks it.");
|
|
536
|
+
}
|
|
537
|
+
else if (args.hooks && !args.configPath) {
|
|
387
538
|
out("Next: run `bir-hooks` in this directory, then start your session.");
|
|
388
539
|
out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
|
|
389
540
|
}
|
|
@@ -392,8 +543,8 @@ function install(args) {
|
|
|
392
543
|
}
|
|
393
544
|
if (args.replay) {
|
|
394
545
|
out();
|
|
395
|
-
out("Calculated replay is INSTALLED and ON
|
|
396
|
-
out("
|
|
546
|
+
out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
|
|
547
|
+
out("`bir replay allow <servers>` narrows what it may call unattended — read");
|
|
397
548
|
out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
|
|
398
549
|
out("and a directly executed one never reaches the permission system at all.");
|
|
399
550
|
}
|
|
@@ -470,12 +621,24 @@ function uninstall(args) {
|
|
|
470
621
|
continue;
|
|
471
622
|
if (existsSync(settings)) {
|
|
472
623
|
uninstallHooks(settings);
|
|
624
|
+
disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
|
|
473
625
|
out(` ← hooks removed from ${settings}`);
|
|
474
626
|
}
|
|
475
627
|
}
|
|
628
|
+
// An install older than 0.2.9 wrote to the shared settings file; make sure
|
|
629
|
+
// nothing of ours is left there either, whether or not it was recorded.
|
|
630
|
+
if (!restoredWhole.has(paths.settings) && hasBirHooks(paths.settings)) {
|
|
631
|
+
uninstallHooks(paths.settings);
|
|
632
|
+
out(` ← hooks removed from ${paths.settings}`);
|
|
633
|
+
}
|
|
476
634
|
sidecar.hookFiles = [];
|
|
477
635
|
sidecar.files = {};
|
|
636
|
+
if (sidecar.projects)
|
|
637
|
+
delete sidecar.projects[normalizePath(cwd)];
|
|
478
638
|
writeSidecar(sidecar);
|
|
639
|
+
const exclude = removeGitExclude(cwd);
|
|
640
|
+
if (exclude)
|
|
641
|
+
out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
|
|
479
642
|
out();
|
|
480
643
|
out("Uninstalled.");
|
|
481
644
|
return 0;
|
|
@@ -511,8 +674,12 @@ function status(args) {
|
|
|
511
674
|
return 0;
|
|
512
675
|
}
|
|
513
676
|
out(`Directory : ${report.cwd}`);
|
|
514
|
-
out(`BaseIn : ${report.authUrl ?? "(
|
|
515
|
-
out(`Control : ${discovery
|
|
677
|
+
out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
|
|
678
|
+
out(`Control : ${discovery
|
|
679
|
+
? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
|
|
680
|
+
: "not running — `bir up` starts it"}`);
|
|
681
|
+
if (discovery?.logFile)
|
|
682
|
+
out(`Log : ${discovery.logFile}`);
|
|
516
683
|
out("Servers :");
|
|
517
684
|
if (report.servers.length === 0)
|
|
518
685
|
out(" (none configured for this directory)");
|
|
@@ -526,6 +693,7 @@ function status(args) {
|
|
|
526
693
|
async function doctor(args) {
|
|
527
694
|
const cwd = process.cwd();
|
|
528
695
|
const problems = [];
|
|
696
|
+
const notes = [];
|
|
529
697
|
const entries = resolveServers(cwd);
|
|
530
698
|
const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
|
|
531
699
|
const discovery = readDiscovery(cwd);
|
|
@@ -536,13 +704,24 @@ async function doctor(args) {
|
|
|
536
704
|
problems.push(`control server at ${discovery.url} did not answer /health`);
|
|
537
705
|
}
|
|
538
706
|
else {
|
|
539
|
-
problems.push("no control server for this directory — recording will be Tier 2 (MCP only)"
|
|
707
|
+
problems.push("no control server for this directory — recording will be Tier 2 (MCP only); " +
|
|
708
|
+
"`bir up` starts one, and the SessionStart hook starts one with each session");
|
|
540
709
|
}
|
|
541
710
|
const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
|
|
711
|
+
// A recorder with no session yet is the normal state right after `bir setup`
|
|
712
|
+
// and between sessions: the proxies are Claude Code's child processes, so
|
|
713
|
+
// there is nothing to register until a session starts. Reporting that as a
|
|
714
|
+
// problem made the first `bir doctor` a person ever ran exit non-zero on a
|
|
715
|
+
// machine where nothing was wrong.
|
|
716
|
+
const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
|
|
542
717
|
// The guarantee check: a *registered* proxy. Not the config file (which only
|
|
543
718
|
// says what should happen) and not `serverInfo` (which stays the upstream's).
|
|
544
|
-
|
|
545
|
-
|
|
719
|
+
const unregistered = wantWrapped.filter((name) => !registered.has(name));
|
|
720
|
+
if (unregistered.length > 0 && idleRecorder) {
|
|
721
|
+
notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
for (const name of unregistered) {
|
|
546
725
|
problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
|
|
547
726
|
}
|
|
548
727
|
}
|
|
@@ -558,14 +737,16 @@ async function doctor(args) {
|
|
|
558
737
|
// its own `BIR_AUTH_URL` is not evidence about either, and treating it as
|
|
559
738
|
// evidence produced a confident "nothing will be recorded" on a session that
|
|
560
739
|
// was recording perfectly. Ask the recorder; it is the only process that knows.
|
|
561
|
-
const notes = [];
|
|
562
740
|
if (health) {
|
|
563
741
|
if (health.recording === false) {
|
|
564
|
-
problems.push("the recorder is running but has nowhere to send steps —
|
|
565
|
-
"and
|
|
742
|
+
problems.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
|
|
743
|
+
"(or set BIR_AUTH_URL and `bir login`), then `bir up --restart`");
|
|
566
744
|
}
|
|
567
745
|
else {
|
|
568
|
-
notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
|
|
746
|
+
notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
|
|
747
|
+
}
|
|
748
|
+
if (discovery?.daemon) {
|
|
749
|
+
notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
|
|
569
750
|
}
|
|
570
751
|
// Whether a recurring sub-task the service found may actually run mid-task
|
|
571
752
|
// (segmented.md R-LIFE-8). Observe-only is the default and must be visible
|
|
@@ -599,6 +780,29 @@ async function doctor(args) {
|
|
|
599
780
|
problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
|
|
600
781
|
`\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
|
|
601
782
|
}
|
|
783
|
+
// THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
|
|
784
|
+
// per-shell directory, an uninstalled Node, a moved private Node: the hook
|
|
785
|
+
// then fails on every session and the recorder is never started, which
|
|
786
|
+
// records Tier 2 and looks like nothing at all.
|
|
787
|
+
const paths = claudeCodePaths(cwd);
|
|
788
|
+
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
789
|
+
let parsed;
|
|
790
|
+
try {
|
|
791
|
+
parsed = parseJsonFile(file);
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
for (const matcher of parsed?.hooks?.SessionStart ?? []) {
|
|
797
|
+
for (const hook of matcher.hooks ?? []) {
|
|
798
|
+
if (hook.type !== "command" || !hook.command || !isBirHook(hook))
|
|
799
|
+
continue;
|
|
800
|
+
if (!existsSync(hook.command)) {
|
|
801
|
+
problems.push(`the SessionStart hook in ${file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
602
806
|
// THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
|
|
603
807
|
// at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
|
|
604
808
|
// `bir` entry that starts with `npx` has to resolve and unpack the package
|
|
@@ -633,7 +837,8 @@ async function doctor(args) {
|
|
|
633
837
|
const replay = health?.replay;
|
|
634
838
|
if (replay?.enabled) {
|
|
635
839
|
out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
|
|
636
|
-
`minSimilarity=${replay.minSimilarity ?? "?"}`
|
|
840
|
+
`minSimilarity=${replay.minSimilarity ?? "?"}` +
|
|
841
|
+
(replay.source ? ` source=${replay.source}` : ""));
|
|
637
842
|
// An older control server sends only `deriveKey`; read it as the two states
|
|
638
843
|
// it could describe then.
|
|
639
844
|
const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
|
|
@@ -649,7 +854,7 @@ async function doctor(args) {
|
|
|
649
854
|
"matched scenario with a target is declined and the agent does the task");
|
|
650
855
|
}
|
|
651
856
|
const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
|
|
652
|
-
if (idle.length > 0) {
|
|
857
|
+
if (idle.length > 0 && !idleRecorder) {
|
|
653
858
|
notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
|
|
654
859
|
"their steps will fall back to recorded outputs");
|
|
655
860
|
}
|
|
@@ -810,7 +1015,63 @@ async function scenarioCommand(args) {
|
|
|
810
1015
|
* Without `--dry` the same plan runs against the live proxies through the
|
|
811
1016
|
* control server — the Tier 2 and debugging path (docs/calculatedReplay.md §12.1).
|
|
812
1017
|
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* `bir replay status | on | off | allow <a,b> | allow all` — this project's
|
|
1020
|
+
* replay switches, stored in the sidecar so a recorder started by the
|
|
1021
|
+
* SessionStart hook keeps them (generate.ts, `ReplayPolicy`).
|
|
1022
|
+
*/
|
|
1023
|
+
function replayPolicyCommand(args) {
|
|
1024
|
+
const cwd = process.cwd();
|
|
1025
|
+
const sidecar = readSidecar();
|
|
1026
|
+
const record = projectRecord(sidecar, cwd);
|
|
1027
|
+
const word = args.positionals[0];
|
|
1028
|
+
const describe = (policy) => {
|
|
1029
|
+
out(`Replay policy for ${cwd}`);
|
|
1030
|
+
out(` replay ${policy?.enabled === false ? "off" : "on"}`);
|
|
1031
|
+
out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
|
|
1032
|
+
out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
|
|
1033
|
+
const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
|
|
1034
|
+
if (overrides.length) {
|
|
1035
|
+
out(` (set in this shell's environment, which a recorder started from it would prefer: ${overrides.join(", ")})`);
|
|
1036
|
+
}
|
|
1037
|
+
if (!record)
|
|
1038
|
+
out(" (this directory has not been installed — `bir setup` here first)");
|
|
1039
|
+
out("Restart the recorder to apply a change: `bir up --restart`.");
|
|
1040
|
+
};
|
|
1041
|
+
if (word === "status") {
|
|
1042
|
+
describe(record?.replay);
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
if (!record) {
|
|
1046
|
+
out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
|
|
1047
|
+
return 1;
|
|
1048
|
+
}
|
|
1049
|
+
const next = { ...(record.replay ?? {}) };
|
|
1050
|
+
if (word === "on")
|
|
1051
|
+
delete next.enabled;
|
|
1052
|
+
else if (word === "off")
|
|
1053
|
+
next.enabled = false;
|
|
1054
|
+
else if (word === "allow") {
|
|
1055
|
+
const list = args.positionals[1];
|
|
1056
|
+
if (!list) {
|
|
1057
|
+
out("usage: bir replay allow <server,server> | bir replay allow all");
|
|
1058
|
+
return 2;
|
|
1059
|
+
}
|
|
1060
|
+
if (list === "all")
|
|
1061
|
+
delete next.allowServers;
|
|
1062
|
+
else
|
|
1063
|
+
next.allowServers = list.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1064
|
+
}
|
|
1065
|
+
setProjectRecord(sidecar, cwd, { port: record.port, replay: next });
|
|
1066
|
+
writeSidecar(sidecar);
|
|
1067
|
+
describe(next);
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
813
1070
|
async function replayCommand(args) {
|
|
1071
|
+
const word = args.positionals[0];
|
|
1072
|
+
if (word === "status" || word === "on" || word === "off" || word === "allow") {
|
|
1073
|
+
return replayPolicyCommand(args);
|
|
1074
|
+
}
|
|
814
1075
|
const scenarioId = args.scenarioId ?? args.positionals[0];
|
|
815
1076
|
if (!scenarioId || !args.prompt) {
|
|
816
1077
|
out('usage: bir replay --scenario <scnId> --prompt "…" [--dry]');
|
|
@@ -972,6 +1233,65 @@ async function main() {
|
|
|
972
1233
|
}
|
|
973
1234
|
},
|
|
974
1235
|
});
|
|
1236
|
+
case "setup": {
|
|
1237
|
+
// The invocation is decided here, not asked: an installed package gets
|
|
1238
|
+
// absolute paths (--global), a checkout points at itself (--local).
|
|
1239
|
+
const installed = runsFromInstalledPackage();
|
|
1240
|
+
return runSetup({
|
|
1241
|
+
authUrl: args.authUrl,
|
|
1242
|
+
token: args.token,
|
|
1243
|
+
project: args.project,
|
|
1244
|
+
browser: args.browser,
|
|
1245
|
+
replay: !args.noReplay,
|
|
1246
|
+
daemon: !args.noDaemon,
|
|
1247
|
+
allowServers: args.allowServers,
|
|
1248
|
+
}, {
|
|
1249
|
+
out,
|
|
1250
|
+
err: (line) => process.stderr.write(`[bir] ${line}\n`),
|
|
1251
|
+
install: ({ replay }) => install({ ...args, global: installed, local: !installed, replay, hooks: true, servers: [] }),
|
|
1252
|
+
hooksScript: localHooksPath(),
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
case "up": {
|
|
1256
|
+
const cwd = process.cwd();
|
|
1257
|
+
if (args.restart)
|
|
1258
|
+
await stopDaemon(cwd);
|
|
1259
|
+
const { status, started, restarted } = await ensureDaemon(cwd, {
|
|
1260
|
+
hooksScript: localHooksPath(),
|
|
1261
|
+
restartOnSkew: true,
|
|
1262
|
+
});
|
|
1263
|
+
const { info } = status;
|
|
1264
|
+
out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
|
|
1265
|
+
out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
|
|
1266
|
+
if (info.logFile)
|
|
1267
|
+
out(` log ${info.logFile}`);
|
|
1268
|
+
if (status.health.recording === false) {
|
|
1269
|
+
out(" !! it has no session to record with — run `bir setup` or `bir login`, then `bir up --restart`");
|
|
1270
|
+
return 1;
|
|
1271
|
+
}
|
|
1272
|
+
return 0;
|
|
1273
|
+
}
|
|
1274
|
+
case "down": {
|
|
1275
|
+
const cwd = process.cwd();
|
|
1276
|
+
const before = readDiscovery(cwd);
|
|
1277
|
+
if (!before) {
|
|
1278
|
+
out("No recorder is running for this directory.");
|
|
1279
|
+
return 0;
|
|
1280
|
+
}
|
|
1281
|
+
const result = await stopDaemon(cwd);
|
|
1282
|
+
if (result.stale) {
|
|
1283
|
+
out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
|
|
1284
|
+
return 0;
|
|
1285
|
+
}
|
|
1286
|
+
if (!result.stopped) {
|
|
1287
|
+
out(`The recorder (pid ${before.pid}) did not stop.`);
|
|
1288
|
+
return 1;
|
|
1289
|
+
}
|
|
1290
|
+
out(result.forced
|
|
1291
|
+
? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
|
|
1292
|
+
: `Recorder stopped (pid ${before.pid}).`);
|
|
1293
|
+
return 0;
|
|
1294
|
+
}
|
|
975
1295
|
case "login": {
|
|
976
1296
|
// Settle the URL the way every other command does, then refuse to go on
|
|
977
1297
|
// when what is there is not the service. "Signed in" must mean the service
|
|
@@ -1041,6 +1361,9 @@ async function main() {
|
|
|
1041
1361
|
}
|
|
1042
1362
|
if (!session)
|
|
1043
1363
|
return 1;
|
|
1364
|
+
// From here on no terminal needs BIR_AUTH_URL: the address a session was
|
|
1365
|
+
// just issued by is the one every process should use.
|
|
1366
|
+
rememberAuthUrl(baseUrl);
|
|
1044
1367
|
out(`Signed in to ${baseUrl} as ${session.user.email}.`);
|
|
1045
1368
|
return 0;
|
|
1046
1369
|
}
|
package/dist/bin/investigate.js
CHANGED
|
@@ -179,7 +179,11 @@ export const DECLINES = {
|
|
|
179
179
|
},
|
|
180
180
|
known_bad_first_step: {
|
|
181
181
|
cause: "the chain's first step is parked after repeated failures.",
|
|
182
|
-
fix: "
|
|
182
|
+
fix: "wait: the service retries it once an hour for a day, then repairs the plan from the next run of the prompt. To repair now: `bir scenario calc <runId> --force`.",
|
|
183
|
+
},
|
|
184
|
+
nondeterministic_first_step: {
|
|
185
|
+
cause: "the chain's first step needs a judgement the calculation could not write code for, so the plan cannot start.",
|
|
186
|
+
fix: "the agent does this task itself; a model step for such judgements is planned (plan-services.md W3.1).",
|
|
183
187
|
},
|
|
184
188
|
unusable_first_step: {
|
|
185
189
|
cause: "the first step calls a sub-task that is gone, switched off or stale.",
|