@mxalbert/context-mode 2.0.0

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 (365) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +13 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/index.ts +12 -0
  7. package/.openclaw-plugin/openclaw.plugin.json +38 -0
  8. package/.openclaw-plugin/package.json +28 -0
  9. package/LICENSE +94 -0
  10. package/README.md +1619 -0
  11. package/bin/statusline.mjs +378 -0
  12. package/build/adapters/antigravity/index.d.ts +51 -0
  13. package/build/adapters/antigravity/index.js +169 -0
  14. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  15. package/build/adapters/antigravity-cli/index.js +342 -0
  16. package/build/adapters/base.d.ts +92 -0
  17. package/build/adapters/base.js +135 -0
  18. package/build/adapters/claude-code/config.d.ts +8 -0
  19. package/build/adapters/claude-code/config.js +8 -0
  20. package/build/adapters/claude-code/hooks.d.ts +106 -0
  21. package/build/adapters/claude-code/hooks.js +183 -0
  22. package/build/adapters/claude-code/index.d.ts +80 -0
  23. package/build/adapters/claude-code/index.js +569 -0
  24. package/build/adapters/claude-code-base.d.ts +49 -0
  25. package/build/adapters/claude-code-base.js +113 -0
  26. package/build/adapters/client-map.d.ts +10 -0
  27. package/build/adapters/client-map.js +42 -0
  28. package/build/adapters/codex/config.d.ts +8 -0
  29. package/build/adapters/codex/config.js +8 -0
  30. package/build/adapters/codex/hooks.d.ts +55 -0
  31. package/build/adapters/codex/hooks.js +64 -0
  32. package/build/adapters/codex/index.d.ts +87 -0
  33. package/build/adapters/codex/index.js +995 -0
  34. package/build/adapters/codex/paths.d.ts +1 -0
  35. package/build/adapters/codex/paths.js +12 -0
  36. package/build/adapters/codex/usage.d.ts +107 -0
  37. package/build/adapters/codex/usage.js +227 -0
  38. package/build/adapters/copilot-base.d.ts +94 -0
  39. package/build/adapters/copilot-base.js +297 -0
  40. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  41. package/build/adapters/copilot-cli/hooks.js +64 -0
  42. package/build/adapters/copilot-cli/index.d.ts +48 -0
  43. package/build/adapters/copilot-cli/index.js +341 -0
  44. package/build/adapters/cursor/config.d.ts +4 -0
  45. package/build/adapters/cursor/config.js +4 -0
  46. package/build/adapters/cursor/hooks.d.ts +55 -0
  47. package/build/adapters/cursor/hooks.js +82 -0
  48. package/build/adapters/cursor/index.d.ts +64 -0
  49. package/build/adapters/cursor/index.js +517 -0
  50. package/build/adapters/detect.d.ts +136 -0
  51. package/build/adapters/detect.js +623 -0
  52. package/build/adapters/gemini-cli/config.d.ts +8 -0
  53. package/build/adapters/gemini-cli/config.js +8 -0
  54. package/build/adapters/gemini-cli/hooks.d.ts +83 -0
  55. package/build/adapters/gemini-cli/hooks.js +115 -0
  56. package/build/adapters/gemini-cli/index.d.ts +80 -0
  57. package/build/adapters/gemini-cli/index.js +483 -0
  58. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  59. package/build/adapters/jetbrains-copilot/config.js +8 -0
  60. package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
  61. package/build/adapters/jetbrains-copilot/hooks.js +87 -0
  62. package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
  63. package/build/adapters/jetbrains-copilot/index.js +131 -0
  64. package/build/adapters/kimi/config.d.ts +8 -0
  65. package/build/adapters/kimi/config.js +8 -0
  66. package/build/adapters/kimi/hooks.d.ts +28 -0
  67. package/build/adapters/kimi/hooks.js +34 -0
  68. package/build/adapters/kimi/index.d.ts +66 -0
  69. package/build/adapters/kimi/index.js +537 -0
  70. package/build/adapters/kimi/paths.d.ts +21 -0
  71. package/build/adapters/kimi/paths.js +52 -0
  72. package/build/adapters/kimi/usage.d.ts +82 -0
  73. package/build/adapters/kimi/usage.js +217 -0
  74. package/build/adapters/kiro/hooks.d.ts +61 -0
  75. package/build/adapters/kiro/hooks.js +94 -0
  76. package/build/adapters/kiro/index.d.ts +61 -0
  77. package/build/adapters/kiro/index.js +301 -0
  78. package/build/adapters/omp/index.d.ts +65 -0
  79. package/build/adapters/omp/index.js +182 -0
  80. package/build/adapters/omp/plugin.d.ts +81 -0
  81. package/build/adapters/omp/plugin.js +331 -0
  82. package/build/adapters/omp/usage.d.ts +49 -0
  83. package/build/adapters/omp/usage.js +110 -0
  84. package/build/adapters/openclaw/config.d.ts +8 -0
  85. package/build/adapters/openclaw/config.js +8 -0
  86. package/build/adapters/openclaw/hooks.d.ts +50 -0
  87. package/build/adapters/openclaw/hooks.js +61 -0
  88. package/build/adapters/openclaw/index.d.ts +74 -0
  89. package/build/adapters/openclaw/index.js +439 -0
  90. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  91. package/build/adapters/openclaw/mcp-tools.js +207 -0
  92. package/build/adapters/openclaw/plugin.d.ts +141 -0
  93. package/build/adapters/openclaw/plugin.js +818 -0
  94. package/build/adapters/openclaw/session-db.d.ts +55 -0
  95. package/build/adapters/openclaw/session-db.js +88 -0
  96. package/build/adapters/openclaw/usage.d.ts +34 -0
  97. package/build/adapters/openclaw/usage.js +52 -0
  98. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  99. package/build/adapters/openclaw/workspace-router.js +64 -0
  100. package/build/adapters/opencode/config.d.ts +8 -0
  101. package/build/adapters/opencode/config.js +8 -0
  102. package/build/adapters/opencode/hooks.d.ts +38 -0
  103. package/build/adapters/opencode/hooks.js +50 -0
  104. package/build/adapters/opencode/index.d.ts +144 -0
  105. package/build/adapters/opencode/index.js +638 -0
  106. package/build/adapters/opencode/plugin.d.ts +249 -0
  107. package/build/adapters/opencode/plugin.js +1536 -0
  108. package/build/adapters/opencode/v2.d.ts +117 -0
  109. package/build/adapters/opencode/v2.js +224 -0
  110. package/build/adapters/opencode/zod3tov4.d.ts +1 -0
  111. package/build/adapters/opencode/zod3tov4.js +132 -0
  112. package/build/adapters/pi/extension.d.ts +81 -0
  113. package/build/adapters/pi/extension.js +861 -0
  114. package/build/adapters/pi/index.d.ts +57 -0
  115. package/build/adapters/pi/index.js +173 -0
  116. package/build/adapters/pi/mcp-bridge.d.ts +268 -0
  117. package/build/adapters/pi/mcp-bridge.js +894 -0
  118. package/build/adapters/qwen-code/hooks.d.ts +26 -0
  119. package/build/adapters/qwen-code/hooks.js +29 -0
  120. package/build/adapters/qwen-code/index.d.ts +40 -0
  121. package/build/adapters/qwen-code/index.js +361 -0
  122. package/build/adapters/qwen-code/usage.d.ts +90 -0
  123. package/build/adapters/qwen-code/usage.js +222 -0
  124. package/build/adapters/types.d.ts +360 -0
  125. package/build/adapters/types.js +118 -0
  126. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  127. package/build/adapters/vscode-copilot/config.js +8 -0
  128. package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
  129. package/build/adapters/vscode-copilot/hooks.js +98 -0
  130. package/build/adapters/vscode-copilot/index.d.ts +32 -0
  131. package/build/adapters/vscode-copilot/index.js +222 -0
  132. package/build/adapters/zed/index.d.ts +40 -0
  133. package/build/adapters/zed/index.js +163 -0
  134. package/build/cli.d.ts +34 -0
  135. package/build/cli.js +1828 -0
  136. package/build/db-base.d.ts +250 -0
  137. package/build/db-base.js +756 -0
  138. package/build/executor.d.ts +72 -0
  139. package/build/executor.js +678 -0
  140. package/build/exit-classify.d.ts +19 -0
  141. package/build/exit-classify.js +12 -0
  142. package/build/fetch/blocks.d.ts +145 -0
  143. package/build/fetch/blocks.js +244 -0
  144. package/build/fetch/extract.d.ts +68 -0
  145. package/build/fetch/extract.js +123 -0
  146. package/build/fetch/page-store.d.ts +66 -0
  147. package/build/fetch/page-store.js +174 -0
  148. package/build/fetch-cache.d.ts +13 -0
  149. package/build/fetch-cache.js +15 -0
  150. package/build/lifecycle.d.ts +124 -0
  151. package/build/lifecycle.js +278 -0
  152. package/build/package-identity.d.ts +26 -0
  153. package/build/package-identity.js +28 -0
  154. package/build/runPool.d.ts +36 -0
  155. package/build/runPool.js +51 -0
  156. package/build/runtime.d.ts +71 -0
  157. package/build/runtime.js +659 -0
  158. package/build/search/auto-memory.d.ts +47 -0
  159. package/build/search/auto-memory.js +170 -0
  160. package/build/search/ctx-search-schema.d.ts +90 -0
  161. package/build/search/ctx-search-schema.js +135 -0
  162. package/build/search/flood-guard.d.ts +57 -0
  163. package/build/search/flood-guard.js +80 -0
  164. package/build/search/unified.d.ts +56 -0
  165. package/build/search/unified.js +104 -0
  166. package/build/security.d.ts +206 -0
  167. package/build/security.js +780 -0
  168. package/build/server.d.ts +240 -0
  169. package/build/server.js +5034 -0
  170. package/build/session/analytics.d.ts +773 -0
  171. package/build/session/analytics.js +2420 -0
  172. package/build/session/db.d.ts +468 -0
  173. package/build/session/db.js +1330 -0
  174. package/build/session/error-classifier.d.ts +87 -0
  175. package/build/session/error-classifier.js +303 -0
  176. package/build/session/event-emit.d.ts +48 -0
  177. package/build/session/event-emit.js +101 -0
  178. package/build/session/extract.d.ts +333 -0
  179. package/build/session/extract.js +2688 -0
  180. package/build/session/model-prices.json +429 -0
  181. package/build/session/persist-tool-calls.d.ts +54 -0
  182. package/build/session/persist-tool-calls.js +105 -0
  183. package/build/session/pricing.d.ts +64 -0
  184. package/build/session/pricing.js +151 -0
  185. package/build/session/project-attribution.d.ts +73 -0
  186. package/build/session/project-attribution.js +245 -0
  187. package/build/session/purge.d.ts +138 -0
  188. package/build/session/purge.js +240 -0
  189. package/build/session/retrieval-marker.d.ts +39 -0
  190. package/build/session/retrieval-marker.js +65 -0
  191. package/build/session/snapshot.d.ts +46 -0
  192. package/build/session/snapshot.js +532 -0
  193. package/build/store-directory.d.ts +56 -0
  194. package/build/store-directory.js +254 -0
  195. package/build/store.d.ts +147 -0
  196. package/build/store.js +1714 -0
  197. package/build/truncate.d.ts +57 -0
  198. package/build/truncate.js +142 -0
  199. package/build/types.d.ts +117 -0
  200. package/build/types.js +20 -0
  201. package/build/util/claude-config.d.ts +32 -0
  202. package/build/util/claude-config.js +84 -0
  203. package/build/util/hook-config.d.ts +27 -0
  204. package/build/util/hook-config.js +76 -0
  205. package/build/util/jsonc.d.ts +14 -0
  206. package/build/util/jsonc.js +104 -0
  207. package/build/util/plugin-cache-integrity.d.ts +51 -0
  208. package/build/util/plugin-cache-integrity.js +146 -0
  209. package/build/util/project-dir.d.ts +149 -0
  210. package/build/util/project-dir.js +340 -0
  211. package/build/util/sibling-mcp.d.ts +79 -0
  212. package/build/util/sibling-mcp.js +181 -0
  213. package/cli.bundle.mjs +1495 -0
  214. package/configs/antigravity/GEMINI.md +68 -0
  215. package/configs/antigravity/mcp_config.json +7 -0
  216. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  217. package/configs/antigravity-cli/hooks.json +37 -0
  218. package/configs/antigravity-cli/mcp_config.json +10 -0
  219. package/configs/antigravity-cli/plugin.json +14 -0
  220. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  221. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  222. package/configs/claude-code/CLAUDE.md +91 -0
  223. package/configs/codex/AGENTS.md +99 -0
  224. package/configs/codex/config.toml +8 -0
  225. package/configs/codex/hooks.json +47 -0
  226. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  227. package/configs/copilot-cli/.mcp.json +12 -0
  228. package/configs/copilot-cli/README.md +47 -0
  229. package/configs/copilot-cli/hooks.json +41 -0
  230. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  231. package/configs/cursor/context-mode.mdc +76 -0
  232. package/configs/cursor/hooks.json +21 -0
  233. package/configs/cursor/mcp.json +7 -0
  234. package/configs/gemini-cli/GEMINI.md +88 -0
  235. package/configs/gemini-cli/mcp.json +7 -0
  236. package/configs/gemini-cli/settings.json +60 -0
  237. package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
  238. package/configs/jetbrains-copilot/hooks.json +16 -0
  239. package/configs/jetbrains-copilot/mcp.json +7 -0
  240. package/configs/kilo/AGENTS.md +84 -0
  241. package/configs/kilo/kilo.json +6 -0
  242. package/configs/kimi/hooks.json +54 -0
  243. package/configs/kiro/KIRO.md +84 -0
  244. package/configs/kiro/agent.json +18 -0
  245. package/configs/kiro/mcp.json +7 -0
  246. package/configs/omp/SYSTEM.md +85 -0
  247. package/configs/omp/mcp.json +7 -0
  248. package/configs/openclaw/AGENTS.md +87 -0
  249. package/configs/openclaw/openclaw.json +13 -0
  250. package/configs/opencode/AGENTS.md +84 -0
  251. package/configs/opencode/opencode.json +6 -0
  252. package/configs/pi/AGENTS.md +3 -0
  253. package/configs/qwen-code/QWEN.md +91 -0
  254. package/configs/vscode-copilot/copilot-instructions.md +80 -0
  255. package/configs/vscode-copilot/hooks.json +16 -0
  256. package/configs/vscode-copilot/mcp.json +7 -0
  257. package/configs/zed/AGENTS.md +68 -0
  258. package/hooks/antigravity-cli/payload.mjs +98 -0
  259. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  260. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  261. package/hooks/antigravity-cli/stop.mjs +58 -0
  262. package/hooks/auto-injection.mjs +102 -0
  263. package/hooks/cache-heal-utils.mjs +379 -0
  264. package/hooks/codex/platform.mjs +1 -0
  265. package/hooks/codex/posttooluse.mjs +66 -0
  266. package/hooks/codex/precompact.mjs +70 -0
  267. package/hooks/codex/pretooluse.mjs +42 -0
  268. package/hooks/codex/sessionstart.mjs +121 -0
  269. package/hooks/codex/stop.mjs +138 -0
  270. package/hooks/codex/userpromptsubmit.mjs +75 -0
  271. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  272. package/hooks/copilot-cli/precompact.mjs +66 -0
  273. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  274. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  275. package/hooks/copilot-cli/stop.mjs +59 -0
  276. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  277. package/hooks/core/codex-caps.mjs +112 -0
  278. package/hooks/core/formatters.mjs +356 -0
  279. package/hooks/core/mcp-ready.mjs +109 -0
  280. package/hooks/core/platform-detect.mjs +49 -0
  281. package/hooks/core/routing.mjs +1050 -0
  282. package/hooks/core/stdin.mjs +90 -0
  283. package/hooks/core/tool-naming.mjs +57 -0
  284. package/hooks/cursor/afteragentresponse.mjs +74 -0
  285. package/hooks/cursor/hooks.json +31 -0
  286. package/hooks/cursor/posttooluse.mjs +72 -0
  287. package/hooks/cursor/pretooluse.mjs +26 -0
  288. package/hooks/cursor/sessionstart.mjs +97 -0
  289. package/hooks/cursor/stop.mjs +48 -0
  290. package/hooks/ensure-deps.mjs +254 -0
  291. package/hooks/formatters/claude-code.mjs +113 -0
  292. package/hooks/formatters/cursor.mjs +37 -0
  293. package/hooks/formatters/gemini-cli.mjs +55 -0
  294. package/hooks/formatters/vscode-copilot.mjs +55 -0
  295. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  296. package/hooks/gemini-cli/aftertool.mjs +61 -0
  297. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  298. package/hooks/gemini-cli/beforetool.mjs +27 -0
  299. package/hooks/gemini-cli/precompress.mjs +55 -0
  300. package/hooks/gemini-cli/sessionstart.mjs +137 -0
  301. package/hooks/heal-partial-install.mjs +712 -0
  302. package/hooks/hooks.json +143 -0
  303. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  304. package/hooks/jetbrains-copilot/precompact.mjs +55 -0
  305. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  306. package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
  307. package/hooks/kimi/platform.mjs +1 -0
  308. package/hooks/kimi/posttooluse.mjs +72 -0
  309. package/hooks/kimi/precompact.mjs +80 -0
  310. package/hooks/kimi/pretooluse.mjs +42 -0
  311. package/hooks/kimi/sessionend.mjs +61 -0
  312. package/hooks/kimi/sessionstart.mjs +113 -0
  313. package/hooks/kimi/stop.mjs +132 -0
  314. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  315. package/hooks/kiro/agentspawn.mjs +97 -0
  316. package/hooks/kiro/posttooluse.mjs +51 -0
  317. package/hooks/kiro/pretooluse.mjs +64 -0
  318. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  319. package/hooks/normalize-hooks.mjs +323 -0
  320. package/hooks/platform-bridge.mjs +340 -0
  321. package/hooks/posttooluse.mjs +225 -0
  322. package/hooks/precompact.mjs +100 -0
  323. package/hooks/pretooluse.mjs +227 -0
  324. package/hooks/qwen-code/platform.mjs +1 -0
  325. package/hooks/qwen-code/stop.mjs +168 -0
  326. package/hooks/routing-block.mjs +105 -0
  327. package/hooks/run-hook.mjs +95 -0
  328. package/hooks/security.bundle.mjs +2 -0
  329. package/hooks/session-attribution.bundle.mjs +1 -0
  330. package/hooks/session-db.bundle.mjs +145 -0
  331. package/hooks/session-directive.mjs +520 -0
  332. package/hooks/session-extract.bundle.mjs +3 -0
  333. package/hooks/session-helpers.mjs +428 -0
  334. package/hooks/session-loaders.mjs +448 -0
  335. package/hooks/session-snapshot.bundle.mjs +32 -0
  336. package/hooks/sessionstart.mjs +464 -0
  337. package/hooks/stop.mjs +82 -0
  338. package/hooks/suppress-stderr.mjs +25 -0
  339. package/hooks/userpromptsubmit.mjs +108 -0
  340. package/hooks/vscode-copilot/posttooluse.mjs +61 -0
  341. package/hooks/vscode-copilot/precompact.mjs +55 -0
  342. package/hooks/vscode-copilot/pretooluse.mjs +27 -0
  343. package/hooks/vscode-copilot/sessionstart.mjs +129 -0
  344. package/openclaw.plugin.json +38 -0
  345. package/package.json +132 -0
  346. package/scripts/heal-better-sqlite3.mjs +430 -0
  347. package/scripts/heal-installed-plugins.mjs +635 -0
  348. package/scripts/plugin-cache-integrity.mjs +248 -0
  349. package/scripts/postinstall.mjs +397 -0
  350. package/server.bundle.mjs +1421 -0
  351. package/server.js +5 -0
  352. package/skills/.ignore +7 -0
  353. package/skills/context-mode/SKILL.md +300 -0
  354. package/skills/context-mode/references/anti-patterns.md +283 -0
  355. package/skills/context-mode/references/patterns-javascript.md +298 -0
  356. package/skills/context-mode/references/patterns-python.md +304 -0
  357. package/skills/context-mode/references/patterns-shell.md +277 -0
  358. package/skills/ctx-doctor/SKILL.md +22 -0
  359. package/skills/ctx-index/SKILL.md +46 -0
  360. package/skills/ctx-insight/SKILL.md +24 -0
  361. package/skills/ctx-purge/SKILL.md +49 -0
  362. package/skills/ctx-search/SKILL.md +35 -0
  363. package/skills/ctx-stats/SKILL.md +26 -0
  364. package/skills/ctx-upgrade/SKILL.md +31 -0
  365. package/start.mjs +616 -0
@@ -0,0 +1,323 @@
1
+ // normalize-hooks.mjs — fixes #378
2
+ //
3
+ // Static committed files (hooks/hooks.json, .claude-plugin/plugin.json) ship
4
+ // with `${CLAUDE_PLUGIN_ROOT}` placeholder + bare `node` command. On Windows
5
+ // + Claude Code this triggers cjs/loader:1479 errors because:
6
+ // 1. bare `node` may not resolve via PATH (Git Bash, see #369)
7
+ // 2. `${CLAUDE_PLUGIN_ROOT}` resolution can hit MSYS path mangling (#372)
8
+ // 3. backslash paths get corrupted in shell quoting
9
+ //
10
+ // Our buildNodeCommand() fix handles dynamically-generated settings.json but
11
+ // not the static committed files. Solution: start.mjs detects the placeholder
12
+ // pattern on every MCP boot and rewrites with absolute paths using
13
+ // process.execPath + forward slashes. Idempotent — only rewrites when needed.
14
+ // Survives upgrades because it runs at every start.
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+
19
+ const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
20
+
21
+ // #604: matches a cache path segment `context-mode/context-mode/<version>`.
22
+ // Capture group is the X.Y.Z version. Used to detect command paths frozen on a
23
+ // previous-version dir that Claude Code's native plugin manager has since
24
+ // cleaned up. `/g` so a single content blob with multiple stale references is
25
+ // fully covered. Forward-slash only — callers convert beforehand.
26
+ const CACHE_VERSION_RE =
27
+ /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
28
+
29
+ /** Convert any path string to forward slashes (MSYS-safe). */
30
+ function fwd(p) {
31
+ return String(p).replace(/\\/g, "/");
32
+ }
33
+
34
+ /**
35
+ * Extract the X.Y.Z version segment from a pluginRoot under the context-mode
36
+ * cache layout. Returns null when running from npm-global, a dev checkout, or
37
+ * any layout that does not match the `<…>/context-mode/context-mode/<v>(/…)?`
38
+ * pattern — callers must treat null as "no stale-path check is possible".
39
+ */
40
+ function pluginRootVersion(pluginRoot) {
41
+ if (!pluginRoot) return null;
42
+ const m =
43
+ /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(
44
+ fwd(pluginRoot),
45
+ );
46
+ return m ? m[1] : null;
47
+ }
48
+
49
+ /**
50
+ * Does `content` reference any context-mode cache version segment that differs
51
+ * from `currentVersion`? Detects the #604 ratchet: already-normalized hooks.json
52
+ * / plugin.json carrying a previous version's absolute paths forward into a
53
+ * newer version's cache directory after Claude Code's auto-update.
54
+ */
55
+ function hasStaleCacheVersionSegment(content, currentVersion) {
56
+ if (!currentVersion || !content || typeof content !== "string") return false;
57
+ const safe = fwd(content);
58
+ CACHE_VERSION_RE.lastIndex = 0;
59
+ let m;
60
+ while ((m = CACHE_VERSION_RE.exec(safe)) !== null) {
61
+ if (m[1] !== currentVersion) return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /**
67
+ * Pure detection: does this content need to be (re-)normalized?
68
+ *
69
+ * Two triggers:
70
+ * 1. Fresh content still containing the `${CLAUDE_PLUGIN_ROOT}` placeholder
71
+ * — the original #378 first-boot path on any host.
72
+ * 2. (#604) Already-resolved content whose absolute paths point at a
73
+ * different version of the context-mode cache than the current
74
+ * `pluginRoot`. Breaks the ratchet that previously froze stale paths
75
+ * after Claude Code's native plugin manager copied a previous version's
76
+ * hooks.json forward.
77
+ *
78
+ * `pluginRoot` is optional for backwards compatibility with single-arg
79
+ * callers; without it, only the placeholder check runs.
80
+ */
81
+ export function needsHookNormalization(content, pluginRoot) {
82
+ if (!content || typeof content !== "string") return false;
83
+ if (content.includes(PLACEHOLDER)) return true;
84
+ return hasStaleCacheVersionSegment(content, pluginRootVersion(pluginRoot));
85
+ }
86
+
87
+ /**
88
+ * Rewrite hooks.json content. Replaces:
89
+ * - `node "${CLAUDE_PLUGIN_ROOT}/x.mjs"` →
90
+ * `"<execPath>" "<pluginRoot>/x.mjs"` (forward slashes, double-quoted)
91
+ *
92
+ * Pure function — takes content + paths, returns new content.
93
+ * Idempotent — leaves already-normalized content unchanged.
94
+ */
95
+ export function normalizeHooksJson(content, nodePath, pluginRoot) {
96
+ if (!needsHookNormalization(content, pluginRoot)) return content;
97
+
98
+ const safeNode = fwd(nodePath);
99
+ const safeRoot = fwd(pluginRoot);
100
+ const currentVersion = pluginRootVersion(pluginRoot);
101
+
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(content);
105
+ } catch {
106
+ return content;
107
+ }
108
+
109
+ const hooks = parsed?.hooks;
110
+ if (!hooks || typeof hooks !== "object") return content;
111
+
112
+ let mutated = false;
113
+ for (const eventName of Object.keys(hooks)) {
114
+ const matchers = hooks[eventName];
115
+ if (!Array.isArray(matchers)) continue;
116
+ for (const matcher of matchers) {
117
+ const inner = matcher?.hooks;
118
+ if (!Array.isArray(inner)) continue;
119
+ for (const h of inner) {
120
+ if (typeof h?.command !== "string") continue;
121
+
122
+ const hasPlaceholder = h.command.includes(PLACEHOLDER);
123
+ // #604: also rewrite when the command holds a stale absolute path under
124
+ // a previous-version cache dir (Claude Code's auto-update ratchet).
125
+ const hasStale = hasStaleCacheVersionSegment(h.command, currentVersion);
126
+ if (!hasPlaceholder && !hasStale) continue;
127
+
128
+ let next = h.command;
129
+ if (hasPlaceholder) {
130
+ // Replace placeholder with absolute root (forward-slash).
131
+ next = next.replaceAll(PLACEHOLDER, safeRoot);
132
+ // Replace bare `node ` prefix with quoted execPath. Match both
133
+ // `node ` and `node\t` at start, with optional surrounding whitespace.
134
+ next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
135
+ }
136
+ if (hasStale) {
137
+ // Re-point every `context-mode/context-mode/<old-version>/…` segment
138
+ // to the current pluginRoot's version. Operates on the forward-slash
139
+ // form so MSYS-mangled paths heal as well.
140
+ next = fwd(next).replace(
141
+ CACHE_VERSION_RE,
142
+ `context-mode/context-mode/${currentVersion}`,
143
+ );
144
+ }
145
+ h.command = next;
146
+ mutated = true;
147
+ }
148
+ }
149
+ }
150
+
151
+ if (!mutated) return content;
152
+
153
+ // Preserve 2-space indent (matches committed format).
154
+ return JSON.stringify(parsed, null, 2);
155
+ }
156
+
157
+ /**
158
+ * Rewrite plugin.json mcpServers. Replaces:
159
+ * - `command: "node"` → `command: "<execPath-fwd>"`
160
+ * - `args: ["${CLAUDE_PLUGIN_ROOT}/start.mjs"]` →
161
+ * `args: ["<pluginRoot-fwd>/start.mjs"]`
162
+ *
163
+ * Idempotent.
164
+ */
165
+ export function normalizePluginJson(content, nodePath, pluginRoot) {
166
+ if (!needsHookNormalization(content, pluginRoot)) return content;
167
+
168
+ const safeNode = fwd(nodePath);
169
+ const safeRoot = fwd(pluginRoot);
170
+ const currentVersion = pluginRootVersion(pluginRoot);
171
+
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(content);
175
+ } catch {
176
+ return content;
177
+ }
178
+
179
+ const servers = parsed?.mcpServers;
180
+ if (!servers || typeof servers !== "object") return content;
181
+
182
+ let mutated = false;
183
+ for (const name of Object.keys(servers)) {
184
+ const srv = servers[name];
185
+ if (!srv || typeof srv !== "object") continue;
186
+
187
+ if (Array.isArray(srv.args)) {
188
+ const before = srv.args;
189
+ const after = before.map((a) => {
190
+ if (typeof a !== "string") return a;
191
+ let next = a;
192
+ if (next.includes(PLACEHOLDER)) {
193
+ next = next.replaceAll(PLACEHOLDER, safeRoot);
194
+ }
195
+ // #604: same auto-update ratchet hits plugin.json args (see #523).
196
+ if (hasStaleCacheVersionSegment(next, currentVersion)) {
197
+ next = fwd(next).replace(
198
+ CACHE_VERSION_RE,
199
+ `context-mode/context-mode/${currentVersion}`,
200
+ );
201
+ }
202
+ return next;
203
+ });
204
+ if (after.some((v, i) => v !== before[i])) {
205
+ srv.args = after;
206
+ mutated = true;
207
+ }
208
+ }
209
+
210
+ if (srv.command === "node" && mutated) {
211
+ // Only swap bare `node` when we also rewrote args — otherwise we'd
212
+ // touch user-customized server entries unrelated to placeholders.
213
+ srv.command = safeNode;
214
+ }
215
+ }
216
+
217
+ if (!mutated) return content;
218
+ return JSON.stringify(parsed, null, 2);
219
+ }
220
+
221
+ /**
222
+ * Apply normalization to hooks/hooks.json ONLY (not plugin.json).
223
+ *
224
+ * Why a narrow variant exists (#711 + #414 / #528):
225
+ * - plugin.json is read by Claude Code's plugin manager and carried forward
226
+ * into NEW versioned cache dirs on auto-update. Baking absolute paths into
227
+ * it during /ctx-upgrade poisons the next version (#711).
228
+ * - hooks/hooks.json lives in the per-version dir and is read by the SAME
229
+ * Node process that needs to spawn a child. On Windows + Git Bash, Claude
230
+ * Code fires SessionStart/PreToolUse BEFORE MCP boot — the unresolved
231
+ * `${CLAUDE_PLUGIN_ROOT}` placeholder yields MODULE_NOT_FOUND for the
232
+ * first hook fire after /ctx-upgrade (#414).
233
+ *
234
+ * So /ctx-upgrade calls THIS narrow function (hooks.json only) to close the
235
+ * Windows first-hook-fire window without re-introducing #711.
236
+ *
237
+ * Options:
238
+ * - pluginRoot: absolute path to plugin install dir
239
+ * - nodePath: process.execPath (the Node binary running this script)
240
+ * - jsRuntimePath: optional — resolved Bun ≥1.0 path (#738). When set, the
241
+ * rewrite uses this instead of nodePath so hook invocations
242
+ * gain Bun's ~40-60ms cold-start advantage. Falls back to
243
+ * nodePath when omitted (back-compat).
244
+ * - platform: process.platform. Triggers a write on:
245
+ * • "win32" / "linux" — the original #378 path
246
+ * (#369/#372 MSYS / nvm fixes), AND
247
+ * • any platform when jsRuntimePath !== nodePath
248
+ * (#738 — bun swap is a perf optimisation that should
249
+ * not be gated by the historical Windows-only check;
250
+ * issue was filed from macOS).
251
+ *
252
+ * Best-effort — never throws.
253
+ */
254
+ export function normalizeHooksJsonOnly({ pluginRoot, nodePath, jsRuntimePath, platform }) {
255
+ const effectiveRuntime = jsRuntimePath || nodePath;
256
+ // #378 path: always normalize on Windows/Linux to heal placeholder + bare-node.
257
+ // #738 path: also fire on macOS when we have a real bun swap to perform — the
258
+ // legacy gate skipped darwin because system node was reliable there, but bun
259
+ // resolution is the new perf-win that the gate now needs to allow through.
260
+ const isPlatformGated = platform !== "win32" && platform !== "linux";
261
+ const hasBunSwap = jsRuntimePath && jsRuntimePath !== nodePath;
262
+ if (isPlatformGated && !hasBunSwap) return;
263
+ if (!pluginRoot || !effectiveRuntime) return;
264
+
265
+ try {
266
+ const hooksPath = resolve(pluginRoot, "hooks", "hooks.json");
267
+ if (existsSync(hooksPath)) {
268
+ const original = readFileSync(hooksPath, "utf-8");
269
+ if (needsHookNormalization(original, pluginRoot)) {
270
+ const next = normalizeHooksJson(original, effectiveRuntime, pluginRoot);
271
+ if (next !== original) {
272
+ writeFileSync(hooksPath, next, "utf-8");
273
+ }
274
+ }
275
+ }
276
+ } catch {
277
+ /* best effort */
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Apply normalization to hooks.json and plugin.json on startup.
283
+ *
284
+ * Options:
285
+ * - pluginRoot: absolute path to plugin install dir (e.g. __dirname of start.mjs)
286
+ * - nodePath: process.execPath
287
+ * - jsRuntimePath: optional Bun ≥1.0 path (#738) — used for hooks.json only,
288
+ * never for plugin.json (the MCP server itself must stay on
289
+ * Node — better-sqlite3 ABI, #543)
290
+ * - platform: process.platform ("win32" and "linux" trigger plugin.json
291
+ * rewrite for #378; hooks.json also rewrites on darwin when
292
+ * `jsRuntimePath` !== `nodePath` for #738)
293
+ *
294
+ * Best-effort — never throws.
295
+ */
296
+ export function normalizeHooksOnStartup({ pluginRoot, nodePath, jsRuntimePath, platform }) {
297
+ // Delegate the hooks.json branch to the narrow helper so /ctx-upgrade and
298
+ // boot share one implementation. plugin.json normalization stays here —
299
+ // start.mjs and postinstall still need it; /ctx-upgrade must NOT (#711).
300
+ normalizeHooksJsonOnly({ pluginRoot, nodePath, jsRuntimePath, platform });
301
+
302
+ // plugin.json rewrite: ALWAYS uses nodePath (MCP server must stay on Node,
303
+ // #543). Bun resolution is irrelevant here — `jsRuntimePath` is consumed
304
+ // exclusively by the hooks.json branch above.
305
+ if (platform !== "win32" && platform !== "linux") return;
306
+ if (!pluginRoot || !nodePath) return;
307
+
308
+ // .claude-plugin/plugin.json
309
+ try {
310
+ const pluginPath = resolve(pluginRoot, ".claude-plugin", "plugin.json");
311
+ if (existsSync(pluginPath)) {
312
+ const original = readFileSync(pluginPath, "utf-8");
313
+ if (needsHookNormalization(original, pluginRoot)) {
314
+ const next = normalizePluginJson(original, nodePath, pluginRoot);
315
+ if (next !== original) {
316
+ writeFileSync(pluginPath, next, "utf-8");
317
+ }
318
+ }
319
+ }
320
+ } catch {
321
+ /* best effort */
322
+ }
323
+ }
@@ -0,0 +1,340 @@
1
+ // hooks/platform-bridge.mjs — Fire-and-forget event forwarder.
2
+ // Reads ~/.context-mode/platform.json {api_key, platform_url}.
3
+ // POSTs every event to ${platform_url}/events.
4
+ // Privacy: redacts secrets + normalizes $HOME before send.
5
+ // Backward-compat (PRD §5.3 Upgrade Lag): v1 events_url → platform_url; token → api_key.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+ import { execSync } from "node:child_process";
11
+
12
+ const CACHE_TTL_MS = 60_000;
13
+ const FETCH_TIMEOUT_MS = 2_000;
14
+ const MAX_FIELD_LEN = 200;
15
+ const MAX_DEPTH = 4;
16
+
17
+ // Negative-cache sentinel — distinguishes "uninitialized" (null) from
18
+ // "we checked recently and there's no config" (NO_CONFIG). Without this,
19
+ // the unconfigured-user path would hit fs.readFileSync on every event.
20
+ const NO_CONFIG = Symbol("no-config");
21
+
22
+ let _cache = null;
23
+ let _cacheLoadedAt = 0;
24
+ let _warned = false; // dedupe stderr — log first failure only, reset on success
25
+ let _fsLoads = 0; // test-only counter: how many times readConfig hit the FS
26
+
27
+ // === Cross-platform config path (bug-free across Win/Linux/Mac/WSL) ===
28
+ export function configPath() {
29
+ if (process.platform === "win32") {
30
+ const appdata = process.env.APPDATA;
31
+ if (appdata) return path.join(appdata, "context-mode", "platform.json");
32
+ // Fallback if APPDATA unset (rare — Git Bash without env)
33
+ return path.join(os.homedir(), "AppData", "Roaming", "context-mode", "platform.json");
34
+ }
35
+ if (process.env.XDG_CONFIG_HOME) {
36
+ return path.join(process.env.XDG_CONFIG_HOME, "context-mode", "platform.json");
37
+ }
38
+ return path.join(os.homedir(), ".context-mode", "platform.json");
39
+ }
40
+
41
+ function warn(msg) {
42
+ if (_warned) return;
43
+ _warned = true;
44
+ process.stderr.write(`[context-mode] ${msg}\n`);
45
+ }
46
+
47
+ // v1 events_url + token alias kept for Upgrade Lag (PRD §5.3).
48
+ // Stray fields (version, hostname, device_id, endpoints.sessions, routes) silently ignored.
49
+ function normalizeConfig(raw) {
50
+ if (!raw || typeof raw !== "object") return null;
51
+ const api_key = raw.api_key ?? raw.token;
52
+ let platform_url = raw.platform_url;
53
+ if (!platform_url && raw.endpoints?.events) platform_url = String(raw.endpoints.events).replace(/\/events$/, "");
54
+ if (!platform_url && raw.events_url) platform_url = String(raw.events_url).replace(/\/events$/, "");
55
+ if (typeof api_key !== "string" || !api_key.startsWith("ctxm_")) return null;
56
+ if (typeof platform_url !== "string" || !platform_url) return null;
57
+ return { api_key, platform_url: platform_url.replace(/\/$/, "") };
58
+ }
59
+
60
+ function readConfig() {
61
+ const now = Date.now();
62
+ // Cache hit — covers BOTH positive (config object) and negative (NO_CONFIG) results.
63
+ if (_cache !== null && now - _cacheLoadedAt < CACHE_TTL_MS) {
64
+ return _cache === NO_CONFIG ? null : _cache;
65
+ }
66
+ _cacheLoadedAt = now;
67
+ _fsLoads++;
68
+ const cfgPath = configPath();
69
+
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(cfgPath, "utf8");
73
+ } catch (e) {
74
+ if (e.code !== "ENOENT") warn(`cannot read ${cfgPath}: ${e.code || e.message}`);
75
+ _cache = NO_CONFIG;
76
+ return null;
77
+ }
78
+
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ } catch (e) {
83
+ warn(`${cfgPath} is not valid JSON: ${e.message}`);
84
+ _cache = NO_CONFIG;
85
+ return null;
86
+ }
87
+
88
+ const normalized = normalizeConfig(parsed);
89
+ if (!normalized) {
90
+ warn(`${cfgPath} schema invalid — expected {api_key (ctxm_*), platform_url}`);
91
+ _cache = NO_CONFIG;
92
+ return null;
93
+ }
94
+
95
+ _warned = false; // success — re-arm warning for future failures
96
+ _cache = normalized;
97
+ return _cache;
98
+ }
99
+
100
+ // === Gate — cheap boolean for callers that want to skip allocations entirely ===
101
+ // `session-loaders.mjs` uses this BEFORE the per-event forwarding loop so the
102
+ // loop never executes when ~/.context-mode/platform.json is missing. Honors
103
+ // the same 60s TTL as readConfig() — first call hits the FS, subsequent calls
104
+ // within the TTL return the cached decision (positive or negative).
105
+ export function hasPlatformConfig() {
106
+ return readConfig() !== null;
107
+ }
108
+
109
+ // === URL construction (single endpoint per ADR-0006) ===
110
+ export function buildUrl(cfg, _eventType) {
111
+ return `${cfg.platform_url}/events`;
112
+ }
113
+
114
+ // === Project identity resolution — worktree-invariant canonicalization ===
115
+ // Filesystem path is the wrong identifier for "project": git worktrees fork
116
+ // the path while keeping the same repo, monorepos collapse N packages into
117
+ // one umbrella, and forks of the same repo look like different projects.
118
+ // Resolve to a stable identity using:
119
+ // 1. Closest package.json `name` if it lives DEEPER than the .git root
120
+ // (monorepo sub-package — preserve granularity)
121
+ // 2. git config remote.origin.url, normalized
122
+ // (worktrees of one repo collapse to one identity)
123
+ // 3. Closest package.json `name` at any depth
124
+ // (local-only Node project)
125
+ // 4. basename(projectDir) (last resort)
126
+ const _projectIdentityCache = new Map();
127
+
128
+ function resolveProjectIdentity(projectDir) {
129
+ if (typeof projectDir !== "string" || !projectDir) return null;
130
+ if (_projectIdentityCache.has(projectDir)) return _projectIdentityCache.get(projectDir);
131
+ const id = computeProjectIdentity(projectDir);
132
+ _projectIdentityCache.set(projectDir, id);
133
+ return id;
134
+ }
135
+
136
+ function computeProjectIdentity(projectDir) {
137
+ let absoluteDir;
138
+ try {
139
+ absoluteDir = path.resolve(projectDir);
140
+ } catch {
141
+ return null;
142
+ }
143
+ const walked = walkUpFromDir(absoluteDir);
144
+ const pkg = walked.packageJson;
145
+ const gitTop = walked.gitToplevel;
146
+
147
+ // (1) Monorepo sub-package: package.json STRICTLY deeper than .git root.
148
+ if (pkg && gitTop && pkg.dir !== gitTop && pkg.dir.length > gitTop.length && pkg.name) {
149
+ return pkg.name;
150
+ }
151
+ // (2) Git remote URL.
152
+ const remote = gitTop ? readGitRemote(gitTop) : null;
153
+ if (remote) return normalizeRemoteUrl(remote);
154
+ // (3) Closest package.json (any depth).
155
+ if (pkg?.name) return pkg.name;
156
+ // (4) Basename.
157
+ return path.basename(absoluteDir);
158
+ }
159
+
160
+ function walkUpFromDir(start) {
161
+ let dir = start;
162
+ let pkg = null;
163
+ let gitTop = null;
164
+ // Safety: cap walk to 64 levels; real filesystems never hit this.
165
+ for (let i = 0; i < 64; i++) {
166
+ if (!pkg) {
167
+ const pkgPath = path.join(dir, "package.json");
168
+ if (fs.existsSync(pkgPath)) {
169
+ try {
170
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
171
+ if (typeof parsed?.name === "string" && parsed.name.trim()) {
172
+ pkg = { dir, name: parsed.name.trim() };
173
+ }
174
+ } catch { /* malformed — skip silently */ }
175
+ }
176
+ }
177
+ if (!gitTop && fs.existsSync(path.join(dir, ".git"))) {
178
+ gitTop = dir;
179
+ }
180
+ if (pkg && gitTop) break;
181
+ const parent = path.dirname(dir);
182
+ if (parent === dir) break;
183
+ dir = parent;
184
+ }
185
+ return { packageJson: pkg, gitToplevel: gitTop };
186
+ }
187
+
188
+ function readGitRemote(gitTop) {
189
+ try {
190
+ const url = execSync("git config --get remote.origin.url", {
191
+ cwd: gitTop,
192
+ encoding: "utf8",
193
+ timeout: 500,
194
+ stdio: ["ignore", "pipe", "ignore"],
195
+ }).trim();
196
+ return url || null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // Canonical wire shape: host/path, lowercased host, no scheme, no .git suffix,
203
+ // no embedded credentials. All clone-equivalents collapse to one identity.
204
+ function normalizeRemoteUrl(url) {
205
+ let u = String(url).trim();
206
+ // SSH form (git@host:org/repo) → host/org/repo
207
+ const sshMatch = u.match(/^[a-z0-9_-]+@([^:]+):(.+)$/i);
208
+ if (sshMatch) {
209
+ u = `${sshMatch[1]}/${sshMatch[2]}`;
210
+ } else {
211
+ // scheme://[user[:pass]@]host/path → host/path
212
+ u = u.replace(/^[a-z]+:\/\/(?:[^@/]+@)?/i, "");
213
+ }
214
+ u = u.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
215
+ // Lowercase host segment only (paths can be case-sensitive)
216
+ const slash = u.indexOf("/");
217
+ if (slash > 0) {
218
+ u = u.slice(0, slash).toLowerCase() + u.slice(slash);
219
+ } else {
220
+ u = u.toLowerCase();
221
+ }
222
+ return u;
223
+ }
224
+
225
+ // === Privacy: secret + PII redaction ===
226
+ const SECRETS = [
227
+ /\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}\b/g, // GitHub
228
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS
229
+ /\bsk-(?:ant|proj)?-?[A-Za-z0-9_-]{32,}\b/g, // OpenAI/Anthropic
230
+ /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, // JWT
231
+ /\bxox[bpoas]-[A-Za-z0-9-]{10,}\b/g, // Slack
232
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
233
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // emails
234
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN-like
235
+ ];
236
+ const HOME = os.homedir();
237
+ const USER_DIR_RE = /(\/Users\/|\/home\/|\\+Users\\+)[^/\\]+/g;
238
+
239
+ function privacyTransform(s) {
240
+ if (typeof s !== "string") return s;
241
+ let out = s.split(HOME).join("<HOME>")
242
+ .replace(USER_DIR_RE, (m) => m.replace(/[^/\\]+$/, "<USER>"));
243
+ for (const re of SECRETS) out = out.replace(re, "[REDACTED]");
244
+ return out;
245
+ }
246
+
247
+ function walk(obj, depth) {
248
+ if (depth > MAX_DEPTH) return "[depth-limited]";
249
+ if (obj == null) return obj;
250
+ if (typeof obj === "string") {
251
+ const cleaned = privacyTransform(obj);
252
+ return cleaned.length > MAX_FIELD_LEN ? cleaned.slice(0, MAX_FIELD_LEN) + "…[truncated]" : cleaned;
253
+ }
254
+ if (Array.isArray(obj)) return obj.slice(0, 50).map((x) => walk(x, depth + 1));
255
+ if (typeof obj === "object") {
256
+ const out = {};
257
+ for (const [k, v] of Object.entries(obj)) out[k] = walk(v, depth + 1);
258
+ return out;
259
+ }
260
+ return obj;
261
+ }
262
+
263
+ export function sanitizeEvent(event) {
264
+ return event && typeof event === "object" ? walk(event, 0) : event;
265
+ }
266
+
267
+ // === Public API ===
268
+ export async function maybeForward(event, platform, opts = {}) {
269
+ const cfg = readConfig();
270
+ if (!cfg) return;
271
+
272
+ // Project identity must be resolved from the RAW projectDir — the resolver
273
+ // reads `git config` against the actual filesystem path. After sanitize,
274
+ // $HOME-normalization would break the lookup. We overlay the resolved id
275
+ // back onto the event so the sanitize/walk path sees the canonical value
276
+ // (URL or package name, which need no further normalization).
277
+ const resolvedProject = resolveProjectIdentity(event?.projectDir);
278
+ const eventWithProject = resolvedProject !== null
279
+ ? { ...event, project: resolvedProject }
280
+ : event;
281
+ const ev = sanitizeEvent(eventWithProject);
282
+ const ctrl = new AbortController();
283
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
284
+
285
+ try {
286
+ const res = await fetch(buildUrl(cfg, ev.type), {
287
+ method: "POST",
288
+ headers: {
289
+ "Authorization": `Bearer ${cfg.api_key}`,
290
+ "Content-Type": "application/json",
291
+ "X-Source-Platform": platform,
292
+ "X-Schema-Version": "2",
293
+ },
294
+ // Canonical envelope (PRD §5.4 stability ABI):
295
+ // - All event fields passthrough — server-side Zod picks per event.type
296
+ // - `platform` envelope metadata (claude-code, cursor, ...)
297
+ // - `ts` defaulted from event or wall clock
298
+ // Hand-mapping individual fields here is the anti-pattern: every new
299
+ // event field forced a bridge release. With this envelope, new fields
300
+ // ride the existing pipe and the platform schema is the only thing
301
+ // that ever needs to learn them.
302
+ body: JSON.stringify({
303
+ ...ev,
304
+ platform,
305
+ ts: ev.ts ?? opts.ts ?? Math.floor(Date.now() / 1000),
306
+ }),
307
+ signal: ctrl.signal,
308
+ });
309
+ if (res.status === 401) { _cache = null; _cacheLoadedAt = 0; }
310
+ else if (res.status === 429) {
311
+ process.stderr.write(`[context-mode-platform] rate limited (retry after ${res.headers.get("Retry-After")}s)\n`);
312
+ }
313
+ return { ok: res.ok, status: res.status };
314
+ } catch (e) {
315
+ return { ok: false, status: 0, error: e.message };
316
+ } finally {
317
+ clearTimeout(t);
318
+ }
319
+ }
320
+
321
+ export const _internal = {
322
+ readConfig,
323
+ normalizeConfig,
324
+ buildUrl,
325
+ sanitizeEvent,
326
+ privacyTransform,
327
+ configPath,
328
+ resolveProjectIdentity,
329
+ normalizeRemoteUrl,
330
+ walkUpFromDir,
331
+ resetState: () => {
332
+ _cache = null;
333
+ _cacheLoadedAt = 0;
334
+ _warned = false;
335
+ _fsLoads = 0;
336
+ _projectIdentityCache.clear();
337
+ },
338
+ get fsLoads() { return _fsLoads; },
339
+ get projectIdentityCacheSize() { return _projectIdentityCache.size; },
340
+ };