@basein/runner 0.3.25 → 0.3.27
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.d.ts +13 -0
- package/dist/bin/investigate.js +72 -2
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +75 -11
- package/dist/control/server.js +440 -77
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +92 -1
- package/dist/replay/controller.js +246 -47
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +41 -0
- package/dist/replay/plan.js +171 -3
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/reads-only.d.ts +48 -0
- package/dist/replay/reads-only.js +161 -0
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/bin/bir.js
CHANGED
|
@@ -46,9 +46,9 @@ import { ControlClient } from "../control/client.js";
|
|
|
46
46
|
import { pidAlive, readDiscovery } from "../control/discovery.js";
|
|
47
47
|
import * as daemonApi from "../control/daemon.js";
|
|
48
48
|
import { activeSessions, daemonStatus, ensureDaemon, probeHealth, readPortProblem, stopDaemon, } from "../control/daemon.js";
|
|
49
|
-
import { configDir, controlDir, controlKey, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
|
|
49
|
+
import { configDir, controlDir, controlKey, DEFAULT_CONTROL_PORT, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
|
|
50
50
|
import { offFlagPath, offSource, projectOffFlagPath, setRunnerOff } from "../util/off-switch.js";
|
|
51
|
-
import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
51
|
+
import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, sidecarReadError, stashStands, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
52
52
|
import { chooseNodePath } from "../config/node-path.js";
|
|
53
53
|
import { claudeUserSettingsPath, managedMcpPolicyBlocker, mcpjsonApprovals } from "../config/mcp-approval.js";
|
|
54
54
|
import { checkOverrides, recordServerName } from "../config/overrides.js";
|
|
@@ -56,11 +56,10 @@ import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeou
|
|
|
56
56
|
import { runSetup } from "./setup.js";
|
|
57
57
|
import { runUpdate } from "./update.js";
|
|
58
58
|
import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
|
|
59
|
-
import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
|
|
59
|
+
import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readJsonOrUndefined, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, UnreadableFileError, } from "../config/adapters/claude-code.js";
|
|
60
60
|
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
61
61
|
import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
|
|
62
62
|
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, inspectAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
63
|
-
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
64
63
|
import { errText, logLine } from "../util/log.js";
|
|
65
64
|
import { journalPath, readJournal } from "../util/journal.js";
|
|
66
65
|
import { investigateCommand } from "./investigate.js";
|
|
@@ -287,10 +286,19 @@ function parseArgs(argv) {
|
|
|
287
286
|
case "--no-correlation":
|
|
288
287
|
args.noCorrelation = true;
|
|
289
288
|
break;
|
|
290
|
-
case "--port":
|
|
291
|
-
|
|
289
|
+
case "--port": {
|
|
290
|
+
// Digits, and a port a URL can name. `Number("abc")` and a forgotten
|
|
291
|
+
// value are both NaN, and install then wrote every hook as
|
|
292
|
+
// http://127.0.0.1:NaN/… — no URL at all, so every hook failed, and
|
|
293
|
+
// none was recognised as ours to take out again (isBirHook).
|
|
294
|
+
const raw = argv[++i] ?? "";
|
|
295
|
+
const port = Number(raw);
|
|
296
|
+
if (!/^\d+$/.test(raw) || port < 1 || port > 65535)
|
|
297
|
+
usage(2);
|
|
298
|
+
args.port = port;
|
|
292
299
|
args.portExplicit = true;
|
|
293
300
|
break;
|
|
301
|
+
}
|
|
294
302
|
case "--auth-url":
|
|
295
303
|
args.authUrl = argv[++i];
|
|
296
304
|
if (!args.authUrl)
|
|
@@ -523,11 +531,16 @@ function isClaudeJson(file, claudeJson) {
|
|
|
523
531
|
* a copy of it in installed.json was also a copy of every project's secrets.
|
|
524
532
|
* Neither is a `--config` file (`entryOnly`), which is usually a client's one
|
|
525
533
|
* global config, with every server's credentials in it. And a `.mcp.json`
|
|
526
|
-
* (the `--mcp-json` shape) is kept without its env and header VALUES
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
534
|
+
* (the `--mcp-json` shape) is kept without its env and header VALUES, a
|
|
535
|
+
* settings file without the values of its own `env` block (generate.ts,
|
|
536
|
+
* protectBackupText): uninstall reads them back from the file as it is then,
|
|
537
|
+
* and restores it byte-for-byte only when that gives the original exactly. A
|
|
538
|
+
* backup an older runner kept verbatim is converted the same way the next
|
|
539
|
+
* time the file is touched.
|
|
540
|
+
*
|
|
541
|
+
* A file that is there and cannot be read throws (readTextOrNull). Taken for a
|
|
542
|
+
* missing one, it went on record as "created by install" — nothing to restore —
|
|
543
|
+
* while the write that followed replaced it.
|
|
531
544
|
*/
|
|
532
545
|
function backupFile(sidecar, file, claudeJson, entryOnly = false) {
|
|
533
546
|
sidecar.files ??= {};
|
|
@@ -616,6 +629,26 @@ function recordedOriginal(record, wrapped) {
|
|
|
616
629
|
return record.original;
|
|
617
630
|
return restoreSecrets(record.original, wrapped);
|
|
618
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* The server a wrapped entry stands for: the original its record stashed,
|
|
634
|
+
* while the entry still says what that stash says (generate.ts, stashStands);
|
|
635
|
+
* otherwise what the entry itself says now. `record`: the one that describes
|
|
636
|
+
* this entry, if there is one.
|
|
637
|
+
*
|
|
638
|
+
* The stash used to win for good. A server changed after its first install —
|
|
639
|
+
* another script after `--`, a key rotated in the entry's env — was then put
|
|
640
|
+
* back as its first-install self by the next `bir setup`, which re-wrapped the
|
|
641
|
+
* stash over it ("re-wrapping: its runner moved"), and by `bir uninstall`.
|
|
642
|
+
* Kept while it stands, it still supplies what a wrapped entry cannot carry
|
|
643
|
+
* (`oauth`, `headersHelper`, `type: "stdio"`).
|
|
644
|
+
*/
|
|
645
|
+
function wrappedOriginal(record, wrapped) {
|
|
646
|
+
const now = unwrapEntry(wrapped);
|
|
647
|
+
const stash = record ? recordedOriginal(record, wrapped) : undefined;
|
|
648
|
+
if (!stash || !now)
|
|
649
|
+
return stash ?? now;
|
|
650
|
+
return stashStands(stash, now) ? stash : now;
|
|
651
|
+
}
|
|
619
652
|
/** Why an existing wrapped entry is being written again, for the log line. */
|
|
620
653
|
function rewrapReason(current, next) {
|
|
621
654
|
if (!current || isRemote(current) || !isWrapped(current))
|
|
@@ -665,10 +698,32 @@ function scenarioServerHere(args, cwd) {
|
|
|
665
698
|
return false;
|
|
666
699
|
}
|
|
667
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* installed.json, for a command that is about to change what it describes —
|
|
703
|
+
* or a refusal. One that is there and cannot be read comes back empty
|
|
704
|
+
* (generate.ts, readSidecar): an install would then hand this project a new
|
|
705
|
+
* port and token and rewrite every hook and entry with them, an uninstall
|
|
706
|
+
* would find no record of anything, and neither could save what it had done.
|
|
707
|
+
* Both stop here instead, before their first write.
|
|
708
|
+
*/
|
|
709
|
+
function readSidecarOrRefuse() {
|
|
710
|
+
const sidecar = readSidecar();
|
|
711
|
+
const unread = sidecarReadError(sidecar);
|
|
712
|
+
if (unread) {
|
|
713
|
+
throw new UnreadableFileError(`${unread}; nothing was changed. Make it readable (its permissions, or the program that holds it open), then run this again.`);
|
|
714
|
+
}
|
|
715
|
+
return sidecar;
|
|
716
|
+
}
|
|
668
717
|
// ── install ────────────────────────────────────────────────────────────────
|
|
669
718
|
async function install(args) {
|
|
670
719
|
const cwd = process.cwd();
|
|
671
|
-
|
|
720
|
+
// `--config` as a full path from here on. It goes on record, and `bir
|
|
721
|
+
// uninstall --all` reads that record from whichever directory it is run in:
|
|
722
|
+
// kept as typed, `client.json` was looked for there, reported as gone, and
|
|
723
|
+
// the real file stayed wrapped behind an "Uninstalled".
|
|
724
|
+
if (args.configPath)
|
|
725
|
+
args = { ...args, configPath: resolvePath(args.configPath) };
|
|
726
|
+
const sidecar = readSidecarOrRefuse();
|
|
672
727
|
const before = projectRecord(sidecar, cwd);
|
|
673
728
|
const invocation = resolveInvocation(args, before?.invocation, before?.invocationChosen);
|
|
674
729
|
const flagged = args.local || args.global || args.npx;
|
|
@@ -696,7 +751,8 @@ async function install(args) {
|
|
|
696
751
|
}
|
|
697
752
|
// REFUSE BEFORE WRITING ANYTHING. A config with comments or a trailing comma
|
|
698
753
|
// is not JSON; rewriting it would drop the comments (or, worse, the file).
|
|
699
|
-
//
|
|
754
|
+
// One that is there and cannot be read would be replaced outright. Checking
|
|
755
|
+
// every file up front means a refusal leaves nothing half-done.
|
|
700
756
|
try {
|
|
701
757
|
if (args.configPath)
|
|
702
758
|
assertEditableJson(args.configPath);
|
|
@@ -708,8 +764,10 @@ async function install(args) {
|
|
|
708
764
|
}
|
|
709
765
|
}
|
|
710
766
|
catch (err) {
|
|
711
|
-
|
|
712
|
-
"
|
|
767
|
+
const fix = err instanceof UnreadableFileError
|
|
768
|
+
? "make it readable (its permissions, or the program that holds it open)"
|
|
769
|
+
: "fix or remove the comments/trailing commas";
|
|
770
|
+
process.stderr.write(`[bir] ${errText(err)}\n[bir] refusing to rewrite it: ${fix}, then run this again. Nothing was changed.\n`);
|
|
713
771
|
return 1;
|
|
714
772
|
}
|
|
715
773
|
// An uninstall that found this project's recorder still serving an open
|
|
@@ -951,7 +1009,7 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
|
|
|
951
1009
|
const found = findRecord(sidecar, cwd, name);
|
|
952
1010
|
let source = config;
|
|
953
1011
|
if (isWrapped(config)) {
|
|
954
|
-
source = (found
|
|
1012
|
+
source = wrappedOriginal(found?.record, config);
|
|
955
1013
|
if (!source) {
|
|
956
1014
|
out(` = ${name} — already wrapped`);
|
|
957
1015
|
continue;
|
|
@@ -982,7 +1040,9 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
|
|
|
982
1040
|
const reason = rewrapReason(config, proxied);
|
|
983
1041
|
backupFile(sidecar, file, claudeJson, true);
|
|
984
1042
|
noteWritten(sidecar, file, setGenericServerEntry(file, name, proxied), claudeJson, true);
|
|
985
|
-
|
|
1043
|
+
// What was just wrapped, never an earlier install's stash: the entry may
|
|
1044
|
+
// have been replaced by another server since (see wrappedOriginal).
|
|
1045
|
+
const kept = redactSecrets(source);
|
|
986
1046
|
putRecord(sidecar, cwd, name, found, {
|
|
987
1047
|
original: kept.config,
|
|
988
1048
|
scope: "explicit",
|
|
@@ -1133,26 +1193,42 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1133
1193
|
let source;
|
|
1134
1194
|
let target;
|
|
1135
1195
|
const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
|
|
1136
|
-
|
|
1196
|
+
// The record that describes the local entry, if one does: a record of
|
|
1197
|
+
// another scope (an older install's, a `--config` file's) says nothing
|
|
1198
|
+
// about it.
|
|
1199
|
+
const own = found?.record.scope === "local" ? found.record : undefined;
|
|
1200
|
+
// A wrapped local entry is a COPY of the server under it when its record
|
|
1201
|
+
// says so — and, with no record of it (installed.json lost, or
|
|
1202
|
+
// ~/.baseinstrunner deleted), when it still says exactly what that server
|
|
1203
|
+
// says. Without the second half `bir setup` took a record-less copy for
|
|
1204
|
+
// the person's own server and froze the team's definition into its stash:
|
|
1205
|
+
// no later change to `.mcp.json` reached this project, and uninstall left
|
|
1206
|
+
// a plain copy shadowing the team's entry for good. The same comparison
|
|
1207
|
+
// uninstall makes for an entry no record explains (uninstallProject).
|
|
1208
|
+
const copy = L !== undefined && isWrapped(L) && (own ? own.override !== undefined : sameServerConfig(unwrapEntry(L), unwrapped(P ?? U)));
|
|
1209
|
+
if (copy) {
|
|
1137
1210
|
source = unwrapped(P ?? U);
|
|
1138
1211
|
target = { kind: "override", from: P !== undefined ? "project" : "user", keys: [...new Set([...overrideKeys, ...holders])] };
|
|
1139
1212
|
if (source === undefined) {
|
|
1140
1213
|
// The server it overrode is gone from .mcp.json and the user scope.
|
|
1141
1214
|
setLocalEntries(claudeJson, target.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
|
|
1142
|
-
|
|
1215
|
+
if (found)
|
|
1216
|
+
delete sidecar.servers[found.key];
|
|
1143
1217
|
out(` - ${name} — its local copy is removed: the server is no longer configured anywhere else`);
|
|
1144
1218
|
continue;
|
|
1145
1219
|
}
|
|
1146
1220
|
}
|
|
1147
1221
|
else if (L !== undefined) {
|
|
1148
|
-
source = isWrapped(L)
|
|
1149
|
-
? (found?.record.scope === "local" ? recordedOriginal(found.record, L) : undefined) ?? unwrapEntry(L)
|
|
1150
|
-
: L;
|
|
1222
|
+
source = isWrapped(L) ? wrappedOriginal(own, L) : L;
|
|
1151
1223
|
target = { kind: "inplace", keys: holders };
|
|
1152
1224
|
}
|
|
1225
|
+
else if (P !== undefined && mcpJsonMode) {
|
|
1226
|
+
source = isWrapped(P) ? wrappedOriginal(found?.record.scope === "project" ? found.record : undefined, P) : P;
|
|
1227
|
+
target = { kind: "mcpjson" };
|
|
1228
|
+
}
|
|
1153
1229
|
else if (P !== undefined) {
|
|
1154
1230
|
source = unwrapped(P);
|
|
1155
|
-
target =
|
|
1231
|
+
target = { kind: "override", from: "project", keys: overrideKeys };
|
|
1156
1232
|
}
|
|
1157
1233
|
else {
|
|
1158
1234
|
source = unwrapped(U);
|
|
@@ -1247,7 +1323,9 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1247
1323
|
}
|
|
1248
1324
|
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1249
1325
|
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, proxied, paths).text, claudeJson);
|
|
1250
|
-
|
|
1326
|
+
// The stash is what was just wrapped (see wrappedOriginal), not what an
|
|
1327
|
+
// earlier install found here.
|
|
1328
|
+
const kept = redactSecrets(source);
|
|
1251
1329
|
putRecord(sidecar, cwd, name, found, {
|
|
1252
1330
|
original: kept.config,
|
|
1253
1331
|
scope: "project",
|
|
@@ -1283,7 +1361,10 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
|
1283
1361
|
});
|
|
1284
1362
|
}
|
|
1285
1363
|
else {
|
|
1286
|
-
|
|
1364
|
+
// The stash is what is wrapped now. Kept from the first install for
|
|
1365
|
+
// good, it was written back over a server the person had since
|
|
1366
|
+
// replaced: by the next setup, and by uninstall (see wrappedOriginal).
|
|
1367
|
+
const kept = redactSecrets(source);
|
|
1287
1368
|
putRecord(sidecar, cwd, name, found, {
|
|
1288
1369
|
original: kept.config,
|
|
1289
1370
|
scope: "local",
|
|
@@ -1607,7 +1688,9 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
|
|
|
1607
1688
|
for (const { file, other } of claudeFiles) {
|
|
1608
1689
|
let claude = {};
|
|
1609
1690
|
try {
|
|
1610
|
-
|
|
1691
|
+
// Strict: one that is there and cannot be read is listed as such, never
|
|
1692
|
+
// read as a file with nothing of ours in it.
|
|
1693
|
+
claude = readJsonOrUndefined(file) ?? {};
|
|
1611
1694
|
}
|
|
1612
1695
|
catch (err) {
|
|
1613
1696
|
unreadable.push(`${file} (${errText(err)})`);
|
|
@@ -1634,7 +1717,7 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
|
|
|
1634
1717
|
}
|
|
1635
1718
|
}
|
|
1636
1719
|
try {
|
|
1637
|
-
const mcp =
|
|
1720
|
+
const mcp = readJsonOrUndefined(paths.mcpJson)?.mcpServers;
|
|
1638
1721
|
for (const [name, config] of Object.entries(mcp ?? {})) {
|
|
1639
1722
|
if (ourEntry(config))
|
|
1640
1723
|
found.push({ name, scope: "project", config, file: paths.mcpJson });
|
|
@@ -1690,6 +1773,14 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1690
1773
|
const here = normalizePath(cwd);
|
|
1691
1774
|
const narrow = opts.servers.length > 0;
|
|
1692
1775
|
const wanted = (name) => !narrow || opts.servers.includes(name);
|
|
1776
|
+
// A `--config` file that an install before this one recorded as it was
|
|
1777
|
+
// typed: relative to the directory that install ran in, not to the one this
|
|
1778
|
+
// runs in (`bir uninstall --all` runs anywhere).
|
|
1779
|
+
for (const record of Object.values(sidecar.servers)) {
|
|
1780
|
+
if (record.scope === "explicit" && typeof record.file === "string" && !isAbsolute(record.file)) {
|
|
1781
|
+
record.file = resolvePath(record.cwd, record.file);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1693
1784
|
const mine = Object.entries(sidecar.servers)
|
|
1694
1785
|
.filter(([, record]) => normalizePath(record.cwd) === here)
|
|
1695
1786
|
.map(([key, record]) => ({ key, record, name: recordName(key, record) }));
|
|
@@ -1705,7 +1796,10 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1705
1796
|
const claudeFileOf = (record) => (record.scope === "local" || record.scope === "user") && typeof record.file === "string" && record.file
|
|
1706
1797
|
? record.file
|
|
1707
1798
|
: claudeJson;
|
|
1708
|
-
|
|
1799
|
+
// Strict, as every read on the way to an undo: a file that is there and
|
|
1800
|
+
// cannot be read throws, and the entry it holds is reported as left, not
|
|
1801
|
+
// as "changed since install" with its record dropped.
|
|
1802
|
+
const readClaude = (file = claudeJson) => readJsonOrUndefined(file) ?? {};
|
|
1709
1803
|
const currentEntry = (record, name) => {
|
|
1710
1804
|
if (record.scope === "explicit")
|
|
1711
1805
|
return readGenericServers(record.file)[name];
|
|
@@ -1720,7 +1814,7 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1720
1814
|
}
|
|
1721
1815
|
if (record.scope === "user")
|
|
1722
1816
|
return readClaude(claudeFileOf(record)).mcpServers?.[name];
|
|
1723
|
-
return
|
|
1817
|
+
return readJsonOrUndefined(record.file)?.mcpServers?.[name];
|
|
1724
1818
|
};
|
|
1725
1819
|
/** Put one entry back (or take it out), touching it only while it is still ours. */
|
|
1726
1820
|
const repair = (record, name) => {
|
|
@@ -1736,9 +1830,11 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1736
1830
|
const now = currentEntry(record, name);
|
|
1737
1831
|
if (!ourEntry(now))
|
|
1738
1832
|
return { what: `left as it is (changed since install)${where}`, done: true };
|
|
1739
|
-
// The stash
|
|
1740
|
-
//
|
|
1741
|
-
|
|
1833
|
+
// The stash, while the wrapped entry still says what it says; else the
|
|
1834
|
+
// original rebuilt from the entry's own arguments (wrappedOriginal) — which
|
|
1835
|
+
// is also what a server changed inside its wrapped entry comes back as.
|
|
1836
|
+
// An entry left wrapped breaks the day the runner is removed.
|
|
1837
|
+
const restored = record.original === null ? null : wrappedOriginal(record, now);
|
|
1742
1838
|
if (restored === undefined)
|
|
1743
1839
|
return { what: `left as it is (its original could not be rebuilt)${where}`, done: false };
|
|
1744
1840
|
if (record.scope === "explicit")
|
|
@@ -1786,7 +1882,17 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1786
1882
|
for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
|
|
1787
1883
|
if (isClaudeJson(file, claudeJson) || othersFiles.has(normalizePath(file)) || !ownsFile(file))
|
|
1788
1884
|
continue;
|
|
1789
|
-
|
|
1885
|
+
let current;
|
|
1886
|
+
try {
|
|
1887
|
+
current = readTextOrNull(file);
|
|
1888
|
+
}
|
|
1889
|
+
catch (err) {
|
|
1890
|
+
// There and not readable: not restored, and not done. The steps below
|
|
1891
|
+
// repair its entries if they can, and this keeps the snapshot and
|
|
1892
|
+
// the records for the next run, which may find it readable.
|
|
1893
|
+
remaining.set(`read:${file}`, `${file} could not be read, so it was not restored (${errText(err)})`);
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1790
1896
|
if (current === null || !backup.writtenSha || backup.stale || sha256(current) !== backup.writtenSha)
|
|
1791
1897
|
continue;
|
|
1792
1898
|
if (backupIsCreation(backup)) {
|
|
@@ -1935,7 +2041,17 @@ function uninstallProject(cwd, sidecar, opts) {
|
|
|
1935
2041
|
if (!narrow) {
|
|
1936
2042
|
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
1937
2043
|
const key = `hooks:${normalizePath(file)}`;
|
|
1938
|
-
if (
|
|
2044
|
+
if (remaining.has(key))
|
|
2045
|
+
continue;
|
|
2046
|
+
try {
|
|
2047
|
+
readTextOrNull(file);
|
|
2048
|
+
}
|
|
2049
|
+
catch (err) {
|
|
2050
|
+
// hasBirHooks reads such a file as one without hooks.
|
|
2051
|
+
remaining.set(key, `${file} could not be read, so hooks of ours may still be in it (${errText(err)})`);
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
if (hasBirHooks(file))
|
|
1939
2055
|
remaining.set(key, `hooks of ours are still in ${file}`);
|
|
1940
2056
|
}
|
|
1941
2057
|
}
|
|
@@ -2100,7 +2216,7 @@ async function uninstall(args) {
|
|
|
2100
2216
|
if (args.all)
|
|
2101
2217
|
return uninstallAll(args);
|
|
2102
2218
|
const cwd = process.cwd();
|
|
2103
|
-
const sidecar =
|
|
2219
|
+
const sidecar = readSidecarOrRefuse();
|
|
2104
2220
|
// `--replay` on its own is a narrow removal: take the scenario server out and
|
|
2105
2221
|
// leave every proxy and the hooks exactly as they are. Somebody turning replay
|
|
2106
2222
|
// off should not have to reinstall recording afterwards.
|
|
@@ -2365,7 +2481,7 @@ async function uninstallAll(args) {
|
|
|
2365
2481
|
process.stderr.write("[bir] --all removes everything; it takes no --server, --replay or --config\n");
|
|
2366
2482
|
return 2;
|
|
2367
2483
|
}
|
|
2368
|
-
const sidecar =
|
|
2484
|
+
const sidecar = readSidecarOrRefuse();
|
|
2369
2485
|
const paths = claudeCodePaths(process.cwd());
|
|
2370
2486
|
const projects = installedProjects(sidecar);
|
|
2371
2487
|
// Every `.claude.json` an install wrote to: this environment's, and any other
|
|
@@ -2830,7 +2946,7 @@ function hookFacts(paths) {
|
|
|
2830
2946
|
facts.ports.push(port);
|
|
2831
2947
|
}
|
|
2832
2948
|
catch {
|
|
2833
|
-
/* isBirHook
|
|
2949
|
+
/* ours, and no URL (isBirHook: a `--port` that was not one): it names no port */
|
|
2834
2950
|
}
|
|
2835
2951
|
}
|
|
2836
2952
|
}
|
|
@@ -102,6 +102,19 @@ export interface Turn {
|
|
|
102
102
|
of?: string;
|
|
103
103
|
reason?: string;
|
|
104
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* The plan was retired before any step ran (`replay.vetoed`): the model
|
|
107
|
+
* changed something itself first, or its `run_scenario` never reached the
|
|
108
|
+
* runner. `code` from runners after 0.3.26; the reason text always.
|
|
109
|
+
*/
|
|
110
|
+
vetoed?: {
|
|
111
|
+
why?: string;
|
|
112
|
+
code?: string;
|
|
113
|
+
};
|
|
114
|
+
/** A direct plan waited through a call that only reads (`replay.direct_waits`, B2 is11.md). */
|
|
115
|
+
waited?: {
|
|
116
|
+
tool?: string;
|
|
117
|
+
};
|
|
105
118
|
/** `replay.*_failed`, `replay.arm_failed`, `replay.diverge`, `replay.handover` … */
|
|
106
119
|
incidents: JournalEntry[];
|
|
107
120
|
events: JournalEntry[];
|
package/dist/bin/investigate.js
CHANGED
|
@@ -164,6 +164,12 @@ export function turnsFromJournal(entries) {
|
|
|
164
164
|
case "run.repeat":
|
|
165
165
|
turn.repeat = { runId: str(e.run), of: str(e.of), reason: str(e.reason) };
|
|
166
166
|
break;
|
|
167
|
+
case "replay.vetoed":
|
|
168
|
+
turn.vetoed ??= { why: str(e.why), code: str(e.code) };
|
|
169
|
+
break;
|
|
170
|
+
case "replay.direct_waits":
|
|
171
|
+
turn.waited ??= { tool: str(e.tool) };
|
|
172
|
+
break;
|
|
167
173
|
case "run.finish":
|
|
168
174
|
turn.finishedAt = e.at;
|
|
169
175
|
turn.finish = {
|
|
@@ -241,6 +247,11 @@ export const DECLINES = {
|
|
|
241
247
|
cause: "the plan was not ready before the hook that would have announced it had to answer: the prompt hook waits 7.5 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.",
|
|
242
248
|
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, and a dry replay shows what the service's reading took, under `reading` (`bir scenario replay <scnId> --prompt \"…\" --dry --json`). 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.",
|
|
243
249
|
},
|
|
250
|
+
// The control server's verdict too: the same plan was cancelled a moment ago.
|
|
251
|
+
after_cancel: {
|
|
252
|
+
cause: "the same plan was cancelled a few minutes earlier (Esc on run_scenario, or its caller timed out) after some of its steps had run, so it was not started again: those steps had done their work, and running them twice would add a second row or send a second mail. The agent was told which steps ran, and did the task.",
|
|
253
|
+
fix: "nothing to do. Ask again after ten minutes if you want the plan to run from its first step. The recorder's log shows the earlier run's `replay.cancelled` line.",
|
|
254
|
+
},
|
|
244
255
|
// Nor is this one: the match itself was late, so nothing was armed at all.
|
|
245
256
|
match_late: {
|
|
246
257
|
cause: "the service's answer to the match landed too late: after the prompt hook had stopped waiting for it (3 s, BIR_MATCH_BUDGET_MS); while prompts were not waiting on the match at all — after one slow answer they stop waiting for a few minutes (`replay.match_gate state=holding`); or with the hook not waiting from the start, because a failure to reach the service was still on record (a `service.degraded` line, perhaps hours before, and no `service.recovered` until after this prompt) or the match request's first attempt failed. The agent did the task itself.",
|
|
@@ -335,6 +346,46 @@ function readingDecline(code, why) {
|
|
|
335
346
|
}
|
|
336
347
|
return undefined;
|
|
337
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Why a plan that armed ran no step (`replay.vetoed`). The recording page
|
|
351
|
+
* once said "the runner did not say why" of such a turn, and guessed wrong
|
|
352
|
+
* (B2 is11.md): the runner had said, here, and only here.
|
|
353
|
+
*/
|
|
354
|
+
function vetoFinding(turn, v) {
|
|
355
|
+
const why = (v.why ?? "").replace(/ — the plan is retired without running any step$/, "");
|
|
356
|
+
const scenarioId = turn.plan?.scenarioId ?? turn.matched?.scenarioId ?? "<scnId>";
|
|
357
|
+
if (v.code === "run_refused" || (!v.code && /\brun_scenario (was called|failed|answered)\b/.test(why))) {
|
|
358
|
+
return {
|
|
359
|
+
code: "vetoed_run_refused",
|
|
360
|
+
severity: "info",
|
|
361
|
+
title: `The plan was not run: ${why}.`,
|
|
362
|
+
cause: "run_scenario was refused — by the person, or by a deny rule — or it failed before it reached the runner. Refused means refused: the plan ran no step, and the agent did the task.",
|
|
363
|
+
fix: "nothing, if it was refused on purpose. If it failed: `bir doctor` shows whether the bir server runs here, and `bir setup` lets Claude Code run it without asking.",
|
|
364
|
+
evidence: { code: v.code ?? null },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const tool = /\bfirst call (?:that may change something )?was (\S+), not run_scenario\b/.exec(why)?.[1];
|
|
368
|
+
if (!v.code) {
|
|
369
|
+
// Runners up to 0.3.26: any first call retired the plan, and no reason
|
|
370
|
+
// went with the report.
|
|
371
|
+
return {
|
|
372
|
+
code: "vetoed_first_call",
|
|
373
|
+
severity: "warn",
|
|
374
|
+
title: `The plan armed, and was not run: ${why}.`,
|
|
375
|
+
cause: "this runner retires a direct plan at the agent's first call that is not run_scenario, whatever it is — a file read included. A careful agent looks before it runs a plan that writes, the more so when the note names the recording's values where this request has others (another person, another date), and the replay is lost for it: the steps would have taken this request's values.",
|
|
376
|
+
fix: `update the runner when a version after 0.3.26 is out (\`bir update\`): it keeps the plan through calls that only read, and its note names this request's values in place of the recording's. \`bir scenario show ${scenarioId}\` shows which values the steps read from the request.`,
|
|
377
|
+
evidence: { tool: tool ?? null },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
code: "vetoed_changed_first",
|
|
382
|
+
severity: "warn",
|
|
383
|
+
title: `The plan armed, and was not run: ${why}.`,
|
|
384
|
+
cause: `the agent changed something itself (${tool ?? "a call"}) before it called run_scenario. Run after that, the plan could do the same work twice, so it ran no step and the agent did the task. Calls that only read before it would have kept the plan.`,
|
|
385
|
+
fix: `nothing is wrong with the plan. Why the agent went its own way is in its first lines of the turn; \`bir scenario show ${scenarioId}\` shows which values the steps read from the request.`,
|
|
386
|
+
evidence: { code: v.code, tool: tool ?? null },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
338
389
|
export const LOW_SAVING_PCT = 0.3;
|
|
339
390
|
const usd = (v) => `$${v.toFixed(4)}`;
|
|
340
391
|
const pct = (v) => `${(v * 100).toFixed(1)}%`;
|
|
@@ -380,6 +431,17 @@ export function localFindings(turn) {
|
|
|
380
431
|
fix: "Check the incidents below and `bir doctor`; the service section says whether the scenario is ready.",
|
|
381
432
|
});
|
|
382
433
|
}
|
|
434
|
+
if (turn.vetoed)
|
|
435
|
+
out.push(vetoFinding(turn, turn.vetoed));
|
|
436
|
+
else if (turn.waited) {
|
|
437
|
+
out.push({
|
|
438
|
+
code: "direct_waited",
|
|
439
|
+
severity: "info",
|
|
440
|
+
title: `The agent's first call only read (${turn.waited.tool ?? "a read"}), and the plan waited for run_scenario.`,
|
|
441
|
+
cause: "A call that only reads does not retire a direct plan: it ran as the agent wrote it, and the agent was told once that the plan was still ready.",
|
|
442
|
+
fix: "",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
383
445
|
if (turn.plan) {
|
|
384
446
|
const coverage = turn.plan.coverage ?? [];
|
|
385
447
|
const tools = turn.plan.tools ?? [];
|
|
@@ -414,10 +476,16 @@ export function localFindings(turn) {
|
|
|
414
476
|
});
|
|
415
477
|
}
|
|
416
478
|
if (turn.plan.mode === "direct") {
|
|
479
|
+
const steps = turn.plan.steps ?? coverage.length;
|
|
480
|
+
// A plan that armed ran nothing when it was retired first, or the turn
|
|
481
|
+
// reported it not steered: "28 steps ran" of such a turn was false.
|
|
482
|
+
const ranNone = turn.vetoed !== undefined || turn.reported?.outcome === "not_steered";
|
|
417
483
|
out.push({
|
|
418
484
|
code: "direct_mode",
|
|
419
485
|
severity: "info",
|
|
420
|
-
title:
|
|
486
|
+
title: ranNone
|
|
487
|
+
? `The plan armed in direct mode, ${steps} steps on the proxies and the runner, and none of them ran.`
|
|
488
|
+
: `The plan armed in direct mode: ${steps} steps ran on the proxies and the runner, no model in the loop.`,
|
|
421
489
|
cause: "Every step is a wrapped MCP tool this session has a proxy for, or a built-in the runner runs itself.",
|
|
422
490
|
fix: "",
|
|
423
491
|
});
|
|
@@ -809,7 +877,9 @@ export async function investigateCommand(args, deps) {
|
|
|
809
877
|
: t.decision?.verdict === "no-steer"
|
|
810
878
|
? `declined ${t.decision.code ?? ""}`.trim()
|
|
811
879
|
: t.plan
|
|
812
|
-
? `${t.plan.mode} ${t.done?.outcome ?? t.reported?.outcome ?? "armed"}
|
|
880
|
+
? `${t.plan.mode} ${t.done?.outcome ?? t.reported?.outcome ?? "armed"}${
|
|
881
|
+
// Retired before any step ran: the reason, where the runner gave one.
|
|
882
|
+
t.vetoed ? ` (${t.vetoed.code ?? "not run"})` : ""}`
|
|
813
883
|
: "matched";
|
|
814
884
|
const p = savedPct(t);
|
|
815
885
|
const saved = t.reported?.savedUsd != null ? ` saved ${usd(t.reported.savedUsd)}${p != null ? ` (${pct(p)})` : ""}` : "";
|
|
@@ -68,6 +68,11 @@ export interface EditReport {
|
|
|
68
68
|
before: InputStatus;
|
|
69
69
|
after: InputStatus;
|
|
70
70
|
regressed: boolean;
|
|
71
|
+
/** Its test (autocorrect.md AC5) said yes over the recording and no longer does: then `after` is `not_checked`. */
|
|
72
|
+
test?: {
|
|
73
|
+
says: "no" | "throws" | "not_run";
|
|
74
|
+
error?: string;
|
|
75
|
+
};
|
|
71
76
|
}>;
|
|
72
77
|
answer: {
|
|
73
78
|
informativeBefore: boolean;
|
|
@@ -480,7 +480,10 @@ export function renderReport(out, r, opts = {}) {
|
|
|
480
480
|
}
|
|
481
481
|
else {
|
|
482
482
|
for (const l of regressed) {
|
|
483
|
-
|
|
483
|
+
// A step stopped by its test was not run, so "reproduced before; now it
|
|
484
|
+
// cannot be checked" said neither what happened nor what was before.
|
|
485
|
+
const what = l.test ? laterTestPhrase(l.test) : `reproduced before; now it ${laterPhrase(l.after)}`;
|
|
486
|
+
out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) ${what}`));
|
|
484
487
|
}
|
|
485
488
|
}
|
|
486
489
|
if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
|
|
@@ -532,6 +535,19 @@ function laterPhrase(status) {
|
|
|
532
535
|
return status;
|
|
533
536
|
}
|
|
534
537
|
}
|
|
538
|
+
/** What a later step's test says now, over what the changed step emits. */
|
|
539
|
+
function laterTestPhrase(test) {
|
|
540
|
+
switch (test.says) {
|
|
541
|
+
case "no":
|
|
542
|
+
return "— its test said yes over the recording, and now says no";
|
|
543
|
+
case "throws":
|
|
544
|
+
return `— its test said yes over the recording, and now throws${test.error ? `: ${test.error}` : ""}`;
|
|
545
|
+
case "not_run":
|
|
546
|
+
return "— its test could not be run (it overran a few minutes ago and is held), so it is not known to pass";
|
|
547
|
+
default:
|
|
548
|
+
return `— its test now says ${test.says}`;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
535
551
|
/** A report's `MarkState` or a history entry's `StepState`, in the words the report uses. */
|
|
536
552
|
const markWord = (m) => (m.marked ?? m.nondeterministic)
|
|
537
553
|
? `needs a judgement (${m.why ?? m.nondeterministicWhy ?? "?"})`
|