@basein/runner 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -8,7 +8,9 @@
8
8
  *
9
9
  * Claude Code merges three scopes, highest precedence first. The whole winning
10
10
  * entry is used — fields are never merged across scopes:
11
- * 1. **Local** — `~/.claude.json` → `projects[<cwd>].mcpServers`
11
+ * 1. **Local** — `~/.claude.json` → `projects[<project key>].mcpServers`,
12
+ * where the key is the canonical git root (see
13
+ * {@link claudeProjectKey}), not necessarily the cwd
12
14
  * 2. **Project** — `<cwd>/.mcp.json` → `mcpServers`
13
15
  * 3. **User** — `~/.claude.json` → top-level `mcpServers`
14
16
  *
@@ -26,11 +28,17 @@ export interface StdioServerConfig {
26
28
  args?: string[];
27
29
  env?: Record<string, string>;
28
30
  }
29
- /** A remote server entry (streamable HTTP or legacy SSE). */
31
+ /**
32
+ * A remote server entry (streamable HTTP or legacy SSE). `headersHelper` and
33
+ * `oauth` are Claude Code's own ways to authenticate one; nothing here can
34
+ * reproduce either, which is why such an entry is never wrapped by default.
35
+ */
30
36
  export interface RemoteServerConfig {
31
37
  type: "http" | "sse";
32
38
  url: string;
33
39
  headers?: Record<string, string>;
40
+ headersHelper?: string;
41
+ oauth?: unknown;
34
42
  }
35
43
  export type McpServerConfig = StdioServerConfig | RemoteServerConfig;
36
44
  export type Scope = "local" | "project" | "user" | "explicit";
@@ -51,17 +59,71 @@ export interface ResolveOptions {
51
59
  export declare function isRemote(config: McpServerConfig): config is RemoteServerConfig;
52
60
  /** Parse a JSON file. `undefined` if absent; throws if present but malformed. */
53
61
  export declare function parseJsonFile(path: string): unknown;
54
- export declare function claudeJsonPath(override?: string): string;
55
62
  /**
56
- * The local scope's servers for `cwd`. The literal key is tried first, then
57
- * every key is compared as a normalised path — and a key resolving to *no*
58
- * servers does not end the search, because the empty twin is exactly the shape
59
- * the Windows miss takes on disk.
63
+ * Parse the text of a JSON file (BOM tolerated). Throws, naming `path`, when
64
+ * it is not JSON — a file with comments (JSONC) included, which is exactly the
65
+ * file that must be refused rather than rewritten without its comments.
66
+ */
67
+ export declare function parseJsonText(raw: string, path: string): unknown;
68
+ /**
69
+ * The file Claude Code keeps its user and local scopes in.
70
+ *
71
+ * `~/.claude.json` — unless `CLAUDE_CONFIG_DIR` is set, when Claude Code reads
72
+ * `$CLAUDE_CONFIG_DIR/.claude.json` instead (checked against Claude Code
73
+ * itself). Writing the home-directory file then wraps servers in a file no
74
+ * session reads: `bir setup` says "wrapped" and nothing is. `BIR_CLAUDE_JSON`
75
+ * (tests, a second profile) still names it outright. An empty variable counts
76
+ * as unset, the way Claude Code treats one.
77
+ */
78
+ export declare function claudeJsonPath(override?: string, env?: NodeJS.ProcessEnv): string;
79
+ /**
80
+ * `CLAUDE_CONFIG_DIR` when it is set: where Claude Code keeps `.claude.json`,
81
+ * `settings.json` and the rest of what normally lives in `~/.claude`.
82
+ */
83
+ export declare function claudeConfigHome(env?: NodeJS.ProcessEnv): string | undefined;
84
+ /**
85
+ * The directory Claude Code keys this cwd's local scope (and its trust and
86
+ * permission state) under: the canonical git root, else `cwd` itself.
87
+ */
88
+ export declare function claudeProjectRoot(cwd: string): string;
89
+ /**
90
+ * The linked worktrees (`git worktree add`) of the repository whose main
91
+ * checkout is `root`, as directories that still exist.
92
+ *
93
+ * Claude Code keys their local scope under `root` too (see
94
+ * {@link canonicalGitRoot}), so a local-scope server written for `root` also
95
+ * runs in every one of them — sessions nobody set up, on branches whose
96
+ * `.mcp.json` may say something else. `bir install` names them, and leaves a
97
+ * server alone where a worktree's own `.mcp.json` defines it differently.
98
+ */
99
+ export declare function linkedWorktrees(root: string): string[];
100
+ /** {@link claudeProjectRoot} spelled the way Claude Code spells a `projects` key. */
101
+ export declare function claudeProjectKey(cwd: string, platform?: NodeJS.Platform): string;
102
+ /**
103
+ * Every existing `projects` key Claude Code could read for this cwd — the
104
+ * spellings of its canonical root that it wrote (forward slashes on Windows;
105
+ * both drive-letter cases when both exist) — or, when it has never run here,
106
+ * the one key it will create. Never a backslash key: Claude Code does not read
107
+ * one, and writing there is the trap that made entries invisible to `/mcp`.
108
+ */
109
+ export declare function projectKeysFor(projects: Record<string, unknown> | undefined, cwd: string, platform?: NodeJS.Platform): string[];
110
+ /**
111
+ * The local scope's servers for `cwd`, as Claude Code sees them: the first key
112
+ * it reads that holds any. Only when it has no key here at all is a legacy
113
+ * spelling of `cwd` itself (a backslash key, or the directory rather than its
114
+ * git root) consulted — so an install made by an older runner is still found
115
+ * and can be repaired, without reporting entries Claude Code never uses as live.
60
116
  */
61
117
  export declare function projectScope(projects: Record<string, {
62
118
  mcpServers?: Record<string, McpServerConfig>;
63
119
  }> | undefined, cwd: string): Record<string, McpServerConfig>;
64
- /** The key under which Claude Code stores (or would store) this cwd's local scope. */
120
+ /**
121
+ * The key under which Claude Code stores (or would store) this cwd's local
122
+ * scope. A key spelled exactly like `cwd` is kept (it is where an older install
123
+ * put an entry, and where uninstall must look for it); otherwise the key
124
+ * Claude Code reads. It used to fall back to the raw cwd, which on Windows is a
125
+ * backslash key nothing ever reads.
126
+ */
65
127
  export declare function projectKeyFor(projects: Record<string, unknown> | undefined, cwd: string): string;
66
128
  /** Every MCP server that would be live for `cwd`, with the scope that won. */
67
129
  export declare function resolveServers(cwd: string, opts?: ResolveOptions): ResolvedServer[];
@@ -8,7 +8,9 @@
8
8
  *
9
9
  * Claude Code merges three scopes, highest precedence first. The whole winning
10
10
  * entry is used — fields are never merged across scopes:
11
- * 1. **Local** — `~/.claude.json` → `projects[<cwd>].mcpServers`
11
+ * 1. **Local** — `~/.claude.json` → `projects[<project key>].mcpServers`,
12
+ * where the key is the canonical git root (see
13
+ * {@link claudeProjectKey}), not necessarily the cwd
12
14
  * 2. **Project** — `<cwd>/.mcp.json` → `mcpServers`
13
15
  * 3. **User** — `~/.claude.json` → top-level `mcpServers`
14
16
  *
@@ -19,9 +21,9 @@
19
21
  * config. Matching the literal key misses every time; matching a *normalised*
20
22
  * path, and refusing to stop at an empty twin, is what makes local scope work.
21
23
  */
22
- import { readFileSync } from "node:fs";
24
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
23
25
  import { homedir } from "node:os";
24
- import { join } from "node:path";
26
+ import { basename, dirname, join, normalize as normalizeNative, resolve as resolvePath } from "node:path";
25
27
  import { normalizePath } from "../control/paths.js";
26
28
  export function isRemote(config) {
27
29
  return (config.type === "http" || config.type === "sse");
@@ -47,6 +49,14 @@ export function parseJsonFile(path) {
47
49
  catch {
48
50
  return undefined; // absent / unreadable — "not configured"
49
51
  }
52
+ return parseJsonText(raw, path);
53
+ }
54
+ /**
55
+ * Parse the text of a JSON file (BOM tolerated). Throws, naming `path`, when
56
+ * it is not JSON — a file with comments (JSONC) included, which is exactly the
57
+ * file that must be refused rather than rewritten without its comments.
58
+ */
59
+ export function parseJsonText(raw, path) {
50
60
  try {
51
61
  return JSON.parse(stripBom(raw));
52
62
  }
@@ -63,18 +73,172 @@ function readMcpServers(path, required) {
63
73
  }
64
74
  return parsed.mcpServers ?? {};
65
75
  }
66
- export function claudeJsonPath(override) {
67
- return override ?? process.env.BIR_CLAUDE_JSON ?? join(homedir(), ".claude.json");
76
+ /**
77
+ * The file Claude Code keeps its user and local scopes in.
78
+ *
79
+ * `~/.claude.json` — unless `CLAUDE_CONFIG_DIR` is set, when Claude Code reads
80
+ * `$CLAUDE_CONFIG_DIR/.claude.json` instead (checked against Claude Code
81
+ * itself). Writing the home-directory file then wraps servers in a file no
82
+ * session reads: `bir setup` says "wrapped" and nothing is. `BIR_CLAUDE_JSON`
83
+ * (tests, a second profile) still names it outright. An empty variable counts
84
+ * as unset, the way Claude Code treats one.
85
+ */
86
+ export function claudeJsonPath(override, env = process.env) {
87
+ if (override)
88
+ return override;
89
+ if (env.BIR_CLAUDE_JSON)
90
+ return env.BIR_CLAUDE_JSON;
91
+ return join(claudeConfigHome(env) ?? homedir(), ".claude.json");
92
+ }
93
+ /**
94
+ * `CLAUDE_CONFIG_DIR` when it is set: where Claude Code keeps `.claude.json`,
95
+ * `settings.json` and the rest of what normally lives in `~/.claude`.
96
+ */
97
+ export function claudeConfigHome(env = process.env) {
98
+ return env.CLAUDE_CONFIG_DIR || undefined;
99
+ }
100
+ // ── the local scope's key, as Claude Code computes it ──────────────────────
101
+ //
102
+ // Read out of Claude Code itself rather than guessed, because a local-scope
103
+ // entry written under any other key is silently never used: Claude Code keys
104
+ // `projects` by the project's CANONICAL GIT ROOT when the directory is inside
105
+ // a repository (walking up for a `.git` directory or file; a linked worktree
106
+ // maps to its main checkout), and by the directory itself otherwise — then
107
+ // normalises it and, on Windows, turns every backslash into a forward slash.
108
+ // The drive letter keeps whatever case the launching shell reported, which is
109
+ // why `c:/x` and `C:/x` routinely sit side by side: one per way Claude Code has
110
+ // been started there.
111
+ /** True when `dir` has a `.git` directory or gitfile (not a symlink). */
112
+ function hasGitEntry(dir) {
113
+ try {
114
+ const st = lstatSync(join(dir, ".git"));
115
+ return st.isDirectory() || st.isFile();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * A linked worktree's main checkout, the way Claude Code resolves it: only when
123
+ * the gitfile, the `commondir` file and the back-pointing `gitdir` file all
124
+ * agree. Anything unusual (a submodule, a hand-edited file) keeps `root`.
125
+ */
126
+ function canonicalGitRoot(root) {
127
+ try {
128
+ const dotGit = join(root, ".git");
129
+ if (!statSync(dotGit).isFile())
130
+ return root;
131
+ const text = readFileSync(dotGit, "utf8").trim();
132
+ if (!text.startsWith("gitdir:"))
133
+ return root;
134
+ const gitdir = resolvePath(root, text.slice("gitdir:".length).trim());
135
+ const commondirFile = join(gitdir, "commondir");
136
+ if (!existsSync(commondirFile))
137
+ return root;
138
+ const common = resolvePath(gitdir, readFileSync(commondirFile, "utf8").trim());
139
+ if (normalizePath(dirname(gitdir)) !== normalizePath(join(common, "worktrees")))
140
+ return root;
141
+ const back = readFileSync(join(gitdir, "gitdir"), "utf8").trim();
142
+ if (normalizePath(resolvePath(gitdir, back)) !== normalizePath(dotGit))
143
+ return root;
144
+ if (basename(common) !== ".git")
145
+ return hasGitEntry(common) ? root : common;
146
+ return dirname(common);
147
+ }
148
+ catch {
149
+ return root;
150
+ }
151
+ }
152
+ /**
153
+ * The directory Claude Code keys this cwd's local scope (and its trust and
154
+ * permission state) under: the canonical git root, else `cwd` itself.
155
+ */
156
+ export function claudeProjectRoot(cwd) {
157
+ let dir = resolvePath(cwd);
158
+ for (;;) {
159
+ if (hasGitEntry(dir))
160
+ return canonicalGitRoot(dir);
161
+ const parent = dirname(dir);
162
+ if (parent === dir)
163
+ return resolvePath(cwd);
164
+ dir = parent;
165
+ }
166
+ }
167
+ /**
168
+ * The linked worktrees (`git worktree add`) of the repository whose main
169
+ * checkout is `root`, as directories that still exist.
170
+ *
171
+ * Claude Code keys their local scope under `root` too (see
172
+ * {@link canonicalGitRoot}), so a local-scope server written for `root` also
173
+ * runs in every one of them — sessions nobody set up, on branches whose
174
+ * `.mcp.json` may say something else. `bir install` names them, and leaves a
175
+ * server alone where a worktree's own `.mcp.json` defines it differently.
176
+ */
177
+ export function linkedWorktrees(root) {
178
+ const admin = join(root, ".git", "worktrees");
179
+ let names;
180
+ try {
181
+ if (!statSync(join(root, ".git")).isDirectory())
182
+ return [];
183
+ names = readdirSync(admin);
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ const found = [];
189
+ for (const name of names) {
190
+ try {
191
+ const dotGit = resolvePath(join(admin, name), readFileSync(join(admin, name, "gitdir"), "utf8").trim());
192
+ const dir = dirname(dotGit);
193
+ if (existsSync(dotGit) && normalizePath(dir) !== normalizePath(root))
194
+ found.push(dir);
195
+ }
196
+ catch {
197
+ /* a pruned or half-made worktree */
198
+ }
199
+ }
200
+ return found.sort();
201
+ }
202
+ /** {@link claudeProjectRoot} spelled the way Claude Code spells a `projects` key. */
203
+ export function claudeProjectKey(cwd, platform = process.platform) {
204
+ const root = normalizeNative(claudeProjectRoot(cwd));
205
+ return platform === "win32" ? root.replace(/\\/g, "/") : root;
206
+ }
207
+ /**
208
+ * Every existing `projects` key Claude Code could read for this cwd — the
209
+ * spellings of its canonical root that it wrote (forward slashes on Windows;
210
+ * both drive-letter cases when both exist) — or, when it has never run here,
211
+ * the one key it will create. Never a backslash key: Claude Code does not read
212
+ * one, and writing there is the trap that made entries invisible to `/mcp`.
213
+ */
214
+ export function projectKeysFor(projects, cwd, platform = process.platform) {
215
+ const canonical = claudeProjectKey(cwd, platform);
216
+ const want = normalizePath(canonical);
217
+ const found = Object.keys(projects ?? {}).filter((key) => normalizePath(key) === want && !(platform === "win32" && key.includes("\\")));
218
+ if (found.length === 0)
219
+ return [canonical];
220
+ // The exact canonical spelling first, so single-key callers use it.
221
+ return found.sort((a, b) => Number(b === canonical) - Number(a === canonical));
68
222
  }
69
223
  /**
70
- * The local scope's servers for `cwd`. The literal key is tried first, then
71
- * every key is compared as a normalised path — and a key resolving to *no*
72
- * servers does not end the search, because the empty twin is exactly the shape
73
- * the Windows miss takes on disk.
224
+ * The local scope's servers for `cwd`, as Claude Code sees them: the first key
225
+ * it reads that holds any. Only when it has no key here at all is a legacy
226
+ * spelling of `cwd` itself (a backslash key, or the directory rather than its
227
+ * git root) consulted — so an install made by an older runner is still found
228
+ * and can be repaired, without reporting entries Claude Code never uses as live.
74
229
  */
75
230
  export function projectScope(projects, cwd) {
76
231
  if (!projects)
77
232
  return {};
233
+ const readable = projectKeysFor(projects, cwd).filter((key) => key in projects);
234
+ if (readable.length > 0) {
235
+ for (const key of readable) {
236
+ const servers = projects[key]?.mcpServers;
237
+ if (servers && Object.keys(servers).length > 0)
238
+ return servers;
239
+ }
240
+ return projects[readable[0]]?.mcpServers ?? {};
241
+ }
78
242
  const exact = projects[cwd]?.mcpServers;
79
243
  if (exact && Object.keys(exact).length > 0)
80
244
  return exact;
@@ -86,18 +250,25 @@ export function projectScope(projects, cwd) {
86
250
  }
87
251
  return exact ?? {};
88
252
  }
89
- /** The key under which Claude Code stores (or would store) this cwd's local scope. */
253
+ /**
254
+ * The key under which Claude Code stores (or would store) this cwd's local
255
+ * scope. A key spelled exactly like `cwd` is kept (it is where an older install
256
+ * put an entry, and where uninstall must look for it); otherwise the key
257
+ * Claude Code reads. It used to fall back to the raw cwd, which on Windows is a
258
+ * backslash key nothing ever reads.
259
+ */
90
260
  export function projectKeyFor(projects, cwd) {
91
- if (!projects)
92
- return cwd;
93
- if (cwd in projects)
261
+ if (projects && cwd in projects)
94
262
  return cwd;
263
+ const keys = projectKeysFor(projects, cwd);
264
+ if (projects && keys[0] in projects)
265
+ return keys[0];
95
266
  const want = normalizePath(cwd);
96
- for (const key of Object.keys(projects)) {
267
+ for (const key of Object.keys(projects ?? {})) {
97
268
  if (normalizePath(key) === want)
98
269
  return key;
99
270
  }
100
- return cwd;
271
+ return keys[0];
101
272
  }
102
273
  /** Every MCP server that would be live for `cwd`, with the scope that won. */
103
274
  export function resolveServers(cwd, opts = {}) {
@@ -23,6 +23,19 @@ export interface ProxyWork {
23
23
  arguments: unknown;
24
24
  timeoutMs: number;
25
25
  }
26
+ /** What one long-poll came back with (see {@link ControlClient.pollWork}). */
27
+ export interface PollOutcome {
28
+ /** False when the control server could not be reached, or refused us. */
29
+ reachable: boolean;
30
+ work?: ProxyWork;
31
+ /**
32
+ * The server said calculated replay is off here (`{ replay: false }`). A
33
+ * proxy drops to its idle pace (one poll every 30 s) rather than ask again
34
+ * four times a second — but does not stop: replay is turned back on by a
35
+ * restart that never makes a bound proxy register again.
36
+ */
37
+ replayOff?: boolean;
38
+ }
26
39
  /**
27
40
  * Who a proxy is, sent with every register, poll and step report. The control
28
41
  * server uses it to run a session's replay steps on that session's own upstream
@@ -65,9 +78,18 @@ export declare class ControlClient {
65
78
  * with a small delay so a dead server is not busy-looped.
66
79
  */
67
80
  poll(serverName: string, holdMs: number): Promise<ProxyWork | undefined>;
81
+ /**
82
+ * {@link poll}, with enough of the answer to pace the loop: whether the
83
+ * server was reachable at all, and whether it said replay is off. An empty
84
+ * answer that came back at once means the server is not holding polls for
85
+ * us, and the caller backs off.
86
+ */
87
+ pollWork(serverName: string, holdMs: number): Promise<PollOutcome>;
68
88
  /** Hand back one dispatched call's result, or the reason it could not run. */
69
89
  result(workId: string, result?: unknown, error?: string): Promise<void>;
70
90
  private post;
71
91
  private request;
92
+ /** One HTTP exchange. Never throws; `ok: false` covers refused, timed out and non-2xx. */
93
+ private exchange;
72
94
  }
73
95
  //# sourceMappingURL=client.d.ts.map
@@ -47,8 +47,20 @@ export class ControlClient {
47
47
  * with a small delay so a dead server is not busy-looped.
48
48
  */
49
49
  async poll(serverName, holdMs) {
50
- const body = (await this.request("POST", "/proxy/poll", { ...this.identity, serverName, holdMs }, holdMs + 10_000));
51
- return body?.work;
50
+ return (await this.pollWork(serverName, holdMs)).work;
51
+ }
52
+ /**
53
+ * {@link poll}, with enough of the answer to pace the loop: whether the
54
+ * server was reachable at all, and whether it said replay is off. An empty
55
+ * answer that came back at once means the server is not holding polls for
56
+ * us, and the caller backs off.
57
+ */
58
+ async pollWork(serverName, holdMs) {
59
+ const outcome = await this.exchange("POST", "/proxy/poll", { ...this.identity, serverName, holdMs }, holdMs + 10_000);
60
+ if (!outcome.ok)
61
+ return { reachable: false };
62
+ const body = outcome.body;
63
+ return { reachable: true, work: body?.work, replayOff: body?.replay === false || undefined };
52
64
  }
53
65
  /** Hand back one dispatched call's result, or the reason it could not run. */
54
66
  async result(workId, result, error) {
@@ -58,6 +70,11 @@ export class ControlClient {
58
70
  return this.request("POST", path, body);
59
71
  }
60
72
  async request(method, path, body, timeoutMs = this.timeoutMs) {
73
+ const outcome = await this.exchange(method, path, body, timeoutMs);
74
+ return outcome.ok ? outcome.body : undefined;
75
+ }
76
+ /** One HTTP exchange. Never throws; `ok: false` covers refused, timed out and non-2xx. */
77
+ async exchange(method, path, body, timeoutMs = this.timeoutMs) {
61
78
  const controller = new AbortController();
62
79
  const timer = setTimeout(() => controller.abort(), timeoutMs);
63
80
  timer.unref?.();
@@ -73,21 +90,23 @@ export class ControlClient {
73
90
  });
74
91
  if (!res.ok) {
75
92
  logDetail("control.http_error", { path, status: res.status });
76
- return undefined;
93
+ // Drain, so the connection is released rather than held to its timeout.
94
+ await res.text().catch(() => undefined);
95
+ return { ok: false, status: res.status };
77
96
  }
78
97
  const text = await res.text();
79
98
  if (!text)
80
- return {};
99
+ return { ok: true, status: res.status, body: {} };
81
100
  try {
82
- return JSON.parse(text);
101
+ return { ok: true, status: res.status, body: JSON.parse(text) };
83
102
  }
84
103
  catch {
85
- return {};
104
+ return { ok: true, status: res.status, body: {} };
86
105
  }
87
106
  }
88
107
  catch (err) {
89
108
  logDetail("control.unreachable", { path, error: errText(err) });
90
- return undefined;
109
+ return { ok: false };
91
110
  }
92
111
  finally {
93
112
  clearTimeout(timer);
@@ -10,21 +10,32 @@
10
10
  * - the **proxy** contributes the true upstream result, `isError`,
11
11
  * `structuredContent` and real execution duration.
12
12
  *
13
- * MECHANISM. At `PreToolUse` the hook answers `permissionDecision: "allow"` with
13
+ * MECHANISM. At `PreToolUse` the hook may answer with
14
14
  * `updatedInput = { ...original, [BIR_CALL_ID]: id }`. Claude Code passes those
15
15
  * arguments through as the call's `arguments`, the proxy strips the key before
16
16
  * relaying, and echoes it on the step report. The control server joins on it.
17
17
  *
18
+ * NOT BY DEFAULT. Carrying an id means answering `allow`, and `allow` skips
19
+ * the permission prompt the user would otherwise have seen for that call. So
20
+ * the control server injects only where that changes nothing (a session in
21
+ * `bypassPermissions` mode, a replay-pinned step) or where the operator opted
22
+ * in (`BIR_CORRELATION_DECISION=allow`); everywhere else the hook stays silent
23
+ * and the report joins by fingerprint — below.
24
+ *
18
25
  * THE COST, INHERITED KNOWINGLY from RRepeat's sentinel: carrying an extra
19
26
  * argument requires **relaxing the tool's schema** (`additionalProperties: true`
20
27
  * plus the declared key), and that relaxation is visible to the model on every
21
28
  * call, not only recorded ones. Hence {@link relaxSchema} is applied only when
22
29
  * Tier 1 correlation is actually active, and only to wrapped servers.
23
30
  *
24
- * THE FALLBACK. `--no-correlation` matches on `(serverName, toolName, hash(args))`
25
- * inside a 30 s window instead. It is lossy under identical concurrent calls —
26
- * two parallel calls with the same arguments are genuinely indistinguishable
27
- * without an id — and says so rather than pretending otherwise.
31
+ * THE FINGERPRINT JOIN — the default, and all `--no-correlation` uses — matches
32
+ * on `(serverName, toolName, hash(args))`, the args hashed *raw*, as the model
33
+ * wrote them, before any redaction or clipping (the proxy sends that hash as
34
+ * `argsFingerprint`; an older proxy's report is hashed as reported), within
35
+ * the time a user may take to approve the call. It is lossy
36
+ * under identical concurrent calls — two parallel calls with the same arguments
37
+ * are genuinely indistinguishable without an id — and says so rather than
38
+ * pretending otherwise.
28
39
  */
29
40
  /** The argument key that carries the call id from hook to proxy. */
30
41
  export declare const BIR_CALL_ID = "__bir_call_id__";
@@ -42,6 +53,13 @@ export interface ProxyStepReport {
42
53
  qualifiedName: string;
43
54
  /** Post-redaction. */
44
55
  args: unknown;
56
+ /**
57
+ * The fingerprint join key over the call's *raw* arguments, taken by the
58
+ * proxy before redaction and clipping (proxy/intercept.ts). `args` above is
59
+ * what is recorded, and hashing it would miss the hook-side twin whenever
60
+ * redaction or clipping changed anything. Absent from older proxies.
61
+ */
62
+ argsFingerprint?: string;
45
63
  /** Whole CallToolResult, post-redaction and truncation. */
46
64
  result?: unknown;
47
65
  isError: boolean;
Binary file