@basein/runner 0.2.12 → 0.3.1
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 +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
|
@@ -7,16 +7,25 @@
|
|
|
7
7
|
* runs. `resolveServers` reports the winner; this module writes there and warns
|
|
8
8
|
* loudly when a lower scope also defines the name.
|
|
9
9
|
*
|
|
10
|
-
* Byte-for-byte reversibility: the *text* of every file touched is
|
|
11
|
-
* the first edit, together with a hash of what we wrote. On
|
|
12
|
-
* still matching that hash
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Byte-for-byte reversibility: the *text* of every project file touched is
|
|
11
|
+
* stashed before the first edit, together with a hash of what we wrote. On
|
|
12
|
+
* uninstall, a file still matching that hash — and not changed by anybody else
|
|
13
|
+
* between two of our writes — is restored verbatim; a file edited since is
|
|
14
|
+
* repaired entry-by-entry instead, with a warning. `~/.claude.json` is always
|
|
15
|
+
* repaired entry by entry: it is Claude Code's own, rewritten constantly, and
|
|
16
|
+
* holds every project's settings.
|
|
17
|
+
*
|
|
18
|
+
* Where the wrapped entries go (bir.ts, install): the LOCAL scope, as an
|
|
19
|
+
* override of the same name — never the team's `.mcp.json`, which gets
|
|
20
|
+
* committed and would carry this machine's node and runner paths to every
|
|
21
|
+
* clone, and never the user scope, which applies to every project.
|
|
15
22
|
*/
|
|
16
23
|
import { createHash } from "node:crypto";
|
|
17
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync
|
|
18
|
-
import { dirname, join, parse as parsePath } from "node:path";
|
|
19
|
-
import {
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
26
|
+
import { writeFileAtomic } from "../../util/atomic-write.js";
|
|
27
|
+
import { FIRST_PROJECT_PORT, PACKAGE_NAME } from "../generate.js";
|
|
28
|
+
import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
|
|
20
29
|
export function claudeCodePaths(cwd, claudeJsonOverride) {
|
|
21
30
|
return {
|
|
22
31
|
claudeJson: claudeJsonPath(claudeJsonOverride),
|
|
@@ -34,12 +43,20 @@ export const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
|
|
|
34
43
|
* `.git/info/exclude`, never `.gitignore`: the first is the person's own,
|
|
35
44
|
* untracked list; the second is a file the team owns and would see in a diff.
|
|
36
45
|
* Walks up from `dir` to find the repository, follows a worktree's `gitdir:`
|
|
37
|
-
* pointer
|
|
46
|
+
* pointer to the repository's common git directory (the only exclude file git
|
|
47
|
+
* reads for a linked worktree), and appends the line once. Returns the exclude file's path when the
|
|
38
48
|
* line is there (added or already present), undefined outside a repository or
|
|
39
49
|
* when the file could not be written — an exclude that fails is a note, not a
|
|
40
50
|
* failed install.
|
|
41
51
|
*/
|
|
42
52
|
export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
53
|
+
return ensureGitExcludeLine(dir, line)?.path;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link ensureGitExclude}, also saying whether the line was ADDED now (false:
|
|
57
|
+
* it was already there — somebody's own, which uninstall must leave alone).
|
|
58
|
+
*/
|
|
59
|
+
export function ensureGitExcludeLine(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
43
60
|
let gitDir;
|
|
44
61
|
let current = dir;
|
|
45
62
|
for (;;) {
|
|
@@ -51,9 +68,12 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
|
51
68
|
}
|
|
52
69
|
else {
|
|
53
70
|
// A worktree or submodule: `.git` is a file naming the real directory.
|
|
71
|
+
// RESOLVED, not joined: `git worktree add` writes an absolute
|
|
72
|
+
// pointer, and join() would nest it under this directory — a junk
|
|
73
|
+
// `home/…/.git/worktrees/<name>/info/exclude` tree in the checkout.
|
|
54
74
|
const pointer = readFileSync(candidate, "utf8").match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
|
|
55
75
|
if (pointer)
|
|
56
|
-
gitDir =
|
|
76
|
+
gitDir = commonGitDir(resolvePath(current, pointer));
|
|
57
77
|
}
|
|
58
78
|
}
|
|
59
79
|
catch {
|
|
@@ -66,7 +86,9 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
|
66
86
|
break;
|
|
67
87
|
current = parent;
|
|
68
88
|
}
|
|
69
|
-
|
|
89
|
+
// A pointer to nowhere (a moved or pruned repository) is no repository: its
|
|
90
|
+
// directory is never created here.
|
|
91
|
+
if (!gitDir || !existsSync(gitDir))
|
|
70
92
|
return undefined;
|
|
71
93
|
try {
|
|
72
94
|
const infoDir = join(gitDir, "info");
|
|
@@ -77,13 +99,33 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
|
77
99
|
if (!lines.includes(line)) {
|
|
78
100
|
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
79
101
|
appendFileSync(exclude, `${sep}${line}\n`);
|
|
102
|
+
return { path: exclude, added: true };
|
|
80
103
|
}
|
|
81
|
-
return exclude;
|
|
104
|
+
return { path: exclude, added: false };
|
|
82
105
|
}
|
|
83
106
|
catch {
|
|
84
107
|
return undefined;
|
|
85
108
|
}
|
|
86
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The git directory whose `info/exclude` git actually reads for `gitDir`. A
|
|
112
|
+
* linked worktree's own directory (`<repo>/.git/worktrees/<name>`) has a
|
|
113
|
+
* `commondir` file naming the repository's, and git reads only that one's
|
|
114
|
+
* exclude file — a line in the per-worktree one is ignored. A submodule's
|
|
115
|
+
* `.git/modules/<name>` has no `commondir` and is its own.
|
|
116
|
+
*/
|
|
117
|
+
function commonGitDir(gitDir) {
|
|
118
|
+
try {
|
|
119
|
+
const commondir = join(gitDir, "commondir");
|
|
120
|
+
if (!existsSync(commondir))
|
|
121
|
+
return gitDir;
|
|
122
|
+
const common = readFileSync(commondir, "utf8").trim();
|
|
123
|
+
return common ? resolvePath(gitDir, common) : gitDir;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return gitDir;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
87
129
|
/** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
|
|
88
130
|
export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
89
131
|
const exclude = ensureGitExclude(dir, line); // finds the file (and would add the line, which is there)
|
|
@@ -94,7 +136,7 @@ export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
|
94
136
|
.split(/\r?\n/)
|
|
95
137
|
.filter((l) => l.trim() !== line);
|
|
96
138
|
const text = kept.join("\n").replace(/\n+$/, "");
|
|
97
|
-
|
|
139
|
+
writeFileAtomic(exclude, text ? `${text}\n` : "");
|
|
98
140
|
return exclude;
|
|
99
141
|
}
|
|
100
142
|
catch {
|
|
@@ -110,24 +152,29 @@ export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
|
|
|
110
152
|
* settings, so the question is never asked. Returns the file's new text.
|
|
111
153
|
*/
|
|
112
154
|
export function enableMcpjsonServers(settingsPath, names) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
155
|
+
return editJson(settingsPath, (parsed) => {
|
|
156
|
+
const current = Array.isArray(parsed.enabledMcpjsonServers)
|
|
157
|
+
? parsed.enabledMcpjsonServers.map(String)
|
|
158
|
+
: [];
|
|
159
|
+
parsed.enabledMcpjsonServers = [...new Set([...current, ...names])];
|
|
160
|
+
});
|
|
119
161
|
}
|
|
120
162
|
/** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
|
|
121
163
|
export function disableMcpjsonServers(settingsPath, names) {
|
|
122
164
|
const parsed = parseJsonFile(settingsPath);
|
|
123
165
|
if (!parsed || !Array.isArray(parsed.enabledMcpjsonServers))
|
|
124
166
|
return undefined;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
if (!parsed.enabledMcpjsonServers.map(String).some((n) => names.includes(n)))
|
|
168
|
+
return undefined;
|
|
169
|
+
return editJson(settingsPath, (fresh) => {
|
|
170
|
+
if (!Array.isArray(fresh.enabledMcpjsonServers))
|
|
171
|
+
return;
|
|
172
|
+
const kept = fresh.enabledMcpjsonServers.map(String).filter((n) => !names.includes(n));
|
|
173
|
+
if (kept.length === 0)
|
|
174
|
+
delete fresh.enabledMcpjsonServers;
|
|
175
|
+
else
|
|
176
|
+
fresh.enabledMcpjsonServers = kept;
|
|
177
|
+
});
|
|
131
178
|
}
|
|
132
179
|
/** True when `settingsPath` holds any hook of ours. */
|
|
133
180
|
export function hasBirHooks(settingsPath) {
|
|
@@ -167,11 +214,51 @@ export function readTextOrNull(path) {
|
|
|
167
214
|
return null;
|
|
168
215
|
}
|
|
169
216
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Read-modify-write one JSON file, safely:
|
|
219
|
+
*
|
|
220
|
+
* - a file that is not plain JSON (comments, trailing commas, a torn write)
|
|
221
|
+
* is REFUSED with an error naming it, never rewritten from a guess;
|
|
222
|
+
* - the file is re-read just before the write, and the edit is applied again
|
|
223
|
+
* to the fresh text when somebody else changed it in between (Claude Code
|
|
224
|
+
* rewrites `~/.claude.json` constantly, and a lost update there loses a
|
|
225
|
+
* server the user just added, or ours);
|
|
226
|
+
* - the write itself is atomic (util/atomic-write.ts), so Claude Code never
|
|
227
|
+
* reads a half-written config, and a symlinked file stays a symlink.
|
|
228
|
+
*
|
|
229
|
+
* `mutate` gets a fresh parse each attempt and must not keep state between
|
|
230
|
+
* calls. Returns the text written, for the sidecar's hash.
|
|
231
|
+
*/
|
|
232
|
+
export function editJson(path, mutate) {
|
|
233
|
+
for (let attempt = 0;; attempt += 1) {
|
|
234
|
+
const before = readTextOrNull(path);
|
|
235
|
+
const value = before === null ? {} : parseJsonText(before, path);
|
|
236
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
237
|
+
throw new Error(`refusing to rewrite ${path}: it is not a JSON object`);
|
|
238
|
+
}
|
|
239
|
+
const parsed = value;
|
|
240
|
+
mutate(parsed);
|
|
241
|
+
const text = JSON.stringify(parsed, null, 2) + "\n";
|
|
242
|
+
if (attempt < 3 && readTextOrNull(path) !== before)
|
|
243
|
+
continue;
|
|
244
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
245
|
+
writeFileAtomic(path, text);
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Throw (naming the file) when `path` exists and is not a plain JSON object —
|
|
251
|
+
* so an install can refuse up front, before it has written anything, instead
|
|
252
|
+
* of stopping halfway through with some files changed and others not.
|
|
253
|
+
*/
|
|
254
|
+
export function assertEditableJson(path) {
|
|
255
|
+
const text = readTextOrNull(path);
|
|
256
|
+
if (text === null)
|
|
257
|
+
return;
|
|
258
|
+
const value = parseJsonText(text, path);
|
|
259
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
260
|
+
throw new Error(`${path} is not a JSON object`);
|
|
261
|
+
}
|
|
175
262
|
}
|
|
176
263
|
/**
|
|
177
264
|
* Set (or delete, with `entry === null`) one MCP server entry in `scope`.
|
|
@@ -179,27 +266,78 @@ function writeJson(path, value) {
|
|
|
179
266
|
*/
|
|
180
267
|
export function setServerEntry(scope, cwd, name, entry, paths) {
|
|
181
268
|
const file = fileForScope(scope, paths);
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
269
|
+
const text = editJson(file, (parsed) => {
|
|
270
|
+
if (scope === "local") {
|
|
271
|
+
const projects = parsed.projects ?? {};
|
|
272
|
+
const key = projectKeyFor(projects, cwd);
|
|
273
|
+
const project = projects[key] ?? {};
|
|
274
|
+
const servers = { ...(project.mcpServers ?? {}) };
|
|
275
|
+
if (entry === null)
|
|
276
|
+
delete servers[name];
|
|
277
|
+
else
|
|
278
|
+
servers[name] = entry;
|
|
279
|
+
parsed.projects = { ...projects, [key]: { ...project, mcpServers: servers } };
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const servers = { ...(parsed.mcpServers ?? {}) };
|
|
283
|
+
if (entry === null)
|
|
284
|
+
delete servers[name];
|
|
285
|
+
else
|
|
286
|
+
servers[name] = entry;
|
|
287
|
+
parsed.mcpServers = servers;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return { file, text };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Set (or delete) one entry of the local scope under each of `keys` in
|
|
294
|
+
* `~/.claude.json`, in ONE atomic edit. `shouldTouch` sees the entry currently
|
|
295
|
+
* under a key (or undefined) and may veto that key — so a user's own entry
|
|
296
|
+
* under another spelling of the project is never overwritten or deleted.
|
|
297
|
+
* Returns the text written and the keys actually changed (none: no write).
|
|
298
|
+
*/
|
|
299
|
+
export function setLocalEntries(claudeJson, keys, name, entry, shouldTouch = () => true) {
|
|
300
|
+
let changed = [];
|
|
301
|
+
const probe = parseJsonFile(claudeJson);
|
|
302
|
+
const wanted = keys.filter((key) => {
|
|
303
|
+
const current = probe?.projects?.[key]?.mcpServers?.[name];
|
|
304
|
+
if (!shouldTouch(current))
|
|
305
|
+
return false;
|
|
196
306
|
if (entry === null)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
307
|
+
return current !== undefined;
|
|
308
|
+
return JSON.stringify(current) !== JSON.stringify(entry);
|
|
309
|
+
});
|
|
310
|
+
if (wanted.length === 0)
|
|
311
|
+
return { keys: [] };
|
|
312
|
+
const text = editJson(claudeJson, (parsed) => {
|
|
313
|
+
changed = [];
|
|
314
|
+
const projects = { ...(parsed.projects ?? {}) };
|
|
315
|
+
for (const key of keys) {
|
|
316
|
+
const project = projects[key] ?? {};
|
|
317
|
+
const current = project.mcpServers?.[name];
|
|
318
|
+
if (!shouldTouch(current))
|
|
319
|
+
continue;
|
|
320
|
+
if (entry === null && current === undefined)
|
|
321
|
+
continue;
|
|
322
|
+
const servers = { ...(project.mcpServers ?? {}) };
|
|
323
|
+
if (entry === null)
|
|
324
|
+
delete servers[name];
|
|
325
|
+
else
|
|
326
|
+
servers[name] = entry;
|
|
327
|
+
const next = { ...project, mcpServers: servers };
|
|
328
|
+
// A project key holding nothing but an empty `mcpServers` is one an
|
|
329
|
+
// install created before Claude Code ever ran there (Claude Code's own
|
|
330
|
+
// entries carry its trust and permission state). Taking our last entry
|
|
331
|
+
// out takes the key with it, so uninstall leaves the file as it found it.
|
|
332
|
+
if (entry === null && Object.keys(next).length === 1 && Object.keys(servers).length === 0)
|
|
333
|
+
delete projects[key];
|
|
334
|
+
else
|
|
335
|
+
projects[key] = next;
|
|
336
|
+
changed.push(key);
|
|
337
|
+
}
|
|
338
|
+
parsed.projects = projects;
|
|
339
|
+
});
|
|
340
|
+
return { text, keys: changed };
|
|
203
341
|
}
|
|
204
342
|
// ── hooks ──────────────────────────────────────────────────────────────────
|
|
205
343
|
/**
|
|
@@ -210,49 +348,67 @@ export function setServerEntry(scope, cwd, name, entry, paths) {
|
|
|
210
348
|
* only listens to the success one records a step that never closes.
|
|
211
349
|
* `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
|
|
212
350
|
* subagent's steps be attributed rather than silently interleaved.
|
|
351
|
+
*
|
|
352
|
+
* THE TIMEOUTS ARE THE USER'S WORST CASE. Claude Code waits for a hung hook up
|
|
353
|
+
* to its `timeout`, so each number here is how long a slow or blackholed
|
|
354
|
+
* service could make a person wait — at every prompt, every tool call, every
|
|
355
|
+
* `/exit`. The control server keeps its own per-route deadlines well inside
|
|
356
|
+
* them (prompt ≤ 7 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
|
|
357
|
+
* start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
|
|
358
|
+
* defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
|
|
359
|
+
* default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
|
|
360
|
+
* used to be turned every `/exit` during a service incident into a 30 s wait.
|
|
361
|
+
* A re-run of `bir setup` rewrites existing installs with these.
|
|
213
362
|
*/
|
|
214
363
|
export const HOOK_ROUTES = [
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
364
|
+
// The HTTP form, used only when no self-starting hook can be written.
|
|
365
|
+
{ event: "SessionStart", route: "/session/start", matcher: "", timeout: 5 },
|
|
366
|
+
// Awaits the similar-prompt match and the derivation before it can return a
|
|
367
|
+
// steering directive (docs/calculatedReplay.md §10); the server caps the
|
|
368
|
+
// whole route at 7 s.
|
|
220
369
|
//
|
|
221
370
|
// It must NEVER become `async`. An async hook is fire-and-forget, so it cannot
|
|
222
371
|
// return `additionalContext` at all — replay would silently never arm.
|
|
223
|
-
{ event: "UserPromptSubmit", route: "/session/prompt", matcher: "", timeout:
|
|
372
|
+
{ event: "UserPromptSubmit", route: "/session/prompt", matcher: "", timeout: 10 },
|
|
224
373
|
// PreToolUse and PostToolUse are likewise NOT async, and that is load-bearing
|
|
225
374
|
// twice over: correlation needs `updatedInput` back, and a steered replay needs
|
|
226
375
|
// step N's output threaded into `respParams` *before* step N+1's input is
|
|
227
|
-
// computed (docs/calculatedReplay.md §7.3).
|
|
228
|
-
|
|
229
|
-
{ event: "
|
|
230
|
-
{ event: "
|
|
231
|
-
{ event: "
|
|
232
|
-
{ event: "
|
|
233
|
-
{ event: "
|
|
234
|
-
{ event: "
|
|
376
|
+
// computed (docs/calculatedReplay.md §7.3). PreToolUse may run an in-hook
|
|
377
|
+
// replay step, capped by the server at 12 s.
|
|
378
|
+
{ event: "PreToolUse", route: "/tool/pre", matcher: "*", timeout: 15 },
|
|
379
|
+
{ event: "PostToolUse", route: "/tool/post", matcher: "*", timeout: 5 },
|
|
380
|
+
{ event: "PostToolUseFailure", route: "/tool/post", matcher: "*", timeout: 5 },
|
|
381
|
+
{ event: "SubagentStart", route: "/session/agent", matcher: "", timeout: 3 },
|
|
382
|
+
{ event: "SubagentStop", route: "/session/agent", matcher: "", timeout: 3 },
|
|
383
|
+
{ event: "Stop", route: "/session/stop", matcher: "", timeout: 5 },
|
|
384
|
+
{ event: "StopFailure", route: "/session/stop", matcher: "", timeout: 5 },
|
|
235
385
|
// NOT async. An async hook is fire-and-forget, and `SessionEnd` fires while the
|
|
236
386
|
// host is exiting — the exit wins that race and the request is never sent, so
|
|
237
|
-
// every run was left unfinished on the server.
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
{ event: "SessionEnd", route: "/session/end", matcher: "", timeout: 30 },
|
|
387
|
+
// every run was left unfinished on the server. The server answers within 1 s
|
|
388
|
+
// and finishes the upload in the background; 3 s is margin, not a wait.
|
|
389
|
+
{ event: "SessionEnd", route: "/session/end", matcher: "", timeout: 3 },
|
|
241
390
|
];
|
|
242
391
|
/**
|
|
243
392
|
* The argument that makes a 'bir-hooks' invocation the SessionStart hook.
|
|
244
393
|
* Exported so the binary and the adapter cannot drift apart on the spelling.
|
|
245
394
|
*/
|
|
246
395
|
export const ENSURE_ARG = "ensure";
|
|
247
|
-
/**
|
|
248
|
-
|
|
396
|
+
/**
|
|
397
|
+
* How long SessionStart may wait for a background recorder to come up. The
|
|
398
|
+
* ensure hook keeps its own budget at 12 s or less and always exits 0; this is
|
|
399
|
+
* the host's ceiling on top of it.
|
|
400
|
+
*/
|
|
401
|
+
export const ENSURE_TIMEOUT_S = 15;
|
|
249
402
|
/** Build the 'hooks' block pointing at a control server. */
|
|
250
403
|
export function buildHooksBlock(controlUrl, token, opts = {}) {
|
|
251
404
|
const base = controlUrl.replace(/\/+$/, "");
|
|
252
405
|
const block = {};
|
|
253
406
|
for (const { event, route, matcher, timeout, async } of HOOK_ROUTES) {
|
|
254
407
|
let hook;
|
|
255
|
-
if (event === "SessionStart" && opts.ensure) {
|
|
408
|
+
if (event === "SessionStart" && opts.ensure && "command" in opts.ensure) {
|
|
409
|
+
hook = { type: "command", command: opts.ensure.command, args: [...opts.ensure.args], timeout: ENSURE_TIMEOUT_S };
|
|
410
|
+
}
|
|
411
|
+
else if (event === "SessionStart" && opts.ensure && "node" in opts.ensure) {
|
|
256
412
|
hook = {
|
|
257
413
|
type: "command",
|
|
258
414
|
command: opts.ensure.node,
|
|
@@ -275,55 +431,89 @@ export function buildHooksBlock(controlUrl, token, opts = {}) {
|
|
|
275
431
|
}
|
|
276
432
|
return block;
|
|
277
433
|
}
|
|
434
|
+
/** A loopback bearer token as `bir install` makes them: 32 random bytes, hex. */
|
|
435
|
+
const OUR_TOKEN = /^Bearer [0-9a-f]{64}$/i;
|
|
278
436
|
/**
|
|
279
|
-
* True when a hook entry is one of ours
|
|
280
|
-
*
|
|
437
|
+
* True when a hook entry is one of ours, and only then — install and uninstall
|
|
438
|
+
* delete what this says is ours, so a false positive deletes somebody's hook.
|
|
439
|
+
*
|
|
440
|
+
* HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
|
|
441
|
+
* one of our routes, AND either our kind of bearer token or a port in
|
|
442
|
+
* the range `bir` allocates (the token-less 0.2.8 shape). A hook to
|
|
443
|
+
* `https://hooks.example.com/session/end` is somebody else's.
|
|
444
|
+
* exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
|
|
445
|
+
* npx form `… -p @basein/runner@<v> bir-hooks ensure`.
|
|
281
446
|
*/
|
|
282
447
|
export function isBirHook(entry) {
|
|
283
|
-
const e = entry;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
448
|
+
const e = (entry ?? {});
|
|
449
|
+
if (typeof e.url === "string" && e.url) {
|
|
450
|
+
let url;
|
|
451
|
+
try {
|
|
452
|
+
url = new URL(e.url);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
458
|
+
return false;
|
|
459
|
+
if (!HOOK_ROUTES.some(({ route }) => url.pathname === route))
|
|
460
|
+
return false;
|
|
461
|
+
const auth = e.headers?.Authorization;
|
|
462
|
+
if (typeof auth === "string" && OUR_TOKEN.test(auth))
|
|
463
|
+
return true;
|
|
464
|
+
const port = Number(url.port);
|
|
465
|
+
return port >= FIRST_PROJECT_PORT && port < FIRST_PROJECT_PORT + 200;
|
|
466
|
+
}
|
|
287
467
|
if (Array.isArray(e.args)) {
|
|
288
468
|
const args = e.args.map(String);
|
|
289
|
-
|
|
469
|
+
if (!args.includes(ENSURE_ARG))
|
|
470
|
+
return false;
|
|
471
|
+
if (args.some((a) => /[\\/]bir-hooks\.js$/.test(a)))
|
|
472
|
+
return true;
|
|
473
|
+
return args.includes("bir-hooks") && args.some((a) => a.startsWith(PACKAGE_NAME));
|
|
290
474
|
}
|
|
291
475
|
return false;
|
|
292
476
|
}
|
|
293
477
|
/**
|
|
294
478
|
* Merge our hooks into a settings file, leaving every other hook alone. Returns
|
|
295
479
|
* the new text. Our own entries are replaced rather than appended, so repeated
|
|
296
|
-
* installs stay idempotent
|
|
480
|
+
* installs stay idempotent — hook by hook: a user's own hook that happens to
|
|
481
|
+
* share a matcher group with one of ours stays exactly where it is.
|
|
297
482
|
*/
|
|
298
483
|
export function installHooks(settingsPath, block) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
484
|
+
return editJson(settingsPath, (parsed) => {
|
|
485
|
+
const hooks = { ...(parsed.hooks ?? {}) };
|
|
486
|
+
for (const [event, matchers] of Object.entries(block)) {
|
|
487
|
+
const existing = (hooks[event] ?? [])
|
|
488
|
+
.map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
|
|
489
|
+
.filter((m) => m.hooks.length > 0);
|
|
490
|
+
hooks[event] = [...existing, ...matchers];
|
|
491
|
+
}
|
|
492
|
+
parsed.hooks = hooks;
|
|
493
|
+
});
|
|
307
494
|
}
|
|
308
495
|
/** Remove only our hook entries, and drop events left empty. */
|
|
309
496
|
export function uninstallHooks(settingsPath) {
|
|
310
497
|
const parsed = parseJsonFile(settingsPath);
|
|
311
498
|
if (!parsed?.hooks)
|
|
312
499
|
return undefined;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
500
|
+
return editJson(settingsPath, (fresh) => {
|
|
501
|
+
if (!fresh.hooks)
|
|
502
|
+
return;
|
|
503
|
+
const hooks = { ...fresh.hooks };
|
|
504
|
+
for (const [event, matchers] of Object.entries(hooks)) {
|
|
505
|
+
const kept = (matchers ?? [])
|
|
506
|
+
.map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
|
|
507
|
+
.filter((m) => m.hooks.length > 0);
|
|
508
|
+
if (kept.length === 0)
|
|
509
|
+
delete hooks[event];
|
|
510
|
+
else
|
|
511
|
+
hooks[event] = kept;
|
|
512
|
+
}
|
|
513
|
+
if (Object.keys(hooks).length === 0)
|
|
514
|
+
delete fresh.hooks;
|
|
320
515
|
else
|
|
321
|
-
hooks
|
|
322
|
-
}
|
|
323
|
-
if (Object.keys(hooks).length === 0)
|
|
324
|
-
delete parsed.hooks;
|
|
325
|
-
else
|
|
326
|
-
parsed.hooks = hooks;
|
|
327
|
-
return writeJson(settingsPath, parsed);
|
|
516
|
+
fresh.hooks = hooks;
|
|
517
|
+
});
|
|
328
518
|
}
|
|
329
519
|
//# sourceMappingURL=claude-code.js.map
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* user and never looks for one.
|
|
9
9
|
*
|
|
10
10
|
* There are no scopes here: the file given is the whole configuration, and it is
|
|
11
|
-
* both the read source and the write target.
|
|
11
|
+
* both the read source and the write target. Writes go through the same safe
|
|
12
|
+
* edit as Claude Code's files (claude-code.ts, `editJson`): refused when the
|
|
13
|
+
* file is not plain JSON, re-applied if the client rewrote it meanwhile, and
|
|
14
|
+
* atomic.
|
|
12
15
|
*/
|
|
13
16
|
import { type McpServerConfig } from "../resolve.js";
|
|
14
17
|
export declare function readGenericServers(path: string): Record<string, McpServerConfig>;
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* user and never looks for one.
|
|
9
9
|
*
|
|
10
10
|
* There are no scopes here: the file given is the whole configuration, and it is
|
|
11
|
-
* both the read source and the write target.
|
|
11
|
+
* both the read source and the write target. Writes go through the same safe
|
|
12
|
+
* edit as Claude Code's files (claude-code.ts, `editJson`): refused when the
|
|
13
|
+
* file is not plain JSON, re-applied if the client rewrote it meanwhile, and
|
|
14
|
+
* atomic.
|
|
12
15
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import { dirname } from "node:path";
|
|
16
|
+
import { editJson } from "./claude-code.js";
|
|
15
17
|
import { parseJsonFile } from "../resolve.js";
|
|
16
18
|
export function readGenericServers(path) {
|
|
17
19
|
const parsed = parseJsonFile(path);
|
|
@@ -21,16 +23,13 @@ export function readGenericServers(path) {
|
|
|
21
23
|
}
|
|
22
24
|
/** Set or delete one entry, preserving every other key in the file. */
|
|
23
25
|
export function setGenericServerEntry(path, name, entry) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
-
writeFileSync(path, text, "utf8");
|
|
34
|
-
return text;
|
|
26
|
+
return editJson(path, (parsed) => {
|
|
27
|
+
const servers = { ...(parsed.mcpServers ?? {}) };
|
|
28
|
+
if (entry === null)
|
|
29
|
+
delete servers[name];
|
|
30
|
+
else
|
|
31
|
+
servers[name] = entry;
|
|
32
|
+
parsed.mcpServers = servers;
|
|
33
|
+
});
|
|
35
34
|
}
|
|
36
35
|
//# sourceMappingURL=generic.js.map
|