mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ createGoalRuntime,
9
+ DEFAULT_GOAL_TIME_LIMIT_MS,
10
+ parseGoalDuration,
11
+ } from './goal-runtime.mjs';
12
+
13
+ test('Goal runtime persists criteria, enforces completion evidence, and archives after user input', async () => {
14
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-goal-'));
15
+ let clock = 1_800_000_000_000;
16
+ const runtime = createGoalRuntime({ dataDir, now: () => clock });
17
+ try {
18
+ const created = await runtime.control('sess_goal_main', {
19
+ command: 'Ship Goal mode --time 2h',
20
+ });
21
+ assert.equal(created.goal.status, 'active');
22
+ assert.equal(created.goal.timeLimitMs, 2 * 60 * 60 * 1000);
23
+ assert.equal(created.goal.criteriaTotal, 0);
24
+
25
+ const criteria = [
26
+ { text: 'State is durable', satisfied: true, evidence: 'persistence test passed' },
27
+ { text: 'Idle continuation works', satisfied: false, evidence: '' },
28
+ ];
29
+ await runtime.executeTool('update_goal', {
30
+ success_criteria: criteria,
31
+ progress_summary: 'State complete; continuation remains.',
32
+ }, { callerSessionId: 'sess_goal_main' });
33
+
34
+ await assert.rejects(
35
+ runtime.executeTool('update_goal', {
36
+ status: 'complete',
37
+ completion_evidence: 'partial evidence',
38
+ }, { callerSessionId: 'sess_goal_main' }),
39
+ /success criteria remain unsatisfied/,
40
+ );
41
+
42
+ clock += 12 * 60 * 1000;
43
+ const completedCriteria = criteria.map((criterion) => ({
44
+ ...criterion,
45
+ satisfied: true,
46
+ evidence: criterion.evidence || 'continuation test passed',
47
+ }));
48
+ const completedText = await runtime.executeTool('update_goal', {
49
+ success_criteria: completedCriteria,
50
+ status: 'complete',
51
+ completion_evidence: 'All required checks passed.',
52
+ }, { callerSessionId: 'sess_goal_main' });
53
+ const completed = JSON.parse(completedText).goal;
54
+ assert.equal(completed.status, 'complete');
55
+ assert.equal(completed.criteriaCompleted, 2);
56
+ assert.equal(completed.timeUsedMs, 12 * 60 * 1000);
57
+
58
+ await runtime.archiveCompletedOnUserInput('sess_goal_main');
59
+ assert.equal(runtime.snapshot('sess_goal_main'), null);
60
+ assert.equal(runtime.storedSnapshot('sess_goal_main').status, 'complete');
61
+
62
+ const reloaded = createGoalRuntime({ dataDir, now: () => clock });
63
+ try {
64
+ assert.equal(reloaded.snapshot('sess_goal_main'), null);
65
+ assert.equal(reloaded.storedSnapshot('sess_goal_main').completionEvidence, 'All required checks passed.');
66
+ } finally {
67
+ reloaded.close();
68
+ }
69
+ } finally {
70
+ runtime.close();
71
+ rmSync(dataDir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test('Goal continuation parks only for active agent work and the deadline limits new turns', async () => {
76
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-goal-limit-'));
77
+ let clock = 1_900_000_000_000;
78
+ const runtime = createGoalRuntime({ dataDir, now: () => clock });
79
+ try {
80
+ await runtime.control('sess_goal_limit', { action: 'create', objective: 'Finish the objective' });
81
+ const parked = runtime.continuation('sess_goal_limit', {
82
+ agentStatus: { agentJobs: [{ status: 'running' }] },
83
+ });
84
+ assert.equal(parked.run, false);
85
+ assert.equal(parked.reason, 'agent-running');
86
+
87
+ const shellIgnored = runtime.continuation('sess_goal_limit', {
88
+ agentStatus: { agentJobs: [], shellJobs: [{ status: 'running' }] },
89
+ });
90
+ assert.equal(shellIgnored.run, true);
91
+ assert.match(shellIgnored.prompt, /Active Goal/);
92
+
93
+ clock += DEFAULT_GOAL_TIME_LIMIT_MS + 1;
94
+ const limited = runtime.snapshot('sess_goal_limit');
95
+ assert.equal(limited.status, 'budget_limited');
96
+ assert.equal(runtime.continuation('sess_goal_limit').run, false);
97
+ } finally {
98
+ runtime.close();
99
+ rmSync(dataDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test('Goal duration parser accepts compound limits and rejects unsafe values', () => {
104
+ assert.equal(parseGoalDuration('1h30m'), 90 * 60 * 1000);
105
+ assert.equal(parseGoalDuration('2d 4h'), (2 * 24 + 4) * 60 * 60 * 1000);
106
+ assert.throws(() => parseGoalDuration('30s'), /at least 1 minute/);
107
+ assert.throws(() => parseGoalDuration('8d'), /exceeds 7 days/);
108
+ });
@@ -18,10 +18,13 @@ import {
18
18
  import { SessionClosedError } from '../runtime/agent/orchestrator/session/manager/session-errors.mjs';
19
19
  import { saveSession } from '../runtime/agent/orchestrator/session/store.mjs';
20
20
 
21
- function resolveResumeCwd(session, currentCwd) {
21
+ export function resolveResumeCwd(session, currentCwd) {
22
22
  const desktop = session?.desktopSession;
23
23
  if (desktop?.classification === 'project') {
24
- return clean(desktop.projectPath) || session?.cwd || currentCwd;
24
+ // `session.cwd` is the execution source of truth. Older builds updated it
25
+ // on `cwd set` without updating desktopSession.projectPath, so preferring
26
+ // the duplicate UI metadata reverted the Project after a restart.
27
+ return clean(session?.cwd) || clean(desktop.projectPath) || currentCwd;
25
28
  }
26
29
  if (desktop?.classification === 'task') {
27
30
  // Desktop task sessions deliberately stay in the app-managed unclassified
@@ -80,8 +83,8 @@ export function createLifecycleApi(deps) {
80
83
  computeContextStatus, invalidateContextStatusCache, invalidatePreSessionToolSurface,
81
84
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
82
85
  beginRoutePreparation, clearRoutePreparation,
83
- notificationListeners, clearRuntimeNotifications,
84
- disposeSessionTitles, abortActiveTurns, getReservedSessionId,
86
+ notificationListeners, clearRuntimeNotifications, goalRuntime,
87
+ disposeSessionTitles, disposeGlobalExtensionSubscription, abortActiveTurns, getReservedSessionId,
85
88
  } = deps;
86
89
  const closeSurfaceSession = (session, reason, options) => {
87
90
  if (!session?.id) return false;
@@ -198,6 +201,7 @@ export function createLifecycleApi(deps) {
198
201
  // of reaping every session's jobs. CLI exit paths never set this.
199
202
  const keepBackgroundWork = options?.keepBackgroundWork === true;
200
203
  setCloseRequested(true);
204
+ try { disposeGlobalExtensionSubscription?.(); } catch {}
201
205
  const closingTurnId = getSession()?.id || getReservedSessionId?.() || 'pending';
202
206
  try {
203
207
  abortActiveTurns?.(new SessionClosedError(
@@ -348,6 +352,7 @@ export function createLifecycleApi(deps) {
348
352
  invalidateContextStatusCache();
349
353
  if (typeof clearRuntimeNotifications === 'function') clearRuntimeNotifications();
350
354
  else notificationListeners?.clear?.();
355
+ try { goalRuntime?.close?.(); } catch {}
351
356
  const shellJobsStop = teardownReapsWork && globalThis.__mixdogShellJobsRuntimeLoaded === true
352
357
  ? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
353
358
  .then((mod) => mod?.shutdownShellJobs?.(reason, {
@@ -616,6 +621,15 @@ export function createLifecycleApi(deps) {
616
621
  async inheritFrom(sourceSessionId) {
617
622
  const id = clean(sourceSessionId);
618
623
  if (!id) throw new TypeError('inheritFrom: source session id is required');
624
+ // A daemon-created heir is still only RESERVED when the desktop calls
625
+ // this: reserveSessionId starts the materializing create in the
626
+ // background. Join that single-flight instead of rejecting a session
627
+ // that is about to exist. (The TUI path already ran newSession().)
628
+ if (!getSession()?.id
629
+ && clean(getReservedSessionId?.())
630
+ && typeof createCurrentSession === 'function') {
631
+ await createCurrentSession('inherit');
632
+ }
619
633
  const target = getSession();
620
634
  if (!target?.id) throw new Error('inheritFrom: no session is open');
621
635
  if (target.id === id) throw new Error('inheritFrom: a session cannot inherit from itself');
@@ -0,0 +1,21 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { resolveResumeCwd } from './lifecycle-api.mjs';
5
+
6
+ test('project resume prefers canonical session cwd over stale desktop metadata', () => {
7
+ assert.equal(resolveResumeCwd({
8
+ cwd: 'C:\\Project\\mixdog',
9
+ desktopSession: {
10
+ classification: 'project',
11
+ projectPath: 'C:\\Project\\GamerScroll',
12
+ },
13
+ }, 'C:\\fallback'), 'C:\\Project\\mixdog');
14
+ });
15
+
16
+ test('unclassified desktop task resume stays in its host-managed workspace', () => {
17
+ assert.equal(resolveResumeCwd({
18
+ cwd: 'C:\\old-transient',
19
+ desktopSession: { classification: 'task', projectPath: null },
20
+ }, 'C:\\task-workspace'), 'C:\\task-workspace');
21
+ });
@@ -1,34 +1,10 @@
1
1
  // MCP config/status/connect glue. Mutable runtime state is dependency-injected
2
2
  // through accessors and the caller-owned `state` object.
3
+ import { homedir } from 'node:os';
3
4
  import { resolve } from 'node:path';
4
- import { statSync } from 'node:fs';
5
5
  import { clean } from './session-text.mjs';
6
- import { normalizeMcpProjectPathKey, readProjectMcpServers } from './plugin-mcp.mjs';
7
6
  import { envFlag } from './env.mjs';
8
7
 
9
- // Cache project-local `.mcp.json` reads by path + mtime so repeated mcpStatus()
10
- // calls skip existsSync+readFileSync+JSON.parse when the file is unchanged.
11
- // Invalidated automatically on any mtime change (or create/delete via mtime=0).
12
- const projectMcpCache = new Map();
13
- const PROJECT_MCP_CACHE_MAX = 32;
14
- export function invalidateProjectMcpCache(cwd) {
15
- projectMcpCache.delete(resolve(cwd || '.', '.mcp.json'));
16
- }
17
- function cachedProjectMcpServers(cwd) {
18
- const path = resolve(cwd || '.', '.mcp.json');
19
- let mtimeMs = 0;
20
- try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = 0; }
21
- const hit = projectMcpCache.get(path);
22
- if (hit && hit.mtimeMs === mtimeMs) return hit.value;
23
- const value = readProjectMcpServers(cwd);
24
- // Bound the cache: Map preserves insertion order, so drop the oldest entry.
25
- if (!projectMcpCache.has(path) && projectMcpCache.size >= PROJECT_MCP_CACHE_MAX) {
26
- projectMcpCache.delete(projectMcpCache.keys().next().value);
27
- }
28
- projectMcpCache.set(path, { mtimeMs, value });
29
- return value;
30
- }
31
-
32
8
  export function createMcpGlue({
33
9
  mcpClient,
34
10
  getConfig,
@@ -51,29 +27,43 @@ export function createMcpGlue({
51
27
  // (precedence: project > user config). `sources[name]` records each server's
52
28
  // origin ('config' | 'project') for status reporting.
53
29
  function resolveEffectiveMcpServers() {
54
- // Benchmark/embedding guard: do not even inspect project `.mcp.json`.
30
+ // MCP is machine-global. Project `.mcp.json` files and per-project
31
+ // overrides are intentionally outside the runtime resolution chain.
55
32
  if (envFlag('MIXDOG_DISABLE_MCP')) return { servers: {}, sources: {} };
56
33
  const config = getConfig();
57
34
  const configured = config?.mcpServers && typeof config.mcpServers === 'object'
58
35
  ? config.mcpServers
59
36
  : {};
60
- const projectKey = normalizeMcpProjectPathKey(getCurrentCwd());
61
- const overrides = config?.mcpProjectOverrides?.[projectKey];
62
- const foldedConfigured = {};
37
+ const servers = {};
63
38
  for (const [name, cfg] of Object.entries(configured)) {
64
- const override = overrides?.[name];
65
- foldedConfigured[name] = typeof override?.enabled === 'boolean'
66
- ? { ...cfg, enabled: override.enabled }
67
- : cfg;
39
+ servers[name] = {
40
+ ...cfg,
41
+ ...(cfg?._mixdogPluginDisabled === true ? { enabled: false } : {}),
42
+ };
68
43
  }
69
- const project = cachedProjectMcpServers(getCurrentCwd());
70
- const servers = { ...foldedConfigured, ...project };
71
44
  const sources = {};
72
45
  for (const name of Object.keys(configured)) sources[name] = 'config';
73
- for (const name of Object.keys(project)) sources[name] = 'project';
74
46
  return { servers, sources };
75
47
  }
76
48
 
49
+ function getMcpServerConfig(name) {
50
+ const serverName = clean(name);
51
+ if (!serverName) throw new Error('MCP server name is required');
52
+ const { servers } = resolveEffectiveMcpServers();
53
+ const effective = servers[serverName];
54
+ if (!effective) throw new Error(`MCP server not configured: ${serverName}`);
55
+ const raw = getConfig()?.mcpServers?.[serverName];
56
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
57
+ throw new Error(`MCP server config is unavailable: ${serverName}`);
58
+ }
59
+ return {
60
+ name: serverName,
61
+ source: 'config',
62
+ enabled: effective.enabled !== false,
63
+ config: { ...raw },
64
+ };
65
+ }
66
+
77
67
  function mcpStatus() {
78
68
  if (envFlag('MIXDOG_DISABLE_MCP')) {
79
69
  return { servers: [], configuredCount: 0, connectedCount: 0, failedCount: 0 };
@@ -94,6 +84,7 @@ export function createMcpGlue({
94
84
  transport: mcpTransportLabel(cfg),
95
85
  toolCount: live?.toolCount || 0,
96
86
  tools: live?.tools || [],
87
+ capabilities: live?.capabilities || { tools: false, prompts: false, resources: false },
97
88
  error: fail?.msg || null,
98
89
  source: sources[name] || 'config',
99
90
  });
@@ -118,10 +109,10 @@ export function createMcpGlue({
118
109
  const target = clean(name);
119
110
  if (!target) return;
120
111
  const { servers } = resolveEffectiveMcpServers();
121
- // The toggle is applied to whichever source drives this name: project
122
- // `.mcp.json` entries persist their `enabled` flag in that file (project >
123
- // config precedence) and config entries in mixdog-config, so acting on the
124
- // effective entry here keeps live state in sync with the durable flag.
112
+ // Definitions stay in their original source. Enabled state is folded from
113
+ // Mixdog's per-project override for both global and `.mcp.json` entries, so
114
+ // acting on the effective entry keeps live state aligned without rewriting
115
+ // a shared project file.
125
116
  // Changing this server's state clears any stale failure record for it.
126
117
  if (Array.isArray(state.mcpFailures)) {
127
118
  state.mcpFailures = state.mcpFailures.filter((row) => row.name !== target);
@@ -228,9 +219,16 @@ export function createMcpGlue({
228
219
  }
229
220
  return Object.keys(out).length > 0 ? out : null;
230
221
  };
222
+ const coerceStringArray = (value) => Array.isArray(value)
223
+ ? value.map((entry) => clean(entry)).filter(Boolean)
224
+ : [];
231
225
  const withOptionalHeaders = (config) => {
232
226
  const headers = coerceStringRecord(input.headers);
233
227
  if (headers) config.headers = headers;
228
+ const bearerTokenEnvVar = clean(input.bearer_token_env_var || input.bearerTokenEnvVar);
229
+ if (bearerTokenEnvVar) config.bearer_token_env_var = bearerTokenEnvVar;
230
+ const envHeaders = coerceStringRecord(input.env_http_headers || input.envHttpHeaders);
231
+ if (envHeaders) config.env_http_headers = envHeaders;
234
232
  return config;
235
233
  };
236
234
  const url = clean(input.url);
@@ -259,15 +257,18 @@ export function createMcpGlue({
259
257
  ? input.args.map((v) => String(v)).filter(Boolean)
260
258
  : clean(input.args).split(/\s+/).filter(Boolean);
261
259
  const requestedCwd = clean(input.cwd);
262
- const cwdForServer = requestedCwd ? resolve(currentCwd, requestedCwd) : currentCwd;
263
- const root = resolve(currentCwd);
264
- const resolvedCwd = resolve(cwdForServer);
265
- if (resolvedCwd !== root && !resolvedCwd.startsWith(`${root}\\`) && !resolvedCwd.startsWith(`${root}/`)) {
266
- throw new Error('MCP server cwd must stay under the current project');
267
- }
268
- const config = { type: 'stdio', command, args, cwd: resolvedCwd };
260
+ const expandedCwd = requestedCwd.replace(/^~(?=$|[\\/])/, homedir());
261
+ const resolvedCwd = expandedCwd ? resolve(currentCwd, expandedCwd) : '';
262
+ const config = {
263
+ type: 'stdio',
264
+ command,
265
+ args,
266
+ ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
267
+ };
269
268
  const env = coerceStringRecord(input.env);
270
269
  if (env) config.env = env;
270
+ const envVars = coerceStringArray(input.env_vars || input.envVars);
271
+ if (envVars.length > 0) config.env_vars = envVars;
271
272
  return { name, config };
272
273
  }
273
274
 
@@ -309,6 +310,7 @@ export function createMcpGlue({
309
310
  mcpTransportLabel,
310
311
  resolveEffectiveMcpServers,
311
312
  mcpStatus,
313
+ getMcpServerConfig,
312
314
  connectConfiguredMcp,
313
315
  awaitInitialMcpConnect,
314
316
  normalizeMcpServerInput,
@@ -0,0 +1,177 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { createMcpGlue } from './mcp-glue.mjs';
8
+ import { createResourceApi } from './resource-api.mjs';
9
+ import {
10
+ mergeMcpServerConfig,
11
+ readProjectMcpServerConfig,
12
+ saveProjectMcpServer,
13
+ } from './plugin-mcp.mjs';
14
+
15
+ test('project MCP edits preserve document shape and unknown fields', () => {
16
+ const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-standard-'));
17
+ try {
18
+ const path = join(cwd, '.mcp.json');
19
+ writeFileSync(path, `${JSON.stringify({
20
+ projectMetadata: { owner: 'test' },
21
+ mcpServers: {
22
+ demo: {
23
+ type: 'stdio',
24
+ command: 'old-command',
25
+ args: ['--old'],
26
+ vendorExtension: { keep: true },
27
+ },
28
+ sibling: { url: 'https://example.com/mcp', custom: 7 },
29
+ },
30
+ }, null, 2)}\n`);
31
+
32
+ saveProjectMcpServer(cwd, {
33
+ originalName: 'demo',
34
+ name: 'renamed',
35
+ config: {
36
+ type: 'http',
37
+ url: 'https://example.com/new-mcp',
38
+ headers: { Authorization: '${MCP_TOKEN}' },
39
+ },
40
+ });
41
+
42
+ const saved = JSON.parse(readFileSync(path, 'utf8'));
43
+ assert.deepEqual(saved.projectMetadata, { owner: 'test' });
44
+ assert.deepEqual(saved.mcpServers.sibling, {
45
+ url: 'https://example.com/mcp',
46
+ custom: 7,
47
+ });
48
+ assert.equal(saved.mcpServers.demo, undefined);
49
+ assert.deepEqual(saved.mcpServers.renamed, {
50
+ vendorExtension: { keep: true },
51
+ type: 'http',
52
+ url: 'https://example.com/new-mcp',
53
+ headers: { Authorization: '${MCP_TOKEN}' },
54
+ });
55
+ assert.deepEqual(readProjectMcpServerConfig(cwd, 'renamed')?.config, saved.mcpServers.renamed);
56
+ } finally {
57
+ rmSync(cwd, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test('MCP transport edits drop stale transport fields but retain extensions', () => {
62
+ assert.deepEqual(mergeMcpServerConfig({
63
+ type: 'http',
64
+ url: 'https://example.com/mcp',
65
+ headers: { Authorization: 'secret' },
66
+ bearer_token_env_var: 'MCP_TOKEN',
67
+ env_http_headers: { 'X-API-Key': 'MCP_API_KEY' },
68
+ env_vars: ['STALE_STDIO_VALUE'],
69
+ startupTimeoutSec: 20,
70
+ }, {
71
+ type: 'stdio',
72
+ command: 'server',
73
+ args: [],
74
+ }), {
75
+ startupTimeoutSec: 20,
76
+ type: 'stdio',
77
+ command: 'server',
78
+ args: [],
79
+ });
80
+ });
81
+
82
+ test('plugin-disabled MCP cannot be re-enabled by a stale project override', () => {
83
+ const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-plugin-off-'));
84
+ try {
85
+ const config = {
86
+ mcpServers: {
87
+ plugin: {
88
+ command: 'server',
89
+ _mixdogPluginDisabled: true,
90
+ },
91
+ },
92
+ mcpProjectOverrides: {
93
+ [cwd]: {
94
+ plugin: { enabled: true },
95
+ },
96
+ },
97
+ };
98
+ const glue = createMcpGlue({
99
+ mcpClient: {
100
+ resolveMcpTransportKind: () => 'stdio',
101
+ getMcpServerStatus: () => [],
102
+ },
103
+ getConfig: () => config,
104
+ getCurrentCwd: () => cwd,
105
+ state: {
106
+ mcpFailures: [],
107
+ mcpConnectGeneration: 0,
108
+ mcpConnectInFlight: null,
109
+ },
110
+ });
111
+ assert.equal(glue.mcpStatus().servers[0]?.enabled, false);
112
+ } finally {
113
+ rmSync(cwd, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test('MCP toggle updates the global server and ignores project config and overrides', async () => {
118
+ const cwd = mkdtempSync(join(tmpdir(), 'mixdog-mcp-override-'));
119
+ try {
120
+ const path = join(cwd, '.mcp.json');
121
+ writeFileSync(path, `${JSON.stringify({
122
+ mcpServers: {
123
+ local: { command: 'server', enabled: false, extension: 'kept' },
124
+ },
125
+ }, null, 2)}\n`);
126
+ const before = readFileSync(path, 'utf8');
127
+ let config = {
128
+ mcpServers: {
129
+ local: { command: 'global-server', enabled: false, extension: 'kept' },
130
+ },
131
+ mcpProjectOverrides: {
132
+ [cwd]: {
133
+ local: { enabled: false },
134
+ },
135
+ },
136
+ };
137
+ const api = createResourceApi({
138
+ getConfig: () => config,
139
+ getSession: () => null,
140
+ getCurrentCwd: () => cwd,
141
+ cfgMod: {},
142
+ mgr: {},
143
+ saveConfigAndAdopt: (next) => { config = next; },
144
+ connectConfiguredMcp: async () => ({ servers: [] }),
145
+ invalidatePreSessionToolSurface: () => {},
146
+ recreateCurrentSessionIfReady: async () => {},
147
+ mcpStatus: () => ({ servers: [{ name: 'local', source: 'config' }] }),
148
+ getActiveTurnCount: () => 0,
149
+ });
150
+ await api.setMcpServerEnabled('local', true);
151
+ assert.equal(config.mcpServers.local.enabled, true);
152
+ assert.equal(config.mcpServers.local.extension, 'kept');
153
+ assert.equal(config.mcpProjectOverrides, undefined);
154
+
155
+ const glue = createMcpGlue({
156
+ mcpClient: {
157
+ resolveMcpTransportKind: () => 'stdio',
158
+ getMcpServerStatus: () => [],
159
+ },
160
+ getConfig: () => config,
161
+ getCurrentCwd: () => cwd,
162
+ state: {
163
+ mcpFailures: [],
164
+ mcpConnectGeneration: 0,
165
+ mcpConnectInFlight: null,
166
+ },
167
+ });
168
+
169
+ const row = glue.mcpStatus().servers.find((server) => server.name === 'local');
170
+ assert.equal(row?.source, 'config');
171
+ assert.equal(row?.enabled, true);
172
+ assert.equal(glue.getMcpServerConfig('local').config.command, 'global-server');
173
+ assert.equal(readFileSync(path, 'utf8'), before);
174
+ } finally {
175
+ rmSync(cwd, { recursive: true, force: true });
176
+ }
177
+ });
@@ -1,5 +1,17 @@
1
1
  // Plugin/project MCP server discovery + normalization, and skill-file counting.
2
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ openSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ renameSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
3
15
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
16
  import { clean } from './session-text.mjs';
5
17
  import { readJsonSafe } from './fs-utils.mjs';
@@ -52,39 +64,101 @@ export function readProjectMcpServers(cwd) {
52
64
  return out;
53
65
  }
54
66
 
55
- // Persist an enable/disable flag for a project-local `.mcp.json` server, in
56
- // place, preserving the file's shape. Accepts both the standard
57
- // `{ mcpServers: {...} }` wrapper and a bare name->cfg map. Writes pretty JSON
58
- // (2-space indent + trailing newline) so the file stays valid + diff-friendly.
59
- // Throws if the file is missing/unparseable or the server isn't defined there.
60
- export function setProjectMcpServerEnabled(cwd, name, enabled) {
67
+ const MCP_TRANSPORT_FIELDS = new Set([
68
+ 'type',
69
+ 'transport',
70
+ 'command',
71
+ 'args',
72
+ 'env',
73
+ 'cwd',
74
+ 'url',
75
+ 'headers',
76
+ 'bearer_token_env_var',
77
+ 'env_http_headers',
78
+ 'env_vars',
79
+ 'autoDetect',
80
+ ]);
81
+
82
+ export function mergeMcpServerConfig(existing, next) {
83
+ const merged = isPlainObject(existing) ? { ...existing } : {};
84
+ for (const field of MCP_TRANSPORT_FIELDS) delete merged[field];
85
+ return { ...merged, ...next };
86
+ }
87
+
88
+ function readProjectMcpDocument(cwd, allowMissing = false) {
61
89
  const path = join(cwd || '.', '.mcp.json');
62
- const key = clean(name);
63
- if (!key) throw new Error('MCP server name is required');
90
+ if (!existsSync(path)) {
91
+ if (allowMissing) return { path, raw: { mcpServers: {} }, usesWrapper: true };
92
+ throw new Error(`MCP config file not found: ${path}`);
93
+ }
64
94
  let raw;
65
95
  try {
66
96
  raw = JSON.parse(readFileSync(path, 'utf8'));
67
97
  } catch (error) {
68
- throw new Error(`cannot update ${path}: ${error?.message || String(error)}`);
98
+ throw new Error(`cannot read ${path}: ${error?.message || String(error)}`);
69
99
  }
70
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
100
+ if (!isPlainObject(raw)) throw new Error(`unexpected .mcp.json shape at ${path}`);
101
+ if (Object.prototype.hasOwnProperty.call(raw, 'mcpServers') && !isPlainObject(raw.mcpServers)) {
71
102
  throw new Error(`unexpected .mcp.json shape at ${path}`);
72
103
  }
73
- const usesWrapper = raw.mcpServers && typeof raw.mcpServers === 'object' && !Array.isArray(raw.mcpServers);
104
+ const usesWrapper = isPlainObject(raw.mcpServers);
105
+ const map = usesWrapper ? raw.mcpServers : raw;
106
+ if (!isPlainObject(map)) throw new Error(`unexpected .mcp.json shape at ${path}`);
107
+ return { path, raw, usesWrapper };
108
+ }
109
+
110
+ function writeProjectMcpDocument(path, raw) {
111
+ let mode = 0o644;
112
+ try { mode = statSync(path).mode; } catch { /* new file */ }
113
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
114
+ let fd = null;
115
+ try {
116
+ fd = openSync(tempPath, 'w', mode);
117
+ writeFileSync(fd, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
118
+ fsyncSync(fd);
119
+ closeSync(fd);
120
+ fd = null;
121
+ chmodSync(tempPath, mode);
122
+ renameSync(tempPath, path);
123
+ } catch (error) {
124
+ if (fd !== null) {
125
+ try { closeSync(fd); } catch { /* best effort */ }
126
+ }
127
+ try { unlinkSync(tempPath); } catch { /* best effort */ }
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ export function readProjectMcpServerConfig(cwd, name) {
133
+ const { raw, usesWrapper } = readProjectMcpDocument(cwd);
74
134
  const map = usesWrapper ? raw.mcpServers : raw;
135
+ const key = String(name || '').trim();
75
136
  const entryKey = Object.prototype.hasOwnProperty.call(map, key)
76
137
  ? key
77
- : Object.keys(map).reverse().find((candidate) => clean(candidate) === key);
78
- if (!map || typeof map !== 'object' || Array.isArray(map) || !entryKey) {
79
- throw new Error(`MCP server not defined in ${path}: ${key}`);
80
- }
81
- const entry = map[entryKey];
82
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
83
- throw new Error(`MCP server is not an object in ${path}: ${key}`);
138
+ : Object.keys(map).find((candidate) => String(candidate || '').trim() === key);
139
+ return entryKey && isPlainObject(map[entryKey]) ? { name: entryKey, config: { ...map[entryKey] } } : null;
140
+ }
141
+
142
+ export function saveProjectMcpServer(cwd, { originalName = '', name, config }) {
143
+ const { path, raw, usesWrapper } = readProjectMcpDocument(cwd, !originalName);
144
+ const map = usesWrapper ? raw.mcpServers : raw;
145
+ const target = String(name || '').trim();
146
+ const original = String(originalName || '').trim();
147
+ if (!target) throw new Error('MCP server name is required');
148
+ const entryKey = original
149
+ ? (Object.prototype.hasOwnProperty.call(map, original)
150
+ ? original
151
+ : Object.keys(map).find((candidate) => String(candidate || '').trim() === original))
152
+ : null;
153
+ if (original && !entryKey) throw new Error(`MCP server not defined in ${path}: ${original}`);
154
+ if (target !== entryKey && Object.prototype.hasOwnProperty.call(map, target)) {
155
+ throw new Error(`MCP server already exists in ${path}: ${target}`);
84
156
  }
85
- map[entryKey] = { ...entry, enabled: enabled !== false };
86
- writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
87
- return raw;
157
+ const existing = entryKey ? map[entryKey] : {};
158
+ if (entryKey && entryKey !== target) delete map[entryKey];
159
+ map[target] = mergeMcpServerConfig(existing, config);
160
+ writeProjectMcpDocument(path, raw);
161
+ return { name: target, source: 'project', path, config: { ...map[target] } };
88
162
  }
89
163
 
90
164
  function isPlainObject(value) {