mixdog 1.2.0 → 1.2.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.
Files changed (201) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -0
  4. package/src/defaults/skills/pptx/SKILL.md +6 -6
  5. package/src/defaults/skills/pptx/references/review.md +6 -4
  6. package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
  7. package/src/defaults/skills/setup/references/actions.md +13 -3
  8. package/src/defaults/skills/visualize/SKILL.md +12 -9
  9. package/src/rules/shared/60-verification.md +3 -1
  10. package/src/rules/shared/70-delivery.md +4 -2
  11. package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
  12. package/src/runtime/agent/orchestrator/config.mjs +10 -4
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  14. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
  15. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
  16. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +86 -8
  17. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +65 -7
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +128 -44
  20. package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
  21. package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +19 -11
  25. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
  27. package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
  28. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
  29. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
  31. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +9 -2
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
  33. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
  34. package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
  35. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
  36. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
  37. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
  38. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +9 -3
  44. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
  45. package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
  46. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
  47. package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
  48. package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
  49. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +267 -84
  50. package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
  53. package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
  54. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
  55. package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
  56. package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
  57. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
  58. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
  59. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
  60. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
  61. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
  62. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
  63. package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
  64. package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
  66. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
  68. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +6 -4
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
  71. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +3 -1
  73. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
  77. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
  78. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
  79. package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
  80. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
  81. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
  82. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +19 -12
  84. package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
  85. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
  86. package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
  87. package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +21 -2
  89. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
  92. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
  95. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
  99. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
  101. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
  102. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
  104. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
  105. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  106. package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
  107. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
  108. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
  109. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
  110. package/src/runtime/attachments/store.d.mts +3 -0
  111. package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
  112. package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
  113. package/src/runtime/channels/lib/worker-main.mjs +17 -14
  114. package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
  115. package/src/runtime/effort-judge/judge-client.mjs +56 -18
  116. package/src/runtime/memory/lib/memory.mjs +1 -1
  117. package/src/runtime/memory/lib/session-ingest.mjs +2 -2
  118. package/src/runtime/shared/atomic-file.mjs +25 -4
  119. package/src/runtime/shared/extension-scopes.mjs +5 -3
  120. package/src/runtime/shared/file-lock.mjs +37 -11
  121. package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
  122. package/src/runtime/shared/llm/model-catalog.mjs +0 -12
  123. package/src/runtime/shared/llm/provider-catalog-registry.mjs +6 -0
  124. package/src/runtime/shared/plugin-manifest.mjs +18 -9
  125. package/src/runtime/shared/pristine-execution-contract.json +9 -2
  126. package/src/runtime/shared/pristine-execution.mjs +28 -4
  127. package/src/runtime/shared/provider-accounts.mjs +124 -20
  128. package/src/runtime/shared/provider-api-key.mjs +12 -0
  129. package/src/runtime/shared/shell-display.mjs +9 -3
  130. package/src/runtime/shared/skill-mentions.d.mts +10 -0
  131. package/src/runtime/shared/skill-mentions.mjs +133 -0
  132. package/src/runtime/shared/skill-script-runtime.mjs +264 -0
  133. package/src/runtime/shared/tool-primitives.mjs +35 -2
  134. package/src/runtime/shared/turn-worktree-snapshot.mjs +25 -5
  135. package/src/runtime/shared/worker-requests.mjs +15 -2
  136. package/src/session-runtime/automation-agents.mjs +8 -2
  137. package/src/session-runtime/boot/apis.mjs +55 -0
  138. package/src/session-runtime/boot/config.mjs +5 -2
  139. package/src/session-runtime/boot/facade.mjs +1 -0
  140. package/src/session-runtime/boot/infrastructure.mjs +29 -18
  141. package/src/session-runtime/conversation-import/discovery.mjs +698 -0
  142. package/src/session-runtime/conversation-import/service.mjs +369 -0
  143. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
  144. package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
  145. package/src/session-runtime/mcp-glue/connect.mjs +57 -10
  146. package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
  147. package/src/session-runtime/mcp-glue/server-catalog.mjs +115 -30
  148. package/src/session-runtime/mcp-glue.mjs +22 -2
  149. package/src/session-runtime/new-session-config.mjs +3 -2
  150. package/src/session-runtime/notification-bus.mjs +9 -2
  151. package/src/session-runtime/provider-auth/accounts.mjs +33 -15
  152. package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
  153. package/src/session-runtime/provider-readiness.mjs +16 -10
  154. package/src/session-runtime/resource-extension-sync.mjs +20 -5
  155. package/src/session-runtime/resource-mcp-api.mjs +101 -77
  156. package/src/session-runtime/resource-plugins-api.mjs +29 -41
  157. package/src/session-runtime/resource-scope-decorate.mjs +6 -8
  158. package/src/session-runtime/resource-skills-api.mjs +23 -2
  159. package/src/session-runtime/schedule-session-run.mjs +1 -1
  160. package/src/session-runtime/services/channel-session-router.mjs +4 -1
  161. package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
  162. package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
  163. package/src/session-runtime/services/provider-admin.mjs +64 -18
  164. package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
  165. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  166. package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
  167. package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
  168. package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
  169. package/src/session-runtime/skills-api.mjs +7 -3
  170. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
  171. package/src/session-runtime/webhook-session-run.mjs +16 -4
  172. package/src/skill-script-entry.mjs +36 -0
  173. package/src/standalone/daemon-stored-session-views.mjs +11 -3
  174. package/src/standalone/daemon.mjs +41 -4
  175. package/src/standalone/session-protocol.mjs +5 -0
  176. package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
  177. package/src/tui/App.jsx +9 -1
  178. package/src/tui/app/app-view/floating-panel.jsx +9 -1
  179. package/src/tui/app/app-view.jsx +6 -0
  180. package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
  181. package/src/tui/app/model-options.mjs +2 -0
  182. package/src/tui/app/skill-mention-palette.mjs +88 -0
  183. package/src/tui/app/slash-palette.mjs +23 -7
  184. package/src/tui/app/transcript-row-estimate.mjs +11 -3
  185. package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
  186. package/src/tui/app/use-prompt-state.mjs +5 -0
  187. package/src/tui/components/Message.jsx +22 -1
  188. package/src/tui/components/PromptInput.jsx +21 -3
  189. package/src/tui/components/SlashCommandPalette.jsx +12 -3
  190. package/src/tui/components/TranscriptItem.jsx +9 -1
  191. package/src/tui/components/prompt-input/key-handler.mjs +21 -8
  192. package/src/tui/components/user-message-skills.mjs +38 -0
  193. package/src/tui/dist/index.mjs +507 -171
  194. package/src/tui/session/queue-helpers.mjs +7 -2
  195. package/src/tui/session/session-api/extensions.mjs +4 -2
  196. package/src/tui/session/session-api/settings.mjs +7 -0
  197. package/src/tui/session/session-flow/drain.mjs +14 -4
  198. package/src/tui/session/tui-steering-persist.mjs +25 -9
  199. package/src/tui/session/turn.mjs +17 -0
  200. package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
  201. package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
@@ -12,10 +12,14 @@ import {
12
12
  unlinkSync,
13
13
  writeFileSync,
14
14
  } from 'node:fs';
15
- import { isAbsolute, join } from 'node:path';
15
+ import { join, relative, sep } from 'node:path';
16
16
  import { clean } from './session-text.mjs';
17
- import { readJsonSafe } from './fs-utils.mjs';
18
- import { pluginManifest, pluginSkillsRoots, resolveContainedPluginPath } from '../../../shared/plugin-manifest.mjs';
17
+ import {
18
+ PLUGIN_MANIFEST_FILES,
19
+ pluginManifest,
20
+ pluginSkillsRoots,
21
+ resolveContainedPluginPath,
22
+ } from '../../../shared/plugin-manifest.mjs';
19
23
  import { isPlainObject } from '../../../shared/object.mjs';
20
24
 
21
25
  const MCP_TRANSPORT_FIELDS = new Set([
@@ -138,17 +142,15 @@ export function saveProjectMcpServer(cwd, { originalName = '', name, config }) {
138
142
  // discovery and this status surface read the same roots.
139
143
  export { pluginManifest, resolveContainedPluginPath };
140
144
 
141
- function mcpServersMapFromJson(raw) {
142
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
143
- if (isPlainObject(raw.mcpServers)) return raw.mcpServers;
144
- return raw;
145
- }
146
-
147
145
  function isSkillMdFile(name) {
148
146
  return /^(SKILL|skill)\.md$/i.test(name);
149
147
  }
150
148
 
151
- const PLUGIN_INLINE_MCP_SCRIPT = 'plugin.json';
149
+ const PLUGIN_MANIFEST_FORMATS = {
150
+ '.codex-plugin/plugin.json': 'codex',
151
+ 'plugin.json': 'mixdog',
152
+ '.claude-plugin/plugin.json': 'claude',
153
+ };
152
154
 
153
155
  export function countSkillFiles(root) {
154
156
  let count = 0;
@@ -177,102 +179,283 @@ export function countSkillFiles(root) {
177
179
  return count;
178
180
  }
179
181
 
180
- export function discoverPluginMcp(root) {
181
- const manifest = pluginManifest(root);
182
- const mcp = manifest.mcpServers;
183
- if (typeof mcp === 'string' && mcp.trim()) {
184
- const rel = mcp.trim();
185
- const abs = resolveContainedPluginPath(root, rel);
186
- if (abs && existsSync(abs)) return { mcpScript: rel, mcpInline: false };
187
- } else if (isPlainObject(mcp)) {
188
- const keys = Object.keys(mcp).filter((k) => isPlainObject(mcp[k]));
189
- if (keys.length) return { mcpScript: null, mcpInline: true };
182
+ const MCP_STDIO_TYPES = new Set(['stdio']);
183
+ const MCP_URL_TYPES = new Set(['http', 'streamable-http', 'sse', 'ws']);
184
+
185
+ function manifestFormat(root) {
186
+ for (const rel of PLUGIN_MANIFEST_FILES) {
187
+ const path = join(root, ...rel.split('/'));
188
+ if (existsSync(path)) return { path, format: PLUGIN_MANIFEST_FORMATS[rel] };
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /** Transport a declared server asks for, inferred the way the MCP client does
194
+ * when `type` is absent: `transport: "http"` or a `url` -> http, else stdio.
195
+ * Null for an unrecognized explicit type. */
196
+ export function declaredMcpTransport(config) {
197
+ if (config?.type !== undefined && config?.type !== null && config?.type !== '') {
198
+ if (typeof config.type !== 'string') return null;
199
+ const type = config.type.toLowerCase();
200
+ if (type === 'streamablehttp') return 'streamable-http';
201
+ return MCP_STDIO_TYPES.has(type) || MCP_URL_TYPES.has(type) ? type : null;
190
202
  }
191
- const candidates = ['.mcp.json', 'scripts/run-mcp.mjs', 'mcp/server.mjs', 'server.mjs'];
192
- for (const rel of candidates) {
193
- const abs = resolveContainedPluginPath(root, rel);
194
- if (abs && existsSync(abs)) return { mcpScript: rel, mcpInline: false };
203
+ if (config?.transport === 'http') return 'http';
204
+ if (config?.command !== undefined) return 'stdio';
205
+ if (config?.url !== undefined) return 'http';
206
+ return 'stdio';
207
+ }
208
+
209
+ // One server entry -> a message when it cannot be launched, else null. Messages
210
+ // name fields and expected types only, never a configured value.
211
+ function mcpServerProblem(config) {
212
+ if (!isPlainObject(config)) return 'server entry must be an object';
213
+ const type = declaredMcpTransport(config);
214
+ if (!type) return '"type" must be one of stdio, http, streamable-http, sse, ws';
215
+ if (MCP_STDIO_TYPES.has(type)) {
216
+ return typeof config.command === 'string' && config.command.trim() ? null : 'stdio server requires "command"';
195
217
  }
196
- return { mcpScript: null, mcpInline: false };
218
+ return typeof config.url === 'string' && config.url.trim() ? null : `${type} server requires "url"`;
197
219
  }
198
220
 
199
- export function mcpScriptForPlugin(root) {
200
- return discoverPluginMcp(root).mcpScript;
221
+ // A JSON parse failure as a location only: engines may quote the offending
222
+ // source text, which can hold credentials.
223
+ function jsonErrorMessage(error) {
224
+ const text = String(error?.message || '');
225
+ const lineColumn = /line (\d+) column (\d+)/.exec(text);
226
+ if (lineColumn) return `invalid JSON at line ${lineColumn[1]} column ${lineColumn[2]}`;
227
+ const position = /position (\d+)/.exec(text);
228
+ if (position) return `invalid JSON at position ${position[1]}`;
229
+ return /end of (JSON|data) input/i.test(text) ? 'invalid JSON: unexpected end of input' : 'invalid JSON';
201
230
  }
202
231
 
203
- export function pluginMcpEnableScript(root, plugin = {}) {
204
- if (plugin.mcpInline) return PLUGIN_INLINE_MCP_SCRIPT;
205
- const fromPlugin = clean(plugin.mcpScript);
206
- if (fromPlugin) return fromPlugin;
207
- const discovered = discoverPluginMcp(root);
208
- if (discovered.mcpInline) return PLUGIN_INLINE_MCP_SCRIPT;
209
- return discovered.mcpScript || null;
232
+ function readJsonFile(path, errors) {
233
+ let text;
234
+ try {
235
+ text = readFileSync(path, 'utf8');
236
+ } catch (error) {
237
+ errors.push({ source: path, message: `cannot read file${error?.code ? ` (${error.code})` : ''}` });
238
+ return { ok: false };
239
+ }
240
+ try {
241
+ return { ok: true, value: JSON.parse(text) };
242
+ } catch (error) {
243
+ errors.push({ source: path, message: jsonErrorMessage(error) });
244
+ return { ok: false };
245
+ }
210
246
  }
211
247
 
212
- function substitutePluginRootTokens(value, root) {
213
- if (typeof value !== 'string') return value;
214
- return value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, root).replace(/\$\{CODEX_PLUGIN_ROOT\}/g, root);
248
+ // A `{ mcpServers: {...} }` wrapper or a bare server map -> the server map.
249
+ function mcpServerMap(doc, source, errors, where = '') {
250
+ if (!isPlainObject(doc)) {
251
+ errors.push({ source, message: `${where}MCP declaration must be an object` });
252
+ return null;
253
+ }
254
+ if (!Object.hasOwn(doc, 'mcpServers')) return doc;
255
+ if (!isPlainObject(doc.mcpServers)) {
256
+ errors.push({ source, message: `${where}"mcpServers" must be an object` });
257
+ return null;
258
+ }
259
+ return doc.mcpServers;
215
260
  }
216
261
 
217
- function substitutePluginRootTokensDeep(value, root) {
218
- if (typeof value === 'string') return substitutePluginRootTokens(value, root);
219
- if (Array.isArray(value)) return value.map((v) => substitutePluginRootTokensDeep(v, root));
220
- if (value && typeof value === 'object') {
221
- const out = {};
222
- for (const [k, v] of Object.entries(value)) out[k] = substitutePluginRootTokensDeep(v, root);
223
- return out;
262
+ function collectMcpServers(map, source, into, errors) {
263
+ for (const [key, config] of Object.entries(map)) {
264
+ const problem = mcpServerProblem(config);
265
+ if (problem) errors.push({ source, key, message: problem });
266
+ else into.set(key, { key, config, source });
224
267
  }
225
- return value;
226
268
  }
227
269
 
228
- export function normalizePluginMcpServerConfig(cfg, root) {
229
- const substituted = substitutePluginRootTokensDeep(cfg, root) || {};
230
- const out = { ...substituted };
231
- if (typeof out.cwd === 'string' && out.cwd) {
232
- out.cwd = isAbsolute(out.cwd) ? out.cwd : join(root, out.cwd);
233
- } else {
234
- out.cwd = root;
270
+ function loadMcpJsonFile(path, into, errors) {
271
+ const read = readJsonFile(path, errors);
272
+ if (!read.ok) return;
273
+ const map = mcpServerMap(read.value, path, errors);
274
+ if (map) collectMcpServers(map, path, into, errors);
275
+ }
276
+
277
+ // `where` locates the manifest field (e.g. `mcpServers[1]: `); the declared
278
+ // path itself is never echoed.
279
+ function loadDeclaredPath(root, rel, manifestPath, into, errors, touched, where) {
280
+ if (typeof rel !== 'string' || !rel.trim()) {
281
+ errors.push({ source: manifestPath, message: `${where}MCP path must be a non-empty string` });
282
+ return;
235
283
  }
236
- return out;
284
+ const abs = resolveContainedPluginPath(root, rel);
285
+ if (!abs || rel.split(/[\\/]/).includes('..')) {
286
+ errors.push({ source: manifestPath, message: `${where}MCP path must be relative and stay inside the plugin root` });
287
+ return;
288
+ }
289
+ touched.add(abs);
290
+ if (!existsSync(abs)) {
291
+ errors.push({ source: manifestPath, message: `${where}MCP file not found` });
292
+ return;
293
+ }
294
+ loadMcpJsonFile(abs, into, errors);
237
295
  }
238
296
 
239
- export function pluginRawMcpServers(root, script) {
240
- const rel = clean(script);
241
- if (!rel) return null;
242
- if (rel === PLUGIN_INLINE_MCP_SCRIPT) {
243
- const manifest = pluginManifest(root);
244
- const rawServers = manifest.mcpServers;
245
- if (!isPlainObject(rawServers)) {
246
- throw new Error('plugin.json missing inline mcpServers object');
297
+ function readPluginMcpDeclarations(root, touched) {
298
+ const errors = [];
299
+ const into = new Map();
300
+ for (const rel of PLUGIN_MANIFEST_FILES) touched.add(join(root, ...rel.split('/')));
301
+ const found = manifestFormat(root);
302
+ const mcpJson = join(root, '.mcp.json');
303
+ touched.add(mcpJson);
304
+ const hasMcpJson = existsSync(mcpJson);
305
+ let format = 'none';
306
+ if (found) format = found.format;
307
+ else if (hasMcpJson) format = 'claude';
308
+
309
+ if (hasMcpJson) loadMcpJsonFile(mcpJson, into, errors);
310
+
311
+ let declaredRef;
312
+ if (found) {
313
+ const read = readJsonFile(found.path, errors);
314
+ const declared = read.ok && isPlainObject(read.value) ? read.value.mcpServers : undefined;
315
+ declaredRef = declared;
316
+ if (declared === undefined) {
317
+ /* nothing declared */
318
+ } else if (typeof declared === 'string') {
319
+ loadDeclaredPath(root, declared, found.path, into, errors, touched, '"mcpServers": ');
320
+ } else if (Array.isArray(declared)) {
321
+ // Members merge in order: a path to a JSON file, or an inline server
322
+ // map (bare or `{ mcpServers }`-wrapped).
323
+ declared.forEach((entry, index) => {
324
+ const where = `"mcpServers"[${index}]: `;
325
+ if (isPlainObject(entry)) {
326
+ const map = mcpServerMap(entry, found.path, errors, where);
327
+ if (map) collectMcpServers(map, found.path, into, errors);
328
+ } else {
329
+ loadDeclaredPath(root, entry, found.path, into, errors, touched, where);
330
+ }
331
+ });
332
+ } else if (isPlainObject(declared)) {
333
+ const map = mcpServerMap(declared, found.path, errors);
334
+ if (map) collectMcpServers(map, found.path, into, errors);
335
+ } else {
336
+ errors.push({ source: found.path, message: '"mcpServers" must be a path, a list, or an object' });
247
337
  }
248
- const keys = Object.keys(rawServers).filter((k) => isPlainObject(rawServers[k]));
249
- if (!keys.length) throw new Error('plugin.json has no mcpServers');
250
- return { rawServers, mcpRoot: root };
251
338
  }
252
- if (!/\.json$/i.test(rel)) return null;
253
- const mcpJsonPath = resolveContainedPluginPath(root, rel);
254
- if (!mcpJsonPath || !existsSync(mcpJsonPath)) {
255
- throw new Error(`plugin MCP manifest not found: ${join(root, rel)}`);
339
+ const declaredPath = typeof declaredRef === 'string' ? declaredRef : null;
340
+ return { format, servers: [...into.values()], errors, declaredPath };
341
+ }
342
+
343
+ // Parsed declarations per plugin root, valid while every file the parse looked
344
+ // at (present or absent) still has the same identity, size and timestamps.
345
+ const DECLARATION_CACHE_MAX = 256;
346
+ const declarationCache = new Map();
347
+ const declarationCacheStats = { hits: 0, misses: 0 };
348
+
349
+ function fileStamp(path) {
350
+ try {
351
+ const st = statSync(path, { bigint: true });
352
+ return `${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
353
+ } catch {
354
+ return '-';
256
355
  }
257
- const rawServers = mcpServersMapFromJson(readJsonSafe(mcpJsonPath) || {});
258
- if (!isPlainObject(rawServers)) {
259
- throw new Error(`plugin MCP manifest missing mcpServers object: ${mcpJsonPath}`);
356
+ }
357
+
358
+ function copyDeclarations(result) {
359
+ return {
360
+ format: result.format,
361
+ servers: result.servers.map((server) => ({ ...server, config: structuredClone(server.config) })),
362
+ errors: result.errors.map((error) => ({ ...error })),
363
+ };
364
+ }
365
+
366
+ /** What a plugin declares for MCP, read raw: `<root>/.mcp.json` first, then the
367
+ * manifest `mcpServers` (a path, an inline object, or a list mixing both).
368
+ * Later sources override earlier ones by key. Never throws for plugin
369
+ * content; errors carry locations and field names, never values. Unchanged
370
+ * declaration files are not parsed again. */
371
+ export function loadPluginMcpDeclarations(root) {
372
+ return copyDeclarations(cachedDeclarations(root));
373
+ }
374
+
375
+ function cachedDeclarations(root) {
376
+ const key = String(root || '');
377
+ const cached = declarationCache.get(key);
378
+ if (cached?.signature.every(([path, stamp]) => fileStamp(path) === stamp)) {
379
+ declarationCacheStats.hits += 1;
380
+ return cached.result;
260
381
  }
261
- const keys = Object.keys(rawServers).filter((k) => isPlainObject(rawServers[k]));
262
- if (!keys.length) throw new Error(`plugin MCP manifest has no mcpServers: ${mcpJsonPath}`);
263
- return { rawServers, mcpRoot: root };
382
+ declarationCacheStats.misses += 1;
383
+ // Stamps are taken before reading, so a write racing the parse invalidates.
384
+ const before = new Map();
385
+ const result = readPluginMcpDeclarations(root, {
386
+ add(path) {
387
+ if (!before.has(path)) before.set(path, fileStamp(path));
388
+ },
389
+ });
390
+ declarationCache.delete(key);
391
+ declarationCache.set(key, { signature: [...before], result });
392
+ if (declarationCache.size > DECLARATION_CACHE_MAX) declarationCache.delete(declarationCache.keys().next().value);
393
+ return result;
264
394
  }
265
395
 
266
- /** Every MCP entry a plugin owns: `<serverName>` and `<serverName>--<key>`. */
267
- export function pluginServerMatcher(serverName) {
268
- const prefix = `${serverName}--`;
269
- return (name) => name === serverName || name.startsWith(prefix);
396
+ /** Test seam: cache hit/miss counters. */
397
+ export function pluginMcpDeclarationCacheStats() {
398
+ return { ...declarationCacheStats };
270
399
  }
271
400
 
272
- export function pluginMcpServerName(plugin = {}) {
273
- const base = clean(plugin.name || plugin.title || 'plugin')
274
- .toLowerCase()
275
- .replace(/[^a-z0-9_.-]+/g, '-')
276
- .replace(/^-+|-+$/g, '');
277
- return base ? `plugin-${base}` : 'plugin-mcp';
401
+ // Adapter: where the declarations live, in the shape older callers expect.
402
+ export function discoverPluginMcp(root) {
403
+ const { servers, declaredPath } = cachedDeclarations(root);
404
+ if (!servers.length) return { mcpScript: null, mcpInline: false };
405
+ const inline = servers.some((s) => PLUGIN_MANIFEST_FILES.some((rel) => s.source === join(root, ...rel.split('/'))));
406
+ if (inline) return { mcpScript: null, mcpInline: true };
407
+ if (declaredPath?.trim()) {
408
+ const abs = resolveContainedPluginPath(root, declaredPath);
409
+ if (servers.some((s) => s.source === abs)) return { mcpScript: declaredPath.trim(), mcpInline: false };
410
+ }
411
+ const mcpJson = join(root, '.mcp.json');
412
+ const declaredFile = servers.find((s) => s.source !== mcpJson) || servers[0];
413
+ return { mcpScript: relative(root, declaredFile.source).split(sep).join('/'), mcpInline: false };
414
+ }
415
+
416
+ // User intent for plugin-declared servers, stored apart from the declarations
417
+ // as `pluginMcp[pluginId][declaredKey]`: `enabled: false`, a kept `alias`
418
+ // name, and connection overrides layered over the declaration.
419
+ export const PLUGIN_MCP_OVERRIDE_MAP_FIELDS = Object.freeze(['env', 'headers', 'env_http_headers']);
420
+ export const PLUGIN_MCP_OVERRIDE_VALUE_FIELDS = Object.freeze([
421
+ 'command',
422
+ 'args',
423
+ 'url',
424
+ 'bearer_token_env_var',
425
+ 'env_vars',
426
+ ...MCP_TIMEOUT_FIELD_GROUPS.flat(),
427
+ ]);
428
+
429
+ function normalizePluginMcpIntent(value) {
430
+ if (!isPlainObject(value)) return null;
431
+ const out = {};
432
+ // `enabled: true` is kept too: it turns on a server declared `enabled: false`.
433
+ if (typeof value.enabled === 'boolean') out.enabled = value.enabled;
434
+ if (typeof value.alias === 'string' && value.alias.trim()) out.alias = value.alias.trim();
435
+ for (const field of PLUGIN_MCP_OVERRIDE_MAP_FIELDS) {
436
+ if (!isPlainObject(value[field])) continue;
437
+ const map = Object.fromEntries(Object.entries(value[field]).filter(([, v]) => v !== null && v !== undefined));
438
+ if (Object.keys(map).length) out[field] = map;
439
+ }
440
+ for (const field of PLUGIN_MCP_OVERRIDE_VALUE_FIELDS) {
441
+ if (value[field] !== undefined && value[field] !== null) out[field] = value[field];
442
+ }
443
+ return Object.keys(out).length ? out : null;
444
+ }
445
+
446
+ /** `{ [pluginId]: { [declaredKey]: intent } }` with empty records dropped. */
447
+ export function normalizePluginMcpIntents(value) {
448
+ const out = {};
449
+ if (!isPlainObject(value)) return out;
450
+ for (const [pluginId, servers] of Object.entries(value)) {
451
+ const id = clean(pluginId);
452
+ if (!id || !isPlainObject(servers)) continue;
453
+ const kept = {};
454
+ for (const [key, intent] of Object.entries(servers)) {
455
+ const normalized = normalizePluginMcpIntent(intent);
456
+ if (key && normalized) kept[key] = normalized;
457
+ }
458
+ if (Object.keys(kept).length) out[id] = kept;
459
+ }
460
+ return out;
278
461
  }
@@ -2,6 +2,7 @@
2
2
  // definitions out of the eager prefix; selection never changes permission policy.
3
3
  import { deferredCatalogUnion, selectDeferredTools, toolSearchNativePayload } from './tool-catalog.mjs';
4
4
  import { preDispatchDenyForSession } from '../session/loop/pre-dispatch-deny.mjs';
5
+ import { parseMcpToolName } from '../../../shared/tool-primitives.mjs';
5
6
 
6
7
  export function loadSkillToolDependencies(envelope, session, mode) {
7
8
  if (!envelope?.__toolEnvelope) return envelope;
@@ -15,7 +16,9 @@ export function loadSkillToolDependencies(envelope, session, mode) {
15
16
  for (const { type, value } of dependencies) {
16
17
  if (type === 'tool') names.add(value);
17
18
  else if (type === 'mcp') {
18
- const matches = [...catalog.keys()].filter((name) => name.startsWith(`mcp__${value}__`));
19
+ const matches = [...catalog.values()]
20
+ .filter((tool) => parseMcpToolName(tool)?.server === value)
21
+ .map((tool) => tool.name);
19
22
  if (matches.length)
20
23
  matches.forEach((name) => {
21
24
  names.add(name);
@@ -145,11 +145,32 @@ export function sortedCatalogByMeasuredUsage(catalog) {
145
145
  .map((entry) => entry.tool);
146
146
  }
147
147
 
148
+ const IMMUTABLE_SURFACE_TOOLS = new WeakSet();
149
+
150
+ function deepFreeze(value) {
151
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
152
+ Object.freeze(value);
153
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
154
+ }
155
+ return value;
156
+ }
157
+
148
158
  export function activeToolForSurface(tool) {
149
159
  if (!tool || typeof tool !== 'object') return tool;
160
+ if (IMMUTABLE_SURFACE_TOOLS.has(tool)) return tool;
150
161
  return JSON.parse(JSON.stringify(tool));
151
162
  }
152
163
 
164
+ // Clone once into a deep-frozen definition that activeToolForSurface passes
165
+ // through without re-cloning. Safe to share: it can never be mutated in place.
166
+ export function immutableToolForSurface(tool) {
167
+ if (!tool || typeof tool !== 'object') return tool;
168
+ if (IMMUTABLE_SURFACE_TOOLS.has(tool)) return tool;
169
+ const frozen = deepFreeze(JSON.parse(JSON.stringify(tool)));
170
+ IMMUTABLE_SURFACE_TOOLS.add(frozen);
171
+ return frozen;
172
+ }
173
+
153
174
  export function deferredProviderMode(provider) {
154
175
  const p = clean(provider).toLowerCase();
155
176
  if (p === 'gemini') return 'manifest';
@@ -4,7 +4,7 @@
4
4
  import { clean } from './session-text.mjs';
5
5
  import { mergePendingDeferredToolDelta } from './deferred-tool-delta.mjs';
6
6
  import { applyInitialDeferredToolManifestToBp2, stripDeferredToolManifestBlock } from '../context/collect.mjs';
7
- import { getMcpServerInstructionsMap, getMcpTools } from '../mcp/client.mjs';
7
+ import { getMcpServerInstructionsMap, getMcpTools, getMcpToolGeneration } from '../mcp/client.mjs';
8
8
  import { filterMcpToolsForSession } from './extension-scopes.mjs';
9
9
  import { isDeferredToolAvailable } from './deferred-tool-availability.mjs';
10
10
  import { isResponsesFreeformTool, toResponsesCustomTool } from '../providers/custom-tool-wire.mjs';
@@ -23,6 +23,7 @@ import {
23
23
  routeToolRank,
24
24
  sortedCatalogByMeasuredUsage,
25
25
  activeToolForSurface,
26
+ immutableToolForSurface,
26
27
  deferredProviderMode,
27
28
  nativeProviderFamily,
28
29
  } from './tool-catalog-schema.mjs';
@@ -464,6 +465,56 @@ export function refreshInitialDeferredMcpSurface(session, liveMcpTools) {
464
465
  */
465
466
  export function reconcileDeferredMcpToolCatalog(session, liveMcpTools) {
466
467
  if (!session || !Array.isArray(session.tools)) return null;
468
+ const cached = RECONCILE_STATE.get(session);
469
+ if (cached && reconcileStateMatches(cached, reconcileStateOf(session, liveMcpTools))) return null;
470
+ const result = reconcileDeferredMcpToolCatalogUncached(session, liveMcpTools);
471
+ RECONCILE_STATE.set(session, reconcileStateOf(session, liveMcpTools));
472
+ return result;
473
+ }
474
+
475
+ // Last reconciled inputs per session. A matching state means the surface that
476
+ // reconciliation would rebuild is identical, so it is skipped outright.
477
+ const RECONCILE_STATE = new WeakMap();
478
+ const LIVE_CLONES = new WeakMap();
479
+
480
+ function liveToolClone(tool) {
481
+ if (!tool || typeof tool !== 'object') return tool;
482
+ const generation = getMcpToolGeneration();
483
+ const hit = LIVE_CLONES.get(tool);
484
+ if (hit && hit.generation === generation) return hit.clone;
485
+ const clone = immutableToolForSurface(tool);
486
+ LIVE_CLONES.set(tool, { generation, clone });
487
+ return clone;
488
+ }
489
+
490
+ function reconcileStateOf(session, liveMcpTools) {
491
+ return {
492
+ generation: getMcpToolGeneration(),
493
+ live: Array.isArray(liveMcpTools) ? liveMcpTools.slice() : [],
494
+ tools: session.tools.slice(),
495
+ lateCatalog: session.deferredLateToolCatalog,
496
+ scope: clean(session.mcpScopeId),
497
+ cwd: clean(session.cwd),
498
+ mode: clean(session.deferredSurfaceMode),
499
+ providerMode: clean(session.deferredProviderMode),
500
+ provider: clean(session.provider),
501
+ model: clean(session.model),
502
+ disallowed: JSON.stringify(session.disallowedTools || []),
503
+ callable: JSON.stringify(parseToolSelection(session.deferredCallableTools)),
504
+ discovered: JSON.stringify(parseToolSelection(session.deferredDiscoveredTools)),
505
+ };
506
+ }
507
+
508
+ function reconcileStateMatches(a, b) {
509
+ for (const key of Object.keys(a)) {
510
+ if (key === 'live' || key === 'tools') {
511
+ if (a[key].length !== b[key].length || a[key].some((tool, i) => tool !== b[key][i])) return false;
512
+ } else if (a[key] !== b[key]) return false;
513
+ }
514
+ return true;
515
+ }
516
+
517
+ function reconcileDeferredMcpToolCatalogUncached(session, liveMcpTools) {
467
518
  const live = filterDisallowedTools(Array.isArray(liveMcpTools) ? liveMcpTools : [], session.disallowedTools);
468
519
  const hadSnapshot = Array.isArray(session.deferredMcpToolNames);
469
520
  const previousNames = new Set(
@@ -482,7 +533,7 @@ export function reconcileDeferredMcpToolCatalog(session, liveMcpTools) {
482
533
  }
483
534
  for (const tool of live) {
484
535
  const name = clean(tool?.name);
485
- if (name && isMcpToolName(name)) byName.set(name, activeToolForSurface(tool));
536
+ if (name && isMcpToolName(name)) byName.set(name, liveToolClone(tool));
486
537
  }
487
538
  const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
488
539
  const next = catalog.filter((tool) => session.deferredSurfaceMode !== 'readonly' || isReadonlySelectable(tool));
@@ -505,7 +556,7 @@ export function reconcileDeferredMcpToolCatalog(session, liveMcpTools) {
505
556
  for (const tool of live) {
506
557
  const name = clean(tool?.name);
507
558
  if (!name || !isMcpToolName(name) || liveMcpByName.has(name)) continue;
508
- liveMcpByName.set(name, activeToolForSurface(tool));
559
+ liveMcpByName.set(name, liveToolClone(tool));
509
560
  }
510
561
 
511
562
  // Rebuild the LATE pool only (boot catalog stays frozen). It holds live MCP