@intutic/sync-daemon 1.10.0 → 1.10.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 (200) hide show
  1. package/README.md +2 -2
  2. package/dist/agentReporter.d.ts +205 -0
  3. package/dist/agentReporter.d.ts.map +1 -0
  4. package/dist/agentReporter.js +394 -0
  5. package/dist/agentReporter.js.map +1 -0
  6. package/dist/configReader.d.ts +39 -2
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +56 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +51 -1
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +490 -63
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  15. package/dist/harness/antigravityHooks.js +33 -55
  16. package/dist/harness/antigravityHooks.js.map +1 -1
  17. package/dist/harness/claudeCodeHooks.d.ts +47 -13
  18. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  19. package/dist/harness/claudeCodeHooks.js +316 -54
  20. package/dist/harness/claudeCodeHooks.js.map +1 -1
  21. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  22. package/dist/harness/claudeDesktopHooks.js +35 -48
  23. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  24. package/dist/harness/clineHooks.d.ts.map +1 -1
  25. package/dist/harness/clineHooks.js +42 -34
  26. package/dist/harness/clineHooks.js.map +1 -1
  27. package/dist/harness/codexHooks.d.ts +36 -0
  28. package/dist/harness/codexHooks.d.ts.map +1 -0
  29. package/dist/harness/codexHooks.js +227 -0
  30. package/dist/harness/codexHooks.js.map +1 -0
  31. package/dist/harness/continueHooks.d.ts +45 -5
  32. package/dist/harness/continueHooks.d.ts.map +1 -1
  33. package/dist/harness/continueHooks.js +225 -5
  34. package/dist/harness/continueHooks.js.map +1 -1
  35. package/dist/harness/cursorHooks.d.ts +31 -0
  36. package/dist/harness/cursorHooks.d.ts.map +1 -1
  37. package/dist/harness/cursorHooks.js +78 -49
  38. package/dist/harness/cursorHooks.js.map +1 -1
  39. package/dist/harness/dshHooks.d.ts +198 -0
  40. package/dist/harness/dshHooks.d.ts.map +1 -0
  41. package/dist/harness/dshHooks.js +569 -0
  42. package/dist/harness/dshHooks.js.map +1 -0
  43. package/dist/harness/gateBody.d.ts +422 -0
  44. package/dist/harness/gateBody.d.ts.map +1 -0
  45. package/dist/harness/gateBody.js +1816 -0
  46. package/dist/harness/gateBody.js.map +1 -0
  47. package/dist/harness/gateKind.d.ts +164 -0
  48. package/dist/harness/gateKind.d.ts.map +1 -0
  49. package/dist/harness/gateKind.js +143 -0
  50. package/dist/harness/gateKind.js.map +1 -0
  51. package/dist/harness/githubCopilotHooks.d.ts +54 -0
  52. package/dist/harness/githubCopilotHooks.d.ts.map +1 -0
  53. package/dist/harness/githubCopilotHooks.js +218 -0
  54. package/dist/harness/githubCopilotHooks.js.map +1 -0
  55. package/dist/harness/gooseHooks.d.ts.map +1 -1
  56. package/dist/harness/gooseHooks.js +67 -37
  57. package/dist/harness/gooseHooks.js.map +1 -1
  58. package/dist/harness/grokHooks.d.ts +77 -0
  59. package/dist/harness/grokHooks.d.ts.map +1 -0
  60. package/dist/harness/grokHooks.js +350 -0
  61. package/dist/harness/grokHooks.js.map +1 -0
  62. package/dist/harness/hermesHooks.d.ts.map +1 -1
  63. package/dist/harness/hermesHooks.js +26 -55
  64. package/dist/harness/hermesHooks.js.map +1 -1
  65. package/dist/harness/holdRedaction.d.ts +69 -0
  66. package/dist/harness/holdRedaction.d.ts.map +1 -0
  67. package/dist/harness/holdRedaction.js +211 -0
  68. package/dist/harness/holdRedaction.js.map +1 -0
  69. package/dist/harness/jetbrainsXmlConfig.d.ts +51 -0
  70. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -0
  71. package/dist/harness/jetbrainsXmlConfig.js +151 -0
  72. package/dist/harness/jetbrainsXmlConfig.js.map +1 -0
  73. package/dist/harness/mcpAutoWrite.d.ts +100 -3
  74. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  75. package/dist/harness/mcpAutoWrite.js +712 -90
  76. package/dist/harness/mcpAutoWrite.js.map +1 -1
  77. package/dist/harness/museHooks.d.ts +53 -0
  78. package/dist/harness/museHooks.d.ts.map +1 -0
  79. package/dist/harness/museHooks.js +273 -0
  80. package/dist/harness/museHooks.js.map +1 -0
  81. package/dist/harness/n8nHooks.d.ts +25 -9
  82. package/dist/harness/n8nHooks.d.ts.map +1 -1
  83. package/dist/harness/n8nHooks.js +174 -9
  84. package/dist/harness/n8nHooks.js.map +1 -1
  85. package/dist/harness/openCodeHooks.d.ts +93 -0
  86. package/dist/harness/openCodeHooks.d.ts.map +1 -0
  87. package/dist/harness/openCodeHooks.js +248 -0
  88. package/dist/harness/openCodeHooks.js.map +1 -0
  89. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  90. package/dist/harness/openWebuiHooks.js +56 -11
  91. package/dist/harness/openWebuiHooks.js.map +1 -1
  92. package/dist/harness/openclawHooks.d.ts.map +1 -1
  93. package/dist/harness/openclawHooks.js +30 -54
  94. package/dist/harness/openclawHooks.js.map +1 -1
  95. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  96. package/dist/harness/openhandsHooks.js +35 -41
  97. package/dist/harness/openhandsHooks.js.map +1 -1
  98. package/dist/harness/piHooks.d.ts.map +1 -1
  99. package/dist/harness/piHooks.js +31 -58
  100. package/dist/harness/piHooks.js.map +1 -1
  101. package/dist/harness/protectedPaths.d.ts +353 -0
  102. package/dist/harness/protectedPaths.d.ts.map +1 -0
  103. package/dist/harness/protectedPaths.js +805 -0
  104. package/dist/harness/protectedPaths.js.map +1 -0
  105. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  106. package/dist/harness/rooCodeHooks.js +34 -48
  107. package/dist/harness/rooCodeHooks.js.map +1 -1
  108. package/dist/harness/windsurfHooks.d.ts +72 -0
  109. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  110. package/dist/harness/windsurfHooks.js +172 -58
  111. package/dist/harness/windsurfHooks.js.map +1 -1
  112. package/dist/harness/windsurfJetBrainsProxy.d.ts +78 -0
  113. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -0
  114. package/dist/harness/windsurfJetBrainsProxy.js +159 -0
  115. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -0
  116. package/dist/hashReporter.js +1 -1
  117. package/dist/hashReporter.js.map +1 -1
  118. package/dist/index.d.ts +12 -6
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +14 -6
  121. package/dist/index.js.map +1 -1
  122. package/dist/integrityStore.d.ts +0 -8
  123. package/dist/integrityStore.d.ts.map +1 -1
  124. package/dist/integrityStore.js +6 -29
  125. package/dist/integrityStore.js.map +1 -1
  126. package/dist/lib/approvedBypasses.d.ts +76 -0
  127. package/dist/lib/approvedBypasses.d.ts.map +1 -0
  128. package/dist/lib/approvedBypasses.js +140 -0
  129. package/dist/lib/approvedBypasses.js.map +1 -0
  130. package/dist/lib/complianceProbes.d.ts.map +1 -1
  131. package/dist/lib/complianceProbes.js +5 -0
  132. package/dist/lib/complianceProbes.js.map +1 -1
  133. package/dist/lib/decisionsDigest.d.ts +99 -0
  134. package/dist/lib/decisionsDigest.d.ts.map +1 -0
  135. package/dist/lib/decisionsDigest.js +199 -0
  136. package/dist/lib/decisionsDigest.js.map +1 -0
  137. package/dist/lib/egressPolicy.d.ts +69 -0
  138. package/dist/lib/egressPolicy.d.ts.map +1 -0
  139. package/dist/lib/egressPolicy.js +126 -0
  140. package/dist/lib/egressPolicy.js.map +1 -0
  141. package/dist/lib/gitWorktrees.d.ts +140 -0
  142. package/dist/lib/gitWorktrees.d.ts.map +1 -0
  143. package/dist/lib/gitWorktrees.js +191 -0
  144. package/dist/lib/gitWorktrees.js.map +1 -0
  145. package/dist/lib/policySnapshot.d.ts +224 -0
  146. package/dist/lib/policySnapshot.d.ts.map +1 -0
  147. package/dist/lib/policySnapshot.js +637 -0
  148. package/dist/lib/policySnapshot.js.map +1 -0
  149. package/dist/lib/processPoller.d.ts +44 -0
  150. package/dist/lib/processPoller.d.ts.map +1 -1
  151. package/dist/lib/processPoller.js +176 -1
  152. package/dist/lib/processPoller.js.map +1 -1
  153. package/dist/lib/runtimeEnv.d.ts +8 -0
  154. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  155. package/dist/lib/runtimeEnv.js +3 -0
  156. package/dist/lib/runtimeEnv.js.map +1 -1
  157. package/dist/localTrajectoryHeuristics.d.ts +51 -0
  158. package/dist/localTrajectoryHeuristics.d.ts.map +1 -0
  159. package/dist/localTrajectoryHeuristics.js +90 -0
  160. package/dist/localTrajectoryHeuristics.js.map +1 -0
  161. package/dist/sessionReporter.d.ts +70 -0
  162. package/dist/sessionReporter.d.ts.map +1 -0
  163. package/dist/sessionReporter.js +140 -0
  164. package/dist/sessionReporter.js.map +1 -0
  165. package/dist/skillWriter.d.ts +1 -1
  166. package/dist/skillWriter.d.ts.map +1 -1
  167. package/dist/skillWriter.js +5 -2
  168. package/dist/skillWriter.js.map +1 -1
  169. package/dist/syncLoop.d.ts +105 -3
  170. package/dist/syncLoop.d.ts.map +1 -1
  171. package/dist/syncLoop.js +397 -65
  172. package/dist/syncLoop.js.map +1 -1
  173. package/dist/trajectoryMonitor.d.ts +2 -0
  174. package/dist/trajectoryMonitor.d.ts.map +1 -1
  175. package/dist/trajectoryMonitor.js +57 -0
  176. package/dist/trajectoryMonitor.js.map +1 -1
  177. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  178. package/dist/watcher/driftWatcher.js +12 -3
  179. package/dist/watcher/driftWatcher.js.map +1 -1
  180. package/dist/watcher/settingsGuard.d.ts +23 -0
  181. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  182. package/dist/watcher/settingsGuard.js +202 -17
  183. package/dist/watcher/settingsGuard.js.map +1 -1
  184. package/package.json +37 -6
  185. package/dist/brainIndexer.d.ts +0 -51
  186. package/dist/brainIndexer.d.ts.map +0 -1
  187. package/dist/brainIndexer.js +0 -148
  188. package/dist/brainIndexer.js.map +0 -1
  189. package/dist/harness/cursorHooksJson.d.ts +0 -29
  190. package/dist/harness/cursorHooksJson.d.ts.map +0 -1
  191. package/dist/harness/cursorHooksJson.js +0 -130
  192. package/dist/harness/cursorHooksJson.js.map +0 -1
  193. package/dist/harness/gooseHooksWriter.d.ts +0 -27
  194. package/dist/harness/gooseHooksWriter.d.ts.map +0 -1
  195. package/dist/harness/gooseHooksWriter.js +0 -147
  196. package/dist/harness/gooseHooksWriter.js.map +0 -1
  197. package/dist/harness/openhandsHooksWriter.d.ts +0 -23
  198. package/dist/harness/openhandsHooksWriter.d.ts.map +0 -1
  199. package/dist/harness/openhandsHooksWriter.js +0 -146
  200. package/dist/harness/openhandsHooksWriter.js.map +0 -1
@@ -12,11 +12,72 @@
12
12
  * - Continue: ~/.continue/config.json (mcpServers section)
13
13
  * - Goose: ~/.config/goose/config.yaml (mcp section)
14
14
  * - OpenHands: <workspaceRoot>/.openhands/mcp.json
15
+ * - Muse Code: ~/.config/muse/settings.json (`mcp_servers` section)
16
+ * - Grok Build: ~/.grok/config.toml + <workspaceRoot>/.grok/config.toml
17
+ * ([mcp_servers.*] tables)
18
+ *
19
+ * That is 12 config paths across 10 `HarnessType` values (Cursor and Grok
20
+ * Build each own two paths — global/project for Cursor, user/project for
21
+ * Grok Build). `discoverMcpServers` below reads all twelve read-only, for
22
+ * reporting; the injectors above are the only thing that writes.
23
+ *
24
+ * # Grok Build's compat-path overlap — dedup, not a bug
25
+ *
26
+ * Grok Build ALSO natively reads `.cursor/mcp.json` (a compatibility
27
+ * feature) — which `injectCursor`/`discoverJsonObjectHarness` below ALREADY
28
+ * wrap/discover under `harness: 'cursor'`, independently of anything this
29
+ * file does for Grok Build specifically. A server declared in BOTH
30
+ * `.cursor/mcp.json` and Grok Build's own `[mcp_servers.*]` table therefore
31
+ * produces TWO rows in `discoverMcpServers`' output — one tagged `cursor`,
32
+ * one tagged `grok` — not one row silently merged or dropped. That is
33
+ * consistent with how every other harness pair already behaves here (this
34
+ * file reports per CONFIG FILE, never cross-harness-deduplicated), and it is
35
+ * the correct behaviour, not a defect to fix: the two configs are genuinely
36
+ * separate on disk, a user (or a future sync) could point them at different
37
+ * servers, and a naive same-name merge would hide that a `.cursor/mcp.json`
38
+ * entry is unwrapped while the `[mcp_servers.*]` entry with the same name is
39
+ * wrapped (or vice versa). See `grokHooks.test.ts`'s dedup test for the pin:
40
+ * the same server name in both files must total exactly two DISTINCT rows,
41
+ * never one collapsed row and never three.
42
+ *
43
+ * (Grok Build is also documented to read a bare project-root `.mcp.json` —
44
+ * a convention this file does not wrap or discover for ANY harness today,
45
+ * Grok Build included; that is a pre-existing gap in this file predating
46
+ * this harness, out of scope for this phase, not something newly introduced
47
+ * here.)
48
+ *
49
+ * Muse's `mcp_servers` map carries both `stdio` and `streamable_http` server
50
+ * entries, but its ON-DISK shape (`command`/`args`/`env` for stdio,
51
+ * `url`/`headers` for a remote transport) was ASSUMED to match the same
52
+ * `command`-or-`url` shape every other JSON-map harness here uses — `wrapAllServers`
53
+ * / `wrapWithProxy` branch on exactly that, so a `streamable_http` entry is
54
+ * wrapped through the SAME remote-bridge path a `url`-keyed Claude Desktop/
55
+ * Windsurf entry gets, without new code. See TD-362 for why this is recorded
56
+ * as an assumption rather than a confirmation.
15
57
  *
16
58
  * Proxy-wrapping convention:
17
- * Each existing MCP server entry is rewritten so that the governance proxy
18
- * binary is the command, and the original server command is passed after `--`.
19
- * A `__intutic_wrapped: true` flag is added to prevent double-wrapping.
59
+ * Each existing stdio MCP server entry is rewritten so that the governance
60
+ * proxy binary is the command, and the original server command is passed
61
+ * after `--`, tagged with `--server-name <name>` so the proxy can attribute
62
+ * traffic to the server it fronts. A `__intutic_wrapped: true` flag is added
63
+ * to prevent double-wrapping. Remote (HTTP/SSE, `url`-keyed) entries are now
64
+ * ALSO wrapped — TD-354's stdio→HTTP bridge phase closed the gap this
65
+ * comment used to describe as "left untouched": the entry is rewritten to
66
+ * spawn the SAME proxy binary in bridge mode (`--remote-url`/
67
+ * `--remote-transport`, headers riding in `INTUTIC_REMOTE_HEADERS` env, not
68
+ * argv — `ps` visibility). `__intutic_original` preserves the entry's
69
+ * pre-wrap `url`/`type`/`headers` so `classifyEntry` can report the TRUE
70
+ * transport of what got wrapped, and so a wrapped entry could be unwrapped
71
+ * later. See TECH_DEBT.md TD-354 for the historical decline this phase
72
+ * supersedes.
73
+ *
74
+ * Continuous invariant, not one-shot: `injectMcpServer` is called once from
75
+ * `intutic connect` (tools/cli) AND once per sync-loop iteration
76
+ * (services/sync-daemon/src/syncLoop.ts), so a server a user adds to a harness
77
+ * config after their first `connect` still gets wrapped on the next sync
78
+ * cycle rather than staying invisible to governance forever. Running this
79
+ * every ~30s only works because `writeJsonFile` below is write-if-changed —
80
+ * an already-wrapped, unchanged config produces zero bytes written.
20
81
  *
21
82
  * LLD #14 — mcpAutoWrite.ts
22
83
  * HLD §3.14 — GUI Harness Interception (MCP registration + Universal MCP Governance)
@@ -27,7 +88,10 @@ import * as node_fs from 'node:fs/promises';
27
88
  import * as node_path from 'node:path';
28
89
  import * as node_os from 'node:os';
29
90
  import { existsSync } from 'node:fs';
91
+ import { isDeepStrictEqual } from 'node:util';
30
92
  import { createLogger } from '@intutic/logger';
93
+ import { parseDocument, isMap } from 'yaml';
94
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
31
95
  const log = createLogger('sync-mcp-autowrite');
32
96
  // ─── Proxy Binary Resolution ─────────────────────────────────────────────────
33
97
  /**
@@ -50,31 +114,138 @@ function resolveProxyBin(workspaceRoot) {
50
114
  const pkgPath = node_path.join(workspaceRoot, 'packages', 'mcp-proxy', 'dist', 'index.js');
51
115
  return pkgPath;
52
116
  }
117
+ // ─── Config Path Resolution ───────────────────────────────────────────────────
118
+ //
119
+ // Shared by both the injectors (which write) and discoverMcpServers (which
120
+ // only reads), so the two can never independently drift about where a
121
+ // harness keeps its config.
122
+ function claudeCodeConfigPath() {
123
+ return node_path.join(node_os.homedir(), '.claude', 'mcp.json');
124
+ }
125
+ function claudeDesktopConfigPath() {
126
+ const home = node_os.homedir();
127
+ if (process.platform === 'darwin') {
128
+ return node_path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
129
+ }
130
+ else if (process.platform === 'win32') {
131
+ return node_path.join(process.env['APPDATA'] ?? '', 'Claude', 'claude_desktop_config.json');
132
+ }
133
+ return node_path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
134
+ }
135
+ function cursorGlobalConfigPath() {
136
+ const home = node_os.homedir();
137
+ if (process.platform === 'darwin') {
138
+ return node_path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json');
139
+ }
140
+ else if (process.platform === 'win32') {
141
+ return node_path.join(process.env['APPDATA'] ?? '', 'Cursor', 'User', 'globalSettings.json');
142
+ }
143
+ return node_path.join(home, '.config', 'Cursor', 'User', 'globalSettings.json');
144
+ }
145
+ function cursorProjectConfigPath(workspaceRoot) {
146
+ return node_path.join(workspaceRoot, '.cursor', 'mcp.json');
147
+ }
148
+ function clineConfigPath(workspaceRoot) {
149
+ return node_path.join(workspaceRoot, '.cline', 'mcp.json');
150
+ }
151
+ function windsurfConfigPath() {
152
+ return node_path.join(node_os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
153
+ }
154
+ function continueConfigPath() {
155
+ return node_path.join(node_os.homedir(), '.continue', 'config.json');
156
+ }
157
+ function gooseConfigPath() {
158
+ return node_path.join(node_os.homedir(), '.config', 'goose', 'config.yaml');
159
+ }
160
+ function openHandsConfigPath(workspaceRoot) {
161
+ return node_path.join(workspaceRoot, '.openhands', 'mcp.json');
162
+ }
163
+ function museConfigPath() {
164
+ return node_path.join(node_os.homedir(), '.config', 'muse', 'settings.json');
165
+ }
166
+ function grokUserConfigPath() {
167
+ return node_path.join(node_os.homedir(), '.grok', 'config.toml');
168
+ }
169
+ function grokProjectConfigPath(workspaceRoot) {
170
+ return node_path.join(workspaceRoot, '.grok', 'config.toml');
171
+ }
53
172
  // ─── Proxy Wrapping ───────────────────────────────────────────────────────────
54
173
  /**
55
174
  * Wrap a single MCP server entry with the governance proxy.
56
- * Returns the entry unmodified if it's already wrapped.
175
+ *
176
+ * Returns the entry unmodified if it's already wrapped, or if its shape is
177
+ * neither a stdio entry (`command`) nor a remote entry (`url`) — an unknown
178
+ * shape has nothing safe to rewrite it into.
179
+ *
180
+ * Two wrap conventions, chosen by the entry's pre-wrap shape:
181
+ * - stdio (`command`/`args`): the proxy binary becomes the command, the
182
+ * original command is passed after `--`, tagged `--server-name`.
183
+ * - remote (`url`, HTTP/SSE — TD-354, now superseded): the proxy binary is
184
+ * invoked in bridge mode (`--remote-url`/`--remote-transport` instead of
185
+ * `--`), also tagged `--server-name`. Any `headers` on the original entry
186
+ * ride into the wrapped entry's env as `INTUTIC_REMOTE_HEADERS` (a JSON
187
+ * object string) — never argv, for the same `ps`-visibility reasoning
188
+ * `packages/mcp-proxy/src/config.ts` documents for `--remote-url` itself.
189
+ * Both conventions end up with the SAME top-level shape (`command: 'node'`,
190
+ * `args: […]`) since the harness only knows how to spawn a stdio process —
191
+ * `__intutic_original` is what lets `classifyEntry` tell them apart again.
57
192
  */
58
- function wrapWithProxy(entry, workspaceId, workspaceRoot) {
193
+ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
59
194
  if (entry.__intutic_wrapped)
60
195
  return entry;
61
196
  const proxyBin = resolveProxyBin(workspaceRoot);
62
- const originalArgs = entry.args ?? [];
63
- return {
64
- command: 'node',
65
- args: [
66
- proxyBin,
67
- '--workspace-id', workspaceId,
68
- '--',
69
- entry.command,
70
- ...originalArgs,
71
- ],
72
- env: {
197
+ if (typeof entry.command === 'string') {
198
+ const originalArgs = entry.args ?? [];
199
+ return {
200
+ command: 'node',
201
+ args: [
202
+ proxyBin,
203
+ '--workspace-id', workspaceId,
204
+ '--server-name', serverName,
205
+ '--',
206
+ entry.command,
207
+ ...originalArgs,
208
+ ],
209
+ env: {
210
+ ...(entry.env ?? {}),
211
+ INTUTIC_WORKSPACE_ID: workspaceId,
212
+ },
213
+ __intutic_wrapped: true,
214
+ };
215
+ }
216
+ if (typeof entry.url === 'string') {
217
+ const remoteTransport = entry.type?.toLowerCase() === 'sse' ? 'sse' : 'http';
218
+ const hasHeaders = entry.headers !== undefined && Object.keys(entry.headers).length > 0;
219
+ const original = { url: entry.url };
220
+ if (entry.type !== undefined)
221
+ original.type = entry.type;
222
+ if (entry.headers !== undefined)
223
+ original.headers = entry.headers;
224
+ const env = {
73
225
  ...(entry.env ?? {}),
74
226
  INTUTIC_WORKSPACE_ID: workspaceId,
75
- },
76
- __intutic_wrapped: true,
77
- };
227
+ };
228
+ if (hasHeaders) {
229
+ env['INTUTIC_REMOTE_HEADERS'] = JSON.stringify(entry.headers);
230
+ }
231
+ return {
232
+ command: 'node',
233
+ args: [
234
+ proxyBin,
235
+ '--workspace-id', workspaceId,
236
+ '--server-name', serverName,
237
+ '--remote-url', entry.url,
238
+ '--remote-transport', remoteTransport,
239
+ ],
240
+ env,
241
+ __intutic_wrapped: true,
242
+ __intutic_original: original,
243
+ };
244
+ }
245
+ // Neither a stdio command nor a remote url — an unrecognised shape has
246
+ // nothing safe to rewrite it into. Leave it alone rather than building an
247
+ // args array around `undefined`.
248
+ return entry;
78
249
  }
79
250
  /**
80
251
  * Wrap all servers in an mcpServers map, preserving the `intutic` entry as-is.
@@ -89,7 +260,7 @@ function wrapAllServers(servers, workspaceId, workspaceRoot) {
89
260
  result[name] = entry;
90
261
  }
91
262
  else {
92
- result[name] = wrapWithProxy(entry, workspaceId, workspaceRoot);
263
+ result[name] = wrapWithProxy(entry, workspaceId, workspaceRoot, name);
93
264
  }
94
265
  }
95
266
  return result;
@@ -120,14 +291,39 @@ async function readJsonFile(filePath, fallback) {
120
291
  return fallback;
121
292
  }
122
293
  }
294
+ /**
295
+ * Write JSON to disk, but only if the content actually changed.
296
+ *
297
+ * `injectMcpServer` now runs every sync-loop iteration (~every 30s, see
298
+ * syncLoop.ts) instead of only once at `connect` time — re-running the same
299
+ * wrap against an already-wrapped, unchanged config must not touch the file,
300
+ * or every cycle would churn the file's mtime and fire a spurious inotify /
301
+ * FSEvents event for every harness config on every developer machine.
302
+ *
303
+ * Compares the parsed object structurally (`isDeepStrictEqual`), not the
304
+ * serialized string — a naive string compare would report a false "changed"
305
+ * on nothing more than JSON.stringify key-ordering differences between two
306
+ * semantically identical objects.
307
+ */
123
308
  async function writeJsonFile(filePath, data) {
309
+ let existing;
310
+ let hasExisting;
311
+ try {
312
+ existing = JSON.parse(await node_fs.readFile(filePath, 'utf-8'));
313
+ hasExisting = true;
314
+ }
315
+ catch {
316
+ hasExisting = false;
317
+ }
318
+ if (hasExisting && isDeepStrictEqual(existing, data)) {
319
+ return;
320
+ }
124
321
  await node_fs.mkdir(node_path.dirname(filePath), { recursive: true });
125
322
  await node_fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
126
323
  }
127
324
  // ─── Target: Claude Code ─────────────────────────────────────────────────────
128
325
  async function injectClaudeCode(workspaceId, workspaceRoot) {
129
- const home = node_os.homedir();
130
- const configPath = node_path.join(home, '.claude', 'mcp.json');
326
+ const configPath = claudeCodeConfigPath();
131
327
  const current = await readJsonFile(configPath, {});
132
328
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
133
329
  await writeJsonFile(configPath, current);
@@ -135,17 +331,7 @@ async function injectClaudeCode(workspaceId, workspaceRoot) {
135
331
  }
136
332
  // ─── Target: Claude Desktop ───────────────────────────────────────────────────
137
333
  async function injectClaudeDesktop(workspaceId, workspaceRoot) {
138
- const home = node_os.homedir();
139
- let configPath;
140
- if (process.platform === 'darwin') {
141
- configPath = node_path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
142
- }
143
- else if (process.platform === 'win32') {
144
- configPath = node_path.join(process.env['APPDATA'] ?? '', 'Claude', 'claude_desktop_config.json');
145
- }
146
- else {
147
- configPath = node_path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
148
- }
334
+ const configPath = claudeDesktopConfigPath();
149
335
  // Skip if Claude Desktop directory doesn't exist (not installed)
150
336
  try {
151
337
  await node_fs.access(node_path.dirname(configPath));
@@ -161,18 +347,8 @@ async function injectClaudeDesktop(workspaceId, workspaceRoot) {
161
347
  }
162
348
  // ─── Target: Cursor (global + project) ───────────────────────────────────────
163
349
  async function injectCursor(workspaceId, workspaceRoot) {
164
- const home = node_os.homedir();
165
350
  // Global settings
166
- let globalPath;
167
- if (process.platform === 'darwin') {
168
- globalPath = node_path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json');
169
- }
170
- else if (process.platform === 'win32') {
171
- globalPath = node_path.join(process.env['APPDATA'] ?? '', 'Cursor', 'User', 'globalSettings.json');
172
- }
173
- else {
174
- globalPath = node_path.join(home, '.config', 'Cursor', 'User', 'globalSettings.json');
175
- }
351
+ const globalPath = cursorGlobalConfigPath();
176
352
  try {
177
353
  await node_fs.access(node_path.dirname(globalPath));
178
354
  const current = await readJsonFile(globalPath, {});
@@ -184,7 +360,7 @@ async function injectCursor(workspaceId, workspaceRoot) {
184
360
  log.debug({ action: 'cursor_global_skip' }, 'Cursor not installed — skipping global settings');
185
361
  }
186
362
  // Project-level .cursor/mcp.json
187
- const projectPath = node_path.join(workspaceRoot, '.cursor', 'mcp.json');
363
+ const projectPath = cursorProjectConfigPath(workspaceRoot);
188
364
  try {
189
365
  const current = await readJsonFile(projectPath, {});
190
366
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
@@ -197,7 +373,7 @@ async function injectCursor(workspaceId, workspaceRoot) {
197
373
  }
198
374
  // ─── Target: Cline ────────────────────────────────────────────────────────────
199
375
  async function injectCline(workspaceId, workspaceRoot) {
200
- const configPath = node_path.join(workspaceRoot, '.cline', 'mcp.json');
376
+ const configPath = clineConfigPath(workspaceRoot);
201
377
  try {
202
378
  const current = await readJsonFile(configPath, {});
203
379
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
@@ -210,8 +386,7 @@ async function injectCline(workspaceId, workspaceRoot) {
210
386
  }
211
387
  // ─── Target: Windsurf ─────────────────────────────────────────────────────────
212
388
  async function injectWindsurf(workspaceId, workspaceRoot) {
213
- const home = node_os.homedir();
214
- const configPath = node_path.join(home, '.codeium', 'windsurf', 'mcp_config.json');
389
+ const configPath = windsurfConfigPath();
215
390
  try {
216
391
  await node_fs.access(node_path.dirname(configPath));
217
392
  const current = await readJsonFile(configPath, {});
@@ -224,8 +399,7 @@ async function injectWindsurf(workspaceId, workspaceRoot) {
224
399
  }
225
400
  }
226
401
  async function injectContinue(workspaceId, workspaceRoot) {
227
- const home = node_os.homedir();
228
- const configPath = node_path.join(home, '.continue', 'config.json');
402
+ const configPath = continueConfigPath();
229
403
  try {
230
404
  await node_fs.access(node_path.dirname(configPath));
231
405
  const current = await readJsonFile(configPath, {});
@@ -235,23 +409,12 @@ async function injectContinue(workspaceId, workspaceRoot) {
235
409
  }
236
410
  // Remove any existing intutic entry and add the wrapped one
237
411
  current.mcpServers = current.mcpServers.filter((s) => s.name !== 'intutic');
238
- // Wrap existing non-intutic servers
412
+ // Wrap existing non-intutic servers — routed through the same
413
+ // wrapWithProxy used by every other harness, so the stdio-only guard and
414
+ // --server-name threading stay in one place instead of two.
239
415
  current.mcpServers = current.mcpServers.map((s) => {
240
- if (s.__intutic_wrapped)
241
- return s;
242
- return {
243
- name: s.name,
244
- command: 'node',
245
- args: [
246
- resolveProxyBin(workspaceRoot),
247
- '--workspace-id', workspaceId,
248
- '--',
249
- s.command,
250
- ...(s.args ?? []),
251
- ],
252
- env: { ...(s.env ?? {}), INTUTIC_WORKSPACE_ID: workspaceId },
253
- __intutic_wrapped: true,
254
- };
416
+ const wrapped = wrapWithProxy({ command: s.command, args: s.args, env: s.env, url: s.url, type: s.type, __intutic_wrapped: s.__intutic_wrapped }, workspaceId, workspaceRoot, s.name);
417
+ return { name: s.name, ...wrapped };
255
418
  });
256
419
  // Add Intutic MCP server
257
420
  const entry = buildIntuticMcpEntry(workspaceRoot);
@@ -264,40 +427,132 @@ async function injectContinue(workspaceId, workspaceRoot) {
264
427
  }
265
428
  }
266
429
  // ─── Target: Goose ────────────────────────────────────────────────────────────
430
+ /**
431
+ * The pre-YAML-dep fallback: append-only string injection of an `intutic:`
432
+ * MCP block, never touching (or even parsing) anything else in the file.
433
+ * This is now ONLY the fallback path (see `injectGoose`) for a config.yaml
434
+ * that does not parse as YAML at all — a parser that cannot safely represent
435
+ * the file cannot safely round-trip it either, so falling back to "never
436
+ * touch anything but the block we control" is the safe degrade, not a
437
+ * regression. Never rewraps a pre-existing remote MCP server entry (it never
438
+ * looks at `mcp:` beyond the literal string `intutic:`), which is why
439
+ * `discoverGoose` cannot claim more than it can actually verify when THIS
440
+ * path was the one that ran.
441
+ */
442
+ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, proxyBin) {
443
+ let text = existingYaml;
444
+ const intuticBlock = [
445
+ 'mcp:',
446
+ ' intutic:',
447
+ ` command: node`,
448
+ ` args: [${JSON.stringify(proxyBin)}, "--workspace-id", ${JSON.stringify(workspaceId)}]`,
449
+ ].join('\n');
450
+ if (!text.includes('intutic:')) {
451
+ text = text.trimEnd() + '\n\n' + intuticBlock + '\n';
452
+ }
453
+ if (text === existingYaml) {
454
+ // Already present and unchanged — write-if-changed applies here too
455
+ // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
456
+ return;
457
+ }
458
+ await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
459
+ await node_fs.writeFile(configPath, text, 'utf-8');
460
+ log.info({ action: 'goose_mcp_injected', mode: 'append_only_fallback' }, 'Goose config.yaml updated (append-only fallback)');
461
+ }
462
+ /**
463
+ * Structurally edits `~/.config/goose/config.yaml`'s `mcp:` block via the
464
+ * `yaml` package's `parseDocument` — unlike every other harness here (plain
465
+ * `JSON.parse`/`JSON.stringify`), this is a user's hand-maintained YAML file,
466
+ * and `parseDocument` preserves comments and formatting for everything this
467
+ * function does NOT touch (`Document#setIn` replaces only the specific
468
+ * `['mcp', <name>]` paths whose wrapped shape actually changed).
469
+ *
470
+ * Applies the SAME `wrapWithProxy` convention every other harness uses —
471
+ * stdio entries wrapped with `--`, remote (`url`-keyed) entries wrapped with
472
+ * `--remote-url`/`--remote-transport` — so a remote MCP server declared in
473
+ * Goose's config gets the identical bridge coverage a remote entry in
474
+ * `~/.claude/mcp.json` would get, not a second, divergent convention.
475
+ *
476
+ * Falls back to `injectGooseAppendOnly` (append-only text injection, the
477
+ * pre-existing behaviour) when the file does not parse as YAML at all —
478
+ * logged as `goose_yaml_unparseable` so the fallback is diagnosable rather
479
+ * than silently degrading coverage. A parser that cannot safely represent a
480
+ * malformed file cannot safely round-trip it either; corrupting a user's
481
+ * hand-maintained config is a worse failure than leaving non-`intutic`
482
+ * entries in that file unwrapped for one more sync cycle.
483
+ */
267
484
  async function injectGoose(workspaceId, workspaceRoot) {
268
- const home = node_os.homedir();
269
- const configPath = node_path.join(home, '.config', 'goose', 'config.yaml');
485
+ const configPath = gooseConfigPath();
270
486
  try {
271
487
  await node_fs.access(node_path.dirname(configPath));
272
- let yaml = '';
273
- try {
274
- yaml = await node_fs.readFile(configPath, 'utf-8');
275
- }
276
- catch {
277
- yaml = '';
278
- }
279
- // Inject MCP server block if not present (simple string injection — avoids yaml dep)
280
- const proxyBin = resolveProxyBin(workspaceRoot);
281
- const intuticBlock = [
282
- 'mcp:',
283
- ' intutic:',
284
- ` command: node`,
285
- ` args: [${JSON.stringify(proxyBin)}, "--workspace-id", ${JSON.stringify(workspaceId)}]`,
286
- ].join('\n');
287
- if (!yaml.includes('intutic:')) {
288
- yaml = yaml.trimEnd() + '\n\n' + intuticBlock + '\n';
289
- }
290
- await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
291
- await node_fs.writeFile(configPath, yaml, 'utf-8');
292
- log.info({ action: 'goose_mcp_injected' }, 'Goose config.yaml updated');
293
488
  }
294
489
  catch {
295
490
  log.debug({ action: 'goose_skip' }, 'Goose not installed — skipping');
491
+ return;
492
+ }
493
+ let existingYaml;
494
+ try {
495
+ existingYaml = await node_fs.readFile(configPath, 'utf-8');
496
+ }
497
+ catch {
498
+ existingYaml = '';
499
+ }
500
+ const proxyBin = resolveProxyBin(workspaceRoot);
501
+ let doc;
502
+ try {
503
+ doc = parseDocument(existingYaml);
504
+ if (doc.errors.length > 0) {
505
+ throw doc.errors[0];
506
+ }
507
+ }
508
+ catch (err) {
509
+ log.warn({ action: 'goose_yaml_unparseable', err: err.message }, 'Goose config.yaml did not parse as YAML — falling back to append-only text injection');
510
+ await injectGooseAppendOnly(configPath, existingYaml, workspaceId, proxyBin);
511
+ return;
512
+ }
513
+ const intuticEntry = {
514
+ command: 'node',
515
+ args: [proxyBin, '--workspace-id', workspaceId],
516
+ };
517
+ let changed = false;
518
+ const mcpNode = doc.get('mcp');
519
+ const mcpIsMap = isMap(mcpNode);
520
+ const servers = mcpIsMap
521
+ ? (doc.toJS().mcp ?? {})
522
+ : {};
523
+ if (mcpNode !== undefined && !mcpIsMap) {
524
+ // `mcp:` exists but isn't a mapping (e.g. `mcp: null`, `mcp: []`,
525
+ // `mcp: "x"`) — nothing safe to preserve there. Replace with a fresh
526
+ // empty map so the `setIn` calls below have a collection to write into
527
+ // (`Document#setIn` throws on a non-collection intermediate path).
528
+ doc.set('mcp', {});
529
+ changed = true;
530
+ }
531
+ if (!isDeepStrictEqual(servers['intutic'], intuticEntry)) {
532
+ doc.setIn(['mcp', 'intutic'], intuticEntry);
533
+ changed = true;
296
534
  }
535
+ for (const [name, entry] of Object.entries(servers)) {
536
+ if (name === 'intutic')
537
+ continue;
538
+ const wrapped = wrapWithProxy(entry, workspaceId, workspaceRoot, name);
539
+ if (!isDeepStrictEqual(wrapped, entry)) {
540
+ doc.setIn(['mcp', name], wrapped);
541
+ changed = true;
542
+ }
543
+ }
544
+ if (!changed) {
545
+ // Already present and unchanged — write-if-changed applies here too
546
+ // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
547
+ return;
548
+ }
549
+ await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
550
+ await node_fs.writeFile(configPath, doc.toString(), 'utf-8');
551
+ log.info({ action: 'goose_mcp_injected', mode: 'yaml' }, 'Goose config.yaml updated (structural YAML edit)');
297
552
  }
298
553
  // ─── Target: OpenHands ────────────────────────────────────────────────────────
299
554
  async function injectOpenHands(workspaceId, workspaceRoot) {
300
- const configPath = node_path.join(workspaceRoot, '.openhands', 'mcp.json');
555
+ const configPath = openHandsConfigPath(workspaceRoot);
301
556
  try {
302
557
  const current = await readJsonFile(configPath, {});
303
558
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
@@ -308,12 +563,377 @@ async function injectOpenHands(workspaceId, workspaceRoot) {
308
563
  log.debug({ action: 'openhands_mcp_skip', err: err.message }, 'OpenHands config not found — skipping');
309
564
  }
310
565
  }
566
+ // ─── Target: Muse Code ────────────────────────────────────────────────────────
567
+ /**
568
+ * `~/.config/muse/settings.json` carries `schema_version` (documented as a
569
+ * mandatory field — defaulted to `1` if this is genuinely the first thing to
570
+ * ever write the file), `mcp_servers` (this function's target), and
571
+ * `managed_hooks_path` (written by `museHooks.ts`). Read-modify-write, same as
572
+ * every other JSON-object harness here — `mergeMuseSettingsJson` in
573
+ * `museHooks.ts` performs the identical pattern for the hooks half of this
574
+ * same file, sequentially, the same way `gooseHooks.ts`'s `mergeGooseConfig`
575
+ * and `injectGoose` both write `config.yaml` without coordinating.
576
+ */
577
+ async function injectMuse(workspaceId, workspaceRoot) {
578
+ const configPath = museConfigPath();
579
+ try {
580
+ await node_fs.access(node_path.dirname(configPath));
581
+ }
582
+ catch {
583
+ log.debug({ action: 'muse_skip' }, 'Muse Code not installed — skipping');
584
+ return;
585
+ }
586
+ const current = await readJsonFile(configPath, {});
587
+ current.mcp_servers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcp_servers ?? {}) }, workspaceId, workspaceRoot);
588
+ if (current.schema_version === undefined)
589
+ current.schema_version = 1;
590
+ await writeJsonFile(configPath, current);
591
+ log.info({ action: 'muse_mcp_injected' }, 'Muse Code ~/.config/muse/settings.json mcp_servers updated');
592
+ }
593
+ /**
594
+ * The pre-parser fallback for a `config.toml` that does not parse as TOML at
595
+ * all — append an `[mcp_servers.intutic]` block only, mirroring
596
+ * `injectGooseAppendOnly`'s exact reasoning: a parser that cannot safely
597
+ * represent a malformed file cannot safely round-trip it either, so this
598
+ * never touches (or even parses) anything else already in the file. Never
599
+ * rewraps a pre-existing `[mcp_servers.*]` entry when this path runs — same
600
+ * honest limit `injectGooseAppendOnly`/`discoverGooseLineScanFallback`
601
+ * document for their own fallback.
602
+ */
603
+ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin) {
604
+ let text = existingToml;
605
+ if (!text.includes('[mcp_servers.intutic]') && !text.includes('[mcp_servers."intutic"]')) {
606
+ const block = [
607
+ '[mcp_servers.intutic]',
608
+ 'command = "node"',
609
+ `args = [${JSON.stringify(proxyBin)}, "--workspace-id", ${JSON.stringify(workspaceId)}]`,
610
+ ].join('\n');
611
+ text = text.trimEnd() + '\n\n' + block + '\n';
612
+ }
613
+ if (text === existingToml)
614
+ return;
615
+ await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
616
+ await node_fs.writeFile(configPath, text, 'utf-8');
617
+ log.info({ action: 'grok_mcp_injected', mode: 'append_only_fallback' }, 'Grok config.toml updated (append-only fallback)');
618
+ }
619
+ /**
620
+ * Structurally edits `config.toml`'s `[mcp_servers.*]` tables via `smol-toml`
621
+ * — the TOML counterpart of `injectGoose`'s YAML edit above, applying the
622
+ * SAME `wrapWithProxy` convention every other harness uses (stdio entries
623
+ * wrapped with `--`, remote/`url`-keyed entries wrapped with
624
+ * `--remote-url`/`--remote-transport`) so a remote MCP server declared for
625
+ * Grok Build gets identical bridge coverage to one in `~/.claude/mcp.json`,
626
+ * not a second, divergent convention.
627
+ *
628
+ * Unlike `injectGoose`'s `Document#setIn` (which preserves comments/
629
+ * formatting for untouched YAML), `smol-toml` round-trips through a plain
630
+ * object — no TOML-parsing dependency preserving comments existed anywhere
631
+ * in this monorepo to reuse (checked; see grokHooks.ts's module doc for the
632
+ * same finding), so a table this function DOES touch loses any inline
633
+ * comments it carried. Tables it does not touch (`[model.*]`, anything
634
+ * else) are written back byte-for-byte equal in content, just
635
+ * re-serialized. Falls back to `injectGrokConfigAppendOnly` when the file
636
+ * does not parse as TOML at all.
637
+ *
638
+ * @param configPath - Absolute path to the `config.toml` to edit (project or
639
+ * user level — this function is level-agnostic; the caller supplies both).
640
+ */
641
+ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
642
+ let existingToml = '';
643
+ try {
644
+ existingToml = await node_fs.readFile(configPath, 'utf-8');
645
+ }
646
+ catch {
647
+ // Deliberate fail-open: no config.toml yet, or unreadable — falls
648
+ // through with '' so a fresh file is written below.
649
+ }
650
+ const proxyBin = resolveProxyBin(workspaceRoot);
651
+ let doc;
652
+ try {
653
+ doc = existingToml.trim() ? parseToml(existingToml) : {};
654
+ }
655
+ catch (err) {
656
+ log.warn({ action: 'grok_toml_unparseable', path: configPath, err: err.message }, 'Grok config.toml did not parse as TOML — falling back to append-only text injection');
657
+ await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin);
658
+ return;
659
+ }
660
+ const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' ? doc.mcp_servers : {};
661
+ const intuticEntry = buildIntuticMcpEntry(workspaceRoot);
662
+ let changed = false;
663
+ if (!isDeepStrictEqual(servers['intutic'], intuticEntry)) {
664
+ servers['intutic'] = intuticEntry;
665
+ changed = true;
666
+ }
667
+ for (const [name, entry] of Object.entries(servers)) {
668
+ if (name === 'intutic')
669
+ continue;
670
+ const wrapped = wrapWithProxy(entry, workspaceId, workspaceRoot, name);
671
+ if (!isDeepStrictEqual(wrapped, entry)) {
672
+ servers[name] = wrapped;
673
+ changed = true;
674
+ }
675
+ }
676
+ if (!changed) {
677
+ // write-if-changed, same reasoning as writeJsonFile/injectGoose: this
678
+ // runs every sync cycle, so a no-op merge must not churn the file's mtime.
679
+ return;
680
+ }
681
+ doc.mcp_servers = servers;
682
+ await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
683
+ const tmp = configPath + '.intutic-tmp';
684
+ await node_fs.writeFile(tmp, stringifyToml(doc), 'utf-8');
685
+ await node_fs.rename(tmp, configPath);
686
+ log.info({ action: 'grok_mcp_injected', path: configPath, mode: 'toml' }, 'Grok config.toml updated (structural TOML edit)');
687
+ }
688
+ async function injectGrok(workspaceId, workspaceRoot) {
689
+ await injectGrokConfig(grokProjectConfigPath(workspaceRoot), workspaceId, workspaceRoot);
690
+ await injectGrokConfig(grokUserConfigPath(), workspaceId, workspaceRoot);
691
+ }
692
+ // ─── Discovery (read-only — writes nothing) ───────────────────────────────────
693
+ /** Classify a raw server entry's transport + wrapped status, tolerant of any shape. */
694
+ function classifyEntry(entry) {
695
+ if (!entry || typeof entry !== 'object')
696
+ return { transport: 'unknown', wrapped: false };
697
+ const e = entry;
698
+ const wrapped = e.__intutic_wrapped === true;
699
+ // A wrapped remote entry's TOP-LEVEL shape is stdio (`command: 'node'`,
700
+ // `args: […]`) — that IS the bridge, not a lie, but it would misreport
701
+ // every wrapped remote server as though wrapping made it stdio if checked
702
+ // first. `__intutic_original` (see `wrapWithProxy`) records what was
703
+ // actually wrapped, so this checks it BEFORE falling through to the
704
+ // generic `command`/`url` shape check below — visibility must stay honest.
705
+ const original = e.__intutic_original;
706
+ if (wrapped && original !== null && typeof original === 'object') {
707
+ const o = original;
708
+ if (typeof o.url === 'string') {
709
+ const type = typeof o.type === 'string' ? o.type.toLowerCase() : '';
710
+ return { transport: type === 'sse' ? 'sse' : 'http', wrapped: true };
711
+ }
712
+ }
713
+ if (typeof e.command === 'string')
714
+ return { transport: 'stdio', wrapped };
715
+ if (typeof e.url === 'string') {
716
+ const type = typeof e.type === 'string' ? e.type.toLowerCase() : '';
717
+ return { transport: type === 'sse' ? 'sse' : 'http', wrapped };
718
+ }
719
+ return { transport: 'unknown', wrapped };
720
+ }
721
+ /** Read a `{ mcpServers: { [name]: entry } }` shaped config file (the shape
722
+ * shared by Claude Code, Claude Desktop, both Cursor configs, Cline,
723
+ * Windsurf and OpenHands) without writing anything back. */
724
+ async function discoverJsonObjectHarness(harness, filePath) {
725
+ if (!existsSync(filePath))
726
+ return [];
727
+ const current = await readJsonFile(filePath, {});
728
+ const out = [];
729
+ for (const [name, entry] of Object.entries(current.mcpServers ?? {})) {
730
+ const { transport, wrapped } = classifyEntry(entry);
731
+ out.push({ server: name, harness, transport, wrapped });
732
+ }
733
+ return out;
734
+ }
735
+ /** Muse Code's `~/.config/muse/settings.json` keeps servers under `mcp_servers`,
736
+ * not `mcpServers` — otherwise the same JSON-map shape `discoverJsonObjectHarness`
737
+ * reads, so this is that function with one key renamed rather than a new format. */
738
+ async function discoverMuse() {
739
+ const filePath = museConfigPath();
740
+ if (!existsSync(filePath))
741
+ return [];
742
+ const current = await readJsonFile(filePath, {});
743
+ const out = [];
744
+ for (const [name, entry] of Object.entries(current.mcp_servers ?? {})) {
745
+ const { transport, wrapped } = classifyEntry(entry);
746
+ out.push({ server: name, harness: 'muse-code', transport, wrapped });
747
+ }
748
+ return out;
749
+ }
750
+ /** Continue's `~/.continue/config.json` keeps `mcpServers` as an array, not a map. */
751
+ async function discoverContinue() {
752
+ const filePath = continueConfigPath();
753
+ if (!existsSync(filePath))
754
+ return [];
755
+ const current = await readJsonFile(filePath, {});
756
+ const out = [];
757
+ for (const s of current.mcpServers ?? []) {
758
+ const { transport, wrapped } = classifyEntry(s);
759
+ out.push({ server: s.name, harness: 'continue', transport, wrapped });
760
+ }
761
+ return out;
762
+ }
763
+ /**
764
+ * Goose's `~/.config/goose/config.yaml` is YAML. `injectGoose` now
765
+ * structurally parses and edits it (via the `yaml` package), and — per
766
+ * `wrapWithProxy` — DOES rewrap a pre-existing, non-`intutic` server entry
767
+ * (both stdio and remote), so a blanket "nothing discovered here is ever
768
+ * `wrapped`" claim is no longer true. Discovery mirrors the writer: a real
769
+ * YAML parse via `classifyEntry` (the SAME classifier every JSON-shaped
770
+ * harness below uses), reporting each entry's true transport and wrapped
771
+ * status honestly, exactly like every other harness in this file.
772
+ *
773
+ * Falls back to the old line-scan (2-space-indented `<name>:` keys under a
774
+ * top-level `mcp:` block) when the file does not parse as YAML — matching
775
+ * `injectGoose`'s own fallback to append-only injection for the same
776
+ * malformed-file case. The line-scan fallback still cannot see a server
777
+ * nested any other way (anchors, flow style, deeper indentation) and still
778
+ * never reports one `wrapped`, because it never inspects a value, only a key
779
+ * — an honest limit of a best-effort fallback over a file real parsing
780
+ * already gave up on.
781
+ */
782
+ async function discoverGoose() {
783
+ const filePath = gooseConfigPath();
784
+ let rawYaml;
785
+ try {
786
+ rawYaml = await node_fs.readFile(filePath, 'utf-8');
787
+ }
788
+ catch {
789
+ return [];
790
+ }
791
+ try {
792
+ const doc = parseDocument(rawYaml);
793
+ if (doc.errors.length > 0)
794
+ throw doc.errors[0];
795
+ const mcpNode = doc.get('mcp');
796
+ if (!isMap(mcpNode))
797
+ return [];
798
+ // Not filtering out 'intutic' here — `discoverMcpServers` below does that
799
+ // once, for every harness alike (see its own doc comment), the same
800
+ // convention `discoverJsonObjectHarness`/`discoverContinue` already rely on.
801
+ const servers = doc.toJS().mcp ?? {};
802
+ const out = [];
803
+ for (const [name, entry] of Object.entries(servers)) {
804
+ const { transport, wrapped } = classifyEntry(entry);
805
+ out.push({ server: name, harness: 'goose', transport, wrapped });
806
+ }
807
+ return out;
808
+ }
809
+ catch {
810
+ return discoverGooseLineScanFallback(rawYaml);
811
+ }
812
+ }
813
+ /** Best-effort fallback for a `config.yaml` that does not parse as YAML —
814
+ * see `discoverGoose`'s doc comment for why this exists and what it cannot see. */
815
+ function discoverGooseLineScanFallback(rawYaml) {
816
+ const out = [];
817
+ let inMcpBlock = false;
818
+ for (const line of rawYaml.split('\n')) {
819
+ if (/^mcp:\s*$/.test(line)) {
820
+ inMcpBlock = true;
821
+ continue;
822
+ }
823
+ if (!inMcpBlock)
824
+ continue;
825
+ if (/^\S/.test(line)) {
826
+ // Dedented back to column 0 — the mcp: block ended.
827
+ inMcpBlock = false;
828
+ continue;
829
+ }
830
+ const m = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
831
+ if (m) {
832
+ out.push({ server: m[1], harness: 'goose', transport: 'stdio', wrapped: false });
833
+ }
834
+ }
835
+ return out;
836
+ }
837
+ /**
838
+ * Grok Build's `config.toml`, discovered read-only. Mirrors `injectGrokConfig`:
839
+ * a real structural TOML parse via `classifyEntry` (the SAME classifier every
840
+ * other harness below uses), so a wrapped `[mcp_servers.*]` entry reports its
841
+ * true pre-wrap transport honestly. Falls back to a best-effort line-scan
842
+ * (top-level keys under `[mcp_servers.` — never reports one `wrapped`, the
843
+ * same honest limit `discoverGooseLineScanFallback` documents for its own
844
+ * fallback) when the file does not parse as TOML at all.
845
+ */
846
+ async function discoverGrokConfig(configPath) {
847
+ let rawToml;
848
+ try {
849
+ rawToml = await node_fs.readFile(configPath, 'utf-8');
850
+ }
851
+ catch {
852
+ return [];
853
+ }
854
+ try {
855
+ const doc = parseToml(rawToml);
856
+ const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' ? doc.mcp_servers : {};
857
+ const out = [];
858
+ for (const [name, entry] of Object.entries(servers)) {
859
+ const { transport, wrapped } = classifyEntry(entry);
860
+ out.push({ server: name, harness: 'grok', transport, wrapped });
861
+ }
862
+ return out;
863
+ }
864
+ catch {
865
+ return discoverGrokLineScanFallback(rawToml);
866
+ }
867
+ }
868
+ /** Best-effort fallback for a `config.toml` that does not parse as TOML —
869
+ * see `discoverGrokConfig`'s doc comment for why this exists and what it
870
+ * cannot see. */
871
+ function discoverGrokLineScanFallback(rawToml) {
872
+ const out = [];
873
+ for (const line of rawToml.split('\n')) {
874
+ const m = line.match(/^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*$/);
875
+ if (m) {
876
+ out.push({ server: (m[1] ?? m[2] ?? m[3]), harness: 'grok', transport: 'stdio', wrapped: false });
877
+ }
878
+ }
879
+ return out;
880
+ }
881
+ async function discoverGrok(workspaceRoot) {
882
+ const [project, user] = await Promise.all([
883
+ discoverGrokConfig(grokProjectConfigPath(workspaceRoot)),
884
+ discoverGrokConfig(grokUserConfigPath()),
885
+ ]);
886
+ return [...project, ...user];
887
+ }
888
+ /**
889
+ * Discover every MCP server declared in any harness config this daemon knows
890
+ * how to parse — the same 12 config paths / 10 harnesses `injectMcpServer`
891
+ * wraps — without writing anything. Used for reporting (agentReporter's
892
+ * `mcp_tools` facet) so visibility does not silently lag behind whatever
893
+ * `injectMcpServer` was last run against.
894
+ *
895
+ * The `intutic` entry itself is excluded from the result: `wrapAllServers`
896
+ * deliberately never wraps it (wrapping our own governance server through
897
+ * itself is circular), so it would always report `wrapped: false` and drag
898
+ * down a wrapped-ratio reading of a workspace that is, in fact, fully covered.
899
+ *
900
+ * A server declared in BOTH `.cursor/mcp.json` (Grok Build's compat read
901
+ * path, already discovered under `harness: 'cursor'` below) AND Grok
902
+ * Build's own `[mcp_servers.*]` table legitimately produces two rows here —
903
+ * one per harness config file — not one merged row. See this module's
904
+ * top-of-file doc comment ("Grok Build's compat-path overlap — dedup, not a
905
+ * bug") for why that is correct, not a double-count to fix.
906
+ */
907
+ export async function discoverMcpServers(workspaceRoot) {
908
+ const results = await Promise.all([
909
+ discoverJsonObjectHarness('claude-code', claudeCodeConfigPath()),
910
+ discoverJsonObjectHarness('claude-desktop', claudeDesktopConfigPath()),
911
+ discoverJsonObjectHarness('cursor', cursorGlobalConfigPath()),
912
+ discoverJsonObjectHarness('cursor', cursorProjectConfigPath(workspaceRoot)),
913
+ discoverJsonObjectHarness('cline', clineConfigPath(workspaceRoot)),
914
+ discoverJsonObjectHarness('windsurf', windsurfConfigPath()),
915
+ discoverJsonObjectHarness('openhands', openHandsConfigPath(workspaceRoot)),
916
+ discoverContinue(),
917
+ discoverGoose(),
918
+ discoverMuse(),
919
+ discoverGrok(workspaceRoot),
920
+ ]);
921
+ return results.flat().filter((s) => s.server !== 'intutic');
922
+ }
311
923
  // ─── Public API ───────────────────────────────────────────────────────────────
312
924
  /**
313
925
  * Injects and proxy-wraps the Intutic MCP server configuration into all supported harnesses.
314
926
  *
315
927
  * Non-fatal: a failure in one harness does not prevent other harnesses from being updated.
316
928
  *
929
+ * Called from two places by design: once from `intutic connect` (tools/cli)
930
+ * for immediate effect, and once per sync-loop iteration
931
+ * (services/sync-daemon/src/syncLoop.ts) so it is a continuous invariant
932
+ * rather than a one-shot — a server a user adds after their first `connect`
933
+ * still gets wrapped on the next cycle. Safe to call every cycle because
934
+ * `writeJsonFile` is write-if-changed: an already-wrapped, unchanged config
935
+ * writes zero bytes.
936
+ *
317
937
  * @param workspaceId - The workspace ID for policy lookups and event attribution.
318
938
  * @param workspaceRoot - Absolute path to the project workspace root.
319
939
  */
@@ -328,6 +948,8 @@ export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown') {
328
948
  injectContinue(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'continue' }, 'MCP injection failed')),
329
949
  injectGoose(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'goose' }, 'MCP injection failed')),
330
950
  injectOpenHands(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'openhands' }, 'MCP injection failed')),
951
+ injectMuse(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'muse-code' }, 'MCP injection failed')),
952
+ injectGrok(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'grok' }, 'MCP injection failed')),
331
953
  ]);
332
954
  log.info({ action: 'mcp_inject_complete', workspaceRoot }, 'MCP server injection complete');
333
955
  }