context-mode 1.0.169 → 2.0.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/.claude-plugin/marketplace.json +4 -22
  2. package/.claude-plugin/plugin.json +9 -24
  3. package/SHA256SUMS +31 -0
  4. package/claude-code/skills/ctx-doctor/SKILL.md +6 -0
  5. package/claude-code/skills/ctx-stats/SKILL.md +6 -0
  6. package/claude-code/skills/ctx-upgrade/SKILL.md +6 -0
  7. package/cli.mjs +6744 -0
  8. package/copilot/plugin.json +14 -0
  9. package/fixtures/hook-roundtrip.json +22 -0
  10. package/gemini-extension.json +14 -0
  11. package/hook.mjs +202 -0
  12. package/hooks/claude-code.json +50 -0
  13. package/hooks/codex.json +50 -0
  14. package/hooks/copilot-cli.json +33 -0
  15. package/hooks/hooks.json +16 -111
  16. package/hooks/kiro.json +69 -0
  17. package/hooks/qwen.json +50 -0
  18. package/hooks/vscode.json +40 -0
  19. package/hooks/zcode.json +53 -0
  20. package/opencode/plugin.js +22456 -0
  21. package/package.json +29 -114
  22. package/pi/index.js +50841 -0
  23. package/pi/skills/ctx-doctor/SKILL.md +6 -0
  24. package/pi/skills/ctx-stats/SKILL.md +6 -0
  25. package/pi/skills/ctx-upgrade/SKILL.md +6 -0
  26. package/qwen-extension.json +15 -0
  27. package/run.mjs +5633 -0
  28. package/search.mjs +5633 -0
  29. package/server.mjs +48198 -0
  30. package/skills/ctx-doctor/SKILL.md +3 -19
  31. package/skills/ctx-stats/SKILL.md +3 -23
  32. package/skills/ctx-upgrade/SKILL.md +3 -28
  33. package/.codex-plugin/hooks.json +0 -65
  34. package/.codex-plugin/mcp.json +0 -13
  35. package/.codex-plugin/plugin.json +0 -31
  36. package/.openclaw-plugin/index.ts +0 -12
  37. package/.openclaw-plugin/openclaw.plugin.json +0 -38
  38. package/.openclaw-plugin/package.json +0 -28
  39. package/LICENSE +0 -94
  40. package/README.md +0 -1619
  41. package/bin/statusline.mjs +0 -378
  42. package/build/adapters/antigravity/index.d.ts +0 -51
  43. package/build/adapters/antigravity/index.js +0 -169
  44. package/build/adapters/antigravity-cli/index.d.ts +0 -51
  45. package/build/adapters/antigravity-cli/index.js +0 -342
  46. package/build/adapters/base.d.ts +0 -92
  47. package/build/adapters/base.js +0 -135
  48. package/build/adapters/claude-code/config.d.ts +0 -8
  49. package/build/adapters/claude-code/config.js +0 -8
  50. package/build/adapters/claude-code/hooks.d.ts +0 -106
  51. package/build/adapters/claude-code/hooks.js +0 -183
  52. package/build/adapters/claude-code/index.d.ts +0 -80
  53. package/build/adapters/claude-code/index.js +0 -569
  54. package/build/adapters/claude-code-base.d.ts +0 -49
  55. package/build/adapters/claude-code-base.js +0 -113
  56. package/build/adapters/client-map.d.ts +0 -10
  57. package/build/adapters/client-map.js +0 -42
  58. package/build/adapters/codex/config.d.ts +0 -8
  59. package/build/adapters/codex/config.js +0 -8
  60. package/build/adapters/codex/hooks.d.ts +0 -55
  61. package/build/adapters/codex/hooks.js +0 -64
  62. package/build/adapters/codex/index.d.ts +0 -87
  63. package/build/adapters/codex/index.js +0 -992
  64. package/build/adapters/codex/paths.d.ts +0 -1
  65. package/build/adapters/codex/paths.js +0 -12
  66. package/build/adapters/codex/usage.d.ts +0 -107
  67. package/build/adapters/codex/usage.js +0 -227
  68. package/build/adapters/copilot-base.d.ts +0 -94
  69. package/build/adapters/copilot-base.js +0 -297
  70. package/build/adapters/copilot-cli/hooks.d.ts +0 -33
  71. package/build/adapters/copilot-cli/hooks.js +0 -64
  72. package/build/adapters/copilot-cli/index.d.ts +0 -48
  73. package/build/adapters/copilot-cli/index.js +0 -341
  74. package/build/adapters/cursor/config.d.ts +0 -4
  75. package/build/adapters/cursor/config.js +0 -4
  76. package/build/adapters/cursor/hooks.d.ts +0 -55
  77. package/build/adapters/cursor/hooks.js +0 -82
  78. package/build/adapters/cursor/index.d.ts +0 -64
  79. package/build/adapters/cursor/index.js +0 -517
  80. package/build/adapters/detect.d.ts +0 -136
  81. package/build/adapters/detect.js +0 -623
  82. package/build/adapters/gemini-cli/config.d.ts +0 -8
  83. package/build/adapters/gemini-cli/config.js +0 -8
  84. package/build/adapters/gemini-cli/hooks.d.ts +0 -83
  85. package/build/adapters/gemini-cli/hooks.js +0 -115
  86. package/build/adapters/gemini-cli/index.d.ts +0 -80
  87. package/build/adapters/gemini-cli/index.js +0 -483
  88. package/build/adapters/jetbrains-copilot/config.d.ts +0 -8
  89. package/build/adapters/jetbrains-copilot/config.js +0 -8
  90. package/build/adapters/jetbrains-copilot/hooks.d.ts +0 -59
  91. package/build/adapters/jetbrains-copilot/hooks.js +0 -87
  92. package/build/adapters/jetbrains-copilot/index.d.ts +0 -31
  93. package/build/adapters/jetbrains-copilot/index.js +0 -131
  94. package/build/adapters/kimi/config.d.ts +0 -8
  95. package/build/adapters/kimi/config.js +0 -8
  96. package/build/adapters/kimi/hooks.d.ts +0 -28
  97. package/build/adapters/kimi/hooks.js +0 -34
  98. package/build/adapters/kimi/index.d.ts +0 -66
  99. package/build/adapters/kimi/index.js +0 -537
  100. package/build/adapters/kimi/paths.d.ts +0 -21
  101. package/build/adapters/kimi/paths.js +0 -52
  102. package/build/adapters/kimi/usage.d.ts +0 -82
  103. package/build/adapters/kimi/usage.js +0 -217
  104. package/build/adapters/kiro/hooks.d.ts +0 -61
  105. package/build/adapters/kiro/hooks.js +0 -94
  106. package/build/adapters/kiro/index.d.ts +0 -61
  107. package/build/adapters/kiro/index.js +0 -301
  108. package/build/adapters/omp/index.d.ts +0 -65
  109. package/build/adapters/omp/index.js +0 -182
  110. package/build/adapters/omp/plugin.d.ts +0 -81
  111. package/build/adapters/omp/plugin.js +0 -331
  112. package/build/adapters/omp/usage.d.ts +0 -49
  113. package/build/adapters/omp/usage.js +0 -110
  114. package/build/adapters/openclaw/config.d.ts +0 -8
  115. package/build/adapters/openclaw/config.js +0 -8
  116. package/build/adapters/openclaw/hooks.d.ts +0 -50
  117. package/build/adapters/openclaw/hooks.js +0 -61
  118. package/build/adapters/openclaw/index.d.ts +0 -74
  119. package/build/adapters/openclaw/index.js +0 -439
  120. package/build/adapters/openclaw/mcp-tools.d.ts +0 -54
  121. package/build/adapters/openclaw/mcp-tools.js +0 -207
  122. package/build/adapters/openclaw/plugin.d.ts +0 -141
  123. package/build/adapters/openclaw/plugin.js +0 -818
  124. package/build/adapters/openclaw/session-db.d.ts +0 -55
  125. package/build/adapters/openclaw/session-db.js +0 -88
  126. package/build/adapters/openclaw/usage.d.ts +0 -34
  127. package/build/adapters/openclaw/usage.js +0 -52
  128. package/build/adapters/openclaw/workspace-router.d.ts +0 -29
  129. package/build/adapters/openclaw/workspace-router.js +0 -64
  130. package/build/adapters/opencode/config.d.ts +0 -8
  131. package/build/adapters/opencode/config.js +0 -8
  132. package/build/adapters/opencode/hooks.d.ts +0 -38
  133. package/build/adapters/opencode/hooks.js +0 -50
  134. package/build/adapters/opencode/index.d.ts +0 -67
  135. package/build/adapters/opencode/index.js +0 -463
  136. package/build/adapters/opencode/plugin.d.ts +0 -184
  137. package/build/adapters/opencode/plugin.js +0 -558
  138. package/build/adapters/opencode/zod3tov4.d.ts +0 -1
  139. package/build/adapters/opencode/zod3tov4.js +0 -132
  140. package/build/adapters/pi/extension.d.ts +0 -81
  141. package/build/adapters/pi/extension.js +0 -861
  142. package/build/adapters/pi/index.d.ts +0 -57
  143. package/build/adapters/pi/index.js +0 -173
  144. package/build/adapters/pi/mcp-bridge.d.ts +0 -268
  145. package/build/adapters/pi/mcp-bridge.js +0 -894
  146. package/build/adapters/qwen-code/hooks.d.ts +0 -26
  147. package/build/adapters/qwen-code/hooks.js +0 -29
  148. package/build/adapters/qwen-code/index.d.ts +0 -40
  149. package/build/adapters/qwen-code/index.js +0 -361
  150. package/build/adapters/qwen-code/usage.d.ts +0 -90
  151. package/build/adapters/qwen-code/usage.js +0 -222
  152. package/build/adapters/types.d.ts +0 -360
  153. package/build/adapters/types.js +0 -118
  154. package/build/adapters/vscode-copilot/config.d.ts +0 -8
  155. package/build/adapters/vscode-copilot/config.js +0 -8
  156. package/build/adapters/vscode-copilot/hooks.d.ts +0 -71
  157. package/build/adapters/vscode-copilot/hooks.js +0 -98
  158. package/build/adapters/vscode-copilot/index.d.ts +0 -32
  159. package/build/adapters/vscode-copilot/index.js +0 -222
  160. package/build/adapters/zed/index.d.ts +0 -40
  161. package/build/adapters/zed/index.js +0 -163
  162. package/build/cli.d.ts +0 -34
  163. package/build/cli.js +0 -1827
  164. package/build/db-base.d.ts +0 -179
  165. package/build/db-base.js +0 -569
  166. package/build/executor.d.ts +0 -72
  167. package/build/executor.js +0 -678
  168. package/build/exit-classify.d.ts +0 -19
  169. package/build/exit-classify.js +0 -12
  170. package/build/fetch-cache.d.ts +0 -13
  171. package/build/fetch-cache.js +0 -15
  172. package/build/lifecycle.d.ts +0 -124
  173. package/build/lifecycle.js +0 -278
  174. package/build/runPool.d.ts +0 -36
  175. package/build/runPool.js +0 -51
  176. package/build/runtime.d.ts +0 -71
  177. package/build/runtime.js +0 -659
  178. package/build/search/auto-memory.d.ts +0 -47
  179. package/build/search/auto-memory.js +0 -170
  180. package/build/search/ctx-search-schema.d.ts +0 -90
  181. package/build/search/ctx-search-schema.js +0 -135
  182. package/build/search/flood-guard.d.ts +0 -57
  183. package/build/search/flood-guard.js +0 -80
  184. package/build/search/unified.d.ts +0 -56
  185. package/build/search/unified.js +0 -104
  186. package/build/security.d.ts +0 -206
  187. package/build/security.js +0 -780
  188. package/build/server.d.ts +0 -184
  189. package/build/server.js +0 -4563
  190. package/build/session/analytics.d.ts +0 -773
  191. package/build/session/analytics.js +0 -2420
  192. package/build/session/db.d.ts +0 -468
  193. package/build/session/db.js +0 -1308
  194. package/build/session/error-classifier.d.ts +0 -87
  195. package/build/session/error-classifier.js +0 -303
  196. package/build/session/event-emit.d.ts +0 -48
  197. package/build/session/event-emit.js +0 -101
  198. package/build/session/extract.d.ts +0 -333
  199. package/build/session/extract.js +0 -2688
  200. package/build/session/model-prices.json +0 -429
  201. package/build/session/persist-tool-calls.d.ts +0 -54
  202. package/build/session/persist-tool-calls.js +0 -105
  203. package/build/session/pricing.d.ts +0 -64
  204. package/build/session/pricing.js +0 -151
  205. package/build/session/project-attribution.d.ts +0 -73
  206. package/build/session/project-attribution.js +0 -245
  207. package/build/session/purge.d.ts +0 -138
  208. package/build/session/purge.js +0 -240
  209. package/build/session/retrieval-marker.d.ts +0 -39
  210. package/build/session/retrieval-marker.js +0 -65
  211. package/build/session/snapshot.d.ts +0 -46
  212. package/build/session/snapshot.js +0 -532
  213. package/build/store-directory.d.ts +0 -56
  214. package/build/store-directory.js +0 -254
  215. package/build/store.d.ts +0 -147
  216. package/build/store.js +0 -1714
  217. package/build/truncate.d.ts +0 -57
  218. package/build/truncate.js +0 -142
  219. package/build/types.d.ts +0 -117
  220. package/build/types.js +0 -20
  221. package/build/util/claude-config.d.ts +0 -32
  222. package/build/util/claude-config.js +0 -84
  223. package/build/util/hook-config.d.ts +0 -27
  224. package/build/util/hook-config.js +0 -76
  225. package/build/util/jsonc.d.ts +0 -14
  226. package/build/util/jsonc.js +0 -104
  227. package/build/util/plugin-cache-integrity.d.ts +0 -51
  228. package/build/util/plugin-cache-integrity.js +0 -146
  229. package/build/util/project-dir.d.ts +0 -149
  230. package/build/util/project-dir.js +0 -340
  231. package/build/util/sibling-mcp.d.ts +0 -79
  232. package/build/util/sibling-mcp.js +0 -181
  233. package/cli.bundle.mjs +0 -1236
  234. package/configs/antigravity/GEMINI.md +0 -68
  235. package/configs/antigravity/mcp_config.json +0 -7
  236. package/configs/antigravity-cli/hooks/hooks.json +0 -37
  237. package/configs/antigravity-cli/hooks.json +0 -37
  238. package/configs/antigravity-cli/mcp_config.json +0 -10
  239. package/configs/antigravity-cli/plugin.json +0 -14
  240. package/configs/antigravity-cli/rules/context-mode.md +0 -77
  241. package/configs/antigravity-cli/skills/context-mode/SKILL.md +0 -77
  242. package/configs/claude-code/CLAUDE.md +0 -91
  243. package/configs/codex/AGENTS.md +0 -99
  244. package/configs/codex/config.toml +0 -8
  245. package/configs/codex/hooks.json +0 -47
  246. package/configs/copilot-cli/.github/plugin/plugin.json +0 -23
  247. package/configs/copilot-cli/.mcp.json +0 -12
  248. package/configs/copilot-cli/README.md +0 -47
  249. package/configs/copilot-cli/hooks.json +0 -41
  250. package/configs/copilot-cli/skills/context-mode/SKILL.md +0 -38
  251. package/configs/cursor/context-mode.mdc +0 -76
  252. package/configs/cursor/hooks.json +0 -21
  253. package/configs/cursor/mcp.json +0 -7
  254. package/configs/gemini-cli/GEMINI.md +0 -88
  255. package/configs/gemini-cli/mcp.json +0 -7
  256. package/configs/gemini-cli/settings.json +0 -60
  257. package/configs/jetbrains-copilot/copilot-instructions.md +0 -80
  258. package/configs/jetbrains-copilot/hooks.json +0 -16
  259. package/configs/jetbrains-copilot/mcp.json +0 -7
  260. package/configs/kilo/AGENTS.md +0 -84
  261. package/configs/kilo/kilo.json +0 -6
  262. package/configs/kimi/hooks.json +0 -54
  263. package/configs/kiro/KIRO.md +0 -84
  264. package/configs/kiro/agent.json +0 -18
  265. package/configs/kiro/mcp.json +0 -7
  266. package/configs/omp/SYSTEM.md +0 -85
  267. package/configs/omp/mcp.json +0 -7
  268. package/configs/openclaw/AGENTS.md +0 -87
  269. package/configs/openclaw/openclaw.json +0 -13
  270. package/configs/opencode/AGENTS.md +0 -84
  271. package/configs/opencode/opencode.json +0 -6
  272. package/configs/pi/AGENTS.md +0 -3
  273. package/configs/qwen-code/QWEN.md +0 -91
  274. package/configs/vscode-copilot/copilot-instructions.md +0 -80
  275. package/configs/vscode-copilot/hooks.json +0 -16
  276. package/configs/vscode-copilot/mcp.json +0 -7
  277. package/configs/zed/AGENTS.md +0 -68
  278. package/hooks/antigravity-cli/payload.mjs +0 -98
  279. package/hooks/antigravity-cli/posttooluse.mjs +0 -138
  280. package/hooks/antigravity-cli/pretooluse.mjs +0 -78
  281. package/hooks/antigravity-cli/stop.mjs +0 -58
  282. package/hooks/auto-injection.mjs +0 -102
  283. package/hooks/cache-heal-utils.mjs +0 -379
  284. package/hooks/codex/platform.mjs +0 -1
  285. package/hooks/codex/posttooluse.mjs +0 -66
  286. package/hooks/codex/precompact.mjs +0 -70
  287. package/hooks/codex/pretooluse.mjs +0 -42
  288. package/hooks/codex/sessionstart.mjs +0 -121
  289. package/hooks/codex/stop.mjs +0 -138
  290. package/hooks/codex/userpromptsubmit.mjs +0 -75
  291. package/hooks/copilot-cli/posttooluse.mjs +0 -79
  292. package/hooks/copilot-cli/precompact.mjs +0 -66
  293. package/hooks/copilot-cli/pretooluse.mjs +0 -41
  294. package/hooks/copilot-cli/sessionstart.mjs +0 -121
  295. package/hooks/copilot-cli/stop.mjs +0 -59
  296. package/hooks/copilot-cli/userpromptsubmit.mjs +0 -77
  297. package/hooks/core/codex-caps.mjs +0 -112
  298. package/hooks/core/formatters.mjs +0 -356
  299. package/hooks/core/mcp-ready.mjs +0 -109
  300. package/hooks/core/platform-detect.mjs +0 -49
  301. package/hooks/core/routing.mjs +0 -1017
  302. package/hooks/core/stdin.mjs +0 -90
  303. package/hooks/core/tool-naming.mjs +0 -57
  304. package/hooks/cursor/afteragentresponse.mjs +0 -74
  305. package/hooks/cursor/hooks.json +0 -31
  306. package/hooks/cursor/posttooluse.mjs +0 -72
  307. package/hooks/cursor/pretooluse.mjs +0 -26
  308. package/hooks/cursor/sessionstart.mjs +0 -97
  309. package/hooks/cursor/stop.mjs +0 -48
  310. package/hooks/ensure-deps.mjs +0 -254
  311. package/hooks/formatters/claude-code.mjs +0 -113
  312. package/hooks/formatters/cursor.mjs +0 -37
  313. package/hooks/formatters/gemini-cli.mjs +0 -55
  314. package/hooks/formatters/vscode-copilot.mjs +0 -55
  315. package/hooks/gemini-cli/aftermodel.mjs +0 -70
  316. package/hooks/gemini-cli/aftertool.mjs +0 -61
  317. package/hooks/gemini-cli/beforeagent.mjs +0 -99
  318. package/hooks/gemini-cli/beforetool.mjs +0 -27
  319. package/hooks/gemini-cli/precompress.mjs +0 -55
  320. package/hooks/gemini-cli/sessionstart.mjs +0 -137
  321. package/hooks/heal-partial-install.mjs +0 -712
  322. package/hooks/jetbrains-copilot/posttooluse.mjs +0 -61
  323. package/hooks/jetbrains-copilot/precompact.mjs +0 -55
  324. package/hooks/jetbrains-copilot/pretooluse.mjs +0 -27
  325. package/hooks/jetbrains-copilot/sessionstart.mjs +0 -124
  326. package/hooks/kimi/platform.mjs +0 -1
  327. package/hooks/kimi/posttooluse.mjs +0 -72
  328. package/hooks/kimi/precompact.mjs +0 -80
  329. package/hooks/kimi/pretooluse.mjs +0 -42
  330. package/hooks/kimi/sessionend.mjs +0 -61
  331. package/hooks/kimi/sessionstart.mjs +0 -113
  332. package/hooks/kimi/stop.mjs +0 -132
  333. package/hooks/kimi/userpromptsubmit.mjs +0 -90
  334. package/hooks/kiro/agentspawn.mjs +0 -97
  335. package/hooks/kiro/posttooluse.mjs +0 -51
  336. package/hooks/kiro/pretooluse.mjs +0 -64
  337. package/hooks/kiro/userpromptsubmit.mjs +0 -88
  338. package/hooks/normalize-hooks.mjs +0 -323
  339. package/hooks/platform-bridge.mjs +0 -340
  340. package/hooks/posttooluse.mjs +0 -225
  341. package/hooks/precompact.mjs +0 -100
  342. package/hooks/pretooluse.mjs +0 -227
  343. package/hooks/qwen-code/platform.mjs +0 -1
  344. package/hooks/qwen-code/stop.mjs +0 -168
  345. package/hooks/routing-block.mjs +0 -105
  346. package/hooks/run-hook.mjs +0 -95
  347. package/hooks/security.bundle.mjs +0 -2
  348. package/hooks/session-attribution.bundle.mjs +0 -1
  349. package/hooks/session-db.bundle.mjs +0 -144
  350. package/hooks/session-directive.mjs +0 -520
  351. package/hooks/session-extract.bundle.mjs +0 -3
  352. package/hooks/session-helpers.mjs +0 -428
  353. package/hooks/session-loaders.mjs +0 -448
  354. package/hooks/session-snapshot.bundle.mjs +0 -32
  355. package/hooks/sessionstart.mjs +0 -464
  356. package/hooks/stop.mjs +0 -82
  357. package/hooks/suppress-stderr.mjs +0 -25
  358. package/hooks/userpromptsubmit.mjs +0 -108
  359. package/hooks/vscode-copilot/posttooluse.mjs +0 -61
  360. package/hooks/vscode-copilot/precompact.mjs +0 -55
  361. package/hooks/vscode-copilot/pretooluse.mjs +0 -27
  362. package/hooks/vscode-copilot/sessionstart.mjs +0 -129
  363. package/openclaw.plugin.json +0 -38
  364. package/scripts/heal-better-sqlite3.mjs +0 -430
  365. package/scripts/heal-installed-plugins.mjs +0 -598
  366. package/scripts/plugin-cache-integrity.mjs +0 -248
  367. package/scripts/postinstall.mjs +0 -384
  368. package/server.bundle.mjs +0 -1162
  369. package/skills/.ignore +0 -7
  370. package/skills/context-mode/SKILL.md +0 -300
  371. package/skills/context-mode/references/anti-patterns.md +0 -283
  372. package/skills/context-mode/references/patterns-javascript.md +0 -298
  373. package/skills/context-mode/references/patterns-python.md +0 -304
  374. package/skills/context-mode/references/patterns-shell.md +0 -277
  375. package/skills/ctx-index/SKILL.md +0 -46
  376. package/skills/ctx-insight/SKILL.md +0 -24
  377. package/skills/ctx-purge/SKILL.md +0 -49
  378. package/skills/ctx-search/SKILL.md +0 -35
  379. package/start.mjs +0 -616
@@ -1,894 +0,0 @@
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
- }