@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,861 @@
1
+ /**
2
+ * Pi coding agent extension for context-mode.
3
+ *
4
+ * Follows the OpenClaw adapter pattern: imports shared session modules,
5
+ * registers Pi-specific hooks. NO copy-paste of session logic.
6
+ * NO external npm dependencies beyond what Pi runtime provides.
7
+ *
8
+ * Entry point: `export default function(pi: ExtensionAPI) { ... }`
9
+ *
10
+ * Lifecycle: session_start, tool_call, tool_result, before_agent_start,
11
+ * session_before_compact, session_compact, session_shutdown.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join, resolve, dirname } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
19
+ import { extractEvents, extractUserEvents, parsePiUsage, buildAgentUsageEvent } from "../../session/extract.js";
20
+ import { buildResumeSnapshot } from "../../session/snapshot.js";
21
+ import { bootstrapMCPTools, makeBridgeDiag, isForegroundSession } from "./mcp-bridge.js";
22
+ import { PiAdapter } from "./index.js";
23
+ // ── Pi Tool Name Mapping ─────────────────────────────────
24
+ // Pi uses lowercase; shared extractors expect PascalCase (Claude Code convention).
25
+ const PI_TOOL_MAP = {
26
+ bash: "Bash",
27
+ read: "Read",
28
+ write: "Write",
29
+ edit: "Edit",
30
+ grep: "Grep",
31
+ find: "Glob",
32
+ ls: "Glob",
33
+ };
34
+ // ── Routing patterns ─────────────────────────────────────
35
+ // Inline HTTP client patterns to block in bash — self-contained, no routing module needed.
36
+ //
37
+ // Issue #625 — split into two classes so we can apply different policies:
38
+ //
39
+ // * BLOCKED_HTTP_PATTERNS — language-level HTTP calls (fetch, requests, http,
40
+ // urllib, Invoke-WebRequest). These always flood context with raw response
41
+ // bodies, so they remain unconditionally blocked.
42
+ //
43
+ // * curl / wget — handled separately by isSafeCurlWget() below. Mirrors the
44
+ // reference logic in hooks/core/routing.mjs:660–722. Silent + file-output
45
+ // forms are allowed as an MCP-down escape hatch (the body never enters
46
+ // context). Unsafe forms (stdout, verbose, missing file output) still
47
+ // block. This prevents an unrecoverable session trap when the bridge dies.
48
+ //
49
+ // Both are evaluated AFTER stripQuotedContent() so quoted CLI arguments
50
+ // (e.g. `gh issue list --search "curl wget"`) no longer false-positive.
51
+ const BLOCKED_HTTP_PATTERNS = [
52
+ /\bfetch\s*\(/,
53
+ /\brequests\.get\s*\(/,
54
+ /\brequests\.post\s*\(/,
55
+ /\bhttp\.get\s*\(/,
56
+ /\bhttp\.request\s*\(/,
57
+ /\burllib\.request/,
58
+ /\bInvoke-WebRequest\b/,
59
+ ];
60
+ /**
61
+ * Strip heredoc + single-quoted + double-quoted content from a shell command
62
+ * so the routing regex only sees command tokens, not user-provided strings.
63
+ *
64
+ * Mirrors hooks/core/routing.mjs:196–209. Inlined here because the Pi
65
+ * extension is bundled as a standalone build artifact (.pi/extensions/...)
66
+ * and cannot import hooks/core/* at runtime — they live in a sibling tree
67
+ * and may not be present in every Pi installation.
68
+ *
69
+ * Exported for unit tests.
70
+ */
71
+ export function stripQuotedContent(cmd) {
72
+ return cmd
73
+ .replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "") // heredocs
74
+ .replace(/'[^']*'/g, "''") // single-quoted
75
+ .replace(/"[^"]*"/g, '""'); // double-quoted
76
+ }
77
+ /**
78
+ * Returns true iff `segment` is a curl/wget invocation that is SAFE to allow
79
+ * through the Pi routing block — i.e. it cannot flood the model's context
80
+ * window because the response body is written to disk (or appended to a file)
81
+ * and no verbose/trace flag is dumping headers to stderr.
82
+ *
83
+ * Mirrors hooks/core/routing.mjs:672–701. Segments that are NOT curl/wget
84
+ * return `true` (nothing to evaluate). The caller is expected to split chained
85
+ * commands on `&&`, `||`, `;` and call this per segment.
86
+ *
87
+ * Issue #625 — without this, the only escape hatch when the MCP bridge dies
88
+ * is `gh` CLI or a full Pi restart. Neither is acceptable as baseline UX.
89
+ *
90
+ * Exported for unit tests.
91
+ */
92
+ export function isSafeCurlWget(segment) {
93
+ const s = segment.trim();
94
+ const isCurl = /\bcurl\b/i.test(s);
95
+ const isWget = /\bwget\b/i.test(s);
96
+ if (!isCurl && !isWget)
97
+ return true; // not curl/wget — nothing to evaluate
98
+ // Check for file output flags (-o file / --output file for curl,
99
+ // -O file / --output-document file for wget) OR shell redirection (> / >>).
100
+ const hasFileOutput = isCurl
101
+ ? /\s(-o|--output)\s/.test(s) || /\s>\s*/.test(s) || /\s>>\s*/.test(s)
102
+ : /\s(-O|--output-document)\s/.test(s) ||
103
+ /\s>\s*/.test(s) ||
104
+ /\s>>\s*/.test(s);
105
+ if (!hasFileOutput)
106
+ return false; // no file output → body flows to stdout
107
+ // Stdout aliases: -o -, -o /dev/stdout, -O -, -O /dev/stdout.
108
+ if (isCurl && /\s(-o|--output)\s+(-|\/dev\/stdout)(\s|$)/.test(s))
109
+ return false;
110
+ if (isWget && /\s(-O|--output-document)\s+(-|\/dev\/stdout)(\s|$)/.test(s))
111
+ return false;
112
+ // Verbose/trace flags dump request+response headers to stderr → context.
113
+ if (/\s(-v|--verbose|--trace)\b/.test(s))
114
+ return false;
115
+ // Must be silent (curl: -s/--silent, wget: -q/--quiet) so the progress bar
116
+ // does not spill into stderr → context.
117
+ const isSilent = isCurl
118
+ ? /\s-[a-zA-Z]*s|--silent/.test(s)
119
+ : /\s-[a-zA-Z]*q|--quiet/.test(s);
120
+ return isSilent;
121
+ }
122
+ // ── Module-level DB singleton ────────────────────────────
123
+ let _db = null;
124
+ let _dbPath = "";
125
+ let _sessionId = "";
126
+ // MCP bridge handle. The bridge spawns server.bundle.mjs once and
127
+ // registers each MCP tool through pi.registerTool() so the Pi LLM can
128
+ // actually call ctx_execute / ctx_search / etc. (#426). Pi 0.73.x has
129
+ // no native MCP support, so without this bridge the tools are
130
+ // invisible to the LLM and the routing block is dead weight.
131
+ let _mcpBridge = null;
132
+ /**
133
+ * Settles when the MCP bridge bootstrap has finished — resolves on
134
+ * success AND on failure (the bootstrap is best-effort; failures are
135
+ * logged to stderr but never propagated). Exposed for tests so they
136
+ * can `await` the wiring deterministically without relying on internal
137
+ * timing or `setImmediate` polling.
138
+ *
139
+ * Starts as an already-settled promise because the bridge is now bootstrapped
140
+ * lazily from `before_agent_start`, not during extension discovery.
141
+ */
142
+ export let _mcpBridgeReady = Promise.resolve();
143
+ // Cached buildAutoInjection (500-token cap, prioritized).
144
+ let _buildAutoInjection = undefined;
145
+ // Pending context to inject via the 'context' hook (avoiding systemPrompt mutation
146
+ // which breaks prefix prompt cache on DeepSeek/Anthropic/OpenAI).
147
+ // See: https://github.com/mksglu/context-mode/issues/598
148
+ let _pendingContext = "";
149
+ async function getAutoInjection(pluginRoot) {
150
+ if (_buildAutoInjection !== undefined)
151
+ return _buildAutoInjection;
152
+ try {
153
+ const mod = await import(pathToFileURL(join(pluginRoot, "hooks", "auto-injection.mjs")).href);
154
+ _buildAutoInjection = mod.buildAutoInjection;
155
+ }
156
+ catch {
157
+ _buildAutoInjection = null;
158
+ }
159
+ return _buildAutoInjection ?? null;
160
+ }
161
+ // ── Helpers ──────────────────────────────────────────────
162
+ // Single PiAdapter instance — owns the canonical session-dir contract
163
+ // (~/.pi/context-mode/sessions). Routing the extension through it means
164
+ // any future segment change in PiAdapter (or BaseAdapter) propagates
165
+ // here automatically instead of silently desyncing (#473 round-3).
166
+ const _piAdapter = new PiAdapter();
167
+ function getSessionDir() {
168
+ const dir = _piAdapter.getSessionDir();
169
+ mkdirSync(dir, { recursive: true });
170
+ return dir;
171
+ }
172
+ // Issue #645 — the MCP server (src/server.ts ctx_stats / ctx_search
173
+ // timeline) resolves the SessionDB filename via
174
+ // `resolveSessionDbPath({ projectDir, sessionsDir })`, which produces a
175
+ // per-project canonical `<16-hex-hash>.db` (case-folded on darwin/win32,
176
+ // suffixed for non-main worktrees). The Pi extension previously wrote
177
+ // every session to a shared `context-mode.db` literal — a different
178
+ // file the server never reads. The result was silent degradation of
179
+ // `ctx_stats` (zero history) and `ctx_search(sort: "timeline")` (sort
180
+ // dropped) for every Pi user. Routing through the same helper keeps the
181
+ // extension-side writes and the server-side reads aligned across
182
+ // case-fold migrations, worktree suffixes, and any future change to the
183
+ // canonical filename contract.
184
+ function getDBPath(projectDir) {
185
+ return resolveSessionDbPath({ projectDir, sessionsDir: getSessionDir() });
186
+ }
187
+ function getOrCreateDB(projectDir) {
188
+ // Reopen the singleton if the resolved DB path changes. Production code
189
+ // normally loads the extension once per process with a single workspace,
190
+ // but defensive re-keying on path keeps the contract honest if a host
191
+ // ever calls piExtension(pi) twice with different projectDirs, and
192
+ // removes a subtle test-isolation foot-gun where stale singletons
193
+ // pointed at a prior test's `<hash>.db`. (#645)
194
+ const dbPath = getDBPath(projectDir);
195
+ if (!_db || _dbPath !== dbPath) {
196
+ if (_db) {
197
+ try {
198
+ _db.close();
199
+ }
200
+ catch { /* best effort */ }
201
+ }
202
+ _db = new SessionDB({ dbPath });
203
+ _dbPath = dbPath;
204
+ }
205
+ return _db;
206
+ }
207
+ /** Derive a stable session ID from Pi's session file path (SHA256, 16 hex chars). */
208
+ function deriveSessionId(ctx) {
209
+ try {
210
+ const sessionManager = ctx.sessionManager;
211
+ const sessionFile = sessionManager?.getSessionFile?.();
212
+ if (sessionFile && typeof sessionFile === "string") {
213
+ return createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
214
+ }
215
+ }
216
+ catch {
217
+ // best effort
218
+ }
219
+ return `pi-${Date.now()}`;
220
+ }
221
+ /**
222
+ * Parse SessionDB timestamps as UTC. SQLite datetime('now') returns
223
+ * "YYYY-MM-DD HH:MM:SS" in UTC without a timezone suffix; JavaScript parses
224
+ * that shape as local time, which skews ages by the local UTC offset.
225
+ */
226
+ function parseSessionTimestampMs(value) {
227
+ const trimmed = value.trim();
228
+ const sqliteUtc = trimmed.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/);
229
+ const normalized = sqliteUtc
230
+ ? `${sqliteUtc[1]}T${sqliteUtc[2]}${sqliteUtc[3] ?? ""}Z`
231
+ : trimmed;
232
+ return Date.parse(normalized);
233
+ }
234
+ /** Build stats text for the /ctx-stats command. */
235
+ function buildStatsText(db, sessionId) {
236
+ try {
237
+ const events = db.getEvents(sessionId);
238
+ const stats = db.getSessionStats(sessionId);
239
+ const lines = [
240
+ "## context-mode stats (Pi)",
241
+ "",
242
+ `- Session: \`${sessionId.slice(0, 8)}...\``,
243
+ `- Events captured: ${events.length}`,
244
+ `- Compactions: ${stats?.compact_count ?? 0}`,
245
+ ];
246
+ // Event breakdown by category
247
+ const byCategory = {};
248
+ for (const ev of events) {
249
+ const key = ev.category ?? "unknown";
250
+ byCategory[key] = (byCategory[key] ?? 0) + 1;
251
+ }
252
+ if (Object.keys(byCategory).length > 0) {
253
+ lines.push("- Event breakdown:");
254
+ for (const [category, count] of Object.entries(byCategory)) {
255
+ lines.push(` - ${category}: ${count}`);
256
+ }
257
+ }
258
+ // Session age
259
+ if (stats?.started_at) {
260
+ const startedMs = parseSessionTimestampMs(stats.started_at);
261
+ if (Number.isFinite(startedMs)) {
262
+ const ageMinutes = Math.round((Date.now() - startedMs) / 60_000);
263
+ lines.push(`- Session age: ${ageMinutes}m`);
264
+ }
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+ catch {
269
+ return "context-mode stats unavailable (session DB error)";
270
+ }
271
+ }
272
+ function resolveCommandContext(argsOrCtx, ctx) {
273
+ if (ctx !== undefined)
274
+ return ctx;
275
+ if (argsOrCtx && typeof argsOrCtx === "object")
276
+ return argsOrCtx;
277
+ return undefined;
278
+ }
279
+ function handleCommandText(text, ctx) {
280
+ if (ctx?.hasUI) {
281
+ ctx.ui.notify(text, "info");
282
+ return;
283
+ }
284
+ return { text };
285
+ }
286
+ // ── Pi MCP bridge lazy bootstrap (#534, #809) ───────────
287
+ //
288
+ // Pi loads extensions in several CLI paths that never dispatch an agent turn:
289
+ // top-level help/version, package management (`install`, `list`, ...), config,
290
+ // metadata commands, and project-trust probing. Bootstrapping the MCP bridge
291
+ // during extension discovery is therefore the wrong signal: there may be no
292
+ // real agent lifecycle and no `session_shutdown` cleanup. That caused both the
293
+ // #534 short-lived help/version orphan class and the #809 package-command hang
294
+ // (the bridge child's stdio handles kept Pi alive after `install`/`list`).
295
+ //
296
+ // The robust signal is Pi's agent lifecycle itself. `before_agent_start` fires
297
+ // only for invocations that are about to dispatch a model call, including
298
+ // print-mode subagents (`pi --mode json -p --no-session`). We start and await
299
+ // the bridge from that hook so ctx_* tools are present before Pi snapshots the
300
+ // tool registry, while CLI-only commands never spawn a bridge at all.
301
+ function startPiMCPBridge(pi, serverBundle, shouldKeepHandle, foreground) {
302
+ if (existsSync(serverBundle)) {
303
+ _mcpBridgeReady = bootstrapMCPTools(pi, serverBundle, { foreground }).then((handle) => {
304
+ if (shouldKeepHandle()) {
305
+ _mcpBridge = handle;
306
+ }
307
+ else {
308
+ // Bootstrap completed after this extension registration had already
309
+ // shut down or superseded the attempt. Do not publish a stale handle;
310
+ // immediately reclaim the child that bootstrap just spawned.
311
+ try {
312
+ handle.shutdown();
313
+ }
314
+ catch {
315
+ // best effort — never throw from best-effort bridge cleanup
316
+ }
317
+ }
318
+ }, (err) => {
319
+ if (!shouldKeepHandle())
320
+ return;
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ // #868: route to Pi's file logger, never process.stderr (raw-mode TUI).
323
+ makeBridgeDiag(pi)(`[context-mode] WARNING: failed to bridge MCP tools to Pi (${msg}). ` +
324
+ `ctx_* tools will not be callable from this session.`);
325
+ });
326
+ }
327
+ else {
328
+ // No bundle on disk → nothing to await. Tests can still rely on
329
+ // _mcpBridgeReady being a settled promise.
330
+ _mcpBridgeReady = Promise.resolve();
331
+ }
332
+ return _mcpBridgeReady;
333
+ }
334
+ /**
335
+ * Issue #545 — Pi workspace resolver.
336
+ *
337
+ * Pi's runtime sets PI_CONFIG_DIR to ~/.pi (its CONFIG dir, not the user's
338
+ * project). The extension previously used this as the project anchor, which
339
+ * meant every Pi session re-rooted under ~/.pi — collapsing all of a user's
340
+ * projects into a single phantom workspace. This helper picks the user's
341
+ * actual project directory while NEVER returning a path equal to or under
342
+ * ~/.pi/.
343
+ *
344
+ * Cascade:
345
+ * 1. PI_WORKSPACE_DIR — set by Pi's bridge (extension-set, freshest)
346
+ * 2. PI_PROJECT_DIR — legacy/user override
347
+ * 3. PWD — shell-set, survives process.chdir
348
+ * 4. cwd — last resort
349
+ *
350
+ * Each candidate is rejected if it equals ~/.pi or lives under ~/.pi/. If
351
+ * every candidate is poisoned, falls back to homedir() as a safe non-config
352
+ * anchor — caller may still render a "no project context" notice but the
353
+ * function stays total.
354
+ */
355
+ export function resolvePiWorkspaceDir(opts) {
356
+ const home = opts.home ?? homedir();
357
+ const piConfigDir = join(home, ".pi");
358
+ const isUnderPi = (p) => {
359
+ if (!p)
360
+ return true;
361
+ if (p === piConfigDir)
362
+ return true;
363
+ // Match both POSIX (/) and Windows (\) child-of relations.
364
+ return p.startsWith(piConfigDir + "/") || p.startsWith(piConfigDir + "\\");
365
+ };
366
+ const candidates = [
367
+ opts.env.PI_WORKSPACE_DIR,
368
+ opts.env.PI_PROJECT_DIR,
369
+ opts.pwd,
370
+ opts.cwd,
371
+ ];
372
+ for (const c of candidates) {
373
+ if (c && !isUnderPi(c))
374
+ return c;
375
+ }
376
+ return home;
377
+ }
378
+ // ── Extension entry point ────────────────────────────────
379
+ /** Pi extension default export. Called once by Pi runtime with the extension API. */
380
+ export default function piExtension(pi) {
381
+ const buildDir = dirname(fileURLToPath(import.meta.url));
382
+ const pluginRoot = resolve(buildDir, "..", "..", "..");
383
+ const serverBundle = resolve(pluginRoot, "server.bundle.mjs");
384
+ let mcpBridgeStarted = false;
385
+ let mcpBridgeGeneration = 0;
386
+ const ensureMCPBridge = (foreground) => {
387
+ if (mcpBridgeStarted)
388
+ return _mcpBridgeReady;
389
+ mcpBridgeStarted = true;
390
+ const generation = ++mcpBridgeGeneration;
391
+ return startPiMCPBridge(pi, serverBundle, () => mcpBridgeStarted && mcpBridgeGeneration === generation, foreground);
392
+ };
393
+ // Issue #545 — Pi workspace resolver. PI_CONFIG_DIR is Pi's CONFIG dir
394
+ // (~/.pi), NOT the user's workspace; using it as the project anchor
395
+ // collapsed every Pi session into a single phantom workspace. The
396
+ // dedicated resolver picks PI_WORKSPACE_DIR > PI_PROJECT_DIR > PWD > cwd
397
+ // and refuses to return any path under ~/.pi/.
398
+ const projectDir = resolvePiWorkspaceDir({
399
+ env: process.env,
400
+ pwd: process.env.PWD,
401
+ cwd: process.cwd(),
402
+ });
403
+ // Attribution object for project isolation — ensures every event recorded
404
+ // by the pi adapter carries the correct project_dir. Without this, all
405
+ // events default to project_dir="" which causes cross-project data leakage
406
+ // in shared SessionDB instances.
407
+ const _attribution = { projectDir, source: "workspace_root", confidence: 0.98 };
408
+ const db = getOrCreateDB(projectDir);
409
+ // ── 1. session_start — Initialize session ──────────────
410
+ pi.on("session_start", (_event, ctx) => {
411
+ try {
412
+ _sessionId = deriveSessionId(ctx ?? {});
413
+ db.ensureSession(_sessionId, projectDir);
414
+ db.cleanupOldSessions(7);
415
+ }
416
+ catch {
417
+ // best effort — never break session start
418
+ if (!_sessionId) {
419
+ _sessionId = `pi-${Date.now()}`;
420
+ }
421
+ }
422
+ });
423
+ // ── 2. tool_call — PreToolUse routing enforcement ──────
424
+ // Block bash commands that contain curl/wget/fetch/requests patterns.
425
+ pi.on("tool_call", (event) => {
426
+ try {
427
+ const toolName = String(event?.toolName ?? "").toLowerCase();
428
+ if (toolName !== "bash")
429
+ return;
430
+ const command = String(event?.input?.command ?? "");
431
+ if (!command)
432
+ return;
433
+ // Issue #625 — strip quoted content first so words like `curl` inside
434
+ // a `gh issue list --search "...curl..."` argument do not false-positive.
435
+ const stripped = stripQuotedContent(command);
436
+ // Language-level HTTP calls (fetch, requests, http, urllib,
437
+ // Invoke-WebRequest) always flood context — block unconditionally.
438
+ if (BLOCKED_HTTP_PATTERNS.some((p) => p.test(stripped))) {
439
+ return {
440
+ block: true,
441
+ reason: "Use context-mode MCP tools (execute, fetch_and_index) instead of inline HTTP clients. " +
442
+ "Raw fetch/requests/http output floods the context window.",
443
+ };
444
+ }
445
+ // curl / wget — split chained command on &&, ||, ; and evaluate each
446
+ // segment with isSafeCurlWget(). Allowed if EVERY curl/wget segment is
447
+ // silent + file-output + no verbose + no stdout alias. This preserves
448
+ // the "do not flood context" intent while leaving a recovery path open
449
+ // when the MCP bridge is unreachable.
450
+ if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
451
+ const segments = stripped.split(/\s*(?:&&|\|\||;)\s*/);
452
+ const hasUnsafeSegment = segments.some((seg) => !isSafeCurlWget(seg));
453
+ if (hasUnsafeSegment) {
454
+ return {
455
+ block: true,
456
+ reason: "Use context-mode MCP tools (execute, fetch_and_index) instead of inline HTTP clients. " +
457
+ "Raw curl/wget output floods the context window. " +
458
+ "For an MCP-down escape hatch, use silent + file output: " +
459
+ "`curl -s -o /tmp/x.json URL` or `wget -q -O /tmp/x.json URL`.",
460
+ };
461
+ }
462
+ }
463
+ }
464
+ catch {
465
+ // Routing failure — allow passthrough
466
+ }
467
+ });
468
+ // ── 3. tool_result — PostToolUse event capture ─────────
469
+ pi.on("tool_result", (event) => {
470
+ try {
471
+ if (!_sessionId)
472
+ return;
473
+ const rawToolName = String(event?.toolName ?? event?.tool_name ?? "");
474
+ let mappedToolName = PI_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
475
+ // Pi namespaces MCP-registered tools with the "context_mode_" prefix;
476
+ // the extract functions expect the "mcp__" prefix for MCP tool calls.
477
+ if (/^context_mode_/.test(rawToolName)) {
478
+ mappedToolName = rawToolName.replace(/^context_mode_/, "mcp__context_mode__");
479
+ }
480
+ // Normalize result to string
481
+ const rawResult = event?.result ?? event?.output;
482
+ const resultStr = typeof rawResult === "string"
483
+ ? rawResult
484
+ : rawResult != null
485
+ ? JSON.stringify(rawResult)
486
+ : undefined;
487
+ // Detect errors
488
+ const hasError = Boolean(event?.error || event?.isError);
489
+ // Pi sends file tool parameters as "path"; the extract functions
490
+ // expect "file_path" (Claude Code convention). Normalise before
491
+ // passing to extractEvents so file reads/writes/edits are tracked.
492
+ const rawInput = { ...(event?.params ?? event?.input ?? {}) };
493
+ if (rawInput.path !== undefined && rawInput.file_path === undefined) {
494
+ rawInput.file_path = String(rawInput.path);
495
+ }
496
+ const hookInput = {
497
+ tool_name: mappedToolName,
498
+ tool_input: rawInput,
499
+ tool_response: resultStr,
500
+ tool_output: hasError ? { isError: true } : undefined,
501
+ };
502
+ const events = extractEvents(hookInput);
503
+ if (events.length > 0) {
504
+ for (const ev of events) {
505
+ db.insertEvent(_sessionId, ev, "PostToolUse", _attribution);
506
+ }
507
+ }
508
+ else if (rawToolName) {
509
+ // Fallback: record unrecognized tool call as generic event
510
+ const data = JSON.stringify({
511
+ tool: rawToolName,
512
+ params: event?.params ?? event?.input,
513
+ });
514
+ db.insertEvent(_sessionId, {
515
+ type: "tool_call",
516
+ category: "pi",
517
+ data,
518
+ priority: 1,
519
+ data_hash: createHash("sha256")
520
+ .update(data)
521
+ .digest("hex")
522
+ .slice(0, 16),
523
+ }, "PostToolUse", _attribution);
524
+ }
525
+ }
526
+ catch {
527
+ // Silent — session capture must never break the tool call
528
+ }
529
+ });
530
+ // ── 4. before_agent_start — Routing + active_memory + resume injection ─
531
+ pi.on("before_agent_start", async (event, ctx) => {
532
+ try {
533
+ _pendingContext = ""; // Reset — will be filled below if events exist
534
+ // Lazily start and await the MCP bridge only when Pi is about to
535
+ // dispatch a real agent turn. This is the non-brittle #534/#809 guard:
536
+ // help/version/package/config CLI paths may load the extension, but they
537
+ // never fire before_agent_start, so they never spawn server.bundle.mjs.
538
+ // Subagents (`pi --mode json -p --no-session`) do fire this hook; awaiting
539
+ // here ensures ctx_* tools are registered before Pi snapshots the tool
540
+ // registry for the model call. Resolves on bootstrap failure too — the
541
+ // bridge is best-effort.
542
+ //
543
+ // #868: the FOREGROUND interactive session's bridge child is spawned with
544
+ // the #854 idle reaper disabled (via ctx.hasUI), so a multi-minute human
545
+ // pause never drops its ctx_* tools. Subagents (hasUI:false) keep the
546
+ // reaper so abandoned children can't accumulate (#854).
547
+ //
548
+ // INVARIANT — deciding foreground on the FIRST before_agent_start is safe
549
+ // even though the bridge spawns single-flight (first-wins, no sticky
550
+ // latch): Pi wires the interactive uiContext inside `mode.init()`, which
551
+ // main.ts AWAITS before dispatching the first prompt — and
552
+ // before_agent_start is emitted only from the per-turn prompt path. So the
553
+ // foreground session's first hook ALWAYS observes hasUI:true; subagents are
554
+ // provably hasUI:false. There is no early-init window where the foreground
555
+ // transiently reads hasUI:false (that window is scoped to a separate,
556
+ // buffered credential event). Do NOT add a latch here — it would guard an
557
+ // unreachable state. (Verified against oh-my-pi: main.ts init→prompt order,
558
+ // interactive-mode.ts uiContext wiring, executor.ts subagent hasUI:false.)
559
+ await ensureMCPBridge(isForegroundSession(ctx));
560
+ if (!_sessionId)
561
+ return;
562
+ const prompt = String(event?.prompt ?? "");
563
+ // Extract user events from the prompt text
564
+ if (prompt) {
565
+ const userEvents = extractUserEvents(prompt);
566
+ for (const ev of userEvents) {
567
+ db.insertEvent(_sessionId, ev, "UserPromptSubmit", _attribution);
568
+ }
569
+ }
570
+ const existingPrompt = String(event?.systemPrompt ?? "");
571
+ const parts = [];
572
+ if (existingPrompt)
573
+ parts.push(existingPrompt);
574
+ // Pi-1: Lightweight routing anchor — 7KB routing block is too heavy
575
+ // for Pi's context budget. Tool descriptions from pi.registerTool()
576
+ // already tell the model what each tool does. This anchor gives the
577
+ // deliberate choice (which tool for which scenario) without the full
578
+ // block/redirect/memory/tool-selection hierarchy.
579
+ parts.push("context-mode active. Hierarchy: ctx_batch_execute > ctx_execute > ctx_execute_file > ctx_search. " +
580
+ "Read/edit files → ctx_execute_file. Multi-command research → ctx_batch_execute. " +
581
+ "Web pages → ctx_fetch_and_index then ctx_search. Index docs → ctx_index. " +
582
+ "Stats → ctx_stats. Doctor → ctx_doctor. Upgrade → ctx_upgrade. Purge → ctx_purge.");
583
+ // Pi-3 + Pi-4: Always build active_memory (not just post-compact),
584
+ // capped at 500 tokens via buildAutoInjection. Falls back to inline
585
+ // budget loop if the helper is unavailable.
586
+ //
587
+ // Issue #856 — do NOT re-inject `role` as a standing behavioral_directive
588
+ // on every turn. A casual past phrase that classified as a role would
589
+ // otherwise be pinned and replayed each turn ("since you said 'that's
590
+ // fine for now', I'll leave it"), producing a do-nothing loop. Defense in
591
+ // depth: even if a stale `role` event exists (from an older build, or a
592
+ // genuine persona the user has since moved past), it must not become an
593
+ // inescapable per-turn standing order. Role events stay in the DB and
594
+ // remain queryable via ctx_search(source: "session-events"); intent,
595
+ // skills, decisions, and the resume snapshot are unaffected.
596
+ const activeEvents = db
597
+ .getEvents(_sessionId, {
598
+ minPriority: 3,
599
+ limit: 50,
600
+ })
601
+ .filter((e) => String(e.category ?? "") !== "role");
602
+ if (activeEvents.length > 0) {
603
+ const buildAuto = await getAutoInjection(pluginRoot);
604
+ let memoryContext = "";
605
+ if (buildAuto) {
606
+ memoryContext = buildAuto(activeEvents.map((e) => ({
607
+ category: String(e.category ?? ""),
608
+ data: String(e.data ?? ""),
609
+ })));
610
+ }
611
+ // Fallback (or if helper produced empty output): inline 500-token cap.
612
+ if (!memoryContext) {
613
+ const memoryLines = ["<active_memory>"];
614
+ let budget = 2000; // ~500 tokens at 4 chars/token
615
+ for (const ev of activeEvents) {
616
+ const line = ` <event type="${ev.type}" category="${ev.category}">${ev.data}</event>`;
617
+ if (line.length > budget)
618
+ break;
619
+ memoryLines.push(line);
620
+ budget -= line.length;
621
+ }
622
+ memoryLines.push("</active_memory>");
623
+ if (memoryLines.length > 2)
624
+ memoryContext = memoryLines.join("\n");
625
+ }
626
+ if (memoryContext)
627
+ parts.push(memoryContext);
628
+ }
629
+ // Resume snapshot (only when present and unconsumed).
630
+ const resume = db.getResume(_sessionId);
631
+ if (resume && !resume.consumed && resume.snapshot) {
632
+ parts.push(resume.snapshot);
633
+ db.markResumeConsumed(_sessionId);
634
+ }
635
+ // Store extra context (routing anchor, active_memory, resume, behavioralDirective)
636
+ // for injection via the 'context' hook as a message, NOT as a systemPrompt
637
+ // modification. Mutating systemPrompt breaks prefix prompt caching on
638
+ // DeepSeek/Anthropic/OpenAI because the system message sits at messages[0]
639
+ // and any change invalidates the entire cache chain.
640
+ const baseLen = existingPrompt ? 1 : 0;
641
+ if (parts.length > baseLen) {
642
+ const extraParts = parts.slice(baseLen);
643
+ _pendingContext = extraParts.join("\n\n");
644
+ }
645
+ else {
646
+ _pendingContext = "";
647
+ }
648
+ }
649
+ catch {
650
+ _pendingContext = ""; // Reset — ensure no stale data escapes
651
+ // best effort — never break agent start
652
+ }
653
+ });
654
+ // ── 4a2. context — Inject active_memory + resume + behavioralDirective as message ──
655
+ // Uses the 'context' hook (like hindsight does) to append context at the END of
656
+ // messages rather than mutating systemPrompt at the beginning. This preserves
657
+ // prefix prompt cache for DeepSeek, Anthropic, and OpenAI.
658
+ pi.on("context", (event) => {
659
+ try {
660
+ if (!_pendingContext)
661
+ return;
662
+ const ctx = _pendingContext;
663
+ _pendingContext = "";
664
+ event.messages.push({
665
+ role: "user",
666
+ content: ctx,
667
+ });
668
+ return { messages: event.messages };
669
+ }
670
+ catch {
671
+ // best effort — never break context assembly
672
+ }
673
+ });
674
+ // ── 4b. before_provider_response — capture response metadata ───
675
+ // Pi-2: Register the missing event so providers can record latency,
676
+ // model, and token usage when Pi exposes them. Best-effort only;
677
+ // the handler must never throw or modify the response.
678
+ pi.on("before_provider_response", (event) => {
679
+ try {
680
+ if (!_sessionId)
681
+ return;
682
+ const meta = {
683
+ model: event?.model ?? event?.providerModel,
684
+ provider: event?.provider,
685
+ latencyMs: event?.latencyMs ?? event?.latency,
686
+ tokens: event?.usage ?? event?.tokens,
687
+ };
688
+ // Skip when Pi gives us nothing useful — avoids noise in the DB.
689
+ if (meta.model == null &&
690
+ meta.provider == null &&
691
+ meta.latencyMs == null &&
692
+ meta.tokens == null) {
693
+ return;
694
+ }
695
+ const data = JSON.stringify(meta);
696
+ db.insertEvent(_sessionId, {
697
+ type: "provider_response",
698
+ category: "pi",
699
+ data,
700
+ priority: 1,
701
+ data_hash: createHash("sha256").update(data).digest("hex").slice(0, 16),
702
+ }, "PostToolUse", _attribution);
703
+ }
704
+ catch {
705
+ // best effort — never break provider response
706
+ }
707
+ });
708
+ // ── 4c. turn_end — per-turn token + native-USD cost capture ───
709
+ //
710
+ // Pi delivers per-turn usage on TurnEndEvent.message (an AssistantMessage):
711
+ // usage.{input,output,cacheRead,cacheWrite} + native usage.cost.total in USD,
712
+ // with model on .model. Usage is per-turn incremental, so each turn_end maps
713
+ // to exactly one structured `agent_usage` (category "cost") event — the same
714
+ // shape the Claude Code Stop path emits via buildAgentUsageEvent. We pass
715
+ // Pi's native cost as native_cost_usd so the builder trusts the source over
716
+ // the local price table (cost_confidence: HIGH — no price-table maintenance).
717
+ //
718
+ // Refs: adapter-matrix/pi.md @320261f — shared-events.ts:204-209 (TurnEndEvent),
719
+ // ai/src/types.ts:510/521 (model/usage), catalog/src/types.ts:100-145 (Usage).
720
+ // Best-effort: parse is null-safe and the handler never throws (a telemetry
721
+ // forwarder must never break the agent turn).
722
+ pi.on("turn_end", (event) => {
723
+ try {
724
+ if (!_sessionId)
725
+ return;
726
+ const counts = parsePiUsage(event);
727
+ if (!counts)
728
+ return; // non-assistant turn or all-zero usage
729
+ const ev = buildAgentUsageEvent(counts);
730
+ if (!ev)
731
+ return;
732
+ // db.insertEvent is the extension-side analog of the .mjs hooks'
733
+ // attributeAndInsertEvents (insert + project attribution). The MCP
734
+ // server forwards persisted agent_usage events to the platform.
735
+ db.insertEvent(_sessionId, ev, "Stop", _attribution);
736
+ }
737
+ catch {
738
+ // best effort — never break the agent turn
739
+ }
740
+ });
741
+ // ── 5. session_before_compact — Build resume snapshot ──
742
+ pi.on("session_before_compact", () => {
743
+ try {
744
+ if (!_sessionId)
745
+ return;
746
+ const allEvents = db.getEvents(_sessionId);
747
+ if (allEvents.length === 0)
748
+ return;
749
+ const stats = db.getSessionStats(_sessionId);
750
+ const snapshot = buildResumeSnapshot(allEvents, {
751
+ compactCount: (stats?.compact_count ?? 0) + 1,
752
+ });
753
+ db.upsertResume(_sessionId, snapshot, allEvents.length);
754
+ }
755
+ catch {
756
+ // best effort — never break compaction
757
+ }
758
+ });
759
+ // ── 6. session_compact — Increment compact counter ─────
760
+ pi.on("session_compact", () => {
761
+ try {
762
+ if (!_sessionId)
763
+ return;
764
+ db.incrementCompactCount(_sessionId);
765
+ }
766
+ catch {
767
+ // best effort
768
+ }
769
+ });
770
+ // ── 7. session_shutdown — Cleanup old sessions ─────────
771
+ pi.on("session_shutdown", async () => {
772
+ try {
773
+ if (_db) {
774
+ _db.cleanupOldSessions(7);
775
+ }
776
+ _db = null;
777
+ _dbPath = "";
778
+ _sessionId = "";
779
+ }
780
+ catch {
781
+ // best effort — never throw during shutdown
782
+ }
783
+ // Race fix (#472 round-3 + #809 lazy follow-up): if shutdown fires while
784
+ // bridge bootstrap is still in flight, _mcpBridge may be null at this
785
+ // point. Invalidate this bootstrap generation before waiting so any handle
786
+ // that resolves after the 2s ceiling self-shuts down instead of publishing
787
+ // a stale child handle after session shutdown.
788
+ mcpBridgeGeneration++;
789
+ mcpBridgeStarted = false;
790
+ try {
791
+ await Promise.race([
792
+ _mcpBridgeReady,
793
+ new Promise((r) => setTimeout(r, 2000).unref()),
794
+ ]);
795
+ }
796
+ catch {
797
+ // _mcpBridgeReady never rejects (best-effort), but defensively
798
+ // swallow anyway so shutdown never throws.
799
+ }
800
+ if (_mcpBridge) {
801
+ try {
802
+ _mcpBridge.shutdown();
803
+ }
804
+ catch {
805
+ // best effort — never throw during shutdown
806
+ }
807
+ _mcpBridge = null;
808
+ }
809
+ _mcpBridgeReady = Promise.resolve();
810
+ });
811
+ // ── 8. Slash commands ──────────────────────────────────
812
+ pi.registerCommand("ctx-stats", {
813
+ description: "Show context-mode session statistics",
814
+ handler: async (argsOrCtx, maybeCtx) => {
815
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
816
+ const text = !_db || !_sessionId
817
+ ? "context-mode: no active session"
818
+ : buildStatsText(_db, _sessionId);
819
+ return handleCommandText(text, ctx);
820
+ },
821
+ });
822
+ pi.registerCommand("ctx-doctor", {
823
+ description: "Run context-mode diagnostics",
824
+ handler: async (argsOrCtx, maybeCtx) => {
825
+ const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
826
+ const dbPath = getDBPath(projectDir);
827
+ const dbExists = existsSync(dbPath);
828
+ const lines = [
829
+ "## ctx-doctor (Pi)",
830
+ "",
831
+ `- DB path: \`${dbPath}\``,
832
+ `- DB exists: ${dbExists}`,
833
+ `- Session ID: \`${_sessionId ? _sessionId.slice(0, 8) + "..." : "none"}\``,
834
+ `- Plugin root: \`${pluginRoot}\``,
835
+ `- Project dir: \`${projectDir}\``,
836
+ ];
837
+ if (_db && _sessionId) {
838
+ try {
839
+ const stats = _db.getSessionStats(_sessionId);
840
+ const eventCount = _db.getEventCount(_sessionId);
841
+ lines.push(`- Events: ${eventCount}`);
842
+ lines.push(`- Compactions: ${stats?.compact_count ?? 0}`);
843
+ const resume = _db.getResume(_sessionId);
844
+ lines.push(`- Resume snapshot: ${resume ? (resume.consumed ? "consumed" : "available") : "none"}`);
845
+ }
846
+ catch {
847
+ lines.push("- DB query error");
848
+ }
849
+ }
850
+ const text = lines.join("\n");
851
+ return handleCommandText(text, ctx);
852
+ },
853
+ });
854
+ // ── 9. MCP tool bridge (#426) ───────────────────────────
855
+ //
856
+ // Intentionally no eager bootstrap here. `before_agent_start` is the first
857
+ // lifecycle signal that proves Pi is about to run a model call; starting the
858
+ // bridge there keeps ctx_* available for real agent turns while package/help
859
+ // commands that only load extensions never spawn a long-lived child (#809).
860
+ _mcpBridgeReady = Promise.resolve();
861
+ }