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