context-mode 1.0.169 → 2.0.0-next.5

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 (379) hide show
  1. package/.claude-plugin/marketplace.json +4 -22
  2. package/.claude-plugin/plugin.json +9 -24
  3. package/SHA256SUMS +31 -0
  4. package/claude-code/skills/ctx-doctor/SKILL.md +6 -0
  5. package/claude-code/skills/ctx-stats/SKILL.md +6 -0
  6. package/claude-code/skills/ctx-upgrade/SKILL.md +6 -0
  7. package/cli.mjs +6744 -0
  8. package/copilot/plugin.json +14 -0
  9. package/fixtures/hook-roundtrip.json +22 -0
  10. package/gemini-extension.json +14 -0
  11. package/hook.mjs +202 -0
  12. package/hooks/claude-code.json +50 -0
  13. package/hooks/codex.json +50 -0
  14. package/hooks/copilot-cli.json +33 -0
  15. package/hooks/hooks.json +16 -111
  16. package/hooks/kiro.json +69 -0
  17. package/hooks/qwen.json +50 -0
  18. package/hooks/vscode.json +40 -0
  19. package/hooks/zcode.json +53 -0
  20. package/opencode/plugin.js +22456 -0
  21. package/package.json +29 -114
  22. package/pi/index.js +50841 -0
  23. package/pi/skills/ctx-doctor/SKILL.md +6 -0
  24. package/pi/skills/ctx-stats/SKILL.md +6 -0
  25. package/pi/skills/ctx-upgrade/SKILL.md +6 -0
  26. package/qwen-extension.json +15 -0
  27. package/run.mjs +5633 -0
  28. package/search.mjs +5633 -0
  29. package/server.mjs +48198 -0
  30. package/skills/ctx-doctor/SKILL.md +3 -19
  31. package/skills/ctx-stats/SKILL.md +3 -23
  32. package/skills/ctx-upgrade/SKILL.md +3 -28
  33. package/.codex-plugin/hooks.json +0 -65
  34. package/.codex-plugin/mcp.json +0 -13
  35. package/.codex-plugin/plugin.json +0 -31
  36. package/.openclaw-plugin/index.ts +0 -12
  37. package/.openclaw-plugin/openclaw.plugin.json +0 -38
  38. package/.openclaw-plugin/package.json +0 -28
  39. package/LICENSE +0 -94
  40. package/README.md +0 -1619
  41. package/bin/statusline.mjs +0 -378
  42. package/build/adapters/antigravity/index.d.ts +0 -51
  43. package/build/adapters/antigravity/index.js +0 -169
  44. package/build/adapters/antigravity-cli/index.d.ts +0 -51
  45. package/build/adapters/antigravity-cli/index.js +0 -342
  46. package/build/adapters/base.d.ts +0 -92
  47. package/build/adapters/base.js +0 -135
  48. package/build/adapters/claude-code/config.d.ts +0 -8
  49. package/build/adapters/claude-code/config.js +0 -8
  50. package/build/adapters/claude-code/hooks.d.ts +0 -106
  51. package/build/adapters/claude-code/hooks.js +0 -183
  52. package/build/adapters/claude-code/index.d.ts +0 -80
  53. package/build/adapters/claude-code/index.js +0 -569
  54. package/build/adapters/claude-code-base.d.ts +0 -49
  55. package/build/adapters/claude-code-base.js +0 -113
  56. package/build/adapters/client-map.d.ts +0 -10
  57. package/build/adapters/client-map.js +0 -42
  58. package/build/adapters/codex/config.d.ts +0 -8
  59. package/build/adapters/codex/config.js +0 -8
  60. package/build/adapters/codex/hooks.d.ts +0 -55
  61. package/build/adapters/codex/hooks.js +0 -64
  62. package/build/adapters/codex/index.d.ts +0 -87
  63. package/build/adapters/codex/index.js +0 -992
  64. package/build/adapters/codex/paths.d.ts +0 -1
  65. package/build/adapters/codex/paths.js +0 -12
  66. package/build/adapters/codex/usage.d.ts +0 -107
  67. package/build/adapters/codex/usage.js +0 -227
  68. package/build/adapters/copilot-base.d.ts +0 -94
  69. package/build/adapters/copilot-base.js +0 -297
  70. package/build/adapters/copilot-cli/hooks.d.ts +0 -33
  71. package/build/adapters/copilot-cli/hooks.js +0 -64
  72. package/build/adapters/copilot-cli/index.d.ts +0 -48
  73. package/build/adapters/copilot-cli/index.js +0 -341
  74. package/build/adapters/cursor/config.d.ts +0 -4
  75. package/build/adapters/cursor/config.js +0 -4
  76. package/build/adapters/cursor/hooks.d.ts +0 -55
  77. package/build/adapters/cursor/hooks.js +0 -82
  78. package/build/adapters/cursor/index.d.ts +0 -64
  79. package/build/adapters/cursor/index.js +0 -517
  80. package/build/adapters/detect.d.ts +0 -136
  81. package/build/adapters/detect.js +0 -623
  82. package/build/adapters/gemini-cli/config.d.ts +0 -8
  83. package/build/adapters/gemini-cli/config.js +0 -8
  84. package/build/adapters/gemini-cli/hooks.d.ts +0 -83
  85. package/build/adapters/gemini-cli/hooks.js +0 -115
  86. package/build/adapters/gemini-cli/index.d.ts +0 -80
  87. package/build/adapters/gemini-cli/index.js +0 -483
  88. package/build/adapters/jetbrains-copilot/config.d.ts +0 -8
  89. package/build/adapters/jetbrains-copilot/config.js +0 -8
  90. package/build/adapters/jetbrains-copilot/hooks.d.ts +0 -59
  91. package/build/adapters/jetbrains-copilot/hooks.js +0 -87
  92. package/build/adapters/jetbrains-copilot/index.d.ts +0 -31
  93. package/build/adapters/jetbrains-copilot/index.js +0 -131
  94. package/build/adapters/kimi/config.d.ts +0 -8
  95. package/build/adapters/kimi/config.js +0 -8
  96. package/build/adapters/kimi/hooks.d.ts +0 -28
  97. package/build/adapters/kimi/hooks.js +0 -34
  98. package/build/adapters/kimi/index.d.ts +0 -66
  99. package/build/adapters/kimi/index.js +0 -537
  100. package/build/adapters/kimi/paths.d.ts +0 -21
  101. package/build/adapters/kimi/paths.js +0 -52
  102. package/build/adapters/kimi/usage.d.ts +0 -82
  103. package/build/adapters/kimi/usage.js +0 -217
  104. package/build/adapters/kiro/hooks.d.ts +0 -61
  105. package/build/adapters/kiro/hooks.js +0 -94
  106. package/build/adapters/kiro/index.d.ts +0 -61
  107. package/build/adapters/kiro/index.js +0 -301
  108. package/build/adapters/omp/index.d.ts +0 -65
  109. package/build/adapters/omp/index.js +0 -182
  110. package/build/adapters/omp/plugin.d.ts +0 -81
  111. package/build/adapters/omp/plugin.js +0 -331
  112. package/build/adapters/omp/usage.d.ts +0 -49
  113. package/build/adapters/omp/usage.js +0 -110
  114. package/build/adapters/openclaw/config.d.ts +0 -8
  115. package/build/adapters/openclaw/config.js +0 -8
  116. package/build/adapters/openclaw/hooks.d.ts +0 -50
  117. package/build/adapters/openclaw/hooks.js +0 -61
  118. package/build/adapters/openclaw/index.d.ts +0 -74
  119. package/build/adapters/openclaw/index.js +0 -439
  120. package/build/adapters/openclaw/mcp-tools.d.ts +0 -54
  121. package/build/adapters/openclaw/mcp-tools.js +0 -207
  122. package/build/adapters/openclaw/plugin.d.ts +0 -141
  123. package/build/adapters/openclaw/plugin.js +0 -818
  124. package/build/adapters/openclaw/session-db.d.ts +0 -55
  125. package/build/adapters/openclaw/session-db.js +0 -88
  126. package/build/adapters/openclaw/usage.d.ts +0 -34
  127. package/build/adapters/openclaw/usage.js +0 -52
  128. package/build/adapters/openclaw/workspace-router.d.ts +0 -29
  129. package/build/adapters/openclaw/workspace-router.js +0 -64
  130. package/build/adapters/opencode/config.d.ts +0 -8
  131. package/build/adapters/opencode/config.js +0 -8
  132. package/build/adapters/opencode/hooks.d.ts +0 -38
  133. package/build/adapters/opencode/hooks.js +0 -50
  134. package/build/adapters/opencode/index.d.ts +0 -67
  135. package/build/adapters/opencode/index.js +0 -463
  136. package/build/adapters/opencode/plugin.d.ts +0 -184
  137. package/build/adapters/opencode/plugin.js +0 -558
  138. package/build/adapters/opencode/zod3tov4.d.ts +0 -1
  139. package/build/adapters/opencode/zod3tov4.js +0 -132
  140. package/build/adapters/pi/extension.d.ts +0 -81
  141. package/build/adapters/pi/extension.js +0 -861
  142. package/build/adapters/pi/index.d.ts +0 -57
  143. package/build/adapters/pi/index.js +0 -173
  144. package/build/adapters/pi/mcp-bridge.d.ts +0 -268
  145. package/build/adapters/pi/mcp-bridge.js +0 -894
  146. package/build/adapters/qwen-code/hooks.d.ts +0 -26
  147. package/build/adapters/qwen-code/hooks.js +0 -29
  148. package/build/adapters/qwen-code/index.d.ts +0 -40
  149. package/build/adapters/qwen-code/index.js +0 -361
  150. package/build/adapters/qwen-code/usage.d.ts +0 -90
  151. package/build/adapters/qwen-code/usage.js +0 -222
  152. package/build/adapters/types.d.ts +0 -360
  153. package/build/adapters/types.js +0 -118
  154. package/build/adapters/vscode-copilot/config.d.ts +0 -8
  155. package/build/adapters/vscode-copilot/config.js +0 -8
  156. package/build/adapters/vscode-copilot/hooks.d.ts +0 -71
  157. package/build/adapters/vscode-copilot/hooks.js +0 -98
  158. package/build/adapters/vscode-copilot/index.d.ts +0 -32
  159. package/build/adapters/vscode-copilot/index.js +0 -222
  160. package/build/adapters/zed/index.d.ts +0 -40
  161. package/build/adapters/zed/index.js +0 -163
  162. package/build/cli.d.ts +0 -34
  163. package/build/cli.js +0 -1827
  164. package/build/db-base.d.ts +0 -179
  165. package/build/db-base.js +0 -569
  166. package/build/executor.d.ts +0 -72
  167. package/build/executor.js +0 -678
  168. package/build/exit-classify.d.ts +0 -19
  169. package/build/exit-classify.js +0 -12
  170. package/build/fetch-cache.d.ts +0 -13
  171. package/build/fetch-cache.js +0 -15
  172. package/build/lifecycle.d.ts +0 -124
  173. package/build/lifecycle.js +0 -278
  174. package/build/runPool.d.ts +0 -36
  175. package/build/runPool.js +0 -51
  176. package/build/runtime.d.ts +0 -71
  177. package/build/runtime.js +0 -659
  178. package/build/search/auto-memory.d.ts +0 -47
  179. package/build/search/auto-memory.js +0 -170
  180. package/build/search/ctx-search-schema.d.ts +0 -90
  181. package/build/search/ctx-search-schema.js +0 -135
  182. package/build/search/flood-guard.d.ts +0 -57
  183. package/build/search/flood-guard.js +0 -80
  184. package/build/search/unified.d.ts +0 -56
  185. package/build/search/unified.js +0 -104
  186. package/build/security.d.ts +0 -206
  187. package/build/security.js +0 -780
  188. package/build/server.d.ts +0 -184
  189. package/build/server.js +0 -4563
  190. package/build/session/analytics.d.ts +0 -773
  191. package/build/session/analytics.js +0 -2420
  192. package/build/session/db.d.ts +0 -468
  193. package/build/session/db.js +0 -1308
  194. package/build/session/error-classifier.d.ts +0 -87
  195. package/build/session/error-classifier.js +0 -303
  196. package/build/session/event-emit.d.ts +0 -48
  197. package/build/session/event-emit.js +0 -101
  198. package/build/session/extract.d.ts +0 -333
  199. package/build/session/extract.js +0 -2688
  200. package/build/session/model-prices.json +0 -429
  201. package/build/session/persist-tool-calls.d.ts +0 -54
  202. package/build/session/persist-tool-calls.js +0 -105
  203. package/build/session/pricing.d.ts +0 -64
  204. package/build/session/pricing.js +0 -151
  205. package/build/session/project-attribution.d.ts +0 -73
  206. package/build/session/project-attribution.js +0 -245
  207. package/build/session/purge.d.ts +0 -138
  208. package/build/session/purge.js +0 -240
  209. package/build/session/retrieval-marker.d.ts +0 -39
  210. package/build/session/retrieval-marker.js +0 -65
  211. package/build/session/snapshot.d.ts +0 -46
  212. package/build/session/snapshot.js +0 -532
  213. package/build/store-directory.d.ts +0 -56
  214. package/build/store-directory.js +0 -254
  215. package/build/store.d.ts +0 -147
  216. package/build/store.js +0 -1714
  217. package/build/truncate.d.ts +0 -57
  218. package/build/truncate.js +0 -142
  219. package/build/types.d.ts +0 -117
  220. package/build/types.js +0 -20
  221. package/build/util/claude-config.d.ts +0 -32
  222. package/build/util/claude-config.js +0 -84
  223. package/build/util/hook-config.d.ts +0 -27
  224. package/build/util/hook-config.js +0 -76
  225. package/build/util/jsonc.d.ts +0 -14
  226. package/build/util/jsonc.js +0 -104
  227. package/build/util/plugin-cache-integrity.d.ts +0 -51
  228. package/build/util/plugin-cache-integrity.js +0 -146
  229. package/build/util/project-dir.d.ts +0 -149
  230. package/build/util/project-dir.js +0 -340
  231. package/build/util/sibling-mcp.d.ts +0 -79
  232. package/build/util/sibling-mcp.js +0 -181
  233. package/cli.bundle.mjs +0 -1236
  234. package/configs/antigravity/GEMINI.md +0 -68
  235. package/configs/antigravity/mcp_config.json +0 -7
  236. package/configs/antigravity-cli/hooks/hooks.json +0 -37
  237. package/configs/antigravity-cli/hooks.json +0 -37
  238. package/configs/antigravity-cli/mcp_config.json +0 -10
  239. package/configs/antigravity-cli/plugin.json +0 -14
  240. package/configs/antigravity-cli/rules/context-mode.md +0 -77
  241. package/configs/antigravity-cli/skills/context-mode/SKILL.md +0 -77
  242. package/configs/claude-code/CLAUDE.md +0 -91
  243. package/configs/codex/AGENTS.md +0 -99
  244. package/configs/codex/config.toml +0 -8
  245. package/configs/codex/hooks.json +0 -47
  246. package/configs/copilot-cli/.github/plugin/plugin.json +0 -23
  247. package/configs/copilot-cli/.mcp.json +0 -12
  248. package/configs/copilot-cli/README.md +0 -47
  249. package/configs/copilot-cli/hooks.json +0 -41
  250. package/configs/copilot-cli/skills/context-mode/SKILL.md +0 -38
  251. package/configs/cursor/context-mode.mdc +0 -76
  252. package/configs/cursor/hooks.json +0 -21
  253. package/configs/cursor/mcp.json +0 -7
  254. package/configs/gemini-cli/GEMINI.md +0 -88
  255. package/configs/gemini-cli/mcp.json +0 -7
  256. package/configs/gemini-cli/settings.json +0 -60
  257. package/configs/jetbrains-copilot/copilot-instructions.md +0 -80
  258. package/configs/jetbrains-copilot/hooks.json +0 -16
  259. package/configs/jetbrains-copilot/mcp.json +0 -7
  260. package/configs/kilo/AGENTS.md +0 -84
  261. package/configs/kilo/kilo.json +0 -6
  262. package/configs/kimi/hooks.json +0 -54
  263. package/configs/kiro/KIRO.md +0 -84
  264. package/configs/kiro/agent.json +0 -18
  265. package/configs/kiro/mcp.json +0 -7
  266. package/configs/omp/SYSTEM.md +0 -85
  267. package/configs/omp/mcp.json +0 -7
  268. package/configs/openclaw/AGENTS.md +0 -87
  269. package/configs/openclaw/openclaw.json +0 -13
  270. package/configs/opencode/AGENTS.md +0 -84
  271. package/configs/opencode/opencode.json +0 -6
  272. package/configs/pi/AGENTS.md +0 -3
  273. package/configs/qwen-code/QWEN.md +0 -91
  274. package/configs/vscode-copilot/copilot-instructions.md +0 -80
  275. package/configs/vscode-copilot/hooks.json +0 -16
  276. package/configs/vscode-copilot/mcp.json +0 -7
  277. package/configs/zed/AGENTS.md +0 -68
  278. package/hooks/antigravity-cli/payload.mjs +0 -98
  279. package/hooks/antigravity-cli/posttooluse.mjs +0 -138
  280. package/hooks/antigravity-cli/pretooluse.mjs +0 -78
  281. package/hooks/antigravity-cli/stop.mjs +0 -58
  282. package/hooks/auto-injection.mjs +0 -102
  283. package/hooks/cache-heal-utils.mjs +0 -379
  284. package/hooks/codex/platform.mjs +0 -1
  285. package/hooks/codex/posttooluse.mjs +0 -66
  286. package/hooks/codex/precompact.mjs +0 -70
  287. package/hooks/codex/pretooluse.mjs +0 -42
  288. package/hooks/codex/sessionstart.mjs +0 -121
  289. package/hooks/codex/stop.mjs +0 -138
  290. package/hooks/codex/userpromptsubmit.mjs +0 -75
  291. package/hooks/copilot-cli/posttooluse.mjs +0 -79
  292. package/hooks/copilot-cli/precompact.mjs +0 -66
  293. package/hooks/copilot-cli/pretooluse.mjs +0 -41
  294. package/hooks/copilot-cli/sessionstart.mjs +0 -121
  295. package/hooks/copilot-cli/stop.mjs +0 -59
  296. package/hooks/copilot-cli/userpromptsubmit.mjs +0 -77
  297. package/hooks/core/codex-caps.mjs +0 -112
  298. package/hooks/core/formatters.mjs +0 -356
  299. package/hooks/core/mcp-ready.mjs +0 -109
  300. package/hooks/core/platform-detect.mjs +0 -49
  301. package/hooks/core/routing.mjs +0 -1017
  302. package/hooks/core/stdin.mjs +0 -90
  303. package/hooks/core/tool-naming.mjs +0 -57
  304. package/hooks/cursor/afteragentresponse.mjs +0 -74
  305. package/hooks/cursor/hooks.json +0 -31
  306. package/hooks/cursor/posttooluse.mjs +0 -72
  307. package/hooks/cursor/pretooluse.mjs +0 -26
  308. package/hooks/cursor/sessionstart.mjs +0 -97
  309. package/hooks/cursor/stop.mjs +0 -48
  310. package/hooks/ensure-deps.mjs +0 -254
  311. package/hooks/formatters/claude-code.mjs +0 -113
  312. package/hooks/formatters/cursor.mjs +0 -37
  313. package/hooks/formatters/gemini-cli.mjs +0 -55
  314. package/hooks/formatters/vscode-copilot.mjs +0 -55
  315. package/hooks/gemini-cli/aftermodel.mjs +0 -70
  316. package/hooks/gemini-cli/aftertool.mjs +0 -61
  317. package/hooks/gemini-cli/beforeagent.mjs +0 -99
  318. package/hooks/gemini-cli/beforetool.mjs +0 -27
  319. package/hooks/gemini-cli/precompress.mjs +0 -55
  320. package/hooks/gemini-cli/sessionstart.mjs +0 -137
  321. package/hooks/heal-partial-install.mjs +0 -712
  322. package/hooks/jetbrains-copilot/posttooluse.mjs +0 -61
  323. package/hooks/jetbrains-copilot/precompact.mjs +0 -55
  324. package/hooks/jetbrains-copilot/pretooluse.mjs +0 -27
  325. package/hooks/jetbrains-copilot/sessionstart.mjs +0 -124
  326. package/hooks/kimi/platform.mjs +0 -1
  327. package/hooks/kimi/posttooluse.mjs +0 -72
  328. package/hooks/kimi/precompact.mjs +0 -80
  329. package/hooks/kimi/pretooluse.mjs +0 -42
  330. package/hooks/kimi/sessionend.mjs +0 -61
  331. package/hooks/kimi/sessionstart.mjs +0 -113
  332. package/hooks/kimi/stop.mjs +0 -132
  333. package/hooks/kimi/userpromptsubmit.mjs +0 -90
  334. package/hooks/kiro/agentspawn.mjs +0 -97
  335. package/hooks/kiro/posttooluse.mjs +0 -51
  336. package/hooks/kiro/pretooluse.mjs +0 -64
  337. package/hooks/kiro/userpromptsubmit.mjs +0 -88
  338. package/hooks/normalize-hooks.mjs +0 -323
  339. package/hooks/platform-bridge.mjs +0 -340
  340. package/hooks/posttooluse.mjs +0 -225
  341. package/hooks/precompact.mjs +0 -100
  342. package/hooks/pretooluse.mjs +0 -227
  343. package/hooks/qwen-code/platform.mjs +0 -1
  344. package/hooks/qwen-code/stop.mjs +0 -168
  345. package/hooks/routing-block.mjs +0 -105
  346. package/hooks/run-hook.mjs +0 -95
  347. package/hooks/security.bundle.mjs +0 -2
  348. package/hooks/session-attribution.bundle.mjs +0 -1
  349. package/hooks/session-db.bundle.mjs +0 -144
  350. package/hooks/session-directive.mjs +0 -520
  351. package/hooks/session-extract.bundle.mjs +0 -3
  352. package/hooks/session-helpers.mjs +0 -428
  353. package/hooks/session-loaders.mjs +0 -448
  354. package/hooks/session-snapshot.bundle.mjs +0 -32
  355. package/hooks/sessionstart.mjs +0 -464
  356. package/hooks/stop.mjs +0 -82
  357. package/hooks/suppress-stderr.mjs +0 -25
  358. package/hooks/userpromptsubmit.mjs +0 -108
  359. package/hooks/vscode-copilot/posttooluse.mjs +0 -61
  360. package/hooks/vscode-copilot/precompact.mjs +0 -55
  361. package/hooks/vscode-copilot/pretooluse.mjs +0 -27
  362. package/hooks/vscode-copilot/sessionstart.mjs +0 -129
  363. package/openclaw.plugin.json +0 -38
  364. package/scripts/heal-better-sqlite3.mjs +0 -430
  365. package/scripts/heal-installed-plugins.mjs +0 -598
  366. package/scripts/plugin-cache-integrity.mjs +0 -248
  367. package/scripts/postinstall.mjs +0 -384
  368. package/server.bundle.mjs +0 -1162
  369. package/skills/.ignore +0 -7
  370. package/skills/context-mode/SKILL.md +0 -300
  371. package/skills/context-mode/references/anti-patterns.md +0 -283
  372. package/skills/context-mode/references/patterns-javascript.md +0 -298
  373. package/skills/context-mode/references/patterns-python.md +0 -304
  374. package/skills/context-mode/references/patterns-shell.md +0 -277
  375. package/skills/ctx-index/SKILL.md +0 -46
  376. package/skills/ctx-insight/SKILL.md +0 -24
  377. package/skills/ctx-purge/SKILL.md +0 -49
  378. package/skills/ctx-search/SKILL.md +0 -35
  379. package/start.mjs +0 -616
package/build/server.js DELETED
@@ -1,4563 +0,0 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { createRequire } from "node:module";
5
- import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, writeSync, renameSync, rmSync, mkdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
6
- import { spawnSync } from "node:child_process";
7
- import { join, dirname, resolve, sep, isAbsolute } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { homedir, tmpdir, cpus } from "node:os";
10
- import { request as httpsRequest } from "node:https";
11
- import { AsyncLocalStorage } from "node:async_hooks";
12
- import { z } from "zod";
13
- import { PolyglotExecutor } from "./executor.js";
14
- import { runPool } from "./runPool.js";
15
- import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
16
- import { composeFetchCacheKey } from "./fetch-cache.js";
17
- import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
18
- import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
19
- import { classifyNonZeroExit } from "./exit-classify.js";
20
- import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
21
- import { charSafePrefix } from "./truncate.js";
22
- import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
23
- import { purgeSession } from "./session/purge.js";
24
- import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
25
- import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
26
- import { appendRetrievalBytes } from "./session/retrieval-marker.js";
27
- import { searchAllSources } from "./search/unified.js";
28
- import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
29
- import { FloodGuard } from "./search/flood-guard.js";
30
- import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
31
- import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
32
- import { parseCodexContextModePluginRoot } from "./adapters/codex/index.js";
33
- import { getHookScriptPaths } from "./util/hook-config.js";
34
- import { stripJsonComments } from "./util/jsonc.js";
35
- import { resolveClaudeConfigDir } from "./util/claude-config.js";
36
- import { resolveProjectDir } from "./util/project-dir.js";
37
- import { loadDatabase } from "./db-base.js";
38
- import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getConversationWindowStats, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js";
39
- const __pkg_dir = dirname(fileURLToPath(import.meta.url));
40
- const VERSION = (() => {
41
- for (const rel of ["../package.json", "./package.json"]) {
42
- const p = resolve(__pkg_dir, rel);
43
- if (existsSync(p)) {
44
- try {
45
- return JSON.parse(readFileSync(p, "utf8")).version;
46
- }
47
- catch { }
48
- }
49
- }
50
- return "unknown";
51
- })();
52
- function getPackageRoot() {
53
- return existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
54
- }
55
- function resolveCodexRuntimePluginRoot(fallbackRoot) {
56
- try {
57
- const probe = process.platform === "win32"
58
- ? spawnSync("cmd.exe", ["/d", "/s", "/c", "codex plugin list"], {
59
- encoding: "utf-8",
60
- stdio: ["ignore", "pipe", "ignore"],
61
- timeout: 5000,
62
- })
63
- : spawnSync("codex", ["plugin", "list"], {
64
- encoding: "utf-8",
65
- stdio: ["ignore", "pipe", "ignore"],
66
- timeout: 5000,
67
- });
68
- if (probe.status !== 0)
69
- return fallbackRoot;
70
- const runtimeRoot = parseCodexContextModePluginRoot(String(probe.stdout));
71
- if (runtimeRoot && existsSync(resolve(runtimeRoot, ".codex-plugin", "hooks.json"))) {
72
- return runtimeRoot;
73
- }
74
- }
75
- catch {
76
- // Best effort only. Non-Codex hosts and older Codex builds may not expose
77
- // plugin list; keep the package-root fallback for those environments.
78
- }
79
- return fallbackRoot;
80
- }
81
- function getRuntimeAwarePackageRoot(platformId) {
82
- const packageRoot = getPackageRoot();
83
- return platformId === "codex"
84
- ? resolveCodexRuntimePluginRoot(packageRoot)
85
- : packageRoot;
86
- }
87
- // Prevent silent MCP server death from unhandled async errors.
88
- //
89
- // Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
90
- // imported only to reuse the ctx_* tool registry, these handlers would become
91
- // process-wide OpenCode/Kilo host handlers. In Node, adding an
92
- // `uncaughtException` listener changes default crash behavior, so only the
93
- // standalone MCP process may install them.
94
- if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
95
- process.on("unhandledRejection", (err) => {
96
- process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
97
- });
98
- process.on("uncaughtException", (err) => {
99
- try {
100
- writeSync(2, `[context-mode] uncaughtException: ${err?.message ?? err}\n`);
101
- }
102
- finally {
103
- process.exit(1);
104
- }
105
- });
106
- }
107
- const runtimes = detectRuntimes();
108
- const available = getAvailableLanguages(runtimes);
109
- export const server = new McpServer({
110
- name: "context-mode",
111
- version: VERSION,
112
- });
113
- export const REGISTERED_CTX_TOOLS = [];
114
- export function shouldSuppressMcpToolsForNativePluginHost(opts = {}) {
115
- const embedded = opts.embedded ?? process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
116
- if (embedded === "1")
117
- return false;
118
- const platform = opts.platform ?? detectPlatform().platform;
119
- if (platform !== "opencode" && platform !== "kilo")
120
- return false;
121
- const settings = opts.settings ?? readNativePluginHostSettings(platform);
122
- return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
123
- }
124
- function readNativePluginHostSettings(platform) {
125
- const base = platform === "kilo" ? "kilo" : "opencode";
126
- const paths = [
127
- resolve(`${base}.json`),
128
- resolve(`${base}.jsonc`),
129
- resolve(`.${base}`, `${base}.json`),
130
- resolve(`.${base}`, `${base}.jsonc`),
131
- join(homedir(), ".config", base, `${base}.json`),
132
- join(homedir(), ".config", base, `${base}.jsonc`),
133
- ];
134
- for (const p of paths) {
135
- try {
136
- if (!existsSync(p))
137
- continue;
138
- return JSON.parse(stripJsonComments(readFileSync(p, "utf8")));
139
- }
140
- catch { /* try next config path */ }
141
- }
142
- return null;
143
- }
144
- function settingsHasContextModePlugin(settings) {
145
- const plugins = settings?.plugin;
146
- return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
147
- }
148
- function settingsHasLegacyContextModeMcp(settings) {
149
- const mcp = settings?.mcp;
150
- return !!(mcp &&
151
- typeof mcp === "object" &&
152
- !Array.isArray(mcp) &&
153
- Object.prototype.hasOwnProperty.call(mcp, "context-mode"));
154
- }
155
- const suppressMcpToolsForNativePluginHost = shouldSuppressMcpToolsForNativePluginHost();
156
- /**
157
- * Issue #623 — surface why ctx_* tools/list is empty on suppressed legacy MCP
158
- * children. When a user upgrades OpenCode/Kilo from v1.0.136 → v1.0.137+ without
159
- * running `context-mode upgrade`, their opencode.json still has BOTH the legacy
160
- * mcp.context-mode block AND the plugin entry. The plugin path registers the
161
- * tools natively, but the legacy MCP child runs in parallel and used to expose
162
- * duplicate tools — v1.0.137 suppressed those duplicates. The suppression was
163
- * silent, leaving any MCP client that inspected the child via tools/list with
164
- * an empty list and no diagnostic. Emit one stderr line per process so an
165
- * operator running the child directly (or any non-plugin MCP host) sees the
166
- * exact reason and the `context-mode upgrade` fix.
167
- *
168
- * Exported for test (suppression-diagnostic regression guard).
169
- */
170
- let __suppressionDiagnosticEmitted = false;
171
- export function emitSuppressionDiagnostic(opts = {}) {
172
- if (__suppressionDiagnosticEmitted)
173
- return;
174
- __suppressionDiagnosticEmitted = true;
175
- const write = opts.write ?? ((c) => { process.stderr.write(c); });
176
- const platform = opts.platform ?? "opencode/kilo";
177
- write(`[context-mode] ctx_* tools/list intentionally empty on this MCP child: ` +
178
- `legacy mcp.context-mode block coexists with plugin: ["context-mode"] in ` +
179
- `${platform}.json — plugin-native tools are the supported path (#623). ` +
180
- `Run \`context-mode upgrade\` to remove the legacy block (preserves other ` +
181
- `MCP servers).\n`);
182
- }
183
- /** Test-only: reset the one-shot emission flag so suites can re-exercise. */
184
- export function __resetSuppressionDiagnosticForTests() {
185
- __suppressionDiagnosticEmitted = false;
186
- }
187
- /**
188
- * Issue #637 — register an explicit empty `tools/list` handler on the McpServer.
189
- *
190
- * Background: when `suppressMcpToolsForNativePluginHost` is true, every
191
- * `server.registerTool()` call is short-circuited (returns `undefined` above).
192
- * The MCP SDK only installs the SDK-default `tools/list` handler when at least
193
- * one `registerTool()` reaches `setToolRequestHandlers()` internally
194
- * (mcp.js:56-67). Suppressing every registration leaves `tools/list`
195
- * unregistered, and the framework's RPC layer answers it with
196
- * `-32601 "Method not found"`.
197
- *
198
- * The reporter of #637 (SquirrelRat) inspected the suppressed child via
199
- * `tools/list` and read the JSON-RPC error as "the plugin never registers any
200
- * ctx_* tools" — when in fact the plugin DOES register all 11 tools natively
201
- * (verified at `src/adapters/opencode/plugin.ts:469` and
202
- * `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of
203
- * the #637 perception.
204
- *
205
- * This helper installs an explicit handler that returns `{tools: []}` — a
206
- * spec-compliant empty list. Paired with the existing #623 stderr diagnostic,
207
- * an operator now sees:
208
- * - wire response: `{tools: []}` (matches expectation, no JSON-RPC error)
209
- * - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)`
210
- *
211
- * Idempotent: throws inside SDK if called twice on the same server because
212
- * `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations;
213
- * we therefore install the SDK's default tool handlers FIRST (via a no-op
214
- * registerTool of a fake tool, immediately removed) only if needed. To keep
215
- * the public surface minimal, we just call `server.server.setRequestHandler`
216
- * directly — that is the same low-level call used for prompts/resources at
217
- * server.ts:259-261 and avoids the SDK guard entirely.
218
- *
219
- * Exported for test (#637 in-memory regression guard).
220
- */
221
- export function registerEmptyToolsListHandler(target = server) {
222
- target.server.registerCapabilities({ tools: { listChanged: false } });
223
- target.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
224
- }
225
- const originalRegisterTool = server.registerTool.bind(server);
226
- server.registerTool = (...args) => {
227
- const [name, config, handler] = args;
228
- if (suppressMcpToolsForNativePluginHost) {
229
- emitSuppressionDiagnostic();
230
- return undefined;
231
- }
232
- const wrappedHandler = wrapToolHandler(name, handler);
233
- REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler });
234
- args[2] = wrappedHandler;
235
- return originalRegisterTool(...args);
236
- };
237
- function wrapToolHandler(name, handler) {
238
- return async (toolArgs) => {
239
- // #854: mark a tool call in-flight so the bridge-child idle reaper never
240
- // shuts the server down mid-execution during a long ctx_execute/batch that
241
- // emits no further inbound messages. Symmetric end in finally (success+error).
242
- noteRequestStart();
243
- try {
244
- return await handler(toolArgs);
245
- }
246
- catch (err) {
247
- const result = storageErrorResult(err);
248
- if (result) {
249
- try {
250
- return trackResponse(name, result);
251
- }
252
- catch (trackErr) {
253
- if (trackErr instanceof StorageDirectoryError)
254
- return result;
255
- throw trackErr;
256
- }
257
- }
258
- throw err;
259
- }
260
- finally {
261
- noteRequestEnd();
262
- }
263
- };
264
- }
265
- // Issue #637 — when suppression is active, install the empty tools/list handler
266
- // once at module-init time so the suppressed MCP child responds with
267
- // `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
268
- // #623 stderr diagnostic that explains WHY the list is empty. Skipped for the
269
- // embedded plugin-import path because the embedded process is not the stdio
270
- // MCP child an operator would inspect — it lives inside the OpenCode/Kilo
271
- // host and never speaks JSON-RPC over stdio.
272
- if (suppressMcpToolsForNativePluginHost && process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
273
- registerEmptyToolsListHandler(server);
274
- }
275
- const projectDirOverride = new AsyncLocalStorage();
276
- export async function withProjectDirOverride(projectDir, fn) {
277
- const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
278
- return projectDirOverride.run(ctx, fn);
279
- }
280
- // Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
281
- // OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
282
- // the SDK transport layer, causing subsequent listTools() calls to fail permanently.
283
- import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
284
- server.server.registerCapabilities({ prompts: { listChanged: false }, resources: { listChanged: false } });
285
- server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
286
- server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
287
- server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
288
- // ── Strict-client (Gemini function-calling) schema compatibility ──────────────
289
- // Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
290
- // — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
291
- // schema makes the host SILENTLY DROP that tool from the model's function list,
292
- // so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
293
- // protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
294
- // • `const: X` → `enum: [X]` — an identical single-value constraint
295
- // • drop `additionalProperties` — advisory only; every ctx_* handler parses
296
- // args with Zod (which strips unknown keys server-side), so removing it
297
- // changes no validation and no call behavior.
298
- // Both transforms are behavior-preserving for every other client (Claude Code,
299
- // Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
300
- // model sends undeclared properties. Only the wire schema changes — never
301
- // validation or how any tool is invoked.
302
- export function sanitizeSchemaForStrictClients(node) {
303
- if (Array.isArray(node))
304
- return node.map(sanitizeSchemaForStrictClients);
305
- if (node === null || typeof node !== "object")
306
- return node;
307
- const out = {};
308
- for (const [key, value] of Object.entries(node)) {
309
- if (key === "additionalProperties")
310
- continue;
311
- if (key === "const") {
312
- out.enum = [value];
313
- continue;
314
- }
315
- out[key] = sanitizeSchemaForStrictClients(value);
316
- }
317
- return out;
318
- }
319
- // Wrap the SDK-installed tools/list handler so its generated schemas pass through
320
- // the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
321
- // the original handler is left untouched (no regression — strict clients stay as
322
- // they were, every other client unaffected). Must run AFTER all registerTool()
323
- // calls so the SDK's default tools/list handler already exists.
324
- export function installStrictClientSchemaCompat(target = server) {
325
- try {
326
- const low = target.server;
327
- const original = low._requestHandlers?.get("tools/list");
328
- if (typeof original !== "function")
329
- return;
330
- target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
331
- const result = (await original(req, extra));
332
- if (result && Array.isArray(result.tools)) {
333
- for (const tool of result.tools) {
334
- if (!tool || tool.inputSchema == null)
335
- continue;
336
- try {
337
- tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
338
- }
339
- catch {
340
- /* leave this tool's schema unchanged */
341
- }
342
- }
343
- }
344
- return result;
345
- });
346
- }
347
- catch {
348
- /* best-effort — never break tools/list */
349
- }
350
- }
351
- const executor = new PolyglotExecutor({
352
- runtimes,
353
- projectRoot: () => getProjectDir(),
354
- });
355
- // ─────────────────────────────────────────────────────────
356
- // FS read tracking preload for ctx_batch_execute
357
- // ─────────────────────────────────────────────────────────
358
- // NODE_OPTIONS is denied by the executor's #buildSafeEnv (security).
359
- // Instead, we inject it as an inline shell env prefix in each batch command.
360
- // This temp file is loaded via --require when batch commands spawn Node processes.
361
- const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
362
- writeFileSync(CM_FS_PRELOAD, `(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`);
363
- // In the stdio MCP path, main() also removes this file during graceful
364
- // shutdown. Plugin-native OpenCode/Kilo imports skip main() (#574), so
365
- // register a top-level best-effort cleanup too to avoid leaking preload
366
- // snippets under /tmp when the host process exits.
367
- process.on("exit", () => { try {
368
- unlinkSync(CM_FS_PRELOAD);
369
- }
370
- catch { /* best effort */ } });
371
- // Lazy singleton — no DB overhead unless index/search is used
372
- let _store = null;
373
- /**
374
- * Build the FK-attribution object passed to every ContentStore.index*() call
375
- * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
376
- * the current session — eventId stays undefined because MCP tool invocations
377
- * are not paired with PostToolUse event rows at index time (the hook fires
378
- * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
379
- * legacy unattributed rows readable.
380
- */
381
- export function currentAttribution() {
382
- const override = projectDirOverride.getStore();
383
- if (override?.sessionId)
384
- return { sessionId: override.sessionId };
385
- // CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
386
- // Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
387
- // and writes session_events via hooks. Read the most-recent session_id from
388
- // THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/
389
- // kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/
390
- // omp/pi/antigravity — no adapter-specific transcript path required.
391
- const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB();
392
- if (!sessionId)
393
- return undefined;
394
- return { sessionId };
395
- }
396
- let __cachedSessionId;
397
- /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
398
- export function resolveSessionIdFromSessionDB(opts) {
399
- // 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up.
400
- const now = Date.now();
401
- if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) {
402
- return __cachedSessionId.sid;
403
- }
404
- try {
405
- const projectDir = opts?.projectDir
406
- ?? process.env.CLAUDE_PROJECT_DIR
407
- ?? process.env.CONTEXT_MODE_PROJECT_DIR;
408
- if (!projectDir)
409
- return undefined;
410
- const sessionsDir = opts?.sessionsDir ?? getSessionDir();
411
- const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
412
- if (!existsSync(dbPath))
413
- return undefined;
414
- const Database = loadDatabase();
415
- const db = new Database(dbPath, { readonly: true, fileMustExist: true });
416
- try {
417
- const row = db.prepare("SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1").get();
418
- const sid = row?.session_id;
419
- if (sid)
420
- __cachedSessionId = { sid, checkedAt: now };
421
- return sid;
422
- }
423
- finally {
424
- try {
425
- db.close();
426
- }
427
- catch { /* best-effort */ }
428
- }
429
- }
430
- catch {
431
- return undefined;
432
- }
433
- }
434
- /**
435
- * Auto-index session events files written by SessionStart hook.
436
- * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
437
- * CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks —
438
- * so we glob-scan instead of computing a specific hash.
439
- * Files are consumed (deleted) after indexing to prevent double-indexing.
440
- * Called on every getStore() — readdirSync is sub-millisecond when no files match.
441
- */
442
- function maybeIndexSessionEvents(store) {
443
- try {
444
- const sessionsDir = getSessionDir();
445
- if (!existsSync(sessionsDir))
446
- return;
447
- const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
448
- for (const file of files) {
449
- const filePath = join(sessionsDir, file);
450
- try {
451
- store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
452
- unlinkSync(filePath);
453
- }
454
- catch { /* best-effort per file */ }
455
- }
456
- }
457
- catch { /* best-effort — session continuity never blocks tools */ }
458
- }
459
- // ── Platform-aware paths ──────────────────────────────────────────────────
460
- // The adapter (stored after MCP handshake) is the canonical source for
461
- // platform-specific paths. All session DB paths go through it — no
462
- // hardcoded configDir detection in tool handlers.
463
- let _detectedAdapter = null;
464
- /**
465
- * Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl.
466
- * leading `~`) before falling back to `~/.claude`. Mirrors
467
- * `hooks/session-helpers.mjs::resolveConfigDir` and
468
- * `ClaudeCodeAdapter.getConfigDir` so the pre-detection path agrees with
469
- * hooks/adapter on where Claude Code session data lives. See issue #453.
470
- *
471
- * Issue #460 round-3: delegates to the canonical util so empty/whitespace
472
- * env values fall back instead of poisoning downstream `join()` calls.
473
- */
474
- async function getDiagnosticAdapter() {
475
- if (_detectedAdapter)
476
- return _detectedAdapter;
477
- try {
478
- const { getAdapter } = await import("./adapters/detect.js");
479
- const signal = detectPlatform();
480
- return await getAdapter(signal.platform);
481
- }
482
- catch {
483
- return null;
484
- }
485
- }
486
- /**
487
- * Get the platform-specific sessions directory from the detected adapter.
488
- * Falls back to the detected platform config root before adapter detection.
489
- */
490
- function getDefaultSessionDir() {
491
- if (_detectedAdapter)
492
- return _detectedAdapter.getSessionDir();
493
- // Pre-detection path (race window before MCP `initialize` completes):
494
- // call detectPlatform() (sync, env-var-based) and look up segments via
495
- // getSessionDirSegments() (sync map, no adapter instantiation). This keeps
496
- // non-Claude platforms from spilling sessions into ~/.claude/. For Claude
497
- // Code/Codex (single-segment roots), reroute through their config-dir
498
- // contracts so the pre-detection window does not split-state with hooks.
499
- try {
500
- const signal = detectPlatform();
501
- const segments = getSessionDirSegments(signal.platform);
502
- if (segments) {
503
- return resolveDefaultSessionDir({
504
- configDir: join(...segments),
505
- configDirEnv: configDirEnvForSessionSegments(segments),
506
- });
507
- }
508
- }
509
- catch { /* fall through to claude fallback */ }
510
- return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" });
511
- }
512
- function configDirEnvForSessionSegments(segments) {
513
- if (segments.length === 1 && segments[0] === ".claude")
514
- return "CLAUDE_CONFIG_DIR";
515
- if (segments.length === 1 && segments[0] === ".codex")
516
- return "CODEX_HOME";
517
- return undefined;
518
- }
519
- function getSessionDir() {
520
- return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir));
521
- }
522
- /**
523
- * Project directory detection across supported platforms.
524
- *
525
- * Priority:
526
- * 1. Platform-specific env var (set by host IDE before MCP server spawn)
527
- * 2. CONTEXT_MODE_PROJECT_DIR (set by start.mjs for ALL platforms — universal)
528
- * 3. process.cwd() (last resort)
529
- *
530
- * CONTEXT_MODE_PROJECT_DIR guarantees correct projectDir even for platforms
531
- * that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed).
532
- */
533
- export function getProjectDir() {
534
- const override = projectDirOverride.getStore();
535
- if (override)
536
- return override.projectDir;
537
- // Delegated to the shared resolver so the env-var chain rejects plugin
538
- // install paths (set by a prior MCP boot's start.mjs after `/ctx-upgrade`)
539
- // and prefers the shell-set PWD before the chdir'd cwd. v1.0.115 adds
540
- // the Claude Code transcript heuristic — read `cwd` from the most-recently-
541
- // modified `~/.claude/projects/<encoded>/<session>.jsonl` to recover the
542
- // real project dir when MCP was launched from a non-project cwd (desktop-
543
- // app launch, /ctx-upgrade respawn). See src/util/project-dir.ts.
544
- //
545
- // Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude
546
- // Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no
547
- // transcript at that path or use a different schema without `cwd`. Worse,
548
- // a Cursor user who also runs Claude Code would pick up the most-recently-
549
- // modified Claude Code session's cwd — wrong project entirely. Gate the
550
- // path on detected platform so non-Claude hosts skip the heuristic and
551
- // fall through to PWD/cwd cleanly.
552
- //
553
- // The Claude heuristic must also be fresh. Hosts such as Pi can be
554
- // misdetected as Claude Code solely because ~/.claude exists; without a
555
- // freshness guard an old Claude transcript can globally hijack ctx shell cwd
556
- // after reboot. Active Claude sessions update their transcript as the user
557
- // interacts, so stale transcripts should fall through to PWD/cwd.
558
- //
559
- // Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the
560
- // env-var cascade is built ALGORITHMICALLY from the platform's own
561
- // workspace vars + universal escape hatch — foreign workspace vars (e.g.
562
- // CLAUDE_PROJECT_DIR leaked into Pi's MCP child env from the user's shell)
563
- // cannot win, regardless of cascade order. start.mjs intentionally does
564
- // NOT pass strictPlatform — host detection is unreliable at the entrypoint
565
- // and the legacy literal cascade is preserved there for semver safety.
566
- let transcriptsRoot;
567
- let strictPlatform;
568
- let codexHome;
569
- try {
570
- const detected = detectPlatform().platform;
571
- strictPlatform = detected;
572
- if (detected === "claude-code") {
573
- transcriptsRoot = join(homedir(), ".claude", "projects");
574
- }
575
- // Issue #45 — Codex publishes no workspace env var, so the resolver
576
- // reads `meta.cwd` from the most-recently-modified session.jsonl under
577
- // `${codexHome}/sessions/`. Wire codexHome at the call site so the
578
- // resolver can be exercised under test without process-level mutation.
579
- if (detected === "codex") {
580
- codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
581
- }
582
- }
583
- catch { /* detection failure — leave undefined, resolver uses legacy cascade */ }
584
- return resolveProjectDir({
585
- env: process.env,
586
- cwd: process.cwd(),
587
- pwd: process.env.PWD,
588
- transcriptsRoot,
589
- transcriptMaxAgeMs: 5 * 60 * 1000,
590
- strictPlatform,
591
- codexHome,
592
- });
593
- }
594
- /**
595
- * Resolve a possibly-relative path against the project directory (full env cascade),
596
- * not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd
597
- * is unrelated to where the user is working.
598
- */
599
- function resolveProjectPath(filePath) {
600
- return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath);
601
- }
602
- /**
603
- * Resolve the per-project SessionDB path. Delegates to
604
- * {@link resolveSessionDbPath} so casing-only variants of the same
605
- * physical worktree on macOS / Windows hit ONE DB, not two — and any
606
- * pre-existing legacy raw-casing DB gets migrated in place on first
607
- * resolve. Linux is a no-op.
608
- */
609
- function getSessionDbPath() {
610
- return resolveSessionDbPath({
611
- projectDir: getProjectDir(),
612
- sessionsDir: getSessionDir(),
613
- });
614
- }
615
- /**
616
- * Compute a per-project, per-platform persistent path for the ContentStore.
617
- * Derives content dir from the adapter's session dir so each platform
618
- * has its own isolated FTS5 DB — no cross-platform data sharing.
619
- *
620
- * Layout: ~/<configDir>/context-mode/content/<hash>.db
621
- * e.g. ~/.claude/context-mode/content/87c28c41ddb64d38.db
622
- * ~/.cursor/context-mode/content/87c28c41ddb64d38.db
623
- */
624
- function getStorePath() {
625
- const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir));
626
- // Delegate to resolveContentStorePath: same case-fold + one-shot legacy
627
- // rename behavior as resolveSessionDbPath. On macOS / Windows, an
628
- // existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is
629
- // migrated in place on first call. On Linux it's a no-op.
630
- return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
631
- }
632
- function getStore() {
633
- if (!_store) {
634
- // Content DB cleanup on fresh start is handled by SessionStart hook.
635
- // Server just opens whatever DB exists (or creates new if hook deleted it).
636
- const dbPath = getStorePath();
637
- _store = new ContentStore(dbPath);
638
- // Wire deny-policy hook: store re-checks the Read deny list before
639
- // re-reading any file_path during auto-refresh. Catches policy edits
640
- // made after a file was originally indexed. See #442 round-3.
641
- _store.setDenyChecker((filePath) => {
642
- try {
643
- const projectDir = getProjectDir();
644
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
645
- const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
646
- return r.denied;
647
- }
648
- catch {
649
- // Fail-closed for refresh: skip on error rather than re-read.
650
- return true;
651
- }
652
- });
653
- // One-time startup cleanup: remove stale content DBs (>14 days)
654
- try {
655
- const contentDir = dirname(getStorePath());
656
- cleanupStaleContentDBs(contentDir, 14);
657
- _store.cleanupStaleSources(14);
658
- // Also clean legacy shared dir from before platform isolation
659
- const legacyDir = join(homedir(), ".context-mode", "content");
660
- if (existsSync(legacyDir))
661
- cleanupStaleContentDBs(legacyDir, 0);
662
- }
663
- catch { /* best-effort */ }
664
- // Also clean old PID-based DBs from migration
665
- cleanupStaleDBs();
666
- }
667
- maybeIndexSessionEvents(_store);
668
- return _store;
669
- }
670
- // ─────────────────────────────────────────────────────────
671
- // Session stats — track context consumption per tool
672
- // ─────────────────────────────────────────────────────────
673
- const sessionStats = {
674
- calls: {},
675
- bytesReturned: {},
676
- bytesIndexed: 0,
677
- bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context)
678
- cacheHits: 0,
679
- cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache
680
- cacheBytesSaved: 0, // bytes avoided by TTL cache hits
681
- sessionStart: Date.now(),
682
- };
683
- function storageErrorResult(err) {
684
- if (!(err instanceof StorageDirectoryError))
685
- return null;
686
- return {
687
- content: [{ type: "text", text: formatStorageDirectoryError(err) }],
688
- isError: true,
689
- };
690
- }
691
- // ── Version outdated warning ──────────────────────────────────────────────
692
- // Non-blocking npm check at startup. trackResponse prepends warning
693
- // using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat.
694
- let _latestVersion = null;
695
- let _warningBurstCount = 0;
696
- let _lastBurstStart = 0;
697
- const VERSION_BURST_SIZE = 3;
698
- const VERSION_SILENT_MS = 60 * 60 * 1000; // 1 hour
699
- async function fetchLatestVersion() {
700
- return new Promise((res) => {
701
- const req = httpsRequest("https://registry.npmjs.org/context-mode/latest", { headers: { Connection: "close" } }, (resp) => {
702
- let raw = "";
703
- resp.on("data", (chunk) => { raw += chunk; });
704
- resp.on("end", () => {
705
- try {
706
- const data = JSON.parse(raw);
707
- res(data.version ?? "unknown");
708
- }
709
- catch {
710
- res("unknown");
711
- }
712
- });
713
- });
714
- req.on("error", () => res("unknown"));
715
- req.setTimeout(5000, () => { req.destroy(); res("unknown"); });
716
- req.end();
717
- });
718
- }
719
- function getUpgradeHint() {
720
- const name = _detectedAdapter?.name;
721
- if (name === "Claude Code")
722
- return "/ctx-upgrade";
723
- if (name === "OpenClaw")
724
- return "npm run install:openclaw";
725
- if (name === "Pi")
726
- return "npm run build";
727
- return "npm update -g context-mode";
728
- }
729
- function semverNewer(a, b) {
730
- const pa = a.split(".").map(Number);
731
- const pb = b.split(".").map(Number);
732
- for (let i = 0; i < 3; i++) {
733
- if ((pa[i] ?? 0) > (pb[i] ?? 0))
734
- return true;
735
- if ((pa[i] ?? 0) < (pb[i] ?? 0))
736
- return false;
737
- }
738
- return false;
739
- }
740
- function isOutdated() {
741
- if (!_latestVersion || _latestVersion === "unknown")
742
- return false;
743
- return semverNewer(_latestVersion, VERSION);
744
- }
745
- function shouldShowVersionWarning() {
746
- if (!isOutdated())
747
- return false;
748
- const now = Date.now();
749
- // Start of a new burst?
750
- if (_warningBurstCount >= VERSION_BURST_SIZE) {
751
- if (now - _lastBurstStart < VERSION_SILENT_MS)
752
- return false; // still silent
753
- _warningBurstCount = 0; // silence over, reset burst
754
- }
755
- if (_warningBurstCount === 0)
756
- _lastBurstStart = now;
757
- _warningBurstCount++;
758
- return true;
759
- }
760
- // ── Self-heal Layer 2: Mid-session registry heal (anthropics/claude-code#46915) ──
761
- // Runs once on first tool call. If Claude Code auto-updated the registry mid-session,
762
- // hooks break because CLAUDE_PLUGIN_ROOT points to a deleted directory. We create a
763
- // symlink from the broken path to our actual directory so hooks recover.
764
- let _cacheHealDone = false;
765
- function healCacheMidSession() {
766
- if (_cacheHealDone)
767
- return;
768
- _cacheHealDone = true;
769
- try {
770
- // Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so users who relocate
771
- // their CC config root don't have plugin cache healing operate against
772
- // the wrong tree (and silently miss dangling-symlink cleanup).
773
- const claudeRoot = resolveClaudeConfigDir();
774
- const ipPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
775
- if (!existsSync(ipPath))
776
- return;
777
- const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
778
- const cacheRoot = resolve(claudeRoot, "plugins", "cache");
779
- // Issue #795: canonicalize cacheRoot so the traversal guard works when
780
- // ~/.claude is a symlink to another volume. path.resolve() does not
781
- // dereference symlinks, so installPath values stored as physical paths
782
- // (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith
783
- // check against a symlink-path cacheRoot (/Users/me/.claude/...).
784
- // realpathSync follows the symlink chain to the canonical location.
785
- let cacheRootCanon;
786
- try {
787
- cacheRootCanon = realpathSync(cacheRoot);
788
- }
789
- catch {
790
- cacheRootCanon = cacheRoot;
791
- }
792
- // Plugin root: build/ for tsc, plugin root for bundle
793
- const pluginRoot = getPackageRoot();
794
- for (const [key, entries] of Object.entries((ip.plugins ?? {}))) {
795
- if (key !== "context-mode@context-mode")
796
- continue;
797
- for (const entry of entries) {
798
- const rp = entry.installPath;
799
- if (!rp || existsSync(rp))
800
- continue;
801
- // Path traversal guard (canonical comparison — see #795)
802
- if (!resolve(rp).startsWith(cacheRootCanon + sep))
803
- continue;
804
- // Remove dangling symlink
805
- try {
806
- if (lstatSync(rp).isSymbolicLink())
807
- unlinkSync(rp);
808
- }
809
- catch { }
810
- const parent = dirname(rp);
811
- if (!existsSync(parent))
812
- mkdirSync(parent, { recursive: true });
813
- if (existsSync(pluginRoot)) {
814
- symlinkSync(pluginRoot, rp, process.platform === "win32" ? "junction" : undefined);
815
- }
816
- }
817
- }
818
- }
819
- catch { /* best effort */ }
820
- }
821
- function trackResponse(toolName, response) {
822
- // #854: a response is activity too — refresh the bridge-child idle clock so a
823
- // chatty/streaming call keeps its server alive even between inbound frames.
824
- noteMcpActivity();
825
- // Mid-session cache heal — one-shot, first tool call
826
- healCacheMidSession();
827
- // Prepend version outdated warning if needed
828
- if (shouldShowVersionWarning() && response.content.length > 0) {
829
- const hint = getUpgradeHint();
830
- response.content[0].text =
831
- `⚠️ context-mode v${VERSION} outdated → v${_latestVersion} available. Upgrade: ${hint}\n\n` +
832
- response.content[0].text;
833
- }
834
- const bytes = response.content.reduce((sum, c) => sum + Buffer.byteLength(c.text), 0);
835
- sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
836
- sessionStats.bytesReturned[toolName] =
837
- (sessionStats.bytesReturned[toolName] || 0) + bytes;
838
- // Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
839
- // bin/statusline.mjs (and any external dashboard) so they don't have to
840
- // open the SQLite database. Throttled inside persistStats() (500ms) so
841
- // it's safe to call on every response.
842
- persistStats();
843
- // Persist to SessionDB so counters survive process restart, --continue,
844
- // upgrade. Re-introduces the write path 4742160 added and b392c2f dropped.
845
- // setImmediate keeps this off the response hot path; the helper itself
846
- // is best-effort (never throws).
847
- setImmediate(() => persistToolCallCounter(getSessionDbPath(), toolName, bytes));
848
- // D2 Phase 5/7 — sandbox-execute event emission. Tracks the bytes the
849
- // user actually saw from sandboxed runs so getRealBytesStats() can
850
- // replace the conservative `events × 256` estimate. Best-effort and
851
- // off the hot path, same shape as persistToolCallCounter above.
852
- if (toolName === "ctx_execute"
853
- || toolName === "ctx_execute_file"
854
- || toolName === "ctx_batch_execute") {
855
- setImmediate(() => emitSandboxExecuteEvent({
856
- sessionDbPath: getSessionDbPath(),
857
- toolName,
858
- bytesReturned: bytes,
859
- }));
860
- }
861
- // Retrieval ("With context-mode") bridge — ctx_search / ctx_fetch_and_index
862
- // response bytes are the kept-out content the model paid to access. The
863
- // PostToolUse hook never fires for the plugin's OWN MCP tools, so the
864
- // hook-side extractMcpToolCall can never see these calls (bytes_retrieved
865
- // was 0/124454 in prod). Drop the count into a marker keyed by the session
866
- // DB; the next ordinary-tool PostToolUse consumes it and emits a forwardable
867
- // bytes_retrieved event. Off the hot path; never throws.
868
- if (toolName === "ctx_search" || toolName === "ctx_fetch_and_index") {
869
- setImmediate(() => appendRetrievalBytes(getSessionDbPath(), bytes));
870
- }
871
- return response;
872
- }
873
- function trackIndexed(bytes, source = "unknown") {
874
- sessionStats.bytesIndexed += bytes;
875
- persistStats();
876
- // D2 Phase 5/7 — index-write event emission. `bytes_avoided` because
877
- // these are bytes that would have flooded context if the user had
878
- // Read'd the source instead of indexing.
879
- if (bytes > 0) {
880
- setImmediate(() => emitIndexWriteEvent({
881
- sessionDbPath: getSessionDbPath(),
882
- source,
883
- bytesAvoided: bytes,
884
- }));
885
- }
886
- }
887
- // ─────────────────────────────────────────────────────────
888
- // Stats persistence — written after every tool call so
889
- // external readers (status line scripts, dashboards, hooks)
890
- // can see real-time savings without spawning an MCP client.
891
- // ─────────────────────────────────────────────────────────
892
- const STATS_PERSIST_THROTTLE_MS = 500;
893
- // Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json).
894
- // Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when
895
- // it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently
896
- // rendering missing fields (PR #401 architect review P1.3).
897
- // v2: added tokens_saved_lifetime + dollars_saved_lifetime.
898
- const STATS_SCHEMA_VERSION = 2;
899
- // pricePerToken() intentionally NOT defined here — single source in
900
- // src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
901
- // PR #401 architect + ops 2-vote convergence.)
902
- const LIFETIME_REFRESH_MS = 30_000;
903
- // Matches the conversion factor in src/session/analytics.ts renderBottomLine:
904
- // ~1KB per session event ÷ 4 bytes/token = 256 tokens/event.
905
- const TOKENS_PER_EVENT = 256;
906
- let _lastStatsPersist = 0;
907
- let _lifetimeCache;
908
- /**
909
- * Resolve the per-session stats file path.
910
- *
911
- * The session id mirrors the Claude Code adapter contract
912
- * (`pid-<parent pid>`), so a status line script can derive
913
- * the same id from `$PPID` without coupling to MCP.
914
- */
915
- // CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.)
916
- // straight into a path.join, and path.join collapses ".." into the result,
917
- // so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two
918
- // levels above statsDir. The env var is not under direct MCP-tool-caller
919
- // control, but in CI / multi-tenant contexts where the host env is partly
920
- // influenceable this is an arbitrary-write primitive within the MCP server
921
- // process's filesystem permissions. Constrain to a UUID-shaped charset
922
- // before splicing into the stats filename.
923
- const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
924
- function sanitizeSessionId(raw) {
925
- return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`;
926
- }
927
- function getStatsFilePath() {
928
- const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
929
- const sessionId = sanitizeSessionId(raw);
930
- const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
931
- return join(statsDir, `stats-${sessionId}.json`);
932
- }
933
- function persistStats() {
934
- const now = Date.now();
935
- if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS)
936
- return;
937
- _lastStatsPersist = now;
938
- try {
939
- const totalReturned = Object.values(sessionStats.bytesReturned).reduce((a, b) => a + b, 0);
940
- const totalCalls = Object.values(sessionStats.calls).reduce((a, b) => a + b, 0);
941
- const keptOut = sessionStats.bytesIndexed +
942
- sessionStats.bytesSandboxed +
943
- sessionStats.cacheBytesSaved;
944
- const totalProcessed = keptOut + totalReturned;
945
- const reductionPct = totalProcessed > 0
946
- ? Math.round((1 - totalReturned / totalProcessed) * 100)
947
- : 0;
948
- const tokensSaved = Math.round(keptOut / 4);
949
- // Lifetime savings — cached separately because getLifetimeStats() scans
950
- // disk (per-project SessionDBs + auto-memory dirs) and is too expensive
951
- // for the 500ms persist throttle. Refresh every 30s; the statusline
952
- // doesn't need second-by-second lifetime accuracy.
953
- let lifetimeTokens = _lifetimeCache?.tokens ?? 0;
954
- if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) {
955
- try {
956
- const life = getLifetimeStats({ sessionsDir: getSessionDir() });
957
- lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT;
958
- _lifetimeCache = { tokens: lifetimeTokens, computedAt: now };
959
- }
960
- catch {
961
- // best-effort — keep stale cache or 0
962
- }
963
- }
964
- const payload = {
965
- schemaVersion: STATS_SCHEMA_VERSION,
966
- version: VERSION,
967
- updated_at: now,
968
- session_start: sessionStats.sessionStart,
969
- uptime_ms: now - sessionStats.sessionStart,
970
- total_calls: totalCalls,
971
- bytes_returned: totalReturned,
972
- bytes_indexed: sessionStats.bytesIndexed,
973
- bytes_sandboxed: sessionStats.bytesSandboxed,
974
- cache_hits: sessionStats.cacheHits,
975
- cache_bytes_saved: sessionStats.cacheBytesSaved,
976
- kept_out: keptOut,
977
- total_processed: totalProcessed,
978
- reduction_pct: reductionPct,
979
- tokens_saved: tokensSaved,
980
- // statusline-facing $ values — pre-computed at the current per-token
981
- // rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a
982
- // Pi host; Opus $15/1M otherwise). Resolved on every persist via
983
- // pricePerToken() so the env override picks up without an MCP restart.
984
- dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2),
985
- tokens_saved_lifetime: lifetimeTokens,
986
- dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2),
987
- by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
988
- t,
989
- {
990
- calls: sessionStats.calls[t] || 0,
991
- bytes: sessionStats.bytesReturned[t] || 0,
992
- },
993
- ])),
994
- };
995
- const filePath = getStatsFilePath();
996
- const tmpPath = `${filePath}.tmp`;
997
- writeFileSync(tmpPath, JSON.stringify(payload));
998
- renameSync(tmpPath, filePath);
999
- }
1000
- catch {
1001
- // best-effort — never break tool calls because of stats persistence
1002
- }
1003
- }
1004
- // ==============================================================================
1005
- // Security: server-side deny firewall
1006
- // ==============================================================================
1007
- /**
1008
- * Check a shell command against Bash deny patterns.
1009
- * Returns an error ToolResult if denied, or null if allowed.
1010
- */
1011
- function checkDenyPolicy(command, toolName) {
1012
- try {
1013
- const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
1014
- const result = evaluateCommandDenyOnly(command, policies);
1015
- if (result.decision === "deny") {
1016
- return trackResponse(toolName, {
1017
- content: [{
1018
- type: "text",
1019
- text: `Command blocked by security policy: matches deny pattern ${result.matchedPattern}`,
1020
- }],
1021
- isError: true,
1022
- });
1023
- }
1024
- }
1025
- catch {
1026
- // Security check failed — allow through (fail-open for server,
1027
- // hooks are the primary enforcement layer)
1028
- }
1029
- return null;
1030
- }
1031
- /**
1032
- * Check non-shell code for shell-escape calls against deny patterns.
1033
- */
1034
- function checkNonShellDenyPolicy(code, language, toolName) {
1035
- try {
1036
- const commands = extractShellCommands(code, language);
1037
- if (commands.length === 0)
1038
- return null;
1039
- const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
1040
- for (const cmd of commands) {
1041
- const result = evaluateCommandDenyOnly(cmd, policies);
1042
- if (result.decision === "deny") {
1043
- return trackResponse(toolName, {
1044
- content: [{
1045
- type: "text",
1046
- text: `Command blocked by security policy: embedded shell command "${cmd}" matches deny pattern ${result.matchedPattern}`,
1047
- }],
1048
- isError: true,
1049
- });
1050
- }
1051
- }
1052
- }
1053
- catch {
1054
- // Fail-open
1055
- }
1056
- return null;
1057
- }
1058
- /**
1059
- * Issue #852 — project-boundary containment for `ctx_execute_file`.
1060
- *
1061
- * The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
1062
- * user approving a `ctx_execute_file` call cannot see that its `path` escapes
1063
- * the workspace. This guard refuses a `path` that resolves outside the project
1064
- * root (absolute escape, `../` traversal, or symlink-out), restoring the
1065
- * boundary the host believes it is enforcing.
1066
- *
1067
- * Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
1068
- * expressed in the SAME host config the user already maintains: a
1069
- * `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
1070
- * mechanism Claude Code uses to whitelist a path outside its sandbox, so the
1071
- * grant lives in one place and stays meaningful instead of rotting into a
1072
- * context-mode-only env flag nobody sets.
1073
- *
1074
- * Fail-open on resolver failure (consistent with the other deny checks): if the
1075
- * project root cannot be resolved, containment evaluates as "inside" and the
1076
- * path is allowed through rather than spuriously blocking legitimate work.
1077
- */
1078
- function checkProjectBoundary(filePath, toolName) {
1079
- try {
1080
- const projectDir = getProjectDir();
1081
- const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
1082
- const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
1083
- if (verdict.allowed)
1084
- return null;
1085
- return trackResponse(toolName, {
1086
- content: [{
1087
- type: "text",
1088
- text: `File access blocked: "${filePath}" resolves outside the project root ` +
1089
- `(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
1090
- `cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
1091
- `To intentionally process a file outside the project, add a host allow rule, ` +
1092
- `e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
1093
- }],
1094
- isError: true,
1095
- });
1096
- }
1097
- catch {
1098
- // Fail-open — resolver failure must not block legitimate in-project work.
1099
- }
1100
- return null;
1101
- }
1102
- /**
1103
- * Check a file path against Read deny patterns.
1104
- * Returns an error ToolResult if denied, or null if allowed.
1105
- */
1106
- function checkFilePathDenyPolicy(filePath, toolName) {
1107
- try {
1108
- const projectDir = getProjectDir();
1109
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
1110
- const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
1111
- if (result.denied) {
1112
- return trackResponse(toolName, {
1113
- content: [{
1114
- type: "text",
1115
- text: `File access blocked by security policy: path matches Read deny pattern ${result.matchedPattern}`,
1116
- }],
1117
- isError: true,
1118
- });
1119
- }
1120
- }
1121
- catch {
1122
- // Fail-open
1123
- }
1124
- return null;
1125
- }
1126
- // Build description dynamically based on detected runtimes
1127
- const langList = available.join(", ");
1128
- const bunNote = hasBunRuntime()
1129
- ? " (Bun detected — JS/TS runs 3-5x faster)"
1130
- : "";
1131
- // ─────────────────────────────────────────────────────────
1132
- // Helper: smart snippet extraction — returns windows around
1133
- // matching query terms instead of dumb truncation
1134
- //
1135
- // When `highlighted` is provided (from FTS5 `highlight()` with
1136
- // STX/ETX markers), match positions are derived from the markers.
1137
- // This is the authoritative source — FTS5 uses the exact same
1138
- // tokenizer that produced the BM25 match, so stemmed variants
1139
- // like "configuration" matching query "configure" are found
1140
- // correctly. Falls back to indexOf on raw terms when highlighted
1141
- // is absent (non-FTS codepath).
1142
- // ─────────────────────────────────────────────────────────
1143
- const STX = "\x02";
1144
- const ETX = "\x03";
1145
- /**
1146
- * Parse FTS5 highlight markers to find match positions in the
1147
- * original (marker-free) text. Returns character offsets into the
1148
- * stripped content where each matched token begins.
1149
- */
1150
- export function positionsFromHighlight(highlighted) {
1151
- const positions = [];
1152
- let cleanOffset = 0;
1153
- let i = 0;
1154
- while (i < highlighted.length) {
1155
- if (highlighted[i] === STX) {
1156
- // Record position of this match in the clean text
1157
- positions.push(cleanOffset);
1158
- i++; // skip STX
1159
- // Advance through matched text until ETX
1160
- while (i < highlighted.length && highlighted[i] !== ETX) {
1161
- cleanOffset++;
1162
- i++;
1163
- }
1164
- if (i < highlighted.length)
1165
- i++; // skip ETX
1166
- }
1167
- else {
1168
- cleanOffset++;
1169
- i++;
1170
- }
1171
- }
1172
- return positions;
1173
- }
1174
- /** Strip STX/ETX markers to recover original content. */
1175
- function stripMarkers(highlighted) {
1176
- return highlighted.replaceAll(STX, "").replaceAll(ETX, "");
1177
- }
1178
- export function extractSnippet(content, query, maxLen = 1500, highlighted) {
1179
- if (content.length <= maxLen)
1180
- return content;
1181
- // Derive match positions from FTS5 highlight markers when available
1182
- const positions = [];
1183
- if (highlighted) {
1184
- for (const pos of positionsFromHighlight(highlighted)) {
1185
- positions.push(pos);
1186
- }
1187
- }
1188
- // Fallback: indexOf on raw query terms (non-FTS codepath)
1189
- if (positions.length === 0) {
1190
- const terms = query
1191
- .toLowerCase()
1192
- .split(/\s+/)
1193
- .filter((t) => t.length > 2);
1194
- const lower = content.toLowerCase();
1195
- for (const term of terms) {
1196
- let idx = lower.indexOf(term);
1197
- while (idx !== -1) {
1198
- positions.push(idx);
1199
- idx = lower.indexOf(term, idx + 1);
1200
- }
1201
- }
1202
- }
1203
- // No matches at all — return prefix
1204
- if (positions.length === 0) {
1205
- return content.slice(0, maxLen) + "\n…";
1206
- }
1207
- // Sort positions, merge overlapping windows
1208
- positions.sort((a, b) => a - b);
1209
- const WINDOW = 300;
1210
- const windows = [];
1211
- for (const pos of positions) {
1212
- const start = Math.max(0, pos - WINDOW);
1213
- const end = Math.min(content.length, pos + WINDOW);
1214
- if (windows.length > 0 && start <= windows[windows.length - 1][1]) {
1215
- windows[windows.length - 1][1] = end;
1216
- }
1217
- else {
1218
- windows.push([start, end]);
1219
- }
1220
- }
1221
- // Collect windows until maxLen
1222
- const parts = [];
1223
- let total = 0;
1224
- for (const [start, end] of windows) {
1225
- if (total >= maxLen)
1226
- break;
1227
- const part = content.slice(start, Math.min(end, start + (maxLen - total)));
1228
- parts.push((start > 0 ? "…" : "") + part + (end < content.length ? "…" : ""));
1229
- total += part.length;
1230
- }
1231
- return parts.join("\n\n");
1232
- }
1233
- export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024, scope = "batch") {
1234
- const sections = [];
1235
- let outputSize = 0;
1236
- // When scope is "global", searchWithFallback receives `undefined` for the
1237
- // source filter, which makes it query the entire persistent index instead
1238
- // of only the chunks just produced by this batch's commands. Default
1239
- // remains "batch" to preserve the historical behavior.
1240
- const searchSource = scope === "global" ? undefined : source;
1241
- for (const query of queries) {
1242
- if (outputSize > maxOutput) {
1243
- sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
1244
- continue;
1245
- }
1246
- const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact");
1247
- sections.push(`## ${query}`);
1248
- sections.push("");
1249
- if (results.length > 0) {
1250
- for (const result of results) {
1251
- const snippet = extractSnippet(result.content, query, 3000, result.highlighted);
1252
- sections.push(`### ${result.title}`);
1253
- sections.push(snippet);
1254
- sections.push("");
1255
- outputSize += snippet.length + result.title.length;
1256
- }
1257
- continue;
1258
- }
1259
- sections.push("No matching sections found.");
1260
- sections.push("");
1261
- }
1262
- if (scope === "global") {
1263
- sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`);
1264
- }
1265
- else {
1266
- sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`);
1267
- }
1268
- return sections;
1269
- }
1270
- function quotePosixSingle(value) {
1271
- return `'${value.replace(/'/g, "'\\''")}'`;
1272
- }
1273
- function quotePowerShellSingle(value) {
1274
- return `'${value.replace(/'/g, "''")}'`;
1275
- }
1276
- export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
1277
- const option = `--require ${preloadPath}`;
1278
- const shell = shellPath.toLowerCase();
1279
- const base = shell.split(/[\\/]/).pop() ?? shell;
1280
- if (shell.includes("powershell") || shell.includes("pwsh")) {
1281
- return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `;
1282
- }
1283
- if (base === "cmd" || base === "cmd.exe") {
1284
- return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `;
1285
- }
1286
- return `NODE_OPTIONS=${quotePosixSingle(option)} `;
1287
- }
1288
- /**
1289
- * Per-section budget for the echoed `$ <command>` line so a 50KB heredoc
1290
- * payload cannot dominate the response body. The full command always reaches
1291
- * the executor — only the echo is clipped (Issues #717 + #736).
1292
- */
1293
- const COMMAND_ECHO_MAX = 500;
1294
- function truncateCommandForEcho(command) {
1295
- const cleaned = command.replace(/\s+/g, " ").trim();
1296
- if (cleaned.length <= COMMAND_ECHO_MAX)
1297
- return cleaned;
1298
- return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
1299
- }
1300
- /**
1301
- * Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
1302
- * agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
1303
- * blocking script hangs forever — the host never kills it and the user must
1304
- * interrupt. Every other host enforces its own RPC timeout, so we keep the
1305
- * no-server-timer behavior there (Issue #406 — long builds need an unbounded
1306
- * run). A caller can still pass an explicit `timeout` to override on any host.
1307
- */
1308
- export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000;
1309
- export function resolveExecTimeout(timeout) {
1310
- if (timeout !== undefined)
1311
- return timeout;
1312
- // Only agy gets a default — every other host enforces its own RPC timeout, so
1313
- // keep the unbounded behavior there. Detected via the env the agy bundle pins
1314
- // (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS.
1315
- if (detectPlatform().platform !== "antigravity-cli")
1316
- return undefined;
1317
- const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS);
1318
- return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS;
1319
- }
1320
- /**
1321
- * Per-call budget for the source-code echo prepended by `ctx_execute` and
1322
- * `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
1323
- * sandbox — only the echo is clipped so massive payloads don't dominate
1324
- * the response. Multi-line preserved (unlike command echo) so the user
1325
- * sees the actual program shape.
1326
- */
1327
- const CODE_ECHO_MAX = 2000;
1328
- function truncateCodeForEcho(code) {
1329
- if (code.length <= CODE_ECHO_MAX)
1330
- return code;
1331
- return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)";
1332
- }
1333
- /**
1334
- * Build the source-code preamble surfaced before tool stdout. Provenance
1335
- * survives in indexed chunks (FTS5 sees the fenced block) so later
1336
- * ctx_search hits remember what ran.
1337
- */
1338
- function buildExecuteEcho(language, code, path) {
1339
- const header = path ? `path=${path}\n` : "";
1340
- const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``;
1341
- return `${header}${fenced}\n\n`;
1342
- }
1343
- function formatCommandOutput(label, command, raw, onFsBytes) {
1344
- let output = raw || "(no output)";
1345
- const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
1346
- let cmdFsBytes = 0;
1347
- for (const m of fsMatches)
1348
- cmdFsBytes += parseInt(m[1]);
1349
- if (cmdFsBytes > 0) {
1350
- onFsBytes?.(cmdFsBytes);
1351
- output = output.replace(/__CM_FS__:\d+\n?/g, "");
1352
- }
1353
- // Echo the executed command below the section heading so per-chunk
1354
- // indexed content retains provenance for later ctx_search hits
1355
- // (Issues #717 + #736).
1356
- const echoed = truncateCommandForEcho(command);
1357
- return `# ${label}\n\n$ ${echoed}\n\n${output}\n`;
1358
- }
1359
- function combineExecOutput(result) {
1360
- const stdout = result.stdout || "";
1361
- const stderr = result.stderr || "";
1362
- if (!stderr)
1363
- return stdout;
1364
- if (!stdout)
1365
- return stderr;
1366
- return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`;
1367
- }
1368
- /**
1369
- * Execute batch commands. concurrency=1 preserves the legacy serial path
1370
- * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
1371
- * commands concurrently with at most N in flight; each command receives the
1372
- * full timeout, output is collated by input index, and per-command timeouts
1373
- * record `(timed out)` blocks without skipping siblings.
1374
- */
1375
- export async function runBatchCommands(commands, opts, executor) {
1376
- const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;
1377
- if (concurrency <= 1) {
1378
- // Serial path — shared timeout budget, cascading skip on timeout.
1379
- // When `timeout` is undefined, no shared budget is enforced; each
1380
- // command runs to completion (Issue #406).
1381
- const outputs = [];
1382
- const startTime = Date.now();
1383
- let timedOut = false;
1384
- for (let i = 0; i < commands.length; i++) {
1385
- const cmd = commands[i];
1386
- let perCmdTimeout;
1387
- if (timeout !== undefined) {
1388
- const elapsed = Date.now() - startTime;
1389
- const remaining = timeout - elapsed;
1390
- if (remaining <= 0) {
1391
- outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
1392
- timedOut = true;
1393
- continue;
1394
- }
1395
- perCmdTimeout = remaining;
1396
- }
1397
- const result = await executor.execute({
1398
- language: "shell",
1399
- code: `${nodeOptsPrefix}${cmd.command}`,
1400
- timeout: perCmdTimeout,
1401
- cwd,
1402
- });
1403
- outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
1404
- if (result.timedOut) {
1405
- timedOut = true;
1406
- for (let j = i + 1; j < commands.length; j++) {
1407
- outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`);
1408
- }
1409
- break;
1410
- }
1411
- }
1412
- return { outputs, timedOut };
1413
- }
1414
- // Parallel path — delegated to the shared runPool primitive.
1415
- // Each job returns { output, timedOut }; runPool handles in-flight cap,
1416
- // throw isolation (Promise.allSettled semantics), and order preservation.
1417
- const jobs = commands.map((cmd) => ({
1418
- run: async () => {
1419
- const result = await executor.execute({
1420
- language: "shell",
1421
- code: `${nodeOptsPrefix}${cmd.command}`,
1422
- timeout,
1423
- cwd,
1424
- });
1425
- // Always route partial output through formatCommandOutput so __CM_FS__
1426
- // markers are stripped + counted, even when the command timed out.
1427
- const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes);
1428
- const output = result.timedOut
1429
- ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
1430
- : formatted;
1431
- return { output, timedOut: !!result.timedOut };
1432
- },
1433
- }));
1434
- const { settled } = await runPool(jobs, { concurrency });
1435
- const outputs = new Array(commands.length);
1436
- let timedOut = false;
1437
- for (let i = 0; i < settled.length; i++) {
1438
- const r = settled[i];
1439
- if (r.status === "fulfilled") {
1440
- outputs[i] = r.value.output;
1441
- if (r.value.timedOut)
1442
- timedOut = true;
1443
- }
1444
- else {
1445
- // Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running.
1446
- const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
1447
- outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`;
1448
- }
1449
- }
1450
- return { outputs, timedOut };
1451
- }
1452
- // ─────────────────────────────────────────────────────────
1453
- // Tool: execute
1454
- // ─────────────────────────────────────────────────────────
1455
- server.registerTool("ctx_execute", {
1456
- // #852: surface code execution in the host approval prompt's title (the
1457
- // only server-controlled field the MCP permission UI renders besides args).
1458
- title: "Run code in a sandbox (executes the supplied code)",
1459
- // #846: runs arbitrary code in a sandbox with full network access.
1460
- annotations: {
1461
- readOnlyHint: false,
1462
- destructiveHint: true,
1463
- idempotentHint: false,
1464
- openWorldHint: true,
1465
- },
1466
- description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
1467
-
1468
- Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work.
1469
-
1470
- Concrete shape — analyze 47 source files without reading any of them:
1471
- ctx_execute(language: "javascript", code: \`
1472
- const fs = require('fs');
1473
- const files = fs.readdirSync('src').filter(f => f.endsWith('.ts'));
1474
- files.forEach(f => {
1475
- const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length;
1476
- console.log(f + ': ' + lines + ' lines');
1477
- });
1478
- \`)
1479
- // 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB.
1480
-
1481
- WHEN:
1482
- - You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer
1483
- - Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans)
1484
- - You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation
1485
- - You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process
1486
- - The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search
1487
-
1488
- WHEN NOT:
1489
- - Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler
1490
- - File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface
1491
- - You already know the output is one short fixed line and you want to read it as-is
1492
-
1493
- RETURNS:
1494
- Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections.
1495
-
1496
- EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('npm test', {encoding:'utf8', stdio:['ignore','pipe','pipe']}); console.log(out.split('\\\\n').filter(l => /(FAIL|✗|×|Error:|Tests +.*(failed|passed))/i.test(l)).slice(0, 60).join('\\\\n'))")
1497
- EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`,
1498
- inputSchema: z.object({
1499
- language: z
1500
- .enum([
1501
- "javascript",
1502
- "typescript",
1503
- "python",
1504
- "shell",
1505
- "ruby",
1506
- "go",
1507
- "rust",
1508
- "php",
1509
- "perl",
1510
- "r",
1511
- "elixir",
1512
- "csharp",
1513
- ])
1514
- .describe("Runtime language"),
1515
- code: z
1516
- .string()
1517
- .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context."),
1518
- timeout: z
1519
- .coerce.number()
1520
- .optional()
1521
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
1522
- // background: wrapped in coerceBoolean preprocessor so the literal
1523
- // strings "true"/"false" arriving from OpenCode's native plugin
1524
- // bridge (and several LLM providers' tool-call JSON) parse as the
1525
- // boolean the handler expects. z.coerce.boolean() is unsafe here —
1526
- // Boolean("false") is true. Fixes #627.
1527
- background: z
1528
- .preprocess(coerceBoolean, z.boolean())
1529
- .optional()
1530
- .default(false)
1531
- .describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
1532
- cwd: z
1533
- .string()
1534
- .optional()
1535
- .describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
1536
- intent: z
1537
- .string()
1538
- .optional()
1539
- .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
1540
- "indexes output into knowledge base and returns section titles + previews — not full content. " +
1541
- "Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
1542
- "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
1543
- }),
1544
- }, async ({ language, code, timeout, background, cwd, intent }) => {
1545
- // Security: deny-only firewall
1546
- if (language === "shell") {
1547
- const denied = checkDenyPolicy(code, "execute");
1548
- if (denied)
1549
- return denied;
1550
- }
1551
- else {
1552
- const denied = checkNonShellDenyPolicy(code, language, "execute");
1553
- if (denied)
1554
- return denied;
1555
- }
1556
- try {
1557
- // For JS/TS: wrap in async IIFE with fetch + http/https interceptors to track network bytes
1558
- let instrumentedCode = code;
1559
- if (language === "javascript" || language === "typescript") {
1560
- // Wrap user code in a closure that shadows CJS require with http/https interceptor.
1561
- // globalThis.require does NOT work because CJS require is module-scoped, not global.
1562
- // The closure approach (function(__cm_req){ var require=...; })(require) correctly
1563
- // shadows the CJS require for all code inside, including __cm_main().
1564
- instrumentedCode = `
1565
- // FS read instrumentation — count bytes read via fs.readFileSync/readFile
1566
- let __cm_fs=0;
1567
- process.on('exit',()=>{if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch{}});
1568
- (function(){
1569
- try{
1570
- var f=typeof require!=='undefined'?require('fs'):null;
1571
- if(!f)return;
1572
- var ors=f.readFileSync;
1573
- f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};
1574
- var orf=f.readFile;
1575
- if(orf)f.readFile=function(){var a=Array.from(arguments),cb=a.pop();orf.apply(this,a.concat([function(e,d){if(!e&&d){if(Buffer.isBuffer(d))__cm_fs+=d.length;else if(typeof d==='string')__cm_fs+=Buffer.byteLength(d);}cb(e,d);}]));};
1576
- }catch{}
1577
- })();
1578
- let __cm_net=0;
1579
- // Report network bytes on process exit — works with both promise and callback patterns.
1580
- // process.on('exit') fires after all I/O completes, unlike .finally() which fires
1581
- // when __cm_main() resolves (immediately for callback-based http.get without await).
1582
- process.on('exit',()=>{if(__cm_net>0)try{process.stderr.write('__CM_NET__:'+__cm_net+'\\n')}catch{}});
1583
- ;(function(__cm_req){
1584
- // Intercept globalThis.fetch
1585
- const __cm_f=globalThis.fetch;
1586
- globalThis.fetch=async(...a)=>{const r=await __cm_f(...a);
1587
- try{const cl=r.clone();const b=await cl.arrayBuffer();__cm_net+=b.byteLength}catch{}
1588
- return r};
1589
- // Shadow CJS require with http/https network tracking.
1590
- const __cm_hc=new Map();
1591
- const __cm_hm=new Set(['http','https','node:http','node:https']);
1592
- function __cm_wf(m,origFn){return function(...a){
1593
- const li=a.length-1;
1594
- if(li>=0&&typeof a[li]==='function'){const oc=a[li];a[li]=function(res){
1595
- res.on('data',function(c){__cm_net+=c.length});oc(res);};}
1596
- const req=origFn.apply(m,a);
1597
- const oOn=req.on.bind(req);
1598
- req.on=function(ev,cb,...r){
1599
- if(ev==='response'){return oOn(ev,function(res){
1600
- res.on('data',function(c){__cm_net+=c.length});cb(res);
1601
- },...r);}
1602
- return oOn(ev,cb,...r);
1603
- };
1604
- return req;
1605
- }}
1606
- var require=__cm_req?function(id){
1607
- const m=__cm_req(id);
1608
- if(!__cm_hm.has(id))return m;
1609
- const k=id.replace('node:','');
1610
- if(__cm_hc.has(k))return __cm_hc.get(k);
1611
- const w=Object.create(m);
1612
- if(typeof m.get==='function')w.get=__cm_wf(m,m.get);
1613
- if(typeof m.request==='function')w.request=__cm_wf(m,m.request);
1614
- __cm_hc.set(k,w);return w;
1615
- }:__cm_req;
1616
- if(__cm_req){if(__cm_req.resolve)require.resolve=__cm_req.resolve;
1617
- if(__cm_req.cache)require.cache=__cm_req.cache;}
1618
- async function __cm_main(){
1619
- ${code}
1620
- }
1621
- __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
1622
- })(typeof require!=='undefined'?require:null);`;
1623
- }
1624
- const effTimeout = resolveExecTimeout(timeout);
1625
- const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
1626
- // Echo the executed source code before stdout so users can audit
1627
- // and tooling can block command patterns (Issues #717 + #736).
1628
- // Built from the user-supplied `code`, NOT the instrumented variant.
1629
- const echo = buildExecuteEcho(language, code);
1630
- // Parse sandbox network metrics from stderr
1631
- const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
1632
- if (netMatch) {
1633
- sessionStats.bytesSandboxed += parseInt(netMatch[1]);
1634
- // Clean the metric line from stderr
1635
- result.stderr = result.stderr.replace(/\n?__CM_NET__:\d+\n?/g, "");
1636
- }
1637
- // Parse sandbox FS read metrics from stderr
1638
- const fsMatch = result.stderr?.match(/__CM_FS__:(\d+)/);
1639
- if (fsMatch) {
1640
- sessionStats.bytesSandboxed += parseInt(fsMatch[1]);
1641
- result.stderr = result.stderr.replace(/\n?__CM_FS__:\d+\n?/g, "");
1642
- }
1643
- if (result.timedOut) {
1644
- const partialOutput = result.stdout?.trim();
1645
- if (result.backgrounded && partialOutput) {
1646
- // Background mode: process is still running, return partial output as success
1647
- return trackResponse("ctx_execute", {
1648
- content: [
1649
- {
1650
- type: "text",
1651
- text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`,
1652
- },
1653
- ],
1654
- });
1655
- }
1656
- if (partialOutput) {
1657
- // Timeout with partial output — return as success with note
1658
- return trackResponse("ctx_execute", {
1659
- content: [
1660
- {
1661
- type: "text",
1662
- text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`,
1663
- },
1664
- ],
1665
- });
1666
- }
1667
- return trackResponse("ctx_execute", {
1668
- content: [
1669
- {
1670
- type: "text",
1671
- text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`,
1672
- },
1673
- ],
1674
- isError: true,
1675
- });
1676
- }
1677
- if (result.exitCode !== 0) {
1678
- const { isError, output } = classifyNonZeroExit({
1679
- language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
1680
- });
1681
- if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
1682
- trackIndexed(Buffer.byteLength(output));
1683
- return trackResponse("ctx_execute", {
1684
- content: [
1685
- { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` },
1686
- ],
1687
- isError,
1688
- });
1689
- }
1690
- // Auto-index large error output into FTS5 — no data loss
1691
- if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
1692
- trackIndexed(Buffer.byteLength(output));
1693
- return trackResponse("ctx_execute", {
1694
- content: [
1695
- { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` },
1696
- ],
1697
- isError,
1698
- });
1699
- }
1700
- return trackResponse("ctx_execute", {
1701
- content: [
1702
- { type: "text", text: `${echo}${output}` },
1703
- ],
1704
- isError,
1705
- });
1706
- }
1707
- const stdout = result.stdout || "(no output)";
1708
- // Intent-driven search: if intent provided and output is large enough
1709
- if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
1710
- trackIndexed(Buffer.byteLength(stdout));
1711
- return trackResponse("ctx_execute", {
1712
- content: [
1713
- { type: "text", text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` },
1714
- ],
1715
- });
1716
- }
1717
- // Auto-index large stdout into FTS5 — return pointer, not raw content
1718
- if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1719
- const indexed = indexStdout(stdout, `execute:${language}`);
1720
- // Prepend echo to the first text content so provenance still surfaces
1721
- const echoed = {
1722
- ...indexed,
1723
- content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1724
- ? { ...c, text: `${echo}${c.text}` }
1725
- : c),
1726
- };
1727
- return trackResponse("ctx_execute", echoed);
1728
- }
1729
- return trackResponse("ctx_execute", {
1730
- content: [
1731
- { type: "text", text: `${echo}${stdout}` },
1732
- ],
1733
- });
1734
- }
1735
- catch (err) {
1736
- const message = err instanceof Error ? err.message : String(err);
1737
- return trackResponse("ctx_execute", {
1738
- content: [
1739
- { type: "text", text: `Runtime error: ${message}` },
1740
- ],
1741
- isError: true,
1742
- });
1743
- }
1744
- });
1745
- // ─────────────────────────────────────────────────────────
1746
- // Helper: index stdout into FTS5 knowledge base
1747
- // ─────────────────────────────────────────────────────────
1748
- function indexStdout(stdout, source) {
1749
- const store = getStore();
1750
- trackIndexed(Buffer.byteLength(stdout));
1751
- const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
1752
- return {
1753
- content: [
1754
- {
1755
- type: "text",
1756
- text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`,
1757
- },
1758
- ],
1759
- };
1760
- }
1761
- // ─────────────────────────────────────────────────────────
1762
- // Helper: intent-driven search on execution output
1763
- // ─────────────────────────────────────────────────────────
1764
- const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines
1765
- const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, return pointer
1766
- function intentSearch(stdout, intent, source, maxResults = 5) {
1767
- const totalLines = stdout.split("\n").length;
1768
- const totalBytes = Buffer.byteLength(stdout);
1769
- // Index into the PERSISTENT store so user can ctx_search() later
1770
- const persistent = getStore();
1771
- const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
1772
- // Search the persistent store directly (porter → trigram → fuzzy)
1773
- let results = persistent.searchWithFallback(intent, maxResults, source);
1774
- // Extract distinctive terms as vocabulary hints for the LLM
1775
- const distinctiveTerms = persistent.getDistinctiveTerms(indexed.sourceId);
1776
- if (results.length === 0) {
1777
- const lines = [
1778
- `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
1779
- `No sections matched intent "${intent}" in ${totalLines}-line output (${(totalBytes / 1024).toFixed(1)}KB).`,
1780
- ];
1781
- if (distinctiveTerms.length > 0) {
1782
- lines.push("");
1783
- lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
1784
- }
1785
- lines.push("");
1786
- lines.push("Use ctx_search(queries: [...]) to explore the indexed content.");
1787
- return lines.join("\n");
1788
- }
1789
- // Return ONLY titles + first-line previews — not full content
1790
- const lines = [
1791
- `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
1792
- `${results.length} sections matched "${intent}" (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB):`,
1793
- "",
1794
- ];
1795
- for (const r of results) {
1796
- const preview = r.content.split("\n")[0].slice(0, 120);
1797
- lines.push(` - ${r.title}: ${preview}`);
1798
- }
1799
- if (distinctiveTerms.length > 0) {
1800
- lines.push("");
1801
- lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
1802
- }
1803
- lines.push("");
1804
- lines.push("Use ctx_search(queries: [...]) to retrieve full content of any section.");
1805
- return lines.join("\n");
1806
- }
1807
- // ─────────────────────────────────────────────────────────
1808
- // Tool: execute_file
1809
- // ─────────────────────────────────────────────────────────
1810
- server.registerTool("ctx_execute_file", {
1811
- // #852: the host's MCP approval prompt renders only the tool name/title +
1812
- // raw args — the title is the one server-controlled signal, so make it
1813
- // unambiguously announce code execution + file read for the reviewer.
1814
- title: "Run code over a file (executes code, reads the given path)",
1815
- // #846: runs arbitrary code over a file in a sandbox with full network access.
1816
- annotations: {
1817
- readOnlyHint: false,
1818
- destructiveHint: true,
1819
- idempotentHint: false,
1820
- openWorldHint: true,
1821
- },
1822
- description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversation — the file bytes stay in the sandbox.
1823
-
1824
- Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable.
1825
-
1826
- WHEN:
1827
- - You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it
1828
- - The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim
1829
- - The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work
1830
- - The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search
1831
-
1832
- WHEN NOT:
1833
- - You intend to EDIT the file — use Read so the subsequent Edit can match the exact text
1834
- - You only need one specific line and you know its offset — Read with offset/limit is the simplest path
1835
- - The file is small AND you will consume all of it for understanding/editing — Read directly
1836
-
1837
- RETURNS:
1838
- Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout.
1839
-
1840
- EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))")
1841
- EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`,
1842
- inputSchema: z.object({
1843
- path: z
1844
- .string()
1845
- .describe("Absolute file path or relative to project root"),
1846
- language: z
1847
- .enum([
1848
- "javascript",
1849
- "typescript",
1850
- "python",
1851
- "shell",
1852
- "ruby",
1853
- "go",
1854
- "rust",
1855
- "php",
1856
- "perl",
1857
- "r",
1858
- "elixir",
1859
- "csharp",
1860
- ])
1861
- .describe("Runtime language"),
1862
- code: z
1863
- .string()
1864
- .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine."),
1865
- timeout: z
1866
- .coerce.number()
1867
- .optional()
1868
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
1869
- intent: z
1870
- .string()
1871
- .optional()
1872
- .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
1873
- "returns only matching sections via BM25 search instead of truncated output."),
1874
- }),
1875
- }, async ({ path, language, code, timeout, intent }) => {
1876
- // Security (#852): confine the processed file to the project root so
1877
- // ctx_execute_file cannot be used to escape the host's sandbox/permission
1878
- // controls. Runs before the deny-glob check — boundary first, then policy.
1879
- const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
1880
- if (boundaryDenied)
1881
- return boundaryDenied;
1882
- // Security: check file path against Read deny patterns
1883
- const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
1884
- if (pathDenied)
1885
- return pathDenied;
1886
- // Security: check code parameter against Bash deny patterns
1887
- if (language === "shell") {
1888
- const codeDenied = checkDenyPolicy(code, "execute_file");
1889
- if (codeDenied)
1890
- return codeDenied;
1891
- }
1892
- else {
1893
- const codeDenied = checkNonShellDenyPolicy(code, language, "execute_file");
1894
- if (codeDenied)
1895
- return codeDenied;
1896
- }
1897
- try {
1898
- const effTimeout = resolveExecTimeout(timeout);
1899
- const result = await executor.executeFile({
1900
- path,
1901
- language,
1902
- code,
1903
- timeout: effTimeout,
1904
- });
1905
- // Echo path + executed source code before stdout for audit/debug
1906
- // (Issues #717 + #736).
1907
- const echo = buildExecuteEcho(language, code, path);
1908
- if (result.timedOut) {
1909
- return trackResponse("ctx_execute_file", {
1910
- content: [
1911
- {
1912
- type: "text",
1913
- text: `${echo}Timed out processing ${path} after ${effTimeout}ms`,
1914
- },
1915
- ],
1916
- isError: true,
1917
- });
1918
- }
1919
- if (result.exitCode !== 0) {
1920
- const { isError, output } = classifyNonZeroExit({
1921
- language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
1922
- });
1923
- if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
1924
- trackIndexed(Buffer.byteLength(output));
1925
- return trackResponse("ctx_execute_file", {
1926
- content: [
1927
- { type: "text", text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` },
1928
- ],
1929
- isError,
1930
- });
1931
- }
1932
- // Auto-index large error output into FTS5 — no data loss
1933
- if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
1934
- trackIndexed(Buffer.byteLength(output));
1935
- return trackResponse("ctx_execute_file", {
1936
- content: [
1937
- { type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` },
1938
- ],
1939
- isError,
1940
- });
1941
- }
1942
- return trackResponse("ctx_execute_file", {
1943
- content: [
1944
- { type: "text", text: `${echo}${output}` },
1945
- ],
1946
- isError,
1947
- });
1948
- }
1949
- const stdout = result.stdout || "(no output)";
1950
- if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
1951
- trackIndexed(Buffer.byteLength(stdout));
1952
- return trackResponse("ctx_execute_file", {
1953
- content: [
1954
- { type: "text", text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` },
1955
- ],
1956
- });
1957
- }
1958
- // Auto-index large stdout into FTS5 — return pointer, not raw content
1959
- if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
1960
- const indexed = indexStdout(stdout, `file:${path}`);
1961
- const echoed = {
1962
- ...indexed,
1963
- content: indexed.content.map((c, i) => i === 0 && c.type === "text"
1964
- ? { ...c, text: `${echo}${c.text}` }
1965
- : c),
1966
- };
1967
- return trackResponse("ctx_execute_file", echoed);
1968
- }
1969
- return trackResponse("ctx_execute_file", {
1970
- content: [
1971
- { type: "text", text: `${echo}${stdout}` },
1972
- ],
1973
- });
1974
- }
1975
- catch (err) {
1976
- const message = err instanceof Error ? err.message : String(err);
1977
- return trackResponse("ctx_execute_file", {
1978
- content: [
1979
- { type: "text", text: `Runtime error: ${message}` },
1980
- ],
1981
- isError: true,
1982
- });
1983
- }
1984
- });
1985
- // ─────────────────────────────────────────────────────────
1986
- // Tool: index
1987
- // ─────────────────────────────────────────────────────────
1988
- server.registerTool("ctx_index", {
1989
- title: "Index Content",
1990
- // #846: writes content into the local FTS5 store (additive, not destructive;
1991
- // re-indexing the same content adds rows, so not idempotent). No network.
1992
- annotations: {
1993
- readOnlyHint: false,
1994
- destructiveHint: false,
1995
- idempotentHint: false,
1996
- openWorldHint: false,
1997
- },
1998
- description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated.
1999
-
2000
- WHEN:
2001
- - Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)
2002
- - API references (endpoint details, parameter specs, response schemas)
2003
- - MCP tools/list output (exact tool signatures and descriptions)
2004
- - Skill prompts and instructions that are too large to keep verbatim in conversation
2005
- - README files, migration guides, changelog entries
2006
- - Any content with code examples you may need to reference precisely later
2007
-
2008
- WHEN NOT:
2009
- - Log files, test output, CSV, or build output — use ctx_execute_file, which processes in-sandbox without persisting bytes
2010
- - Single-use ephemeral content you will not query later — keep it inline if it fits, or ctx_execute_file it
2011
-
2012
- RETURNS:
2013
- Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "<label>"). When \`path\` is provided, a content hash is stored so ctx_search results auto-flag staleness on future calls.
2014
-
2015
- EXAMPLE: ctx_index(content: "# React useEffect\\n\\nThe Effect Hook lets you ...", source: "react-useeffect-docs")
2016
- EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
2017
- inputSchema: z.object({
2018
- content: z
2019
- .string()
2020
- .optional()
2021
- .describe("Raw text/markdown to index. Provide this OR path, not both."),
2022
- path: z
2023
- .string()
2024
- .optional()
2025
- .describe("File OR directory path to read and index (content never enters context). Provide this OR content. Directory paths trigger a bounded recursive walk (#687)."),
2026
- source: z
2027
- .string()
2028
- .optional()
2029
- .describe("Label for the indexed content (e.g., 'Context7: React useEffect', 'Skill: frontend-design')"),
2030
- include: z.array(z.string()).optional().describe("Directory-only: glob patterns to include (default: all matching extensions)."),
2031
- exclude: z.array(z.string()).optional().describe("Directory-only: glob patterns to exclude. Merged with defaults (node_modules, .git, dist, build, .next, coverage, .venv, __pycache__, .DS_Store)."),
2032
- maxDepth: z.number().int().min(0).optional().describe("Directory-only: max recursion depth from root (default: 5)."),
2033
- maxFiles: z.number().int().min(1).optional().describe("Directory-only: hard cap on files indexed (default: 200) — FTS5 blow-up guard."),
2034
- extensions: z.array(z.string()).optional().describe("Directory-only: allowed file extensions (default: .md .mdx .txt .json .yaml .yml .ts .tsx .js .jsx .py .rs .go .sh)."),
2035
- respectGitignore: z.boolean().optional().describe("Directory-only: apply nearest .gitignore (default: true)."),
2036
- followSymlinks: z.boolean().optional().describe("Directory-only: follow directory symlinks (default: false — cycle hazard + escape risk)."),
2037
- }),
2038
- }, async ({ content, path, source, include, exclude, maxDepth, maxFiles, extensions, respectGitignore, followSymlinks }) => {
2039
- if (!content && !path) {
2040
- return trackResponse("ctx_index", {
2041
- content: [
2042
- {
2043
- type: "text",
2044
- text: "Error: Either content or path must be provided",
2045
- },
2046
- ],
2047
- isError: true,
2048
- });
2049
- }
2050
- // Apply Read deny-policy to prevent indexing sensitive files into the
2051
- // FTS5 store, which would otherwise be queryable via ctx_search and
2052
- // exfiltrate content into the model's context (issue #442). Mirrors the
2053
- // check ctx_execute_file already performs.
2054
- if (path) {
2055
- const pathDenied = checkFilePathDenyPolicy(path, "ctx_index");
2056
- if (pathDenied)
2057
- return pathDenied;
2058
- }
2059
- try {
2060
- const resolvedPath = path ? resolveProjectPath(path) : undefined;
2061
- // Directory dispatch (#687, reported by @matiasduartee). When the
2062
- // resolved path is a directory, walk it bounded and re-enter `index()`
2063
- // per-file so the security gate at store.ts:845 (TOCTOU defense from
2064
- // #442 round-3) keeps running for every file.
2065
- //
2066
- // Root-level symlink defense: the deny-glob check above ran on the
2067
- // user-supplied `path`. If `path` is a symlink whose target lands in
2068
- // a sensitive directory (e.g. `/tmp/link -> /etc`), statSync would
2069
- // happily report directory and walkDirectoryDetailed would
2070
- // realpathSync internally, walking /etc with the user's deny globs
2071
- // bound to /tmp/link instead of the real target. Detect the symlink
2072
- // with lstatSync, follow it once, and re-apply the deny check
2073
- // against the realpath so the user's deny globs see the actual
2074
- // walk root.
2075
- if (resolvedPath && existsSync(resolvedPath)) {
2076
- const lst = lstatSync(resolvedPath);
2077
- if (lst.isSymbolicLink()) {
2078
- let realTarget;
2079
- try {
2080
- realTarget = realpathSync(resolvedPath);
2081
- }
2082
- catch {
2083
- return trackResponse("ctx_index", {
2084
- content: [{ type: "text", text: "Error: symlink target could not be resolved." }],
2085
- });
2086
- }
2087
- if (realTarget !== resolvedPath) {
2088
- const realDenied = checkFilePathDenyPolicy(realTarget, "ctx_index");
2089
- if (realDenied)
2090
- return realDenied;
2091
- }
2092
- }
2093
- }
2094
- if (resolvedPath && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
2095
- const store = getStore();
2096
- const projectDir = getProjectDir();
2097
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
2098
- const isWin32 = process.platform === "win32";
2099
- const perFileDeny = (absPath) => {
2100
- try {
2101
- return evaluateFilePath(absPath, denyGlobs, isWin32, projectDir).denied;
2102
- }
2103
- catch {
2104
- return false; // fail-open consistent with checkFilePathDenyPolicy
2105
- }
2106
- };
2107
- const dirResult = store.indexDirectory({
2108
- path: resolvedPath,
2109
- source: source ?? resolvedPath,
2110
- attribution: currentAttribution(),
2111
- perFileDeny,
2112
- include,
2113
- exclude,
2114
- maxDepth,
2115
- maxFiles,
2116
- extensions,
2117
- respectGitignore,
2118
- followSymlinks,
2119
- });
2120
- const capNote = dirResult.capped
2121
- ? ` (cap reached — only first ${dirResult.filesIndexed} of ${dirResult.totalSeen}+ files; raise maxFiles to index more)`
2122
- : "";
2123
- const denyNote = dirResult.denied > 0
2124
- ? ` (${dirResult.denied} file${dirResult.denied === 1 ? "" : "s"} blocked by Read deny policy)`
2125
- : "";
2126
- const failNote = dirResult.failed > 0
2127
- ? ` (${dirResult.failed} file${dirResult.failed === 1 ? "" : "s"} failed to read)`
2128
- : "";
2129
- return trackResponse("ctx_index", {
2130
- content: [
2131
- {
2132
- type: "text",
2133
- text: `Indexed ${dirResult.filesIndexed} file${dirResult.filesIndexed === 1 ? "" : "s"} (${dirResult.totalChunks} sections) from directory: ${dirResult.label}${capNote}${denyNote}${failNote}\nUse ctx_search(queries: ["..."]) to query this content.`,
2134
- },
2135
- ],
2136
- });
2137
- }
2138
- // Track the raw bytes being indexed (content or file)
2139
- if (content)
2140
- trackIndexed(Buffer.byteLength(content));
2141
- else if (resolvedPath) {
2142
- try {
2143
- const fs = await import("fs");
2144
- trackIndexed(fs.readFileSync(resolvedPath).byteLength);
2145
- }
2146
- catch { /* ignore — file read errors handled by store */ }
2147
- }
2148
- const store = getStore();
2149
- const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
2150
- return trackResponse("ctx_index", {
2151
- content: [
2152
- {
2153
- type: "text",
2154
- text: `Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from: ${result.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${result.label}" to scope results.`,
2155
- },
2156
- ],
2157
- });
2158
- }
2159
- catch (err) {
2160
- const message = err instanceof Error ? err.message : String(err);
2161
- return trackResponse("ctx_index", {
2162
- content: [
2163
- { type: "text", text: `Index error: ${message}` },
2164
- ],
2165
- isError: true,
2166
- });
2167
- }
2168
- });
2169
- // ─────────────────────────────────────────────────────────
2170
- // Tool: search — progressive throttling
2171
- // ─────────────────────────────────────────────────────────
2172
- // Track search calls per N-second window for progressive throttling.
2173
- // Defaults preserve the historical behavior (60s window, soft-cap at 3
2174
- // calls, hard-block at 8). All three thresholds are overridable via env
2175
- // vars so users can loosen or tighten the policy without forking. Invalid
2176
- // values (non-positive numbers, NaN) fall back to the default to avoid
2177
- // silently disabling the protection.
2178
- function readPositiveEnv(name, defaultValue) {
2179
- const raw = process.env[name];
2180
- if (!raw)
2181
- return defaultValue;
2182
- const parsed = Number(raw);
2183
- return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
2184
- }
2185
- const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
2186
- const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
2187
- const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
2188
- // #769: progressive throttle bucketed PER agent-context, not machine-global.
2189
- // Concurrent subagents share ONE MCP server process; a single global counter
2190
- // summed their independent searches into one budget and hard-blocked
2191
- // legitimate parallel fan-out. The guard keys each actor's window separately
2192
- // so single-actor flood protection is preserved while fan-out is not starved.
2193
- const searchFloodGuard = new FloodGuard({
2194
- windowMs: SEARCH_WINDOW_MS,
2195
- softCapAfter: SEARCH_MAX_RESULTS_AFTER,
2196
- blockAfter: SEARCH_BLOCK_AFTER,
2197
- });
2198
- /**
2199
- * Per-agent flood-guard key. Each concurrent subagent in a Claude Code
2200
- * Task/Workflow fan-out runs under its own session id (written to SessionDB
2201
- * via hooks), so currentAttribution().sessionId is the per-agent discriminator
2202
- * already available MCP-side. Falls back to a single shared bucket when no
2203
- * identity is resolvable (preserves today's single-threaded behaviour).
2204
- */
2205
- function searchFloodGuardKey() {
2206
- try {
2207
- return currentAttribution()?.sessionId ?? "__default__";
2208
- }
2209
- catch {
2210
- return "__default__";
2211
- }
2212
- }
2213
- /**
2214
- * Defensive coercion: parse stringified JSON arrays, AND lift a bare
2215
- * non-empty string into a single-element array.
2216
- *
2217
- * Two shapes show up from the wild:
2218
- * 1. `"[\"a\",\"b\"]"` — Claude Code double-serialization bug
2219
- * (https://github.com/anthropics/claude-code/issues/34520).
2220
- * 2. `"single query"` — some LLM providers / OpenCode's native plugin
2221
- * bridge deliver a single string when the schema expects `string[]`
2222
- * (issue #627). v1.0.139 (#621) made the bridge run the Zod schema,
2223
- * so this now surfaces as `Expected array, received string`. The
2224
- * ergonomic recovery is to treat it as `["single query"]`.
2225
- *
2226
- * An empty string is intentionally NOT lifted — empty input should still
2227
- * fail Zod's `.min(1)` check rather than masquerade as `[""]`.
2228
- */
2229
- function coerceJsonArray(val) {
2230
- if (typeof val === "string") {
2231
- const trimmed = val.trim();
2232
- if (trimmed.length === 0)
2233
- return val; // let zod produce "non-empty" error
2234
- try {
2235
- const parsed = JSON.parse(val);
2236
- if (Array.isArray(parsed))
2237
- return parsed;
2238
- }
2239
- catch { /* fall through — not JSON, treat as bare-string lift */ }
2240
- // Bare-string lift (#627): single query delivered as a plain string.
2241
- return [val];
2242
- }
2243
- return val;
2244
- }
2245
- /**
2246
- * Defensive coercion: accept the string literals "true"/"false" as
2247
- * booleans. The OpenCode native plugin bridge (and several LLM providers'
2248
- * tool-call JSON) stringifies primitives — `background:"false"` instead
2249
- * of `background:false`, `confirm:"true"` instead of `confirm:true`.
2250
- *
2251
- * We deliberately do NOT use `z.coerce.boolean()` for boolean fields:
2252
- * `Boolean("false")` is `true`, so Zod's coerce path silently flips the
2253
- * meaning. This helper recognises only the documented literal forms and
2254
- * passes anything else through untouched so Zod surfaces the right error.
2255
- *
2256
- * Fixes #627.
2257
- */
2258
- function coerceBoolean(val) {
2259
- if (typeof val === "string") {
2260
- const t = val.trim().toLowerCase();
2261
- if (t === "true")
2262
- return true;
2263
- if (t === "false")
2264
- return false;
2265
- }
2266
- return val;
2267
- }
2268
- /**
2269
- * Coerce commands array: handles double-serialization AND the case where
2270
- * the model passes plain command strings instead of {label, command} objects.
2271
- */
2272
- function coerceCommandsArray(val) {
2273
- const arr = coerceJsonArray(val);
2274
- if (Array.isArray(arr)) {
2275
- return arr.map((item, i) => typeof item === "string" ? { label: `cmd_${i + 1}`, command: item } : item);
2276
- }
2277
- return arr;
2278
- }
2279
- server.registerTool("ctx_search", {
2280
- title: "Search Indexed Content",
2281
- // #846: read-only query over the local FTS5 store. No mutation, no network.
2282
- annotations: {
2283
- readOnlyHint: true,
2284
- destructiveHint: false,
2285
- idempotentHint: true,
2286
- openWorldHint: false,
2287
- },
2288
- description: `Search a unified knowledge base with a multi-strategy ranking pipeline. Two parallel matchers run on every query: a Porter-stemming matcher ("caching" finds "cached", "caches", "cach") and a trigram-substring matcher ("useEff" finds "useEffect"). Their ranked lists are merged via Reciprocal Rank Fusion, so a document that ranks well in both surfaces above one that wins only on a single strategy. Multi-term queries get an additional proximity-rerank pass that boosts passages where the query terms appear close together. Typos are corrected via Levenshtein distance and re-searched. Result snippets are window-extracted around the matched terms, not blindly truncated.
2289
-
2290
- The knowledge base is unified: queries reach indexed content you stored (ctx_index, ctx_fetch_and_index, ctx_batch_execute output) AND auto-captured session memory written by hooks (decisions, errors, blockers, plans, user prompts, rejected approaches, tool failures, compaction guides — 26 event categories). File-backed sources carry a content hash and auto-flag staleness when the source file changes.
2291
-
2292
- WHEN:
2293
- - You want to recall something that exists in storage (recently indexed content, prior session events, auto-memory) instead of re-reading raw sources
2294
- - You have multiple related questions about the same body of knowledge — batch every question into one call (the ranking pipeline runs per-query but the round-trip cost is paid once)
2295
- - You want to scope the query to one labelled source (pass \`source\` — partial match is fine)
2296
- - You want a chronological view across current session + prior sessions + persistent auto-memory (pass \`sort: "timeline"\` — the default \`relevance\` mode only ranks within the current session)
2297
- - You want to filter ranked results by content shape (pass \`contentType: "code"\` to surface implementation snippets or \`contentType: "prose"\` to surface explanations)
2298
-
2299
- WHEN NOT:
2300
- - The data you want to query has never been stored in the knowledge base AND no session memory has accumulated around it — capture first (run a gather-and-index call), then come back here to query
2301
- - You have one ad-hoc question against data that is not in the knowledge base — answer it inline by running code in the sandbox tool; one round-trip instead of capture-then-query
2302
-
2303
- RETURNS:
2304
- Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts. Each response carries a throttle counter (call #N/M in the rolling time window); results taper toward the soft cap and calls block after the hard cap. Tune via CONTEXT_MODE_SEARCH_WINDOW_MS, CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER, CONTEXT_MODE_SEARCH_BLOCK_AFTER.
2305
-
2306
- EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
2307
- EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
2308
- EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
2309
- EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
2310
- // Schema construction is centralised in `src/search/ctx-search-schema.ts`
2311
- // so the conditional `project` field (only registered when the host runs
2312
- // in shared-DB mode, `CONTEXT_MODE_PROJECT_DIR` set at module load) is a
2313
- // hard property of the tool surface — not a runtime hint. Fixes #737.
2314
- inputSchema: buildCtxSearchInputSchema(CTX_SEARCH_SHARED_MODE),
2315
- }, async (params) => {
2316
- try {
2317
- const store = getStore();
2318
- const sort = params.sort || "relevance";
2319
- // Guard: redirect when the index is empty — ctx_search is a follow-up
2320
- // tool that requires prior indexing. Skip for timeline mode (SessionDB/auto-memory may have data).
2321
- if (sort !== "timeline" && store.getStats().chunks === 0) {
2322
- return trackResponse("ctx_search", {
2323
- content: [{
2324
- type: "text",
2325
- text: "Knowledge base is empty — no content has been indexed yet.\n\n" +
2326
- "ctx_search is a follow-up tool that queries previously indexed content. " +
2327
- "To gather and index content first, use:\n" +
2328
- " • ctx_batch_execute(commands, queries) — run commands, auto-index output, and search in one call\n" +
2329
- " • ctx_fetch_and_index(url) — fetch a URL, index it, then search with ctx_search\n" +
2330
- " • ctx_index(content, source) — manually index text content\n\n" +
2331
- "After indexing, ctx_search becomes available for follow-up queries.",
2332
- }],
2333
- isError: true,
2334
- });
2335
- }
2336
- const raw = params;
2337
- // Normalize: accept both query (string) and queries (array)
2338
- const queryList = [];
2339
- if (Array.isArray(raw.queries) && raw.queries.length > 0) {
2340
- queryList.push(...raw.queries);
2341
- }
2342
- else if (typeof raw.query === "string" && raw.query.length > 0) {
2343
- queryList.push(raw.query);
2344
- }
2345
- if (queryList.length === 0) {
2346
- return trackResponse("ctx_search", {
2347
- content: [{ type: "text", text: "Error: provide query or queries." }],
2348
- isError: true,
2349
- });
2350
- }
2351
- const { limit = 3, source, contentType, project } = params;
2352
- // Resolve the per-project scope (#737). When shared-DB mode is off the
2353
- // resolver returns `undefined` and `project` is silently ignored — the
2354
- // per-project DB is naturally isolated by directory hash, so there is
2355
- // nothing for an in-process filter to do.
2356
- const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
2357
- // Progressive throttling: track calls per agent-context window (#769).
2358
- const now = Date.now();
2359
- const flood = searchFloodGuard.record(searchFloodGuardKey(), now);
2360
- const searchCallCount = flood.count;
2361
- // After SEARCH_BLOCK_AFTER calls (for THIS agent): refuse
2362
- if (flood.blocked) {
2363
- return trackResponse("ctx_search", {
2364
- content: [{
2365
- type: "text",
2366
- text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - flood.windowStart) / 1000)}s. ` +
2367
- "You're flooding context. STOP making individual search calls. " +
2368
- "Use ctx_batch_execute(commands, queries) for your next research step.",
2369
- }],
2370
- isError: true,
2371
- });
2372
- }
2373
- // Determine per-query result limit based on throttle level
2374
- const effectiveLimit = flood.softCapped
2375
- ? 1 // after soft cap: only 1 result per query
2376
- : Math.min(limit, 2); // normal: max 2
2377
- const MAX_TOTAL = 40 * 1024; // 40KB total cap
2378
- let totalSize = 0;
2379
- const sections = [];
2380
- // Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
2381
- // Issue #737: also open in relevance mode when a string `projectScope`
2382
- // is in play — the 2-step IN-clause needs SessionDB to translate
2383
- // `project_dir` → allow-set of session ids for the ContentStore filter.
2384
- let timelineDB = null;
2385
- const needsSessionDB = sort === "timeline" || typeof projectScope === "string";
2386
- if (needsSessionDB) {
2387
- try {
2388
- const sessionsDir = getSessionDir();
2389
- const projectDir = getProjectDir();
2390
- const dbFile = resolveSessionDbPath({ projectDir, sessionsDir });
2391
- if (existsSync(dbFile)) {
2392
- timelineDB = new SessionDB({ dbPath: dbFile });
2393
- }
2394
- }
2395
- catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
2396
- }
2397
- // Resolve the session-id allow-set once for the relevance-mode path —
2398
- // searchAllSources resolves its own copy for timeline mode. Empty set
2399
- // is preserved (means "no events for this project"), which surfaces
2400
- // only legacy `session_id=''` chunks via the post-filter.
2401
- let relevanceAllowSet;
2402
- if (typeof projectScope === "string" && timelineDB) {
2403
- try {
2404
- relevanceAllowSet = new Set(timelineDB.getSessionIdsForProject(projectScope));
2405
- }
2406
- catch { /* best-effort */ }
2407
- }
2408
- const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
2409
- try {
2410
- for (const q of queryList) {
2411
- if (totalSize > MAX_TOTAL) {
2412
- sections.push(`## ${q}\n(output cap reached)\n`);
2413
- continue;
2414
- }
2415
- let results;
2416
- if (sort === "timeline") {
2417
- results = searchAllSources({
2418
- query: q,
2419
- limit: effectiveLimit,
2420
- store,
2421
- sort,
2422
- source,
2423
- contentType,
2424
- sessionDB: timelineDB,
2425
- projectDir: getProjectDir(),
2426
- configDir,
2427
- adapter: _detectedAdapter ?? undefined,
2428
- projectScope,
2429
- });
2430
- }
2431
- else {
2432
- results = store.searchWithFallback(q, effectiveLimit, source, contentType, "like", relevanceAllowSet);
2433
- }
2434
- if (results.length === 0) {
2435
- sections.push(`## ${q}\nNo results found.`);
2436
- continue;
2437
- }
2438
- const formatted = results
2439
- .map((r, i) => {
2440
- const origin = r.origin || "current-session";
2441
- const ts = r.timestamp ? r.timestamp.slice(0, 16).replace("T", " ") : "";
2442
- const header = `--- [${origin}${ts ? " | " + ts : ""} | ${r.source}] ---`;
2443
- const heading = `### ${r.title}`;
2444
- const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
2445
- return `${header}\n${heading}\n\n${snippet}`;
2446
- })
2447
- .join("\n\n");
2448
- sections.push(`## ${q}\n\n${formatted}`);
2449
- totalSize += formatted.length;
2450
- }
2451
- }
2452
- finally {
2453
- try {
2454
- timelineDB?.close();
2455
- }
2456
- catch { }
2457
- }
2458
- let output = sections.join("\n\n---\n\n");
2459
- // Report auto-refreshed stale sources
2460
- if (store.lastRefreshCount > 0) {
2461
- output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
2462
- }
2463
- // Throttle counter — always surfaced so agents can pace themselves
2464
- // proactively instead of discovering the limit only after results are
2465
- // already truncated. Soft warning after SEARCH_MAX_RESULTS_AFTER calls;
2466
- // gentle informational line before that.
2467
- const throttleRemaining = Math.max(0, SEARCH_BLOCK_AFTER - searchCallCount);
2468
- const softCapRemaining = Math.max(0, SEARCH_MAX_RESULTS_AFTER - searchCallCount);
2469
- if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
2470
- output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2471
- `Results limited to ${effectiveLimit}/query. ${throttleRemaining} call(s) remaining before block. ` +
2472
- `Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
2473
- }
2474
- else {
2475
- output += `\n\n> Throttle: call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
2476
- `${softCapRemaining} call(s) before soft cap. ` +
2477
- `Prefer ctx_search(queries: [...]) array form for multi-query workloads — it counts as a single call.`;
2478
- }
2479
- if (output.trim().length === 0) {
2480
- const sources = store.listSources();
2481
- const sourceList = sources.length > 0
2482
- ? `\nIndexed sources: ${sources.map((s) => `"${s.label}" (${s.chunkCount} sections)`).join(", ")}`
2483
- : "";
2484
- return trackResponse("ctx_search", {
2485
- content: [{ type: "text", text: `No results found.${sourceList}` }],
2486
- });
2487
- }
2488
- return trackResponse("ctx_search", {
2489
- content: [{ type: "text", text: output }],
2490
- });
2491
- }
2492
- catch (err) {
2493
- const message = err instanceof Error ? err.message : String(err);
2494
- return trackResponse("ctx_search", {
2495
- content: [{ type: "text", text: `Search error: ${message}` }],
2496
- isError: true,
2497
- });
2498
- }
2499
- });
2500
- // ─────────────────────────────────────────────────────────
2501
- // Turndown path resolution (external dep, like better-sqlite3)
2502
- // ─────────────────────────────────────────────────────────
2503
- let _turndownPath = null;
2504
- let _gfmPluginPath = null;
2505
- function resolveTurndownPath() {
2506
- if (!_turndownPath) {
2507
- const require = createRequire(import.meta.url);
2508
- _turndownPath = require.resolve("turndown");
2509
- }
2510
- return _turndownPath;
2511
- }
2512
- function resolveGfmPluginPath() {
2513
- if (!_gfmPluginPath) {
2514
- const require = createRequire(import.meta.url);
2515
- _gfmPluginPath = require.resolve("turndown-plugin-gfm");
2516
- }
2517
- return _gfmPluginPath;
2518
- }
2519
- // ─────────────────────────────────────────────────────────
2520
- // Tool: fetch_and_index
2521
- // ─────────────────────────────────────────────────────────
2522
- // Subprocess code that fetches a URL, detects Content-Type, and outputs a
2523
- // __CM_CT__:<type> marker on the first line so the handler can route to the
2524
- // appropriate indexing strategy. HTML is converted to markdown via Turndown.
2525
- export function buildFetchCode(url, outputPath) {
2526
- const turndownPath = JSON.stringify(resolveTurndownPath());
2527
- const gfmPath = JSON.stringify(resolveGfmPluginPath());
2528
- const escapedOutputPath = JSON.stringify(outputPath);
2529
- // Embed classifyIp into the subprocess so the connect-time DNS lookup is
2530
- // re-validated with the same policy as ssrfGuard. Without this, an attacker
2531
- // can serve a public IP for the parent's pre-flight ssrfGuard lookup and
2532
- // then a blocked IP (e.g. 169.254.169.254 IMDS) for the subprocess fetch's
2533
- // own lookup — classic DNS rebinding across the parent/child boundary.
2534
- //
2535
- // CRITICAL: bundlers (esbuild) rename top-level identifiers — `classifyIp`
2536
- // becomes e.g. `_h` in server.bundle.mjs. `classifyIp.toString()` returns
2537
- // the renamed source `function _h(t){...}`, but the embedded subprocess
2538
- // template references the literal name `classifyIp` (and the function's
2539
- // own internal recursion is also `_h(...)`). Result: the subprocess sees
2540
- // `function _h(t){...; return _h(...)}` injected, then references to
2541
- // `classifyIp` blow up with `ReferenceError: classifyIp is not defined`.
2542
- //
2543
- // Fix: emit `var <fnName> = <fn-expr>; var classifyIp = <fnName>;`. The
2544
- // named function expression preserves recursion under whatever name the
2545
- // bundler chose, and the alias re-exposes the canonical `classifyIp`
2546
- // identifier the rest of the embedded script depends on.
2547
- const classifyIpInner = classifyIp.toString();
2548
- const classifyIpFnName = classifyIp.name || "classifyIp";
2549
- const classifyIpSrc = classifyIpFnName === "classifyIp"
2550
- ? `var classifyIp = ${classifyIpInner};`
2551
- : `var ${classifyIpFnName} = ${classifyIpInner};\nvar classifyIp = ${classifyIpFnName};`;
2552
- const strictMode = process.env.CTX_FETCH_STRICT === "1";
2553
- return `
2554
- const TurndownService = require(${turndownPath});
2555
- const { gfm } = require(${gfmPath});
2556
- const fs = require('fs');
2557
- const dns = require('no' + 'de:dns');
2558
- const dnsPromises = require('no' + 'de:dns/promises');
2559
- const url = ${JSON.stringify(url)};
2560
- const outputPath = ${escapedOutputPath};
2561
-
2562
- // Strip proxy env vars from this subprocess only. A configured outbound
2563
- // proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) would route fetch through
2564
- // an arbitrary target — DNS resolution happens at the proxy and the
2565
- // in-subprocess DNS rebinding guard never sees the rebound IP. The
2566
- // sandbox fetch path has no legitimate need for an upstream proxy.
2567
- delete process.env.HTTP_PROXY;
2568
- delete process.env.HTTPS_PROXY;
2569
- delete process.env.ALL_PROXY;
2570
- delete process.env.http_proxy;
2571
- delete process.env.https_proxy;
2572
- delete process.env.all_proxy;
2573
- delete process.env.npm_config_proxy;
2574
- delete process.env.npm_config_https_proxy;
2575
-
2576
- ${classifyIpSrc}
2577
-
2578
- const STRICT = ${JSON.stringify(strictMode)};
2579
-
2580
- // SSRF rebinding defense: every dns.lookup call inside this subprocess
2581
- // (including the one undici performs to connect the fetch socket) is
2582
- // re-validated against the same policy ssrfGuard runs in the parent.
2583
- // Even if a hostname rebinds between the parent's pre-flight check and
2584
- // the subprocess's actual connect, the connect-time lookup re-classifies
2585
- // every returned record and aborts before TCP if any verdict is "block".
2586
- const _origLookup = dns.lookup;
2587
- dns.lookup = function patchedLookup(hostname, options, callback) {
2588
- if (typeof options === 'function') { callback = options; options = {}; }
2589
- if (typeof options === 'number') { options = { family: options }; }
2590
- const wantAll = options && options.all;
2591
- const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
2592
- _origLookup(hostname, opts, function(err, records) {
2593
- if (err) return callback(err);
2594
- if (!Array.isArray(records)) {
2595
- records = [{ address: records, family: (options && options.family) || 4 }];
2596
- }
2597
- for (var i = 0; i < records.length; i++) {
2598
- var verdict = classifyIp(records[i].address);
2599
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2600
- return callback(new Error(
2601
- 'SSRF blocked at connect-time: ' + hostname +
2602
- ' resolves to ' + records[i].address +
2603
- ' (' + verdict + ')'
2604
- ));
2605
- }
2606
- }
2607
- if (wantAll) callback(null, records);
2608
- else callback(null, records[0].address, records[0].family);
2609
- });
2610
- };
2611
-
2612
- // dns/promises is a separate function reference. Patching dns.lookup does
2613
- // NOT affect dnsPromises.lookup. Today undici's connect path uses callback
2614
- // dns.lookup so default fetch is covered, but the invariant is fragile —
2615
- // any future undici switch (or user code calling dnsPromises.lookup
2616
- // directly) would bypass the guard. Patch both to keep the contract.
2617
- const _origPromisesLookup = dnsPromises.lookup;
2618
- dnsPromises.lookup = async function patchedPromisesLookup(hostname, options) {
2619
- const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
2620
- const records = await _origPromisesLookup(hostname, opts);
2621
- const list = Array.isArray(records) ? records : [records];
2622
- for (var i = 0; i < list.length; i++) {
2623
- var verdict = classifyIp(list[i].address);
2624
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2625
- throw new Error(
2626
- 'SSRF blocked at connect-time: ' + hostname +
2627
- ' resolves to ' + list[i].address + ' (' + verdict + ')'
2628
- );
2629
- }
2630
- }
2631
- return options && options.all
2632
- ? list
2633
- : { address: list[0].address, family: list[0].family };
2634
- };
2635
-
2636
- // dns.resolve4 / dns.resolve6 use a different code path (no getaddrinfo,
2637
- // no /etc/hosts) than dns.lookup — they must be patched separately or the
2638
- // guard is trivially bypassed by any caller using dns.resolve* directly.
2639
- ['resolve4', 'resolve6'].forEach(function patchResolve(name) {
2640
- const _origResolve = dns[name];
2641
- dns[name] = function patchedResolve(hostname, options, cb) {
2642
- if (typeof options === 'function') { cb = options; options = undefined; }
2643
- _origResolve.call(dns, hostname, options || {}, function(err, addrs) {
2644
- if (err) return cb(err);
2645
- var withTtl = options && options.ttl;
2646
- for (var i = 0; i < addrs.length; i++) {
2647
- var ip = withTtl ? addrs[i].address : addrs[i];
2648
- var v = classifyIp(ip);
2649
- if (v === 'block' || (STRICT && v === 'private')) {
2650
- return cb(new Error(
2651
- 'SSRF blocked at connect-time: ' + hostname +
2652
- ' resolves to ' + ip + ' (' + v + ')'
2653
- ));
2654
- }
2655
- }
2656
- cb(null, addrs);
2657
- });
2658
- };
2659
- });
2660
-
2661
- // Generic dns.resolve is a polymorphic dispatcher (rrtype-driven). Internally
2662
- // Node delegates to dns.resolve4/dns.resolve6 for A/AAAA, but the patches
2663
- // above hook the *exported* references — Node's internal dispatcher holds
2664
- // captured originals and bypasses our patch. Patch the wrapper explicitly:
2665
- // classify A/AAAA records the same way; pass through CNAME/MX/TXT/SRV/etc.
2666
- const _origResolveGeneric = dns.resolve;
2667
- dns.resolve = function patchedResolveGeneric(hostname, rrtype, cb) {
2668
- if (typeof rrtype === 'function') { cb = rrtype; rrtype = 'A'; }
2669
- _origResolveGeneric.call(dns, hostname, rrtype, function(err, records) {
2670
- if (err) return cb(err);
2671
- if ((rrtype === 'A' || rrtype === 'AAAA') && Array.isArray(records)) {
2672
- for (var i = 0; i < records.length; i++) {
2673
- var ip = records[i];
2674
- var v = classifyIp(ip);
2675
- if (v === 'block' || (STRICT && v === 'private')) {
2676
- return cb(new Error(
2677
- 'SSRF blocked at connect-time: ' + hostname +
2678
- ' resolves to ' + ip + ' (' + v + ')'
2679
- ));
2680
- }
2681
- }
2682
- }
2683
- cb(null, records);
2684
- });
2685
- };
2686
-
2687
- function emit(ct, content) {
2688
- // Write content to file to bypass executor stdout truncation (100KB limit).
2689
- // Only the content-type marker goes to stdout.
2690
- fs.writeFileSync(outputPath, content);
2691
- console.log('__CM_CT__:' + ct);
2692
- }
2693
-
2694
- // Manual redirect handling: a 3xx Location header can rebind the subprocess
2695
- // fetch to an alternate host the parent's pre-flight ssrfGuard never saw.
2696
- // Even with the connect-time DNS patch, a redirect target that is a literal
2697
- // IP (e.g. http://169.254.169.254/) skips getaddrinfo entirely. Walk the
2698
- // chain manually so every hop runs through classifyIp before the next fetch.
2699
- const MAX_REDIRECTS = 5;
2700
- async function fetchWithManualRedirect(initialUrl) {
2701
- let currentUrl = initialUrl;
2702
- for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
2703
- const resp = await fetch(currentUrl, { redirect: 'manual' });
2704
- if (resp.status < 300 || resp.status >= 400) return resp;
2705
- const location = resp.headers.get('location') || resp.headers.get('Location');
2706
- if (!location) return resp;
2707
- if (redirectCount === MAX_REDIRECTS) {
2708
- throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
2709
- }
2710
- let nextParsed;
2711
- try { nextParsed = new URL(location, currentUrl); } catch (e) {
2712
- throw new Error('SSRF blocked: invalid redirect Location: ' + location);
2713
- }
2714
- if (nextParsed.protocol !== 'http:' && nextParsed.protocol !== 'https:') {
2715
- throw new Error('SSRF blocked: redirect to non-http(s) scheme ' + nextParsed.protocol);
2716
- }
2717
- // If the redirect target is a literal IP, classify it directly — no DNS
2718
- // lookup will fire and the connect-time guard would never see it.
2719
- const hostname = nextParsed.hostname.replace(/^\[|\]$/g, '');
2720
- const isIpLiteral = /^[0-9.]+$/.test(hostname) || hostname.includes(':');
2721
- if (isIpLiteral) {
2722
- const verdict = classifyIp(hostname);
2723
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2724
- throw new Error('SSRF blocked: redirect to ' + hostname + ' (' + verdict + ')');
2725
- }
2726
- } else {
2727
- // Hostname target: resolve and classify every record. The patched
2728
- // dns.lookup also fires on the next fetch's connect, but checking
2729
- // here gives a clearer error and short-circuits before TCP setup.
2730
- const records = await dnsPromises.lookup(hostname, { all: true, verbatim: true });
2731
- for (const rec of records) {
2732
- const verdict = classifyIp(rec.address);
2733
- if (verdict === 'block' || (STRICT && verdict === 'private')) {
2734
- throw new Error(
2735
- 'SSRF blocked: redirect target ' + hostname +
2736
- ' resolves to ' + rec.address + ' (' + verdict + ')'
2737
- );
2738
- }
2739
- }
2740
- }
2741
- currentUrl = nextParsed.toString();
2742
- }
2743
- throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
2744
- }
2745
-
2746
- // Subprocess response-body size cap. A malicious or unexpectedly large
2747
- // endpoint reachable through ctx_fetch_and_index would otherwise stream
2748
- // gigabytes into resp.text(), then into outputPath, then into the parent
2749
- // MCP server's heap via readFileSync. 50 MB is far above typical web
2750
- // page / API response sizes (~1-5 MB) but bounded enough to keep parent
2751
- // heap survivable. Cap both early via Content-Length and after the read.
2752
- const MAX_FETCH_BYTES = 50 * 1024 * 1024;
2753
- async function safeText(resp) {
2754
- const cl = parseInt(resp.headers.get('content-length') || '0', 10);
2755
- if (cl > MAX_FETCH_BYTES) {
2756
- throw new Error('Response too large: Content-Length ' + cl + ' exceeds ' + MAX_FETCH_BYTES);
2757
- }
2758
- const text = await resp.text();
2759
- if (text.length > MAX_FETCH_BYTES) {
2760
- throw new Error('Response too large: ' + text.length + ' bytes exceeds ' + MAX_FETCH_BYTES);
2761
- }
2762
- return text;
2763
- }
2764
-
2765
- async function main() {
2766
- const resp = await fetchWithManualRedirect(url);
2767
- if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
2768
- const contentType = resp.headers.get('content-type') || '';
2769
-
2770
- // --- JSON responses ---
2771
- if (contentType.includes('application/json') || contentType.includes('+json')) {
2772
- const text = await safeText(resp);
2773
- try {
2774
- const pretty = JSON.stringify(JSON.parse(text), null, 2);
2775
- emit('json', pretty);
2776
- } catch {
2777
- emit('text', text);
2778
- }
2779
- return;
2780
- }
2781
-
2782
- // --- HTML responses (default for text/html, application/xhtml+xml) ---
2783
- if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
2784
- const html = await safeText(resp);
2785
- const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
2786
- td.use(gfm);
2787
- td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
2788
- emit('html', td.turndown(html));
2789
- return;
2790
- }
2791
-
2792
- // --- Everything else: plain text, CSV, XML, etc. ---
2793
- const text = await safeText(resp);
2794
- emit('text', text);
2795
- }
2796
- main();
2797
- `;
2798
- }
2799
- // ─────────────────────────────────────────────────────────
2800
- // fetch_and_index helpers — split into parallel-safe fetch and serial-only index
2801
- // ─────────────────────────────────────────────────────────
2802
- const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
2803
- const FETCH_PREVIEW_LIMIT = 3072;
2804
- function formatFetchTtl(ttlMs) {
2805
- if (ttlMs === 0)
2806
- return "0ms";
2807
- const day = 24 * 60 * 60 * 1000;
2808
- const hour = 60 * 60 * 1000;
2809
- const minute = 60 * 1000;
2810
- if (ttlMs % day === 0)
2811
- return `${ttlMs / day}d`;
2812
- if (ttlMs % hour === 0)
2813
- return `${ttlMs / hour}h`;
2814
- if (ttlMs % minute === 0)
2815
- return `${ttlMs / minute}m`;
2816
- return `${ttlMs}ms`;
2817
- }
2818
- /**
2819
- * Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
2820
- * Performs zero SQLite writes (only reads source meta). Caller must funnel
2821
- * fetched results through `indexFetched` serially to avoid FTS5 WAL contention.
2822
- */
2823
- /**
2824
- * SSRF guard for ctx_fetch_and_index: validate URL scheme + resolve target IP +
2825
- * block link-local / IMDS / multicast / reserved IP ranges. Returns null if
2826
- * safe; returns a FetchOneResult fetch_error if blocked.
2827
- *
2828
- * Policy (PR #401 ops review, developer-friendly default):
2829
- *
2830
- * **HARD BLOCK** (no legitimate dev workflow):
2831
- * - file://, gopher://, javascript:, data: schemes (only http: and https:)
2832
- * - 169.254.0.0/16 link-local (INCLUDES 169.254.169.254 = AWS/GCP/Azure IMDS
2833
- * cloud credential endpoint — high-value target for indirect prompt injection)
2834
- * - IPv6 link-local fe80::/10
2835
- * - Multicast (224+ IPv4, ff00::/8 IPv6) and reserved (0.0.0.0/8) ranges
2836
- *
2837
- * **ALLOW by default** (legitimate developer use cases dominate):
2838
- * - localhost, 127.x.x.x, ::1 (local dev servers — Next.js, Vite, Postgres, …)
2839
- * - 10.x, 172.16-31.x, 192.168.x RFC1918 private (developer's internal network)
2840
- *
2841
- * **STRICT MODE** opt-in via env var: `CTX_FETCH_STRICT=1`
2842
- * - Blocks loopback + RFC1918 too
2843
- * - For hosted/CI environments where the runtime isn't the user's own machine
2844
- *
2845
- * DNS resolution is performed against the resolved IP (not just URL parse) so a
2846
- * hostname like `evil.com` pointing to 169.254.169.254 is rejected — defends
2847
- * against attacker-controlled DNS records and DNS rebinding.
2848
- */
2849
- async function ssrfGuard(rawUrl) {
2850
- let parsed;
2851
- try {
2852
- parsed = new URL(rawUrl);
2853
- }
2854
- catch {
2855
- return { kind: "fetch_error", url: rawUrl, error: "invalid URL", reason: "exit" };
2856
- }
2857
- // 1. Scheme allowlist — http and https only
2858
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2859
- return {
2860
- kind: "fetch_error",
2861
- url: rawUrl,
2862
- error: `URL scheme "${parsed.protocol}" not allowed (only http: and https:)`,
2863
- reason: "exit",
2864
- };
2865
- }
2866
- const strict = process.env.CTX_FETCH_STRICT === "1";
2867
- // 2. DNS resolve + check IP ranges (hard-block + optional strict-mode block)
2868
- try {
2869
- const { lookup } = await import("node:dns/promises");
2870
- const records = await lookup(parsed.hostname, { all: true, verbatim: true });
2871
- for (const rec of records) {
2872
- const verdict = classifyIp(rec.address);
2873
- if (verdict === "block") {
2874
- return {
2875
- kind: "fetch_error",
2876
- url: rawUrl,
2877
- error: `URL "${parsed.hostname}" resolves to ${rec.address} — blocked (link-local / IMDS / multicast / reserved)`,
2878
- reason: "exit",
2879
- };
2880
- }
2881
- if (verdict === "private" && strict) {
2882
- return {
2883
- kind: "fetch_error",
2884
- url: rawUrl,
2885
- error: `URL "${parsed.hostname}" resolves to private IP ${rec.address} — blocked under CTX_FETCH_STRICT=1`,
2886
- reason: "exit",
2887
- };
2888
- }
2889
- }
2890
- }
2891
- catch (err) {
2892
- // libuv DNS error codes that typically indicate the resolver itself can't
2893
- // reach a nameserver — common when the MCP host process is running under
2894
- // a sandbox that blocks outbound network, OR a transient upstream DNS
2895
- // hiccup. Append an imperative retry hint so the agent does not capitulate
2896
- // to training data on the FIRST transient failure (PR #654 substitute —
2897
- // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
2898
- const errCode = err?.code ?? "";
2899
- const isTransientDns = errCode === "ETIMEOUT" || errCode === "ETIMEDOUT" ||
2900
- errCode === "EAI_AGAIN" || errCode === "ENETUNREACH" || errCode === "EPERM";
2901
- const baseMsg = err instanceof Error ? err.message : String(err);
2902
- const hint = isTransientDns
2903
- ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
2904
- : "";
2905
- return {
2906
- kind: "fetch_error",
2907
- url: rawUrl,
2908
- error: `DNS lookup failed for "${parsed.hostname}": ${baseMsg}${hint}`,
2909
- reason: "exit",
2910
- };
2911
- }
2912
- return null; // safe to fetch
2913
- }
2914
- /**
2915
- * Classify an IP address.
2916
- * - "block": always blocked (link-local/IMDS/multicast/reserved/malformed)
2917
- * - "private": loopback or RFC1918 — allowed by default, blocked in strict mode
2918
- * - "public": safe to fetch
2919
- *
2920
- * Exported (via the function name) so SSRF tests can exercise the matcher directly.
2921
- */
2922
- export function classifyIp(rawIp) {
2923
- // RFC 6874 zone identifiers (`fe80::1%eth0`, URL-encoded `%25eth0`) must
2924
- // be stripped BEFORE any prefix/equality classification. Without the strip,
2925
- // a loopback `::1%eth0` no longer matches `lower === "::1"` and falls
2926
- // through to "public" — silently bypassing the SSRF guard. Strip first,
2927
- // classify second.
2928
- const pctIdx = rawIp.indexOf("%");
2929
- const ip = pctIdx === -1 ? rawIp : rawIp.slice(0, pctIdx);
2930
- const lower = ip.toLowerCase();
2931
- // IPv6 takes priority — check for `:` first so IPv4-mapped addresses
2932
- // (`::ffff:127.0.0.1`) don't get incorrectly routed through the IPv4 parser.
2933
- if (lower.includes(":")) {
2934
- // IPv4-mapped IPv6 (`::ffff:127.0.0.1`) — recurse through IPv4 classifier
2935
- const v4MappedMatch = lower.match(/^::ffff:([\d.]+)$/);
2936
- if (v4MappedMatch)
2937
- return classifyIp(v4MappedMatch[1]);
2938
- // Hard-block
2939
- if (lower === "::")
2940
- return "block"; // unspecified
2941
- if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
2942
- lower.startsWith("fea") || lower.startsWith("feb"))
2943
- return "block"; // fe80::/10 link-local
2944
- if (lower.startsWith("ff"))
2945
- return "block"; // ff00::/8 multicast
2946
- // Private (loopback + ULA)
2947
- if (lower === "::1")
2948
- return "private";
2949
- if (lower.startsWith("fc") || lower.startsWith("fd"))
2950
- return "private"; // fc00::/7 ULA
2951
- return "public";
2952
- }
2953
- // IPv4 (or non-IP string — malformed = block)
2954
- if (!ip.includes("."))
2955
- return "block"; // not an IP at all
2956
- const parts = ip.split(".").map((p) => parseInt(p, 10));
2957
- if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
2958
- return "block";
2959
- const [a, b] = parts;
2960
- // Hard-block (no legitimate use)
2961
- if (a === 169 && b === 254)
2962
- return "block"; // link-local incl. 169.254.169.254 (IMDS)
2963
- if (a === 0)
2964
- return "block"; // 0.0.0.0/8 (current network)
2965
- if (a >= 224)
2966
- return "block"; // 224.0.0.0+ multicast/reserved
2967
- // Private (loopback + RFC1918) — allow by default
2968
- if (a === 127)
2969
- return "private"; // 127.0.0.0/8 loopback
2970
- if (a === 10)
2971
- return "private"; // 10.0.0.0/8
2972
- if (a === 172 && b >= 16 && b <= 31)
2973
- return "private"; // 172.16.0.0/12
2974
- if (a === 192 && b === 168)
2975
- return "private"; // 192.168.0.0/16
2976
- return "public";
2977
- }
2978
- async function fetchOneUrl(url, source, force, ttl) {
2979
- // SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
2980
- // BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
2981
- // serve a previously-poisoned source label.
2982
- const ssrfBlock = await ssrfGuard(url);
2983
- if (ssrfBlock)
2984
- return ssrfBlock;
2985
- if (!force && ttl !== 0) {
2986
- const store = getStore();
2987
- // Cache key composes (source, url) so two distinct URLs sharing the same
2988
- // `source` label do not collide — they each get their own cache slot
2989
- // (commit 1f1243e regression test enforced).
2990
- const cacheKey = composeFetchCacheKey(source, url);
2991
- const meta = store.getSourceMeta(cacheKey);
2992
- if (meta) {
2993
- const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
2994
- const ageMs = Date.now() - indexedAt.getTime();
2995
- const cacheTtlMs = ttl ?? FETCH_TTL_MS;
2996
- if (ageMs < cacheTtlMs) {
2997
- const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
2998
- const ageMin = Math.floor(ageMs / (60 * 1000));
2999
- const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
3000
- const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
3001
- return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr, ttlStr: formatFetchTtl(cacheTtlMs) };
3002
- }
3003
- // Stale — fall through to re-fetch silently
3004
- }
3005
- }
3006
- const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
3007
- try {
3008
- const fetchCode = buildFetchCode(url, outputPath);
3009
- const result = await executor.execute({
3010
- language: "javascript",
3011
- code: fetchCode,
3012
- timeout: 30_000,
3013
- });
3014
- if (result.exitCode !== 0) {
3015
- // Subprocess fetch failure — undici / fetch can surface EAI_AGAIN /
3016
- // ETIMEDOUT / ENETUNREACH in stderr when the resolver is overloaded
3017
- // or the network is briefly unavailable. Append the same retry hint
3018
- // ssrfGuard's pre-flight DNS path emits so the agent doesn't capitulate
3019
- // to training data on the first transient failure (PR #654 substitute —
3020
- // sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
3021
- const raw = result.stderr || result.stdout || "unknown error";
3022
- const isTransientDns = /\b(EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ENETUNREACH|EPERM|getaddrinfo)\b/.test(raw);
3023
- const hint = isTransientDns
3024
- ? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
3025
- : "";
3026
- return { kind: "fetch_error", url, error: `${raw}${hint}`, reason: "exit" };
3027
- }
3028
- const header = (result.stdout || "").trim();
3029
- let markdown;
3030
- try {
3031
- // Parent-side defense-in-depth on the subprocess output size. The
3032
- // embedded safeText() in buildFetchCode already caps before writing,
3033
- // but a torn write (subprocess killed mid-write, fs cache desync,
3034
- // etc.) could still leave an oversized file. Bail before slurping
3035
- // multiple gigabytes into the long-running MCP server's heap.
3036
- const MAX_FETCH_OUTPUT_BYTES = 50 * 1024 * 1024;
3037
- const fileSize = statSync(outputPath).size;
3038
- if (fileSize > MAX_FETCH_OUTPUT_BYTES) {
3039
- return { kind: "fetch_error", url, error: `subprocess output ${fileSize} bytes exceeds cap ${MAX_FETCH_OUTPUT_BYTES}`, reason: "read" };
3040
- }
3041
- markdown = readFileSync(outputPath, "utf-8").trim();
3042
- }
3043
- catch {
3044
- return { kind: "fetch_error", url, error: "could not read subprocess output", reason: "read" };
3045
- }
3046
- if (markdown.length === 0) {
3047
- return { kind: "fetch_error", url, error: "empty content", reason: "empty" };
3048
- }
3049
- return { kind: "fetched", url, source, markdown, header };
3050
- }
3051
- catch (err) {
3052
- return {
3053
- kind: "fetch_error",
3054
- url,
3055
- error: err instanceof Error ? err.message : String(err),
3056
- reason: "throw",
3057
- };
3058
- }
3059
- finally {
3060
- try {
3061
- rmSync(outputPath);
3062
- }
3063
- catch { /* already gone */ }
3064
- }
3065
- }
3066
- /**
3067
- * Serial-only indexing step — single FTS5 write per call. Caller loops over
3068
- * fetched results and calls this one-at-a-time to avoid SQLite WAL contention
3069
- * (PRD finding E).
3070
- */
3071
- function indexFetched(f) {
3072
- const store = getStore();
3073
- // Storage label composed via composeFetchCacheKey so two URLs sharing a
3074
- // `source` label do not overwrite each other (commit 1f1243e). ctx_search()
3075
- // still finds both via LIKE-mode source filter on the `source` substring.
3076
- const storageLabel = composeFetchCacheKey(f.source, f.url);
3077
- const attribution = currentAttribution();
3078
- let indexed;
3079
- if (f.header === "__CM_CT__:json") {
3080
- indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
3081
- }
3082
- else if (f.header === "__CM_CT__:text") {
3083
- indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
3084
- }
3085
- else {
3086
- indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
3087
- }
3088
- // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
3089
- trackIndexed(Buffer.byteLength(f.markdown));
3090
- const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
3091
- ? charSafePrefix(f.markdown, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
3092
- : f.markdown;
3093
- return {
3094
- label: indexed.label,
3095
- totalChunks: indexed.totalChunks,
3096
- totalBytes: Buffer.byteLength(f.markdown),
3097
- preview,
3098
- };
3099
- }
3100
- server.registerTool("ctx_fetch_and_index", {
3101
- title: "Fetch & Index URL(s)",
3102
- // #846: fetches external URLs (open world) and writes them into the store.
3103
- annotations: {
3104
- readOnlyHint: false,
3105
- destructiveHint: false,
3106
- idempotentHint: false,
3107
- openWorldHint: true,
3108
- },
3109
- description: `Fetches URL content, converts HTML to markdown (JSON is chunked by key paths, plain text indexed directly), persists it in a searchable knowledge base, and returns a small preview window per source. The raw page bytes never enter your conversation — they live in storage and you retrieve any section on-demand via ctx_search.
3110
-
3111
- Caching: every fetch is cached on disk and reused for repeat calls within the TTL window. The default TTL is 24 hours; override per-call with the \`ttl\` parameter (milliseconds, \`ttl: 0\` bypasses cache like \`force: true\`). Stored content older than 14 days is cleaned up on startup.
3112
-
3113
- WHEN:
3114
- - You need web content (docs, changelogs, API references, spec pages) and the raw page bytes should NOT enter your conversation
3115
- - Multi-URL research (library evaluation, migration scans, doc comparisons): pass the \`requests\` array and a \`concurrency\` value 2-8 for parallel I/O
3116
- - You want repeat lookups against the same URL to be cheap (TTL cache hits return only a hint, no re-fetch)
3117
- - You want a long-lived cache window (override \`ttl\` upward for stable specs) or a guaranteed-fresh fetch (\`ttl: 0\` or \`force: true\`)
3118
-
3119
- WHEN NOT:
3120
- - You already have the content locally — store it via the inline index tool
3121
- - The page is SPA-rendered (JavaScript-required to materialize content) — this is a plain HTTP fetch, no headless browser
3122
-
3123
- RETURNS:
3124
- Per-source preview windows extracted around indexable headings plus indexing metadata (chunk counts, source labels, cache state). Raw content is NOT echoed back — retrieve any section on-demand via ctx_search(source: "<label>"). Concurrency parallelizes the fetch phase up to your chosen value (capped by the host's logical CPU count); the FTS5 write phase always runs serially because SQLite is a single-writer store. Net latency = max(fetch latency across the pool) + sum(per-source index write time). Cache hits skip both phases and return a small freshness hint instead of re-fetching. Use 4-8 for stable I/O-bound batches; lower the value when the target host enforces a per-IP rate limit you cannot raise.
3125
-
3126
- EXAMPLE: ctx_fetch_and_index(
3127
- requests: [{url: "https://react.dev/...", source: "react"}, {url: "https://vuejs.org/...", source: "vue"}],
3128
- concurrency: 5
3129
- )`,
3130
- inputSchema: z.object({
3131
- url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
3132
- source: z
3133
- .string()
3134
- .optional()
3135
- .describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
3136
- requests: z
3137
- .preprocess(coerceJsonArray, z.array(z.object({
3138
- url: z.string().describe("URL to fetch"),
3139
- source: z.string().optional().describe("Label for this URL's indexed content"),
3140
- })).min(1))
3141
- .optional()
3142
- .describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
3143
- "Each request indexed under its own source label. Output preserves input order."),
3144
- concurrency: z
3145
- .coerce.number()
3146
- .int()
3147
- .min(1)
3148
- .max(8)
3149
- .optional()
3150
- .default(1)
3151
- .describe("Max URLs to fetch in parallel (1-8, default: 1). " +
3152
- "Use 4-8 for I/O-bound multi-URL batches (library docs, changelogs, pricing pages). " +
3153
- "Capped by os.cpus().length on small machines (response notes when capped). " +
3154
- "Indexing is always serial regardless — only fetches race."),
3155
- force: z
3156
- .preprocess(coerceBoolean, z.boolean())
3157
- .optional()
3158
- .describe("Skip cache and re-fetch even if content was recently indexed"),
3159
- ttl: z
3160
- .coerce.number()
3161
- .int()
3162
- .min(0)
3163
- .optional()
3164
- .describe("Override the cache freshness window for this call, in milliseconds. " +
3165
- "`ttl: 0` bypasses the cache like `force: true`; omit to use the default 24h TTL."),
3166
- }),
3167
- }, async ({ url, source, requests, concurrency, force, ttl }) => {
3168
- // Normalize input: legacy {url} or new {requests: [...]}.
3169
- // requests wins when both are provided (explicit batch intent).
3170
- const batch = requests
3171
- ? requests
3172
- : url
3173
- ? [{ url, source }]
3174
- : [];
3175
- if (batch.length === 0) {
3176
- return trackResponse("ctx_fetch_and_index", {
3177
- content: [{
3178
- type: "text",
3179
- text: "ctx_fetch_and_index requires either `url` (single) or `requests: [{url, source?}, ...]` (batch).",
3180
- }],
3181
- isError: true,
3182
- });
3183
- }
3184
- const isLegacySingle = !requests && batch.length === 1;
3185
- const requestedConcurrency = concurrency ?? 1;
3186
- // Parallel fetch via shared runPool primitive. capByCpuCount only for batch
3187
- // — single-URL doesn't need the cap (only one job, executor is one subprocess).
3188
- const jobs = batch.map((req) => ({
3189
- run: () => fetchOneUrl(req.url, req.source, force, ttl),
3190
- }));
3191
- const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
3192
- concurrency: requestedConcurrency,
3193
- capByCpuCount: !isLegacySingle && requestedConcurrency > 1,
3194
- });
3195
- const finalized = [];
3196
- for (let i = 0; i < settled.length; i++) {
3197
- const r = settled[i];
3198
- if (r.status === "rejected") {
3199
- const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
3200
- finalized.push({ kind: "job_error", url: batch[i].url, error: message });
3201
- continue;
3202
- }
3203
- const v = r.value;
3204
- if (v.kind === "cached") {
3205
- sessionStats.cacheHits++;
3206
- sessionStats.cacheBytesSaved += v.estimatedBytes;
3207
- // D2 Phase 5/7 — cache-hit event emission. `bytes_avoided` is the
3208
- // size of the cached payload that would have re-entered context
3209
- // had the TTL window missed. Best-effort, off the hot path.
3210
- const cachedBytes = v.estimatedBytes;
3211
- const cachedLabel = v.label;
3212
- setImmediate(() => emitCacheHitEvent({
3213
- sessionDbPath: getSessionDbPath(),
3214
- source: cachedLabel,
3215
- bytesAvoided: cachedBytes,
3216
- }));
3217
- finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr, ttlStr: v.ttlStr });
3218
- }
3219
- else if (v.kind === "fetch_error") {
3220
- finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
3221
- }
3222
- else {
3223
- // Serial FTS5 write here — no parallel store.index calls.
3224
- // Cache miss: the URL was not in the TTL window so we paid the
3225
- // network round-trip + re-indexed. Counted here so ctx_stats can
3226
- // report nominal cache_hit_rate alongside the existing hit metrics.
3227
- sessionStats.cacheMisses++;
3228
- finalized.push({ kind: "fetched", indexed: indexFetched(v) });
3229
- }
3230
- }
3231
- // Backward-compat single-URL response shape — preserve the EXACT original wording.
3232
- if (isLegacySingle) {
3233
- const r = finalized[0];
3234
- if (r.kind === "cached") {
3235
- return trackResponse("ctx_fetch_and_index", {
3236
- content: [{
3237
- type: "text",
3238
- text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: ${r.ttlStr}).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
3239
- }],
3240
- });
3241
- }
3242
- if (r.kind === "fetched") {
3243
- const totalKB = (r.indexed.totalBytes / 1024).toFixed(1);
3244
- const text = [
3245
- `Fetched and indexed **${r.indexed.totalChunks} sections** (${totalKB}KB) from: ${r.indexed.label}`,
3246
- `Full content indexed in sandbox — use ctx_search(queries: [...], source: "${r.indexed.label}") for specific lookups.`,
3247
- "",
3248
- "---",
3249
- "",
3250
- r.indexed.preview,
3251
- ].join("\n");
3252
- return trackResponse("ctx_fetch_and_index", {
3253
- content: [{ type: "text", text }],
3254
- });
3255
- }
3256
- // fetch_error — preserve original error wording per reason
3257
- if (r.kind === "fetch_error") {
3258
- const text = r.reason === "empty" ? `Fetched ${r.url} but got empty content`
3259
- : r.reason === "read" ? `Fetched ${r.url} but could not read subprocess output`
3260
- : r.reason === "exit" ? `Failed to fetch ${r.url}: ${r.error}`
3261
- : /* throw */ `Fetch error: ${r.error}`;
3262
- return trackResponse("ctx_fetch_and_index", {
3263
- content: [{ type: "text", text }],
3264
- isError: true,
3265
- });
3266
- }
3267
- // job_error
3268
- return trackResponse("ctx_fetch_and_index", {
3269
- content: [{ type: "text", text: `Fetch error: ${r.error}` }],
3270
- isError: true,
3271
- });
3272
- }
3273
- // Batch response — aggregated summary; isError only when EVERY URL failed.
3274
- // Per-URL preview capped tightly so a 8-URL batch doesn't undo the
3275
- // context-savings the tool exists to deliver (PRD review finding G1).
3276
- const FETCH_BATCH_PREVIEW_LIMIT = 384; // ~3KB total for 8-URL batches
3277
- const lines = [];
3278
- let totalSections = 0;
3279
- let totalBytes = 0;
3280
- let cachedCount = 0;
3281
- let fetchedCount = 0;
3282
- let errorCount = 0;
3283
- const snippets = [];
3284
- for (const r of finalized) {
3285
- if (r.kind === "cached") {
3286
- cachedCount++;
3287
- lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr}, TTL: ${r.ttlStr})`);
3288
- }
3289
- else if (r.kind === "fetched") {
3290
- fetchedCount++;
3291
- totalSections += r.indexed.totalChunks;
3292
- totalBytes += r.indexed.totalBytes;
3293
- const kb = (r.indexed.totalBytes / 1024).toFixed(1);
3294
- lines.push(`- [new] ${r.indexed.label} — ${r.indexed.totalChunks} sections (${kb}KB)`);
3295
- const snippet = r.indexed.preview.length > FETCH_BATCH_PREVIEW_LIMIT
3296
- ? r.indexed.preview.slice(0, FETCH_BATCH_PREVIEW_LIMIT).trimEnd() + "…"
3297
- : r.indexed.preview;
3298
- snippets.push(`### ${r.indexed.label}\n\n${snippet}`);
3299
- }
3300
- else {
3301
- errorCount++;
3302
- lines.push(`- [err] ${r.url}: ${r.error}`);
3303
- }
3304
- }
3305
- const totalKB = (totalBytes / 1024).toFixed(1);
3306
- const cappedNote = capped
3307
- ? ` cap=${effectiveConcurrency}/${cpus().length}cpu`
3308
- : "";
3309
- // Status line: counts + sections + size, with singular/plural agreement
3310
- // (count=1 → "1 error" not "1 errors") so the line stays grammatical.
3311
- const fmt = (n, sing, plur) => `${n} ${n === 1 ? sing : plur}`;
3312
- const headerLine = `fetched ${batch.length} c=${effectiveConcurrency}${cappedNote}. ` +
3313
- `ok=${fetchedCount} cache=${cachedCount} err=${errorCount}. ` +
3314
- `${fmt(totalSections, "section", "sections")} ${totalKB}KB.`;
3315
- const text = [
3316
- headerLine,
3317
- "",
3318
- ...lines,
3319
- "",
3320
- `ctx_search(queries: [...], source: "<label>") for full content.`,
3321
- ...(snippets.length > 0 ? ["", "---", "", ...snippets] : []),
3322
- ].join("\n");
3323
- return trackResponse("ctx_fetch_and_index", {
3324
- content: [{ type: "text", text }],
3325
- isError: errorCount === batch.length, // only mark error if every URL failed
3326
- });
3327
- });
3328
- // ─────────────────────────────────────────────────────────
3329
- // Tool: batch_execute
3330
- // ─────────────────────────────────────────────────────────
3331
- server.registerTool("ctx_batch_execute", {
3332
- title: "Batch Execute & Search",
3333
- // #846: runs arbitrary shell commands (with network) and indexes output.
3334
- annotations: {
3335
- readOnlyHint: false,
3336
- destructiveHint: true,
3337
- idempotentHint: false,
3338
- openWorldHint: true,
3339
- },
3340
- description: `Run multiple commands in ONE call. Every command's output is auto-indexed into the knowledge base; if you also pass \`queries\`, the matching sections come back in the same round trip so a follow-up search call is not needed.
3341
-
3342
- Concurrency parallelizes the FETCH phase (run-the-commands). The DERIVATION phase — turning raw output into an answer — still belongs in code: add a processing command that consumes the indexed output and prints only the answer, so the raw bytes never enter your conversation (Think-in-Code, same principle as the sandbox tool).
3343
-
3344
- WHEN:
3345
- - You have 3+ related commands you would otherwise run sequentially (multi-issue lookups, git log + git diff + git blame, multi-file reads, multi-region cloud queries)
3346
- - You want to gather AND query in one round trip — pass \`queries\` so the matching sections come back inline
3347
- - You want to parallelize I/O-bound work — pass \`concurrency\` 2-8 (network calls, gh CLI, cloud APIs, multi-repo git reads)
3348
- - The combined output is large enough that piping it through ctx_search later would itself be expensive — let auto-index + inline queries do both in one shot
3349
-
3350
- WHEN NOT:
3351
- - Single command with no follow-up query — run it in the sandbox tool directly
3352
- - CPU-bound or stateful commands — keep concurrency at 1 (npm test, build, lint, port-binding servers, lock-file holders, anything that races on the same resource)
3353
-
3354
- RETURNS:
3355
- Auto-indexed section list per command label, plus top matches per query (when \`queries\` is passed). Raw output is NOT echoed in full — only the matched windows. Concurrency>1 switches each command to its own per-command timeout (no shared budget); concurrency=1 preserves the legacy shared-budget cascading-skip-on-timeout path. Use 4-8 for I/O-bound batches; keep at 1 for CPU work or shared-state commands; lower the value when target hosts enforce per-IP rate limits.
3356
-
3357
- EXAMPLE: ctx_batch_execute(
3358
- commands: [
3359
- {label: "issue 1", command: "gh issue view 1"},
3360
- {label: "issue 2", command: "gh issue view 2"},
3361
- {label: "summarize", command: "echo done"}
3362
- ],
3363
- queries: ["root cause", "proposed fix"],
3364
- concurrency: 2
3365
- )`,
3366
- inputSchema: z.object({
3367
- commands: z.preprocess(coerceCommandsArray, z
3368
- .array(z.object({
3369
- label: z
3370
- .string()
3371
- .describe("Section header for this command's output (e.g., 'README', 'Package.json', 'Source Tree')"),
3372
- command: z
3373
- .string()
3374
- .describe("Shell command to execute"),
3375
- }))
3376
- .min(1)
3377
- .describe("Commands to execute as a batch. Output is labeled with the section header. " +
3378
- "Default order is sequential; pass concurrency>1 to run in parallel (output stays in input order).")),
3379
- queries: z.preprocess(coerceJsonArray, z
3380
- .array(z.string())
3381
- .min(1)
3382
- .describe("Search queries to extract information from indexed output. Use 5-8 comprehensive queries. " +
3383
- "Each returns top 5 matching sections with full content. " +
3384
- "This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
3385
- timeout: z
3386
- .coerce.number()
3387
- .optional()
3388
- .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
3389
- concurrency: z
3390
- .coerce.number()
3391
- .int()
3392
- .min(1)
3393
- .max(8)
3394
- .optional()
3395
- .default(1)
3396
- .describe("Max commands to run in parallel (1-8, default: 1). " +
3397
- "Use 4-8 for I/O-bound batches (network, gh, curl, multi-repo git reads). " +
3398
- "Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
3399
- ">1 switches to per-command timeouts (no shared budget) and " +
3400
- "individual `(timed out)` blocks instead of cascading skip."),
3401
- cwd: z
3402
- .string()
3403
- .optional()
3404
- .describe("Optional working directory for all shell commands in this batch."),
3405
- query_scope: z
3406
- .enum(["batch", "global"])
3407
- .optional()
3408
- .default("batch")
3409
- .describe("Scope for `queries` (default: `batch`). " +
3410
- "`batch` searches ONLY the chunks produced by this batch's commands " +
3411
- "— useful when you want answers about the just-fetched output. " +
3412
- "`global` searches the entire persistent index (same scope as ctx_search) " +
3413
- "— useful when you want the batch commands to enrich context and " +
3414
- "the queries to also surface related prior knowledge in one round trip."),
3415
- }),
3416
- }, async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
3417
- // Security: check each command against deny patterns
3418
- for (const cmd of commands) {
3419
- const denied = checkDenyPolicy(cmd.command, "batch_execute");
3420
- if (denied)
3421
- return denied;
3422
- }
3423
- try {
3424
- // Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
3425
- // The executor denies NODE_OPTIONS in its env (security), so we set it
3426
- // as an inline shell prefix. This only affects child `node` invocations.
3427
- const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
3428
- // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
3429
- // Concurrency>1 switches to a worker pool with per-command timeouts.
3430
- const effTimeout = resolveExecTimeout(timeout);
3431
- const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
3432
- timeout: effTimeout,
3433
- concurrency,
3434
- nodeOptsPrefix,
3435
- cwd,
3436
- onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
3437
- }, executor);
3438
- const stdout = perCommandOutputs.join("\n");
3439
- const totalBytes = Buffer.byteLength(stdout);
3440
- const totalLines = stdout.split("\n").length;
3441
- if (timedOut && perCommandOutputs.length === 0) {
3442
- return trackResponse("ctx_batch_execute", {
3443
- content: [
3444
- {
3445
- type: "text",
3446
- text: `Batch timed out after ${effTimeout}ms. No output captured.`,
3447
- },
3448
- ],
3449
- isError: true,
3450
- });
3451
- }
3452
- // Track indexed bytes (raw data that stays in sandbox)
3453
- trackIndexed(totalBytes);
3454
- // Index into knowledge base — markdown heading chunking splits by # labels
3455
- const store = getStore();
3456
- const source = `batch:${commands
3457
- .map((c) => c.label)
3458
- .join(",")
3459
- .slice(0, 80)}`;
3460
- const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
3461
- // Commands inventory — list what the agent actually ran so the
3462
- // response itself documents intent, not just per-section echoes.
3463
- // Placed before "## Indexed Sections" so it scans top-down with
3464
- // the human asking "what just happened" (Issues #717 + #736).
3465
- const commandsInventory = ["## Commands", ""];
3466
- for (const c of commands) {
3467
- commandsInventory.push(`- ${c.label}: \`${truncateCommandForEcho(c.command)}\``);
3468
- }
3469
- // Build section inventory — direct query by source_id (no FTS5 MATCH needed)
3470
- const allSections = store.getChunksBySource(indexed.sourceId);
3471
- const inventory = ["## Indexed Sections", ""];
3472
- const sectionTitles = [];
3473
- for (const s of allSections) {
3474
- const bytes = Buffer.byteLength(s.content);
3475
- inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
3476
- sectionTitles.push(s.title);
3477
- }
3478
- // Run all search queries — default scope is batch-local (legacy behavior).
3479
- // When the caller passes query_scope: "global", searches reach the entire
3480
- // persistent index in the same round trip. Cross-source search remains
3481
- // available via explicit ctx_search() as well.
3482
- const queryResults = formatBatchQueryResults(store, queries, source, undefined, query_scope);
3483
- // Get searchable terms for edge cases where follow-up is needed
3484
- const distinctiveTerms = store.getDistinctiveTerms
3485
- ? store.getDistinctiveTerms(indexed.sourceId)
3486
- : [];
3487
- const output = [
3488
- `Executed ${commands.length} commands (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB). ` +
3489
- `Indexed ${indexed.totalChunks} sections. Searched ${queries.length} queries.`,
3490
- "",
3491
- ...commandsInventory,
3492
- "",
3493
- ...inventory,
3494
- "",
3495
- ...queryResults,
3496
- distinctiveTerms.length > 0
3497
- ? `\nSearchable terms for follow-up: ${distinctiveTerms.join(", ")}`
3498
- : "",
3499
- ].join("\n");
3500
- return trackResponse("ctx_batch_execute", {
3501
- content: [{ type: "text", text: output }],
3502
- });
3503
- }
3504
- catch (err) {
3505
- const message = err instanceof Error ? err.message : String(err);
3506
- return trackResponse("ctx_batch_execute", {
3507
- content: [
3508
- {
3509
- type: "text",
3510
- text: `Batch execution error: ${message}`,
3511
- },
3512
- ],
3513
- isError: true,
3514
- });
3515
- }
3516
- });
3517
- /**
3518
- * Pi byte accounting: patch lifetime.totalEvents from bytes_sandboxed
3519
- * in stats-*.json files instead of the default events × 256 heuristic.
3520
- * Only active for Pi adapter — other platforms use getLifetimeStats() as-is.
3521
- */
3522
- function patchPiLifetimeFromStatsFiles(lifetime, sessionsDir) {
3523
- if (!existsSync(sessionsDir))
3524
- return;
3525
- let sandboxedBytes = 0;
3526
- try {
3527
- for (const f of readdirSync(sessionsDir)) {
3528
- if (!f.startsWith("stats-") || !f.endsWith(".json"))
3529
- continue;
3530
- try {
3531
- const raw = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8"));
3532
- sandboxedBytes += (raw?.bytes_sandboxed ?? 0) + (raw?.bytes_indexed ?? 0);
3533
- }
3534
- catch { /* corrupt file — skip */ }
3535
- }
3536
- }
3537
- catch { /* never block ctx_stats on stats file I/O */ }
3538
- if (sandboxedBytes > 0) {
3539
- const rescueTokens = (lifetime.rescueBytes ?? 0) / 4;
3540
- lifetime.totalEvents = Math.round((sandboxedBytes / 4 + rescueTokens) / 256);
3541
- }
3542
- }
3543
- // ─────────────────────────────────────────────────────────
3544
- // Tool: stats
3545
- // ─────────────────────────────────────────────────────────
3546
- /**
3547
- * Create a minimal in-memory DB adapter for when the session DB is unavailable.
3548
- * All queries return empty results so AnalyticsEngine.queryAll() still works.
3549
- */
3550
- function createMinimalDb() {
3551
- return {
3552
- prepare: () => ({
3553
- run: () => undefined,
3554
- get: (..._args) => ({ cnt: 0, compact_count: 0, minutes: null, rate: 0, avg: 0, outcome: "exploratory" }),
3555
- all: () => [],
3556
- }),
3557
- };
3558
- }
3559
- server.registerTool("ctx_stats", {
3560
- title: "Session Statistics",
3561
- // #846: read-only diagnostics. Was cancelled by Codex when unannotated.
3562
- annotations: {
3563
- readOnlyHint: true,
3564
- destructiveHint: false,
3565
- idempotentHint: true,
3566
- openWorldHint: false,
3567
- },
3568
- description: "Returns context consumption statistics for the current session. " +
3569
- "Shows total bytes returned to context, breakdown by tool, call counts, " +
3570
- "estimated token usage, and context savings ratio.",
3571
- inputSchema: z.object({}),
3572
- }, async () => {
3573
- // ONE call, ONE source — AnalyticsEngine.queryAll()
3574
- let text;
3575
- try {
3576
- const projectDir = getProjectDir();
3577
- // Canonical hash + migration-aware path. The downstream
3578
- // getConversationStats / getRealBytesStats reconstruct the DB
3579
- // filename from worktreeHash; pass the SAME canonical hash that
3580
- // resolveSessionDbPath used so they hit the same file.
3581
- const dbHash = hashProjectDirCanonical(projectDir);
3582
- const sessionDbPath = resolveSessionDbPath({
3583
- projectDir,
3584
- sessionsDir: getSessionDir(),
3585
- });
3586
- if (existsSync(sessionDbPath)) {
3587
- const Database = loadDatabase();
3588
- const sdb = new Database(sessionDbPath, { readonly: true });
3589
- try {
3590
- const engine = new AnalyticsEngine(sdb);
3591
- const report = engine.queryAll(sessionStats);
3592
- // MCP usage is read-only and cheap; only available when DB exists.
3593
- const mcpUsage = engine.getMcpToolUsage();
3594
- // Lifetime stats span every project's SessionDB + auto-memory dir
3595
- // (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
3596
- // corrupt sidecar can never break ctx_stats.
3597
- // B3b Slice 3.1: scope to active adapter via getSessionDir() so
3598
- // non-Claude platforms (Cursor, OpenCode, JetBrains, ...) read
3599
- // from THEIR sessions dir — not the hardcoded ~/.claude/ default.
3600
- // Mirrors the statusline contract at src/server.ts:540.
3601
- const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3602
- // B3b Slices 3.2-3.6: cross-adapter aggregation so the renderer
3603
- // can show "Where it came from" + the "across N AI tools"
3604
- // headline. Best-effort — failures absorbed so a corrupt
3605
- // sidecar in any adapter dir cannot break ctx_stats.
3606
- let multiAdapter;
3607
- try {
3608
- multiAdapter = getMultiAdapterLifetimeStats();
3609
- }
3610
- catch { /* never block ctx_stats */ }
3611
- // F1: wire conversation + realBytes opts so formatReport renders the
3612
- // narrative 5-section "kitap gibi" layout (timeline, ladder, receipt,
3613
- // example cost, auto-memory). Without these, formatReport falls back
3614
- // to the legacy active-session header. Best-effort — failures absorbed.
3615
- // Resolve session_id: prefer env (CLAUDE_SESSION_ID), else most-recent
3616
- // UUID session_id from session_events in this DB.
3617
- let conversation;
3618
- let realBytes;
3619
- try {
3620
- let sid = process.env.CLAUDE_SESSION_ID;
3621
- if (!sid) {
3622
- const row = sdb.prepare("SELECT session_id FROM session_events WHERE session_id LIKE '________-____-____-____-____________' ORDER BY created_at DESC LIMIT 1").get();
3623
- sid = row?.session_id;
3624
- }
3625
- if (sid) {
3626
- conversation = getConversationStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash });
3627
- // v1.0.133 Slice 3: pass contentDbPath so getRealBytesStats can
3628
- // join chunks WHERE session_id = sid and fold the indexed
3629
- // content bytes into the per-conversation bar. Without this,
3630
- // Mert's session showed ~200B (event metadata only) even with
3631
- // 49 MB of indexed content sitting in the content DB.
3632
- // Render-time read-only — no DB mutation, no backfill.
3633
- const contentDbPath = getStorePath();
3634
- // v1.0.148 Bug E+F: a conversation typically spans many
3635
- // session_ids (resume cycles, /compact rebirths, PID
3636
- // sub-process sessions launched by ctx_execute). Scoping
3637
- // per-session loses sandbox-burst bytes_avoided that the
3638
- // PID-sessions own. Look up THIS session's project_dir
3639
- // from META and aggregate via META subquery so all
3640
- // sibling sessions in the same cwd attribute together.
3641
- // Fallback to sessionId scope if the META lookup fails
3642
- // (best-effort — the original metric is still defensible).
3643
- let convReal;
3644
- try {
3645
- const Database = loadDatabase();
3646
- const dbFiles = (await import("node:fs"))
3647
- .readdirSync(getSessionDir())
3648
- .filter((f) => f.endsWith(".db") && (!dbHash || f.startsWith(dbHash)));
3649
- let projectDirForSid;
3650
- for (const file of dbFiles) {
3651
- try {
3652
- const sdb = new Database((await import("node:path")).join(getSessionDir(), file), { readonly: true });
3653
- try {
3654
- const r = sdb
3655
- .prepare("SELECT project_dir FROM session_meta WHERE session_id = ?")
3656
- .get(sid);
3657
- if (r?.project_dir) {
3658
- projectDirForSid = r.project_dir;
3659
- break;
3660
- }
3661
- }
3662
- finally {
3663
- sdb.close();
3664
- }
3665
- }
3666
- catch { /* skip unreadable DB */ }
3667
- }
3668
- // Section 1 "Where you are now" = the LIVE conversation window.
3669
- // Sub-agents + ctx_execute sub-process sessions write to this
3670
- // SAME worktree DB (same worktreeHash = sha256(cwd)) under their
3671
- // own session_ids; their retrieval hit their own disposable
3672
- // windows, not yours. getConversationWindowStats credits the
3673
- // whole worktree's kept-out bytes while counting only THIS
3674
- // session's retrieval as "With context-mode", and the
3675
- // worktreeHash scope keeps the user's OTHER parallel worktrees
3676
- // out. projectDirForSid is intentionally dropped — it
3677
- // under-counted (missed empty-project_dir sub-process sessions)
3678
- // and could not separate sub-agent retrieval from the window's.
3679
- void projectDirForSid;
3680
- convReal = getConversationWindowStats({ sessionId: sid, worktreeHash: dbHash, sessionsDir: getSessionDir(), contentDbPath });
3681
- }
3682
- catch {
3683
- convReal = getConversationWindowStats({ sessionId: sid, worktreeHash: dbHash, sessionsDir: getSessionDir(), contentDbPath });
3684
- }
3685
- const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
3686
- // v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
3687
- // session_id filter). Without this fold, lifetime "kept out"
3688
- // only counts session_events.bytes_avoided and ignores the
3689
- // bulk of indexed payload across every prior conversation.
3690
- const lifeContentBytes = getContentBytesAllSessions(contentDbPath);
3691
- const lifeReal = {
3692
- ...lifeRealBase,
3693
- contentBytes: lifeRealBase.contentBytes + lifeContentBytes,
3694
- bytesAvoided: lifeRealBase.bytesAvoided + lifeContentBytes,
3695
- totalSavedTokens: Math.floor((lifeRealBase.eventDataBytes
3696
- + lifeRealBase.bytesAvoided
3697
- + lifeContentBytes
3698
- + lifeRealBase.snapshotBytes) / 4),
3699
- };
3700
- realBytes = { conversation: convReal, lifetime: lifeReal };
3701
- }
3702
- }
3703
- catch { /* never block ctx_stats */ }
3704
- // Pi byte accounting: patch lifetime from stats-*.json files
3705
- // (actual bytes_sandboxed, not events × 256 heuristic).
3706
- if (_detectedAdapter?.name === "Pi") {
3707
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3708
- }
3709
- // v1.0.117: pass projectDir as cwd so the narrative renderer's
3710
- // "started in <path>" line matches the user's actual project.
3711
- // Snapshot the persistent store so the renderer can show
3712
- // total_chunks / last_indexed_at without callers having to query
3713
- // separately. Best-effort — getStore() is process-local and may
3714
- // be unavailable on cold paths; failures are absorbed.
3715
- let indexState;
3716
- try {
3717
- indexState = getStore().getIndexState();
3718
- }
3719
- catch { /* never block ctx_stats */ }
3720
- text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, indexState, cwd: projectDir });
3721
- }
3722
- finally {
3723
- sdb.close();
3724
- }
3725
- }
3726
- else {
3727
- // No session DB — build a minimal report from runtime stats only.
3728
- // Lifetime still meaningful (other projects, auto-memory) so include it.
3729
- const engine = new AnalyticsEngine(createMinimalDb());
3730
- const report = engine.queryAll(sessionStats);
3731
- const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3732
- if (_detectedAdapter?.name === "Pi") {
3733
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3734
- }
3735
- let multiAdapter;
3736
- try {
3737
- multiAdapter = getMultiAdapterLifetimeStats();
3738
- }
3739
- catch { /* never block ctx_stats */ }
3740
- let indexState;
3741
- try {
3742
- indexState = getStore().getIndexState();
3743
- }
3744
- catch { /* never block ctx_stats */ }
3745
- text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter, indexState });
3746
- }
3747
- }
3748
- catch {
3749
- // Session DB not available or incompatible — build minimal report from runtime stats
3750
- const engine = new AnalyticsEngine(createMinimalDb());
3751
- const report = engine.queryAll(sessionStats);
3752
- let lifetime;
3753
- try {
3754
- lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
3755
- }
3756
- catch { /* never block ctx_stats */ }
3757
- if (_detectedAdapter?.name === "Pi" && lifetime) {
3758
- patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
3759
- }
3760
- let multiAdapter;
3761
- try {
3762
- multiAdapter = getMultiAdapterLifetimeStats();
3763
- }
3764
- catch { /* never block ctx_stats */ }
3765
- text = formatReport(report, VERSION, _latestVersion, (lifetime || multiAdapter) ? { lifetime, multiAdapter } : undefined);
3766
- }
3767
- return trackResponse("ctx_stats", {
3768
- content: [{ type: "text", text }],
3769
- });
3770
- });
3771
- // ── ctx-doctor: diagnostics (server-side) ─────────────────────────────────
3772
- server.registerTool("ctx_doctor", {
3773
- title: "Run Diagnostics",
3774
- // #846: read-only diagnostics (runs an internal self-test, mutates nothing).
3775
- // Was cancelled by Codex when unannotated.
3776
- annotations: {
3777
- readOnlyHint: true,
3778
- destructiveHint: false,
3779
- idempotentHint: true,
3780
- openWorldHint: false,
3781
- },
3782
- description: "Diagnose context-mode installation. Runs all checks server-side and " +
3783
- "returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
3784
- "(renderer-safe across MCP clients). No CLI execution needed.",
3785
- inputSchema: z.object({}),
3786
- }, async () => {
3787
- // Renderer-safe output (Mickey #3 — Z.ai GLM 4.7 ReferenceError):
3788
- // Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
3789
- // markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
3790
- // a missing `client` context, throwing `ReferenceError: client is not
3791
- // defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
3792
- // safe across all MCP renderers — using plain-text status prefixes
3793
- // (`[OK]` / `[FAIL]` / `[WARN]`) instead.
3794
- const lines = ["context-mode doctor", ""];
3795
- let currentPlatform;
3796
- try {
3797
- currentPlatform = detectPlatform(server.server.getClientVersion() ?? undefined).platform;
3798
- }
3799
- catch {
3800
- currentPlatform = detectPlatform().platform;
3801
- }
3802
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3803
- // Codex is special: when plugin-manager runtime root differs from the
3804
- // current package root, diagnose the root Codex will actually execute.
3805
- const pluginRoot = getRuntimeAwarePackageRoot(currentPlatform);
3806
- // Runtimes
3807
- const total = 11;
3808
- const pct = ((available.length / total) * 100).toFixed(0);
3809
- lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
3810
- // Performance
3811
- if (hasBunRuntime()) {
3812
- lines.push("[OK] Performance: FAST (Bun)");
3813
- }
3814
- else {
3815
- lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
3816
- }
3817
- const sessionStorage = resolveSessionStorageDir(getDefaultSessionDir);
3818
- const contentStorage = resolveContentStorageDir(getDefaultSessionDir);
3819
- const statsStorage = resolveStatsStorageDir(getDefaultSessionDir);
3820
- lines.push(`[OK] Storage sessions: ${sessionStorage.path} (${describeStorageDirectorySource(sessionStorage)})`);
3821
- lines.push(`[OK] Storage content: ${contentStorage.path} (${describeStorageDirectorySource(contentStorage)})`);
3822
- lines.push(`[OK] Storage stats: ${statsStorage.path} (${describeStorageDirectorySource(statsStorage)})`);
3823
- // Server test — cleanup executor to prevent resource leaks (#247)
3824
- {
3825
- const testExecutor = new PolyglotExecutor({ runtimes });
3826
- try {
3827
- const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
3828
- if (result.exitCode === 0 && result.stdout.trim() === "ok") {
3829
- lines.push("[OK] Server test: PASS");
3830
- }
3831
- else {
3832
- const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
3833
- lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
3834
- }
3835
- }
3836
- catch (err) {
3837
- lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
3838
- }
3839
- finally {
3840
- testExecutor.cleanupBackgrounded();
3841
- }
3842
- }
3843
- // FTS5 / SQLite — close in finally to prevent GC segfault (#247)
3844
- {
3845
- let testDb;
3846
- try {
3847
- const Database = loadDatabase();
3848
- testDb = new Database(":memory:");
3849
- testDb.exec("CREATE VIRTUAL TABLE fts_test USING fts5(content)");
3850
- testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
3851
- const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
3852
- if (row && row.content === "hello world") {
3853
- lines.push("[OK] FTS5 / SQLite: PASS — native module works");
3854
- }
3855
- else {
3856
- lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
3857
- }
3858
- }
3859
- catch (err) {
3860
- lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
3861
- }
3862
- finally {
3863
- try {
3864
- testDb?.close();
3865
- }
3866
- catch { /* best effort */ }
3867
- }
3868
- }
3869
- // Hooks
3870
- const diagnosticAdapter = await getDiagnosticAdapter();
3871
- if (diagnosticAdapter) {
3872
- for (const result of diagnosticAdapter.validateHooks(pluginRoot)) {
3873
- const prefix = result.status === "pass" ? "[OK]" : result.status === "warn" ? "[WARN]" : "[FAIL]";
3874
- const fix = result.fix ? ` — fix: ${result.fix}` : "";
3875
- lines.push(`${prefix} ${result.check}: ${result.message}${fix}`);
3876
- }
3877
- const hookScriptPaths = getHookScriptPaths(diagnosticAdapter, pluginRoot);
3878
- if (hookScriptPaths.length === 0) {
3879
- lines.push("[OK] Hook scripts: no direct .mjs script paths to verify");
3880
- }
3881
- for (const scriptPath of hookScriptPaths) {
3882
- const hookPath = resolve(pluginRoot, scriptPath);
3883
- if (existsSync(hookPath)) {
3884
- lines.push(`[OK] Hook script: PASS — ${hookPath}`);
3885
- }
3886
- else {
3887
- lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
3888
- }
3889
- }
3890
- }
3891
- else {
3892
- lines.push("[WARN] Hooks: adapter detection unavailable");
3893
- }
3894
- // Version
3895
- lines.push(`[OK] Version: v${VERSION}`);
3896
- return trackResponse("ctx_doctor", {
3897
- content: [{ type: "text", text: lines.join("\n") }],
3898
- });
3899
- });
3900
- // ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
3901
- server.registerTool("ctx_upgrade", {
3902
- title: "Upgrade Plugin",
3903
- // #846: an action tool (returns an upgrade command to run); not read-only,
3904
- // but non-destructive and idempotent. No direct network from the call.
3905
- annotations: {
3906
- readOnlyHint: false,
3907
- destructiveHint: false,
3908
- idempotentHint: true,
3909
- openWorldHint: false,
3910
- },
3911
- description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
3912
- "You MUST run the returned command using your shell tool (Bash, shell_execute, " +
3913
- "run_in_terminal, etc.) and display the output as a checklist. " +
3914
- "Tell the user to restart their session after upgrade.",
3915
- inputSchema: z.object({}),
3916
- }, async () => {
3917
- // Issue #542 — thread MCP clientInfo into the spawned upgrade
3918
- // process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
3919
- // so clientInfo from the MCP handshake is the highest-confidence
3920
- // signal available. We forward the resolved PlatformId as a
3921
- // --platform flag (cross-shell safe on POSIX, Git Bash, PowerShell,
3922
- // and cmd.exe — unlike env-var prefixes). If detection fails we
3923
- // skip the flag and let upgrade()'s own detectPlatform() fall back.
3924
- let platformFlag = "";
3925
- let nodeOpts = undefined;
3926
- let platformId;
3927
- try {
3928
- const clientInfo = server.server.getClientVersion();
3929
- const signal = detectPlatform(clientInfo ?? undefined);
3930
- platformId = signal.platform;
3931
- platformFlag = ` --platform ${signal.platform}`;
3932
- nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
3933
- ? { platform: signal.platform, jsRuntime: runtimes.javascript }
3934
- : undefined;
3935
- }
3936
- catch {
3937
- try {
3938
- platformId = detectPlatform().platform;
3939
- }
3940
- catch { /* best effort — fall back to upgrade()'s own detect */ }
3941
- }
3942
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3943
- // Only Codex may replace it with the plugin-manager runtime root; other
3944
- // adapters can coexist with Codex on the same machine.
3945
- const pluginRoot = getRuntimeAwarePackageRoot(platformId);
3946
- const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
3947
- const fallbackPath = resolve(pluginRoot, "build", "cli.js");
3948
- // Insight pivoted to the hosted dashboard (context-mode.com/insight), so
3949
- // ctx_insight no longer builds a local cache. On upgrade, sweep the legacy
3950
- // insight-cache and stop any stale local dashboard left from old versions.
3951
- try {
3952
- const sessDir = getSessionDir();
3953
- const insightCacheDir = join(dirname(sessDir), "insight-cache");
3954
- if (existsSync(insightCacheDir)) {
3955
- // Kill any running insight server first via the shared helper —
3956
- // this is locale-independent on Windows (PR #469) and isolates per-pid
3957
- // failures. We ignore the structured result: cache cleanup is
3958
- // best-effort and must never block ctx_upgrade.
3959
- killProcessOnPort(4747);
3960
- rmSync(insightCacheDir, { recursive: true, force: true });
3961
- }
3962
- }
3963
- catch { /* best effort — don't block upgrade */ }
3964
- let cmd;
3965
- if (existsSync(bundlePath)) {
3966
- cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
3967
- }
3968
- else if (existsSync(fallbackPath)) {
3969
- cmd = `${buildNodeCommand(fallbackPath, nodeOpts)} upgrade${platformFlag}`;
3970
- }
3971
- else {
3972
- // Inline fallback: neither CLI file exists (e.g. marketplace installs).
3973
- // Generate a self-contained node -e script that performs the upgrade.
3974
- const repoUrl = "https://github.com/mksglu/context-mode.git";
3975
- // Write inline script to a temp .mjs file — avoids quote-escaping issues
3976
- // across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
3977
- const scriptLines = [
3978
- `import{execFileSync}from"node:child_process";`,
3979
- `import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync,lstatSync}from"node:fs";`,
3980
- `import{join,resolve,sep}from"node:path";`,
3981
- `import{tmpdir}from"node:os";`,
3982
- `const P=${JSON.stringify(pluginRoot)};`,
3983
- `const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
3984
- `try{`,
3985
- `console.log("- [x] Starting inline upgrade (no CLI found)");`,
3986
- `execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
3987
- `console.log("- [x] Cloned latest source");`,
3988
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
3989
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["run","build"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
3990
- `console.log("- [x] Built from source");`,
3991
- `const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
3992
- `const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
3993
- // Supply-chain containment on items[]. Mirror the cli.ts upgrade()
3994
- // guard: a compromised upstream package.json with files:["../etc"]
3995
- // would otherwise let path.join follow ".." out of pluginRoot.
3996
- // path.resolve normalizes "..", so the lexical startsWith catches
3997
- // both relative-".." traversal and absolute-path bypass. Plus a
3998
- // symlink filter so a committed symlink inside the clone can't
3999
- // plant itself in pluginRoot (cpSync default preserves source
4000
- // symlinks; a planted symlink in pluginRoot/src then redirects
4001
- // every subsequent load through to an attacker target).
4002
- `const PW=resolve(P)+sep;const TW=resolve(T)+sep;`,
4003
- `const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};`,
4004
- `for(const item of items){const from=resolve(T,item);const to=resolve(P,item);if(!(to+sep).startsWith(PW))continue;if(!(from+sep).startsWith(TW))continue;if(!noSymlink(from))continue;if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true,filter:noSymlink});}}`,
4005
- // Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
4006
- // .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
4007
- // per-version .mcp.json file is a stale-write vector. Same architectural
4008
- // fix as the cli.ts upgrade() path; both writers were the only producers.
4009
- `console.log("- [x] Copied package files");`,
4010
- `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install","--production"],{cwd:P,stdio:"inherit",shell:process.platform==="win32"});`,
4011
- `console.log("- [x] Installed production dependencies");`,
4012
- `console.log("## context-mode upgrade complete");`,
4013
- `}catch(e){`,
4014
- `console.error("- [ ] Upgrade failed:",e.message);`,
4015
- `process.exit(1);`,
4016
- `}finally{`,
4017
- `try{rmSync(T,{recursive:true,force:true})}catch{}`,
4018
- `}`,
4019
- ].join("\n");
4020
- // Server writes the temp script file — avoids shell quoting issues entirely
4021
- const tmpScript = resolve(pluginRoot, ".ctx-upgrade-inline.mjs");
4022
- const { writeFileSync: writeTmp } = await import("node:fs");
4023
- writeTmp(tmpScript, scriptLines);
4024
- cmd = buildNodeCommand(tmpScript, nodeOpts);
4025
- }
4026
- const text = [
4027
- "## ctx-upgrade",
4028
- "",
4029
- "Run this command using your shell execution tool:",
4030
- "",
4031
- "```",
4032
- cmd,
4033
- "```",
4034
- "",
4035
- "After the command completes, display results as a markdown checklist:",
4036
- "- `[x]` for success, `[ ]` for failure",
4037
- "- Example format:",
4038
- " ```",
4039
- " ## context-mode upgrade",
4040
- " - [x] Pulled latest from GitHub",
4041
- " - [x] Built and installed v0.9.24",
4042
- " - [x] npm global updated",
4043
- " - [x] Hooks configured",
4044
- " - [x] Doctor: all checks PASS",
4045
- " ```",
4046
- "- Tell the user to restart their session to pick up the new version.",
4047
- ].join("\n");
4048
- return trackResponse("ctx_upgrade", {
4049
- content: [{ type: "text", text }],
4050
- });
4051
- });
4052
- // ── ctx-purge: explicit knowledge base wipe ─────────────────────────────────
4053
- //
4054
- // Issue #520 — scoped purge.
4055
- // The schema is ADDITIVE: bare {confirm:true} preserves the legacy
4056
- // project-wide wipe verbatim (with a stderr deprecation warning so
4057
- // future callers migrate to explicit scope). When sessionId is given,
4058
- // only that session's rows + FTS5 chunks are removed; project-wide
4059
- // files (events.md, FTS5 store file, stats file) are preserved.
4060
- // Passing both sessionId AND scope:"project" is ambiguous (does the
4061
- // caller want a per-session wipe or a project-wide one?) and is
4062
- // rejected by an explicit check in the handler body — NOT a schema-level
4063
- // .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
4064
- // inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
4065
- // has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
4066
- // Code's strict-input-validation gate then rejects EVERY call to this
4067
- // tool with "input_schema does not support fields". Issue #563.
4068
- server.registerTool("ctx_purge", {
4069
- title: "Purge Knowledge Base",
4070
- // #846: permanently deletes indexed content — destructive. Purging an
4071
- // already-purged scope has no further effect (idempotent). No network.
4072
- annotations: {
4073
- readOnlyHint: false,
4074
- destructiveHint: true,
4075
- idempotentHint: true,
4076
- openWorldHint: false,
4077
- },
4078
- description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
4079
-
4080
- WHEN:
4081
- - User explicitly asks to clear a specific session ('purge this session', 'wipe this conversation')
4082
- - User explicitly asks to reset the whole project ('reset everything', 'wipe the knowledge base')
4083
-
4084
- WHEN NOT:
4085
- - User says 'reset', 'clear', or 'wipe' without naming a scope -> ask which scope before calling
4086
- - User wants to free memory or improve performance -> recommend ctx_stats first, do not purge
4087
-
4088
- SCOPES (pass exactly one):
4089
- - Per-session: ctx_purge(confirm: true, sessionId: "<uuid>") deletes that session's events (auto-captured decisions, errors, plans, user prompts, rejected approaches, etc.) and per-session FTS5 chunks; sibling sessions and stats file are preserved.
4090
- - Per-project: ctx_purge(confirm: true, scope: "project") wipes FTS5 knowledge base, every session DB row, events markdown, and resets the stats file. Use ctx_stats first to preview category counts before purging.
4091
-
4092
- CONTRACT:
4093
- - confirm:true is required; confirm:false returns 'purge cancelled'.
4094
- - sessionId and scope:'project' together return 'ambiguous - pick one'.
4095
- - scope:'session' without sessionId throws (sessionId required).
4096
- - Bare {confirm:true} is deprecated: maps to scope:'project' with a stderr warning; will hard-error in a future major.
4097
-
4098
- RETURNS:
4099
- A summary of removed rows + the resolved scope.
4100
-
4101
- EXAMPLE: ctx_purge(confirm: true, sessionId: "7c8a-1234-5678-9abc-def012345678")
4102
- EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
4103
- // NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
4104
- // .superRefine() wrapper. See block comment above & issue #563. The
4105
- // cross-field ambiguity check lives in the handler body below.
4106
- inputSchema: z.object({
4107
- // confirm: wrapped in coerceBoolean preprocessor — OpenCode's native
4108
- // plugin bridge can deliver `confirm:"true"` / `confirm:"false"` as
4109
- // string literals. Without this, v1.0.139's inputSchema.parse() path
4110
- // rejects valid intent as "Expected boolean, received string" (#627).
4111
- confirm: z.preprocess(coerceBoolean, z.boolean()).describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
4112
- sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
4113
- "session's events + per-session FTS5 chunks. Sibling sessions and the " +
4114
- "stats file are preserved. MUST NOT be combined with scope:'project'."),
4115
- scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
4116
- "the entire project (FTS5 + every session + stats). Omit only for the " +
4117
- "deprecated bare-{confirm:true} back-compat path."),
4118
- }),
4119
- }, async ({ confirm, sessionId, scope }) => {
4120
- // Cross-field ambiguity check — formerly a schema .refine(), moved
4121
- // into the handler so the inputSchema stays a plain ZodObject and
4122
- // the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
4123
- // Same human-readable message as the original refine() preserved.
4124
- if (sessionId && scope === "project") {
4125
- return trackResponse("ctx_purge", {
4126
- content: [{
4127
- type: "text",
4128
- text: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
4129
- "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
4130
- }],
4131
- isError: true,
4132
- });
4133
- }
4134
- if (!confirm) {
4135
- return trackResponse("ctx_purge", {
4136
- content: [{
4137
- type: "text",
4138
- text: "Purge cancelled. Pass confirm: true to proceed.",
4139
- }],
4140
- });
4141
- }
4142
- // Effective scope resolution:
4143
- // - explicit scope wins
4144
- // - else "session" iff sessionId is given
4145
- // - else "project" (back-compat — emit deprecation warning so
4146
- // callers migrate to the explicit form before a future major).
4147
- const effectiveScope = scope ?? (sessionId ? "session" : "project");
4148
- if (!scope && !sessionId) {
4149
- console.warn("[context-mode] ctx_purge: bare {confirm:true} is deprecated. " +
4150
- "Pass scope:'project' for the whole-project wipe, or scope:'session' + sessionId " +
4151
- "for a scoped wipe. See issue #520.");
4152
- }
4153
- // Close the persistent FTS5 content store handle BEFORE delegating to
4154
- // purgeSession so the store's lock is released on Windows. The handle
4155
- // is recreated lazily on the next getStore() call.
4156
- let storePathForPurge;
4157
- try {
4158
- storePathForPurge = getStorePath();
4159
- }
4160
- catch { /* best effort — store path may be unresolvable on fresh install */ }
4161
- if (_store) {
4162
- try {
4163
- _store.cleanup();
4164
- }
4165
- catch { /* best effort */ }
4166
- _store = null;
4167
- }
4168
- // FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
4169
- // and legacy raw-casing variants (dual-hash, mirrors session events).
4170
- // storePath is also passed for the rare case where the resolver picked
4171
- // an absolute path that differs from the dual-hash pair (e.g. caller
4172
- // pre-migrated). Both paths are de-duped during unlink.
4173
- const contentDir = storePathForPurge ? dirname(storePathForPurge) : undefined;
4174
- const { deleted } = purgeSession({
4175
- projectDir: getProjectDir(),
4176
- sessionsDir: getSessionDir(),
4177
- storePath: storePathForPurge,
4178
- contentDir,
4179
- legacyContentDir: join(homedir(), ".context-mode", "content"),
4180
- // hashProjectDirLegacy mirrors the deployed (≤ v1.0.111) raw-casing
4181
- // hash that named files under ~/.context-mode/content/. Using the
4182
- // legacy hash here is correct: that pre-pre-legacy directory was
4183
- // never migrated and still uses raw casing.
4184
- contentHash: hashProjectDirLegacy(getProjectDir()),
4185
- scope: effectiveScope,
4186
- sessionId,
4187
- });
4188
- // Stats are PROJECT-scoped (one stats file per project, summing all
4189
- // sessions). A scoped per-session purge MUST leave stats alone — they
4190
- // still belong to other sessions in the same project. Stats reset
4191
- // happens ONLY when scope === "project".
4192
- if (effectiveScope === "project") {
4193
- // Reset in-memory session stats
4194
- sessionStats.calls = {};
4195
- sessionStats.bytesReturned = {};
4196
- sessionStats.bytesIndexed = 0;
4197
- sessionStats.bytesSandboxed = 0;
4198
- sessionStats.cacheHits = 0;
4199
- sessionStats.cacheBytesSaved = 0;
4200
- sessionStats.sessionStart = Date.now();
4201
- deleted.push("session stats");
4202
- // Also drop the persisted stats file so external readers see a fresh state
4203
- try {
4204
- const statsFile = getStatsFilePath();
4205
- if (existsSync(statsFile))
4206
- unlinkSync(statsFile);
4207
- }
4208
- catch { /* best effort */ }
4209
- }
4210
- const message = effectiveScope === "session"
4211
- ? `Purged session ${sessionId}: ${deleted.length ? deleted.join(", ") : "no matching rows"}. ` +
4212
- `Other sessions and project-wide stats preserved.`
4213
- : `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`;
4214
- return trackResponse("ctx_purge", {
4215
- content: [{
4216
- type: "text",
4217
- text: message,
4218
- }],
4219
- });
4220
- });
4221
- // Hard upper bound on every helper-internal spawnSync call. Caps tail-latency
4222
- // when an external binary hangs (xdg-open waiting for an X11 session, lsof
4223
- // stalling on /proc, taskkill blocking on an unresponsive process, etc.) so
4224
- // the MCP tool surfaces a diagnostic instead of blocking the agent loop.
4225
- // 5s is comfortably above the 99th-percentile completion of every command we
4226
- // invoke; anything past that is hung.
4227
- const HELPER_SPAWN_TIMEOUT_MS = 5000;
4228
- // Returns the argv attempts for opening `url` on `platform`, in fall-back order.
4229
- // Pure data — no I/O.
4230
- export function browserOpenArgv(url, platform) {
4231
- if (platform === "darwin")
4232
- return [{ cmd: "open", args: [url] }];
4233
- if (platform === "win32") {
4234
- // `start` is a cmd.exe builtin; the empty title arg ("") prevents the URL
4235
- // from being consumed as the window title.
4236
- return [{ cmd: "cmd", args: ["/c", "start", "", url] }];
4237
- }
4238
- // linux/bsd: try xdg-open, then sensible-browser (Debian/Ubuntu).
4239
- return [
4240
- { cmd: "xdg-open", args: [url] },
4241
- { cmd: "sensible-browser", args: [url] },
4242
- ];
4243
- }
4244
- // Opens a browser synchronously, waiting for each attempt to complete.
4245
- // Returns a structured result so callers can surface auto-open failures
4246
- // to the user instead of falsely reporting success.
4247
- export function openBrowserSync(url, platform = process.platform, runner = spawnSync) {
4248
- const attempts = browserOpenArgv(url, platform);
4249
- const errors = [];
4250
- for (const { cmd, args } of attempts) {
4251
- try {
4252
- const r = runner(cmd, args, { stdio: "ignore", timeout: HELPER_SPAWN_TIMEOUT_MS });
4253
- // Treat signal-kill (status === null) and any non-zero status as failure
4254
- // so the next fallback fires.
4255
- if (!r.error && r.status === 0)
4256
- return { ok: true, method: cmd };
4257
- const reason = r.error?.message ?? `status=${r.status === null ? "signaled" : r.status}`;
4258
- errors.push(`${cmd}: ${reason}`);
4259
- }
4260
- catch (e) {
4261
- errors.push(`${cmd}: ${e instanceof Error ? e.message : String(e)}`);
4262
- }
4263
- }
4264
- return { ok: false, method: "none", reason: errors.join("; ") };
4265
- }
4266
- // Kills any process listening on `port`. Returns a structured result so
4267
- // the caller can distinguish between (a) port was free, (b) kill succeeded,
4268
- // (c) kill failed (perms, missing binary, or per-pid failure mid-loop).
4269
- //
4270
- // On Windows the netstat parser is locale-independent: the STATE column
4271
- // ("LISTENING" / "ESTABLISHED" / ...) is translated on non-English Windows
4272
- // (Windows-FR shows "À l'écoute", Windows-DE "ABHÖREN", etc.), but the REMOTE
4273
- // ADDRESS column is not. A listening TCP socket always has remote
4274
- // "0.0.0.0:0" (IPv4) or "[::]:0" (IPv6); a connected one has a real
4275
- // addr:port. We therefore key off the remote column instead of the state
4276
- // string. This also rules out the pre-fix bug where matching only the local
4277
- // port number cross-matched a remote :port from an outbound connection and
4278
- // taskkill'd an unrelated process.
4279
- export function killProcessOnPort(port, platform = process.platform, runner = spawnSync) {
4280
- const result = { killedPids: [], attemptedPids: [], errors: [] };
4281
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
4282
- result.errors.push(`invalid port: ${port}`);
4283
- return result;
4284
- }
4285
- try {
4286
- if (platform === "win32") {
4287
- const r = runner("netstat", ["-ano"], {
4288
- encoding: "utf-8",
4289
- stdio: ["ignore", "pipe", "ignore"],
4290
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4291
- });
4292
- if (r.error) {
4293
- result.errors.push(`netstat: ${r.error.message}`);
4294
- return result;
4295
- }
4296
- if (r.status !== 0 || typeof r.stdout !== "string")
4297
- return result;
4298
- const portSuffix = `:${port}`;
4299
- const pids = new Set();
4300
- for (const rawLine of r.stdout.split(/\r?\n/)) {
4301
- const line = rawLine.trim();
4302
- if (!line)
4303
- continue;
4304
- const tokens = line.split(/\s+/);
4305
- // netstat -ano LISTENING row (en-US): "TCP 0.0.0.0:4747 0.0.0.0:0 LISTENING 1234"
4306
- // The STATE column is locale-translated and may itself contain spaces
4307
- // (Windows-FR `À l'écoute` splits into two tokens), so we cannot index
4308
- // STATE by position. PID is always the trailing column; PROTO/LOCAL/
4309
- // REMOTE are the first three. We anchor on those + a remote-wildcard
4310
- // check that's locale-independent.
4311
- if (tokens.length < 5)
4312
- continue;
4313
- const proto = tokens[0];
4314
- const local = tokens[1];
4315
- const remote = tokens[2];
4316
- const pid = tokens[tokens.length - 1];
4317
- if (proto !== "TCP")
4318
- continue;
4319
- if (!local.endsWith(portSuffix))
4320
- continue;
4321
- // Listening sockets carry a wildcard remote; anything else is a
4322
- // connection (and matching it would kill an unrelated process).
4323
- if (remote !== "0.0.0.0:0" && remote !== "[::]:0")
4324
- continue;
4325
- if (!/^\d+$/.test(pid))
4326
- continue;
4327
- pids.add(pid);
4328
- }
4329
- for (const pid of pids) {
4330
- result.attemptedPids.push(pid);
4331
- try {
4332
- const k = runner("taskkill", ["/F", "/PID", pid], {
4333
- stdio: "ignore",
4334
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4335
- });
4336
- if (k.error || k.status !== 0) {
4337
- result.errors.push(`taskkill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
4338
- }
4339
- else {
4340
- result.killedPids.push(pid);
4341
- }
4342
- }
4343
- catch (e) {
4344
- result.errors.push(`taskkill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
4345
- }
4346
- }
4347
- }
4348
- else {
4349
- const r = runner("lsof", ["-ti", `:${port}`], {
4350
- encoding: "utf-8",
4351
- stdio: ["ignore", "pipe", "ignore"],
4352
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4353
- });
4354
- if (r.error) {
4355
- // ENOENT (lsof not installed) is a real diagnostic; surface it.
4356
- result.errors.push(`lsof: ${r.error.message}`);
4357
- return result;
4358
- }
4359
- // lsof exits 1 with empty stdout when the port is free — not an error.
4360
- if (r.status !== 0 || typeof r.stdout !== "string")
4361
- return result;
4362
- const pids = r.stdout.split(/\r?\n/).filter(p => /^\d+$/.test(p));
4363
- for (const pid of pids) {
4364
- result.attemptedPids.push(pid);
4365
- try {
4366
- const k = runner("kill", [pid], {
4367
- stdio: "ignore",
4368
- timeout: HELPER_SPAWN_TIMEOUT_MS,
4369
- });
4370
- if (k.error || k.status !== 0) {
4371
- result.errors.push(`kill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
4372
- }
4373
- else {
4374
- result.killedPids.push(pid);
4375
- }
4376
- }
4377
- catch (e) {
4378
- result.errors.push(`kill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
4379
- }
4380
- }
4381
- }
4382
- }
4383
- catch (e) {
4384
- result.errors.push(e instanceof Error ? e.message : String(e));
4385
- }
4386
- return result;
4387
- }
4388
- // ── ctx-insight: open the hosted Insight dashboard ───────────────────────────
4389
- // Insight pivoted from a locally-built dashboard to the hosted B2B product at
4390
- // context-mode.com/insight (the landing page is the single source of truth).
4391
- // The tool now simply opens that URL in the user default browser via the same
4392
- // cross-platform helper (openBrowserSync) used elsewhere.
4393
- const INSIGHT_URL = "https://context-mode.com/insight";
4394
- server.registerTool("ctx_insight", {
4395
- title: "Open Insight Dashboard",
4396
- // #846: opens a hosted dashboard URL in the browser — an external side
4397
- // effect (open world), not a read-only query; safe to repeat.
4398
- annotations: {
4399
- readOnlyHint: false,
4400
- destructiveHint: false,
4401
- idempotentHint: true,
4402
- openWorldHint: true,
4403
- },
4404
- description: "Opens the context-mode Insight dashboard (https://context-mode.com/insight) in your " +
4405
- "default browser — a dashboard launcher for the hosted analytics layer, not a Q&A engine. " +
4406
- "Insight surfaces per-engineer productive rate, retry waste, blocker detection, and " +
4407
- "role-narrowed views for CTO, EM, IC, CISO, FinOps, and DevOps. " +
4408
- "For natural-language queries over your indexed content, use ctx_search.",
4409
- inputSchema: z.object({}),
4410
- }, async () => {
4411
- const open = openBrowserSync(INSIGHT_URL);
4412
- const text = open.ok
4413
- ? `Opening Insight in your browser: ${INSIGHT_URL}`
4414
- : `Could not auto-open your browser (${open.reason}).\nOpen Insight manually: ${INSIGHT_URL}`;
4415
- return trackResponse("ctx_insight", {
4416
- content: [{ type: "text", text }],
4417
- });
4418
- });
4419
- // ─────────────────────────────────────────────────────────
4420
- // Server startup
4421
- // ─────────────────────────────────────────────────────────
4422
- async function main() {
4423
- // Clean up stale DB files from previous sessions
4424
- const cleaned = cleanupStaleDBs();
4425
- if (cleaned > 0) {
4426
- console.error(`Cleaned up ${cleaned} stale DB file(s) from previous sessions`);
4427
- }
4428
- // MCP readiness sentinel path (#230, #347)
4429
- // Uses process.pid (not ppid) — hooks use directory-scan to find any live sentinel.
4430
- // Hardcoded /tmp on Unix to avoid TMPDIR mismatch (#347).
4431
- const mcpSentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
4432
- const mcpSentinel = join(mcpSentinelDir, `context-mode-mcp-ready-${process.pid}`);
4433
- // #844: handle to the periodic sentinel refresh timer (started after connect).
4434
- let sentinelRefresh;
4435
- // Clean up own DB + backgrounded processes + preload script on shutdown
4436
- const shutdown = () => {
4437
- executor.cleanupBackgrounded();
4438
- if (_store)
4439
- _store.close(); // persist DB for --continue sessions
4440
- try {
4441
- unlinkSync(CM_FS_PRELOAD);
4442
- }
4443
- catch { /* best effort */ }
4444
- // Remove MCP readiness sentinel (#230)
4445
- try {
4446
- unlinkSync(mcpSentinel);
4447
- }
4448
- catch { /* best effort */ }
4449
- // #844: stop refreshing the sentinel mtime on shutdown.
4450
- if (sentinelRefresh)
4451
- clearInterval(sentinelRefresh);
4452
- };
4453
- const gracefulShutdown = async () => {
4454
- // Final stats flush — bypass throttle so the last 0-500ms of
4455
- // bytes_indexed / bytes_returned aren't silently lost on SIGTERM/SIGINT
4456
- // (PR #401 grill-me review B1: persistStats early-returns inside throttle
4457
- // window; gracefulShutdown previously did NOT bypass).
4458
- try {
4459
- _lastStatsPersist = 0;
4460
- persistStats();
4461
- }
4462
- catch { /* best effort — never block shutdown */ }
4463
- shutdown();
4464
- process.exit(0);
4465
- };
4466
- process.on("exit", shutdown);
4467
- process.on("SIGINT", () => { gracefulShutdown(); });
4468
- process.on("SIGTERM", () => { gracefulShutdown(); });
4469
- // Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
4470
- startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
4471
- const transport = new StdioServerTransport();
4472
- await server.connect(transport);
4473
- // #854: refresh the bridge-child idle clock on each inbound MCP message so an
4474
- // abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
4475
- // of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
4476
- attachMcpActivityTap(transport);
4477
- // Write MCP readiness sentinel (#230)
4478
- try {
4479
- writeFileSync(mcpSentinel, String(process.pid));
4480
- }
4481
- catch { /* best effort */ }
4482
- // #844: refresh the sentinel mtime while the server is alive so readiness
4483
- // probes from a foreign PID namespace (shared /tmp) can trust a recent
4484
- // sentinel even when process.kill(pid, 0) cannot see this PID. The reader's
4485
- // freshness window is 90s (hooks/core/mcp-ready.mjs); refresh at 30s (3x).
4486
- // unref() so this timer never keeps the event loop alive on its own.
4487
- sentinelRefresh = setInterval(() => {
4488
- try {
4489
- writeFileSync(mcpSentinel, String(process.pid));
4490
- }
4491
- catch { /* best effort */ }
4492
- }, 30_000);
4493
- sentinelRefresh.unref();
4494
- // Detect platform adapter — stored for platform-aware session paths
4495
- try {
4496
- const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
4497
- const clientInfo = server.server.getClientVersion();
4498
- const signal = detectPlatform(clientInfo ?? undefined);
4499
- _detectedAdapter = await getAdapter(signal.platform);
4500
- if (clientInfo) {
4501
- console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
4502
- }
4503
- }
4504
- catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
4505
- // Restore tool-call counters from SessionDB BEFORE the heartbeat fires
4506
- // so the very first persistStats() carries the prior PID's totals into
4507
- // the sidecar JSON the statusline reads. Otherwise `/ctx-upgrade` flashes
4508
- // `0 calls / $0.00` until the user makes another MCP tool call. Wrapped
4509
- // in try/catch — a stats-restore failure must never block server startup.
4510
- try {
4511
- const restored = restoreSessionStats(getSessionDbPath());
4512
- if (restored) {
4513
- for (const [tool, count] of Object.entries(restored.calls)) {
4514
- sessionStats.calls[tool] = count;
4515
- }
4516
- for (const [tool, bytes] of Object.entries(restored.bytesReturned)) {
4517
- sessionStats.bytesReturned[tool] = bytes;
4518
- }
4519
- // Anchor uptime_ms to the original session start so `/ctx-upgrade`
4520
- // doesn't reset the "session age" the statusline shows.
4521
- if (restored.sessionStart > 0) {
4522
- sessionStats.sessionStart = restored.sessionStart;
4523
- }
4524
- }
4525
- }
4526
- catch { /* best effort — never block startup on a stats restore failure */ }
4527
- // Non-blocking version check — result stored for trackResponse warnings.
4528
- // First fetch at startup, then refresh every hour so long-running sessions
4529
- // (some users keep the MCP server alive 24h+) catch new releases without a
4530
- // restart. `.unref()` lets the process exit normally on SIGTERM regardless
4531
- // of pending intervals.
4532
- fetchLatestVersion().then(v => { if (v !== "unknown")
4533
- _latestVersion = v; });
4534
- setInterval(() => {
4535
- fetchLatestVersion().then(v => { if (v !== "unknown")
4536
- _latestVersion = v; });
4537
- }, 60 * 60 * 1000).unref();
4538
- // Stats heartbeat — keep the statusline truthful while the user works in
4539
- // tools other than MCP (Bash/Read/Edit during long sessions or post-/compact
4540
- // pauses). Without this, stats.updated_at only advances on MCP tool calls,
4541
- // so bin/statusline.mjs falsely flips to "stale — restart to resume saving"
4542
- // even though the server is alive. Heartbeat refreshes updated_at every 60s;
4543
- // statusline staleness threshold is 30min (cliff is 30 missed ticks away).
4544
- setInterval(() => persistStats(), 60_000).unref();
4545
- if (process.stdin.isTTY) {
4546
- console.error(`Context Mode MCP server v${VERSION} running on stdio`);
4547
- console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
4548
- if (!hasBunRuntime()) {
4549
- console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
4550
- console.error(" curl -fsSL https://bun.sh/install | bash");
4551
- }
4552
- }
4553
- }
4554
- // Runs after every registerTool() above, so the SDK's default tools/list handler
4555
- // exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
4556
- // function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
4557
- installStrictClientSchemaCompat();
4558
- if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
4559
- main().catch((err) => {
4560
- console.error("Fatal:", err);
4561
- process.exit(1);
4562
- });
4563
- }