flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -1,27 +1,28 @@
1
1
  /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
- import { execFileSync } from 'node:child_process';
3
- import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
- import { delimiter, isAbsolute, join } from 'node:path';
2
+ import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
5
4
  import { tmpdir } from 'node:os';
6
- import { mcpRefusal, prepareForRunner, skillsRefusal, toml } from './projectToolRuntimes.mjs';
5
+ import { git, gitRaw, splitNul } from './git.mjs';
6
+ import { withoutMachineCredentials } from './machineEnv.mjs';
7
+ import { fencedMountFor, mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
7
8
 
8
9
  const MAX_TOOLS = 40;
9
10
  const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
10
11
  const MAX_INSTRUCTION_FILES = 80;
11
12
  const MAX_INSTRUCTION_BYTES = 512 * 1024;
12
13
  const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
14
+ /** Base-ref reads are QUIET (git's "does not exist in" for an absent file is
15
+ * an answer here, not a message for the daemon's terminal) and keep a 1 MiB
16
+ * cap per file. */
17
+ const SHOW = Object.freeze({ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 });
13
18
  const show = (root, ref, path) => {
14
19
  try {
15
- return execFileSync('git', ['show', `${ref}:${path}`], {
16
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
17
- });
20
+ return gitRaw(['show', `${ref}:${path}`], root, SHOW);
18
21
  } catch { return null; }
19
22
  };
20
23
  const showBytes = (root, ref, path) => {
21
24
  try {
22
- return execFileSync('git', ['show', `${ref}:${path}`], {
23
- cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
24
- });
25
+ return gitRaw(['show', `${ref}:${path}`], root, { ...SHOW, encoding: 'buffer' });
25
26
  } catch { return null; }
26
27
  };
27
28
 
@@ -39,11 +40,39 @@ function referenced(value) {
39
40
  return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
40
41
  }
41
42
 
42
- export function readBaseTools(root, ref, env = process.env) {
43
+ /**
44
+ * THE ENVIRONMENT A `${VAR}` IN THE REPO'S `.mcp.json` IS READ FROM: the
45
+ * caller's, MINUS THE MACHINE CREDENTIAL AND EVERY OTHER `FLOWVIANT_*` NAME,
46
+ * whatever the caller passed (2026-10-10, the audit). A base-branch
47
+ * `"url": "https://x/mcp?k=${FLOWVIANT_FLEET}"` delivered the credential this
48
+ * daemon authenticates with to that host on every agent turn — the
49
+ * inherited-credential leak machineEnv.mjs exists to close. The daemon's own
50
+ * names are nobody's repo config to read, so such a server reads as
51
+ * `missing` ("env FLOWVIANT_FLEET unset").
52
+ */
53
+ const expansionEnv = (env) =>
54
+ Object.fromEntries(Object.entries(withoutMachineCredentials(env ?? process.env)).filter(([key]) => !key.startsWith('FLOWVIANT_')));
55
+
56
+ /**
57
+ * A TREE LISTING IS NUL-SEPARATED AND SIZED FOR A REAL REPOSITORY (2026-10-10,
58
+ * the audit). The whole-tree listing that finds nested CLAUDE.md/AGENTS.md ran
59
+ * under a 1 MiB buffer — about 20k paths — and nothing caught the ENOBUFS, so
60
+ * EVERY agent turn on a larger repository settled `nothing` before its CLI
61
+ * started. 64 MiB is workAdoptCarry.mjs's precedent; MAX_INSTRUCTION_FILES and
62
+ * MAX_INSTRUCTION_BYTES stay the real bounds. `-z` because git C-quotes a path
63
+ * that is not plain ASCII (`"d\303\251j\303\240/CLAUDE.md"`), which the name
64
+ * filter never matched (git.mjs's `-z` rule).
65
+ */
66
+ const LIST_MAX_BUFFER = 64 * 1024 * 1024;
67
+ const listTree = (root, args) =>
68
+ splitNul(gitRaw(['ls-tree', '-r', '-z', '--name-only', ...args], root, {
69
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: LIST_MAX_BUFFER,
70
+ }));
71
+
72
+ export function readBaseTools(root, ref, callerEnv = process.env) {
73
+ const env = expansionEnv(callerEnv);
43
74
  // Resolve once: a base branch may advance while the turn is being prepared.
44
- const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
45
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
46
- }).trim();
75
+ const commit = git(['rev-parse', '--verify', ref], root, { stdio: ['ignore', 'pipe', 'ignore'] });
47
76
  const raw = show(root, commit, '.mcp.json');
48
77
  let servers = {};
49
78
  if (raw != null) {
@@ -53,9 +82,7 @@ export function readBaseTools(root, ref, env = process.env) {
53
82
  const skills = [];
54
83
  const skillFiles = [];
55
84
  try {
56
- const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, '--', '.claude/skills'], {
57
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
58
- }).split('\n');
85
+ const paths = listTree(root, [commit, '--', '.claude/skills']);
59
86
  let bytes = 0;
60
87
  for (const path of paths) {
61
88
  if (!/^\.claude\/skills\/[A-Za-z0-9_-]+\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(path)) continue;
@@ -82,9 +109,7 @@ export function readBaseTools(root, ref, env = process.env) {
82
109
  // Nested instructions are normally discovered when a CLI enters that path.
83
110
  // Their worktree copies must not regain authority through that discovery.
84
111
  const instructions = [];
85
- const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit], {
86
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
87
- }).split('\n').filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
112
+ const paths = listTree(root, [commit]).filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
88
113
  if (paths.length > MAX_INSTRUCTION_FILES) throw new Error('Base branch has too many agent instruction files');
89
114
  let instructionBytes = 0;
90
115
  for (const path of paths) {
@@ -99,6 +124,7 @@ export function readBaseTools(root, ref, env = process.env) {
99
124
 
100
125
  const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
101
126
 
127
+
102
128
  /**
103
129
  * Temporary per-turn files are removed after the last retry. No personal CLI config is written.
104
130
  *
@@ -107,32 +133,33 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
107
133
  * removed by `cleanup`, because Codex indexes a thread under the home it ran
108
134
  * in and the next card resumes that thread. Absent, a Codex turn gets a home
109
135
  * that dies with it.
136
+ *
137
+ * NO EXPANDED VALUE RIDES ARGV (2026-10-10, the audit): Claude's servers go
138
+ * to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
139
+ * (projectToolRuntimes.mjs) — never `-c` pairs on the command line. A fenced
140
+ * Codex turn, which ignores that file, takes `fencedMount` instead: the
141
+ * repo's own words as `-c` pairs and every expanded value in its environment.
110
142
  */
111
- export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
143
+ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
144
+ const env = expansionEnv(callerEnv);
112
145
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
113
146
  const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
147
+ // What the base lists and this turn cannot mount, and why — the turn is told
148
+ // (postureTools.mjs), so a missing server is a named fact, never a guess.
149
+ const mcpUnmounted = snapshot.tools
150
+ .filter((t) => !usable.includes(t))
151
+ .map((t) => ({ name: t.name, reason: String(mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
114
152
  const mcpServers = {};
115
- const codexArgs = [];
116
153
  for (const { name, config } of usable) {
117
154
  if (typeof config.command === 'string') {
118
155
  const command = expand(config.command, env);
119
156
  const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
120
157
  const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
121
158
  mcpServers[name] = { command, args, env: vars };
122
- codexArgs.push('-c', `mcp_servers.${name}.command=${toml(command)}`);
123
- codexArgs.push('-c', `mcp_servers.${name}.args=${JSON.stringify(args)}`);
124
- for (const [key, value] of Object.entries(vars)) {
125
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) codexArgs.push('-c', `mcp_servers.${name}.env.${key}=${toml(value)}`);
126
- }
127
159
  } else if (typeof config.url === 'string') {
128
160
  const url = expand(config.url, env);
129
161
  const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v, env)]));
130
162
  mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
131
- codexArgs.push('-c', `mcp_servers.${name}.url=${toml(url)}`);
132
- if (Object.keys(headers).length) {
133
- const inline = Object.entries(headers).map(([key, value]) => `${toml(key)}=${toml(value)}`).join(',');
134
- codexArgs.push('-c', `mcp_servers.${name}.http_headers={${inline}}`);
135
- }
136
163
  }
137
164
  }
138
165
  const mcpPath = join(dir, 'mcp.json');
@@ -150,7 +177,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
150
177
  ].filter(Boolean).join('\n\n');
151
178
  let prepared;
152
179
  try {
153
- prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome });
180
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers });
154
181
  } catch (error) {
155
182
  rmSync(dir, { recursive: true, force: true });
156
183
  throw error;
@@ -158,7 +185,12 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
158
185
  const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
159
186
  return {
160
187
  mcpPath,
161
- codexArgs,
188
+ /** The names of the servers mounted, as Claude's allow rules need them. */
189
+ mcpServers: Object.keys(mcpServers),
190
+ mcpUnmounted,
191
+ /** The mount a fenced turn takes where the runner's file is ignored
192
+ * (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
193
+ fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
162
194
  instructions,
163
195
  pluginDir,
164
196
  codexHome,
@@ -179,3 +211,46 @@ export function toolReadout(snapshot, runner) {
179
211
  }));
180
212
  return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
181
213
  }
214
+
215
+ const MCP_JSON_MAX_BYTES = 1024 * 1024;
216
+ const MCP_JSON_MAX_NAMES = 500;
217
+ /**
218
+ * EVERY SERVER NAMED IN A `.mcp.json` FROM `dir` UP TO `/` (2026-10-11). An
219
+ * agent turn that mounts the machine's MCP servers is not strict, and Claude
220
+ * Code then reads `.mcp.json` in its cwd and every parent wherever a settings
221
+ * scope approves one (measured, 2.1.290). The base branch's copy is the
222
+ * project's only `.mcp.json` input to an agent, so each of these names is
223
+ * disabled for the turn (claudePosture.mjs `withMachineMcp`): the agent's edit
224
+ * of its worktree's file mounts only after review lands it on base. A server
225
+ * the machine should have everywhere belongs in its CLI's user scope.
226
+ *
227
+ * NULL when the answer cannot be bounded — a `.mcp.json` over 1 MiB, or more
228
+ * than 500 names — and the lane then mounts strictly (the base copy alone)
229
+ * rather than leave one discovered. A file that does not parse names nothing:
230
+ * the CLI cannot read it either.
231
+ */
232
+ export function mcpJsonServerNamesAbove(dir) {
233
+ const names = new Set();
234
+ let at = resolve(dir);
235
+ for (;;) {
236
+ const file = join(at, '.mcp.json');
237
+ let raw = null;
238
+ try {
239
+ const st = statSync(file);
240
+ if (st.isFile()) {
241
+ if (st.size > MCP_JSON_MAX_BYTES) return null;
242
+ raw = readFileSync(file, 'utf8');
243
+ }
244
+ } catch { /* none here */ }
245
+ if (raw != null) {
246
+ try {
247
+ const servers = JSON.parse(raw)?.mcpServers;
248
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) for (const name of Object.keys(servers)) names.add(name);
249
+ } catch { /* the CLI cannot parse it either */ }
250
+ }
251
+ if (names.size > MCP_JSON_MAX_NAMES) return null;
252
+ const up = dirname(at);
253
+ if (up === at) return [...names];
254
+ at = up;
255
+ }
256
+ }