muonroi-cli 1.4.1 → 1.5.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 (172) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  4. package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
  5. package/dist/src/agent-harness/mock-model.d.ts +11 -0
  6. package/dist/src/agent-harness/mock-model.js +21 -0
  7. package/dist/src/cli/cost-forensics.js +12 -12
  8. package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
  9. package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
  10. package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
  11. package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
  12. package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
  13. package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
  14. package/dist/src/council/clarifier.js +9 -1
  15. package/dist/src/council/debate.js +5 -1
  16. package/dist/src/council/decisions-lock.js +3 -3
  17. package/dist/src/council/index.js +12 -5
  18. package/dist/src/council/leader.d.ts +0 -17
  19. package/dist/src/council/leader.js +22 -15
  20. package/dist/src/council/planner.js +1 -1
  21. package/dist/src/council/prompts.js +63 -57
  22. package/dist/src/council/types.d.ts +7 -0
  23. package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
  24. package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
  25. package/dist/src/ee/auth.d.ts +9 -0
  26. package/dist/src/ee/auth.js +19 -0
  27. package/dist/src/ee/ee-onboarding.d.ts +5 -0
  28. package/dist/src/ee/ee-onboarding.js +76 -0
  29. package/dist/src/generated/version.d.ts +1 -1
  30. package/dist/src/generated/version.js +1 -1
  31. package/dist/src/headless/output.js +6 -4
  32. package/dist/src/headless/output.test.js +4 -3
  33. package/dist/src/index.js +20 -1
  34. package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
  35. package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
  36. package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
  37. package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
  38. package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
  39. package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
  40. package/dist/src/mcp/auto-setup.js +56 -2
  41. package/dist/src/mcp/client-pool.d.ts +46 -0
  42. package/dist/src/mcp/client-pool.js +212 -0
  43. package/dist/src/mcp/oauth-callback.js +2 -2
  44. package/dist/src/mcp/parse-headers.test.js +14 -14
  45. package/dist/src/mcp/runtime.d.ts +28 -0
  46. package/dist/src/mcp/runtime.js +117 -51
  47. package/dist/src/mcp/self-verify-runner.d.ts +14 -0
  48. package/dist/src/mcp/self-verify-runner.js +38 -0
  49. package/dist/src/mcp/setup-guide-text.d.ts +9 -0
  50. package/dist/src/mcp/setup-guide-text.js +84 -0
  51. package/dist/src/mcp/smart-filter.js +49 -0
  52. package/dist/src/mcp/smoke.test.js +43 -43
  53. package/dist/src/mcp/tools-server.d.ts +7 -0
  54. package/dist/src/mcp/tools-server.js +19 -22
  55. package/dist/src/models/catalog.json +349 -349
  56. package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
  57. package/dist/src/ops/doctor.d.ts +3 -2
  58. package/dist/src/ops/doctor.js +47 -11
  59. package/dist/src/ops/doctor.test.js +4 -3
  60. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
  61. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
  62. package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
  63. package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
  64. package/dist/src/orchestrator/batch-turn-runner.js +7 -11
  65. package/dist/src/orchestrator/message-processor.js +57 -27
  66. package/dist/src/orchestrator/orchestrator.js +26 -0
  67. package/dist/src/orchestrator/prompts.d.ts +51 -0
  68. package/dist/src/orchestrator/prompts.js +257 -134
  69. package/dist/src/orchestrator/scope-ceiling.js +6 -1
  70. package/dist/src/orchestrator/stream-runner.js +20 -15
  71. package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
  72. package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
  73. package/dist/src/pil/__tests__/config.test.js +1 -17
  74. package/dist/src/pil/__tests__/discovery.test.js +144 -11
  75. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
  76. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  77. package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
  78. package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
  79. package/dist/src/pil/__tests__/layer6-output.test.js +137 -18
  80. package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
  81. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  82. package/dist/src/pil/agent-operating-contract.js +2 -0
  83. package/dist/src/pil/agent-operating-contract.test.js +7 -2
  84. package/dist/src/pil/cheap-model-playbook.js +35 -35
  85. package/dist/src/pil/cheap-model-workbooks.js +16 -13
  86. package/dist/src/pil/clarity-gate.d.ts +21 -19
  87. package/dist/src/pil/clarity-gate.js +26 -153
  88. package/dist/src/pil/config.d.ts +9 -1
  89. package/dist/src/pil/config.js +15 -4
  90. package/dist/src/pil/discovery.js +211 -136
  91. package/dist/src/pil/layer1-intent.d.ts +12 -0
  92. package/dist/src/pil/layer1-intent.js +283 -38
  93. package/dist/src/pil/layer1-intent.test.js +210 -4
  94. package/dist/src/pil/layer16-clarity.d.ts +25 -11
  95. package/dist/src/pil/layer16-clarity.js +19 -306
  96. package/dist/src/pil/layer4-gsd.js +18 -6
  97. package/dist/src/pil/layer6-output.d.ts +2 -0
  98. package/dist/src/pil/layer6-output.js +137 -22
  99. package/dist/src/pil/llm-classify.d.ts +26 -0
  100. package/dist/src/pil/llm-classify.js +34 -5
  101. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  102. package/dist/src/pil/native-capabilities-workbook.js +82 -76
  103. package/dist/src/pil/schema.d.ts +8 -0
  104. package/dist/src/pil/schema.js +12 -1
  105. package/dist/src/pil/task-tier-map.js +4 -0
  106. package/dist/src/pil/types.d.ts +11 -1
  107. package/dist/src/product-loop/done-gate.js +3 -3
  108. package/dist/src/product-loop/loop-driver.js +18 -18
  109. package/dist/src/product-loop/progress-snapshot.js +4 -4
  110. package/dist/src/providers/auth/gemini-oauth.js +6 -15
  111. package/dist/src/providers/auth/grok-oauth.js +6 -15
  112. package/dist/src/providers/auth/openai-oauth.js +6 -15
  113. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  114. package/dist/src/reporter/index.js +1 -1
  115. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  116. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  117. package/dist/src/scaffold/continuation-prompt.js +60 -60
  118. package/dist/src/scaffold/init-new.js +453 -453
  119. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  120. package/dist/src/self-qa/agentic-loop.js +24 -19
  121. package/dist/src/self-qa/spec-emitter.js +26 -23
  122. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  123. package/dist/src/storage/interaction-log.js +5 -5
  124. package/dist/src/storage/migrations.js +122 -122
  125. package/dist/src/storage/sessions.js +42 -42
  126. package/dist/src/storage/transcript.js +91 -84
  127. package/dist/src/storage/usage.js +14 -14
  128. package/dist/src/storage/workspaces.js +12 -12
  129. package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
  130. package/dist/src/tools/__tests__/native-tools.test.js +53 -0
  131. package/dist/src/tools/git-safety.d.ts +61 -0
  132. package/dist/src/tools/git-safety.js +141 -0
  133. package/dist/src/tools/git-safety.test.d.ts +1 -0
  134. package/dist/src/tools/git-safety.test.js +111 -0
  135. package/dist/src/tools/native-tools.d.ts +31 -0
  136. package/dist/src/tools/native-tools.js +273 -0
  137. package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
  138. package/dist/src/tools/registry-git-safety.test.js +92 -0
  139. package/dist/src/tools/registry.js +39 -4
  140. package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
  141. package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
  142. package/dist/src/ui/app.js +0 -0
  143. package/dist/src/ui/components/message-view.js +4 -1
  144. package/dist/src/ui/components/structured-response-view.js +7 -3
  145. package/dist/src/ui/components/tool-group.js +7 -1
  146. package/dist/src/ui/markdown-render.d.ts +41 -0
  147. package/dist/src/ui/markdown-render.js +223 -0
  148. package/dist/src/ui/markdown.d.ts +10 -0
  149. package/dist/src/ui/markdown.js +12 -35
  150. package/dist/src/ui/slash/council-inspect.js +4 -4
  151. package/dist/src/ui/slash/export.js +4 -4
  152. package/dist/src/ui/utils/text.d.ts +8 -0
  153. package/dist/src/ui/utils/text.js +16 -0
  154. package/dist/src/ui/utils/text.test.d.ts +1 -0
  155. package/dist/src/ui/utils/text.test.js +23 -0
  156. package/dist/src/usage/ledger.js +48 -15
  157. package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
  158. package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
  159. package/dist/src/utils/clipboard-image.js +23 -23
  160. package/dist/src/utils/open-url.d.ts +56 -0
  161. package/dist/src/utils/open-url.js +58 -0
  162. package/dist/src/utils/open-url.test.d.ts +1 -0
  163. package/dist/src/utils/open-url.test.js +86 -0
  164. package/dist/src/utils/settings.d.ts +12 -0
  165. package/dist/src/utils/settings.js +48 -0
  166. package/dist/src/utils/side-question.js +2 -2
  167. package/dist/src/utils/skills.js +3 -3
  168. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  169. package/dist/src/verify/environment.js +2 -1
  170. package/package.json +1 -1
  171. package/dist/src/pil/layer16-clarity.test.js +0 -31
  172. /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
@@ -5,10 +5,12 @@ export function renderHeadlessPrelude(format, sessionId) {
5
5
  if (format === "json") {
6
6
  return {};
7
7
  }
8
- return {
9
- stdout: "\x1b[36m⏳ Processing...\x1b[0m\n",
10
- stderr: sessionId ? `\x1b[2mSession: ${sessionId}\x1b[0m\n` : undefined,
11
- };
8
+ // Status indicator + session id are progress UX, not the reply. Keep stdout
9
+ // pure (only the model's answer) so `--format text` pipes cleanly. VERIFY F3.
10
+ const statusLines = ["\x1b[36m⏳ Processing...\x1b[0m"];
11
+ if (sessionId)
12
+ statusLines.push(`\x1b[2mSession: ${sessionId}\x1b[0m`);
13
+ return { stderr: `${statusLines.join("\n")}\n` };
12
14
  }
13
15
  /**
14
16
  * Headless text output only. JSON streaming uses {@link createHeadlessJsonlEmitter} + `Agent.processMessage` observer.
@@ -25,10 +25,11 @@ describe("headless output helpers", () => {
25
25
  expect(isHeadlessOutputFormat("json")).toBe(true);
26
26
  expect(isHeadlessOutputFormat("xml")).toBe(false);
27
27
  });
28
- it("renders the text prelude with session metadata", () => {
28
+ it("renders the text prelude with status on stderr (stdout stays pure for piping)", () => {
29
+ // VERIFY F3: spinner + session id are progress UX, not the reply — both go
30
+ // to stderr so `--format text` stdout contains only the model's answer.
29
31
  expect(renderHeadlessPrelude("text", "session-123")).toEqual({
30
- stdout: "\u001b[36m⏳ Processing...\u001b[0m\n",
31
- stderr: "\u001b[2mSession: session-123\u001b[0m\n",
32
+ stderr: "⏳ Processing...\nSession: session-123\n",
32
33
  });
33
34
  });
34
35
  it("suppresses the prelude in json mode", () => {
package/dist/src/index.js CHANGED
@@ -982,8 +982,27 @@ program
982
982
  }
983
983
  }
984
984
  // Bootstrap EE auth (loads serverBaseUrl + token from ~/.experience/config.json)
985
- const { loadEEAuthToken } = await import("./ee/auth.js");
985
+ const { loadEEAuthToken, getCachedServerBaseUrl } = await import("./ee/auth.js");
986
986
  await loadEEAuthToken().catch(() => { });
987
+ // First-run EE setup (interactive, once per install): if no EE server is
988
+ // configured, offer to connect one + write ~/.experience/config.json so the
989
+ // agent's record/recall/feedback loop has a brain. One-time, flag-gated.
990
+ if (isInteractive) {
991
+ try {
992
+ const { loadUserSettings, saveUserSettings } = await import("./utils/settings.js");
993
+ if (loadUserSettings().eeSetupPrompted !== true && !getCachedServerBaseUrl()) {
994
+ const { firstRunEESetup } = await import("./ee/ee-onboarding.js");
995
+ const wrote = await firstRunEESetup();
996
+ if (wrote)
997
+ await loadEEAuthToken().catch(() => { });
998
+ saveUserSettings({ eeSetupPrompted: true });
999
+ }
1000
+ }
1001
+ catch (err) {
1002
+ if (process.env.MUONROI_DEBUG)
1003
+ console.error(`[muonroi-cli] EE first-run setup skipped: ${err?.message}`);
1004
+ }
1005
+ }
987
1006
  // Auto-detect EE client mode (thin / thin-degraded / fat / disabled).
988
1007
  // Result is cached for downstream callsites (PIL layers, bridge.searchByText)
989
1008
  // so each request doesn't re-probe.
@@ -33,6 +33,80 @@ describe("ensureDefaultMcpServers — research servers", () => {
33
33
  expect(ids).toContain("fetch");
34
34
  expect(ids).toContain("tavily");
35
35
  });
36
+ it("registers muonroi-docs as a default, enabled, http ecosystem source", async () => {
37
+ const { ensureDefaultMcpServers } = await import("../auto-setup.js");
38
+ const merged = ensureDefaultMcpServers();
39
+ const docs = merged.find((s) => s.id === "muonroi-docs");
40
+ expect(docs).toBeDefined();
41
+ expect(docs?.enabled).toBe(true);
42
+ expect(docs?.transport).toBe("http");
43
+ expect(docs?.url).toContain("docs-mcp.muonroi.com");
44
+ });
45
+ it("does NOT register muonroi-tools (its tools are native in-process builtins now)", async () => {
46
+ // The CLI no longer self-spawns itself as an MCP server. ee_query/ee_feedback/
47
+ // ee_health/usage_forensics/lsp_query/setup_guide/selfverify_* are native
48
+ // builtins (src/tools/native-tools.ts) — strictly better than a per-turn
49
+ // subprocess cold-start. So muonroi-tools must NOT be seeded.
50
+ const { ensureDefaultMcpServers } = await import("../auto-setup.js");
51
+ const merged = ensureDefaultMcpServers();
52
+ expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
53
+ });
54
+ it("removes an existing self-spawned muonroi-tools entry (incl. an old vitest-worker-poisoned one)", async () => {
55
+ const settingsPath = path.join(tmpHome, ".muonroi-cli", "user-settings.json");
56
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
57
+ fs.writeFileSync(settingsPath, JSON.stringify({
58
+ mcp: {
59
+ servers: [
60
+ {
61
+ id: "muonroi-tools",
62
+ label: "muonroi-tools (Experience + Self-Diagnostics)",
63
+ enabled: true,
64
+ transport: "stdio",
65
+ command: "C:\\Program Files\\nodejs\\node.exe",
66
+ args: [
67
+ "D:\\repo\\node_modules\\.bun\\vitest@4.1.5\\node_modules\\vitest\\dist\\workers\\forks.js",
68
+ "tools-mcp",
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ }));
74
+ const { ensureDefaultMcpServers } = await import("../auto-setup.js");
75
+ const merged = ensureDefaultMcpServers();
76
+ // Deprecated self-spawn stripped; no vitest worker path survives.
77
+ expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
78
+ expect(JSON.stringify(merged)).not.toMatch(/vitest|forks\.js/);
79
+ });
80
+ it("removes a self-spawned muonroi-tools entry even with a valid bun-source command", async () => {
81
+ const settingsPath = path.join(tmpHome, ".muonroi-cli", "user-settings.json");
82
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
83
+ fs.writeFileSync(settingsPath, JSON.stringify({
84
+ mcp: {
85
+ servers: [
86
+ {
87
+ id: "muonroi-tools",
88
+ label: "muonroi-tools",
89
+ enabled: true,
90
+ transport: "stdio",
91
+ command: "bun",
92
+ args: ["/repo/src/index.ts", "tools-mcp"],
93
+ },
94
+ {
95
+ id: "context7",
96
+ label: "Context7",
97
+ enabled: true,
98
+ transport: "http",
99
+ url: "https://mcp.context7.com/mcp",
100
+ },
101
+ ],
102
+ },
103
+ }));
104
+ const { ensureDefaultMcpServers } = await import("../auto-setup.js");
105
+ const merged = ensureDefaultMcpServers();
106
+ expect(merged.find((s) => s.id === "muonroi-tools")).toBeUndefined();
107
+ // Unrelated user server preserved.
108
+ expect(merged.find((s) => s.id === "context7")).toBeDefined();
109
+ });
36
110
  it("context7 and fetch default to enabled", async () => {
37
111
  const { ensureDefaultMcpServers } = await import("../auto-setup.js");
38
112
  const merged = ensureDefaultMcpServers();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Pool reuse semantics: connect a server ONCE, reuse the live client across
3
+ // turns, evict on failure (retry) and on a post-connect connection error
4
+ // (reconnect), and tear everything down on closeAllMcpClients().
5
+ vi.mock("../validate.js", () => ({ validateMcpServerConfig: () => ({ ok: true }) }));
6
+ const connectOneServer = vi.fn();
7
+ vi.mock("../runtime.js", () => ({
8
+ connectOneServer: (...args) => connectOneServer(...args),
9
+ getMcpBuildDeadlineMs: () => 500,
10
+ }));
11
+ import { __mcpClientPoolSize, __resetMcpClientPoolForTests, acquireMcpTools, closeAllMcpClients, warmMcpClients, } from "../client-pool.js";
12
+ const srv = (id) => ({ id, label: id, enabled: true, transport: "stdio", command: "x", args: [] });
13
+ const connected = (id, close = async () => { }) => ({
14
+ tools: { [`mcp_${id}__ping`]: { execute: async () => "pong" } },
15
+ client: { close },
16
+ });
17
+ describe("acquireMcpTools — cross-turn client pool", () => {
18
+ beforeEach(() => {
19
+ __resetMcpClientPoolForTests();
20
+ connectOneServer.mockReset();
21
+ });
22
+ afterEach(async () => {
23
+ await closeAllMcpClients();
24
+ });
25
+ it("connects a server once and reuses it across turns (no per-turn cold-spawn)", async () => {
26
+ connectOneServer.mockImplementation(async (s) => connected(s.id));
27
+ const b1 = await acquireMcpTools([srv("fs")]);
28
+ expect(Object.keys(b1.tools)).toContain("mcp_fs__ping");
29
+ await b1.close(); // release — must NOT kill the pooled client
30
+ const b2 = await acquireMcpTools([srv("fs")]);
31
+ expect(Object.keys(b2.tools)).toContain("mcp_fs__ping");
32
+ expect(connectOneServer).toHaveBeenCalledTimes(1); // reused, not re-spawned
33
+ });
34
+ it("evicts a failed connect so a later turn retries", async () => {
35
+ connectOneServer
36
+ .mockRejectedValueOnce(new Error("spawn failed"))
37
+ .mockImplementation(async (s) => connected(s.id));
38
+ const b1 = await acquireMcpTools([srv("fs")]);
39
+ expect(b1.errors.some((e) => e.includes("fs"))).toBe(true);
40
+ expect(Object.keys(b1.tools)).not.toContain("mcp_fs__ping");
41
+ const b2 = await acquireMcpTools([srv("fs")]);
42
+ expect(Object.keys(b2.tools)).toContain("mcp_fs__ping");
43
+ expect(connectOneServer).toHaveBeenCalledTimes(2); // retried after eviction
44
+ });
45
+ it("self-heals: a tool hitting a connection error evicts the client so the next turn reconnects", async () => {
46
+ connectOneServer.mockImplementation(async (s) => ({
47
+ tools: {
48
+ [`mcp_${s.id}__boom`]: {
49
+ execute: async () => {
50
+ throw new Error("MCP transport closed");
51
+ },
52
+ },
53
+ },
54
+ client: { close: async () => { } },
55
+ }));
56
+ const b1 = await acquireMcpTools([srv("fs")]);
57
+ await expect(b1.tools["mcp_fs__boom"].execute({}, {})).rejects.toThrow(/transport closed/);
58
+ const b2 = await acquireMcpTools([srv("fs")]);
59
+ expect(b2).toBeDefined();
60
+ expect(connectOneServer).toHaveBeenCalledTimes(2); // reconnected after the connection error
61
+ });
62
+ it("keys by cwd/config — a different command reconnects rather than reusing", async () => {
63
+ connectOneServer.mockImplementation(async (s) => connected(s.id));
64
+ await acquireMcpTools([
65
+ { id: "fs", label: "fs", enabled: true, transport: "stdio", command: "a", args: [] },
66
+ ]);
67
+ await acquireMcpTools([
68
+ { id: "fs", label: "fs", enabled: true, transport: "stdio", command: "b", args: [] },
69
+ ]);
70
+ expect(connectOneServer).toHaveBeenCalledTimes(2);
71
+ });
72
+ it("warmMcpClients pre-connects so the first real turn reuses (no extra spawn)", async () => {
73
+ let resolveConnect = () => { };
74
+ connectOneServer.mockImplementation((s) => new Promise((res) => {
75
+ resolveConnect = () => res(connected(s.id));
76
+ }));
77
+ // Warm starts the connect in the background.
78
+ warmMcpClients([srv("fs")]);
79
+ expect(connectOneServer).toHaveBeenCalledTimes(1);
80
+ expect(__mcpClientPoolSize()).toBe(1);
81
+ // Let the warm connect finish, then a real turn reuses it.
82
+ resolveConnect();
83
+ await new Promise((r) => setTimeout(r, 0));
84
+ const b = await acquireMcpTools([srv("fs")]);
85
+ expect(Object.keys(b.tools)).toContain("mcp_fs__ping");
86
+ expect(connectOneServer).toHaveBeenCalledTimes(1); // warmed, not re-spawned
87
+ });
88
+ it("closeAllMcpClients tears down every pooled client", async () => {
89
+ const closeSpy = vi.fn(async () => { });
90
+ connectOneServer.mockImplementation(async (s) => connected(s.id, closeSpy));
91
+ await acquireMcpTools([srv("fs"), srv("mem")]);
92
+ expect(__mcpClientPoolSize()).toBe(2);
93
+ await closeAllMcpClients();
94
+ expect(closeSpy).toHaveBeenCalledTimes(2);
95
+ expect(__mcpClientPoolSize()).toBe(0);
96
+ });
97
+ });
98
+ //# sourceMappingURL=client-pool.spec.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Phase 1c regression: buildMcpToolSet must connect servers in PARALLEL and
3
+ // return PARTIAL results at its deadline, so a slow server (e.g. an npx stdio
4
+ // spawn) never starves a fast one. The OLD sequential build under an outer race
5
+ // dropped the WHOLE bundle on timeout — the agent then saw NO MCP tools even
6
+ // when a fast HTTP server was reachable (session f6f7881a5fae).
7
+ vi.mock("../mcp-keychain.js", () => ({
8
+ getMcpKey: vi.fn(async () => null),
9
+ setMcpKey: vi.fn(async () => true),
10
+ deleteMcpKey: vi.fn(async () => true),
11
+ }));
12
+ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
13
+ StdioClientTransport: vi.fn(function (opts) {
14
+ Object.assign(this, opts);
15
+ }),
16
+ getDefaultEnvironment: () => ({}),
17
+ }));
18
+ vi.mock("../validate.js", () => ({
19
+ validateMcpServerConfig: () => ({ ok: true }),
20
+ }));
21
+ const fastClient = {
22
+ tools: async () => ({ ping: { description: "ping", execute: async () => ({ ok: true }) } }),
23
+ close: async () => { },
24
+ };
25
+ vi.mock("@ai-sdk/mcp", () => ({
26
+ // A server whose name contains "slow" never finishes connecting (simulates a
27
+ // slow npx spawn). Everything else connects instantly.
28
+ createMCPClient: vi.fn(async ({ name }) => {
29
+ if (name.includes("slow"))
30
+ return new Promise(() => { }); // never resolves
31
+ return fastClient;
32
+ }),
33
+ }));
34
+ describe("buildMcpToolSet — parallel build, partial results at deadline (Phase 1c)", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ delete process.env.MUONROI_MCP_BUILD_DEADLINE_MS;
38
+ });
39
+ it("returns the fast server's tools and reports the slow one — slow does NOT starve fast", async () => {
40
+ process.env.MUONROI_MCP_BUILD_DEADLINE_MS = "500";
41
+ const { buildMcpToolSet } = await import("../runtime.js");
42
+ const start = Date.now();
43
+ const bundle = await buildMcpToolSet([
44
+ { id: "slow-server", label: "slow-server", enabled: true, transport: "stdio", command: "node", args: [] },
45
+ { id: "fast-server", label: "fast-server", enabled: true, transport: "stdio", command: "node", args: [] },
46
+ ]);
47
+ const elapsed = Date.now() - start;
48
+ // Resolved at ~the deadline, NOT blocked behind the slow (never-ending) connect.
49
+ expect(elapsed).toBeLessThan(2000);
50
+ // The fast server's tool is available even though a slower server is pending.
51
+ expect(Object.keys(bundle.tools)).toContain("mcp_fast-server__ping");
52
+ // The slow server is surfaced as an error, never silently dropped.
53
+ expect(bundle.errors.some((e) => e.includes("slow-server") && /not ready within/.test(e))).toBe(true);
54
+ await bundle.close();
55
+ });
56
+ it("orders are independent — the fast server loads regardless of position", async () => {
57
+ process.env.MUONROI_MCP_BUILD_DEADLINE_MS = "500";
58
+ const { buildMcpToolSet } = await import("../runtime.js");
59
+ const bundle = await buildMcpToolSet([
60
+ { id: "fast-server", label: "fast-server", enabled: true, transport: "stdio", command: "node", args: [] },
61
+ { id: "slow-server", label: "slow-server", enabled: true, transport: "stdio", command: "node", args: [] },
62
+ ]);
63
+ expect(Object.keys(bundle.tools)).toContain("mcp_fast-server__ping");
64
+ await bundle.close();
65
+ });
66
+ });
67
+ //# sourceMappingURL=parallel-build.spec.js.map
@@ -6,6 +6,7 @@ const servers = [
6
6
  { id: "filesystem" },
7
7
  { id: "muonroi-tools" },
8
8
  { id: "muonroi-harness" },
9
+ { id: "muonroi-docs" },
9
10
  { id: "context7" },
10
11
  { id: "fetch" },
11
12
  { id: "tavily" },
@@ -62,6 +63,26 @@ describe("filterMcpServersByMessage", () => {
62
63
  expect(ids(out)).toContain("muonroi-tools");
63
64
  expect(ids(out)).toContain("muonroi-harness");
64
65
  });
66
+ it("keeps muonroi-docs for an ecosystem question that has no generic docs keyword", () => {
67
+ // Live miss (session dbe408937a3d turn 1): "bạn hiểu thế nào về hệ sinh thái
68
+ // muonroi" carries no docs/api keyword and doesn't say "muonroi-docs", so the
69
+ // authoritative ecosystem source was dropped and the agent guessed from files.
70
+ for (const msg of [
71
+ "bạn hiểu thế nào về hệ sinh thái muonroi",
72
+ "what is the muonroi ecosystem?",
73
+ "explain the building-block rule engine",
74
+ ]) {
75
+ const out = filterMcpServersByMessage(servers, msg);
76
+ expect(ids(out), msg).toContain("muonroi-docs");
77
+ }
78
+ });
79
+ it("keeping muonroi-docs for an ecosystem question does NOT over-keep other docs/web servers", () => {
80
+ const out = filterMcpServersByMessage(servers, "bạn hiểu thế nào về hệ sinh thái muonroi");
81
+ expect(ids(out)).toContain("muonroi-docs");
82
+ expect(ids(out)).not.toContain("context7");
83
+ expect(ids(out)).not.toContain("fetch");
84
+ expect(ids(out)).not.toContain("tavily");
85
+ });
65
86
  it("returns every server unchanged when disabled (MUONROI_DISABLE_SMART_MCP=1)", () => {
66
87
  const out = filterMcpServersByMessage(servers, "Reply PONG", { disabled: true });
67
88
  expect(ids(out)).toEqual(ids(servers));
@@ -74,6 +95,41 @@ describe("filterMcpServersByMessage", () => {
74
95
  expect(ids(out)).toContain("chrome-devtools");
75
96
  }
76
97
  });
98
+ // Regression: session f6f7881a5fae. The user asked "bạn thử call tool
99
+ // setup_guide ... ( call tool chứ không phải đọc code )". `muonroi-docs` (id
100
+ // matches /docs/) carried no docs-lookup keyword, so the category skip dropped
101
+ // it — the model had no `setup_guide` tool and drove the server by hand over
102
+ // bash JSON-RPC. An explicit tool-invocation intent (or an outright server
103
+ // mention) must keep the owning server.
104
+ const docsServers = [{ id: "filesystem" }, { id: "muonroi-docs" }, { id: "context7" }, { id: "tavily" }];
105
+ it("keeps an optional server when the user explicitly asks to CALL a tool by name", () => {
106
+ const msg = "bạn thử call tool setup_guide xem có được thông tin gì không nhé ( call tool chứ không phải đọc code nhé )";
107
+ expect(ids(filterMcpServersByMessage(docsServers, msg))).toContain("muonroi-docs");
108
+ });
109
+ it("recognises explicit tool-invocation intent (EN + VI)", () => {
110
+ for (const msg of [
111
+ "please call the setup_guide tool and report",
112
+ "use the docs_search tool",
113
+ "invoke the mcp tool",
114
+ "do a tool call to setup_guide",
115
+ "dùng tool docs_search giúp tôi",
116
+ "gọi tool setup_guide",
117
+ "thử mcp tool xem sao",
118
+ ]) {
119
+ expect(ids(filterMcpServersByMessage(docsServers, msg)), msg).toContain("muonroi-docs");
120
+ }
121
+ });
122
+ it("keeps a server named outright in the message even without a category signal", () => {
123
+ // "check the muonroi-docs MCP" — no docs-lookup verb, but the server is named.
124
+ const out = filterMcpServersByMessage(docsServers, "bạn check xem dùng được mcp muonroi-docs không nhé");
125
+ expect(ids(out)).toContain("muonroi-docs");
126
+ });
127
+ it("still drops optional servers on a pure code prompt (token savings preserved)", () => {
128
+ // The fix must NOT defeat the filter: no tool-intent, no server mention, no
129
+ // docs signal → muonroi-docs/context7/tavily still dropped.
130
+ const out = filterMcpServersByMessage(docsServers, "fix the off-by-one in parseRange()");
131
+ expect(ids(out)).toEqual(["filesystem"]);
132
+ });
77
133
  });
78
134
  describe("dropRedundantFsMcpTools", () => {
79
135
  const fn = () => ({});
@@ -1,5 +1,44 @@
1
1
  import { loadMcpServers, saveMcpServers } from "../utils/settings.js";
2
+ /**
3
+ * True when running inside a test runner (vitest). Used to keep seed-time
4
+ * persistence from mutating the user's REAL config — see ensureDefaultMcpServers.
5
+ */
6
+ function isTestRunner() {
7
+ return process.env.VITEST === "true" || process.env.VITEST_WORKER_ID !== undefined || process.env.NODE_ENV === "test";
8
+ }
9
+ /**
10
+ * Remove a deprecated self-spawned `muonroi-tools` stdio server from the config.
11
+ *
12
+ * The CLI's OWN inner agent now exposes ee_query/ee_feedback/ee_health/
13
+ * usage_forensics/lsp_query/setup_guide/selfverify_* as NATIVE in-process
14
+ * builtins (src/tools/native-tools.ts) — strictly better than self-spawning a
15
+ * 137MB CLI as an MCP subprocess (which cold-started 2-3.5s, overran the build
16
+ * deadline, and once had a vitest-worker command persisted that crashed on
17
+ * launch). So the self-spawn is now pure waste: every tool it would expose is
18
+ * dropped as a native twin. Strip it on sight. The muonroi-tools MCP server
19
+ * still exists for EXTERNAL agents via their own config (e.g. ~/.claude.json) —
20
+ * that is a different file and is untouched here. Returns true if it changed.
21
+ */
22
+ function removeDeprecatedToolsMcp(servers) {
23
+ const idx = servers.findIndex((s) => s.id === "muonroi-tools" && s.transport === "stdio");
24
+ if (idx < 0)
25
+ return false;
26
+ servers.splice(idx, 1);
27
+ console.error("[mcp:auto-setup] removed deprecated self-spawned muonroi-tools server — its tools are now native in-process builtins");
28
+ return true;
29
+ }
2
30
  const DEFAULT_CONFIGS = [
31
+ {
32
+ // Authoritative source for the Muonroi ecosystem (BB/.NET template recipes,
33
+ // package docs, setup_guide, docs_search). Shipped enabled by default so any
34
+ // task touching the ecosystem always has a standard source to work from —
35
+ // the CLI behaves like a senior who knows the ecosystem, not one guessing.
36
+ id: "muonroi-docs",
37
+ label: "muonroi-docs (Ecosystem Docs)",
38
+ enabled: true,
39
+ transport: "http",
40
+ url: "https://docs-mcp.muonroi.com/mcp",
41
+ },
3
42
  {
4
43
  id: "filesystem",
5
44
  label: "Filesystem",
@@ -81,6 +120,13 @@ export function ensureDefaultMcpServers() {
81
120
  try {
82
121
  const existing = loadMcpServers();
83
122
  let dirty = migrateServers(existing);
123
+ // muonroi-tools is no longer self-spawned by the CLI — its capabilities
124
+ // (ee_query/ee_feedback/ee_health/usage_forensics/lsp_query/setup_guide/
125
+ // selfverify_*) are NATIVE in-process builtins now (src/tools/native-tools.ts).
126
+ // Strip any deprecated self-spawn entry so it stops cold-starting a redundant
127
+ // subprocess every turn (and removes the old vitest-worker-poisoned ones).
128
+ if (removeDeprecatedToolsMcp(existing))
129
+ dirty = true;
84
130
  const existingIds = new Set(existing.map((s) => s.id));
85
131
  const toAdd = DEFAULT_CONFIGS.filter((s) => !existingIds.has(s.id));
86
132
  if (toAdd.length > 0)
@@ -88,10 +134,18 @@ export function ensureDefaultMcpServers() {
88
134
  if (!dirty)
89
135
  return existing;
90
136
  const merged = toAdd.length > 0 ? [...toAdd, ...existing] : existing;
91
- saveMcpServers(merged);
137
+ // Never let a test runner mutate the user's REAL config file. Tests assert on
138
+ // the returned array; persistence is exercised only on real runs. This closes
139
+ // the leak whereby the seed (run from the Orchestrator constructor, which
140
+ // orchestrator tests trigger) wrote into a live config.
141
+ if (!isTestRunner())
142
+ saveMcpServers(merged);
92
143
  return merged;
93
144
  }
94
- catch {
145
+ catch (err) {
146
+ console.error(`[mcp:auto-setup] ensureDefaultMcpServers failed: ${err?.message}`, {
147
+ stack: err?.stack?.split("\n").slice(0, 3),
148
+ });
95
149
  return [];
96
150
  }
97
151
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * src/mcp/client-pool.ts
3
+ *
4
+ * Cross-turn MCP client pool. The orchestrator rebuilds its tool set every turn
5
+ * (and closes it in a `finally`), which previously cold-spawned EVERY stdio MCP
6
+ * server (npx filesystem/playwright/fetch/tavily/…) on every turn — each spawn
7
+ * costs ~1-3s and raced the build deadline. This pool connects each server ONCE
8
+ * and reuses the live client across turns: only the first turn that needs a
9
+ * server pays the cold-start; later turns select its (already-built) tools
10
+ * instantly. Real teardown happens once on orchestrator/process shutdown.
11
+ *
12
+ * Per-turn smart-filtering is unchanged — the caller still passes only the
13
+ * servers relevant to this message; the pool just avoids re-spawning the ones
14
+ * it has already connected.
15
+ *
16
+ * Self-healing: a server that fails to connect is evicted (not cached as a
17
+ * rejection), so a later turn retries. A live client whose child process dies
18
+ * later is evicted when one of its tool calls hits a transport/connection error,
19
+ * so the next turn reconnects fresh.
20
+ */
21
+ import type { McpServerConfig } from "../utils/settings.js";
22
+ import { type McpBuildOptions, type McpToolBundle } from "./runtime.js";
23
+ /**
24
+ * Acquire the tool set for `servers`, reusing pooled clients where possible.
25
+ * Mirrors buildMcpToolSet's parallel + partial-at-deadline contract, but only
26
+ * FIRST-connects can be slow — already-pooled servers resolve instantly. The
27
+ * returned bundle's `close()` is a no-op RELEASE: pooled clients stay alive for
28
+ * the next turn. Use closeAllMcpClients() for real teardown.
29
+ */
30
+ export declare function acquireMcpTools(servers: McpServerConfig[], opts?: McpBuildOptions): Promise<McpToolBundle>;
31
+ /**
32
+ * Fire-and-forget pre-connect: start connecting `servers` in the background so
33
+ * they are pooled BEFORE the first turn needs them. npx stdio servers
34
+ * (filesystem/memory) cold-start >2.5s and would otherwise miss the first turn's
35
+ * build deadline — warming them at startup means they're usually ready by the
36
+ * first prompt. No deadline, no return; per-turn acquireMcpTools reuses whatever
37
+ * has connected. Idempotent (cached entries are reused); a failed connect is
38
+ * evicted by getOrConnect so a real turn retries.
39
+ */
40
+ export declare function warmMcpClients(servers: McpServerConfig[]): void;
41
+ /** Tear down every pooled client. Call on orchestrator/process shutdown. */
42
+ export declare function closeAllMcpClients(): Promise<void>;
43
+ /** Test-only: reset pool state between cases. */
44
+ export declare function __resetMcpClientPoolForTests(): void;
45
+ /** Test-only: number of pooled (connecting or connected) entries. */
46
+ export declare function __mcpClientPoolSize(): number;