@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,894 @@
1
+ /**
2
+ * MCP-stdio bridge for the Pi Coding Agent extension.
3
+ *
4
+ * Pi 0.73.x has no native MCP support — its README is explicit:
5
+ * > "No MCP. Build CLI tools with READMEs (see Skills), or build an
6
+ * > extension that adds MCP support."
7
+ *
8
+ * Without this bridge, the routing block tells the LLM to call
9
+ * `ctx_execute`, `ctx_search`, etc. — but those tools never enter Pi's
10
+ * tool list, so the LLM cannot reach them. context-mode then becomes a
11
+ * pure cost on Pi (~2.5K tokens of system-prompt overhead with 0
12
+ * actual ctx_* calls). Reported in mksglu/context-mode#426.
13
+ *
14
+ * The bridge spawns `server.bundle.mjs` as a long-lived child via stdio
15
+ * JSON-RPC, performs the MCP handshake, calls `tools/list` once, and
16
+ * registers each returned tool through `pi.registerTool({ … })`. Each
17
+ * tool's `execute()` forwards into the child via `tools/call` — same
18
+ * code path Claude Code, Gemini CLI, and the other adapters use, so
19
+ * Pi behavior matches the rest of the platform suite.
20
+ *
21
+ * No external dependencies — pure node:child_process + JSON line frames.
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { spawn, execSync } from "node:child_process";
26
+ import { detectRuntimes } from "../../runtime.js";
27
+ import { foreignWorkspaceEnv, foreignIdentificationEnv } from "../detect.js";
28
+ // ── Fork-bomb prevention (#516) ──────────────────────────────────────
29
+ //
30
+ // Original bug: `spawn(process.execPath, [serverScript])` recursively
31
+ // re-executed the Pi binary on Bun-only systems where `process.execPath`
32
+ // IS pi itself. Each spawn re-loaded context-mode → spawned again →
33
+ // took the box down.
34
+ //
35
+ // Defence in depth:
36
+ // 1. resolveJsRuntimeForBridge() refuses pi-named binaries even when
37
+ // detectRuntimes() returns one, falling back to PATH-resolved
38
+ // node/bun.
39
+ // 2. Spawn passes CONTEXT_MODE_BRIDGE_DEPTH=1 in child env so any
40
+ // transitive bridge load can detect the recursion via env counter.
41
+ // 3. bootstrapMCPTools() aborts if CONTEXT_MODE_BRIDGE_DEPTH > 0 in
42
+ // its own env — catches recursion that bypasses the binary-name
43
+ // check (e.g. a `node` shim that re-execs Pi).
44
+ const PI_BINARY_BASENAME = /^pi(\.exe)?$/i;
45
+ const BRIDGE_DEPTH_ENV = "CONTEXT_MODE_BRIDGE_DEPTH";
46
+ const isWindows = process.platform === "win32";
47
+ function basename(p) {
48
+ const segs = p.split(/[\\/]/);
49
+ return segs[segs.length - 1] ?? "";
50
+ }
51
+ function whichOnPath(cmd) {
52
+ try {
53
+ const probe = isWindows ? `where ${cmd}` : `command -v ${cmd}`;
54
+ const out = execSync(probe, { encoding: "utf-8", stdio: "pipe" })
55
+ .trim()
56
+ .split(/\r?\n/)[0]
57
+ ?.trim();
58
+ return out && out.length > 0 ? out : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Resolve a JS runtime safe to spawn the MCP server with.
66
+ *
67
+ * Returns `null` when no real runtime is reachable (caller must skip
68
+ * the bridge gracefully — see bootstrapMCPTools). Pi-named binaries are
69
+ * explicitly rejected at every step to prevent the #516 fork bomb.
70
+ */
71
+ export function resolveJsRuntimeForBridge(deps = {}) {
72
+ const detect = deps.detect ?? (() => detectRuntimes());
73
+ const which = deps.which ?? whichOnPath;
74
+ const execPath = deps.execPath ?? process.execPath;
75
+ const isPi = (p) => !!p && PI_BINARY_BASENAME.test(basename(p));
76
+ // 1. Prefer detectRuntimes().javascript when it is NOT pi.
77
+ let candidate = null;
78
+ try {
79
+ candidate = detect().javascript ?? null;
80
+ }
81
+ catch {
82
+ candidate = null;
83
+ }
84
+ if (candidate && !isPi(candidate))
85
+ return candidate;
86
+ // 2. Fall back to PATH-resolved node, then bun.
87
+ for (const cmd of ["node", "bun"]) {
88
+ const resolved = which(cmd);
89
+ if (resolved && !isPi(resolved))
90
+ return resolved;
91
+ }
92
+ // 3. Last resort: process.execPath only if it is not pi.
93
+ if (execPath && !isPi(execPath))
94
+ return execPath;
95
+ return null;
96
+ }
97
+ // Bridge-imposed timeout for protocol-handshake methods (initialize,
98
+ // tools/list). These MUST be bounded: a server that never replies to
99
+ // initialize would otherwise block Pi's bridge bootstrap indefinitely.
100
+ // `tools/call` deliberately has NO bridge ceiling (#643) — long-running
101
+ // ctx_execute (test suites, builds, cargo test) was rejected by a 120s
102
+ // hardcoded bound even though the executor child would have finished.
103
+ // Responsibility for bounding a tool call belongs to the executor
104
+ // layer (per-tool timeout / background mode / Pi-level cancel), not
105
+ // to the transport.
106
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
107
+ // Retry budget for the bridge bootstrap `initialize` handshake (#647).
108
+ //
109
+ // On cold NFS home dirs, first JIT compile of server.bundle.mjs, or
110
+ // constrained CI runners, the first `initialize` can exceed the 60s
111
+ // ceiling above. Before this fix, bootstrapMCPTools propagated the
112
+ // rejection up to extension.ts, which logged once and continued with
113
+ // NO ctx_* tools registered — silently degrading the session for its
114
+ // entire lifetime while the routing block kept emitting ~2.5K tokens
115
+ // of dead instructions per turn.
116
+ //
117
+ // Retry pattern mirrors the existing #583 single-flight respawn shape:
118
+ // on failure, shut the prior child cleanly, sleep a short backoff so
119
+ // the OS reclaims fds, then start + initialize again. After the budget
120
+ // is exhausted we re-throw and the existing extension.ts handler runs
121
+ // the degrade-and-log path — preserving the contract for genuinely
122
+ // broken servers (binary missing, runtime crash, etc.) while
123
+ // self-healing the transient warm-up case.
124
+ const MAX_INIT_RETRIES = 2;
125
+ const INIT_RETRY_DELAY_MS = 1_000;
126
+ export class PiTextComponent {
127
+ text;
128
+ constructor(text = "") {
129
+ this.text = text;
130
+ }
131
+ setText(text) {
132
+ this.text = text;
133
+ }
134
+ invalidate() {
135
+ // Stateless renderer: no cached layout to invalidate.
136
+ }
137
+ render(width) {
138
+ if (!this.text || this.text.trim() === "")
139
+ return [];
140
+ return this.text
141
+ .replace(/\t/g, " ")
142
+ .split(/\r?\n/)
143
+ .map((line) => truncateAnsiLine(line, Math.max(1, width)));
144
+ }
145
+ }
146
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
147
+ function extractTerminalEscape(str, pos) {
148
+ if (pos >= str.length || str[pos] !== "\x1b")
149
+ return null;
150
+ const next = str[pos + 1];
151
+ // CSI sequence: ESC [ ... final-byte. Covers SGR plus cursor/control codes.
152
+ if (next === "[") {
153
+ let j = pos + 2;
154
+ while (j < str.length) {
155
+ const code = str.charCodeAt(j);
156
+ if (code >= 0x40 && code <= 0x7e) {
157
+ return { code: str.slice(pos, j + 1), length: j + 1 - pos };
158
+ }
159
+ j++;
160
+ }
161
+ return null;
162
+ }
163
+ // OSC/APC sequence: ESC ]/_ ... BEL or ST (ESC \). Stop at the FIRST
164
+ // terminator so OSC 8 hyperlinks don't swallow visible link text.
165
+ if (next === "]" || next === "_") {
166
+ let j = pos + 2;
167
+ while (j < str.length) {
168
+ if (str[j] === "\x07")
169
+ return { code: str.slice(pos, j + 1), length: j + 1 - pos };
170
+ if (str[j] === "\x1b" && str[j + 1] === "\\") {
171
+ return { code: str.slice(pos, j + 2), length: j + 2 - pos };
172
+ }
173
+ j++;
174
+ }
175
+ return null;
176
+ }
177
+ return null;
178
+ }
179
+ function couldBeEmoji(segment) {
180
+ const cp = segment.codePointAt(0) ?? 0;
181
+ return ((cp >= 0x1f000 && cp <= 0x1fbff) ||
182
+ (cp >= 0x2300 && cp <= 0x23ff) ||
183
+ (cp >= 0x2600 && cp <= 0x27bf) ||
184
+ (cp >= 0x2b50 && cp <= 0x2b55) ||
185
+ segment.includes("\uFE0F") ||
186
+ segment.includes("\u200D"));
187
+ }
188
+ function isZeroWidthCodePoint(cp) {
189
+ return (cp < 0x20 ||
190
+ (cp >= 0x7f && cp <= 0x9f) ||
191
+ (cp >= 0x300 && cp <= 0x36f) || // Combining Diacritical Marks
192
+ (cp >= 0x1ab0 && cp <= 0x1aff) || // Combining Diacritical Marks Extended
193
+ (cp >= 0x1dc0 && cp <= 0x1dff) || // Combining Diacritical Marks Supplement
194
+ (cp >= 0x20d0 && cp <= 0x20ff) || // Combining Diacritical Marks for Symbols
195
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
196
+ (cp >= 0xfe20 && cp <= 0xfe2f) || // Combining Half Marks
197
+ cp === 0x200b ||
198
+ cp === 0x200c ||
199
+ cp === 0x200d ||
200
+ cp === 0xfeff);
201
+ }
202
+ function isZeroWidthGrapheme(segment) {
203
+ if (segment.length === 0)
204
+ return true;
205
+ for (const char of segment) {
206
+ if (!isZeroWidthCodePoint(char.codePointAt(0) ?? 0))
207
+ return false;
208
+ }
209
+ return true;
210
+ }
211
+ /**
212
+ * Returns the terminal display width of a code point.
213
+ * CJK ideographs, Hangul, fullwidth forms, etc. → 2; everything else → 1.
214
+ * Mirrors the Unicode east-asian-width "W"/"F" categories.
215
+ */
216
+ function charWidth(cp) {
217
+ return cp >= 0x1100 && (cp <= 0x115f || // Hangul Jamo
218
+ (cp >= 0xa960 && cp <= 0xa97c) || // Hangul Jamo Extended-A
219
+ cp === 0x2329 || cp === 0x232a ||
220
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK
221
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
222
+ (cp >= 0xd7b0 && cp <= 0xd7fb) || // Hangul Jamo Extended-B
223
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat
224
+ (cp >= 0xfe10 && cp <= 0xfe19) || // Vertical forms
225
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compat forms
226
+ (cp >= 0xff01 && cp <= 0xff60) || // Fullwidth forms
227
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
228
+ (cp >= 0x20000 && cp <= 0x2fffd) || // CJK extensions
229
+ (cp >= 0x30000 && cp <= 0x3fffd) // CJK extensions B+
230
+ ) ? 2 : 1;
231
+ }
232
+ function graphemeWidth(segment) {
233
+ const cp = segment.codePointAt(0);
234
+ if (cp === undefined)
235
+ return 0;
236
+ if (isZeroWidthGrapheme(segment))
237
+ return 0;
238
+ if (couldBeEmoji(segment))
239
+ return 2;
240
+ // Regional indicator symbols render as wide emoji flags in Pi's TUI.
241
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff)
242
+ return 2;
243
+ return charWidth(cp);
244
+ }
245
+ export function truncateAnsiLine(line, maxWidth) {
246
+ if (maxWidth <= 0)
247
+ return "";
248
+ let output = "";
249
+ let visible = 0;
250
+ let index = 0;
251
+ while (index < line.length) {
252
+ const escape = extractTerminalEscape(line, index);
253
+ if (escape) {
254
+ output += escape.code;
255
+ index += escape.length;
256
+ continue;
257
+ }
258
+ let end = index + 1;
259
+ while (end < line.length && !extractTerminalEscape(line, end))
260
+ end++;
261
+ const chunk = line.slice(index, end);
262
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(chunk)) {
263
+ const w = graphemeWidth(segment);
264
+ if (visible + w > maxWidth)
265
+ return output;
266
+ output += segment;
267
+ visible += w;
268
+ }
269
+ index = end;
270
+ }
271
+ return output;
272
+ }
273
+ function createContextModeCallRenderer(toolName) {
274
+ return (_args, theme, context) => {
275
+ const text = context.lastComponent instanceof PiTextComponent
276
+ ? context.lastComponent
277
+ : new PiTextComponent();
278
+ text.setText(theme.fg("toolTitle", theme.bold(toolName)));
279
+ return text;
280
+ };
281
+ }
282
+ function createContextModeResultRenderer(toolName) {
283
+ return (result, { expanded, isPartial }, theme, context) => {
284
+ const text = context.lastComponent instanceof PiTextComponent
285
+ ? context.lastComponent
286
+ : new PiTextComponent();
287
+ if (isPartial) {
288
+ text.setText(theme.fg("warning", "indexing/searching..."));
289
+ return text;
290
+ }
291
+ const output = (result.content ?? [])
292
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
293
+ .map((c) => c.text)
294
+ .join("\n");
295
+ if (expanded) {
296
+ text.setText(theme.fg("toolOutput", output));
297
+ return text;
298
+ }
299
+ const firstLine = output
300
+ .split(/\r?\n/)
301
+ .find((line) => line.trim().length > 0)
302
+ ?.trim();
303
+ const status = firstLine && firstLine.length <= 180
304
+ ? firstLine
305
+ : `${toolName} completed`;
306
+ text.setText(theme.fg("toolOutput", status));
307
+ return text;
308
+ };
309
+ }
310
+ /**
311
+ * Minimal stdio JSON-RPC client targeting the context-mode MCP server.
312
+ *
313
+ * Implementation notes:
314
+ * - One outstanding ID per request; results matched by `id` from the
315
+ * returned envelope. Notifications (no id) are sent fire-and-forget.
316
+ * - Buffer is split on `\n` because the MCP server writes one
317
+ * newline-delimited JSON message per `console.log` / `stdout.write`
318
+ * invocation — this is the standard MCP stdio transport framing.
319
+ * - On child exit / error, every in-flight request is rejected so
320
+ * callers do not hang forever.
321
+ */
322
+ export class MCPStdioClient {
323
+ serverScript;
324
+ env;
325
+ runtimeOverride;
326
+ diag;
327
+ child = null;
328
+ requestId = 0;
329
+ pending = new Map();
330
+ buffer = "";
331
+ initialized = false;
332
+ exited = false;
333
+ /**
334
+ * In-flight respawn promise — set while {@link respawn} runs so
335
+ * concurrent callers awaiting `request()` after an idle exit observe
336
+ * the SAME respawn, not N parallel ones. Without this guard, two
337
+ * simultaneous `callTool` calls would each see `this.exited === true`,
338
+ * each fire their own `respawn()`, and the loser leaks an orphaned
339
+ * child process the GC cannot reach (no `.kill()` reference).
340
+ */
341
+ respawnPromise = null;
342
+ /**
343
+ * Live env passed to the spawned child — exposed (read-only intent)
344
+ * so tests can pin the fork-bomb-prevention env counter (#516)
345
+ * without needing to attach a process-tree probe.
346
+ */
347
+ _spawnEnv = null;
348
+ constructor(serverScript, env = process.env, runtimeOverride = null,
349
+ /**
350
+ * TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
351
+ * no-op so direct callers (skippedBridge, tests) never leak to the
352
+ * terminal; bootstrapMCPTools wires this to the Pi host's file logger.
353
+ */
354
+ diag = () => { }) {
355
+ this.serverScript = serverScript;
356
+ this.env = env;
357
+ this.runtimeOverride = runtimeOverride;
358
+ this.diag = diag;
359
+ }
360
+ /** Spawn the MCP child. Idempotent. */
361
+ start() {
362
+ if (this.child)
363
+ return;
364
+ this.exited = false;
365
+ // Pick a JS runtime that is NOT the host process (#516). When Pi
366
+ // is the host binary, process.execPath would re-exec Pi and fork
367
+ // bomb the box. resolveJsRuntimeForBridge prefers bun/node and
368
+ // explicitly rejects pi-named binaries.
369
+ const runtime = this.runtimeOverride ?? resolveJsRuntimeForBridge() ?? process.execPath;
370
+ // Increment the depth counter so any transitive bridge load inside
371
+ // the child can short-circuit before spawning yet another server.
372
+ const depth = Number.parseInt(this.env[BRIDGE_DEPTH_ENV] ?? "0", 10);
373
+ const childEnv = {
374
+ ...this.env,
375
+ [BRIDGE_DEPTH_ENV]: String(Number.isFinite(depth) ? depth + 1 : 1),
376
+ };
377
+ // Issue #545 — scrub foreign workspace env vars before spawn.
378
+ //
379
+ // Pi's MCP bridge inherits the host shell env (including a prior
380
+ // `claude` invocation's CLAUDE_PROJECT_DIR). Without this scrub, the
381
+ // spawned MCP server resolves getProjectDir() to the foreign workspace
382
+ // and Pi's sessions write into the wrong project. The ban list is
383
+ // derived ALGORITHMICALLY from PLATFORM_ENV_VARS (every other adapter's
384
+ // workspace-role vars), so adding adapter #16 grows the scrub
385
+ // automatically — no edit to this file. Pi's own workspace vars and
386
+ // the universal escape hatch (CONTEXT_MODE_PROJECT_DIR) are NEVER
387
+ // scrubbed.
388
+ for (const banned of foreignWorkspaceEnv("pi")) {
389
+ delete childEnv[banned];
390
+ }
391
+ // Issue #561 — scrub foreign IDENTIFICATION env vars before spawn.
392
+ //
393
+ // Foreign identification vars hijack detectPlatform() — must scrub
394
+ // when spawning child under a different host (#561). When Pi runs
395
+ // co-resident with Claude Code, the inherited shell env carries
396
+ // CLAUDE_CODE_ENTRYPOINT and CLAUDE_PLUGIN_ROOT; the spawned MCP
397
+ // child's detectPlatform() then walks PLATFORM_ENV_VARS in priority
398
+ // order (claude-code first), returns claude-code, and Pi's session
399
+ // data lands in ~/.claude/context-mode/ instead of Pi's own dir.
400
+ // Pi's OWN identification vars (PI_CONFIG_DIR / PI_SESSION_FILE /
401
+ // PI_COMPILED) are excluded from the ban set so the child still
402
+ // detects pi correctly.
403
+ for (const banned of foreignIdentificationEnv("pi")) {
404
+ delete childEnv[banned];
405
+ }
406
+ // Issue #561 regression fix: Pi detection vars are empty after
407
+ // foreign env scrubbing (CLAUDE_CODE_ENTRYPOINT / CLAUDE_PLUGIN_ROOT
408
+ // are deleted by the ban above). Without PI_CONFIG_DIR,
409
+ // detectPlatform() finds zero Pi identification vars and falls
410
+ // through to Claude Code default — stats land in ~/.claude/ instead
411
+ // of ~/.pi/. Set PI_CONFIG_DIR from the child's HOME env var so the
412
+ // child resolves to Pi correctly. (Use childEnv.HOME, not homedir(),
413
+ // because homedir() reads getpwent() which ignores our HOME override
414
+ // in test environments.)
415
+ //
416
+ // Cross-OS PI_CONFIG_DIR rescue (PR #741 follow-up):
417
+ // 1. If the parent already exported PI_CONFIG_DIR, trust it
418
+ // verbatim — Pi's launcher owns that path and may pin it to
419
+ // a non-default location (corporate setup, CI, etc.).
420
+ // 2. POSIX: ~/.pi (HOME-rooted).
421
+ // 3. Windows: probe both %USERPROFILE%\.pi (rare native install)
422
+ // AND %APPDATA%\.pi (XDG-on-Windows, Pi's documented Windows
423
+ // layout). Without the APPDATA fallback, every Pi-on-Windows
424
+ // install silently drops back to the Claude Code default and
425
+ // Pi's sessions write into the wrong directory.
426
+ if (!childEnv.PI_CONFIG_DIR) {
427
+ const home = childEnv.HOME ?? childEnv.USERPROFILE ?? childEnv.HOMEPATH;
428
+ const appData = childEnv.APPDATA; // Windows-only, undefined on POSIX
429
+ const candidates = [];
430
+ if (home)
431
+ candidates.push(join(home, ".pi"));
432
+ if (appData)
433
+ candidates.push(join(appData, ".pi"));
434
+ for (const candidate of candidates) {
435
+ if (existsSync(candidate)) {
436
+ childEnv.PI_CONFIG_DIR = candidate;
437
+ break;
438
+ }
439
+ }
440
+ }
441
+ this._spawnEnv = childEnv;
442
+ this.child = spawn(runtime, [this.serverScript], {
443
+ // Pipe stderr (#472 round-3): swallowing it via "ignore" hides
444
+ // server crash diagnostics — the user only saw "ctx_* tools will
445
+ // not be callable" with no clue WHY. We capture it so the diagnostic
446
+ // is preserved, but route it through `diag` (Pi's file logger), NOT
447
+ // process.stderr — Pi's raw-mode TUI owns the terminal and any console
448
+ // write is rendered into the editor input box, blocking typing (#868).
449
+ stdio: ["pipe", "pipe", "pipe"],
450
+ env: childEnv,
451
+ });
452
+ this.child.stdout?.on("data", (chunk) => this.onData(chunk));
453
+ this.child.stderr?.on("data", (chunk) => {
454
+ const text = chunk.toString("utf-8");
455
+ // Forward each non-empty line, [mcp-bridge]-prefixed so it stays
456
+ // grep-friendly in ~/.omp/logs. debug level: this is mostly routine
457
+ // child chatter (e.g. the #854 idle-reaper notice), not an alert.
458
+ for (const line of splitDiagLines(text)) {
459
+ if (line !== "")
460
+ this.diag(`[mcp-bridge] ${line}`, "debug");
461
+ }
462
+ });
463
+ this.child.on("exit", () => this.onExit());
464
+ this.child.on("error", () => this.onExit());
465
+ }
466
+ onExit() {
467
+ if (this.exited)
468
+ return;
469
+ this.exited = true;
470
+ const err = new Error("MCP server exited");
471
+ for (const [, p] of this.pending)
472
+ p.reject(err);
473
+ this.pending.clear();
474
+ }
475
+ onData(chunk) {
476
+ this.buffer += chunk.toString("utf-8");
477
+ let idx;
478
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
479
+ const line = this.buffer.slice(0, idx).trim();
480
+ this.buffer = this.buffer.slice(idx + 1);
481
+ if (!line)
482
+ continue;
483
+ let msg;
484
+ try {
485
+ msg = JSON.parse(line);
486
+ }
487
+ catch {
488
+ continue; // skip non-JSON noise (e.g. stray log lines)
489
+ }
490
+ if (typeof msg.id !== "number" || !this.pending.has(msg.id))
491
+ continue;
492
+ const handler = this.pending.get(msg.id);
493
+ this.pending.delete(msg.id);
494
+ if (msg.error)
495
+ handler.reject(msg.error);
496
+ else
497
+ handler.resolve(msg.result);
498
+ }
499
+ }
500
+ async request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
501
+ // Respawn-on-idle-exit (#583, #583-followup).
502
+ //
503
+ // Initial #583 fix patched callTool() only. The structural location is
504
+ // here: `request()` is the single chokepoint for `initialize`,
505
+ // `tools/list`, `tools/call`, and any future method. Patching at this
506
+ // layer means listTools / re-initialize paths after an idle exit also
507
+ // self-heal, not just the registered-tool happy path.
508
+ //
509
+ // Sequencing is critical: respawn() resets `exited`, `child`, and
510
+ // `buffer` BEFORE start() + initialize(). The initialize() call inside
511
+ // respawn() goes through this same request() — recursion is safe
512
+ // because by the time we re-enter, `exited` is false again. We use a
513
+ // single-flight `respawnPromise` so concurrent callers share the same
514
+ // respawn (orphan-child guard, see field comment).
515
+ if (this.exited) {
516
+ if (!this.respawnPromise) {
517
+ this.respawnPromise = this.respawn().finally(() => {
518
+ this.respawnPromise = null;
519
+ });
520
+ }
521
+ await this.respawnPromise;
522
+ }
523
+ if (!this.child)
524
+ throw new Error("MCP client not started");
525
+ const id = ++this.requestId;
526
+ return new Promise((resolve, reject) => {
527
+ // Gate the timer on a finite ms value so callers can pass
528
+ // `Number.POSITIVE_INFINITY` to mean "no bridge ceiling" (#643).
529
+ // Node coerces both `undefined` and `Infinity` to a 1ms delay
530
+ // (TimeoutOverflowWarning), so we can't just pass them through —
531
+ // we must skip the setTimeout entirely. tools/call uses this path
532
+ // because long-running ctx_execute must not be bounded here.
533
+ const timer = Number.isFinite(timeoutMs)
534
+ ? setTimeout(() => {
535
+ if (!this.pending.has(id))
536
+ return;
537
+ this.pending.delete(id);
538
+ reject(new Error(`MCP request timeout after ${timeoutMs}ms: ${method}`));
539
+ }, timeoutMs)
540
+ : null;
541
+ this.pending.set(id, {
542
+ resolve: (v) => {
543
+ if (timer)
544
+ clearTimeout(timer);
545
+ resolve(v);
546
+ },
547
+ reject: (e) => {
548
+ if (timer)
549
+ clearTimeout(timer);
550
+ reject(e);
551
+ },
552
+ });
553
+ const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
554
+ const rejectWrite = (err) => {
555
+ const handler = this.pending.get(id);
556
+ if (handler) {
557
+ this.pending.delete(id);
558
+ handler.reject(err);
559
+ return;
560
+ }
561
+ reject(err);
562
+ };
563
+ this.writeFrame(frame, rejectWrite);
564
+ });
565
+ }
566
+ writeFrame(frame, onError) {
567
+ if (!this.child || this.exited) {
568
+ onError?.(new Error("MCP server exited"));
569
+ return false;
570
+ }
571
+ const stdin = this.child.stdin;
572
+ if (!stdin || stdin.destroyed || stdin.writableEnded || stdin.closed) {
573
+ this.onExit();
574
+ onError?.(new Error("MCP server stdin unavailable"));
575
+ return false;
576
+ }
577
+ try {
578
+ stdin.write(frame + "\n", (err) => {
579
+ if (!err)
580
+ return;
581
+ const code = err.code;
582
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
583
+ this.onExit();
584
+ onError?.(err);
585
+ return;
586
+ }
587
+ onError?.(err);
588
+ });
589
+ return true;
590
+ }
591
+ catch (err) {
592
+ const code = err && typeof err === "object" && "code" in err
593
+ ? err.code
594
+ : undefined;
595
+ if (err instanceof Error && (code === "EPIPE" || code === "ERR_STREAM_DESTROYED")) {
596
+ this.onExit();
597
+ onError?.(err);
598
+ return false;
599
+ }
600
+ throw err;
601
+ }
602
+ }
603
+ notify(method, params) {
604
+ if (!this.child)
605
+ return;
606
+ const frame = JSON.stringify({ jsonrpc: "2.0", method, params });
607
+ this.writeFrame(frame);
608
+ }
609
+ async initialize() {
610
+ if (this.initialized)
611
+ return;
612
+ await this.request("initialize", {
613
+ protocolVersion: "2025-06-18",
614
+ capabilities: { tools: {} },
615
+ clientInfo: {
616
+ name: "pi-coding-agent-context-mode-bridge",
617
+ version: "1.0",
618
+ },
619
+ });
620
+ this.notify("notifications/initialized", {});
621
+ this.initialized = true;
622
+ }
623
+ async listTools() {
624
+ const result = await this.request("tools/list", {});
625
+ return Array.isArray(result.tools) ? result.tools : [];
626
+ }
627
+ async callTool(name, args) {
628
+ // Respawn-on-idle-exit is now handled centrally in `request()`
629
+ // (#583 follow-up). Originally patched here in #583 — moving it up
630
+ // one layer covers `listTools` / `initialize` paths too, with a
631
+ // single-flight guard against orphan child processes from
632
+ // concurrent callers.
633
+ //
634
+ // No bridge-imposed timeout for tools/call (#643). The previous
635
+ // 120s ceiling rejected legitimate long-running ctx_execute calls
636
+ // (test suites, builds, large `cargo test`) even though the
637
+ // executor child would have finished. Bounding belongs to the
638
+ // executor layer (per-tool timeout / background mode / Pi cancel),
639
+ // not the transport. `Number.POSITIVE_INFINITY` instructs
640
+ // `request()` to skip the setTimeout entirely — see the gate there.
641
+ return this.request("tools/call", { name, arguments: args ?? {} }, Number.POSITIVE_INFINITY);
642
+ }
643
+ /**
644
+ * Respawn the MCP child after an exit (clean shutdown or crash).
645
+ * Resets state so a fresh `start()` + `initialize()` cycle runs, then
646
+ * the caller's pending request flows through the new child.
647
+ *
648
+ * Single-flight — concurrent callers share one in-flight respawn via
649
+ * {@link respawnPromise}. Internal — only entered via {@link request}.
650
+ *
651
+ * Sequencing pinned (do not reorder without updating the regression
652
+ * test in tests/adapters/pi-mcp-bridge.test.ts):
653
+ * 1. `this.child = null` — drop stale handle
654
+ * 2. `this.buffer = ""` — discard leftover bytes from old child
655
+ * 3. `this.exited = false` — must precede `start()` + `initialize()`,
656
+ * because `request("initialize", …)`
657
+ * inside `initialize()` re-checks this
658
+ * flag and would otherwise re-enter
659
+ * respawn in an infinite loop
660
+ * 4. `this.initialized = false`
661
+ * 5. `this.start()`
662
+ * 6. `await this.initialize()` — flows through `request()` recursively
663
+ */
664
+ async respawn() {
665
+ this.child = null;
666
+ this.buffer = "";
667
+ this.exited = false;
668
+ this.initialized = false;
669
+ this.start();
670
+ await this.initialize();
671
+ }
672
+ shutdown() {
673
+ if (!this.child)
674
+ return;
675
+ const child = this.child;
676
+ try {
677
+ child.kill("SIGTERM");
678
+ }
679
+ catch {
680
+ // best effort
681
+ }
682
+ // SIGKILL fallback (#472 round-3): a child that ignores SIGTERM
683
+ // (e.g. installed handler that swallows the signal, or stuck in
684
+ // an uninterruptible syscall) becomes a zombie because we null
685
+ // the handle immediately. Schedule a hard kill bounded at 5s; the
686
+ // .unref() prevents this timer from keeping the parent alive after
687
+ // legitimate work is done.
688
+ setTimeout(() => {
689
+ try {
690
+ if (child.exitCode === null && child.signalCode === null) {
691
+ child.kill("SIGKILL");
692
+ }
693
+ }
694
+ catch {
695
+ // best effort
696
+ }
697
+ }, 5000).unref();
698
+ this.child = null;
699
+ this.initialized = false;
700
+ this.exited = true;
701
+ }
702
+ }
703
+ /**
704
+ * Build a {@link BridgeDiag} bound to a Pi host's file logger (#868). Writing to
705
+ * process.stderr from inside Pi's raw-mode TUI corrupts the editor, so every
706
+ * bridge diagnostic — the forwarded MCP child stderr included — goes to
707
+ * `pi.logger` instead. When no logger is reachable (tests, non-Pi hosts) the
708
+ * line is dropped; we never touch the terminal as a fallback.
709
+ */
710
+ export function makeBridgeDiag(pi) {
711
+ const logger = pi?.logger;
712
+ return (line, level = "warn") => {
713
+ try {
714
+ const fn = level === "debug" ? logger?.debug : logger?.warn;
715
+ if (typeof fn === "function")
716
+ fn(line);
717
+ }
718
+ catch {
719
+ /* never throw from diagnostics — and never write to the TUI terminal */
720
+ }
721
+ };
722
+ }
723
+ /**
724
+ * Split a chunk of forwarded child output into lines without a regex (the repo
725
+ * forbids regex in source). Trailing `\r` is stripped so CRLF traces stay clean
726
+ * in the log; the final partial line (no trailing newline) is preserved.
727
+ */
728
+ export function splitDiagLines(text) {
729
+ const lines = [];
730
+ let start = 0;
731
+ for (let i = 0; i < text.length; i++) {
732
+ if (text[i] === "\n") {
733
+ let end = i;
734
+ if (end > start && text[end - 1] === "\r")
735
+ end--;
736
+ lines.push(text.slice(start, end));
737
+ start = i + 1;
738
+ }
739
+ }
740
+ if (start < text.length)
741
+ lines.push(text.slice(start));
742
+ return lines;
743
+ }
744
+ /**
745
+ * #868: is this the FOREGROUND interactive Pi session (vs a subagent / print /
746
+ * RPC session)? Pi passes an ExtensionContext as the 2nd arg to
747
+ * `before_agent_start`; `ctx.hasUI === true` only for the interactive session
748
+ * with a real UI attached (refs oh-my-pi runner.ts:330-331), while subagents
749
+ * are provably `hasUI: false` (refs executor.ts:2052). Fail-safe: treat anything
750
+ * that is NOT an explicit `hasUI === false` as foreground, so an
751
+ * ambiguous/absent ctx keeps the session's bridge ALIVE rather than risking the
752
+ * #868 idle-drop. Mis-classifying an abandoned non-interactive child as
753
+ * foreground only costs one lingering child until parent-death/ session_shutdown
754
+ * reaps it; the opposite error re-drops the user's tools mid-session.
755
+ */
756
+ export function isForegroundSession(ctx) {
757
+ const hasUI = ctx?.hasUI;
758
+ return hasUI !== false;
759
+ }
760
+ /**
761
+ * #868: derive the bridge child's spawn env for a session kind. The FOREGROUND
762
+ * interactive session's child must never be idle-reaped — a multi-minute human
763
+ * pause should not drop its ctx_* tools — so we disable the #854 reaper for it
764
+ * via `CONTEXT_MODE_BRIDGE_IDLE_MS=0` (lifecycle.ts honors 0 → reaper not armed).
765
+ * Sub-context / non-interactive children keep the default reaper so abandoned
766
+ * children still can't accumulate (#854). The foreground child is still reaped
767
+ * on actual parent death by the ppid/​signal watchdog (#311/#388) — only the
768
+ * idle-time path is disabled. Pure; does not mutate the input env.
769
+ */
770
+ export function foregroundBridgeEnv(baseEnv, foreground) {
771
+ if (!foreground)
772
+ return baseEnv;
773
+ return { ...baseEnv, CONTEXT_MODE_BRIDGE_IDLE_MS: "0" };
774
+ }
775
+ /**
776
+ * Empty-but-valid handle returned when bootstrap is skipped (#516).
777
+ * Keeps the shutdown contract intact so callers do not need null checks.
778
+ */
779
+ function skippedBridge() {
780
+ return {
781
+ tools: [],
782
+ shutdown: () => {
783
+ /* nothing to shut down */
784
+ },
785
+ client: new MCPStdioClient("/dev/null"),
786
+ };
787
+ }
788
+ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
789
+ const env = options.env ?? process.env;
790
+ // #868: all bridge diagnostics go to Pi's file logger, never the TUI terminal.
791
+ const diag = makeBridgeDiag(pi);
792
+ // Recursion guard (#516): if an ancestor bridge already incremented
793
+ // the depth counter, refuse to spawn another child — even if the
794
+ // binary-name check would let us through. Catches `node` shims that
795
+ // re-exec Pi and other host swaps that bypass basename detection.
796
+ const depth = Number.parseInt(env[BRIDGE_DEPTH_ENV] ?? "0", 10);
797
+ if (Number.isFinite(depth) && depth > 0) {
798
+ diag(`[context-mode] WARNING: skipping MCP bridge — ${BRIDGE_DEPTH_ENV}=${depth} ` +
799
+ `indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable.`);
800
+ return skippedBridge();
801
+ }
802
+ // Runtime guard (#516): when neither node nor bun is on PATH and the
803
+ // host process is pi, there is no safe binary to spawn. Log once and
804
+ // return an empty handle — the rest of the extension keeps working.
805
+ const runtime = (options._resolveJsRuntime ?? resolveJsRuntimeForBridge)();
806
+ if (runtime === null) {
807
+ diag(`[context-mode] WARNING: no JS runtime found (need node or bun on PATH). ` +
808
+ `Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable.`);
809
+ return skippedBridge();
810
+ }
811
+ // #868: the foreground interactive session's child runs with the #854 idle
812
+ // reaper disabled (CONTEXT_MODE_BRIDGE_IDLE_MS=0) so a human pause never drops
813
+ // its tools; sub-context / non-interactive children keep the reaper (#854).
814
+ const spawnEnv = foregroundBridgeEnv(env, options.foreground ?? false);
815
+ const client = new MCPStdioClient(serverScript, spawnEnv, runtime, diag);
816
+ // Retry-on-slow-initialize (#647).
817
+ //
818
+ // Each attempt is independently bounded by DEFAULT_REQUEST_TIMEOUT_MS
819
+ // (60s) inside request(). On failure we shutdown the child to release
820
+ // its fds before respawning — this is the same sequencing the #583
821
+ // respawn path uses, just hoisted into the bootstrap layer where the
822
+ // failure happens before any tool was registered. Final attempt's
823
+ // rejection is re-thrown so extension.ts's existing then/onRejected
824
+ // handler runs the degrade-and-log path for genuinely broken servers.
825
+ let lastError;
826
+ for (let attempt = 0; attempt <= MAX_INIT_RETRIES; attempt++) {
827
+ try {
828
+ client.start();
829
+ await client.initialize();
830
+ lastError = undefined;
831
+ break;
832
+ }
833
+ catch (err) {
834
+ lastError = err;
835
+ if (attempt === MAX_INIT_RETRIES)
836
+ break;
837
+ const msg = err instanceof Error ? err.message : String(err);
838
+ diag(`[context-mode] WARNING: MCP bridge initialize failed ` +
839
+ `(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying…`);
840
+ // Reclaim the failed child's fds before respawning. shutdown() is
841
+ // idempotent and bounded by a 5s SIGKILL fallback (#472 round-3),
842
+ // so a child stuck in an uninterruptible syscall cannot block the
843
+ // retry loop indefinitely.
844
+ try {
845
+ client.shutdown();
846
+ }
847
+ catch {
848
+ // best effort — we are already on the failure path
849
+ }
850
+ await new Promise((resolve) => setTimeout(resolve, INIT_RETRY_DELAY_MS));
851
+ }
852
+ }
853
+ if (lastError !== undefined)
854
+ throw lastError;
855
+ const tools = await client.listTools();
856
+ const registered = [];
857
+ for (const tool of tools) {
858
+ pi.registerTool({
859
+ name: tool.name,
860
+ label: tool.name,
861
+ description: tool.description ?? "",
862
+ // MCP tools/list returns JSON Schema; Pi validates against JSON
863
+ // Schema (TypeBox is just JSON Schema with extra Symbol metadata
864
+ // for type inference). Empty-object fallback keeps tools that
865
+ // declare no parameters callable.
866
+ parameters: tool.inputSchema ?? { type: "object", properties: {} },
867
+ renderCall: createContextModeCallRenderer(tool.name),
868
+ renderResult: createContextModeResultRenderer(tool.name),
869
+ async execute(_toolCallId, params) {
870
+ const result = await client.callTool(tool.name, params ?? {});
871
+ const text = (result.content ?? [])
872
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
873
+ .map((c) => c.text)
874
+ .join("\n");
875
+ if (result.isError) {
876
+ // Throw is the Pi contract for "tool failed". The text body
877
+ // becomes the error message visible to the LLM, so it sees
878
+ // the same diagnostic the MCP server emitted.
879
+ throw new Error(text || `${tool.name} returned an error`);
880
+ }
881
+ return {
882
+ content: [{ type: "text", text }],
883
+ details: {},
884
+ };
885
+ },
886
+ });
887
+ registered.push(tool.name);
888
+ }
889
+ return {
890
+ tools: registered,
891
+ shutdown: () => client.shutdown(),
892
+ client,
893
+ };
894
+ }