mixdog 0.9.104 → 0.9.106

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 (31) hide show
  1. package/README.md +3 -4
  2. package/package.json +1 -1
  3. package/src/rules/shared/01-tool.md +3 -1
  4. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +3 -0
  5. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -46
  6. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -14
  7. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +37 -5
  8. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +4 -2
  9. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +16 -3
  10. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -4
  11. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +33 -2
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +31 -31
  14. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +36 -4
  15. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -3
  16. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +22 -1
  17. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +40 -10
  18. package/src/session-runtime/context-status.mjs +11 -5
  19. package/src/session-runtime/lifecycle-api.mjs +8 -4
  20. package/src/session-runtime/mcp-glue.mjs +10 -8
  21. package/src/session-runtime/runtime-core.mjs +6 -0
  22. package/src/session-runtime/session-lifecycle.mjs +3 -1
  23. package/src/session-runtime/session-turn-api.mjs +2 -2
  24. package/src/session-runtime/tool-catalog.mjs +3 -3
  25. package/src/session-runtime/tool-surface.mjs +3 -2
  26. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  27. package/src/standalone/agent-tool.mjs +2 -0
  28. package/src/standalone/explore-tool.mjs +1 -2
  29. package/src/tui/app/usage-context-panels.mjs +5 -2
  30. package/src/tui/components/ContextPanel.jsx +2 -0
  31. package/src/tui/dist/index.mjs +8 -3
package/README.md CHANGED
@@ -103,7 +103,7 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
103
103
  role mode for scripting.
104
104
  - Mixdog Desktop: a full agent workbench for Windows/macOS/Linux (see
105
105
  below).
106
- - Web/mobile companion over relay pairing — scan a QR code to open your
106
+ - Installable web app over relay pairing — scan a QR code to open your
107
107
  running sessions in a phone browser and keep going from any network.
108
108
  - Optional Discord/Telegram channels, webhook endpoints, and cron schedules
109
109
  with quiet hours for remote/event-driven workflows; channel voice messages
@@ -246,7 +246,7 @@ wizard covers first-run setup. For development run `npm run dev` inside
246
246
  - **Automation** — visual editors for workflow and agent packs, cron
247
247
  schedules, webhooks, and channel integrations.
248
248
  - **Settings hub** — provider auth, capability sweep, git identity, and
249
- QR device pairing for the web/mobile companion, preloaded so every
249
+ QR device pairing for the installable web app, preloaded so every
250
250
  category opens instantly.
251
251
 
252
252
  ## Scripts
@@ -302,8 +302,7 @@ src/
302
302
  rules/ # Lead and agent instructions
303
303
  apps/
304
304
  desktop/ # Mixdog Desktop — Electron workbench (main/preload/renderer)
305
- mobile/ # mobile companion shell
306
- relay/ # relay server for remote/web/mobile access
305
+ relay/ # relay server for remote web-app access
307
306
  scripts/
308
307
  smoke*.mjs # smoke checks
309
308
  *test.mjs # focused node:test checks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.104",
3
+ "version": "0.9.106",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -14,7 +14,9 @@
14
14
  facets in one query array. It
15
15
  returns the minimal complete direct `path:line` anchors, not analysis or
16
16
  solutions; resume baseline routing from those anchors.
17
- - Use verified paths (cwd/project/user/tool); explicit paths may be outside cwd;
17
+ - Use verified paths (cwd/project/user/tool). Within the current project, pass
18
+ project-relative paths and omit optional scopes equal to its root; explicit
19
+ paths may be outside cwd only for targets outside the project;
18
20
  stay focused on the requested outcome. Avoid investigation, implementation,
19
21
  or verification not required to satisfy it; once the requirements are met
20
22
  and proven, stop.
@@ -64,6 +64,7 @@ function normalizeAgentCompactionConfig(value = {}, { memoryEnabled = true } = {
64
64
  * @param {number} [opts.maxLoopIterations]
65
65
  * @param {string} [opts.parentSessionId]
66
66
  * @param {string|null} [opts.ownerSessionId] - owning Mixdog MCP instance id for statusline isolation
67
+ * @param {string|null} [opts.mcpScopeId] - owning session-runtime MCP registry scope
67
68
  * @returns {{ session: object, effectiveCwd: string|null }}
68
69
  */
69
70
  export function prepareAgentSession({
@@ -86,6 +87,7 @@ export function prepareAgentSession({
86
87
  cacheKeyOverride,
87
88
  schemaAllowedTools,
88
89
  sessionId,
90
+ mcpScopeId,
89
91
  }) {
90
92
  const effectivePermission = resolveAgentSessionPermission(agent, permission);
91
93
  // No per-agent loop caps: sessions either pin maxLoopIterations explicitly
@@ -121,6 +123,7 @@ export function prepareAgentSession({
121
123
  ownerSessionId: effectiveOwnerSessionId || null,
122
124
  clientHostPid: clientHostPid || null,
123
125
  compaction: compaction || undefined,
126
+ mcpScopeId: mcpScopeId || null,
124
127
  };
125
128
  if (agentTag) sessionOpts.agentTag = agentTag;
126
129
  if (effectivePermission) sessionOpts.permission = effectivePermission;
@@ -28,6 +28,10 @@ const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
28
28
  const servers = new Map();
29
29
  const reconnects = createKeyedSingleflight();
30
30
  const callAdmissions = new Map();
31
+ const DEFAULT_MCP_SCOPE_ID = 'global';
32
+ const _knownMcpScopes = new Set([DEFAULT_MCP_SCOPE_ID]);
33
+ const _connectAbortGenerations = new Map();
34
+ const _pendingConnects = new Set();
31
35
  let mcpSdkPromise = null;
32
36
  // Memo for mcpToolHasField(name, field) — keyed by `${toolName}|${field}`.
33
37
  // The lookup (regex parse + servers Map get + tools.find + schema property
@@ -39,6 +43,30 @@ const _mcpToolFieldMemo = new Map();
39
43
  function _invalidateMcpToolFieldMemo() {
40
44
  _mcpToolFieldMemo.clear();
41
45
  }
46
+ function normalizeMcpScopeId(value) {
47
+ const raw = value && typeof value === 'object' ? value.scopeId : value;
48
+ const scopeId = String(raw || '').trim();
49
+ return scopeId || DEFAULT_MCP_SCOPE_ID;
50
+ }
51
+ function mcpServerRegistryKey(scopeId, name) {
52
+ return `${normalizeMcpScopeId(scopeId)}\u0000${String(name || '')}`;
53
+ }
54
+ function scopedServer(scopeId, name) {
55
+ return servers.get(mcpServerRegistryKey(scopeId, name));
56
+ }
57
+ function scopedServerEntries(scopeId) {
58
+ const normalized = normalizeMcpScopeId(scopeId);
59
+ return [...servers.entries()].filter(([, server]) => server?.scopeId === normalized);
60
+ }
61
+ function currentConnectAbortGeneration(scopeId) {
62
+ return _connectAbortGenerations.get(normalizeMcpScopeId(scopeId)) || 0;
63
+ }
64
+ function bumpConnectAbortGeneration(scopeId) {
65
+ const normalized = normalizeMcpScopeId(scopeId);
66
+ const next = currentConnectAbortGeneration(normalized) + 1;
67
+ _connectAbortGenerations.set(normalized, next);
68
+ return next;
69
+ }
42
70
  function mcpLog(line) {
43
71
  if (process.env.MIXDOG_QUIET_MCP_LOG) return;
44
72
  process.stderr.write(line);
@@ -106,12 +134,14 @@ export function resolveMcpTransportKind(cfg) {
106
134
  * Connect to MCP servers defined in config.
107
135
  * Supports stdio (child process) and http (Streamable HTTP) transports.
108
136
  */
109
- export async function connectMcpServers(config) {
137
+ export async function connectMcpServers(config, options = {}) {
138
+ const scopeId = normalizeMcpScopeId(options);
139
+ _knownMcpScopes.add(scopeId);
110
140
  // Capture the abort generation SYNCHRONOUSLY at entry: the boot path fires
111
141
  // this un-awaited, so a runtime close can land while connectServer is still
112
142
  // loading the SDK. A capture taken any later would already see the bumped
113
143
  // generation and register the server anyway (leaking its stdio child).
114
- const genAtStart = _connectAbortGeneration;
144
+ const genAtStart = currentConnectAbortGeneration(scopeId);
115
145
  const failures = [];
116
146
  const entries = Object.entries(config).filter(([name, cfg]) => {
117
147
  if (cfg?.enabled === false) {
@@ -123,7 +153,7 @@ export async function connectMcpServers(config) {
123
153
  // Connect all servers in PARALLEL: a slow/hung server (bounded by its
124
154
  // per-server startup timeout) must never delay the others' handshakes.
125
155
  const settled = await Promise.allSettled(
126
- entries.map(([name, cfg]) => connectServer(name, cfg, genAtStart)),
156
+ entries.map(([name, cfg]) => connectServer(name, cfg, scopeId, genAtStart)),
127
157
  );
128
158
  settled.forEach((res, i) => {
129
159
  if (res.status !== 'rejected') return;
@@ -143,15 +173,15 @@ export async function connectMcpServers(config) {
143
173
  * Get all tool definitions from connected MCP servers.
144
174
  * Tool names are prefixed: `mcp__{serverName}__{toolName}`
145
175
  */
146
- export function getMcpTools() {
176
+ export function getMcpTools(scopeId = DEFAULT_MCP_SCOPE_ID) {
147
177
  const tools = [];
148
- for (const server of servers.values()) {
178
+ for (const [, server] of scopedServerEntries(scopeId)) {
149
179
  tools.push(...server.tools);
150
180
  }
151
181
  return tools;
152
182
  }
153
- export function getMcpServerStatus() {
154
- return [...servers.values()].map((server) => ({
183
+ export function getMcpServerStatus(scopeId = DEFAULT_MCP_SCOPE_ID) {
184
+ return scopedServerEntries(scopeId).map(([, server]) => ({
155
185
  name: server.name,
156
186
  connected: true,
157
187
  toolCount: Array.isArray(server.tools) ? server.tools.length : 0,
@@ -173,7 +203,8 @@ function positiveInt(value, fallback) {
173
203
  return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
174
204
  }
175
205
  function callAdmissionFor(server) {
176
- let gate = callAdmissions.get(server.name);
206
+ const registryKey = server.registryKey || mcpServerRegistryKey(server.scopeId, server.name);
207
+ let gate = callAdmissions.get(registryKey);
177
208
  if (gate) return gate;
178
209
  const cfg = server?.cfg || {};
179
210
  gate = createOwnerFairGate({
@@ -192,44 +223,53 @@ function callAdmissionFor(server) {
192
223
  30_000,
193
224
  ),
194
225
  });
195
- callAdmissions.set(server.name, gate);
226
+ gate.mcpServerName = server.name;
227
+ gate.mcpScopeId = server.scopeId;
228
+ callAdmissions.set(registryKey, gate);
196
229
  return gate;
197
230
  }
198
- function closeCallAdmission(name, reason) {
199
- const gate = callAdmissions.get(name);
231
+ function closeCallAdmission(scopeId, name, reason) {
232
+ const registryKey = mcpServerRegistryKey(scopeId, name);
233
+ const gate = callAdmissions.get(registryKey);
200
234
  if (!gate) return;
201
- callAdmissions.delete(name);
235
+ callAdmissions.delete(registryKey);
202
236
  gate.close(reason || `MCP ${name} disconnected`);
203
237
  }
204
- export function getMcpAdmissionSnapshot() {
205
- return [...callAdmissions.entries()].map(([name, gate]) => ({
206
- name,
238
+ export function getMcpAdmissionSnapshot(options = undefined) {
239
+ const scoped = options !== undefined;
240
+ const scopeId = scoped ? normalizeMcpScopeId(options) : null;
241
+ return [...callAdmissions.values()]
242
+ .filter((gate) => !scoped || gate.mcpScopeId === scopeId)
243
+ .map((gate) => ({
244
+ name: gate.mcpServerName,
245
+ scopeId: gate.mcpScopeId,
207
246
  ...gate.snapshot(),
208
247
  }));
209
248
  }
210
249
 
211
250
  /** Snapshot of MCP initialize `instructions` per connected server (handshake time). */
212
- export function getMcpServerInstructionsMap() {
251
+ export function getMcpServerInstructionsMap(scopeId = DEFAULT_MCP_SCOPE_ID) {
213
252
  const out = {};
214
- for (const server of servers.values()) {
253
+ for (const [, server] of scopedServerEntries(scopeId)) {
215
254
  const text = typeof server.instructions === 'string' ? server.instructions.trim() : '';
216
255
  if (text) out[server.name] = text;
217
256
  }
218
257
  return out;
219
258
  }
220
259
 
221
- async function reconnectMcpServer(serverName, failedServer) {
222
- return reconnects.run(serverName, async () => {
223
- const current = servers.get(serverName);
260
+ async function reconnectMcpServer(scopeId, serverName, failedServer) {
261
+ const registryKey = mcpServerRegistryKey(scopeId, serverName);
262
+ return reconnects.run(registryKey, async () => {
263
+ const current = servers.get(registryKey);
224
264
  // A peer already completed the replacement while this failed call was
225
265
  // unwinding. Reuse it immediately instead of closing a fresh transport.
226
266
  if (current && current !== failedServer) return current;
227
267
  if (current) {
228
268
  await _closeServer(current);
229
- if (servers.get(serverName) === current) servers.delete(serverName);
269
+ if (servers.get(registryKey) === current) servers.delete(registryKey);
230
270
  }
231
- await connectServer(serverName, failedServer.cfg);
232
- const replacement = servers.get(serverName);
271
+ await connectServer(serverName, failedServer.cfg, scopeId);
272
+ const replacement = servers.get(registryKey);
233
273
  if (!replacement) {
234
274
  throw new Error(`reconnect succeeded but server "${serverName}" entry is missing from registry`);
235
275
  }
@@ -247,7 +287,8 @@ export async function executeMcpTool(name, args, options = {}) {
247
287
  if (!match)
248
288
  throw new Error(`Not an MCP tool name: ${name}`);
249
289
  const [, serverName, toolName] = match;
250
- const server = servers.get(serverName);
290
+ const scopeId = normalizeMcpScopeId(options);
291
+ const server = scopedServer(scopeId, serverName);
251
292
  if (!server)
252
293
  throw new Error(`MCP server "${serverName}" not connected`);
253
294
  const gate = callAdmissionFor(server);
@@ -264,7 +305,7 @@ export async function executeMcpTool(name, args, options = {}) {
264
305
  mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
265
306
  let retryServer;
266
307
  try {
267
- retryServer = await reconnectMcpServer(serverName, server);
308
+ retryServer = await reconnectMcpServer(scopeId, serverName, server);
268
309
  } catch (reconnectErr) {
269
310
  const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
270
311
  throw new Error(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`);
@@ -415,12 +456,12 @@ export function isMcpTool(name) {
415
456
  return name.startsWith('mcp__');
416
457
  }
417
458
  /** True when the prefixed name exists on a connected MCP server. */
418
- export function isRegisteredMcpTool(name) {
459
+ export function isRegisteredMcpTool(name, scopeId = DEFAULT_MCP_SCOPE_ID) {
419
460
  if (!isMcpTool(name)) return false;
420
461
  const match = name.match(/^mcp__(.+?)__(.+)$/);
421
462
  if (!match) return false;
422
463
  const [, serverName] = match;
423
- const server = servers.get(serverName);
464
+ const server = scopedServer(scopeId, serverName);
424
465
  if (!server || !Array.isArray(server.tools)) return false;
425
466
  return server.tools.some((t) => t?.name === name);
426
467
  }
@@ -430,14 +471,15 @@ export function isRegisteredMcpTool(name) {
430
471
  * (e.g. cwd) into the args before dispatch — schemas that don't declare the
431
472
  * field would reject the unknown argument.
432
473
  */
433
- export function mcpToolHasField(name, field) {
434
- const memoKey = `${name}|${field}`;
474
+ export function mcpToolHasField(name, field, scopeId = DEFAULT_MCP_SCOPE_ID) {
475
+ const normalizedScopeId = normalizeMcpScopeId(scopeId);
476
+ const memoKey = `${normalizedScopeId}|${name}|${field}`;
435
477
  const memoized = _mcpToolFieldMemo.get(memoKey);
436
478
  if (memoized !== undefined) return memoized;
437
479
  const match = name.match(/^mcp__(.+?)__(.+)$/);
438
480
  if (!match) { _mcpToolFieldMemo.set(memoKey, false); return false; }
439
481
  const [, serverName] = match;
440
- const server = servers.get(serverName);
482
+ const server = scopedServer(normalizedScopeId, serverName);
441
483
  if (!server) { _mcpToolFieldMemo.set(memoKey, false); return false; }
442
484
  const tool = server.tools.find((t) => t.name === name);
443
485
  if (!tool) { _mcpToolFieldMemo.set(memoKey, false); return false; }
@@ -449,14 +491,24 @@ export function mcpToolHasField(name, field) {
449
491
  /**
450
492
  * Disconnect all MCP servers.
451
493
  */
452
- export async function disconnectAll() {
494
+ export async function disconnectAll(options = undefined) {
495
+ const hasExplicitScope = options && typeof options === 'object'
496
+ && Object.prototype.hasOwnProperty.call(options, 'scopeId');
497
+ const scopes = hasExplicitScope
498
+ ? new Set([normalizeMcpScopeId(options)])
499
+ : new Set([
500
+ ..._knownMcpScopes,
501
+ ...[...servers.values()].map((server) => server.scopeId),
502
+ ...[..._pendingConnects].map((entry) => entry.scopeId),
503
+ ]);
453
504
  // Abort handshakes still in flight: bump the generation so a connect that
454
505
  // completes after this point tears itself down instead of registering, and
455
506
  // reap any already-spawned stdio child now so its ref'd ChildProcess handle
456
507
  // can't keep the event loop alive (close-during-connect previously leaked
457
508
  // the uvx/npx wrapper tree and hung process exit).
458
- _connectAbortGeneration++;
509
+ for (const scopeId of scopes) bumpConnectAbortGeneration(scopeId);
459
510
  for (const entry of [..._pendingConnects]) {
511
+ if (!scopes.has(entry.scopeId)) continue;
460
512
  _pendingConnects.delete(entry);
461
513
  // Mid-handshake child: nothing to shut down gracefully — hard-kill the
462
514
  // tree without holding the event loop (this path runs during process
@@ -466,13 +518,14 @@ export async function disconnectAll() {
466
518
  try { void entry.client.close().catch(() => { /* ignore */ }); }
467
519
  catch { /* ignore */ }
468
520
  }
469
- for (const [name, server] of servers) {
521
+ for (const [registryKey, server] of [...servers]) {
522
+ if (!scopes.has(server.scopeId)) continue;
470
523
  try {
471
524
  await _closeServer(server);
472
525
  }
473
526
  catch { /* ignore */ }
474
- servers.delete(name);
475
- closeCallAdmission(name, `MCP ${name} disconnected`);
527
+ servers.delete(registryKey);
528
+ closeCallAdmission(server.scopeId, server.name, `MCP ${server.name} disconnected`);
476
529
  }
477
530
  _invalidateMcpToolFieldMemo();
478
531
  }
@@ -482,15 +535,17 @@ export async function disconnectAll() {
482
535
  * it, and invalidates the tool-field memo. Lets callers toggle one server
483
536
  * without a full disconnectAll()/reconnect cycle.
484
537
  */
485
- export async function disconnectMcpServer(name) {
486
- const server = servers.get(name);
538
+ export async function disconnectMcpServer(name, options = {}) {
539
+ const scopeId = normalizeMcpScopeId(options);
540
+ const registryKey = mcpServerRegistryKey(scopeId, name);
541
+ const server = servers.get(registryKey);
487
542
  if (!server) return false;
488
543
  try {
489
544
  await _closeServer(server);
490
545
  }
491
546
  catch { /* ignore */ }
492
- servers.delete(name);
493
- closeCallAdmission(name, `MCP ${name} disconnected`);
547
+ servers.delete(registryKey);
548
+ closeCallAdmission(scopeId, name, `MCP ${name} disconnected`);
494
549
  _invalidateMcpToolFieldMemo();
495
550
  return true;
496
551
  }
@@ -515,9 +570,9 @@ async function _closeServer(server) {
515
570
  // to see (and tear down) their transports, because `servers` only lists fully
516
571
  // handshaken entries. Generation token aborts a connect that outlives a
517
572
  // disconnectAll() issued mid-handshake (runtime close during boot connect).
518
- let _connectAbortGeneration = 0;
519
- const _pendingConnects = new Set();
520
- async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
573
+ async function connectServer(name, cfg, scopeId = DEFAULT_MCP_SCOPE_ID, genAtStart = currentConnectAbortGeneration(scopeId)) {
574
+ scopeId = normalizeMcpScopeId(scopeId);
575
+ _knownMcpScopes.add(scopeId);
521
576
  const {
522
577
  Client,
523
578
  StdioClientTransport,
@@ -525,7 +580,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
525
580
  SSEClientTransport,
526
581
  WebSocketClientTransport,
527
582
  } = await loadMcpSdk();
528
- if (genAtStart !== _connectAbortGeneration) {
583
+ if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
529
584
  // disconnectAll() ran while the SDK was loading: nothing spawned yet —
530
585
  // abort before creating a transport/child at all.
531
586
  throw new Error(`MCP server "${name}" connect aborted by shutdown`);
@@ -623,7 +678,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
623
678
  else {
624
679
  throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
625
680
  }
626
- const pending = { name, client, transport };
681
+ const pending = { scopeId, name, client, transport };
627
682
  _pendingConnects.add(pending);
628
683
  try {
629
684
  // Bound the connect + listTools handshake so a slow/hung server can't
@@ -682,7 +737,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
682
737
  if (!toolsResult || !Array.isArray(toolsResult.tools)) {
683
738
  throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
684
739
  }
685
- if (genAtStart !== _connectAbortGeneration) {
740
+ if (genAtStart !== currentConnectAbortGeneration(scopeId)) {
686
741
  // disconnectAll() ran mid-handshake: never register — tear down.
687
742
  try { await _closeServer({ client, transport }); }
688
743
  catch { /* ignore */ }
@@ -695,7 +750,18 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
695
750
  ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
696
751
  }));
697
752
  const toolNames = tools.map(t => t.name);
698
- servers.set(name, { name, client, transport, tools, cfg, instructions, generation: genAtStart });
753
+ const registryKey = mcpServerRegistryKey(scopeId, name);
754
+ servers.set(registryKey, {
755
+ scopeId,
756
+ registryKey,
757
+ name,
758
+ client,
759
+ transport,
760
+ tools,
761
+ cfg,
762
+ instructions,
763
+ generation: genAtStart,
764
+ });
699
765
  _invalidateMcpToolFieldMemo();
700
766
  mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
701
767
  }
@@ -703,3 +769,36 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
703
769
  _pendingConnects.delete(pending);
704
770
  }
705
771
  }
772
+
773
+ // Test seam for registry scoping without launching an MCP transport.
774
+ export function _registerMcpServerForTest(scopeId, name, rawTools = [], options = {}) {
775
+ const normalizedScopeId = normalizeMcpScopeId(scopeId);
776
+ const registryKey = mcpServerRegistryKey(normalizedScopeId, name);
777
+ const tools = rawTools.map((tool) => ({
778
+ ...tool,
779
+ name: String(tool?.name || '').startsWith('mcp__')
780
+ ? String(tool.name)
781
+ : `mcp__${name}__${String(tool?.name || '')}`,
782
+ inputSchema: tool?.inputSchema || { type: 'object', properties: {} },
783
+ }));
784
+ const server = {
785
+ scopeId: normalizedScopeId,
786
+ registryKey,
787
+ name,
788
+ tools,
789
+ cfg: options.cfg || {},
790
+ instructions: options.instructions || '',
791
+ transport: null,
792
+ client: {
793
+ callTool: typeof options.callTool === 'function'
794
+ ? options.callTool
795
+ : async () => ({ content: [{ type: 'text', text: 'ok' }] }),
796
+ close: async () => {},
797
+ },
798
+ generation: currentConnectAbortGeneration(normalizedScopeId),
799
+ };
800
+ _knownMcpScopes.add(normalizedScopeId);
801
+ servers.set(registryKey, server);
802
+ _invalidateMcpToolFieldMemo();
803
+ return server;
804
+ }
@@ -292,20 +292,13 @@ export function applyAnthropicEffortToBody(
292
292
  // Adaptive-thinking models (4.6+) require `thinking:{type:"adaptive"}`
293
293
  // rather than the legacy budget_tokens shape — sending
294
294
  // `thinking:{type:"enabled"}` here 400s on sonnet-5/opus-4-7/4-8.
295
- // display:"summarized" keeps reasoning blocks populated (4.7+ defaults
296
- // to "omitted", silently hiding reasoning text). Gated on the same
297
- // modelSupportsEffort() allowlist so older models never receive it.
298
- // Set unconditionally (independent of `normalized`) so effort-capable
299
- // turns always carry adaptive thinking + round-trip signatures.
300
- // MIXDOG_ANTHROPIC_THINKING_DISPLAY=omitted (operator/bench knob):
301
- // thinking blocks are omitted entirely, so nothing is
302
- // replayed into later requests (saves the 1h cache-write + re-read on
303
- // accumulated thinking) at the cost of losing visible reasoning and
304
- // cross-iteration thinking continuity. Default stays summarized.
305
- const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim() === 'omitted'
306
- ? 'omitted'
307
- : 'summarized';
308
- body.thinking = { type: 'adaptive', display };
295
+ // Match Claude Code's default wire shape: omit `display` and let the
296
+ // model/API choose its default. Operators and benchmarks can explicitly
297
+ // request either supported display mode.
298
+ const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim();
299
+ body.thinking = display === 'summarized' || display === 'omitted'
300
+ ? { type: 'adaptive', display }
301
+ : { type: 'adaptive' };
309
302
  // Adaptive/4.7+ models reject any non-default sampling param with a 400.
310
303
  delete body.temperature;
311
304
  delete body.top_p;
@@ -199,6 +199,22 @@ function compactPressureTokens(messageTokensEst, policy) {
199
199
  return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration) + otherReserve);
200
200
  }
201
201
 
202
+ // Provider-visible context estimate without operator-only compaction reserve.
203
+ // Request/schema reserve remains included because those bytes are sent to the
204
+ // model; configured reserve is merely local headroom and must not inflate the
205
+ // user-facing context gauge.
206
+ function currentContextEstimateTokens(messageTokensEst, policy) {
207
+ if (messageTokensEst === null) return 0;
208
+ const calibration = Number(policy?.tokenCalibration) > 0 ? Number(policy.tokenCalibration) : 1;
209
+ const configured = Math.max(0, Number(policy?.configuredReserveTokens) || 0);
210
+ const totalReserve = Math.max(0, Number(policy?.reserveTokens) || 0);
211
+ const requestReserve = Math.min(
212
+ totalReserve,
213
+ Math.max(0, Number(policy?.requestReserveTokens ?? (totalReserve - configured)) || 0),
214
+ );
215
+ return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration));
216
+ }
217
+
202
218
  function providerPressureTokens(sessionRef, usage) {
203
219
  if (!usage || typeof usage !== 'object') return 0;
204
220
  const input = Math.max(0, Number(usage.mainInputTokens ?? usage.inputTokens) || 0);
@@ -268,7 +284,9 @@ export function invalidateProviderContextBaseline(sessionRef) {
268
284
  // transcript did NOT grow keeps its baseline regardless of age.
269
285
  const BASELINE_MAX_STALE_GROWTH_MS = 30 * 60 * 1000;
270
286
 
271
- function providerBaselinePressureTokens(messages, sessionRef, policy) {
287
+ function providerBaselinePressureTokens(messages, sessionRef, policy, {
288
+ includeConfiguredReserve = true,
289
+ } = {}) {
272
290
  if (!Array.isArray(messages) || !sessionRef
273
291
  || sessionRef.lastContextTokensStaleAfterCompact === true) return null;
274
292
  let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
@@ -303,16 +321,31 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
303
321
  const growth = count < messages.length
304
322
  ? Math.round(estimateMessagesTokens(messages.slice(count)) * calibration)
305
323
  : 0;
306
- return Math.max(0, tokens + growth + Math.max(0, Number(policy?.configuredReserveTokens) || 0));
324
+ const configuredReserve = includeConfiguredReserve
325
+ ? Math.max(0, Number(policy?.configuredReserveTokens) || 0)
326
+ : 0;
327
+ return Math.max(0, tokens + growth + configuredReserve);
307
328
  } catch {
308
329
  return null;
309
330
  }
310
331
  }
311
332
 
333
+ function preferAlignedBaseline(baseline, estimate) {
334
+ if (baseline == null) return estimate;
335
+ if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
336
+ return baseline;
337
+ }
338
+
339
+ export function resolveCurrentContextTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
340
+ const baseline = providerBaselinePressureTokens(messages, sessionRef, policy, {
341
+ includeConfiguredReserve: false,
342
+ });
343
+ return preferAlignedBaseline(baseline, currentContextEstimateTokens(messageTokensEst, policy));
344
+ }
345
+
312
346
  export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
313
347
  const baseline = providerBaselinePressureTokens(messages, sessionRef, policy);
314
348
  const estimate = compactPressureTokens(messageTokensEst, policy);
315
- if (baseline == null) return estimate;
316
349
  // Sanity band: the baseline exists to correct OVER-counting estimates
317
350
  // (dense-data floors can inflate the estimate up to ~2x real usage), so a
318
351
  // lower baseline is normally preferred. But a corrupt/stale baseline below
@@ -322,8 +355,7 @@ export function resolveCompactionPressureTokens(messageTokensEst, policy, { mess
322
355
  // both the gauge and the compaction decision. Erring toward the estimate
323
356
  // may compact somewhat early; erring toward a rotten baseline blows past
324
357
  // the context window at full token cost.
325
- if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
326
- return baseline;
358
+ return preferAlignedBaseline(baseline, estimate);
327
359
  }
328
360
 
329
361
  /** Telemetry pressure when a reactive overflow retry forces the next compact. */
@@ -178,7 +178,8 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
178
178
  return viewSkill(cwd, args?.name);
179
179
  }
180
180
  if (isMcpTool(name)) {
181
- if (!isOnDeferredToolSurface(sessionRef, name) && !isRegisteredMcpTool(name)) {
181
+ const mcpScopeId = sessionRef?.mcpScopeId || null;
182
+ if (!isOnDeferredToolSurface(sessionRef, name) && !isRegisteredMcpTool(name, mcpScopeId)) {
182
183
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
183
184
  }
184
185
  // 24h trace data shows ~24% of external MCP calls are cwd-sensitive
@@ -187,12 +188,13 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
187
188
  // inputSchema declares the field — schemas without it would reject
188
189
  // an unknown argument.
189
190
  const needsCwdInjection = cwd
190
- && mcpToolHasField(name, 'cwd')
191
+ && mcpToolHasField(name, 'cwd', mcpScopeId)
191
192
  && (args == null || args.cwd == null);
192
193
  const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
193
194
  return executeMcpTool(name, finalArgs, {
194
195
  signal: executeOpts.signal || null,
195
196
  ownerKey: callerSessionId,
197
+ scopeId: mcpScopeId,
196
198
  });
197
199
  }
198
200
  if (name === 'code_graph') {
@@ -249,7 +249,10 @@ export function createSession(opts) {
249
249
  const toolSpec = ownerIsAgent
250
250
  ? (isReadOnlyAgentBundle ? 'readonly' : 'full')
251
251
  : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
252
- let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
252
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, {
253
+ ownerIsAgentSession: ownerIsAgent,
254
+ mcpScopeId: opts.mcpScopeId || null,
255
+ });
253
256
  // Fail-closed permission intersection: when a session declares an explicit
254
257
  // object-form permission, intersect the
255
258
  // resolved tool list with the permission's allow/deny lists. If the
@@ -371,6 +374,7 @@ export function createSession(opts) {
371
374
  agent: opts.agent,
372
375
  owner: opts.owner || 'user',
373
376
  mcpPid: process.pid,
377
+ mcpScopeId: opts.mcpScopeId || null,
374
378
  scopeKey: opts.scopeKey || null,
375
379
  lane: opts.lane || 'agent',
376
380
  cwd: opts.cwd,
@@ -530,7 +534,10 @@ function _prepareResumeTools(session, preset) {
530
534
  if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
531
535
  } catch { /* ignore lookup failures, keep preset fallback */ }
532
536
  }
533
- let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
537
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, {
538
+ ownerIsAgentSession: ownerIsAgent,
539
+ mcpScopeId: session.mcpScopeId || null,
540
+ });
534
541
  if (ownerIsAgent) {
535
542
  toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
536
543
  }
@@ -563,8 +570,10 @@ function _rememberPreparedResume(sessionId, prepared) {
563
570
 
564
571
  function _preparedResumeForSession(session, preset) {
565
572
  const cached = _preparedResumes.get(session.id);
566
- if (cached?.session === session && cached?.preset === preset) return cached;
573
+ if (cached?.session === session && cached?.preset === preset
574
+ && cached?.mcpScopeId === (session.mcpScopeId || null)) return cached;
567
575
  const prepared = _prepareResumeTools(session, preset);
576
+ prepared.mcpScopeId = session.mcpScopeId || null;
568
577
  _rememberPreparedResume(session.id, prepared);
569
578
  return prepared;
570
579
  }
@@ -745,6 +754,9 @@ export async function resumeSession(sessionId, preset, options = {}) {
745
754
  session.desktopSession = expectedDesktop;
746
755
  }
747
756
  if (!session.owner) session.owner = 'user';
757
+ if (Object.prototype.hasOwnProperty.call(options, 'mcpScopeId')) {
758
+ session.mcpScopeId = String(options.mcpScopeId || '').trim() || null;
759
+ }
748
760
  if (_isActivelyOwnedElsewhere(session, sessionId)) {
749
761
  // ATTACH (viewer mode, zero ownership): hand back the live transcript
750
762
  // under the SAME id, flagged remoteAttached. No tool refresh, no save,
@@ -764,6 +776,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
764
776
  const cached = _preparedResumes.get(sessionId);
765
777
  _preparedResumes.delete(sessionId);
766
778
  const prepared = cached?.session === session && cached?.preset === preset
779
+ && cached?.mcpScopeId === (session.mcpScopeId || null)
767
780
  ? cached
768
781
  : _prepareResumeTools(session, preset);
769
782
  // Keep the persisted tool mode in sync on resume (see createSession note).