context-mode 1.0.169 → 2.0.0-next.6
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 +4 -22
- package/.claude-plugin/plugin.json +9 -24
- package/.codex-plugin/hooks.json +18 -33
- package/.codex-plugin/mcp.json +5 -4
- package/.codex-plugin/plugin.json +8 -27
- package/SHA256SUMS +37 -0
- package/claude-code/skills/ctx-doctor/SKILL.md +6 -0
- package/claude-code/skills/ctx-stats/SKILL.md +6 -0
- package/claude-code/skills/ctx-upgrade/SKILL.md +6 -0
- package/cli.mjs +6859 -0
- package/codex/skills/ctx-doctor/SKILL.md +6 -0
- package/codex/skills/ctx-stats/SKILL.md +6 -0
- package/codex/skills/ctx-upgrade/SKILL.md +6 -0
- package/copilot/plugin.json +14 -0
- package/fixtures/hook-roundtrip.json +22 -0
- package/gemini-extension.json +14 -0
- package/hook.mjs +202 -0
- package/hooks/claude-code.json +50 -0
- package/hooks/codex.json +50 -0
- package/hooks/copilot-cli.json +33 -0
- package/hooks/hooks.json +16 -111
- package/hooks/kiro.json +69 -0
- package/hooks/qwen.json +50 -0
- package/hooks/vscode.json +40 -0
- package/hooks/zcode.json +53 -0
- package/opencode/plugin.js +22491 -0
- package/package.json +32 -114
- package/pi/index.js +50876 -0
- package/pi/skills/ctx-doctor/SKILL.md +6 -0
- package/pi/skills/ctx-stats/SKILL.md +6 -0
- package/pi/skills/ctx-upgrade/SKILL.md +6 -0
- package/qwen-extension.json +15 -0
- package/run.mjs +5639 -0
- package/search.mjs +5639 -0
- package/server.mjs +48214 -0
- package/skills/ctx-doctor/SKILL.md +3 -19
- package/skills/ctx-stats/SKILL.md +3 -23
- package/skills/ctx-upgrade/SKILL.md +3 -28
- package/.openclaw-plugin/index.ts +0 -12
- package/.openclaw-plugin/openclaw.plugin.json +0 -38
- package/.openclaw-plugin/package.json +0 -28
- package/LICENSE +0 -94
- package/README.md +0 -1619
- package/bin/statusline.mjs +0 -378
- package/build/adapters/antigravity/index.d.ts +0 -51
- package/build/adapters/antigravity/index.js +0 -169
- package/build/adapters/antigravity-cli/index.d.ts +0 -51
- package/build/adapters/antigravity-cli/index.js +0 -342
- package/build/adapters/base.d.ts +0 -92
- package/build/adapters/base.js +0 -135
- package/build/adapters/claude-code/config.d.ts +0 -8
- package/build/adapters/claude-code/config.js +0 -8
- package/build/adapters/claude-code/hooks.d.ts +0 -106
- package/build/adapters/claude-code/hooks.js +0 -183
- package/build/adapters/claude-code/index.d.ts +0 -80
- package/build/adapters/claude-code/index.js +0 -569
- package/build/adapters/claude-code-base.d.ts +0 -49
- package/build/adapters/claude-code-base.js +0 -113
- package/build/adapters/client-map.d.ts +0 -10
- package/build/adapters/client-map.js +0 -42
- package/build/adapters/codex/config.d.ts +0 -8
- package/build/adapters/codex/config.js +0 -8
- package/build/adapters/codex/hooks.d.ts +0 -55
- package/build/adapters/codex/hooks.js +0 -64
- package/build/adapters/codex/index.d.ts +0 -87
- package/build/adapters/codex/index.js +0 -992
- package/build/adapters/codex/paths.d.ts +0 -1
- package/build/adapters/codex/paths.js +0 -12
- package/build/adapters/codex/usage.d.ts +0 -107
- package/build/adapters/codex/usage.js +0 -227
- package/build/adapters/copilot-base.d.ts +0 -94
- package/build/adapters/copilot-base.js +0 -297
- package/build/adapters/copilot-cli/hooks.d.ts +0 -33
- package/build/adapters/copilot-cli/hooks.js +0 -64
- package/build/adapters/copilot-cli/index.d.ts +0 -48
- package/build/adapters/copilot-cli/index.js +0 -341
- package/build/adapters/cursor/config.d.ts +0 -4
- package/build/adapters/cursor/config.js +0 -4
- package/build/adapters/cursor/hooks.d.ts +0 -55
- package/build/adapters/cursor/hooks.js +0 -82
- package/build/adapters/cursor/index.d.ts +0 -64
- package/build/adapters/cursor/index.js +0 -517
- package/build/adapters/detect.d.ts +0 -136
- package/build/adapters/detect.js +0 -623
- package/build/adapters/gemini-cli/config.d.ts +0 -8
- package/build/adapters/gemini-cli/config.js +0 -8
- package/build/adapters/gemini-cli/hooks.d.ts +0 -83
- package/build/adapters/gemini-cli/hooks.js +0 -115
- package/build/adapters/gemini-cli/index.d.ts +0 -80
- package/build/adapters/gemini-cli/index.js +0 -483
- package/build/adapters/jetbrains-copilot/config.d.ts +0 -8
- package/build/adapters/jetbrains-copilot/config.js +0 -8
- package/build/adapters/jetbrains-copilot/hooks.d.ts +0 -59
- package/build/adapters/jetbrains-copilot/hooks.js +0 -87
- package/build/adapters/jetbrains-copilot/index.d.ts +0 -31
- package/build/adapters/jetbrains-copilot/index.js +0 -131
- package/build/adapters/kimi/config.d.ts +0 -8
- package/build/adapters/kimi/config.js +0 -8
- package/build/adapters/kimi/hooks.d.ts +0 -28
- package/build/adapters/kimi/hooks.js +0 -34
- package/build/adapters/kimi/index.d.ts +0 -66
- package/build/adapters/kimi/index.js +0 -537
- package/build/adapters/kimi/paths.d.ts +0 -21
- package/build/adapters/kimi/paths.js +0 -52
- package/build/adapters/kimi/usage.d.ts +0 -82
- package/build/adapters/kimi/usage.js +0 -217
- package/build/adapters/kiro/hooks.d.ts +0 -61
- package/build/adapters/kiro/hooks.js +0 -94
- package/build/adapters/kiro/index.d.ts +0 -61
- package/build/adapters/kiro/index.js +0 -301
- package/build/adapters/omp/index.d.ts +0 -65
- package/build/adapters/omp/index.js +0 -182
- package/build/adapters/omp/plugin.d.ts +0 -81
- package/build/adapters/omp/plugin.js +0 -331
- package/build/adapters/omp/usage.d.ts +0 -49
- package/build/adapters/omp/usage.js +0 -110
- package/build/adapters/openclaw/config.d.ts +0 -8
- package/build/adapters/openclaw/config.js +0 -8
- package/build/adapters/openclaw/hooks.d.ts +0 -50
- package/build/adapters/openclaw/hooks.js +0 -61
- package/build/adapters/openclaw/index.d.ts +0 -74
- package/build/adapters/openclaw/index.js +0 -439
- package/build/adapters/openclaw/mcp-tools.d.ts +0 -54
- package/build/adapters/openclaw/mcp-tools.js +0 -207
- package/build/adapters/openclaw/plugin.d.ts +0 -141
- package/build/adapters/openclaw/plugin.js +0 -818
- package/build/adapters/openclaw/session-db.d.ts +0 -55
- package/build/adapters/openclaw/session-db.js +0 -88
- package/build/adapters/openclaw/usage.d.ts +0 -34
- package/build/adapters/openclaw/usage.js +0 -52
- package/build/adapters/openclaw/workspace-router.d.ts +0 -29
- package/build/adapters/openclaw/workspace-router.js +0 -64
- package/build/adapters/opencode/config.d.ts +0 -8
- package/build/adapters/opencode/config.js +0 -8
- package/build/adapters/opencode/hooks.d.ts +0 -38
- package/build/adapters/opencode/hooks.js +0 -50
- package/build/adapters/opencode/index.d.ts +0 -67
- package/build/adapters/opencode/index.js +0 -463
- package/build/adapters/opencode/plugin.d.ts +0 -184
- package/build/adapters/opencode/plugin.js +0 -558
- package/build/adapters/opencode/zod3tov4.d.ts +0 -1
- package/build/adapters/opencode/zod3tov4.js +0 -132
- package/build/adapters/pi/extension.d.ts +0 -81
- package/build/adapters/pi/extension.js +0 -861
- package/build/adapters/pi/index.d.ts +0 -57
- package/build/adapters/pi/index.js +0 -173
- package/build/adapters/pi/mcp-bridge.d.ts +0 -268
- package/build/adapters/pi/mcp-bridge.js +0 -894
- package/build/adapters/qwen-code/hooks.d.ts +0 -26
- package/build/adapters/qwen-code/hooks.js +0 -29
- package/build/adapters/qwen-code/index.d.ts +0 -40
- package/build/adapters/qwen-code/index.js +0 -361
- package/build/adapters/qwen-code/usage.d.ts +0 -90
- package/build/adapters/qwen-code/usage.js +0 -222
- package/build/adapters/types.d.ts +0 -360
- package/build/adapters/types.js +0 -118
- package/build/adapters/vscode-copilot/config.d.ts +0 -8
- package/build/adapters/vscode-copilot/config.js +0 -8
- package/build/adapters/vscode-copilot/hooks.d.ts +0 -71
- package/build/adapters/vscode-copilot/hooks.js +0 -98
- package/build/adapters/vscode-copilot/index.d.ts +0 -32
- package/build/adapters/vscode-copilot/index.js +0 -222
- package/build/adapters/zed/index.d.ts +0 -40
- package/build/adapters/zed/index.js +0 -163
- package/build/cli.d.ts +0 -34
- package/build/cli.js +0 -1827
- package/build/db-base.d.ts +0 -179
- package/build/db-base.js +0 -569
- package/build/executor.d.ts +0 -72
- package/build/executor.js +0 -678
- package/build/exit-classify.d.ts +0 -19
- package/build/exit-classify.js +0 -12
- package/build/fetch-cache.d.ts +0 -13
- package/build/fetch-cache.js +0 -15
- package/build/lifecycle.d.ts +0 -124
- package/build/lifecycle.js +0 -278
- package/build/runPool.d.ts +0 -36
- package/build/runPool.js +0 -51
- package/build/runtime.d.ts +0 -71
- package/build/runtime.js +0 -659
- package/build/search/auto-memory.d.ts +0 -47
- package/build/search/auto-memory.js +0 -170
- package/build/search/ctx-search-schema.d.ts +0 -90
- package/build/search/ctx-search-schema.js +0 -135
- package/build/search/flood-guard.d.ts +0 -57
- package/build/search/flood-guard.js +0 -80
- package/build/search/unified.d.ts +0 -56
- package/build/search/unified.js +0 -104
- package/build/security.d.ts +0 -206
- package/build/security.js +0 -780
- package/build/server.d.ts +0 -184
- package/build/server.js +0 -4563
- package/build/session/analytics.d.ts +0 -773
- package/build/session/analytics.js +0 -2420
- package/build/session/db.d.ts +0 -468
- package/build/session/db.js +0 -1308
- package/build/session/error-classifier.d.ts +0 -87
- package/build/session/error-classifier.js +0 -303
- package/build/session/event-emit.d.ts +0 -48
- package/build/session/event-emit.js +0 -101
- package/build/session/extract.d.ts +0 -333
- package/build/session/extract.js +0 -2688
- package/build/session/model-prices.json +0 -429
- package/build/session/persist-tool-calls.d.ts +0 -54
- package/build/session/persist-tool-calls.js +0 -105
- package/build/session/pricing.d.ts +0 -64
- package/build/session/pricing.js +0 -151
- package/build/session/project-attribution.d.ts +0 -73
- package/build/session/project-attribution.js +0 -245
- package/build/session/purge.d.ts +0 -138
- package/build/session/purge.js +0 -240
- package/build/session/retrieval-marker.d.ts +0 -39
- package/build/session/retrieval-marker.js +0 -65
- package/build/session/snapshot.d.ts +0 -46
- package/build/session/snapshot.js +0 -532
- package/build/store-directory.d.ts +0 -56
- package/build/store-directory.js +0 -254
- package/build/store.d.ts +0 -147
- package/build/store.js +0 -1714
- package/build/truncate.d.ts +0 -57
- package/build/truncate.js +0 -142
- package/build/types.d.ts +0 -117
- package/build/types.js +0 -20
- package/build/util/claude-config.d.ts +0 -32
- package/build/util/claude-config.js +0 -84
- package/build/util/hook-config.d.ts +0 -27
- package/build/util/hook-config.js +0 -76
- package/build/util/jsonc.d.ts +0 -14
- package/build/util/jsonc.js +0 -104
- package/build/util/plugin-cache-integrity.d.ts +0 -51
- package/build/util/plugin-cache-integrity.js +0 -146
- package/build/util/project-dir.d.ts +0 -149
- package/build/util/project-dir.js +0 -340
- package/build/util/sibling-mcp.d.ts +0 -79
- package/build/util/sibling-mcp.js +0 -181
- package/cli.bundle.mjs +0 -1236
- package/configs/antigravity/GEMINI.md +0 -68
- package/configs/antigravity/mcp_config.json +0 -7
- package/configs/antigravity-cli/hooks/hooks.json +0 -37
- package/configs/antigravity-cli/hooks.json +0 -37
- package/configs/antigravity-cli/mcp_config.json +0 -10
- package/configs/antigravity-cli/plugin.json +0 -14
- package/configs/antigravity-cli/rules/context-mode.md +0 -77
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +0 -77
- package/configs/claude-code/CLAUDE.md +0 -91
- package/configs/codex/AGENTS.md +0 -99
- package/configs/codex/config.toml +0 -8
- package/configs/codex/hooks.json +0 -47
- package/configs/copilot-cli/.github/plugin/plugin.json +0 -23
- package/configs/copilot-cli/.mcp.json +0 -12
- package/configs/copilot-cli/README.md +0 -47
- package/configs/copilot-cli/hooks.json +0 -41
- package/configs/copilot-cli/skills/context-mode/SKILL.md +0 -38
- package/configs/cursor/context-mode.mdc +0 -76
- package/configs/cursor/hooks.json +0 -21
- package/configs/cursor/mcp.json +0 -7
- package/configs/gemini-cli/GEMINI.md +0 -88
- package/configs/gemini-cli/mcp.json +0 -7
- package/configs/gemini-cli/settings.json +0 -60
- package/configs/jetbrains-copilot/copilot-instructions.md +0 -80
- package/configs/jetbrains-copilot/hooks.json +0 -16
- package/configs/jetbrains-copilot/mcp.json +0 -7
- package/configs/kilo/AGENTS.md +0 -84
- package/configs/kilo/kilo.json +0 -6
- package/configs/kimi/hooks.json +0 -54
- package/configs/kiro/KIRO.md +0 -84
- package/configs/kiro/agent.json +0 -18
- package/configs/kiro/mcp.json +0 -7
- package/configs/omp/SYSTEM.md +0 -85
- package/configs/omp/mcp.json +0 -7
- package/configs/openclaw/AGENTS.md +0 -87
- package/configs/openclaw/openclaw.json +0 -13
- package/configs/opencode/AGENTS.md +0 -84
- package/configs/opencode/opencode.json +0 -6
- package/configs/pi/AGENTS.md +0 -3
- package/configs/qwen-code/QWEN.md +0 -91
- package/configs/vscode-copilot/copilot-instructions.md +0 -80
- package/configs/vscode-copilot/hooks.json +0 -16
- package/configs/vscode-copilot/mcp.json +0 -7
- package/configs/zed/AGENTS.md +0 -68
- package/hooks/antigravity-cli/payload.mjs +0 -98
- package/hooks/antigravity-cli/posttooluse.mjs +0 -138
- package/hooks/antigravity-cli/pretooluse.mjs +0 -78
- package/hooks/antigravity-cli/stop.mjs +0 -58
- package/hooks/auto-injection.mjs +0 -102
- package/hooks/cache-heal-utils.mjs +0 -379
- package/hooks/codex/platform.mjs +0 -1
- package/hooks/codex/posttooluse.mjs +0 -66
- package/hooks/codex/precompact.mjs +0 -70
- package/hooks/codex/pretooluse.mjs +0 -42
- package/hooks/codex/sessionstart.mjs +0 -121
- package/hooks/codex/stop.mjs +0 -138
- package/hooks/codex/userpromptsubmit.mjs +0 -75
- package/hooks/copilot-cli/posttooluse.mjs +0 -79
- package/hooks/copilot-cli/precompact.mjs +0 -66
- package/hooks/copilot-cli/pretooluse.mjs +0 -41
- package/hooks/copilot-cli/sessionstart.mjs +0 -121
- package/hooks/copilot-cli/stop.mjs +0 -59
- package/hooks/copilot-cli/userpromptsubmit.mjs +0 -77
- package/hooks/core/codex-caps.mjs +0 -112
- package/hooks/core/formatters.mjs +0 -356
- package/hooks/core/mcp-ready.mjs +0 -109
- package/hooks/core/platform-detect.mjs +0 -49
- package/hooks/core/routing.mjs +0 -1017
- package/hooks/core/stdin.mjs +0 -90
- package/hooks/core/tool-naming.mjs +0 -57
- package/hooks/cursor/afteragentresponse.mjs +0 -74
- package/hooks/cursor/hooks.json +0 -31
- package/hooks/cursor/posttooluse.mjs +0 -72
- package/hooks/cursor/pretooluse.mjs +0 -26
- package/hooks/cursor/sessionstart.mjs +0 -97
- package/hooks/cursor/stop.mjs +0 -48
- package/hooks/ensure-deps.mjs +0 -254
- package/hooks/formatters/claude-code.mjs +0 -113
- package/hooks/formatters/cursor.mjs +0 -37
- package/hooks/formatters/gemini-cli.mjs +0 -55
- package/hooks/formatters/vscode-copilot.mjs +0 -55
- package/hooks/gemini-cli/aftermodel.mjs +0 -70
- package/hooks/gemini-cli/aftertool.mjs +0 -61
- package/hooks/gemini-cli/beforeagent.mjs +0 -99
- package/hooks/gemini-cli/beforetool.mjs +0 -27
- package/hooks/gemini-cli/precompress.mjs +0 -55
- package/hooks/gemini-cli/sessionstart.mjs +0 -137
- package/hooks/heal-partial-install.mjs +0 -712
- package/hooks/jetbrains-copilot/posttooluse.mjs +0 -61
- package/hooks/jetbrains-copilot/precompact.mjs +0 -55
- package/hooks/jetbrains-copilot/pretooluse.mjs +0 -27
- package/hooks/jetbrains-copilot/sessionstart.mjs +0 -124
- package/hooks/kimi/platform.mjs +0 -1
- package/hooks/kimi/posttooluse.mjs +0 -72
- package/hooks/kimi/precompact.mjs +0 -80
- package/hooks/kimi/pretooluse.mjs +0 -42
- package/hooks/kimi/sessionend.mjs +0 -61
- package/hooks/kimi/sessionstart.mjs +0 -113
- package/hooks/kimi/stop.mjs +0 -132
- package/hooks/kimi/userpromptsubmit.mjs +0 -90
- package/hooks/kiro/agentspawn.mjs +0 -97
- package/hooks/kiro/posttooluse.mjs +0 -51
- package/hooks/kiro/pretooluse.mjs +0 -64
- package/hooks/kiro/userpromptsubmit.mjs +0 -88
- package/hooks/normalize-hooks.mjs +0 -323
- package/hooks/platform-bridge.mjs +0 -340
- package/hooks/posttooluse.mjs +0 -225
- package/hooks/precompact.mjs +0 -100
- package/hooks/pretooluse.mjs +0 -227
- package/hooks/qwen-code/platform.mjs +0 -1
- package/hooks/qwen-code/stop.mjs +0 -168
- package/hooks/routing-block.mjs +0 -105
- package/hooks/run-hook.mjs +0 -95
- package/hooks/security.bundle.mjs +0 -2
- package/hooks/session-attribution.bundle.mjs +0 -1
- package/hooks/session-db.bundle.mjs +0 -144
- package/hooks/session-directive.mjs +0 -520
- package/hooks/session-extract.bundle.mjs +0 -3
- package/hooks/session-helpers.mjs +0 -428
- package/hooks/session-loaders.mjs +0 -448
- package/hooks/session-snapshot.bundle.mjs +0 -32
- package/hooks/sessionstart.mjs +0 -464
- package/hooks/stop.mjs +0 -82
- package/hooks/suppress-stderr.mjs +0 -25
- package/hooks/userpromptsubmit.mjs +0 -108
- package/hooks/vscode-copilot/posttooluse.mjs +0 -61
- package/hooks/vscode-copilot/precompact.mjs +0 -55
- package/hooks/vscode-copilot/pretooluse.mjs +0 -27
- package/hooks/vscode-copilot/sessionstart.mjs +0 -129
- package/openclaw.plugin.json +0 -38
- package/scripts/heal-better-sqlite3.mjs +0 -430
- package/scripts/heal-installed-plugins.mjs +0 -598
- package/scripts/plugin-cache-integrity.mjs +0 -248
- package/scripts/postinstall.mjs +0 -384
- package/server.bundle.mjs +0 -1162
- package/skills/.ignore +0 -7
- package/skills/context-mode/SKILL.md +0 -300
- package/skills/context-mode/references/anti-patterns.md +0 -283
- package/skills/context-mode/references/patterns-javascript.md +0 -298
- package/skills/context-mode/references/patterns-python.md +0 -304
- package/skills/context-mode/references/patterns-shell.md +0 -277
- package/skills/ctx-index/SKILL.md +0 -46
- package/skills/ctx-insight/SKILL.md +0 -24
- package/skills/ctx-purge/SKILL.md +0 -49
- package/skills/ctx-search/SKILL.md +0 -35
- package/start.mjs +0 -616
package/build/server.js
DELETED
|
@@ -1,4563 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
|
-
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, writeSync, renameSync, rmSync, mkdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { join, dirname, resolve, sep, isAbsolute } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { homedir, tmpdir, cpus } from "node:os";
|
|
10
|
-
import { request as httpsRequest } from "node:https";
|
|
11
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
-
import { z } from "zod";
|
|
13
|
-
import { PolyglotExecutor } from "./executor.js";
|
|
14
|
-
import { runPool } from "./runPool.js";
|
|
15
|
-
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
|
|
16
|
-
import { composeFetchCacheKey } from "./fetch-cache.js";
|
|
17
|
-
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
|
|
18
|
-
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
19
|
-
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
20
|
-
import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
|
|
21
|
-
import { charSafePrefix } from "./truncate.js";
|
|
22
|
-
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
23
|
-
import { purgeSession } from "./session/purge.js";
|
|
24
|
-
import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
|
|
25
|
-
import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
|
|
26
|
-
import { appendRetrievalBytes } from "./session/retrieval-marker.js";
|
|
27
|
-
import { searchAllSources } from "./search/unified.js";
|
|
28
|
-
import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
|
|
29
|
-
import { FloodGuard } from "./search/flood-guard.js";
|
|
30
|
-
import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
|
|
31
|
-
import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
|
|
32
|
-
import { parseCodexContextModePluginRoot } from "./adapters/codex/index.js";
|
|
33
|
-
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
34
|
-
import { stripJsonComments } from "./util/jsonc.js";
|
|
35
|
-
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
36
|
-
import { resolveProjectDir } from "./util/project-dir.js";
|
|
37
|
-
import { loadDatabase } from "./db-base.js";
|
|
38
|
-
import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getConversationWindowStats, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js";
|
|
39
|
-
const __pkg_dir = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
const VERSION = (() => {
|
|
41
|
-
for (const rel of ["../package.json", "./package.json"]) {
|
|
42
|
-
const p = resolve(__pkg_dir, rel);
|
|
43
|
-
if (existsSync(p)) {
|
|
44
|
-
try {
|
|
45
|
-
return JSON.parse(readFileSync(p, "utf8")).version;
|
|
46
|
-
}
|
|
47
|
-
catch { }
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return "unknown";
|
|
51
|
-
})();
|
|
52
|
-
function getPackageRoot() {
|
|
53
|
-
return existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
54
|
-
}
|
|
55
|
-
function resolveCodexRuntimePluginRoot(fallbackRoot) {
|
|
56
|
-
try {
|
|
57
|
-
const probe = process.platform === "win32"
|
|
58
|
-
? spawnSync("cmd.exe", ["/d", "/s", "/c", "codex plugin list"], {
|
|
59
|
-
encoding: "utf-8",
|
|
60
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
-
timeout: 5000,
|
|
62
|
-
})
|
|
63
|
-
: spawnSync("codex", ["plugin", "list"], {
|
|
64
|
-
encoding: "utf-8",
|
|
65
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
66
|
-
timeout: 5000,
|
|
67
|
-
});
|
|
68
|
-
if (probe.status !== 0)
|
|
69
|
-
return fallbackRoot;
|
|
70
|
-
const runtimeRoot = parseCodexContextModePluginRoot(String(probe.stdout));
|
|
71
|
-
if (runtimeRoot && existsSync(resolve(runtimeRoot, ".codex-plugin", "hooks.json"))) {
|
|
72
|
-
return runtimeRoot;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
// Best effort only. Non-Codex hosts and older Codex builds may not expose
|
|
77
|
-
// plugin list; keep the package-root fallback for those environments.
|
|
78
|
-
}
|
|
79
|
-
return fallbackRoot;
|
|
80
|
-
}
|
|
81
|
-
function getRuntimeAwarePackageRoot(platformId) {
|
|
82
|
-
const packageRoot = getPackageRoot();
|
|
83
|
-
return platformId === "codex"
|
|
84
|
-
? resolveCodexRuntimePluginRoot(packageRoot)
|
|
85
|
-
: packageRoot;
|
|
86
|
-
}
|
|
87
|
-
// Prevent silent MCP server death from unhandled async errors.
|
|
88
|
-
//
|
|
89
|
-
// Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
|
|
90
|
-
// imported only to reuse the ctx_* tool registry, these handlers would become
|
|
91
|
-
// process-wide OpenCode/Kilo host handlers. In Node, adding an
|
|
92
|
-
// `uncaughtException` listener changes default crash behavior, so only the
|
|
93
|
-
// standalone MCP process may install them.
|
|
94
|
-
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
95
|
-
process.on("unhandledRejection", (err) => {
|
|
96
|
-
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
|
|
97
|
-
});
|
|
98
|
-
process.on("uncaughtException", (err) => {
|
|
99
|
-
try {
|
|
100
|
-
writeSync(2, `[context-mode] uncaughtException: ${err?.message ?? err}\n`);
|
|
101
|
-
}
|
|
102
|
-
finally {
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
const runtimes = detectRuntimes();
|
|
108
|
-
const available = getAvailableLanguages(runtimes);
|
|
109
|
-
export const server = new McpServer({
|
|
110
|
-
name: "context-mode",
|
|
111
|
-
version: VERSION,
|
|
112
|
-
});
|
|
113
|
-
export const REGISTERED_CTX_TOOLS = [];
|
|
114
|
-
export function shouldSuppressMcpToolsForNativePluginHost(opts = {}) {
|
|
115
|
-
const embedded = opts.embedded ?? process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
|
|
116
|
-
if (embedded === "1")
|
|
117
|
-
return false;
|
|
118
|
-
const platform = opts.platform ?? detectPlatform().platform;
|
|
119
|
-
if (platform !== "opencode" && platform !== "kilo")
|
|
120
|
-
return false;
|
|
121
|
-
const settings = opts.settings ?? readNativePluginHostSettings(platform);
|
|
122
|
-
return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
|
|
123
|
-
}
|
|
124
|
-
function readNativePluginHostSettings(platform) {
|
|
125
|
-
const base = platform === "kilo" ? "kilo" : "opencode";
|
|
126
|
-
const paths = [
|
|
127
|
-
resolve(`${base}.json`),
|
|
128
|
-
resolve(`${base}.jsonc`),
|
|
129
|
-
resolve(`.${base}`, `${base}.json`),
|
|
130
|
-
resolve(`.${base}`, `${base}.jsonc`),
|
|
131
|
-
join(homedir(), ".config", base, `${base}.json`),
|
|
132
|
-
join(homedir(), ".config", base, `${base}.jsonc`),
|
|
133
|
-
];
|
|
134
|
-
for (const p of paths) {
|
|
135
|
-
try {
|
|
136
|
-
if (!existsSync(p))
|
|
137
|
-
continue;
|
|
138
|
-
return JSON.parse(stripJsonComments(readFileSync(p, "utf8")));
|
|
139
|
-
}
|
|
140
|
-
catch { /* try next config path */ }
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
function settingsHasContextModePlugin(settings) {
|
|
145
|
-
const plugins = settings?.plugin;
|
|
146
|
-
return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
|
|
147
|
-
}
|
|
148
|
-
function settingsHasLegacyContextModeMcp(settings) {
|
|
149
|
-
const mcp = settings?.mcp;
|
|
150
|
-
return !!(mcp &&
|
|
151
|
-
typeof mcp === "object" &&
|
|
152
|
-
!Array.isArray(mcp) &&
|
|
153
|
-
Object.prototype.hasOwnProperty.call(mcp, "context-mode"));
|
|
154
|
-
}
|
|
155
|
-
const suppressMcpToolsForNativePluginHost = shouldSuppressMcpToolsForNativePluginHost();
|
|
156
|
-
/**
|
|
157
|
-
* Issue #623 — surface why ctx_* tools/list is empty on suppressed legacy MCP
|
|
158
|
-
* children. When a user upgrades OpenCode/Kilo from v1.0.136 → v1.0.137+ without
|
|
159
|
-
* running `context-mode upgrade`, their opencode.json still has BOTH the legacy
|
|
160
|
-
* mcp.context-mode block AND the plugin entry. The plugin path registers the
|
|
161
|
-
* tools natively, but the legacy MCP child runs in parallel and used to expose
|
|
162
|
-
* duplicate tools — v1.0.137 suppressed those duplicates. The suppression was
|
|
163
|
-
* silent, leaving any MCP client that inspected the child via tools/list with
|
|
164
|
-
* an empty list and no diagnostic. Emit one stderr line per process so an
|
|
165
|
-
* operator running the child directly (or any non-plugin MCP host) sees the
|
|
166
|
-
* exact reason and the `context-mode upgrade` fix.
|
|
167
|
-
*
|
|
168
|
-
* Exported for test (suppression-diagnostic regression guard).
|
|
169
|
-
*/
|
|
170
|
-
let __suppressionDiagnosticEmitted = false;
|
|
171
|
-
export function emitSuppressionDiagnostic(opts = {}) {
|
|
172
|
-
if (__suppressionDiagnosticEmitted)
|
|
173
|
-
return;
|
|
174
|
-
__suppressionDiagnosticEmitted = true;
|
|
175
|
-
const write = opts.write ?? ((c) => { process.stderr.write(c); });
|
|
176
|
-
const platform = opts.platform ?? "opencode/kilo";
|
|
177
|
-
write(`[context-mode] ctx_* tools/list intentionally empty on this MCP child: ` +
|
|
178
|
-
`legacy mcp.context-mode block coexists with plugin: ["context-mode"] in ` +
|
|
179
|
-
`${platform}.json — plugin-native tools are the supported path (#623). ` +
|
|
180
|
-
`Run \`context-mode upgrade\` to remove the legacy block (preserves other ` +
|
|
181
|
-
`MCP servers).\n`);
|
|
182
|
-
}
|
|
183
|
-
/** Test-only: reset the one-shot emission flag so suites can re-exercise. */
|
|
184
|
-
export function __resetSuppressionDiagnosticForTests() {
|
|
185
|
-
__suppressionDiagnosticEmitted = false;
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Issue #637 — register an explicit empty `tools/list` handler on the McpServer.
|
|
189
|
-
*
|
|
190
|
-
* Background: when `suppressMcpToolsForNativePluginHost` is true, every
|
|
191
|
-
* `server.registerTool()` call is short-circuited (returns `undefined` above).
|
|
192
|
-
* The MCP SDK only installs the SDK-default `tools/list` handler when at least
|
|
193
|
-
* one `registerTool()` reaches `setToolRequestHandlers()` internally
|
|
194
|
-
* (mcp.js:56-67). Suppressing every registration leaves `tools/list`
|
|
195
|
-
* unregistered, and the framework's RPC layer answers it with
|
|
196
|
-
* `-32601 "Method not found"`.
|
|
197
|
-
*
|
|
198
|
-
* The reporter of #637 (SquirrelRat) inspected the suppressed child via
|
|
199
|
-
* `tools/list` and read the JSON-RPC error as "the plugin never registers any
|
|
200
|
-
* ctx_* tools" — when in fact the plugin DOES register all 11 tools natively
|
|
201
|
-
* (verified at `src/adapters/opencode/plugin.ts:469` and
|
|
202
|
-
* `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of
|
|
203
|
-
* the #637 perception.
|
|
204
|
-
*
|
|
205
|
-
* This helper installs an explicit handler that returns `{tools: []}` — a
|
|
206
|
-
* spec-compliant empty list. Paired with the existing #623 stderr diagnostic,
|
|
207
|
-
* an operator now sees:
|
|
208
|
-
* - wire response: `{tools: []}` (matches expectation, no JSON-RPC error)
|
|
209
|
-
* - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)`
|
|
210
|
-
*
|
|
211
|
-
* Idempotent: throws inside SDK if called twice on the same server because
|
|
212
|
-
* `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations;
|
|
213
|
-
* we therefore install the SDK's default tool handlers FIRST (via a no-op
|
|
214
|
-
* registerTool of a fake tool, immediately removed) only if needed. To keep
|
|
215
|
-
* the public surface minimal, we just call `server.server.setRequestHandler`
|
|
216
|
-
* directly — that is the same low-level call used for prompts/resources at
|
|
217
|
-
* server.ts:259-261 and avoids the SDK guard entirely.
|
|
218
|
-
*
|
|
219
|
-
* Exported for test (#637 in-memory regression guard).
|
|
220
|
-
*/
|
|
221
|
-
export function registerEmptyToolsListHandler(target = server) {
|
|
222
|
-
target.server.registerCapabilities({ tools: { listChanged: false } });
|
|
223
|
-
target.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
|
|
224
|
-
}
|
|
225
|
-
const originalRegisterTool = server.registerTool.bind(server);
|
|
226
|
-
server.registerTool = (...args) => {
|
|
227
|
-
const [name, config, handler] = args;
|
|
228
|
-
if (suppressMcpToolsForNativePluginHost) {
|
|
229
|
-
emitSuppressionDiagnostic();
|
|
230
|
-
return undefined;
|
|
231
|
-
}
|
|
232
|
-
const wrappedHandler = wrapToolHandler(name, handler);
|
|
233
|
-
REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler });
|
|
234
|
-
args[2] = wrappedHandler;
|
|
235
|
-
return originalRegisterTool(...args);
|
|
236
|
-
};
|
|
237
|
-
function wrapToolHandler(name, handler) {
|
|
238
|
-
return async (toolArgs) => {
|
|
239
|
-
// #854: mark a tool call in-flight so the bridge-child idle reaper never
|
|
240
|
-
// shuts the server down mid-execution during a long ctx_execute/batch that
|
|
241
|
-
// emits no further inbound messages. Symmetric end in finally (success+error).
|
|
242
|
-
noteRequestStart();
|
|
243
|
-
try {
|
|
244
|
-
return await handler(toolArgs);
|
|
245
|
-
}
|
|
246
|
-
catch (err) {
|
|
247
|
-
const result = storageErrorResult(err);
|
|
248
|
-
if (result) {
|
|
249
|
-
try {
|
|
250
|
-
return trackResponse(name, result);
|
|
251
|
-
}
|
|
252
|
-
catch (trackErr) {
|
|
253
|
-
if (trackErr instanceof StorageDirectoryError)
|
|
254
|
-
return result;
|
|
255
|
-
throw trackErr;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
throw err;
|
|
259
|
-
}
|
|
260
|
-
finally {
|
|
261
|
-
noteRequestEnd();
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
// Issue #637 — when suppression is active, install the empty tools/list handler
|
|
266
|
-
// once at module-init time so the suppressed MCP child responds with
|
|
267
|
-
// `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
|
|
268
|
-
// #623 stderr diagnostic that explains WHY the list is empty. Skipped for the
|
|
269
|
-
// embedded plugin-import path because the embedded process is not the stdio
|
|
270
|
-
// MCP child an operator would inspect — it lives inside the OpenCode/Kilo
|
|
271
|
-
// host and never speaks JSON-RPC over stdio.
|
|
272
|
-
if (suppressMcpToolsForNativePluginHost && process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
273
|
-
registerEmptyToolsListHandler(server);
|
|
274
|
-
}
|
|
275
|
-
const projectDirOverride = new AsyncLocalStorage();
|
|
276
|
-
export async function withProjectDirOverride(projectDir, fn) {
|
|
277
|
-
const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
|
|
278
|
-
return projectDirOverride.run(ctx, fn);
|
|
279
|
-
}
|
|
280
|
-
// Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
|
|
281
|
-
// OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
|
|
282
|
-
// the SDK transport layer, causing subsequent listTools() calls to fail permanently.
|
|
283
|
-
import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
284
|
-
server.server.registerCapabilities({ prompts: { listChanged: false }, resources: { listChanged: false } });
|
|
285
|
-
server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
286
|
-
server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
287
|
-
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
|
288
|
-
// ── Strict-client (Gemini function-calling) schema compatibility ──────────────
|
|
289
|
-
// Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
|
|
290
|
-
// — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
|
|
291
|
-
// schema makes the host SILENTLY DROP that tool from the model's function list,
|
|
292
|
-
// so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
|
|
293
|
-
// protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
|
|
294
|
-
// • `const: X` → `enum: [X]` — an identical single-value constraint
|
|
295
|
-
// • drop `additionalProperties` — advisory only; every ctx_* handler parses
|
|
296
|
-
// args with Zod (which strips unknown keys server-side), so removing it
|
|
297
|
-
// changes no validation and no call behavior.
|
|
298
|
-
// Both transforms are behavior-preserving for every other client (Claude Code,
|
|
299
|
-
// Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
|
|
300
|
-
// model sends undeclared properties. Only the wire schema changes — never
|
|
301
|
-
// validation or how any tool is invoked.
|
|
302
|
-
export function sanitizeSchemaForStrictClients(node) {
|
|
303
|
-
if (Array.isArray(node))
|
|
304
|
-
return node.map(sanitizeSchemaForStrictClients);
|
|
305
|
-
if (node === null || typeof node !== "object")
|
|
306
|
-
return node;
|
|
307
|
-
const out = {};
|
|
308
|
-
for (const [key, value] of Object.entries(node)) {
|
|
309
|
-
if (key === "additionalProperties")
|
|
310
|
-
continue;
|
|
311
|
-
if (key === "const") {
|
|
312
|
-
out.enum = [value];
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
out[key] = sanitizeSchemaForStrictClients(value);
|
|
316
|
-
}
|
|
317
|
-
return out;
|
|
318
|
-
}
|
|
319
|
-
// Wrap the SDK-installed tools/list handler so its generated schemas pass through
|
|
320
|
-
// the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
|
|
321
|
-
// the original handler is left untouched (no regression — strict clients stay as
|
|
322
|
-
// they were, every other client unaffected). Must run AFTER all registerTool()
|
|
323
|
-
// calls so the SDK's default tools/list handler already exists.
|
|
324
|
-
export function installStrictClientSchemaCompat(target = server) {
|
|
325
|
-
try {
|
|
326
|
-
const low = target.server;
|
|
327
|
-
const original = low._requestHandlers?.get("tools/list");
|
|
328
|
-
if (typeof original !== "function")
|
|
329
|
-
return;
|
|
330
|
-
target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
|
|
331
|
-
const result = (await original(req, extra));
|
|
332
|
-
if (result && Array.isArray(result.tools)) {
|
|
333
|
-
for (const tool of result.tools) {
|
|
334
|
-
if (!tool || tool.inputSchema == null)
|
|
335
|
-
continue;
|
|
336
|
-
try {
|
|
337
|
-
tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
/* leave this tool's schema unchanged */
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return result;
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
/* best-effort — never break tools/list */
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
const executor = new PolyglotExecutor({
|
|
352
|
-
runtimes,
|
|
353
|
-
projectRoot: () => getProjectDir(),
|
|
354
|
-
});
|
|
355
|
-
// ─────────────────────────────────────────────────────────
|
|
356
|
-
// FS read tracking preload for ctx_batch_execute
|
|
357
|
-
// ─────────────────────────────────────────────────────────
|
|
358
|
-
// NODE_OPTIONS is denied by the executor's #buildSafeEnv (security).
|
|
359
|
-
// Instead, we inject it as an inline shell env prefix in each batch command.
|
|
360
|
-
// This temp file is loaded via --require when batch commands spawn Node processes.
|
|
361
|
-
const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
|
|
362
|
-
writeFileSync(CM_FS_PRELOAD, `(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`);
|
|
363
|
-
// In the stdio MCP path, main() also removes this file during graceful
|
|
364
|
-
// shutdown. Plugin-native OpenCode/Kilo imports skip main() (#574), so
|
|
365
|
-
// register a top-level best-effort cleanup too to avoid leaking preload
|
|
366
|
-
// snippets under /tmp when the host process exits.
|
|
367
|
-
process.on("exit", () => { try {
|
|
368
|
-
unlinkSync(CM_FS_PRELOAD);
|
|
369
|
-
}
|
|
370
|
-
catch { /* best effort */ } });
|
|
371
|
-
// Lazy singleton — no DB overhead unless index/search is used
|
|
372
|
-
let _store = null;
|
|
373
|
-
/**
|
|
374
|
-
* Build the FK-attribution object passed to every ContentStore.index*() call
|
|
375
|
-
* in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
|
|
376
|
-
* the current session — eventId stays undefined because MCP tool invocations
|
|
377
|
-
* are not paired with PostToolUse event rows at index time (the hook fires
|
|
378
|
-
* AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
|
|
379
|
-
* legacy unattributed rows readable.
|
|
380
|
-
*/
|
|
381
|
-
export function currentAttribution() {
|
|
382
|
-
const override = projectDirOverride.getStore();
|
|
383
|
-
if (override?.sessionId)
|
|
384
|
-
return { sessionId: override.sessionId };
|
|
385
|
-
// CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
|
|
386
|
-
// Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
|
|
387
|
-
// and writes session_events via hooks. Read the most-recent session_id from
|
|
388
|
-
// THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/
|
|
389
|
-
// kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/
|
|
390
|
-
// omp/pi/antigravity — no adapter-specific transcript path required.
|
|
391
|
-
const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB();
|
|
392
|
-
if (!sessionId)
|
|
393
|
-
return undefined;
|
|
394
|
-
return { sessionId };
|
|
395
|
-
}
|
|
396
|
-
let __cachedSessionId;
|
|
397
|
-
/** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
|
|
398
|
-
export function resolveSessionIdFromSessionDB(opts) {
|
|
399
|
-
// 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up.
|
|
400
|
-
const now = Date.now();
|
|
401
|
-
if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) {
|
|
402
|
-
return __cachedSessionId.sid;
|
|
403
|
-
}
|
|
404
|
-
try {
|
|
405
|
-
const projectDir = opts?.projectDir
|
|
406
|
-
?? process.env.CLAUDE_PROJECT_DIR
|
|
407
|
-
?? process.env.CONTEXT_MODE_PROJECT_DIR;
|
|
408
|
-
if (!projectDir)
|
|
409
|
-
return undefined;
|
|
410
|
-
const sessionsDir = opts?.sessionsDir ?? getSessionDir();
|
|
411
|
-
const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
|
|
412
|
-
if (!existsSync(dbPath))
|
|
413
|
-
return undefined;
|
|
414
|
-
const Database = loadDatabase();
|
|
415
|
-
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
416
|
-
try {
|
|
417
|
-
const row = db.prepare("SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1").get();
|
|
418
|
-
const sid = row?.session_id;
|
|
419
|
-
if (sid)
|
|
420
|
-
__cachedSessionId = { sid, checkedAt: now };
|
|
421
|
-
return sid;
|
|
422
|
-
}
|
|
423
|
-
finally {
|
|
424
|
-
try {
|
|
425
|
-
db.close();
|
|
426
|
-
}
|
|
427
|
-
catch { /* best-effort */ }
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
431
|
-
return undefined;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Auto-index session events files written by SessionStart hook.
|
|
436
|
-
* Scans ~/.claude/context-mode/sessions/ for *-events.md files.
|
|
437
|
-
* CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks —
|
|
438
|
-
* so we glob-scan instead of computing a specific hash.
|
|
439
|
-
* Files are consumed (deleted) after indexing to prevent double-indexing.
|
|
440
|
-
* Called on every getStore() — readdirSync is sub-millisecond when no files match.
|
|
441
|
-
*/
|
|
442
|
-
function maybeIndexSessionEvents(store) {
|
|
443
|
-
try {
|
|
444
|
-
const sessionsDir = getSessionDir();
|
|
445
|
-
if (!existsSync(sessionsDir))
|
|
446
|
-
return;
|
|
447
|
-
const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
|
|
448
|
-
for (const file of files) {
|
|
449
|
-
const filePath = join(sessionsDir, file);
|
|
450
|
-
try {
|
|
451
|
-
store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
|
|
452
|
-
unlinkSync(filePath);
|
|
453
|
-
}
|
|
454
|
-
catch { /* best-effort per file */ }
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
catch { /* best-effort — session continuity never blocks tools */ }
|
|
458
|
-
}
|
|
459
|
-
// ── Platform-aware paths ──────────────────────────────────────────────────
|
|
460
|
-
// The adapter (stored after MCP handshake) is the canonical source for
|
|
461
|
-
// platform-specific paths. All session DB paths go through it — no
|
|
462
|
-
// hardcoded configDir detection in tool handlers.
|
|
463
|
-
let _detectedAdapter = null;
|
|
464
|
-
/**
|
|
465
|
-
* Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl.
|
|
466
|
-
* leading `~`) before falling back to `~/.claude`. Mirrors
|
|
467
|
-
* `hooks/session-helpers.mjs::resolveConfigDir` and
|
|
468
|
-
* `ClaudeCodeAdapter.getConfigDir` so the pre-detection path agrees with
|
|
469
|
-
* hooks/adapter on where Claude Code session data lives. See issue #453.
|
|
470
|
-
*
|
|
471
|
-
* Issue #460 round-3: delegates to the canonical util so empty/whitespace
|
|
472
|
-
* env values fall back instead of poisoning downstream `join()` calls.
|
|
473
|
-
*/
|
|
474
|
-
async function getDiagnosticAdapter() {
|
|
475
|
-
if (_detectedAdapter)
|
|
476
|
-
return _detectedAdapter;
|
|
477
|
-
try {
|
|
478
|
-
const { getAdapter } = await import("./adapters/detect.js");
|
|
479
|
-
const signal = detectPlatform();
|
|
480
|
-
return await getAdapter(signal.platform);
|
|
481
|
-
}
|
|
482
|
-
catch {
|
|
483
|
-
return null;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Get the platform-specific sessions directory from the detected adapter.
|
|
488
|
-
* Falls back to the detected platform config root before adapter detection.
|
|
489
|
-
*/
|
|
490
|
-
function getDefaultSessionDir() {
|
|
491
|
-
if (_detectedAdapter)
|
|
492
|
-
return _detectedAdapter.getSessionDir();
|
|
493
|
-
// Pre-detection path (race window before MCP `initialize` completes):
|
|
494
|
-
// call detectPlatform() (sync, env-var-based) and look up segments via
|
|
495
|
-
// getSessionDirSegments() (sync map, no adapter instantiation). This keeps
|
|
496
|
-
// non-Claude platforms from spilling sessions into ~/.claude/. For Claude
|
|
497
|
-
// Code/Codex (single-segment roots), reroute through their config-dir
|
|
498
|
-
// contracts so the pre-detection window does not split-state with hooks.
|
|
499
|
-
try {
|
|
500
|
-
const signal = detectPlatform();
|
|
501
|
-
const segments = getSessionDirSegments(signal.platform);
|
|
502
|
-
if (segments) {
|
|
503
|
-
return resolveDefaultSessionDir({
|
|
504
|
-
configDir: join(...segments),
|
|
505
|
-
configDirEnv: configDirEnvForSessionSegments(segments),
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
catch { /* fall through to claude fallback */ }
|
|
510
|
-
return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" });
|
|
511
|
-
}
|
|
512
|
-
function configDirEnvForSessionSegments(segments) {
|
|
513
|
-
if (segments.length === 1 && segments[0] === ".claude")
|
|
514
|
-
return "CLAUDE_CONFIG_DIR";
|
|
515
|
-
if (segments.length === 1 && segments[0] === ".codex")
|
|
516
|
-
return "CODEX_HOME";
|
|
517
|
-
return undefined;
|
|
518
|
-
}
|
|
519
|
-
function getSessionDir() {
|
|
520
|
-
return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir));
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* Project directory detection across supported platforms.
|
|
524
|
-
*
|
|
525
|
-
* Priority:
|
|
526
|
-
* 1. Platform-specific env var (set by host IDE before MCP server spawn)
|
|
527
|
-
* 2. CONTEXT_MODE_PROJECT_DIR (set by start.mjs for ALL platforms — universal)
|
|
528
|
-
* 3. process.cwd() (last resort)
|
|
529
|
-
*
|
|
530
|
-
* CONTEXT_MODE_PROJECT_DIR guarantees correct projectDir even for platforms
|
|
531
|
-
* that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed).
|
|
532
|
-
*/
|
|
533
|
-
export function getProjectDir() {
|
|
534
|
-
const override = projectDirOverride.getStore();
|
|
535
|
-
if (override)
|
|
536
|
-
return override.projectDir;
|
|
537
|
-
// Delegated to the shared resolver so the env-var chain rejects plugin
|
|
538
|
-
// install paths (set by a prior MCP boot's start.mjs after `/ctx-upgrade`)
|
|
539
|
-
// and prefers the shell-set PWD before the chdir'd cwd. v1.0.115 adds
|
|
540
|
-
// the Claude Code transcript heuristic — read `cwd` from the most-recently-
|
|
541
|
-
// modified `~/.claude/projects/<encoded>/<session>.jsonl` to recover the
|
|
542
|
-
// real project dir when MCP was launched from a non-project cwd (desktop-
|
|
543
|
-
// app launch, /ctx-upgrade respawn). See src/util/project-dir.ts.
|
|
544
|
-
//
|
|
545
|
-
// Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude
|
|
546
|
-
// Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no
|
|
547
|
-
// transcript at that path or use a different schema without `cwd`. Worse,
|
|
548
|
-
// a Cursor user who also runs Claude Code would pick up the most-recently-
|
|
549
|
-
// modified Claude Code session's cwd — wrong project entirely. Gate the
|
|
550
|
-
// path on detected platform so non-Claude hosts skip the heuristic and
|
|
551
|
-
// fall through to PWD/cwd cleanly.
|
|
552
|
-
//
|
|
553
|
-
// The Claude heuristic must also be fresh. Hosts such as Pi can be
|
|
554
|
-
// misdetected as Claude Code solely because ~/.claude exists; without a
|
|
555
|
-
// freshness guard an old Claude transcript can globally hijack ctx shell cwd
|
|
556
|
-
// after reboot. Active Claude sessions update their transcript as the user
|
|
557
|
-
// interacts, so stale transcripts should fall through to PWD/cwd.
|
|
558
|
-
//
|
|
559
|
-
// Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the
|
|
560
|
-
// env-var cascade is built ALGORITHMICALLY from the platform's own
|
|
561
|
-
// workspace vars + universal escape hatch — foreign workspace vars (e.g.
|
|
562
|
-
// CLAUDE_PROJECT_DIR leaked into Pi's MCP child env from the user's shell)
|
|
563
|
-
// cannot win, regardless of cascade order. start.mjs intentionally does
|
|
564
|
-
// NOT pass strictPlatform — host detection is unreliable at the entrypoint
|
|
565
|
-
// and the legacy literal cascade is preserved there for semver safety.
|
|
566
|
-
let transcriptsRoot;
|
|
567
|
-
let strictPlatform;
|
|
568
|
-
let codexHome;
|
|
569
|
-
try {
|
|
570
|
-
const detected = detectPlatform().platform;
|
|
571
|
-
strictPlatform = detected;
|
|
572
|
-
if (detected === "claude-code") {
|
|
573
|
-
transcriptsRoot = join(homedir(), ".claude", "projects");
|
|
574
|
-
}
|
|
575
|
-
// Issue #45 — Codex publishes no workspace env var, so the resolver
|
|
576
|
-
// reads `meta.cwd` from the most-recently-modified session.jsonl under
|
|
577
|
-
// `${codexHome}/sessions/`. Wire codexHome at the call site so the
|
|
578
|
-
// resolver can be exercised under test without process-level mutation.
|
|
579
|
-
if (detected === "codex") {
|
|
580
|
-
codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
catch { /* detection failure — leave undefined, resolver uses legacy cascade */ }
|
|
584
|
-
return resolveProjectDir({
|
|
585
|
-
env: process.env,
|
|
586
|
-
cwd: process.cwd(),
|
|
587
|
-
pwd: process.env.PWD,
|
|
588
|
-
transcriptsRoot,
|
|
589
|
-
transcriptMaxAgeMs: 5 * 60 * 1000,
|
|
590
|
-
strictPlatform,
|
|
591
|
-
codexHome,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Resolve a possibly-relative path against the project directory (full env cascade),
|
|
596
|
-
* not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd
|
|
597
|
-
* is unrelated to where the user is working.
|
|
598
|
-
*/
|
|
599
|
-
function resolveProjectPath(filePath) {
|
|
600
|
-
return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath);
|
|
601
|
-
}
|
|
602
|
-
/**
|
|
603
|
-
* Resolve the per-project SessionDB path. Delegates to
|
|
604
|
-
* {@link resolveSessionDbPath} so casing-only variants of the same
|
|
605
|
-
* physical worktree on macOS / Windows hit ONE DB, not two — and any
|
|
606
|
-
* pre-existing legacy raw-casing DB gets migrated in place on first
|
|
607
|
-
* resolve. Linux is a no-op.
|
|
608
|
-
*/
|
|
609
|
-
function getSessionDbPath() {
|
|
610
|
-
return resolveSessionDbPath({
|
|
611
|
-
projectDir: getProjectDir(),
|
|
612
|
-
sessionsDir: getSessionDir(),
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Compute a per-project, per-platform persistent path for the ContentStore.
|
|
617
|
-
* Derives content dir from the adapter's session dir so each platform
|
|
618
|
-
* has its own isolated FTS5 DB — no cross-platform data sharing.
|
|
619
|
-
*
|
|
620
|
-
* Layout: ~/<configDir>/context-mode/content/<hash>.db
|
|
621
|
-
* e.g. ~/.claude/context-mode/content/87c28c41ddb64d38.db
|
|
622
|
-
* ~/.cursor/context-mode/content/87c28c41ddb64d38.db
|
|
623
|
-
*/
|
|
624
|
-
function getStorePath() {
|
|
625
|
-
const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir));
|
|
626
|
-
// Delegate to resolveContentStorePath: same case-fold + one-shot legacy
|
|
627
|
-
// rename behavior as resolveSessionDbPath. On macOS / Windows, an
|
|
628
|
-
// existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is
|
|
629
|
-
// migrated in place on first call. On Linux it's a no-op.
|
|
630
|
-
return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
|
|
631
|
-
}
|
|
632
|
-
function getStore() {
|
|
633
|
-
if (!_store) {
|
|
634
|
-
// Content DB cleanup on fresh start is handled by SessionStart hook.
|
|
635
|
-
// Server just opens whatever DB exists (or creates new if hook deleted it).
|
|
636
|
-
const dbPath = getStorePath();
|
|
637
|
-
_store = new ContentStore(dbPath);
|
|
638
|
-
// Wire deny-policy hook: store re-checks the Read deny list before
|
|
639
|
-
// re-reading any file_path during auto-refresh. Catches policy edits
|
|
640
|
-
// made after a file was originally indexed. See #442 round-3.
|
|
641
|
-
_store.setDenyChecker((filePath) => {
|
|
642
|
-
try {
|
|
643
|
-
const projectDir = getProjectDir();
|
|
644
|
-
const denyGlobs = readToolDenyPatterns("Read", projectDir);
|
|
645
|
-
const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
|
|
646
|
-
return r.denied;
|
|
647
|
-
}
|
|
648
|
-
catch {
|
|
649
|
-
// Fail-closed for refresh: skip on error rather than re-read.
|
|
650
|
-
return true;
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
// One-time startup cleanup: remove stale content DBs (>14 days)
|
|
654
|
-
try {
|
|
655
|
-
const contentDir = dirname(getStorePath());
|
|
656
|
-
cleanupStaleContentDBs(contentDir, 14);
|
|
657
|
-
_store.cleanupStaleSources(14);
|
|
658
|
-
// Also clean legacy shared dir from before platform isolation
|
|
659
|
-
const legacyDir = join(homedir(), ".context-mode", "content");
|
|
660
|
-
if (existsSync(legacyDir))
|
|
661
|
-
cleanupStaleContentDBs(legacyDir, 0);
|
|
662
|
-
}
|
|
663
|
-
catch { /* best-effort */ }
|
|
664
|
-
// Also clean old PID-based DBs from migration
|
|
665
|
-
cleanupStaleDBs();
|
|
666
|
-
}
|
|
667
|
-
maybeIndexSessionEvents(_store);
|
|
668
|
-
return _store;
|
|
669
|
-
}
|
|
670
|
-
// ─────────────────────────────────────────────────────────
|
|
671
|
-
// Session stats — track context consumption per tool
|
|
672
|
-
// ─────────────────────────────────────────────────────────
|
|
673
|
-
const sessionStats = {
|
|
674
|
-
calls: {},
|
|
675
|
-
bytesReturned: {},
|
|
676
|
-
bytesIndexed: 0,
|
|
677
|
-
bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context)
|
|
678
|
-
cacheHits: 0,
|
|
679
|
-
cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache
|
|
680
|
-
cacheBytesSaved: 0, // bytes avoided by TTL cache hits
|
|
681
|
-
sessionStart: Date.now(),
|
|
682
|
-
};
|
|
683
|
-
function storageErrorResult(err) {
|
|
684
|
-
if (!(err instanceof StorageDirectoryError))
|
|
685
|
-
return null;
|
|
686
|
-
return {
|
|
687
|
-
content: [{ type: "text", text: formatStorageDirectoryError(err) }],
|
|
688
|
-
isError: true,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
// ── Version outdated warning ──────────────────────────────────────────────
|
|
692
|
-
// Non-blocking npm check at startup. trackResponse prepends warning
|
|
693
|
-
// using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat.
|
|
694
|
-
let _latestVersion = null;
|
|
695
|
-
let _warningBurstCount = 0;
|
|
696
|
-
let _lastBurstStart = 0;
|
|
697
|
-
const VERSION_BURST_SIZE = 3;
|
|
698
|
-
const VERSION_SILENT_MS = 60 * 60 * 1000; // 1 hour
|
|
699
|
-
async function fetchLatestVersion() {
|
|
700
|
-
return new Promise((res) => {
|
|
701
|
-
const req = httpsRequest("https://registry.npmjs.org/context-mode/latest", { headers: { Connection: "close" } }, (resp) => {
|
|
702
|
-
let raw = "";
|
|
703
|
-
resp.on("data", (chunk) => { raw += chunk; });
|
|
704
|
-
resp.on("end", () => {
|
|
705
|
-
try {
|
|
706
|
-
const data = JSON.parse(raw);
|
|
707
|
-
res(data.version ?? "unknown");
|
|
708
|
-
}
|
|
709
|
-
catch {
|
|
710
|
-
res("unknown");
|
|
711
|
-
}
|
|
712
|
-
});
|
|
713
|
-
});
|
|
714
|
-
req.on("error", () => res("unknown"));
|
|
715
|
-
req.setTimeout(5000, () => { req.destroy(); res("unknown"); });
|
|
716
|
-
req.end();
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
function getUpgradeHint() {
|
|
720
|
-
const name = _detectedAdapter?.name;
|
|
721
|
-
if (name === "Claude Code")
|
|
722
|
-
return "/ctx-upgrade";
|
|
723
|
-
if (name === "OpenClaw")
|
|
724
|
-
return "npm run install:openclaw";
|
|
725
|
-
if (name === "Pi")
|
|
726
|
-
return "npm run build";
|
|
727
|
-
return "npm update -g context-mode";
|
|
728
|
-
}
|
|
729
|
-
function semverNewer(a, b) {
|
|
730
|
-
const pa = a.split(".").map(Number);
|
|
731
|
-
const pb = b.split(".").map(Number);
|
|
732
|
-
for (let i = 0; i < 3; i++) {
|
|
733
|
-
if ((pa[i] ?? 0) > (pb[i] ?? 0))
|
|
734
|
-
return true;
|
|
735
|
-
if ((pa[i] ?? 0) < (pb[i] ?? 0))
|
|
736
|
-
return false;
|
|
737
|
-
}
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
function isOutdated() {
|
|
741
|
-
if (!_latestVersion || _latestVersion === "unknown")
|
|
742
|
-
return false;
|
|
743
|
-
return semverNewer(_latestVersion, VERSION);
|
|
744
|
-
}
|
|
745
|
-
function shouldShowVersionWarning() {
|
|
746
|
-
if (!isOutdated())
|
|
747
|
-
return false;
|
|
748
|
-
const now = Date.now();
|
|
749
|
-
// Start of a new burst?
|
|
750
|
-
if (_warningBurstCount >= VERSION_BURST_SIZE) {
|
|
751
|
-
if (now - _lastBurstStart < VERSION_SILENT_MS)
|
|
752
|
-
return false; // still silent
|
|
753
|
-
_warningBurstCount = 0; // silence over, reset burst
|
|
754
|
-
}
|
|
755
|
-
if (_warningBurstCount === 0)
|
|
756
|
-
_lastBurstStart = now;
|
|
757
|
-
_warningBurstCount++;
|
|
758
|
-
return true;
|
|
759
|
-
}
|
|
760
|
-
// ── Self-heal Layer 2: Mid-session registry heal (anthropics/claude-code#46915) ──
|
|
761
|
-
// Runs once on first tool call. If Claude Code auto-updated the registry mid-session,
|
|
762
|
-
// hooks break because CLAUDE_PLUGIN_ROOT points to a deleted directory. We create a
|
|
763
|
-
// symlink from the broken path to our actual directory so hooks recover.
|
|
764
|
-
let _cacheHealDone = false;
|
|
765
|
-
function healCacheMidSession() {
|
|
766
|
-
if (_cacheHealDone)
|
|
767
|
-
return;
|
|
768
|
-
_cacheHealDone = true;
|
|
769
|
-
try {
|
|
770
|
-
// Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so users who relocate
|
|
771
|
-
// their CC config root don't have plugin cache healing operate against
|
|
772
|
-
// the wrong tree (and silently miss dangling-symlink cleanup).
|
|
773
|
-
const claudeRoot = resolveClaudeConfigDir();
|
|
774
|
-
const ipPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
|
|
775
|
-
if (!existsSync(ipPath))
|
|
776
|
-
return;
|
|
777
|
-
const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
|
|
778
|
-
const cacheRoot = resolve(claudeRoot, "plugins", "cache");
|
|
779
|
-
// Issue #795: canonicalize cacheRoot so the traversal guard works when
|
|
780
|
-
// ~/.claude is a symlink to another volume. path.resolve() does not
|
|
781
|
-
// dereference symlinks, so installPath values stored as physical paths
|
|
782
|
-
// (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith
|
|
783
|
-
// check against a symlink-path cacheRoot (/Users/me/.claude/...).
|
|
784
|
-
// realpathSync follows the symlink chain to the canonical location.
|
|
785
|
-
let cacheRootCanon;
|
|
786
|
-
try {
|
|
787
|
-
cacheRootCanon = realpathSync(cacheRoot);
|
|
788
|
-
}
|
|
789
|
-
catch {
|
|
790
|
-
cacheRootCanon = cacheRoot;
|
|
791
|
-
}
|
|
792
|
-
// Plugin root: build/ for tsc, plugin root for bundle
|
|
793
|
-
const pluginRoot = getPackageRoot();
|
|
794
|
-
for (const [key, entries] of Object.entries((ip.plugins ?? {}))) {
|
|
795
|
-
if (key !== "context-mode@context-mode")
|
|
796
|
-
continue;
|
|
797
|
-
for (const entry of entries) {
|
|
798
|
-
const rp = entry.installPath;
|
|
799
|
-
if (!rp || existsSync(rp))
|
|
800
|
-
continue;
|
|
801
|
-
// Path traversal guard (canonical comparison — see #795)
|
|
802
|
-
if (!resolve(rp).startsWith(cacheRootCanon + sep))
|
|
803
|
-
continue;
|
|
804
|
-
// Remove dangling symlink
|
|
805
|
-
try {
|
|
806
|
-
if (lstatSync(rp).isSymbolicLink())
|
|
807
|
-
unlinkSync(rp);
|
|
808
|
-
}
|
|
809
|
-
catch { }
|
|
810
|
-
const parent = dirname(rp);
|
|
811
|
-
if (!existsSync(parent))
|
|
812
|
-
mkdirSync(parent, { recursive: true });
|
|
813
|
-
if (existsSync(pluginRoot)) {
|
|
814
|
-
symlinkSync(pluginRoot, rp, process.platform === "win32" ? "junction" : undefined);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
catch { /* best effort */ }
|
|
820
|
-
}
|
|
821
|
-
function trackResponse(toolName, response) {
|
|
822
|
-
// #854: a response is activity too — refresh the bridge-child idle clock so a
|
|
823
|
-
// chatty/streaming call keeps its server alive even between inbound frames.
|
|
824
|
-
noteMcpActivity();
|
|
825
|
-
// Mid-session cache heal — one-shot, first tool call
|
|
826
|
-
healCacheMidSession();
|
|
827
|
-
// Prepend version outdated warning if needed
|
|
828
|
-
if (shouldShowVersionWarning() && response.content.length > 0) {
|
|
829
|
-
const hint = getUpgradeHint();
|
|
830
|
-
response.content[0].text =
|
|
831
|
-
`⚠️ context-mode v${VERSION} outdated → v${_latestVersion} available. Upgrade: ${hint}\n\n` +
|
|
832
|
-
response.content[0].text;
|
|
833
|
-
}
|
|
834
|
-
const bytes = response.content.reduce((sum, c) => sum + Buffer.byteLength(c.text), 0);
|
|
835
|
-
sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
|
|
836
|
-
sessionStats.bytesReturned[toolName] =
|
|
837
|
-
(sessionStats.bytesReturned[toolName] || 0) + bytes;
|
|
838
|
-
// Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
|
|
839
|
-
// bin/statusline.mjs (and any external dashboard) so they don't have to
|
|
840
|
-
// open the SQLite database. Throttled inside persistStats() (500ms) so
|
|
841
|
-
// it's safe to call on every response.
|
|
842
|
-
persistStats();
|
|
843
|
-
// Persist to SessionDB so counters survive process restart, --continue,
|
|
844
|
-
// upgrade. Re-introduces the write path 4742160 added and b392c2f dropped.
|
|
845
|
-
// setImmediate keeps this off the response hot path; the helper itself
|
|
846
|
-
// is best-effort (never throws).
|
|
847
|
-
setImmediate(() => persistToolCallCounter(getSessionDbPath(), toolName, bytes));
|
|
848
|
-
// D2 Phase 5/7 — sandbox-execute event emission. Tracks the bytes the
|
|
849
|
-
// user actually saw from sandboxed runs so getRealBytesStats() can
|
|
850
|
-
// replace the conservative `events × 256` estimate. Best-effort and
|
|
851
|
-
// off the hot path, same shape as persistToolCallCounter above.
|
|
852
|
-
if (toolName === "ctx_execute"
|
|
853
|
-
|| toolName === "ctx_execute_file"
|
|
854
|
-
|| toolName === "ctx_batch_execute") {
|
|
855
|
-
setImmediate(() => emitSandboxExecuteEvent({
|
|
856
|
-
sessionDbPath: getSessionDbPath(),
|
|
857
|
-
toolName,
|
|
858
|
-
bytesReturned: bytes,
|
|
859
|
-
}));
|
|
860
|
-
}
|
|
861
|
-
// Retrieval ("With context-mode") bridge — ctx_search / ctx_fetch_and_index
|
|
862
|
-
// response bytes are the kept-out content the model paid to access. The
|
|
863
|
-
// PostToolUse hook never fires for the plugin's OWN MCP tools, so the
|
|
864
|
-
// hook-side extractMcpToolCall can never see these calls (bytes_retrieved
|
|
865
|
-
// was 0/124454 in prod). Drop the count into a marker keyed by the session
|
|
866
|
-
// DB; the next ordinary-tool PostToolUse consumes it and emits a forwardable
|
|
867
|
-
// bytes_retrieved event. Off the hot path; never throws.
|
|
868
|
-
if (toolName === "ctx_search" || toolName === "ctx_fetch_and_index") {
|
|
869
|
-
setImmediate(() => appendRetrievalBytes(getSessionDbPath(), bytes));
|
|
870
|
-
}
|
|
871
|
-
return response;
|
|
872
|
-
}
|
|
873
|
-
function trackIndexed(bytes, source = "unknown") {
|
|
874
|
-
sessionStats.bytesIndexed += bytes;
|
|
875
|
-
persistStats();
|
|
876
|
-
// D2 Phase 5/7 — index-write event emission. `bytes_avoided` because
|
|
877
|
-
// these are bytes that would have flooded context if the user had
|
|
878
|
-
// Read'd the source instead of indexing.
|
|
879
|
-
if (bytes > 0) {
|
|
880
|
-
setImmediate(() => emitIndexWriteEvent({
|
|
881
|
-
sessionDbPath: getSessionDbPath(),
|
|
882
|
-
source,
|
|
883
|
-
bytesAvoided: bytes,
|
|
884
|
-
}));
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// ─────────────────────────────────────────────────────────
|
|
888
|
-
// Stats persistence — written after every tool call so
|
|
889
|
-
// external readers (status line scripts, dashboards, hooks)
|
|
890
|
-
// can see real-time savings without spawning an MCP client.
|
|
891
|
-
// ─────────────────────────────────────────────────────────
|
|
892
|
-
const STATS_PERSIST_THROTTLE_MS = 500;
|
|
893
|
-
// Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json).
|
|
894
|
-
// Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when
|
|
895
|
-
// it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently
|
|
896
|
-
// rendering missing fields (PR #401 architect review P1.3).
|
|
897
|
-
// v2: added tokens_saved_lifetime + dollars_saved_lifetime.
|
|
898
|
-
const STATS_SCHEMA_VERSION = 2;
|
|
899
|
-
// pricePerToken() intentionally NOT defined here — single source in
|
|
900
|
-
// src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup,
|
|
901
|
-
// PR #401 architect + ops 2-vote convergence.)
|
|
902
|
-
const LIFETIME_REFRESH_MS = 30_000;
|
|
903
|
-
// Matches the conversion factor in src/session/analytics.ts renderBottomLine:
|
|
904
|
-
// ~1KB per session event ÷ 4 bytes/token = 256 tokens/event.
|
|
905
|
-
const TOKENS_PER_EVENT = 256;
|
|
906
|
-
let _lastStatsPersist = 0;
|
|
907
|
-
let _lifetimeCache;
|
|
908
|
-
/**
|
|
909
|
-
* Resolve the per-session stats file path.
|
|
910
|
-
*
|
|
911
|
-
* The session id mirrors the Claude Code adapter contract
|
|
912
|
-
* (`pid-<parent pid>`), so a status line script can derive
|
|
913
|
-
* the same id from `$PPID` without coupling to MCP.
|
|
914
|
-
*/
|
|
915
|
-
// CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.)
|
|
916
|
-
// straight into a path.join, and path.join collapses ".." into the result,
|
|
917
|
-
// so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two
|
|
918
|
-
// levels above statsDir. The env var is not under direct MCP-tool-caller
|
|
919
|
-
// control, but in CI / multi-tenant contexts where the host env is partly
|
|
920
|
-
// influenceable this is an arbitrary-write primitive within the MCP server
|
|
921
|
-
// process's filesystem permissions. Constrain to a UUID-shaped charset
|
|
922
|
-
// before splicing into the stats filename.
|
|
923
|
-
const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
924
|
-
function sanitizeSessionId(raw) {
|
|
925
|
-
return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`;
|
|
926
|
-
}
|
|
927
|
-
function getStatsFilePath() {
|
|
928
|
-
const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`;
|
|
929
|
-
const sessionId = sanitizeSessionId(raw);
|
|
930
|
-
const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir));
|
|
931
|
-
return join(statsDir, `stats-${sessionId}.json`);
|
|
932
|
-
}
|
|
933
|
-
function persistStats() {
|
|
934
|
-
const now = Date.now();
|
|
935
|
-
if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS)
|
|
936
|
-
return;
|
|
937
|
-
_lastStatsPersist = now;
|
|
938
|
-
try {
|
|
939
|
-
const totalReturned = Object.values(sessionStats.bytesReturned).reduce((a, b) => a + b, 0);
|
|
940
|
-
const totalCalls = Object.values(sessionStats.calls).reduce((a, b) => a + b, 0);
|
|
941
|
-
const keptOut = sessionStats.bytesIndexed +
|
|
942
|
-
sessionStats.bytesSandboxed +
|
|
943
|
-
sessionStats.cacheBytesSaved;
|
|
944
|
-
const totalProcessed = keptOut + totalReturned;
|
|
945
|
-
const reductionPct = totalProcessed > 0
|
|
946
|
-
? Math.round((1 - totalReturned / totalProcessed) * 100)
|
|
947
|
-
: 0;
|
|
948
|
-
const tokensSaved = Math.round(keptOut / 4);
|
|
949
|
-
// Lifetime savings — cached separately because getLifetimeStats() scans
|
|
950
|
-
// disk (per-project SessionDBs + auto-memory dirs) and is too expensive
|
|
951
|
-
// for the 500ms persist throttle. Refresh every 30s; the statusline
|
|
952
|
-
// doesn't need second-by-second lifetime accuracy.
|
|
953
|
-
let lifetimeTokens = _lifetimeCache?.tokens ?? 0;
|
|
954
|
-
if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) {
|
|
955
|
-
try {
|
|
956
|
-
const life = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
957
|
-
lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT;
|
|
958
|
-
_lifetimeCache = { tokens: lifetimeTokens, computedAt: now };
|
|
959
|
-
}
|
|
960
|
-
catch {
|
|
961
|
-
// best-effort — keep stale cache or 0
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
const payload = {
|
|
965
|
-
schemaVersion: STATS_SCHEMA_VERSION,
|
|
966
|
-
version: VERSION,
|
|
967
|
-
updated_at: now,
|
|
968
|
-
session_start: sessionStats.sessionStart,
|
|
969
|
-
uptime_ms: now - sessionStats.sessionStart,
|
|
970
|
-
total_calls: totalCalls,
|
|
971
|
-
bytes_returned: totalReturned,
|
|
972
|
-
bytes_indexed: sessionStats.bytesIndexed,
|
|
973
|
-
bytes_sandboxed: sessionStats.bytesSandboxed,
|
|
974
|
-
cache_hits: sessionStats.cacheHits,
|
|
975
|
-
cache_bytes_saved: sessionStats.cacheBytesSaved,
|
|
976
|
-
kept_out: keptOut,
|
|
977
|
-
total_processed: totalProcessed,
|
|
978
|
-
reduction_pct: reductionPct,
|
|
979
|
-
tokens_saved: tokensSaved,
|
|
980
|
-
// statusline-facing $ values — pre-computed at the current per-token
|
|
981
|
-
// rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a
|
|
982
|
-
// Pi host; Opus $15/1M otherwise). Resolved on every persist via
|
|
983
|
-
// pricePerToken() so the env override picks up without an MCP restart.
|
|
984
|
-
dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2),
|
|
985
|
-
tokens_saved_lifetime: lifetimeTokens,
|
|
986
|
-
dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2),
|
|
987
|
-
by_tool: Object.fromEntries(Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map((t) => [
|
|
988
|
-
t,
|
|
989
|
-
{
|
|
990
|
-
calls: sessionStats.calls[t] || 0,
|
|
991
|
-
bytes: sessionStats.bytesReturned[t] || 0,
|
|
992
|
-
},
|
|
993
|
-
])),
|
|
994
|
-
};
|
|
995
|
-
const filePath = getStatsFilePath();
|
|
996
|
-
const tmpPath = `${filePath}.tmp`;
|
|
997
|
-
writeFileSync(tmpPath, JSON.stringify(payload));
|
|
998
|
-
renameSync(tmpPath, filePath);
|
|
999
|
-
}
|
|
1000
|
-
catch {
|
|
1001
|
-
// best-effort — never break tool calls because of stats persistence
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
// ==============================================================================
|
|
1005
|
-
// Security: server-side deny firewall
|
|
1006
|
-
// ==============================================================================
|
|
1007
|
-
/**
|
|
1008
|
-
* Check a shell command against Bash deny patterns.
|
|
1009
|
-
* Returns an error ToolResult if denied, or null if allowed.
|
|
1010
|
-
*/
|
|
1011
|
-
function checkDenyPolicy(command, toolName) {
|
|
1012
|
-
try {
|
|
1013
|
-
const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
|
|
1014
|
-
const result = evaluateCommandDenyOnly(command, policies);
|
|
1015
|
-
if (result.decision === "deny") {
|
|
1016
|
-
return trackResponse(toolName, {
|
|
1017
|
-
content: [{
|
|
1018
|
-
type: "text",
|
|
1019
|
-
text: `Command blocked by security policy: matches deny pattern ${result.matchedPattern}`,
|
|
1020
|
-
}],
|
|
1021
|
-
isError: true,
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
catch {
|
|
1026
|
-
// Security check failed — allow through (fail-open for server,
|
|
1027
|
-
// hooks are the primary enforcement layer)
|
|
1028
|
-
}
|
|
1029
|
-
return null;
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Check non-shell code for shell-escape calls against deny patterns.
|
|
1033
|
-
*/
|
|
1034
|
-
function checkNonShellDenyPolicy(code, language, toolName) {
|
|
1035
|
-
try {
|
|
1036
|
-
const commands = extractShellCommands(code, language);
|
|
1037
|
-
if (commands.length === 0)
|
|
1038
|
-
return null;
|
|
1039
|
-
const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
|
|
1040
|
-
for (const cmd of commands) {
|
|
1041
|
-
const result = evaluateCommandDenyOnly(cmd, policies);
|
|
1042
|
-
if (result.decision === "deny") {
|
|
1043
|
-
return trackResponse(toolName, {
|
|
1044
|
-
content: [{
|
|
1045
|
-
type: "text",
|
|
1046
|
-
text: `Command blocked by security policy: embedded shell command "${cmd}" matches deny pattern ${result.matchedPattern}`,
|
|
1047
|
-
}],
|
|
1048
|
-
isError: true,
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
catch {
|
|
1054
|
-
// Fail-open
|
|
1055
|
-
}
|
|
1056
|
-
return null;
|
|
1057
|
-
}
|
|
1058
|
-
/**
|
|
1059
|
-
* Issue #852 — project-boundary containment for `ctx_execute_file`.
|
|
1060
|
-
*
|
|
1061
|
-
* The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
|
|
1062
|
-
* user approving a `ctx_execute_file` call cannot see that its `path` escapes
|
|
1063
|
-
* the workspace. This guard refuses a `path` that resolves outside the project
|
|
1064
|
-
* root (absolute escape, `../` traversal, or symlink-out), restoring the
|
|
1065
|
-
* boundary the host believes it is enforcing.
|
|
1066
|
-
*
|
|
1067
|
-
* Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
|
|
1068
|
-
* expressed in the SAME host config the user already maintains: a
|
|
1069
|
-
* `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
|
|
1070
|
-
* mechanism Claude Code uses to whitelist a path outside its sandbox, so the
|
|
1071
|
-
* grant lives in one place and stays meaningful instead of rotting into a
|
|
1072
|
-
* context-mode-only env flag nobody sets.
|
|
1073
|
-
*
|
|
1074
|
-
* Fail-open on resolver failure (consistent with the other deny checks): if the
|
|
1075
|
-
* project root cannot be resolved, containment evaluates as "inside" and the
|
|
1076
|
-
* path is allowed through rather than spuriously blocking legitimate work.
|
|
1077
|
-
*/
|
|
1078
|
-
function checkProjectBoundary(filePath, toolName) {
|
|
1079
|
-
try {
|
|
1080
|
-
const projectDir = getProjectDir();
|
|
1081
|
-
const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
|
|
1082
|
-
const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
|
|
1083
|
-
if (verdict.allowed)
|
|
1084
|
-
return null;
|
|
1085
|
-
return trackResponse(toolName, {
|
|
1086
|
-
content: [{
|
|
1087
|
-
type: "text",
|
|
1088
|
-
text: `File access blocked: "${filePath}" resolves outside the project root ` +
|
|
1089
|
-
`(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
|
|
1090
|
-
`cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
|
|
1091
|
-
`To intentionally process a file outside the project, add a host allow rule, ` +
|
|
1092
|
-
`e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
|
|
1093
|
-
}],
|
|
1094
|
-
isError: true,
|
|
1095
|
-
});
|
|
1096
|
-
}
|
|
1097
|
-
catch {
|
|
1098
|
-
// Fail-open — resolver failure must not block legitimate in-project work.
|
|
1099
|
-
}
|
|
1100
|
-
return null;
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Check a file path against Read deny patterns.
|
|
1104
|
-
* Returns an error ToolResult if denied, or null if allowed.
|
|
1105
|
-
*/
|
|
1106
|
-
function checkFilePathDenyPolicy(filePath, toolName) {
|
|
1107
|
-
try {
|
|
1108
|
-
const projectDir = getProjectDir();
|
|
1109
|
-
const denyGlobs = readToolDenyPatterns("Read", projectDir);
|
|
1110
|
-
const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
|
|
1111
|
-
if (result.denied) {
|
|
1112
|
-
return trackResponse(toolName, {
|
|
1113
|
-
content: [{
|
|
1114
|
-
type: "text",
|
|
1115
|
-
text: `File access blocked by security policy: path matches Read deny pattern ${result.matchedPattern}`,
|
|
1116
|
-
}],
|
|
1117
|
-
isError: true,
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
catch {
|
|
1122
|
-
// Fail-open
|
|
1123
|
-
}
|
|
1124
|
-
return null;
|
|
1125
|
-
}
|
|
1126
|
-
// Build description dynamically based on detected runtimes
|
|
1127
|
-
const langList = available.join(", ");
|
|
1128
|
-
const bunNote = hasBunRuntime()
|
|
1129
|
-
? " (Bun detected — JS/TS runs 3-5x faster)"
|
|
1130
|
-
: "";
|
|
1131
|
-
// ─────────────────────────────────────────────────────────
|
|
1132
|
-
// Helper: smart snippet extraction — returns windows around
|
|
1133
|
-
// matching query terms instead of dumb truncation
|
|
1134
|
-
//
|
|
1135
|
-
// When `highlighted` is provided (from FTS5 `highlight()` with
|
|
1136
|
-
// STX/ETX markers), match positions are derived from the markers.
|
|
1137
|
-
// This is the authoritative source — FTS5 uses the exact same
|
|
1138
|
-
// tokenizer that produced the BM25 match, so stemmed variants
|
|
1139
|
-
// like "configuration" matching query "configure" are found
|
|
1140
|
-
// correctly. Falls back to indexOf on raw terms when highlighted
|
|
1141
|
-
// is absent (non-FTS codepath).
|
|
1142
|
-
// ─────────────────────────────────────────────────────────
|
|
1143
|
-
const STX = "\x02";
|
|
1144
|
-
const ETX = "\x03";
|
|
1145
|
-
/**
|
|
1146
|
-
* Parse FTS5 highlight markers to find match positions in the
|
|
1147
|
-
* original (marker-free) text. Returns character offsets into the
|
|
1148
|
-
* stripped content where each matched token begins.
|
|
1149
|
-
*/
|
|
1150
|
-
export function positionsFromHighlight(highlighted) {
|
|
1151
|
-
const positions = [];
|
|
1152
|
-
let cleanOffset = 0;
|
|
1153
|
-
let i = 0;
|
|
1154
|
-
while (i < highlighted.length) {
|
|
1155
|
-
if (highlighted[i] === STX) {
|
|
1156
|
-
// Record position of this match in the clean text
|
|
1157
|
-
positions.push(cleanOffset);
|
|
1158
|
-
i++; // skip STX
|
|
1159
|
-
// Advance through matched text until ETX
|
|
1160
|
-
while (i < highlighted.length && highlighted[i] !== ETX) {
|
|
1161
|
-
cleanOffset++;
|
|
1162
|
-
i++;
|
|
1163
|
-
}
|
|
1164
|
-
if (i < highlighted.length)
|
|
1165
|
-
i++; // skip ETX
|
|
1166
|
-
}
|
|
1167
|
-
else {
|
|
1168
|
-
cleanOffset++;
|
|
1169
|
-
i++;
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
return positions;
|
|
1173
|
-
}
|
|
1174
|
-
/** Strip STX/ETX markers to recover original content. */
|
|
1175
|
-
function stripMarkers(highlighted) {
|
|
1176
|
-
return highlighted.replaceAll(STX, "").replaceAll(ETX, "");
|
|
1177
|
-
}
|
|
1178
|
-
export function extractSnippet(content, query, maxLen = 1500, highlighted) {
|
|
1179
|
-
if (content.length <= maxLen)
|
|
1180
|
-
return content;
|
|
1181
|
-
// Derive match positions from FTS5 highlight markers when available
|
|
1182
|
-
const positions = [];
|
|
1183
|
-
if (highlighted) {
|
|
1184
|
-
for (const pos of positionsFromHighlight(highlighted)) {
|
|
1185
|
-
positions.push(pos);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
// Fallback: indexOf on raw query terms (non-FTS codepath)
|
|
1189
|
-
if (positions.length === 0) {
|
|
1190
|
-
const terms = query
|
|
1191
|
-
.toLowerCase()
|
|
1192
|
-
.split(/\s+/)
|
|
1193
|
-
.filter((t) => t.length > 2);
|
|
1194
|
-
const lower = content.toLowerCase();
|
|
1195
|
-
for (const term of terms) {
|
|
1196
|
-
let idx = lower.indexOf(term);
|
|
1197
|
-
while (idx !== -1) {
|
|
1198
|
-
positions.push(idx);
|
|
1199
|
-
idx = lower.indexOf(term, idx + 1);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
// No matches at all — return prefix
|
|
1204
|
-
if (positions.length === 0) {
|
|
1205
|
-
return content.slice(0, maxLen) + "\n…";
|
|
1206
|
-
}
|
|
1207
|
-
// Sort positions, merge overlapping windows
|
|
1208
|
-
positions.sort((a, b) => a - b);
|
|
1209
|
-
const WINDOW = 300;
|
|
1210
|
-
const windows = [];
|
|
1211
|
-
for (const pos of positions) {
|
|
1212
|
-
const start = Math.max(0, pos - WINDOW);
|
|
1213
|
-
const end = Math.min(content.length, pos + WINDOW);
|
|
1214
|
-
if (windows.length > 0 && start <= windows[windows.length - 1][1]) {
|
|
1215
|
-
windows[windows.length - 1][1] = end;
|
|
1216
|
-
}
|
|
1217
|
-
else {
|
|
1218
|
-
windows.push([start, end]);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
// Collect windows until maxLen
|
|
1222
|
-
const parts = [];
|
|
1223
|
-
let total = 0;
|
|
1224
|
-
for (const [start, end] of windows) {
|
|
1225
|
-
if (total >= maxLen)
|
|
1226
|
-
break;
|
|
1227
|
-
const part = content.slice(start, Math.min(end, start + (maxLen - total)));
|
|
1228
|
-
parts.push((start > 0 ? "…" : "") + part + (end < content.length ? "…" : ""));
|
|
1229
|
-
total += part.length;
|
|
1230
|
-
}
|
|
1231
|
-
return parts.join("\n\n");
|
|
1232
|
-
}
|
|
1233
|
-
export function formatBatchQueryResults(store, queries, source, maxOutput = 80 * 1024, scope = "batch") {
|
|
1234
|
-
const sections = [];
|
|
1235
|
-
let outputSize = 0;
|
|
1236
|
-
// When scope is "global", searchWithFallback receives `undefined` for the
|
|
1237
|
-
// source filter, which makes it query the entire persistent index instead
|
|
1238
|
-
// of only the chunks just produced by this batch's commands. Default
|
|
1239
|
-
// remains "batch" to preserve the historical behavior.
|
|
1240
|
-
const searchSource = scope === "global" ? undefined : source;
|
|
1241
|
-
for (const query of queries) {
|
|
1242
|
-
if (outputSize > maxOutput) {
|
|
1243
|
-
sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
|
|
1244
|
-
continue;
|
|
1245
|
-
}
|
|
1246
|
-
const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact");
|
|
1247
|
-
sections.push(`## ${query}`);
|
|
1248
|
-
sections.push("");
|
|
1249
|
-
if (results.length > 0) {
|
|
1250
|
-
for (const result of results) {
|
|
1251
|
-
const snippet = extractSnippet(result.content, query, 3000, result.highlighted);
|
|
1252
|
-
sections.push(`### ${result.title}`);
|
|
1253
|
-
sections.push(snippet);
|
|
1254
|
-
sections.push("");
|
|
1255
|
-
outputSize += snippet.length + result.title.length;
|
|
1256
|
-
}
|
|
1257
|
-
continue;
|
|
1258
|
-
}
|
|
1259
|
-
sections.push("No matching sections found.");
|
|
1260
|
-
sections.push("");
|
|
1261
|
-
}
|
|
1262
|
-
if (scope === "global") {
|
|
1263
|
-
sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`);
|
|
1264
|
-
}
|
|
1265
|
-
else {
|
|
1266
|
-
sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`);
|
|
1267
|
-
}
|
|
1268
|
-
return sections;
|
|
1269
|
-
}
|
|
1270
|
-
function quotePosixSingle(value) {
|
|
1271
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1272
|
-
}
|
|
1273
|
-
function quotePowerShellSingle(value) {
|
|
1274
|
-
return `'${value.replace(/'/g, "''")}'`;
|
|
1275
|
-
}
|
|
1276
|
-
export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
|
|
1277
|
-
const option = `--require ${preloadPath}`;
|
|
1278
|
-
const shell = shellPath.toLowerCase();
|
|
1279
|
-
const base = shell.split(/[\\/]/).pop() ?? shell;
|
|
1280
|
-
if (shell.includes("powershell") || shell.includes("pwsh")) {
|
|
1281
|
-
return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `;
|
|
1282
|
-
}
|
|
1283
|
-
if (base === "cmd" || base === "cmd.exe") {
|
|
1284
|
-
return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `;
|
|
1285
|
-
}
|
|
1286
|
-
return `NODE_OPTIONS=${quotePosixSingle(option)} `;
|
|
1287
|
-
}
|
|
1288
|
-
/**
|
|
1289
|
-
* Per-section budget for the echoed `$ <command>` line so a 50KB heredoc
|
|
1290
|
-
* payload cannot dominate the response body. The full command always reaches
|
|
1291
|
-
* the executor — only the echo is clipped (Issues #717 + #736).
|
|
1292
|
-
*/
|
|
1293
|
-
const COMMAND_ECHO_MAX = 500;
|
|
1294
|
-
function truncateCommandForEcho(command) {
|
|
1295
|
-
const cleaned = command.replace(/\s+/g, " ").trim();
|
|
1296
|
-
if (cleaned.length <= COMMAND_ECHO_MAX)
|
|
1297
|
-
return cleaned;
|
|
1298
|
-
return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
|
|
1299
|
-
}
|
|
1300
|
-
/**
|
|
1301
|
-
* Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
|
|
1302
|
-
* agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
|
|
1303
|
-
* blocking script hangs forever — the host never kills it and the user must
|
|
1304
|
-
* interrupt. Every other host enforces its own RPC timeout, so we keep the
|
|
1305
|
-
* no-server-timer behavior there (Issue #406 — long builds need an unbounded
|
|
1306
|
-
* run). A caller can still pass an explicit `timeout` to override on any host.
|
|
1307
|
-
*/
|
|
1308
|
-
export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000;
|
|
1309
|
-
export function resolveExecTimeout(timeout) {
|
|
1310
|
-
if (timeout !== undefined)
|
|
1311
|
-
return timeout;
|
|
1312
|
-
// Only agy gets a default — every other host enforces its own RPC timeout, so
|
|
1313
|
-
// keep the unbounded behavior there. Detected via the env the agy bundle pins
|
|
1314
|
-
// (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS.
|
|
1315
|
-
if (detectPlatform().platform !== "antigravity-cli")
|
|
1316
|
-
return undefined;
|
|
1317
|
-
const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS);
|
|
1318
|
-
return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS;
|
|
1319
|
-
}
|
|
1320
|
-
/**
|
|
1321
|
-
* Per-call budget for the source-code echo prepended by `ctx_execute` and
|
|
1322
|
-
* `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
|
|
1323
|
-
* sandbox — only the echo is clipped so massive payloads don't dominate
|
|
1324
|
-
* the response. Multi-line preserved (unlike command echo) so the user
|
|
1325
|
-
* sees the actual program shape.
|
|
1326
|
-
*/
|
|
1327
|
-
const CODE_ECHO_MAX = 2000;
|
|
1328
|
-
function truncateCodeForEcho(code) {
|
|
1329
|
-
if (code.length <= CODE_ECHO_MAX)
|
|
1330
|
-
return code;
|
|
1331
|
-
return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)";
|
|
1332
|
-
}
|
|
1333
|
-
/**
|
|
1334
|
-
* Build the source-code preamble surfaced before tool stdout. Provenance
|
|
1335
|
-
* survives in indexed chunks (FTS5 sees the fenced block) so later
|
|
1336
|
-
* ctx_search hits remember what ran.
|
|
1337
|
-
*/
|
|
1338
|
-
function buildExecuteEcho(language, code, path) {
|
|
1339
|
-
const header = path ? `path=${path}\n` : "";
|
|
1340
|
-
const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``;
|
|
1341
|
-
return `${header}${fenced}\n\n`;
|
|
1342
|
-
}
|
|
1343
|
-
function formatCommandOutput(label, command, raw, onFsBytes) {
|
|
1344
|
-
let output = raw || "(no output)";
|
|
1345
|
-
const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
|
|
1346
|
-
let cmdFsBytes = 0;
|
|
1347
|
-
for (const m of fsMatches)
|
|
1348
|
-
cmdFsBytes += parseInt(m[1]);
|
|
1349
|
-
if (cmdFsBytes > 0) {
|
|
1350
|
-
onFsBytes?.(cmdFsBytes);
|
|
1351
|
-
output = output.replace(/__CM_FS__:\d+\n?/g, "");
|
|
1352
|
-
}
|
|
1353
|
-
// Echo the executed command below the section heading so per-chunk
|
|
1354
|
-
// indexed content retains provenance for later ctx_search hits
|
|
1355
|
-
// (Issues #717 + #736).
|
|
1356
|
-
const echoed = truncateCommandForEcho(command);
|
|
1357
|
-
return `# ${label}\n\n$ ${echoed}\n\n${output}\n`;
|
|
1358
|
-
}
|
|
1359
|
-
function combineExecOutput(result) {
|
|
1360
|
-
const stdout = result.stdout || "";
|
|
1361
|
-
const stderr = result.stderr || "";
|
|
1362
|
-
if (!stderr)
|
|
1363
|
-
return stdout;
|
|
1364
|
-
if (!stdout)
|
|
1365
|
-
return stderr;
|
|
1366
|
-
return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`;
|
|
1367
|
-
}
|
|
1368
|
-
/**
|
|
1369
|
-
* Execute batch commands. concurrency=1 preserves the legacy serial path
|
|
1370
|
-
* (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
|
|
1371
|
-
* commands concurrently with at most N in flight; each command receives the
|
|
1372
|
-
* full timeout, output is collated by input index, and per-command timeouts
|
|
1373
|
-
* record `(timed out)` blocks without skipping siblings.
|
|
1374
|
-
*/
|
|
1375
|
-
export async function runBatchCommands(commands, opts, executor) {
|
|
1376
|
-
const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;
|
|
1377
|
-
if (concurrency <= 1) {
|
|
1378
|
-
// Serial path — shared timeout budget, cascading skip on timeout.
|
|
1379
|
-
// When `timeout` is undefined, no shared budget is enforced; each
|
|
1380
|
-
// command runs to completion (Issue #406).
|
|
1381
|
-
const outputs = [];
|
|
1382
|
-
const startTime = Date.now();
|
|
1383
|
-
let timedOut = false;
|
|
1384
|
-
for (let i = 0; i < commands.length; i++) {
|
|
1385
|
-
const cmd = commands[i];
|
|
1386
|
-
let perCmdTimeout;
|
|
1387
|
-
if (timeout !== undefined) {
|
|
1388
|
-
const elapsed = Date.now() - startTime;
|
|
1389
|
-
const remaining = timeout - elapsed;
|
|
1390
|
-
if (remaining <= 0) {
|
|
1391
|
-
outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
1392
|
-
timedOut = true;
|
|
1393
|
-
continue;
|
|
1394
|
-
}
|
|
1395
|
-
perCmdTimeout = remaining;
|
|
1396
|
-
}
|
|
1397
|
-
const result = await executor.execute({
|
|
1398
|
-
language: "shell",
|
|
1399
|
-
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1400
|
-
timeout: perCmdTimeout,
|
|
1401
|
-
cwd,
|
|
1402
|
-
});
|
|
1403
|
-
outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
|
|
1404
|
-
if (result.timedOut) {
|
|
1405
|
-
timedOut = true;
|
|
1406
|
-
for (let j = i + 1; j < commands.length; j++) {
|
|
1407
|
-
outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`);
|
|
1408
|
-
}
|
|
1409
|
-
break;
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
return { outputs, timedOut };
|
|
1413
|
-
}
|
|
1414
|
-
// Parallel path — delegated to the shared runPool primitive.
|
|
1415
|
-
// Each job returns { output, timedOut }; runPool handles in-flight cap,
|
|
1416
|
-
// throw isolation (Promise.allSettled semantics), and order preservation.
|
|
1417
|
-
const jobs = commands.map((cmd) => ({
|
|
1418
|
-
run: async () => {
|
|
1419
|
-
const result = await executor.execute({
|
|
1420
|
-
language: "shell",
|
|
1421
|
-
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1422
|
-
timeout,
|
|
1423
|
-
cwd,
|
|
1424
|
-
});
|
|
1425
|
-
// Always route partial output through formatCommandOutput so __CM_FS__
|
|
1426
|
-
// markers are stripped + counted, even when the command timed out.
|
|
1427
|
-
const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes);
|
|
1428
|
-
const output = result.timedOut
|
|
1429
|
-
? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n`
|
|
1430
|
-
: formatted;
|
|
1431
|
-
return { output, timedOut: !!result.timedOut };
|
|
1432
|
-
},
|
|
1433
|
-
}));
|
|
1434
|
-
const { settled } = await runPool(jobs, { concurrency });
|
|
1435
|
-
const outputs = new Array(commands.length);
|
|
1436
|
-
let timedOut = false;
|
|
1437
|
-
for (let i = 0; i < settled.length; i++) {
|
|
1438
|
-
const r = settled[i];
|
|
1439
|
-
if (r.status === "fulfilled") {
|
|
1440
|
-
outputs[i] = r.value.output;
|
|
1441
|
-
if (r.value.timedOut)
|
|
1442
|
-
timedOut = true;
|
|
1443
|
-
}
|
|
1444
|
-
else {
|
|
1445
|
-
// Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running.
|
|
1446
|
-
const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
1447
|
-
outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`;
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
return { outputs, timedOut };
|
|
1451
|
-
}
|
|
1452
|
-
// ─────────────────────────────────────────────────────────
|
|
1453
|
-
// Tool: execute
|
|
1454
|
-
// ─────────────────────────────────────────────────────────
|
|
1455
|
-
server.registerTool("ctx_execute", {
|
|
1456
|
-
// #852: surface code execution in the host approval prompt's title (the
|
|
1457
|
-
// only server-controlled field the MCP permission UI renders besides args).
|
|
1458
|
-
title: "Run code in a sandbox (executes the supplied code)",
|
|
1459
|
-
// #846: runs arbitrary code in a sandbox with full network access.
|
|
1460
|
-
annotations: {
|
|
1461
|
-
readOnlyHint: false,
|
|
1462
|
-
destructiveHint: true,
|
|
1463
|
-
idempotentHint: false,
|
|
1464
|
-
openWorldHint: true,
|
|
1465
|
-
},
|
|
1466
|
-
description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
|
|
1467
|
-
|
|
1468
|
-
Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work.
|
|
1469
|
-
|
|
1470
|
-
Concrete shape — analyze 47 source files without reading any of them:
|
|
1471
|
-
ctx_execute(language: "javascript", code: \`
|
|
1472
|
-
const fs = require('fs');
|
|
1473
|
-
const files = fs.readdirSync('src').filter(f => f.endsWith('.ts'));
|
|
1474
|
-
files.forEach(f => {
|
|
1475
|
-
const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length;
|
|
1476
|
-
console.log(f + ': ' + lines + ' lines');
|
|
1477
|
-
});
|
|
1478
|
-
\`)
|
|
1479
|
-
// 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB.
|
|
1480
|
-
|
|
1481
|
-
WHEN:
|
|
1482
|
-
- You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer
|
|
1483
|
-
- Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans)
|
|
1484
|
-
- You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation
|
|
1485
|
-
- You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process
|
|
1486
|
-
- The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search
|
|
1487
|
-
|
|
1488
|
-
WHEN NOT:
|
|
1489
|
-
- Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler
|
|
1490
|
-
- File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface
|
|
1491
|
-
- You already know the output is one short fixed line and you want to read it as-is
|
|
1492
|
-
|
|
1493
|
-
RETURNS:
|
|
1494
|
-
Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections.
|
|
1495
|
-
|
|
1496
|
-
EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('npm test', {encoding:'utf8', stdio:['ignore','pipe','pipe']}); console.log(out.split('\\\\n').filter(l => /(FAIL|✗|×|Error:|Tests +.*(failed|passed))/i.test(l)).slice(0, 60).join('\\\\n'))")
|
|
1497
|
-
EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`,
|
|
1498
|
-
inputSchema: z.object({
|
|
1499
|
-
language: z
|
|
1500
|
-
.enum([
|
|
1501
|
-
"javascript",
|
|
1502
|
-
"typescript",
|
|
1503
|
-
"python",
|
|
1504
|
-
"shell",
|
|
1505
|
-
"ruby",
|
|
1506
|
-
"go",
|
|
1507
|
-
"rust",
|
|
1508
|
-
"php",
|
|
1509
|
-
"perl",
|
|
1510
|
-
"r",
|
|
1511
|
-
"elixir",
|
|
1512
|
-
"csharp",
|
|
1513
|
-
])
|
|
1514
|
-
.describe("Runtime language"),
|
|
1515
|
-
code: z
|
|
1516
|
-
.string()
|
|
1517
|
-
.describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context."),
|
|
1518
|
-
timeout: z
|
|
1519
|
-
.coerce.number()
|
|
1520
|
-
.optional()
|
|
1521
|
-
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."),
|
|
1522
|
-
// background: wrapped in coerceBoolean preprocessor so the literal
|
|
1523
|
-
// strings "true"/"false" arriving from OpenCode's native plugin
|
|
1524
|
-
// bridge (and several LLM providers' tool-call JSON) parse as the
|
|
1525
|
-
// boolean the handler expects. z.coerce.boolean() is unsafe here —
|
|
1526
|
-
// Boolean("false") is true. Fixes #627.
|
|
1527
|
-
background: z
|
|
1528
|
-
.preprocess(coerceBoolean, z.boolean())
|
|
1529
|
-
.optional()
|
|
1530
|
-
.default(false)
|
|
1531
|
-
.describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
|
|
1532
|
-
cwd: z
|
|
1533
|
-
.string()
|
|
1534
|
-
.optional()
|
|
1535
|
-
.describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
|
|
1536
|
-
intent: z
|
|
1537
|
-
.string()
|
|
1538
|
-
.optional()
|
|
1539
|
-
.describe("What you're looking for in the output. When provided and output is large (>5KB), " +
|
|
1540
|
-
"indexes output into knowledge base and returns section titles + previews — not full content. " +
|
|
1541
|
-
"Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
|
|
1542
|
-
"\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
|
|
1543
|
-
}),
|
|
1544
|
-
}, async ({ language, code, timeout, background, cwd, intent }) => {
|
|
1545
|
-
// Security: deny-only firewall
|
|
1546
|
-
if (language === "shell") {
|
|
1547
|
-
const denied = checkDenyPolicy(code, "execute");
|
|
1548
|
-
if (denied)
|
|
1549
|
-
return denied;
|
|
1550
|
-
}
|
|
1551
|
-
else {
|
|
1552
|
-
const denied = checkNonShellDenyPolicy(code, language, "execute");
|
|
1553
|
-
if (denied)
|
|
1554
|
-
return denied;
|
|
1555
|
-
}
|
|
1556
|
-
try {
|
|
1557
|
-
// For JS/TS: wrap in async IIFE with fetch + http/https interceptors to track network bytes
|
|
1558
|
-
let instrumentedCode = code;
|
|
1559
|
-
if (language === "javascript" || language === "typescript") {
|
|
1560
|
-
// Wrap user code in a closure that shadows CJS require with http/https interceptor.
|
|
1561
|
-
// globalThis.require does NOT work because CJS require is module-scoped, not global.
|
|
1562
|
-
// The closure approach (function(__cm_req){ var require=...; })(require) correctly
|
|
1563
|
-
// shadows the CJS require for all code inside, including __cm_main().
|
|
1564
|
-
instrumentedCode = `
|
|
1565
|
-
// FS read instrumentation — count bytes read via fs.readFileSync/readFile
|
|
1566
|
-
let __cm_fs=0;
|
|
1567
|
-
process.on('exit',()=>{if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch{}});
|
|
1568
|
-
(function(){
|
|
1569
|
-
try{
|
|
1570
|
-
var f=typeof require!=='undefined'?require('fs'):null;
|
|
1571
|
-
if(!f)return;
|
|
1572
|
-
var ors=f.readFileSync;
|
|
1573
|
-
f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};
|
|
1574
|
-
var orf=f.readFile;
|
|
1575
|
-
if(orf)f.readFile=function(){var a=Array.from(arguments),cb=a.pop();orf.apply(this,a.concat([function(e,d){if(!e&&d){if(Buffer.isBuffer(d))__cm_fs+=d.length;else if(typeof d==='string')__cm_fs+=Buffer.byteLength(d);}cb(e,d);}]));};
|
|
1576
|
-
}catch{}
|
|
1577
|
-
})();
|
|
1578
|
-
let __cm_net=0;
|
|
1579
|
-
// Report network bytes on process exit — works with both promise and callback patterns.
|
|
1580
|
-
// process.on('exit') fires after all I/O completes, unlike .finally() which fires
|
|
1581
|
-
// when __cm_main() resolves (immediately for callback-based http.get without await).
|
|
1582
|
-
process.on('exit',()=>{if(__cm_net>0)try{process.stderr.write('__CM_NET__:'+__cm_net+'\\n')}catch{}});
|
|
1583
|
-
;(function(__cm_req){
|
|
1584
|
-
// Intercept globalThis.fetch
|
|
1585
|
-
const __cm_f=globalThis.fetch;
|
|
1586
|
-
globalThis.fetch=async(...a)=>{const r=await __cm_f(...a);
|
|
1587
|
-
try{const cl=r.clone();const b=await cl.arrayBuffer();__cm_net+=b.byteLength}catch{}
|
|
1588
|
-
return r};
|
|
1589
|
-
// Shadow CJS require with http/https network tracking.
|
|
1590
|
-
const __cm_hc=new Map();
|
|
1591
|
-
const __cm_hm=new Set(['http','https','node:http','node:https']);
|
|
1592
|
-
function __cm_wf(m,origFn){return function(...a){
|
|
1593
|
-
const li=a.length-1;
|
|
1594
|
-
if(li>=0&&typeof a[li]==='function'){const oc=a[li];a[li]=function(res){
|
|
1595
|
-
res.on('data',function(c){__cm_net+=c.length});oc(res);};}
|
|
1596
|
-
const req=origFn.apply(m,a);
|
|
1597
|
-
const oOn=req.on.bind(req);
|
|
1598
|
-
req.on=function(ev,cb,...r){
|
|
1599
|
-
if(ev==='response'){return oOn(ev,function(res){
|
|
1600
|
-
res.on('data',function(c){__cm_net+=c.length});cb(res);
|
|
1601
|
-
},...r);}
|
|
1602
|
-
return oOn(ev,cb,...r);
|
|
1603
|
-
};
|
|
1604
|
-
return req;
|
|
1605
|
-
}}
|
|
1606
|
-
var require=__cm_req?function(id){
|
|
1607
|
-
const m=__cm_req(id);
|
|
1608
|
-
if(!__cm_hm.has(id))return m;
|
|
1609
|
-
const k=id.replace('node:','');
|
|
1610
|
-
if(__cm_hc.has(k))return __cm_hc.get(k);
|
|
1611
|
-
const w=Object.create(m);
|
|
1612
|
-
if(typeof m.get==='function')w.get=__cm_wf(m,m.get);
|
|
1613
|
-
if(typeof m.request==='function')w.request=__cm_wf(m,m.request);
|
|
1614
|
-
__cm_hc.set(k,w);return w;
|
|
1615
|
-
}:__cm_req;
|
|
1616
|
-
if(__cm_req){if(__cm_req.resolve)require.resolve=__cm_req.resolve;
|
|
1617
|
-
if(__cm_req.cache)require.cache=__cm_req.cache;}
|
|
1618
|
-
async function __cm_main(){
|
|
1619
|
-
${code}
|
|
1620
|
-
}
|
|
1621
|
-
__cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
|
|
1622
|
-
})(typeof require!=='undefined'?require:null);`;
|
|
1623
|
-
}
|
|
1624
|
-
const effTimeout = resolveExecTimeout(timeout);
|
|
1625
|
-
const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
|
|
1626
|
-
// Echo the executed source code before stdout so users can audit
|
|
1627
|
-
// and tooling can block command patterns (Issues #717 + #736).
|
|
1628
|
-
// Built from the user-supplied `code`, NOT the instrumented variant.
|
|
1629
|
-
const echo = buildExecuteEcho(language, code);
|
|
1630
|
-
// Parse sandbox network metrics from stderr
|
|
1631
|
-
const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/);
|
|
1632
|
-
if (netMatch) {
|
|
1633
|
-
sessionStats.bytesSandboxed += parseInt(netMatch[1]);
|
|
1634
|
-
// Clean the metric line from stderr
|
|
1635
|
-
result.stderr = result.stderr.replace(/\n?__CM_NET__:\d+\n?/g, "");
|
|
1636
|
-
}
|
|
1637
|
-
// Parse sandbox FS read metrics from stderr
|
|
1638
|
-
const fsMatch = result.stderr?.match(/__CM_FS__:(\d+)/);
|
|
1639
|
-
if (fsMatch) {
|
|
1640
|
-
sessionStats.bytesSandboxed += parseInt(fsMatch[1]);
|
|
1641
|
-
result.stderr = result.stderr.replace(/\n?__CM_FS__:\d+\n?/g, "");
|
|
1642
|
-
}
|
|
1643
|
-
if (result.timedOut) {
|
|
1644
|
-
const partialOutput = result.stdout?.trim();
|
|
1645
|
-
if (result.backgrounded && partialOutput) {
|
|
1646
|
-
// Background mode: process is still running, return partial output as success
|
|
1647
|
-
return trackResponse("ctx_execute", {
|
|
1648
|
-
content: [
|
|
1649
|
-
{
|
|
1650
|
-
type: "text",
|
|
1651
|
-
text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`,
|
|
1652
|
-
},
|
|
1653
|
-
],
|
|
1654
|
-
});
|
|
1655
|
-
}
|
|
1656
|
-
if (partialOutput) {
|
|
1657
|
-
// Timeout with partial output — return as success with note
|
|
1658
|
-
return trackResponse("ctx_execute", {
|
|
1659
|
-
content: [
|
|
1660
|
-
{
|
|
1661
|
-
type: "text",
|
|
1662
|
-
text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`,
|
|
1663
|
-
},
|
|
1664
|
-
],
|
|
1665
|
-
});
|
|
1666
|
-
}
|
|
1667
|
-
return trackResponse("ctx_execute", {
|
|
1668
|
-
content: [
|
|
1669
|
-
{
|
|
1670
|
-
type: "text",
|
|
1671
|
-
text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`,
|
|
1672
|
-
},
|
|
1673
|
-
],
|
|
1674
|
-
isError: true,
|
|
1675
|
-
});
|
|
1676
|
-
}
|
|
1677
|
-
if (result.exitCode !== 0) {
|
|
1678
|
-
const { isError, output } = classifyNonZeroExit({
|
|
1679
|
-
language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
|
|
1680
|
-
});
|
|
1681
|
-
if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
|
|
1682
|
-
trackIndexed(Buffer.byteLength(output));
|
|
1683
|
-
return trackResponse("ctx_execute", {
|
|
1684
|
-
content: [
|
|
1685
|
-
{ type: "text", text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` },
|
|
1686
|
-
],
|
|
1687
|
-
isError,
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
// Auto-index large error output into FTS5 — no data loss
|
|
1691
|
-
if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
|
|
1692
|
-
trackIndexed(Buffer.byteLength(output));
|
|
1693
|
-
return trackResponse("ctx_execute", {
|
|
1694
|
-
content: [
|
|
1695
|
-
{ type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` },
|
|
1696
|
-
],
|
|
1697
|
-
isError,
|
|
1698
|
-
});
|
|
1699
|
-
}
|
|
1700
|
-
return trackResponse("ctx_execute", {
|
|
1701
|
-
content: [
|
|
1702
|
-
{ type: "text", text: `${echo}${output}` },
|
|
1703
|
-
],
|
|
1704
|
-
isError,
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
const stdout = result.stdout || "(no output)";
|
|
1708
|
-
// Intent-driven search: if intent provided and output is large enough
|
|
1709
|
-
if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
|
|
1710
|
-
trackIndexed(Buffer.byteLength(stdout));
|
|
1711
|
-
return trackResponse("ctx_execute", {
|
|
1712
|
-
content: [
|
|
1713
|
-
{ type: "text", text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` },
|
|
1714
|
-
],
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
// Auto-index large stdout into FTS5 — return pointer, not raw content
|
|
1718
|
-
if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
|
|
1719
|
-
const indexed = indexStdout(stdout, `execute:${language}`);
|
|
1720
|
-
// Prepend echo to the first text content so provenance still surfaces
|
|
1721
|
-
const echoed = {
|
|
1722
|
-
...indexed,
|
|
1723
|
-
content: indexed.content.map((c, i) => i === 0 && c.type === "text"
|
|
1724
|
-
? { ...c, text: `${echo}${c.text}` }
|
|
1725
|
-
: c),
|
|
1726
|
-
};
|
|
1727
|
-
return trackResponse("ctx_execute", echoed);
|
|
1728
|
-
}
|
|
1729
|
-
return trackResponse("ctx_execute", {
|
|
1730
|
-
content: [
|
|
1731
|
-
{ type: "text", text: `${echo}${stdout}` },
|
|
1732
|
-
],
|
|
1733
|
-
});
|
|
1734
|
-
}
|
|
1735
|
-
catch (err) {
|
|
1736
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1737
|
-
return trackResponse("ctx_execute", {
|
|
1738
|
-
content: [
|
|
1739
|
-
{ type: "text", text: `Runtime error: ${message}` },
|
|
1740
|
-
],
|
|
1741
|
-
isError: true,
|
|
1742
|
-
});
|
|
1743
|
-
}
|
|
1744
|
-
});
|
|
1745
|
-
// ─────────────────────────────────────────────────────────
|
|
1746
|
-
// Helper: index stdout into FTS5 knowledge base
|
|
1747
|
-
// ─────────────────────────────────────────────────────────
|
|
1748
|
-
function indexStdout(stdout, source) {
|
|
1749
|
-
const store = getStore();
|
|
1750
|
-
trackIndexed(Buffer.byteLength(stdout));
|
|
1751
|
-
const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
|
|
1752
|
-
return {
|
|
1753
|
-
content: [
|
|
1754
|
-
{
|
|
1755
|
-
type: "text",
|
|
1756
|
-
text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`,
|
|
1757
|
-
},
|
|
1758
|
-
],
|
|
1759
|
-
};
|
|
1760
|
-
}
|
|
1761
|
-
// ─────────────────────────────────────────────────────────
|
|
1762
|
-
// Helper: intent-driven search on execution output
|
|
1763
|
-
// ─────────────────────────────────────────────────────────
|
|
1764
|
-
const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines
|
|
1765
|
-
const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, return pointer
|
|
1766
|
-
function intentSearch(stdout, intent, source, maxResults = 5) {
|
|
1767
|
-
const totalLines = stdout.split("\n").length;
|
|
1768
|
-
const totalBytes = Buffer.byteLength(stdout);
|
|
1769
|
-
// Index into the PERSISTENT store so user can ctx_search() later
|
|
1770
|
-
const persistent = getStore();
|
|
1771
|
-
const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
|
|
1772
|
-
// Search the persistent store directly (porter → trigram → fuzzy)
|
|
1773
|
-
let results = persistent.searchWithFallback(intent, maxResults, source);
|
|
1774
|
-
// Extract distinctive terms as vocabulary hints for the LLM
|
|
1775
|
-
const distinctiveTerms = persistent.getDistinctiveTerms(indexed.sourceId);
|
|
1776
|
-
if (results.length === 0) {
|
|
1777
|
-
const lines = [
|
|
1778
|
-
`Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
|
|
1779
|
-
`No sections matched intent "${intent}" in ${totalLines}-line output (${(totalBytes / 1024).toFixed(1)}KB).`,
|
|
1780
|
-
];
|
|
1781
|
-
if (distinctiveTerms.length > 0) {
|
|
1782
|
-
lines.push("");
|
|
1783
|
-
lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
|
|
1784
|
-
}
|
|
1785
|
-
lines.push("");
|
|
1786
|
-
lines.push("Use ctx_search(queries: [...]) to explore the indexed content.");
|
|
1787
|
-
return lines.join("\n");
|
|
1788
|
-
}
|
|
1789
|
-
// Return ONLY titles + first-line previews — not full content
|
|
1790
|
-
const lines = [
|
|
1791
|
-
`Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`,
|
|
1792
|
-
`${results.length} sections matched "${intent}" (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB):`,
|
|
1793
|
-
"",
|
|
1794
|
-
];
|
|
1795
|
-
for (const r of results) {
|
|
1796
|
-
const preview = r.content.split("\n")[0].slice(0, 120);
|
|
1797
|
-
lines.push(` - ${r.title}: ${preview}`);
|
|
1798
|
-
}
|
|
1799
|
-
if (distinctiveTerms.length > 0) {
|
|
1800
|
-
lines.push("");
|
|
1801
|
-
lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
|
|
1802
|
-
}
|
|
1803
|
-
lines.push("");
|
|
1804
|
-
lines.push("Use ctx_search(queries: [...]) to retrieve full content of any section.");
|
|
1805
|
-
return lines.join("\n");
|
|
1806
|
-
}
|
|
1807
|
-
// ─────────────────────────────────────────────────────────
|
|
1808
|
-
// Tool: execute_file
|
|
1809
|
-
// ─────────────────────────────────────────────────────────
|
|
1810
|
-
server.registerTool("ctx_execute_file", {
|
|
1811
|
-
// #852: the host's MCP approval prompt renders only the tool name/title +
|
|
1812
|
-
// raw args — the title is the one server-controlled signal, so make it
|
|
1813
|
-
// unambiguously announce code execution + file read for the reviewer.
|
|
1814
|
-
title: "Run code over a file (executes code, reads the given path)",
|
|
1815
|
-
// #846: runs arbitrary code over a file in a sandbox with full network access.
|
|
1816
|
-
annotations: {
|
|
1817
|
-
readOnlyHint: false,
|
|
1818
|
-
destructiveHint: true,
|
|
1819
|
-
idempotentHint: false,
|
|
1820
|
-
openWorldHint: true,
|
|
1821
|
-
},
|
|
1822
|
-
description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversation — the file bytes stay in the sandbox.
|
|
1823
|
-
|
|
1824
|
-
Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable.
|
|
1825
|
-
|
|
1826
|
-
WHEN:
|
|
1827
|
-
- You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it
|
|
1828
|
-
- The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim
|
|
1829
|
-
- The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work
|
|
1830
|
-
- The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search
|
|
1831
|
-
|
|
1832
|
-
WHEN NOT:
|
|
1833
|
-
- You intend to EDIT the file — use Read so the subsequent Edit can match the exact text
|
|
1834
|
-
- You only need one specific line and you know its offset — Read with offset/limit is the simplest path
|
|
1835
|
-
- The file is small AND you will consume all of it for understanding/editing — Read directly
|
|
1836
|
-
|
|
1837
|
-
RETURNS:
|
|
1838
|
-
Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout.
|
|
1839
|
-
|
|
1840
|
-
EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))")
|
|
1841
|
-
EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`,
|
|
1842
|
-
inputSchema: z.object({
|
|
1843
|
-
path: z
|
|
1844
|
-
.string()
|
|
1845
|
-
.describe("Absolute file path or relative to project root"),
|
|
1846
|
-
language: z
|
|
1847
|
-
.enum([
|
|
1848
|
-
"javascript",
|
|
1849
|
-
"typescript",
|
|
1850
|
-
"python",
|
|
1851
|
-
"shell",
|
|
1852
|
-
"ruby",
|
|
1853
|
-
"go",
|
|
1854
|
-
"rust",
|
|
1855
|
-
"php",
|
|
1856
|
-
"perl",
|
|
1857
|
-
"r",
|
|
1858
|
-
"elixir",
|
|
1859
|
-
"csharp",
|
|
1860
|
-
])
|
|
1861
|
-
.describe("Runtime language"),
|
|
1862
|
-
code: z
|
|
1863
|
-
.string()
|
|
1864
|
-
.describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine."),
|
|
1865
|
-
timeout: z
|
|
1866
|
-
.coerce.number()
|
|
1867
|
-
.optional()
|
|
1868
|
-
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."),
|
|
1869
|
-
intent: z
|
|
1870
|
-
.string()
|
|
1871
|
-
.optional()
|
|
1872
|
-
.describe("What you're looking for in the output. When provided and output is large (>5KB), " +
|
|
1873
|
-
"returns only matching sections via BM25 search instead of truncated output."),
|
|
1874
|
-
}),
|
|
1875
|
-
}, async ({ path, language, code, timeout, intent }) => {
|
|
1876
|
-
// Security (#852): confine the processed file to the project root so
|
|
1877
|
-
// ctx_execute_file cannot be used to escape the host's sandbox/permission
|
|
1878
|
-
// controls. Runs before the deny-glob check — boundary first, then policy.
|
|
1879
|
-
const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
|
|
1880
|
-
if (boundaryDenied)
|
|
1881
|
-
return boundaryDenied;
|
|
1882
|
-
// Security: check file path against Read deny patterns
|
|
1883
|
-
const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
|
|
1884
|
-
if (pathDenied)
|
|
1885
|
-
return pathDenied;
|
|
1886
|
-
// Security: check code parameter against Bash deny patterns
|
|
1887
|
-
if (language === "shell") {
|
|
1888
|
-
const codeDenied = checkDenyPolicy(code, "execute_file");
|
|
1889
|
-
if (codeDenied)
|
|
1890
|
-
return codeDenied;
|
|
1891
|
-
}
|
|
1892
|
-
else {
|
|
1893
|
-
const codeDenied = checkNonShellDenyPolicy(code, language, "execute_file");
|
|
1894
|
-
if (codeDenied)
|
|
1895
|
-
return codeDenied;
|
|
1896
|
-
}
|
|
1897
|
-
try {
|
|
1898
|
-
const effTimeout = resolveExecTimeout(timeout);
|
|
1899
|
-
const result = await executor.executeFile({
|
|
1900
|
-
path,
|
|
1901
|
-
language,
|
|
1902
|
-
code,
|
|
1903
|
-
timeout: effTimeout,
|
|
1904
|
-
});
|
|
1905
|
-
// Echo path + executed source code before stdout for audit/debug
|
|
1906
|
-
// (Issues #717 + #736).
|
|
1907
|
-
const echo = buildExecuteEcho(language, code, path);
|
|
1908
|
-
if (result.timedOut) {
|
|
1909
|
-
return trackResponse("ctx_execute_file", {
|
|
1910
|
-
content: [
|
|
1911
|
-
{
|
|
1912
|
-
type: "text",
|
|
1913
|
-
text: `${echo}Timed out processing ${path} after ${effTimeout}ms`,
|
|
1914
|
-
},
|
|
1915
|
-
],
|
|
1916
|
-
isError: true,
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
if (result.exitCode !== 0) {
|
|
1920
|
-
const { isError, output } = classifyNonZeroExit({
|
|
1921
|
-
language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr,
|
|
1922
|
-
});
|
|
1923
|
-
if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) {
|
|
1924
|
-
trackIndexed(Buffer.byteLength(output));
|
|
1925
|
-
return trackResponse("ctx_execute_file", {
|
|
1926
|
-
content: [
|
|
1927
|
-
{ type: "text", text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` },
|
|
1928
|
-
],
|
|
1929
|
-
isError,
|
|
1930
|
-
});
|
|
1931
|
-
}
|
|
1932
|
-
// Auto-index large error output into FTS5 — no data loss
|
|
1933
|
-
if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) {
|
|
1934
|
-
trackIndexed(Buffer.byteLength(output));
|
|
1935
|
-
return trackResponse("ctx_execute_file", {
|
|
1936
|
-
content: [
|
|
1937
|
-
{ type: "text", text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` },
|
|
1938
|
-
],
|
|
1939
|
-
isError,
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
|
-
return trackResponse("ctx_execute_file", {
|
|
1943
|
-
content: [
|
|
1944
|
-
{ type: "text", text: `${echo}${output}` },
|
|
1945
|
-
],
|
|
1946
|
-
isError,
|
|
1947
|
-
});
|
|
1948
|
-
}
|
|
1949
|
-
const stdout = result.stdout || "(no output)";
|
|
1950
|
-
if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) {
|
|
1951
|
-
trackIndexed(Buffer.byteLength(stdout));
|
|
1952
|
-
return trackResponse("ctx_execute_file", {
|
|
1953
|
-
content: [
|
|
1954
|
-
{ type: "text", text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` },
|
|
1955
|
-
],
|
|
1956
|
-
});
|
|
1957
|
-
}
|
|
1958
|
-
// Auto-index large stdout into FTS5 — return pointer, not raw content
|
|
1959
|
-
if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) {
|
|
1960
|
-
const indexed = indexStdout(stdout, `file:${path}`);
|
|
1961
|
-
const echoed = {
|
|
1962
|
-
...indexed,
|
|
1963
|
-
content: indexed.content.map((c, i) => i === 0 && c.type === "text"
|
|
1964
|
-
? { ...c, text: `${echo}${c.text}` }
|
|
1965
|
-
: c),
|
|
1966
|
-
};
|
|
1967
|
-
return trackResponse("ctx_execute_file", echoed);
|
|
1968
|
-
}
|
|
1969
|
-
return trackResponse("ctx_execute_file", {
|
|
1970
|
-
content: [
|
|
1971
|
-
{ type: "text", text: `${echo}${stdout}` },
|
|
1972
|
-
],
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
catch (err) {
|
|
1976
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1977
|
-
return trackResponse("ctx_execute_file", {
|
|
1978
|
-
content: [
|
|
1979
|
-
{ type: "text", text: `Runtime error: ${message}` },
|
|
1980
|
-
],
|
|
1981
|
-
isError: true,
|
|
1982
|
-
});
|
|
1983
|
-
}
|
|
1984
|
-
});
|
|
1985
|
-
// ─────────────────────────────────────────────────────────
|
|
1986
|
-
// Tool: index
|
|
1987
|
-
// ─────────────────────────────────────────────────────────
|
|
1988
|
-
server.registerTool("ctx_index", {
|
|
1989
|
-
title: "Index Content",
|
|
1990
|
-
// #846: writes content into the local FTS5 store (additive, not destructive;
|
|
1991
|
-
// re-indexing the same content adds rows, so not idempotent). No network.
|
|
1992
|
-
annotations: {
|
|
1993
|
-
readOnlyHint: false,
|
|
1994
|
-
destructiveHint: false,
|
|
1995
|
-
idempotentHint: false,
|
|
1996
|
-
openWorldHint: false,
|
|
1997
|
-
},
|
|
1998
|
-
description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated.
|
|
1999
|
-
|
|
2000
|
-
WHEN:
|
|
2001
|
-
- Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples)
|
|
2002
|
-
- API references (endpoint details, parameter specs, response schemas)
|
|
2003
|
-
- MCP tools/list output (exact tool signatures and descriptions)
|
|
2004
|
-
- Skill prompts and instructions that are too large to keep verbatim in conversation
|
|
2005
|
-
- README files, migration guides, changelog entries
|
|
2006
|
-
- Any content with code examples you may need to reference precisely later
|
|
2007
|
-
|
|
2008
|
-
WHEN NOT:
|
|
2009
|
-
- Log files, test output, CSV, or build output — use ctx_execute_file, which processes in-sandbox without persisting bytes
|
|
2010
|
-
- Single-use ephemeral content you will not query later — keep it inline if it fits, or ctx_execute_file it
|
|
2011
|
-
|
|
2012
|
-
RETURNS:
|
|
2013
|
-
Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "<label>"). When \`path\` is provided, a content hash is stored so ctx_search results auto-flag staleness on future calls.
|
|
2014
|
-
|
|
2015
|
-
EXAMPLE: ctx_index(content: "# React useEffect\\n\\nThe Effect Hook lets you ...", source: "react-useeffect-docs")
|
|
2016
|
-
EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
|
|
2017
|
-
inputSchema: z.object({
|
|
2018
|
-
content: z
|
|
2019
|
-
.string()
|
|
2020
|
-
.optional()
|
|
2021
|
-
.describe("Raw text/markdown to index. Provide this OR path, not both."),
|
|
2022
|
-
path: z
|
|
2023
|
-
.string()
|
|
2024
|
-
.optional()
|
|
2025
|
-
.describe("File OR directory path to read and index (content never enters context). Provide this OR content. Directory paths trigger a bounded recursive walk (#687)."),
|
|
2026
|
-
source: z
|
|
2027
|
-
.string()
|
|
2028
|
-
.optional()
|
|
2029
|
-
.describe("Label for the indexed content (e.g., 'Context7: React useEffect', 'Skill: frontend-design')"),
|
|
2030
|
-
include: z.array(z.string()).optional().describe("Directory-only: glob patterns to include (default: all matching extensions)."),
|
|
2031
|
-
exclude: z.array(z.string()).optional().describe("Directory-only: glob patterns to exclude. Merged with defaults (node_modules, .git, dist, build, .next, coverage, .venv, __pycache__, .DS_Store)."),
|
|
2032
|
-
maxDepth: z.number().int().min(0).optional().describe("Directory-only: max recursion depth from root (default: 5)."),
|
|
2033
|
-
maxFiles: z.number().int().min(1).optional().describe("Directory-only: hard cap on files indexed (default: 200) — FTS5 blow-up guard."),
|
|
2034
|
-
extensions: z.array(z.string()).optional().describe("Directory-only: allowed file extensions (default: .md .mdx .txt .json .yaml .yml .ts .tsx .js .jsx .py .rs .go .sh)."),
|
|
2035
|
-
respectGitignore: z.boolean().optional().describe("Directory-only: apply nearest .gitignore (default: true)."),
|
|
2036
|
-
followSymlinks: z.boolean().optional().describe("Directory-only: follow directory symlinks (default: false — cycle hazard + escape risk)."),
|
|
2037
|
-
}),
|
|
2038
|
-
}, async ({ content, path, source, include, exclude, maxDepth, maxFiles, extensions, respectGitignore, followSymlinks }) => {
|
|
2039
|
-
if (!content && !path) {
|
|
2040
|
-
return trackResponse("ctx_index", {
|
|
2041
|
-
content: [
|
|
2042
|
-
{
|
|
2043
|
-
type: "text",
|
|
2044
|
-
text: "Error: Either content or path must be provided",
|
|
2045
|
-
},
|
|
2046
|
-
],
|
|
2047
|
-
isError: true,
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
// Apply Read deny-policy to prevent indexing sensitive files into the
|
|
2051
|
-
// FTS5 store, which would otherwise be queryable via ctx_search and
|
|
2052
|
-
// exfiltrate content into the model's context (issue #442). Mirrors the
|
|
2053
|
-
// check ctx_execute_file already performs.
|
|
2054
|
-
if (path) {
|
|
2055
|
-
const pathDenied = checkFilePathDenyPolicy(path, "ctx_index");
|
|
2056
|
-
if (pathDenied)
|
|
2057
|
-
return pathDenied;
|
|
2058
|
-
}
|
|
2059
|
-
try {
|
|
2060
|
-
const resolvedPath = path ? resolveProjectPath(path) : undefined;
|
|
2061
|
-
// Directory dispatch (#687, reported by @matiasduartee). When the
|
|
2062
|
-
// resolved path is a directory, walk it bounded and re-enter `index()`
|
|
2063
|
-
// per-file so the security gate at store.ts:845 (TOCTOU defense from
|
|
2064
|
-
// #442 round-3) keeps running for every file.
|
|
2065
|
-
//
|
|
2066
|
-
// Root-level symlink defense: the deny-glob check above ran on the
|
|
2067
|
-
// user-supplied `path`. If `path` is a symlink whose target lands in
|
|
2068
|
-
// a sensitive directory (e.g. `/tmp/link -> /etc`), statSync would
|
|
2069
|
-
// happily report directory and walkDirectoryDetailed would
|
|
2070
|
-
// realpathSync internally, walking /etc with the user's deny globs
|
|
2071
|
-
// bound to /tmp/link instead of the real target. Detect the symlink
|
|
2072
|
-
// with lstatSync, follow it once, and re-apply the deny check
|
|
2073
|
-
// against the realpath so the user's deny globs see the actual
|
|
2074
|
-
// walk root.
|
|
2075
|
-
if (resolvedPath && existsSync(resolvedPath)) {
|
|
2076
|
-
const lst = lstatSync(resolvedPath);
|
|
2077
|
-
if (lst.isSymbolicLink()) {
|
|
2078
|
-
let realTarget;
|
|
2079
|
-
try {
|
|
2080
|
-
realTarget = realpathSync(resolvedPath);
|
|
2081
|
-
}
|
|
2082
|
-
catch {
|
|
2083
|
-
return trackResponse("ctx_index", {
|
|
2084
|
-
content: [{ type: "text", text: "Error: symlink target could not be resolved." }],
|
|
2085
|
-
});
|
|
2086
|
-
}
|
|
2087
|
-
if (realTarget !== resolvedPath) {
|
|
2088
|
-
const realDenied = checkFilePathDenyPolicy(realTarget, "ctx_index");
|
|
2089
|
-
if (realDenied)
|
|
2090
|
-
return realDenied;
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
if (resolvedPath && existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
|
|
2095
|
-
const store = getStore();
|
|
2096
|
-
const projectDir = getProjectDir();
|
|
2097
|
-
const denyGlobs = readToolDenyPatterns("Read", projectDir);
|
|
2098
|
-
const isWin32 = process.platform === "win32";
|
|
2099
|
-
const perFileDeny = (absPath) => {
|
|
2100
|
-
try {
|
|
2101
|
-
return evaluateFilePath(absPath, denyGlobs, isWin32, projectDir).denied;
|
|
2102
|
-
}
|
|
2103
|
-
catch {
|
|
2104
|
-
return false; // fail-open consistent with checkFilePathDenyPolicy
|
|
2105
|
-
}
|
|
2106
|
-
};
|
|
2107
|
-
const dirResult = store.indexDirectory({
|
|
2108
|
-
path: resolvedPath,
|
|
2109
|
-
source: source ?? resolvedPath,
|
|
2110
|
-
attribution: currentAttribution(),
|
|
2111
|
-
perFileDeny,
|
|
2112
|
-
include,
|
|
2113
|
-
exclude,
|
|
2114
|
-
maxDepth,
|
|
2115
|
-
maxFiles,
|
|
2116
|
-
extensions,
|
|
2117
|
-
respectGitignore,
|
|
2118
|
-
followSymlinks,
|
|
2119
|
-
});
|
|
2120
|
-
const capNote = dirResult.capped
|
|
2121
|
-
? ` (cap reached — only first ${dirResult.filesIndexed} of ${dirResult.totalSeen}+ files; raise maxFiles to index more)`
|
|
2122
|
-
: "";
|
|
2123
|
-
const denyNote = dirResult.denied > 0
|
|
2124
|
-
? ` (${dirResult.denied} file${dirResult.denied === 1 ? "" : "s"} blocked by Read deny policy)`
|
|
2125
|
-
: "";
|
|
2126
|
-
const failNote = dirResult.failed > 0
|
|
2127
|
-
? ` (${dirResult.failed} file${dirResult.failed === 1 ? "" : "s"} failed to read)`
|
|
2128
|
-
: "";
|
|
2129
|
-
return trackResponse("ctx_index", {
|
|
2130
|
-
content: [
|
|
2131
|
-
{
|
|
2132
|
-
type: "text",
|
|
2133
|
-
text: `Indexed ${dirResult.filesIndexed} file${dirResult.filesIndexed === 1 ? "" : "s"} (${dirResult.totalChunks} sections) from directory: ${dirResult.label}${capNote}${denyNote}${failNote}\nUse ctx_search(queries: ["..."]) to query this content.`,
|
|
2134
|
-
},
|
|
2135
|
-
],
|
|
2136
|
-
});
|
|
2137
|
-
}
|
|
2138
|
-
// Track the raw bytes being indexed (content or file)
|
|
2139
|
-
if (content)
|
|
2140
|
-
trackIndexed(Buffer.byteLength(content));
|
|
2141
|
-
else if (resolvedPath) {
|
|
2142
|
-
try {
|
|
2143
|
-
const fs = await import("fs");
|
|
2144
|
-
trackIndexed(fs.readFileSync(resolvedPath).byteLength);
|
|
2145
|
-
}
|
|
2146
|
-
catch { /* ignore — file read errors handled by store */ }
|
|
2147
|
-
}
|
|
2148
|
-
const store = getStore();
|
|
2149
|
-
const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
|
|
2150
|
-
return trackResponse("ctx_index", {
|
|
2151
|
-
content: [
|
|
2152
|
-
{
|
|
2153
|
-
type: "text",
|
|
2154
|
-
text: `Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from: ${result.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${result.label}" to scope results.`,
|
|
2155
|
-
},
|
|
2156
|
-
],
|
|
2157
|
-
});
|
|
2158
|
-
}
|
|
2159
|
-
catch (err) {
|
|
2160
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2161
|
-
return trackResponse("ctx_index", {
|
|
2162
|
-
content: [
|
|
2163
|
-
{ type: "text", text: `Index error: ${message}` },
|
|
2164
|
-
],
|
|
2165
|
-
isError: true,
|
|
2166
|
-
});
|
|
2167
|
-
}
|
|
2168
|
-
});
|
|
2169
|
-
// ─────────────────────────────────────────────────────────
|
|
2170
|
-
// Tool: search — progressive throttling
|
|
2171
|
-
// ─────────────────────────────────────────────────────────
|
|
2172
|
-
// Track search calls per N-second window for progressive throttling.
|
|
2173
|
-
// Defaults preserve the historical behavior (60s window, soft-cap at 3
|
|
2174
|
-
// calls, hard-block at 8). All three thresholds are overridable via env
|
|
2175
|
-
// vars so users can loosen or tighten the policy without forking. Invalid
|
|
2176
|
-
// values (non-positive numbers, NaN) fall back to the default to avoid
|
|
2177
|
-
// silently disabling the protection.
|
|
2178
|
-
function readPositiveEnv(name, defaultValue) {
|
|
2179
|
-
const raw = process.env[name];
|
|
2180
|
-
if (!raw)
|
|
2181
|
-
return defaultValue;
|
|
2182
|
-
const parsed = Number(raw);
|
|
2183
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
2184
|
-
}
|
|
2185
|
-
const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
|
|
2186
|
-
const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
|
|
2187
|
-
const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
|
|
2188
|
-
// #769: progressive throttle bucketed PER agent-context, not machine-global.
|
|
2189
|
-
// Concurrent subagents share ONE MCP server process; a single global counter
|
|
2190
|
-
// summed their independent searches into one budget and hard-blocked
|
|
2191
|
-
// legitimate parallel fan-out. The guard keys each actor's window separately
|
|
2192
|
-
// so single-actor flood protection is preserved while fan-out is not starved.
|
|
2193
|
-
const searchFloodGuard = new FloodGuard({
|
|
2194
|
-
windowMs: SEARCH_WINDOW_MS,
|
|
2195
|
-
softCapAfter: SEARCH_MAX_RESULTS_AFTER,
|
|
2196
|
-
blockAfter: SEARCH_BLOCK_AFTER,
|
|
2197
|
-
});
|
|
2198
|
-
/**
|
|
2199
|
-
* Per-agent flood-guard key. Each concurrent subagent in a Claude Code
|
|
2200
|
-
* Task/Workflow fan-out runs under its own session id (written to SessionDB
|
|
2201
|
-
* via hooks), so currentAttribution().sessionId is the per-agent discriminator
|
|
2202
|
-
* already available MCP-side. Falls back to a single shared bucket when no
|
|
2203
|
-
* identity is resolvable (preserves today's single-threaded behaviour).
|
|
2204
|
-
*/
|
|
2205
|
-
function searchFloodGuardKey() {
|
|
2206
|
-
try {
|
|
2207
|
-
return currentAttribution()?.sessionId ?? "__default__";
|
|
2208
|
-
}
|
|
2209
|
-
catch {
|
|
2210
|
-
return "__default__";
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
/**
|
|
2214
|
-
* Defensive coercion: parse stringified JSON arrays, AND lift a bare
|
|
2215
|
-
* non-empty string into a single-element array.
|
|
2216
|
-
*
|
|
2217
|
-
* Two shapes show up from the wild:
|
|
2218
|
-
* 1. `"[\"a\",\"b\"]"` — Claude Code double-serialization bug
|
|
2219
|
-
* (https://github.com/anthropics/claude-code/issues/34520).
|
|
2220
|
-
* 2. `"single query"` — some LLM providers / OpenCode's native plugin
|
|
2221
|
-
* bridge deliver a single string when the schema expects `string[]`
|
|
2222
|
-
* (issue #627). v1.0.139 (#621) made the bridge run the Zod schema,
|
|
2223
|
-
* so this now surfaces as `Expected array, received string`. The
|
|
2224
|
-
* ergonomic recovery is to treat it as `["single query"]`.
|
|
2225
|
-
*
|
|
2226
|
-
* An empty string is intentionally NOT lifted — empty input should still
|
|
2227
|
-
* fail Zod's `.min(1)` check rather than masquerade as `[""]`.
|
|
2228
|
-
*/
|
|
2229
|
-
function coerceJsonArray(val) {
|
|
2230
|
-
if (typeof val === "string") {
|
|
2231
|
-
const trimmed = val.trim();
|
|
2232
|
-
if (trimmed.length === 0)
|
|
2233
|
-
return val; // let zod produce "non-empty" error
|
|
2234
|
-
try {
|
|
2235
|
-
const parsed = JSON.parse(val);
|
|
2236
|
-
if (Array.isArray(parsed))
|
|
2237
|
-
return parsed;
|
|
2238
|
-
}
|
|
2239
|
-
catch { /* fall through — not JSON, treat as bare-string lift */ }
|
|
2240
|
-
// Bare-string lift (#627): single query delivered as a plain string.
|
|
2241
|
-
return [val];
|
|
2242
|
-
}
|
|
2243
|
-
return val;
|
|
2244
|
-
}
|
|
2245
|
-
/**
|
|
2246
|
-
* Defensive coercion: accept the string literals "true"/"false" as
|
|
2247
|
-
* booleans. The OpenCode native plugin bridge (and several LLM providers'
|
|
2248
|
-
* tool-call JSON) stringifies primitives — `background:"false"` instead
|
|
2249
|
-
* of `background:false`, `confirm:"true"` instead of `confirm:true`.
|
|
2250
|
-
*
|
|
2251
|
-
* We deliberately do NOT use `z.coerce.boolean()` for boolean fields:
|
|
2252
|
-
* `Boolean("false")` is `true`, so Zod's coerce path silently flips the
|
|
2253
|
-
* meaning. This helper recognises only the documented literal forms and
|
|
2254
|
-
* passes anything else through untouched so Zod surfaces the right error.
|
|
2255
|
-
*
|
|
2256
|
-
* Fixes #627.
|
|
2257
|
-
*/
|
|
2258
|
-
function coerceBoolean(val) {
|
|
2259
|
-
if (typeof val === "string") {
|
|
2260
|
-
const t = val.trim().toLowerCase();
|
|
2261
|
-
if (t === "true")
|
|
2262
|
-
return true;
|
|
2263
|
-
if (t === "false")
|
|
2264
|
-
return false;
|
|
2265
|
-
}
|
|
2266
|
-
return val;
|
|
2267
|
-
}
|
|
2268
|
-
/**
|
|
2269
|
-
* Coerce commands array: handles double-serialization AND the case where
|
|
2270
|
-
* the model passes plain command strings instead of {label, command} objects.
|
|
2271
|
-
*/
|
|
2272
|
-
function coerceCommandsArray(val) {
|
|
2273
|
-
const arr = coerceJsonArray(val);
|
|
2274
|
-
if (Array.isArray(arr)) {
|
|
2275
|
-
return arr.map((item, i) => typeof item === "string" ? { label: `cmd_${i + 1}`, command: item } : item);
|
|
2276
|
-
}
|
|
2277
|
-
return arr;
|
|
2278
|
-
}
|
|
2279
|
-
server.registerTool("ctx_search", {
|
|
2280
|
-
title: "Search Indexed Content",
|
|
2281
|
-
// #846: read-only query over the local FTS5 store. No mutation, no network.
|
|
2282
|
-
annotations: {
|
|
2283
|
-
readOnlyHint: true,
|
|
2284
|
-
destructiveHint: false,
|
|
2285
|
-
idempotentHint: true,
|
|
2286
|
-
openWorldHint: false,
|
|
2287
|
-
},
|
|
2288
|
-
description: `Search a unified knowledge base with a multi-strategy ranking pipeline. Two parallel matchers run on every query: a Porter-stemming matcher ("caching" finds "cached", "caches", "cach") and a trigram-substring matcher ("useEff" finds "useEffect"). Their ranked lists are merged via Reciprocal Rank Fusion, so a document that ranks well in both surfaces above one that wins only on a single strategy. Multi-term queries get an additional proximity-rerank pass that boosts passages where the query terms appear close together. Typos are corrected via Levenshtein distance and re-searched. Result snippets are window-extracted around the matched terms, not blindly truncated.
|
|
2289
|
-
|
|
2290
|
-
The knowledge base is unified: queries reach indexed content you stored (ctx_index, ctx_fetch_and_index, ctx_batch_execute output) AND auto-captured session memory written by hooks (decisions, errors, blockers, plans, user prompts, rejected approaches, tool failures, compaction guides — 26 event categories). File-backed sources carry a content hash and auto-flag staleness when the source file changes.
|
|
2291
|
-
|
|
2292
|
-
WHEN:
|
|
2293
|
-
- You want to recall something that exists in storage (recently indexed content, prior session events, auto-memory) instead of re-reading raw sources
|
|
2294
|
-
- You have multiple related questions about the same body of knowledge — batch every question into one call (the ranking pipeline runs per-query but the round-trip cost is paid once)
|
|
2295
|
-
- You want to scope the query to one labelled source (pass \`source\` — partial match is fine)
|
|
2296
|
-
- You want a chronological view across current session + prior sessions + persistent auto-memory (pass \`sort: "timeline"\` — the default \`relevance\` mode only ranks within the current session)
|
|
2297
|
-
- You want to filter ranked results by content shape (pass \`contentType: "code"\` to surface implementation snippets or \`contentType: "prose"\` to surface explanations)
|
|
2298
|
-
|
|
2299
|
-
WHEN NOT:
|
|
2300
|
-
- The data you want to query has never been stored in the knowledge base AND no session memory has accumulated around it — capture first (run a gather-and-index call), then come back here to query
|
|
2301
|
-
- You have one ad-hoc question against data that is not in the knowledge base — answer it inline by running code in the sandbox tool; one round-trip instead of capture-then-query
|
|
2302
|
-
|
|
2303
|
-
RETURNS:
|
|
2304
|
-
Per-query ranked sections with window-extracted snippets. Use 2-4 specific technical terms per query. Common session-memory source labels: \`decision\` (user corrections / preferences), \`error\` and \`error-resolution\` (past failures + their fixes), \`blocker\`, \`plan\`, \`user-prompt\`, \`rejected-approach\`, \`compaction\` (post-compact session guide). See ctx_stats for live category counts. Each response carries a throttle counter (call #N/M in the rolling time window); results taper toward the soft cap and calls block after the hard cap. Tune via CONTEXT_MODE_SEARCH_WINDOW_MS, CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER, CONTEXT_MODE_SEARCH_BLOCK_AFTER.
|
|
2305
|
-
|
|
2306
|
-
EXAMPLE: ctx_search(queries: ["root cause", "proposed fix", "test coverage"], source: "issue-#683")
|
|
2307
|
-
EXAMPLE: ctx_search(queries: ["what did we decide about caching"], source: "decision", sort: "timeline")
|
|
2308
|
-
EXAMPLE: ctx_search(queries: ["useEffect cleanup pattern"], source: "react-docs", contentType: "code", limit: 5)
|
|
2309
|
-
EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blockers"], sort: "timeline")`,
|
|
2310
|
-
// Schema construction is centralised in `src/search/ctx-search-schema.ts`
|
|
2311
|
-
// so the conditional `project` field (only registered when the host runs
|
|
2312
|
-
// in shared-DB mode, `CONTEXT_MODE_PROJECT_DIR` set at module load) is a
|
|
2313
|
-
// hard property of the tool surface — not a runtime hint. Fixes #737.
|
|
2314
|
-
inputSchema: buildCtxSearchInputSchema(CTX_SEARCH_SHARED_MODE),
|
|
2315
|
-
}, async (params) => {
|
|
2316
|
-
try {
|
|
2317
|
-
const store = getStore();
|
|
2318
|
-
const sort = params.sort || "relevance";
|
|
2319
|
-
// Guard: redirect when the index is empty — ctx_search is a follow-up
|
|
2320
|
-
// tool that requires prior indexing. Skip for timeline mode (SessionDB/auto-memory may have data).
|
|
2321
|
-
if (sort !== "timeline" && store.getStats().chunks === 0) {
|
|
2322
|
-
return trackResponse("ctx_search", {
|
|
2323
|
-
content: [{
|
|
2324
|
-
type: "text",
|
|
2325
|
-
text: "Knowledge base is empty — no content has been indexed yet.\n\n" +
|
|
2326
|
-
"ctx_search is a follow-up tool that queries previously indexed content. " +
|
|
2327
|
-
"To gather and index content first, use:\n" +
|
|
2328
|
-
" • ctx_batch_execute(commands, queries) — run commands, auto-index output, and search in one call\n" +
|
|
2329
|
-
" • ctx_fetch_and_index(url) — fetch a URL, index it, then search with ctx_search\n" +
|
|
2330
|
-
" • ctx_index(content, source) — manually index text content\n\n" +
|
|
2331
|
-
"After indexing, ctx_search becomes available for follow-up queries.",
|
|
2332
|
-
}],
|
|
2333
|
-
isError: true,
|
|
2334
|
-
});
|
|
2335
|
-
}
|
|
2336
|
-
const raw = params;
|
|
2337
|
-
// Normalize: accept both query (string) and queries (array)
|
|
2338
|
-
const queryList = [];
|
|
2339
|
-
if (Array.isArray(raw.queries) && raw.queries.length > 0) {
|
|
2340
|
-
queryList.push(...raw.queries);
|
|
2341
|
-
}
|
|
2342
|
-
else if (typeof raw.query === "string" && raw.query.length > 0) {
|
|
2343
|
-
queryList.push(raw.query);
|
|
2344
|
-
}
|
|
2345
|
-
if (queryList.length === 0) {
|
|
2346
|
-
return trackResponse("ctx_search", {
|
|
2347
|
-
content: [{ type: "text", text: "Error: provide query or queries." }],
|
|
2348
|
-
isError: true,
|
|
2349
|
-
});
|
|
2350
|
-
}
|
|
2351
|
-
const { limit = 3, source, contentType, project } = params;
|
|
2352
|
-
// Resolve the per-project scope (#737). When shared-DB mode is off the
|
|
2353
|
-
// resolver returns `undefined` and `project` is silently ignored — the
|
|
2354
|
-
// per-project DB is naturally isolated by directory hash, so there is
|
|
2355
|
-
// nothing for an in-process filter to do.
|
|
2356
|
-
const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
|
|
2357
|
-
// Progressive throttling: track calls per agent-context window (#769).
|
|
2358
|
-
const now = Date.now();
|
|
2359
|
-
const flood = searchFloodGuard.record(searchFloodGuardKey(), now);
|
|
2360
|
-
const searchCallCount = flood.count;
|
|
2361
|
-
// After SEARCH_BLOCK_AFTER calls (for THIS agent): refuse
|
|
2362
|
-
if (flood.blocked) {
|
|
2363
|
-
return trackResponse("ctx_search", {
|
|
2364
|
-
content: [{
|
|
2365
|
-
type: "text",
|
|
2366
|
-
text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - flood.windowStart) / 1000)}s. ` +
|
|
2367
|
-
"You're flooding context. STOP making individual search calls. " +
|
|
2368
|
-
"Use ctx_batch_execute(commands, queries) for your next research step.",
|
|
2369
|
-
}],
|
|
2370
|
-
isError: true,
|
|
2371
|
-
});
|
|
2372
|
-
}
|
|
2373
|
-
// Determine per-query result limit based on throttle level
|
|
2374
|
-
const effectiveLimit = flood.softCapped
|
|
2375
|
-
? 1 // after soft cap: only 1 result per query
|
|
2376
|
-
: Math.min(limit, 2); // normal: max 2
|
|
2377
|
-
const MAX_TOTAL = 40 * 1024; // 40KB total cap
|
|
2378
|
-
let totalSize = 0;
|
|
2379
|
-
const sections = [];
|
|
2380
|
-
// Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
|
|
2381
|
-
// Issue #737: also open in relevance mode when a string `projectScope`
|
|
2382
|
-
// is in play — the 2-step IN-clause needs SessionDB to translate
|
|
2383
|
-
// `project_dir` → allow-set of session ids for the ContentStore filter.
|
|
2384
|
-
let timelineDB = null;
|
|
2385
|
-
const needsSessionDB = sort === "timeline" || typeof projectScope === "string";
|
|
2386
|
-
if (needsSessionDB) {
|
|
2387
|
-
try {
|
|
2388
|
-
const sessionsDir = getSessionDir();
|
|
2389
|
-
const projectDir = getProjectDir();
|
|
2390
|
-
const dbFile = resolveSessionDbPath({ projectDir, sessionsDir });
|
|
2391
|
-
if (existsSync(dbFile)) {
|
|
2392
|
-
timelineDB = new SessionDB({ dbPath: dbFile });
|
|
2393
|
-
}
|
|
2394
|
-
}
|
|
2395
|
-
catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
|
|
2396
|
-
}
|
|
2397
|
-
// Resolve the session-id allow-set once for the relevance-mode path —
|
|
2398
|
-
// searchAllSources resolves its own copy for timeline mode. Empty set
|
|
2399
|
-
// is preserved (means "no events for this project"), which surfaces
|
|
2400
|
-
// only legacy `session_id=''` chunks via the post-filter.
|
|
2401
|
-
let relevanceAllowSet;
|
|
2402
|
-
if (typeof projectScope === "string" && timelineDB) {
|
|
2403
|
-
try {
|
|
2404
|
-
relevanceAllowSet = new Set(timelineDB.getSessionIdsForProject(projectScope));
|
|
2405
|
-
}
|
|
2406
|
-
catch { /* best-effort */ }
|
|
2407
|
-
}
|
|
2408
|
-
const configDir = _detectedAdapter?.getConfigDir() ?? resolveClaudeConfigDir();
|
|
2409
|
-
try {
|
|
2410
|
-
for (const q of queryList) {
|
|
2411
|
-
if (totalSize > MAX_TOTAL) {
|
|
2412
|
-
sections.push(`## ${q}\n(output cap reached)\n`);
|
|
2413
|
-
continue;
|
|
2414
|
-
}
|
|
2415
|
-
let results;
|
|
2416
|
-
if (sort === "timeline") {
|
|
2417
|
-
results = searchAllSources({
|
|
2418
|
-
query: q,
|
|
2419
|
-
limit: effectiveLimit,
|
|
2420
|
-
store,
|
|
2421
|
-
sort,
|
|
2422
|
-
source,
|
|
2423
|
-
contentType,
|
|
2424
|
-
sessionDB: timelineDB,
|
|
2425
|
-
projectDir: getProjectDir(),
|
|
2426
|
-
configDir,
|
|
2427
|
-
adapter: _detectedAdapter ?? undefined,
|
|
2428
|
-
projectScope,
|
|
2429
|
-
});
|
|
2430
|
-
}
|
|
2431
|
-
else {
|
|
2432
|
-
results = store.searchWithFallback(q, effectiveLimit, source, contentType, "like", relevanceAllowSet);
|
|
2433
|
-
}
|
|
2434
|
-
if (results.length === 0) {
|
|
2435
|
-
sections.push(`## ${q}\nNo results found.`);
|
|
2436
|
-
continue;
|
|
2437
|
-
}
|
|
2438
|
-
const formatted = results
|
|
2439
|
-
.map((r, i) => {
|
|
2440
|
-
const origin = r.origin || "current-session";
|
|
2441
|
-
const ts = r.timestamp ? r.timestamp.slice(0, 16).replace("T", " ") : "";
|
|
2442
|
-
const header = `--- [${origin}${ts ? " | " + ts : ""} | ${r.source}] ---`;
|
|
2443
|
-
const heading = `### ${r.title}`;
|
|
2444
|
-
const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
|
|
2445
|
-
return `${header}\n${heading}\n\n${snippet}`;
|
|
2446
|
-
})
|
|
2447
|
-
.join("\n\n");
|
|
2448
|
-
sections.push(`## ${q}\n\n${formatted}`);
|
|
2449
|
-
totalSize += formatted.length;
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
finally {
|
|
2453
|
-
try {
|
|
2454
|
-
timelineDB?.close();
|
|
2455
|
-
}
|
|
2456
|
-
catch { }
|
|
2457
|
-
}
|
|
2458
|
-
let output = sections.join("\n\n---\n\n");
|
|
2459
|
-
// Report auto-refreshed stale sources
|
|
2460
|
-
if (store.lastRefreshCount > 0) {
|
|
2461
|
-
output = `> Auto-refreshed ${store.lastRefreshCount} stale source${store.lastRefreshCount > 1 ? "s" : ""} (file changed since indexing).\n\n` + output;
|
|
2462
|
-
}
|
|
2463
|
-
// Throttle counter — always surfaced so agents can pace themselves
|
|
2464
|
-
// proactively instead of discovering the limit only after results are
|
|
2465
|
-
// already truncated. Soft warning after SEARCH_MAX_RESULTS_AFTER calls;
|
|
2466
|
-
// gentle informational line before that.
|
|
2467
|
-
const throttleRemaining = Math.max(0, SEARCH_BLOCK_AFTER - searchCallCount);
|
|
2468
|
-
const softCapRemaining = Math.max(0, SEARCH_MAX_RESULTS_AFTER - searchCallCount);
|
|
2469
|
-
if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
|
|
2470
|
-
output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
|
|
2471
|
-
`Results limited to ${effectiveLimit}/query. ${throttleRemaining} call(s) remaining before block. ` +
|
|
2472
|
-
`Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
|
|
2473
|
-
}
|
|
2474
|
-
else {
|
|
2475
|
-
output += `\n\n> Throttle: call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
|
|
2476
|
-
`${softCapRemaining} call(s) before soft cap. ` +
|
|
2477
|
-
`Prefer ctx_search(queries: [...]) array form for multi-query workloads — it counts as a single call.`;
|
|
2478
|
-
}
|
|
2479
|
-
if (output.trim().length === 0) {
|
|
2480
|
-
const sources = store.listSources();
|
|
2481
|
-
const sourceList = sources.length > 0
|
|
2482
|
-
? `\nIndexed sources: ${sources.map((s) => `"${s.label}" (${s.chunkCount} sections)`).join(", ")}`
|
|
2483
|
-
: "";
|
|
2484
|
-
return trackResponse("ctx_search", {
|
|
2485
|
-
content: [{ type: "text", text: `No results found.${sourceList}` }],
|
|
2486
|
-
});
|
|
2487
|
-
}
|
|
2488
|
-
return trackResponse("ctx_search", {
|
|
2489
|
-
content: [{ type: "text", text: output }],
|
|
2490
|
-
});
|
|
2491
|
-
}
|
|
2492
|
-
catch (err) {
|
|
2493
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2494
|
-
return trackResponse("ctx_search", {
|
|
2495
|
-
content: [{ type: "text", text: `Search error: ${message}` }],
|
|
2496
|
-
isError: true,
|
|
2497
|
-
});
|
|
2498
|
-
}
|
|
2499
|
-
});
|
|
2500
|
-
// ─────────────────────────────────────────────────────────
|
|
2501
|
-
// Turndown path resolution (external dep, like better-sqlite3)
|
|
2502
|
-
// ─────────────────────────────────────────────────────────
|
|
2503
|
-
let _turndownPath = null;
|
|
2504
|
-
let _gfmPluginPath = null;
|
|
2505
|
-
function resolveTurndownPath() {
|
|
2506
|
-
if (!_turndownPath) {
|
|
2507
|
-
const require = createRequire(import.meta.url);
|
|
2508
|
-
_turndownPath = require.resolve("turndown");
|
|
2509
|
-
}
|
|
2510
|
-
return _turndownPath;
|
|
2511
|
-
}
|
|
2512
|
-
function resolveGfmPluginPath() {
|
|
2513
|
-
if (!_gfmPluginPath) {
|
|
2514
|
-
const require = createRequire(import.meta.url);
|
|
2515
|
-
_gfmPluginPath = require.resolve("turndown-plugin-gfm");
|
|
2516
|
-
}
|
|
2517
|
-
return _gfmPluginPath;
|
|
2518
|
-
}
|
|
2519
|
-
// ─────────────────────────────────────────────────────────
|
|
2520
|
-
// Tool: fetch_and_index
|
|
2521
|
-
// ─────────────────────────────────────────────────────────
|
|
2522
|
-
// Subprocess code that fetches a URL, detects Content-Type, and outputs a
|
|
2523
|
-
// __CM_CT__:<type> marker on the first line so the handler can route to the
|
|
2524
|
-
// appropriate indexing strategy. HTML is converted to markdown via Turndown.
|
|
2525
|
-
export function buildFetchCode(url, outputPath) {
|
|
2526
|
-
const turndownPath = JSON.stringify(resolveTurndownPath());
|
|
2527
|
-
const gfmPath = JSON.stringify(resolveGfmPluginPath());
|
|
2528
|
-
const escapedOutputPath = JSON.stringify(outputPath);
|
|
2529
|
-
// Embed classifyIp into the subprocess so the connect-time DNS lookup is
|
|
2530
|
-
// re-validated with the same policy as ssrfGuard. Without this, an attacker
|
|
2531
|
-
// can serve a public IP for the parent's pre-flight ssrfGuard lookup and
|
|
2532
|
-
// then a blocked IP (e.g. 169.254.169.254 IMDS) for the subprocess fetch's
|
|
2533
|
-
// own lookup — classic DNS rebinding across the parent/child boundary.
|
|
2534
|
-
//
|
|
2535
|
-
// CRITICAL: bundlers (esbuild) rename top-level identifiers — `classifyIp`
|
|
2536
|
-
// becomes e.g. `_h` in server.bundle.mjs. `classifyIp.toString()` returns
|
|
2537
|
-
// the renamed source `function _h(t){...}`, but the embedded subprocess
|
|
2538
|
-
// template references the literal name `classifyIp` (and the function's
|
|
2539
|
-
// own internal recursion is also `_h(...)`). Result: the subprocess sees
|
|
2540
|
-
// `function _h(t){...; return _h(...)}` injected, then references to
|
|
2541
|
-
// `classifyIp` blow up with `ReferenceError: classifyIp is not defined`.
|
|
2542
|
-
//
|
|
2543
|
-
// Fix: emit `var <fnName> = <fn-expr>; var classifyIp = <fnName>;`. The
|
|
2544
|
-
// named function expression preserves recursion under whatever name the
|
|
2545
|
-
// bundler chose, and the alias re-exposes the canonical `classifyIp`
|
|
2546
|
-
// identifier the rest of the embedded script depends on.
|
|
2547
|
-
const classifyIpInner = classifyIp.toString();
|
|
2548
|
-
const classifyIpFnName = classifyIp.name || "classifyIp";
|
|
2549
|
-
const classifyIpSrc = classifyIpFnName === "classifyIp"
|
|
2550
|
-
? `var classifyIp = ${classifyIpInner};`
|
|
2551
|
-
: `var ${classifyIpFnName} = ${classifyIpInner};\nvar classifyIp = ${classifyIpFnName};`;
|
|
2552
|
-
const strictMode = process.env.CTX_FETCH_STRICT === "1";
|
|
2553
|
-
return `
|
|
2554
|
-
const TurndownService = require(${turndownPath});
|
|
2555
|
-
const { gfm } = require(${gfmPath});
|
|
2556
|
-
const fs = require('fs');
|
|
2557
|
-
const dns = require('no' + 'de:dns');
|
|
2558
|
-
const dnsPromises = require('no' + 'de:dns/promises');
|
|
2559
|
-
const url = ${JSON.stringify(url)};
|
|
2560
|
-
const outputPath = ${escapedOutputPath};
|
|
2561
|
-
|
|
2562
|
-
// Strip proxy env vars from this subprocess only. A configured outbound
|
|
2563
|
-
// proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) would route fetch through
|
|
2564
|
-
// an arbitrary target — DNS resolution happens at the proxy and the
|
|
2565
|
-
// in-subprocess DNS rebinding guard never sees the rebound IP. The
|
|
2566
|
-
// sandbox fetch path has no legitimate need for an upstream proxy.
|
|
2567
|
-
delete process.env.HTTP_PROXY;
|
|
2568
|
-
delete process.env.HTTPS_PROXY;
|
|
2569
|
-
delete process.env.ALL_PROXY;
|
|
2570
|
-
delete process.env.http_proxy;
|
|
2571
|
-
delete process.env.https_proxy;
|
|
2572
|
-
delete process.env.all_proxy;
|
|
2573
|
-
delete process.env.npm_config_proxy;
|
|
2574
|
-
delete process.env.npm_config_https_proxy;
|
|
2575
|
-
|
|
2576
|
-
${classifyIpSrc}
|
|
2577
|
-
|
|
2578
|
-
const STRICT = ${JSON.stringify(strictMode)};
|
|
2579
|
-
|
|
2580
|
-
// SSRF rebinding defense: every dns.lookup call inside this subprocess
|
|
2581
|
-
// (including the one undici performs to connect the fetch socket) is
|
|
2582
|
-
// re-validated against the same policy ssrfGuard runs in the parent.
|
|
2583
|
-
// Even if a hostname rebinds between the parent's pre-flight check and
|
|
2584
|
-
// the subprocess's actual connect, the connect-time lookup re-classifies
|
|
2585
|
-
// every returned record and aborts before TCP if any verdict is "block".
|
|
2586
|
-
const _origLookup = dns.lookup;
|
|
2587
|
-
dns.lookup = function patchedLookup(hostname, options, callback) {
|
|
2588
|
-
if (typeof options === 'function') { callback = options; options = {}; }
|
|
2589
|
-
if (typeof options === 'number') { options = { family: options }; }
|
|
2590
|
-
const wantAll = options && options.all;
|
|
2591
|
-
const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
|
|
2592
|
-
_origLookup(hostname, opts, function(err, records) {
|
|
2593
|
-
if (err) return callback(err);
|
|
2594
|
-
if (!Array.isArray(records)) {
|
|
2595
|
-
records = [{ address: records, family: (options && options.family) || 4 }];
|
|
2596
|
-
}
|
|
2597
|
-
for (var i = 0; i < records.length; i++) {
|
|
2598
|
-
var verdict = classifyIp(records[i].address);
|
|
2599
|
-
if (verdict === 'block' || (STRICT && verdict === 'private')) {
|
|
2600
|
-
return callback(new Error(
|
|
2601
|
-
'SSRF blocked at connect-time: ' + hostname +
|
|
2602
|
-
' resolves to ' + records[i].address +
|
|
2603
|
-
' (' + verdict + ')'
|
|
2604
|
-
));
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
if (wantAll) callback(null, records);
|
|
2608
|
-
else callback(null, records[0].address, records[0].family);
|
|
2609
|
-
});
|
|
2610
|
-
};
|
|
2611
|
-
|
|
2612
|
-
// dns/promises is a separate function reference. Patching dns.lookup does
|
|
2613
|
-
// NOT affect dnsPromises.lookup. Today undici's connect path uses callback
|
|
2614
|
-
// dns.lookup so default fetch is covered, but the invariant is fragile —
|
|
2615
|
-
// any future undici switch (or user code calling dnsPromises.lookup
|
|
2616
|
-
// directly) would bypass the guard. Patch both to keep the contract.
|
|
2617
|
-
const _origPromisesLookup = dnsPromises.lookup;
|
|
2618
|
-
dnsPromises.lookup = async function patchedPromisesLookup(hostname, options) {
|
|
2619
|
-
const opts = Object.assign({}, options || {}, { all: true, verbatim: true });
|
|
2620
|
-
const records = await _origPromisesLookup(hostname, opts);
|
|
2621
|
-
const list = Array.isArray(records) ? records : [records];
|
|
2622
|
-
for (var i = 0; i < list.length; i++) {
|
|
2623
|
-
var verdict = classifyIp(list[i].address);
|
|
2624
|
-
if (verdict === 'block' || (STRICT && verdict === 'private')) {
|
|
2625
|
-
throw new Error(
|
|
2626
|
-
'SSRF blocked at connect-time: ' + hostname +
|
|
2627
|
-
' resolves to ' + list[i].address + ' (' + verdict + ')'
|
|
2628
|
-
);
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
return options && options.all
|
|
2632
|
-
? list
|
|
2633
|
-
: { address: list[0].address, family: list[0].family };
|
|
2634
|
-
};
|
|
2635
|
-
|
|
2636
|
-
// dns.resolve4 / dns.resolve6 use a different code path (no getaddrinfo,
|
|
2637
|
-
// no /etc/hosts) than dns.lookup — they must be patched separately or the
|
|
2638
|
-
// guard is trivially bypassed by any caller using dns.resolve* directly.
|
|
2639
|
-
['resolve4', 'resolve6'].forEach(function patchResolve(name) {
|
|
2640
|
-
const _origResolve = dns[name];
|
|
2641
|
-
dns[name] = function patchedResolve(hostname, options, cb) {
|
|
2642
|
-
if (typeof options === 'function') { cb = options; options = undefined; }
|
|
2643
|
-
_origResolve.call(dns, hostname, options || {}, function(err, addrs) {
|
|
2644
|
-
if (err) return cb(err);
|
|
2645
|
-
var withTtl = options && options.ttl;
|
|
2646
|
-
for (var i = 0; i < addrs.length; i++) {
|
|
2647
|
-
var ip = withTtl ? addrs[i].address : addrs[i];
|
|
2648
|
-
var v = classifyIp(ip);
|
|
2649
|
-
if (v === 'block' || (STRICT && v === 'private')) {
|
|
2650
|
-
return cb(new Error(
|
|
2651
|
-
'SSRF blocked at connect-time: ' + hostname +
|
|
2652
|
-
' resolves to ' + ip + ' (' + v + ')'
|
|
2653
|
-
));
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
cb(null, addrs);
|
|
2657
|
-
});
|
|
2658
|
-
};
|
|
2659
|
-
});
|
|
2660
|
-
|
|
2661
|
-
// Generic dns.resolve is a polymorphic dispatcher (rrtype-driven). Internally
|
|
2662
|
-
// Node delegates to dns.resolve4/dns.resolve6 for A/AAAA, but the patches
|
|
2663
|
-
// above hook the *exported* references — Node's internal dispatcher holds
|
|
2664
|
-
// captured originals and bypasses our patch. Patch the wrapper explicitly:
|
|
2665
|
-
// classify A/AAAA records the same way; pass through CNAME/MX/TXT/SRV/etc.
|
|
2666
|
-
const _origResolveGeneric = dns.resolve;
|
|
2667
|
-
dns.resolve = function patchedResolveGeneric(hostname, rrtype, cb) {
|
|
2668
|
-
if (typeof rrtype === 'function') { cb = rrtype; rrtype = 'A'; }
|
|
2669
|
-
_origResolveGeneric.call(dns, hostname, rrtype, function(err, records) {
|
|
2670
|
-
if (err) return cb(err);
|
|
2671
|
-
if ((rrtype === 'A' || rrtype === 'AAAA') && Array.isArray(records)) {
|
|
2672
|
-
for (var i = 0; i < records.length; i++) {
|
|
2673
|
-
var ip = records[i];
|
|
2674
|
-
var v = classifyIp(ip);
|
|
2675
|
-
if (v === 'block' || (STRICT && v === 'private')) {
|
|
2676
|
-
return cb(new Error(
|
|
2677
|
-
'SSRF blocked at connect-time: ' + hostname +
|
|
2678
|
-
' resolves to ' + ip + ' (' + v + ')'
|
|
2679
|
-
));
|
|
2680
|
-
}
|
|
2681
|
-
}
|
|
2682
|
-
}
|
|
2683
|
-
cb(null, records);
|
|
2684
|
-
});
|
|
2685
|
-
};
|
|
2686
|
-
|
|
2687
|
-
function emit(ct, content) {
|
|
2688
|
-
// Write content to file to bypass executor stdout truncation (100KB limit).
|
|
2689
|
-
// Only the content-type marker goes to stdout.
|
|
2690
|
-
fs.writeFileSync(outputPath, content);
|
|
2691
|
-
console.log('__CM_CT__:' + ct);
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
// Manual redirect handling: a 3xx Location header can rebind the subprocess
|
|
2695
|
-
// fetch to an alternate host the parent's pre-flight ssrfGuard never saw.
|
|
2696
|
-
// Even with the connect-time DNS patch, a redirect target that is a literal
|
|
2697
|
-
// IP (e.g. http://169.254.169.254/) skips getaddrinfo entirely. Walk the
|
|
2698
|
-
// chain manually so every hop runs through classifyIp before the next fetch.
|
|
2699
|
-
const MAX_REDIRECTS = 5;
|
|
2700
|
-
async function fetchWithManualRedirect(initialUrl) {
|
|
2701
|
-
let currentUrl = initialUrl;
|
|
2702
|
-
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
|
2703
|
-
const resp = await fetch(currentUrl, { redirect: 'manual' });
|
|
2704
|
-
if (resp.status < 300 || resp.status >= 400) return resp;
|
|
2705
|
-
const location = resp.headers.get('location') || resp.headers.get('Location');
|
|
2706
|
-
if (!location) return resp;
|
|
2707
|
-
if (redirectCount === MAX_REDIRECTS) {
|
|
2708
|
-
throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
|
|
2709
|
-
}
|
|
2710
|
-
let nextParsed;
|
|
2711
|
-
try { nextParsed = new URL(location, currentUrl); } catch (e) {
|
|
2712
|
-
throw new Error('SSRF blocked: invalid redirect Location: ' + location);
|
|
2713
|
-
}
|
|
2714
|
-
if (nextParsed.protocol !== 'http:' && nextParsed.protocol !== 'https:') {
|
|
2715
|
-
throw new Error('SSRF blocked: redirect to non-http(s) scheme ' + nextParsed.protocol);
|
|
2716
|
-
}
|
|
2717
|
-
// If the redirect target is a literal IP, classify it directly — no DNS
|
|
2718
|
-
// lookup will fire and the connect-time guard would never see it.
|
|
2719
|
-
const hostname = nextParsed.hostname.replace(/^\[|\]$/g, '');
|
|
2720
|
-
const isIpLiteral = /^[0-9.]+$/.test(hostname) || hostname.includes(':');
|
|
2721
|
-
if (isIpLiteral) {
|
|
2722
|
-
const verdict = classifyIp(hostname);
|
|
2723
|
-
if (verdict === 'block' || (STRICT && verdict === 'private')) {
|
|
2724
|
-
throw new Error('SSRF blocked: redirect to ' + hostname + ' (' + verdict + ')');
|
|
2725
|
-
}
|
|
2726
|
-
} else {
|
|
2727
|
-
// Hostname target: resolve and classify every record. The patched
|
|
2728
|
-
// dns.lookup also fires on the next fetch's connect, but checking
|
|
2729
|
-
// here gives a clearer error and short-circuits before TCP setup.
|
|
2730
|
-
const records = await dnsPromises.lookup(hostname, { all: true, verbatim: true });
|
|
2731
|
-
for (const rec of records) {
|
|
2732
|
-
const verdict = classifyIp(rec.address);
|
|
2733
|
-
if (verdict === 'block' || (STRICT && verdict === 'private')) {
|
|
2734
|
-
throw new Error(
|
|
2735
|
-
'SSRF blocked: redirect target ' + hostname +
|
|
2736
|
-
' resolves to ' + rec.address + ' (' + verdict + ')'
|
|
2737
|
-
);
|
|
2738
|
-
}
|
|
2739
|
-
}
|
|
2740
|
-
}
|
|
2741
|
-
currentUrl = nextParsed.toString();
|
|
2742
|
-
}
|
|
2743
|
-
throw new Error('SSRF blocked: redirect chain exceeded ' + MAX_REDIRECTS + ' hops');
|
|
2744
|
-
}
|
|
2745
|
-
|
|
2746
|
-
// Subprocess response-body size cap. A malicious or unexpectedly large
|
|
2747
|
-
// endpoint reachable through ctx_fetch_and_index would otherwise stream
|
|
2748
|
-
// gigabytes into resp.text(), then into outputPath, then into the parent
|
|
2749
|
-
// MCP server's heap via readFileSync. 50 MB is far above typical web
|
|
2750
|
-
// page / API response sizes (~1-5 MB) but bounded enough to keep parent
|
|
2751
|
-
// heap survivable. Cap both early via Content-Length and after the read.
|
|
2752
|
-
const MAX_FETCH_BYTES = 50 * 1024 * 1024;
|
|
2753
|
-
async function safeText(resp) {
|
|
2754
|
-
const cl = parseInt(resp.headers.get('content-length') || '0', 10);
|
|
2755
|
-
if (cl > MAX_FETCH_BYTES) {
|
|
2756
|
-
throw new Error('Response too large: Content-Length ' + cl + ' exceeds ' + MAX_FETCH_BYTES);
|
|
2757
|
-
}
|
|
2758
|
-
const text = await resp.text();
|
|
2759
|
-
if (text.length > MAX_FETCH_BYTES) {
|
|
2760
|
-
throw new Error('Response too large: ' + text.length + ' bytes exceeds ' + MAX_FETCH_BYTES);
|
|
2761
|
-
}
|
|
2762
|
-
return text;
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
async function main() {
|
|
2766
|
-
const resp = await fetchWithManualRedirect(url);
|
|
2767
|
-
if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
|
|
2768
|
-
const contentType = resp.headers.get('content-type') || '';
|
|
2769
|
-
|
|
2770
|
-
// --- JSON responses ---
|
|
2771
|
-
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
|
2772
|
-
const text = await safeText(resp);
|
|
2773
|
-
try {
|
|
2774
|
-
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
2775
|
-
emit('json', pretty);
|
|
2776
|
-
} catch {
|
|
2777
|
-
emit('text', text);
|
|
2778
|
-
}
|
|
2779
|
-
return;
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
// --- HTML responses (default for text/html, application/xhtml+xml) ---
|
|
2783
|
-
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
|
2784
|
-
const html = await safeText(resp);
|
|
2785
|
-
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
2786
|
-
td.use(gfm);
|
|
2787
|
-
td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
|
|
2788
|
-
emit('html', td.turndown(html));
|
|
2789
|
-
return;
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
// --- Everything else: plain text, CSV, XML, etc. ---
|
|
2793
|
-
const text = await safeText(resp);
|
|
2794
|
-
emit('text', text);
|
|
2795
|
-
}
|
|
2796
|
-
main();
|
|
2797
|
-
`;
|
|
2798
|
-
}
|
|
2799
|
-
// ─────────────────────────────────────────────────────────
|
|
2800
|
-
// fetch_and_index helpers — split into parallel-safe fetch and serial-only index
|
|
2801
|
-
// ─────────────────────────────────────────────────────────
|
|
2802
|
-
const FETCH_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
2803
|
-
const FETCH_PREVIEW_LIMIT = 3072;
|
|
2804
|
-
function formatFetchTtl(ttlMs) {
|
|
2805
|
-
if (ttlMs === 0)
|
|
2806
|
-
return "0ms";
|
|
2807
|
-
const day = 24 * 60 * 60 * 1000;
|
|
2808
|
-
const hour = 60 * 60 * 1000;
|
|
2809
|
-
const minute = 60 * 1000;
|
|
2810
|
-
if (ttlMs % day === 0)
|
|
2811
|
-
return `${ttlMs / day}d`;
|
|
2812
|
-
if (ttlMs % hour === 0)
|
|
2813
|
-
return `${ttlMs / hour}h`;
|
|
2814
|
-
if (ttlMs % minute === 0)
|
|
2815
|
-
return `${ttlMs / minute}m`;
|
|
2816
|
-
return `${ttlMs}ms`;
|
|
2817
|
-
}
|
|
2818
|
-
/**
|
|
2819
|
-
* Pure fetch step — TTL cache check + subprocess fetch. SAFE TO RUN IN PARALLEL.
|
|
2820
|
-
* Performs zero SQLite writes (only reads source meta). Caller must funnel
|
|
2821
|
-
* fetched results through `indexFetched` serially to avoid FTS5 WAL contention.
|
|
2822
|
-
*/
|
|
2823
|
-
/**
|
|
2824
|
-
* SSRF guard for ctx_fetch_and_index: validate URL scheme + resolve target IP +
|
|
2825
|
-
* block link-local / IMDS / multicast / reserved IP ranges. Returns null if
|
|
2826
|
-
* safe; returns a FetchOneResult fetch_error if blocked.
|
|
2827
|
-
*
|
|
2828
|
-
* Policy (PR #401 ops review, developer-friendly default):
|
|
2829
|
-
*
|
|
2830
|
-
* **HARD BLOCK** (no legitimate dev workflow):
|
|
2831
|
-
* - file://, gopher://, javascript:, data: schemes (only http: and https:)
|
|
2832
|
-
* - 169.254.0.0/16 link-local (INCLUDES 169.254.169.254 = AWS/GCP/Azure IMDS
|
|
2833
|
-
* cloud credential endpoint — high-value target for indirect prompt injection)
|
|
2834
|
-
* - IPv6 link-local fe80::/10
|
|
2835
|
-
* - Multicast (224+ IPv4, ff00::/8 IPv6) and reserved (0.0.0.0/8) ranges
|
|
2836
|
-
*
|
|
2837
|
-
* **ALLOW by default** (legitimate developer use cases dominate):
|
|
2838
|
-
* - localhost, 127.x.x.x, ::1 (local dev servers — Next.js, Vite, Postgres, …)
|
|
2839
|
-
* - 10.x, 172.16-31.x, 192.168.x RFC1918 private (developer's internal network)
|
|
2840
|
-
*
|
|
2841
|
-
* **STRICT MODE** opt-in via env var: `CTX_FETCH_STRICT=1`
|
|
2842
|
-
* - Blocks loopback + RFC1918 too
|
|
2843
|
-
* - For hosted/CI environments where the runtime isn't the user's own machine
|
|
2844
|
-
*
|
|
2845
|
-
* DNS resolution is performed against the resolved IP (not just URL parse) so a
|
|
2846
|
-
* hostname like `evil.com` pointing to 169.254.169.254 is rejected — defends
|
|
2847
|
-
* against attacker-controlled DNS records and DNS rebinding.
|
|
2848
|
-
*/
|
|
2849
|
-
async function ssrfGuard(rawUrl) {
|
|
2850
|
-
let parsed;
|
|
2851
|
-
try {
|
|
2852
|
-
parsed = new URL(rawUrl);
|
|
2853
|
-
}
|
|
2854
|
-
catch {
|
|
2855
|
-
return { kind: "fetch_error", url: rawUrl, error: "invalid URL", reason: "exit" };
|
|
2856
|
-
}
|
|
2857
|
-
// 1. Scheme allowlist — http and https only
|
|
2858
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2859
|
-
return {
|
|
2860
|
-
kind: "fetch_error",
|
|
2861
|
-
url: rawUrl,
|
|
2862
|
-
error: `URL scheme "${parsed.protocol}" not allowed (only http: and https:)`,
|
|
2863
|
-
reason: "exit",
|
|
2864
|
-
};
|
|
2865
|
-
}
|
|
2866
|
-
const strict = process.env.CTX_FETCH_STRICT === "1";
|
|
2867
|
-
// 2. DNS resolve + check IP ranges (hard-block + optional strict-mode block)
|
|
2868
|
-
try {
|
|
2869
|
-
const { lookup } = await import("node:dns/promises");
|
|
2870
|
-
const records = await lookup(parsed.hostname, { all: true, verbatim: true });
|
|
2871
|
-
for (const rec of records) {
|
|
2872
|
-
const verdict = classifyIp(rec.address);
|
|
2873
|
-
if (verdict === "block") {
|
|
2874
|
-
return {
|
|
2875
|
-
kind: "fetch_error",
|
|
2876
|
-
url: rawUrl,
|
|
2877
|
-
error: `URL "${parsed.hostname}" resolves to ${rec.address} — blocked (link-local / IMDS / multicast / reserved)`,
|
|
2878
|
-
reason: "exit",
|
|
2879
|
-
};
|
|
2880
|
-
}
|
|
2881
|
-
if (verdict === "private" && strict) {
|
|
2882
|
-
return {
|
|
2883
|
-
kind: "fetch_error",
|
|
2884
|
-
url: rawUrl,
|
|
2885
|
-
error: `URL "${parsed.hostname}" resolves to private IP ${rec.address} — blocked under CTX_FETCH_STRICT=1`,
|
|
2886
|
-
reason: "exit",
|
|
2887
|
-
};
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
2890
|
-
}
|
|
2891
|
-
catch (err) {
|
|
2892
|
-
// libuv DNS error codes that typically indicate the resolver itself can't
|
|
2893
|
-
// reach a nameserver — common when the MCP host process is running under
|
|
2894
|
-
// a sandbox that blocks outbound network, OR a transient upstream DNS
|
|
2895
|
-
// hiccup. Append an imperative retry hint so the agent does not capitulate
|
|
2896
|
-
// to training data on the FIRST transient failure (PR #654 substitute —
|
|
2897
|
-
// sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
|
|
2898
|
-
const errCode = err?.code ?? "";
|
|
2899
|
-
const isTransientDns = errCode === "ETIMEOUT" || errCode === "ETIMEDOUT" ||
|
|
2900
|
-
errCode === "EAI_AGAIN" || errCode === "ENETUNREACH" || errCode === "EPERM";
|
|
2901
|
-
const baseMsg = err instanceof Error ? err.message : String(err);
|
|
2902
|
-
const hint = isTransientDns
|
|
2903
|
-
? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
|
|
2904
|
-
: "";
|
|
2905
|
-
return {
|
|
2906
|
-
kind: "fetch_error",
|
|
2907
|
-
url: rawUrl,
|
|
2908
|
-
error: `DNS lookup failed for "${parsed.hostname}": ${baseMsg}${hint}`,
|
|
2909
|
-
reason: "exit",
|
|
2910
|
-
};
|
|
2911
|
-
}
|
|
2912
|
-
return null; // safe to fetch
|
|
2913
|
-
}
|
|
2914
|
-
/**
|
|
2915
|
-
* Classify an IP address.
|
|
2916
|
-
* - "block": always blocked (link-local/IMDS/multicast/reserved/malformed)
|
|
2917
|
-
* - "private": loopback or RFC1918 — allowed by default, blocked in strict mode
|
|
2918
|
-
* - "public": safe to fetch
|
|
2919
|
-
*
|
|
2920
|
-
* Exported (via the function name) so SSRF tests can exercise the matcher directly.
|
|
2921
|
-
*/
|
|
2922
|
-
export function classifyIp(rawIp) {
|
|
2923
|
-
// RFC 6874 zone identifiers (`fe80::1%eth0`, URL-encoded `%25eth0`) must
|
|
2924
|
-
// be stripped BEFORE any prefix/equality classification. Without the strip,
|
|
2925
|
-
// a loopback `::1%eth0` no longer matches `lower === "::1"` and falls
|
|
2926
|
-
// through to "public" — silently bypassing the SSRF guard. Strip first,
|
|
2927
|
-
// classify second.
|
|
2928
|
-
const pctIdx = rawIp.indexOf("%");
|
|
2929
|
-
const ip = pctIdx === -1 ? rawIp : rawIp.slice(0, pctIdx);
|
|
2930
|
-
const lower = ip.toLowerCase();
|
|
2931
|
-
// IPv6 takes priority — check for `:` first so IPv4-mapped addresses
|
|
2932
|
-
// (`::ffff:127.0.0.1`) don't get incorrectly routed through the IPv4 parser.
|
|
2933
|
-
if (lower.includes(":")) {
|
|
2934
|
-
// IPv4-mapped IPv6 (`::ffff:127.0.0.1`) — recurse through IPv4 classifier
|
|
2935
|
-
const v4MappedMatch = lower.match(/^::ffff:([\d.]+)$/);
|
|
2936
|
-
if (v4MappedMatch)
|
|
2937
|
-
return classifyIp(v4MappedMatch[1]);
|
|
2938
|
-
// Hard-block
|
|
2939
|
-
if (lower === "::")
|
|
2940
|
-
return "block"; // unspecified
|
|
2941
|
-
if (lower.startsWith("fe8") || lower.startsWith("fe9") ||
|
|
2942
|
-
lower.startsWith("fea") || lower.startsWith("feb"))
|
|
2943
|
-
return "block"; // fe80::/10 link-local
|
|
2944
|
-
if (lower.startsWith("ff"))
|
|
2945
|
-
return "block"; // ff00::/8 multicast
|
|
2946
|
-
// Private (loopback + ULA)
|
|
2947
|
-
if (lower === "::1")
|
|
2948
|
-
return "private";
|
|
2949
|
-
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
2950
|
-
return "private"; // fc00::/7 ULA
|
|
2951
|
-
return "public";
|
|
2952
|
-
}
|
|
2953
|
-
// IPv4 (or non-IP string — malformed = block)
|
|
2954
|
-
if (!ip.includes("."))
|
|
2955
|
-
return "block"; // not an IP at all
|
|
2956
|
-
const parts = ip.split(".").map((p) => parseInt(p, 10));
|
|
2957
|
-
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
|
|
2958
|
-
return "block";
|
|
2959
|
-
const [a, b] = parts;
|
|
2960
|
-
// Hard-block (no legitimate use)
|
|
2961
|
-
if (a === 169 && b === 254)
|
|
2962
|
-
return "block"; // link-local incl. 169.254.169.254 (IMDS)
|
|
2963
|
-
if (a === 0)
|
|
2964
|
-
return "block"; // 0.0.0.0/8 (current network)
|
|
2965
|
-
if (a >= 224)
|
|
2966
|
-
return "block"; // 224.0.0.0+ multicast/reserved
|
|
2967
|
-
// Private (loopback + RFC1918) — allow by default
|
|
2968
|
-
if (a === 127)
|
|
2969
|
-
return "private"; // 127.0.0.0/8 loopback
|
|
2970
|
-
if (a === 10)
|
|
2971
|
-
return "private"; // 10.0.0.0/8
|
|
2972
|
-
if (a === 172 && b >= 16 && b <= 31)
|
|
2973
|
-
return "private"; // 172.16.0.0/12
|
|
2974
|
-
if (a === 192 && b === 168)
|
|
2975
|
-
return "private"; // 192.168.0.0/16
|
|
2976
|
-
return "public";
|
|
2977
|
-
}
|
|
2978
|
-
async function fetchOneUrl(url, source, force, ttl) {
|
|
2979
|
-
// SSRF guard — reject file://, javascript:, loopback, RFC1918, IMDS, link-local
|
|
2980
|
-
// BEFORE any cache lookup or subprocess spawn. Even cached entries shouldn't
|
|
2981
|
-
// serve a previously-poisoned source label.
|
|
2982
|
-
const ssrfBlock = await ssrfGuard(url);
|
|
2983
|
-
if (ssrfBlock)
|
|
2984
|
-
return ssrfBlock;
|
|
2985
|
-
if (!force && ttl !== 0) {
|
|
2986
|
-
const store = getStore();
|
|
2987
|
-
// Cache key composes (source, url) so two distinct URLs sharing the same
|
|
2988
|
-
// `source` label do not collide — they each get their own cache slot
|
|
2989
|
-
// (commit 1f1243e regression test enforced).
|
|
2990
|
-
const cacheKey = composeFetchCacheKey(source, url);
|
|
2991
|
-
const meta = store.getSourceMeta(cacheKey);
|
|
2992
|
-
if (meta) {
|
|
2993
|
-
const indexedAt = new Date(meta.indexedAt + "Z"); // SQLite datetime is UTC without Z
|
|
2994
|
-
const ageMs = Date.now() - indexedAt.getTime();
|
|
2995
|
-
const cacheTtlMs = ttl ?? FETCH_TTL_MS;
|
|
2996
|
-
if (ageMs < cacheTtlMs) {
|
|
2997
|
-
const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
|
|
2998
|
-
const ageMin = Math.floor(ageMs / (60 * 1000));
|
|
2999
|
-
const ageStr = ageHours > 0 ? `${ageHours}h ago` : ageMin > 0 ? `${ageMin}m ago` : "just now";
|
|
3000
|
-
const estimatedBytes = meta.chunkCount * 1600; // ~1.6KB/chunk avg
|
|
3001
|
-
return { kind: "cached", label: meta.label, chunkCount: meta.chunkCount, estimatedBytes, ageStr, ttlStr: formatFetchTtl(cacheTtlMs) };
|
|
3002
|
-
}
|
|
3003
|
-
// Stale — fall through to re-fetch silently
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
const outputPath = join(tmpdir(), `ctx-fetch-${Date.now()}-${Math.random().toString(36).slice(2)}.dat`);
|
|
3007
|
-
try {
|
|
3008
|
-
const fetchCode = buildFetchCode(url, outputPath);
|
|
3009
|
-
const result = await executor.execute({
|
|
3010
|
-
language: "javascript",
|
|
3011
|
-
code: fetchCode,
|
|
3012
|
-
timeout: 30_000,
|
|
3013
|
-
});
|
|
3014
|
-
if (result.exitCode !== 0) {
|
|
3015
|
-
// Subprocess fetch failure — undici / fetch can surface EAI_AGAIN /
|
|
3016
|
-
// ETIMEDOUT / ENETUNREACH in stderr when the resolver is overloaded
|
|
3017
|
-
// or the network is briefly unavailable. Append the same retry hint
|
|
3018
|
-
// ssrfGuard's pre-flight DNS path emits so the agent doesn't capitulate
|
|
3019
|
-
// to training data on the first transient failure (PR #654 substitute —
|
|
3020
|
-
// sibling-tool consistency with hooks/core/routing.mjs WebFetch wording).
|
|
3021
|
-
const raw = result.stderr || result.stdout || "unknown error";
|
|
3022
|
-
const isTransientDns = /\b(EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ENETUNREACH|EPERM|getaddrinfo)\b/.test(raw);
|
|
3023
|
-
const hint = isTransientDns
|
|
3024
|
-
? " — transient DNS error; retry once before falling back. If it keeps failing, the MCP host may be running under a network sandbox; restart the host with network access enabled."
|
|
3025
|
-
: "";
|
|
3026
|
-
return { kind: "fetch_error", url, error: `${raw}${hint}`, reason: "exit" };
|
|
3027
|
-
}
|
|
3028
|
-
const header = (result.stdout || "").trim();
|
|
3029
|
-
let markdown;
|
|
3030
|
-
try {
|
|
3031
|
-
// Parent-side defense-in-depth on the subprocess output size. The
|
|
3032
|
-
// embedded safeText() in buildFetchCode already caps before writing,
|
|
3033
|
-
// but a torn write (subprocess killed mid-write, fs cache desync,
|
|
3034
|
-
// etc.) could still leave an oversized file. Bail before slurping
|
|
3035
|
-
// multiple gigabytes into the long-running MCP server's heap.
|
|
3036
|
-
const MAX_FETCH_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
3037
|
-
const fileSize = statSync(outputPath).size;
|
|
3038
|
-
if (fileSize > MAX_FETCH_OUTPUT_BYTES) {
|
|
3039
|
-
return { kind: "fetch_error", url, error: `subprocess output ${fileSize} bytes exceeds cap ${MAX_FETCH_OUTPUT_BYTES}`, reason: "read" };
|
|
3040
|
-
}
|
|
3041
|
-
markdown = readFileSync(outputPath, "utf-8").trim();
|
|
3042
|
-
}
|
|
3043
|
-
catch {
|
|
3044
|
-
return { kind: "fetch_error", url, error: "could not read subprocess output", reason: "read" };
|
|
3045
|
-
}
|
|
3046
|
-
if (markdown.length === 0) {
|
|
3047
|
-
return { kind: "fetch_error", url, error: "empty content", reason: "empty" };
|
|
3048
|
-
}
|
|
3049
|
-
return { kind: "fetched", url, source, markdown, header };
|
|
3050
|
-
}
|
|
3051
|
-
catch (err) {
|
|
3052
|
-
return {
|
|
3053
|
-
kind: "fetch_error",
|
|
3054
|
-
url,
|
|
3055
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3056
|
-
reason: "throw",
|
|
3057
|
-
};
|
|
3058
|
-
}
|
|
3059
|
-
finally {
|
|
3060
|
-
try {
|
|
3061
|
-
rmSync(outputPath);
|
|
3062
|
-
}
|
|
3063
|
-
catch { /* already gone */ }
|
|
3064
|
-
}
|
|
3065
|
-
}
|
|
3066
|
-
/**
|
|
3067
|
-
* Serial-only indexing step — single FTS5 write per call. Caller loops over
|
|
3068
|
-
* fetched results and calls this one-at-a-time to avoid SQLite WAL contention
|
|
3069
|
-
* (PRD finding E).
|
|
3070
|
-
*/
|
|
3071
|
-
function indexFetched(f) {
|
|
3072
|
-
const store = getStore();
|
|
3073
|
-
// Storage label composed via composeFetchCacheKey so two URLs sharing a
|
|
3074
|
-
// `source` label do not overwrite each other (commit 1f1243e). ctx_search()
|
|
3075
|
-
// still finds both via LIKE-mode source filter on the `source` substring.
|
|
3076
|
-
const storageLabel = composeFetchCacheKey(f.source, f.url);
|
|
3077
|
-
const attribution = currentAttribution();
|
|
3078
|
-
let indexed;
|
|
3079
|
-
if (f.header === "__CM_CT__:json") {
|
|
3080
|
-
indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
|
|
3081
|
-
}
|
|
3082
|
-
else if (f.header === "__CM_CT__:text") {
|
|
3083
|
-
indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
|
|
3084
|
-
}
|
|
3085
|
-
else {
|
|
3086
|
-
indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
|
|
3087
|
-
}
|
|
3088
|
-
// Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
|
|
3089
|
-
trackIndexed(Buffer.byteLength(f.markdown));
|
|
3090
|
-
const preview = f.markdown.length > FETCH_PREVIEW_LIMIT
|
|
3091
|
-
? charSafePrefix(f.markdown, FETCH_PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
|
|
3092
|
-
: f.markdown;
|
|
3093
|
-
return {
|
|
3094
|
-
label: indexed.label,
|
|
3095
|
-
totalChunks: indexed.totalChunks,
|
|
3096
|
-
totalBytes: Buffer.byteLength(f.markdown),
|
|
3097
|
-
preview,
|
|
3098
|
-
};
|
|
3099
|
-
}
|
|
3100
|
-
server.registerTool("ctx_fetch_and_index", {
|
|
3101
|
-
title: "Fetch & Index URL(s)",
|
|
3102
|
-
// #846: fetches external URLs (open world) and writes them into the store.
|
|
3103
|
-
annotations: {
|
|
3104
|
-
readOnlyHint: false,
|
|
3105
|
-
destructiveHint: false,
|
|
3106
|
-
idempotentHint: false,
|
|
3107
|
-
openWorldHint: true,
|
|
3108
|
-
},
|
|
3109
|
-
description: `Fetches URL content, converts HTML to markdown (JSON is chunked by key paths, plain text indexed directly), persists it in a searchable knowledge base, and returns a small preview window per source. The raw page bytes never enter your conversation — they live in storage and you retrieve any section on-demand via ctx_search.
|
|
3110
|
-
|
|
3111
|
-
Caching: every fetch is cached on disk and reused for repeat calls within the TTL window. The default TTL is 24 hours; override per-call with the \`ttl\` parameter (milliseconds, \`ttl: 0\` bypasses cache like \`force: true\`). Stored content older than 14 days is cleaned up on startup.
|
|
3112
|
-
|
|
3113
|
-
WHEN:
|
|
3114
|
-
- You need web content (docs, changelogs, API references, spec pages) and the raw page bytes should NOT enter your conversation
|
|
3115
|
-
- Multi-URL research (library evaluation, migration scans, doc comparisons): pass the \`requests\` array and a \`concurrency\` value 2-8 for parallel I/O
|
|
3116
|
-
- You want repeat lookups against the same URL to be cheap (TTL cache hits return only a hint, no re-fetch)
|
|
3117
|
-
- You want a long-lived cache window (override \`ttl\` upward for stable specs) or a guaranteed-fresh fetch (\`ttl: 0\` or \`force: true\`)
|
|
3118
|
-
|
|
3119
|
-
WHEN NOT:
|
|
3120
|
-
- You already have the content locally — store it via the inline index tool
|
|
3121
|
-
- The page is SPA-rendered (JavaScript-required to materialize content) — this is a plain HTTP fetch, no headless browser
|
|
3122
|
-
|
|
3123
|
-
RETURNS:
|
|
3124
|
-
Per-source preview windows extracted around indexable headings plus indexing metadata (chunk counts, source labels, cache state). Raw content is NOT echoed back — retrieve any section on-demand via ctx_search(source: "<label>"). Concurrency parallelizes the fetch phase up to your chosen value (capped by the host's logical CPU count); the FTS5 write phase always runs serially because SQLite is a single-writer store. Net latency = max(fetch latency across the pool) + sum(per-source index write time). Cache hits skip both phases and return a small freshness hint instead of re-fetching. Use 4-8 for stable I/O-bound batches; lower the value when the target host enforces a per-IP rate limit you cannot raise.
|
|
3125
|
-
|
|
3126
|
-
EXAMPLE: ctx_fetch_and_index(
|
|
3127
|
-
requests: [{url: "https://react.dev/...", source: "react"}, {url: "https://vuejs.org/...", source: "vue"}],
|
|
3128
|
-
concurrency: 5
|
|
3129
|
-
)`,
|
|
3130
|
-
inputSchema: z.object({
|
|
3131
|
-
url: z.string().optional().describe("Single URL to fetch and index (legacy single-shape)"),
|
|
3132
|
-
source: z
|
|
3133
|
-
.string()
|
|
3134
|
-
.optional()
|
|
3135
|
-
.describe("Label for the indexed content when using single `url` (e.g., 'React useEffect docs', 'Supabase Auth API'). For batch, put source in each requests entry."),
|
|
3136
|
-
requests: z
|
|
3137
|
-
.preprocess(coerceJsonArray, z.array(z.object({
|
|
3138
|
-
url: z.string().describe("URL to fetch"),
|
|
3139
|
-
source: z.string().optional().describe("Label for this URL's indexed content"),
|
|
3140
|
-
})).min(1))
|
|
3141
|
-
.optional()
|
|
3142
|
-
.describe("Batch shape: array of {url, source?} entries. Use with concurrency>1 for parallel fetch. " +
|
|
3143
|
-
"Each request indexed under its own source label. Output preserves input order."),
|
|
3144
|
-
concurrency: z
|
|
3145
|
-
.coerce.number()
|
|
3146
|
-
.int()
|
|
3147
|
-
.min(1)
|
|
3148
|
-
.max(8)
|
|
3149
|
-
.optional()
|
|
3150
|
-
.default(1)
|
|
3151
|
-
.describe("Max URLs to fetch in parallel (1-8, default: 1). " +
|
|
3152
|
-
"Use 4-8 for I/O-bound multi-URL batches (library docs, changelogs, pricing pages). " +
|
|
3153
|
-
"Capped by os.cpus().length on small machines (response notes when capped). " +
|
|
3154
|
-
"Indexing is always serial regardless — only fetches race."),
|
|
3155
|
-
force: z
|
|
3156
|
-
.preprocess(coerceBoolean, z.boolean())
|
|
3157
|
-
.optional()
|
|
3158
|
-
.describe("Skip cache and re-fetch even if content was recently indexed"),
|
|
3159
|
-
ttl: z
|
|
3160
|
-
.coerce.number()
|
|
3161
|
-
.int()
|
|
3162
|
-
.min(0)
|
|
3163
|
-
.optional()
|
|
3164
|
-
.describe("Override the cache freshness window for this call, in milliseconds. " +
|
|
3165
|
-
"`ttl: 0` bypasses the cache like `force: true`; omit to use the default 24h TTL."),
|
|
3166
|
-
}),
|
|
3167
|
-
}, async ({ url, source, requests, concurrency, force, ttl }) => {
|
|
3168
|
-
// Normalize input: legacy {url} or new {requests: [...]}.
|
|
3169
|
-
// requests wins when both are provided (explicit batch intent).
|
|
3170
|
-
const batch = requests
|
|
3171
|
-
? requests
|
|
3172
|
-
: url
|
|
3173
|
-
? [{ url, source }]
|
|
3174
|
-
: [];
|
|
3175
|
-
if (batch.length === 0) {
|
|
3176
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3177
|
-
content: [{
|
|
3178
|
-
type: "text",
|
|
3179
|
-
text: "ctx_fetch_and_index requires either `url` (single) or `requests: [{url, source?}, ...]` (batch).",
|
|
3180
|
-
}],
|
|
3181
|
-
isError: true,
|
|
3182
|
-
});
|
|
3183
|
-
}
|
|
3184
|
-
const isLegacySingle = !requests && batch.length === 1;
|
|
3185
|
-
const requestedConcurrency = concurrency ?? 1;
|
|
3186
|
-
// Parallel fetch via shared runPool primitive. capByCpuCount only for batch
|
|
3187
|
-
// — single-URL doesn't need the cap (only one job, executor is one subprocess).
|
|
3188
|
-
const jobs = batch.map((req) => ({
|
|
3189
|
-
run: () => fetchOneUrl(req.url, req.source, force, ttl),
|
|
3190
|
-
}));
|
|
3191
|
-
const { settled, effectiveConcurrency, capped } = await runPool(jobs, {
|
|
3192
|
-
concurrency: requestedConcurrency,
|
|
3193
|
-
capByCpuCount: !isLegacySingle && requestedConcurrency > 1,
|
|
3194
|
-
});
|
|
3195
|
-
const finalized = [];
|
|
3196
|
-
for (let i = 0; i < settled.length; i++) {
|
|
3197
|
-
const r = settled[i];
|
|
3198
|
-
if (r.status === "rejected") {
|
|
3199
|
-
const message = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
3200
|
-
finalized.push({ kind: "job_error", url: batch[i].url, error: message });
|
|
3201
|
-
continue;
|
|
3202
|
-
}
|
|
3203
|
-
const v = r.value;
|
|
3204
|
-
if (v.kind === "cached") {
|
|
3205
|
-
sessionStats.cacheHits++;
|
|
3206
|
-
sessionStats.cacheBytesSaved += v.estimatedBytes;
|
|
3207
|
-
// D2 Phase 5/7 — cache-hit event emission. `bytes_avoided` is the
|
|
3208
|
-
// size of the cached payload that would have re-entered context
|
|
3209
|
-
// had the TTL window missed. Best-effort, off the hot path.
|
|
3210
|
-
const cachedBytes = v.estimatedBytes;
|
|
3211
|
-
const cachedLabel = v.label;
|
|
3212
|
-
setImmediate(() => emitCacheHitEvent({
|
|
3213
|
-
sessionDbPath: getSessionDbPath(),
|
|
3214
|
-
source: cachedLabel,
|
|
3215
|
-
bytesAvoided: cachedBytes,
|
|
3216
|
-
}));
|
|
3217
|
-
finalized.push({ kind: "cached", label: v.label, chunkCount: v.chunkCount, ageStr: v.ageStr, ttlStr: v.ttlStr });
|
|
3218
|
-
}
|
|
3219
|
-
else if (v.kind === "fetch_error") {
|
|
3220
|
-
finalized.push({ kind: "fetch_error", url: v.url, error: v.error, reason: v.reason });
|
|
3221
|
-
}
|
|
3222
|
-
else {
|
|
3223
|
-
// Serial FTS5 write here — no parallel store.index calls.
|
|
3224
|
-
// Cache miss: the URL was not in the TTL window so we paid the
|
|
3225
|
-
// network round-trip + re-indexed. Counted here so ctx_stats can
|
|
3226
|
-
// report nominal cache_hit_rate alongside the existing hit metrics.
|
|
3227
|
-
sessionStats.cacheMisses++;
|
|
3228
|
-
finalized.push({ kind: "fetched", indexed: indexFetched(v) });
|
|
3229
|
-
}
|
|
3230
|
-
}
|
|
3231
|
-
// Backward-compat single-URL response shape — preserve the EXACT original wording.
|
|
3232
|
-
if (isLegacySingle) {
|
|
3233
|
-
const r = finalized[0];
|
|
3234
|
-
if (r.kind === "cached") {
|
|
3235
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3236
|
-
content: [{
|
|
3237
|
-
type: "text",
|
|
3238
|
-
text: `Cached: **${r.label}** — ${r.chunkCount} sections, indexed ${r.ageStr} (fresh, TTL: ${r.ttlStr}).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${r.label}")`,
|
|
3239
|
-
}],
|
|
3240
|
-
});
|
|
3241
|
-
}
|
|
3242
|
-
if (r.kind === "fetched") {
|
|
3243
|
-
const totalKB = (r.indexed.totalBytes / 1024).toFixed(1);
|
|
3244
|
-
const text = [
|
|
3245
|
-
`Fetched and indexed **${r.indexed.totalChunks} sections** (${totalKB}KB) from: ${r.indexed.label}`,
|
|
3246
|
-
`Full content indexed in sandbox — use ctx_search(queries: [...], source: "${r.indexed.label}") for specific lookups.`,
|
|
3247
|
-
"",
|
|
3248
|
-
"---",
|
|
3249
|
-
"",
|
|
3250
|
-
r.indexed.preview,
|
|
3251
|
-
].join("\n");
|
|
3252
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3253
|
-
content: [{ type: "text", text }],
|
|
3254
|
-
});
|
|
3255
|
-
}
|
|
3256
|
-
// fetch_error — preserve original error wording per reason
|
|
3257
|
-
if (r.kind === "fetch_error") {
|
|
3258
|
-
const text = r.reason === "empty" ? `Fetched ${r.url} but got empty content`
|
|
3259
|
-
: r.reason === "read" ? `Fetched ${r.url} but could not read subprocess output`
|
|
3260
|
-
: r.reason === "exit" ? `Failed to fetch ${r.url}: ${r.error}`
|
|
3261
|
-
: /* throw */ `Fetch error: ${r.error}`;
|
|
3262
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3263
|
-
content: [{ type: "text", text }],
|
|
3264
|
-
isError: true,
|
|
3265
|
-
});
|
|
3266
|
-
}
|
|
3267
|
-
// job_error
|
|
3268
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3269
|
-
content: [{ type: "text", text: `Fetch error: ${r.error}` }],
|
|
3270
|
-
isError: true,
|
|
3271
|
-
});
|
|
3272
|
-
}
|
|
3273
|
-
// Batch response — aggregated summary; isError only when EVERY URL failed.
|
|
3274
|
-
// Per-URL preview capped tightly so a 8-URL batch doesn't undo the
|
|
3275
|
-
// context-savings the tool exists to deliver (PRD review finding G1).
|
|
3276
|
-
const FETCH_BATCH_PREVIEW_LIMIT = 384; // ~3KB total for 8-URL batches
|
|
3277
|
-
const lines = [];
|
|
3278
|
-
let totalSections = 0;
|
|
3279
|
-
let totalBytes = 0;
|
|
3280
|
-
let cachedCount = 0;
|
|
3281
|
-
let fetchedCount = 0;
|
|
3282
|
-
let errorCount = 0;
|
|
3283
|
-
const snippets = [];
|
|
3284
|
-
for (const r of finalized) {
|
|
3285
|
-
if (r.kind === "cached") {
|
|
3286
|
-
cachedCount++;
|
|
3287
|
-
lines.push(`- [cache] ${r.label} — ${r.chunkCount} sections (${r.ageStr}, TTL: ${r.ttlStr})`);
|
|
3288
|
-
}
|
|
3289
|
-
else if (r.kind === "fetched") {
|
|
3290
|
-
fetchedCount++;
|
|
3291
|
-
totalSections += r.indexed.totalChunks;
|
|
3292
|
-
totalBytes += r.indexed.totalBytes;
|
|
3293
|
-
const kb = (r.indexed.totalBytes / 1024).toFixed(1);
|
|
3294
|
-
lines.push(`- [new] ${r.indexed.label} — ${r.indexed.totalChunks} sections (${kb}KB)`);
|
|
3295
|
-
const snippet = r.indexed.preview.length > FETCH_BATCH_PREVIEW_LIMIT
|
|
3296
|
-
? r.indexed.preview.slice(0, FETCH_BATCH_PREVIEW_LIMIT).trimEnd() + "…"
|
|
3297
|
-
: r.indexed.preview;
|
|
3298
|
-
snippets.push(`### ${r.indexed.label}\n\n${snippet}`);
|
|
3299
|
-
}
|
|
3300
|
-
else {
|
|
3301
|
-
errorCount++;
|
|
3302
|
-
lines.push(`- [err] ${r.url}: ${r.error}`);
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
const totalKB = (totalBytes / 1024).toFixed(1);
|
|
3306
|
-
const cappedNote = capped
|
|
3307
|
-
? ` cap=${effectiveConcurrency}/${cpus().length}cpu`
|
|
3308
|
-
: "";
|
|
3309
|
-
// Status line: counts + sections + size, with singular/plural agreement
|
|
3310
|
-
// (count=1 → "1 error" not "1 errors") so the line stays grammatical.
|
|
3311
|
-
const fmt = (n, sing, plur) => `${n} ${n === 1 ? sing : plur}`;
|
|
3312
|
-
const headerLine = `fetched ${batch.length} c=${effectiveConcurrency}${cappedNote}. ` +
|
|
3313
|
-
`ok=${fetchedCount} cache=${cachedCount} err=${errorCount}. ` +
|
|
3314
|
-
`${fmt(totalSections, "section", "sections")} ${totalKB}KB.`;
|
|
3315
|
-
const text = [
|
|
3316
|
-
headerLine,
|
|
3317
|
-
"",
|
|
3318
|
-
...lines,
|
|
3319
|
-
"",
|
|
3320
|
-
`ctx_search(queries: [...], source: "<label>") for full content.`,
|
|
3321
|
-
...(snippets.length > 0 ? ["", "---", "", ...snippets] : []),
|
|
3322
|
-
].join("\n");
|
|
3323
|
-
return trackResponse("ctx_fetch_and_index", {
|
|
3324
|
-
content: [{ type: "text", text }],
|
|
3325
|
-
isError: errorCount === batch.length, // only mark error if every URL failed
|
|
3326
|
-
});
|
|
3327
|
-
});
|
|
3328
|
-
// ─────────────────────────────────────────────────────────
|
|
3329
|
-
// Tool: batch_execute
|
|
3330
|
-
// ─────────────────────────────────────────────────────────
|
|
3331
|
-
server.registerTool("ctx_batch_execute", {
|
|
3332
|
-
title: "Batch Execute & Search",
|
|
3333
|
-
// #846: runs arbitrary shell commands (with network) and indexes output.
|
|
3334
|
-
annotations: {
|
|
3335
|
-
readOnlyHint: false,
|
|
3336
|
-
destructiveHint: true,
|
|
3337
|
-
idempotentHint: false,
|
|
3338
|
-
openWorldHint: true,
|
|
3339
|
-
},
|
|
3340
|
-
description: `Run multiple commands in ONE call. Every command's output is auto-indexed into the knowledge base; if you also pass \`queries\`, the matching sections come back in the same round trip so a follow-up search call is not needed.
|
|
3341
|
-
|
|
3342
|
-
Concurrency parallelizes the FETCH phase (run-the-commands). The DERIVATION phase — turning raw output into an answer — still belongs in code: add a processing command that consumes the indexed output and prints only the answer, so the raw bytes never enter your conversation (Think-in-Code, same principle as the sandbox tool).
|
|
3343
|
-
|
|
3344
|
-
WHEN:
|
|
3345
|
-
- You have 3+ related commands you would otherwise run sequentially (multi-issue lookups, git log + git diff + git blame, multi-file reads, multi-region cloud queries)
|
|
3346
|
-
- You want to gather AND query in one round trip — pass \`queries\` so the matching sections come back inline
|
|
3347
|
-
- You want to parallelize I/O-bound work — pass \`concurrency\` 2-8 (network calls, gh CLI, cloud APIs, multi-repo git reads)
|
|
3348
|
-
- The combined output is large enough that piping it through ctx_search later would itself be expensive — let auto-index + inline queries do both in one shot
|
|
3349
|
-
|
|
3350
|
-
WHEN NOT:
|
|
3351
|
-
- Single command with no follow-up query — run it in the sandbox tool directly
|
|
3352
|
-
- CPU-bound or stateful commands — keep concurrency at 1 (npm test, build, lint, port-binding servers, lock-file holders, anything that races on the same resource)
|
|
3353
|
-
|
|
3354
|
-
RETURNS:
|
|
3355
|
-
Auto-indexed section list per command label, plus top matches per query (when \`queries\` is passed). Raw output is NOT echoed in full — only the matched windows. Concurrency>1 switches each command to its own per-command timeout (no shared budget); concurrency=1 preserves the legacy shared-budget cascading-skip-on-timeout path. Use 4-8 for I/O-bound batches; keep at 1 for CPU work or shared-state commands; lower the value when target hosts enforce per-IP rate limits.
|
|
3356
|
-
|
|
3357
|
-
EXAMPLE: ctx_batch_execute(
|
|
3358
|
-
commands: [
|
|
3359
|
-
{label: "issue 1", command: "gh issue view 1"},
|
|
3360
|
-
{label: "issue 2", command: "gh issue view 2"},
|
|
3361
|
-
{label: "summarize", command: "echo done"}
|
|
3362
|
-
],
|
|
3363
|
-
queries: ["root cause", "proposed fix"],
|
|
3364
|
-
concurrency: 2
|
|
3365
|
-
)`,
|
|
3366
|
-
inputSchema: z.object({
|
|
3367
|
-
commands: z.preprocess(coerceCommandsArray, z
|
|
3368
|
-
.array(z.object({
|
|
3369
|
-
label: z
|
|
3370
|
-
.string()
|
|
3371
|
-
.describe("Section header for this command's output (e.g., 'README', 'Package.json', 'Source Tree')"),
|
|
3372
|
-
command: z
|
|
3373
|
-
.string()
|
|
3374
|
-
.describe("Shell command to execute"),
|
|
3375
|
-
}))
|
|
3376
|
-
.min(1)
|
|
3377
|
-
.describe("Commands to execute as a batch. Output is labeled with the section header. " +
|
|
3378
|
-
"Default order is sequential; pass concurrency>1 to run in parallel (output stays in input order).")),
|
|
3379
|
-
queries: z.preprocess(coerceJsonArray, z
|
|
3380
|
-
.array(z.string())
|
|
3381
|
-
.min(1)
|
|
3382
|
-
.describe("Search queries to extract information from indexed output. Use 5-8 comprehensive queries. " +
|
|
3383
|
-
"Each returns top 5 matching sections with full content. " +
|
|
3384
|
-
"This is your ONLY chance — put ALL your questions here. No follow-up calls needed.")),
|
|
3385
|
-
timeout: z
|
|
3386
|
-
.coerce.number()
|
|
3387
|
-
.optional()
|
|
3388
|
-
.describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs. With concurrency=1, the value (when set) is a shared budget across commands; with concurrency>1, it is applied per-command."),
|
|
3389
|
-
concurrency: z
|
|
3390
|
-
.coerce.number()
|
|
3391
|
-
.int()
|
|
3392
|
-
.min(1)
|
|
3393
|
-
.max(8)
|
|
3394
|
-
.optional()
|
|
3395
|
-
.default(1)
|
|
3396
|
-
.describe("Max commands to run in parallel (1-8, default: 1). " +
|
|
3397
|
-
"Use 4-8 for I/O-bound batches (network, gh, curl, multi-repo git reads). " +
|
|
3398
|
-
"Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
|
|
3399
|
-
">1 switches to per-command timeouts (no shared budget) and " +
|
|
3400
|
-
"individual `(timed out)` blocks instead of cascading skip."),
|
|
3401
|
-
cwd: z
|
|
3402
|
-
.string()
|
|
3403
|
-
.optional()
|
|
3404
|
-
.describe("Optional working directory for all shell commands in this batch."),
|
|
3405
|
-
query_scope: z
|
|
3406
|
-
.enum(["batch", "global"])
|
|
3407
|
-
.optional()
|
|
3408
|
-
.default("batch")
|
|
3409
|
-
.describe("Scope for `queries` (default: `batch`). " +
|
|
3410
|
-
"`batch` searches ONLY the chunks produced by this batch's commands " +
|
|
3411
|
-
"— useful when you want answers about the just-fetched output. " +
|
|
3412
|
-
"`global` searches the entire persistent index (same scope as ctx_search) " +
|
|
3413
|
-
"— useful when you want the batch commands to enrich context and " +
|
|
3414
|
-
"the queries to also surface related prior knowledge in one round trip."),
|
|
3415
|
-
}),
|
|
3416
|
-
}, async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
|
|
3417
|
-
// Security: check each command against deny patterns
|
|
3418
|
-
for (const cmd of commands) {
|
|
3419
|
-
const denied = checkDenyPolicy(cmd.command, "batch_execute");
|
|
3420
|
-
if (denied)
|
|
3421
|
-
return denied;
|
|
3422
|
-
}
|
|
3423
|
-
try {
|
|
3424
|
-
// Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
|
|
3425
|
-
// The executor denies NODE_OPTIONS in its env (security), so we set it
|
|
3426
|
-
// as an inline shell prefix. This only affects child `node` invocations.
|
|
3427
|
-
const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
|
|
3428
|
-
// Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
|
|
3429
|
-
// Concurrency>1 switches to a worker pool with per-command timeouts.
|
|
3430
|
-
const effTimeout = resolveExecTimeout(timeout);
|
|
3431
|
-
const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
|
|
3432
|
-
timeout: effTimeout,
|
|
3433
|
-
concurrency,
|
|
3434
|
-
nodeOptsPrefix,
|
|
3435
|
-
cwd,
|
|
3436
|
-
onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
|
|
3437
|
-
}, executor);
|
|
3438
|
-
const stdout = perCommandOutputs.join("\n");
|
|
3439
|
-
const totalBytes = Buffer.byteLength(stdout);
|
|
3440
|
-
const totalLines = stdout.split("\n").length;
|
|
3441
|
-
if (timedOut && perCommandOutputs.length === 0) {
|
|
3442
|
-
return trackResponse("ctx_batch_execute", {
|
|
3443
|
-
content: [
|
|
3444
|
-
{
|
|
3445
|
-
type: "text",
|
|
3446
|
-
text: `Batch timed out after ${effTimeout}ms. No output captured.`,
|
|
3447
|
-
},
|
|
3448
|
-
],
|
|
3449
|
-
isError: true,
|
|
3450
|
-
});
|
|
3451
|
-
}
|
|
3452
|
-
// Track indexed bytes (raw data that stays in sandbox)
|
|
3453
|
-
trackIndexed(totalBytes);
|
|
3454
|
-
// Index into knowledge base — markdown heading chunking splits by # labels
|
|
3455
|
-
const store = getStore();
|
|
3456
|
-
const source = `batch:${commands
|
|
3457
|
-
.map((c) => c.label)
|
|
3458
|
-
.join(",")
|
|
3459
|
-
.slice(0, 80)}`;
|
|
3460
|
-
const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
|
|
3461
|
-
// Commands inventory — list what the agent actually ran so the
|
|
3462
|
-
// response itself documents intent, not just per-section echoes.
|
|
3463
|
-
// Placed before "## Indexed Sections" so it scans top-down with
|
|
3464
|
-
// the human asking "what just happened" (Issues #717 + #736).
|
|
3465
|
-
const commandsInventory = ["## Commands", ""];
|
|
3466
|
-
for (const c of commands) {
|
|
3467
|
-
commandsInventory.push(`- ${c.label}: \`${truncateCommandForEcho(c.command)}\``);
|
|
3468
|
-
}
|
|
3469
|
-
// Build section inventory — direct query by source_id (no FTS5 MATCH needed)
|
|
3470
|
-
const allSections = store.getChunksBySource(indexed.sourceId);
|
|
3471
|
-
const inventory = ["## Indexed Sections", ""];
|
|
3472
|
-
const sectionTitles = [];
|
|
3473
|
-
for (const s of allSections) {
|
|
3474
|
-
const bytes = Buffer.byteLength(s.content);
|
|
3475
|
-
inventory.push(`- ${s.title} (${(bytes / 1024).toFixed(1)}KB)`);
|
|
3476
|
-
sectionTitles.push(s.title);
|
|
3477
|
-
}
|
|
3478
|
-
// Run all search queries — default scope is batch-local (legacy behavior).
|
|
3479
|
-
// When the caller passes query_scope: "global", searches reach the entire
|
|
3480
|
-
// persistent index in the same round trip. Cross-source search remains
|
|
3481
|
-
// available via explicit ctx_search() as well.
|
|
3482
|
-
const queryResults = formatBatchQueryResults(store, queries, source, undefined, query_scope);
|
|
3483
|
-
// Get searchable terms for edge cases where follow-up is needed
|
|
3484
|
-
const distinctiveTerms = store.getDistinctiveTerms
|
|
3485
|
-
? store.getDistinctiveTerms(indexed.sourceId)
|
|
3486
|
-
: [];
|
|
3487
|
-
const output = [
|
|
3488
|
-
`Executed ${commands.length} commands (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB). ` +
|
|
3489
|
-
`Indexed ${indexed.totalChunks} sections. Searched ${queries.length} queries.`,
|
|
3490
|
-
"",
|
|
3491
|
-
...commandsInventory,
|
|
3492
|
-
"",
|
|
3493
|
-
...inventory,
|
|
3494
|
-
"",
|
|
3495
|
-
...queryResults,
|
|
3496
|
-
distinctiveTerms.length > 0
|
|
3497
|
-
? `\nSearchable terms for follow-up: ${distinctiveTerms.join(", ")}`
|
|
3498
|
-
: "",
|
|
3499
|
-
].join("\n");
|
|
3500
|
-
return trackResponse("ctx_batch_execute", {
|
|
3501
|
-
content: [{ type: "text", text: output }],
|
|
3502
|
-
});
|
|
3503
|
-
}
|
|
3504
|
-
catch (err) {
|
|
3505
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3506
|
-
return trackResponse("ctx_batch_execute", {
|
|
3507
|
-
content: [
|
|
3508
|
-
{
|
|
3509
|
-
type: "text",
|
|
3510
|
-
text: `Batch execution error: ${message}`,
|
|
3511
|
-
},
|
|
3512
|
-
],
|
|
3513
|
-
isError: true,
|
|
3514
|
-
});
|
|
3515
|
-
}
|
|
3516
|
-
});
|
|
3517
|
-
/**
|
|
3518
|
-
* Pi byte accounting: patch lifetime.totalEvents from bytes_sandboxed
|
|
3519
|
-
* in stats-*.json files instead of the default events × 256 heuristic.
|
|
3520
|
-
* Only active for Pi adapter — other platforms use getLifetimeStats() as-is.
|
|
3521
|
-
*/
|
|
3522
|
-
function patchPiLifetimeFromStatsFiles(lifetime, sessionsDir) {
|
|
3523
|
-
if (!existsSync(sessionsDir))
|
|
3524
|
-
return;
|
|
3525
|
-
let sandboxedBytes = 0;
|
|
3526
|
-
try {
|
|
3527
|
-
for (const f of readdirSync(sessionsDir)) {
|
|
3528
|
-
if (!f.startsWith("stats-") || !f.endsWith(".json"))
|
|
3529
|
-
continue;
|
|
3530
|
-
try {
|
|
3531
|
-
const raw = JSON.parse(readFileSync(join(sessionsDir, f), "utf-8"));
|
|
3532
|
-
sandboxedBytes += (raw?.bytes_sandboxed ?? 0) + (raw?.bytes_indexed ?? 0);
|
|
3533
|
-
}
|
|
3534
|
-
catch { /* corrupt file — skip */ }
|
|
3535
|
-
}
|
|
3536
|
-
}
|
|
3537
|
-
catch { /* never block ctx_stats on stats file I/O */ }
|
|
3538
|
-
if (sandboxedBytes > 0) {
|
|
3539
|
-
const rescueTokens = (lifetime.rescueBytes ?? 0) / 4;
|
|
3540
|
-
lifetime.totalEvents = Math.round((sandboxedBytes / 4 + rescueTokens) / 256);
|
|
3541
|
-
}
|
|
3542
|
-
}
|
|
3543
|
-
// ─────────────────────────────────────────────────────────
|
|
3544
|
-
// Tool: stats
|
|
3545
|
-
// ─────────────────────────────────────────────────────────
|
|
3546
|
-
/**
|
|
3547
|
-
* Create a minimal in-memory DB adapter for when the session DB is unavailable.
|
|
3548
|
-
* All queries return empty results so AnalyticsEngine.queryAll() still works.
|
|
3549
|
-
*/
|
|
3550
|
-
function createMinimalDb() {
|
|
3551
|
-
return {
|
|
3552
|
-
prepare: () => ({
|
|
3553
|
-
run: () => undefined,
|
|
3554
|
-
get: (..._args) => ({ cnt: 0, compact_count: 0, minutes: null, rate: 0, avg: 0, outcome: "exploratory" }),
|
|
3555
|
-
all: () => [],
|
|
3556
|
-
}),
|
|
3557
|
-
};
|
|
3558
|
-
}
|
|
3559
|
-
server.registerTool("ctx_stats", {
|
|
3560
|
-
title: "Session Statistics",
|
|
3561
|
-
// #846: read-only diagnostics. Was cancelled by Codex when unannotated.
|
|
3562
|
-
annotations: {
|
|
3563
|
-
readOnlyHint: true,
|
|
3564
|
-
destructiveHint: false,
|
|
3565
|
-
idempotentHint: true,
|
|
3566
|
-
openWorldHint: false,
|
|
3567
|
-
},
|
|
3568
|
-
description: "Returns context consumption statistics for the current session. " +
|
|
3569
|
-
"Shows total bytes returned to context, breakdown by tool, call counts, " +
|
|
3570
|
-
"estimated token usage, and context savings ratio.",
|
|
3571
|
-
inputSchema: z.object({}),
|
|
3572
|
-
}, async () => {
|
|
3573
|
-
// ONE call, ONE source — AnalyticsEngine.queryAll()
|
|
3574
|
-
let text;
|
|
3575
|
-
try {
|
|
3576
|
-
const projectDir = getProjectDir();
|
|
3577
|
-
// Canonical hash + migration-aware path. The downstream
|
|
3578
|
-
// getConversationStats / getRealBytesStats reconstruct the DB
|
|
3579
|
-
// filename from worktreeHash; pass the SAME canonical hash that
|
|
3580
|
-
// resolveSessionDbPath used so they hit the same file.
|
|
3581
|
-
const dbHash = hashProjectDirCanonical(projectDir);
|
|
3582
|
-
const sessionDbPath = resolveSessionDbPath({
|
|
3583
|
-
projectDir,
|
|
3584
|
-
sessionsDir: getSessionDir(),
|
|
3585
|
-
});
|
|
3586
|
-
if (existsSync(sessionDbPath)) {
|
|
3587
|
-
const Database = loadDatabase();
|
|
3588
|
-
const sdb = new Database(sessionDbPath, { readonly: true });
|
|
3589
|
-
try {
|
|
3590
|
-
const engine = new AnalyticsEngine(sdb);
|
|
3591
|
-
const report = engine.queryAll(sessionStats);
|
|
3592
|
-
// MCP usage is read-only and cheap; only available when DB exists.
|
|
3593
|
-
const mcpUsage = engine.getMcpToolUsage();
|
|
3594
|
-
// Lifetime stats span every project's SessionDB + auto-memory dir
|
|
3595
|
-
// (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
|
|
3596
|
-
// corrupt sidecar can never break ctx_stats.
|
|
3597
|
-
// B3b Slice 3.1: scope to active adapter via getSessionDir() so
|
|
3598
|
-
// non-Claude platforms (Cursor, OpenCode, JetBrains, ...) read
|
|
3599
|
-
// from THEIR sessions dir — not the hardcoded ~/.claude/ default.
|
|
3600
|
-
// Mirrors the statusline contract at src/server.ts:540.
|
|
3601
|
-
const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
3602
|
-
// B3b Slices 3.2-3.6: cross-adapter aggregation so the renderer
|
|
3603
|
-
// can show "Where it came from" + the "across N AI tools"
|
|
3604
|
-
// headline. Best-effort — failures absorbed so a corrupt
|
|
3605
|
-
// sidecar in any adapter dir cannot break ctx_stats.
|
|
3606
|
-
let multiAdapter;
|
|
3607
|
-
try {
|
|
3608
|
-
multiAdapter = getMultiAdapterLifetimeStats();
|
|
3609
|
-
}
|
|
3610
|
-
catch { /* never block ctx_stats */ }
|
|
3611
|
-
// F1: wire conversation + realBytes opts so formatReport renders the
|
|
3612
|
-
// narrative 5-section "kitap gibi" layout (timeline, ladder, receipt,
|
|
3613
|
-
// example cost, auto-memory). Without these, formatReport falls back
|
|
3614
|
-
// to the legacy active-session header. Best-effort — failures absorbed.
|
|
3615
|
-
// Resolve session_id: prefer env (CLAUDE_SESSION_ID), else most-recent
|
|
3616
|
-
// UUID session_id from session_events in this DB.
|
|
3617
|
-
let conversation;
|
|
3618
|
-
let realBytes;
|
|
3619
|
-
try {
|
|
3620
|
-
let sid = process.env.CLAUDE_SESSION_ID;
|
|
3621
|
-
if (!sid) {
|
|
3622
|
-
const row = sdb.prepare("SELECT session_id FROM session_events WHERE session_id LIKE '________-____-____-____-____________' ORDER BY created_at DESC LIMIT 1").get();
|
|
3623
|
-
sid = row?.session_id;
|
|
3624
|
-
}
|
|
3625
|
-
if (sid) {
|
|
3626
|
-
conversation = getConversationStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash });
|
|
3627
|
-
// v1.0.133 Slice 3: pass contentDbPath so getRealBytesStats can
|
|
3628
|
-
// join chunks WHERE session_id = sid and fold the indexed
|
|
3629
|
-
// content bytes into the per-conversation bar. Without this,
|
|
3630
|
-
// Mert's session showed ~200B (event metadata only) even with
|
|
3631
|
-
// 49 MB of indexed content sitting in the content DB.
|
|
3632
|
-
// Render-time read-only — no DB mutation, no backfill.
|
|
3633
|
-
const contentDbPath = getStorePath();
|
|
3634
|
-
// v1.0.148 Bug E+F: a conversation typically spans many
|
|
3635
|
-
// session_ids (resume cycles, /compact rebirths, PID
|
|
3636
|
-
// sub-process sessions launched by ctx_execute). Scoping
|
|
3637
|
-
// per-session loses sandbox-burst bytes_avoided that the
|
|
3638
|
-
// PID-sessions own. Look up THIS session's project_dir
|
|
3639
|
-
// from META and aggregate via META subquery so all
|
|
3640
|
-
// sibling sessions in the same cwd attribute together.
|
|
3641
|
-
// Fallback to sessionId scope if the META lookup fails
|
|
3642
|
-
// (best-effort — the original metric is still defensible).
|
|
3643
|
-
let convReal;
|
|
3644
|
-
try {
|
|
3645
|
-
const Database = loadDatabase();
|
|
3646
|
-
const dbFiles = (await import("node:fs"))
|
|
3647
|
-
.readdirSync(getSessionDir())
|
|
3648
|
-
.filter((f) => f.endsWith(".db") && (!dbHash || f.startsWith(dbHash)));
|
|
3649
|
-
let projectDirForSid;
|
|
3650
|
-
for (const file of dbFiles) {
|
|
3651
|
-
try {
|
|
3652
|
-
const sdb = new Database((await import("node:path")).join(getSessionDir(), file), { readonly: true });
|
|
3653
|
-
try {
|
|
3654
|
-
const r = sdb
|
|
3655
|
-
.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?")
|
|
3656
|
-
.get(sid);
|
|
3657
|
-
if (r?.project_dir) {
|
|
3658
|
-
projectDirForSid = r.project_dir;
|
|
3659
|
-
break;
|
|
3660
|
-
}
|
|
3661
|
-
}
|
|
3662
|
-
finally {
|
|
3663
|
-
sdb.close();
|
|
3664
|
-
}
|
|
3665
|
-
}
|
|
3666
|
-
catch { /* skip unreadable DB */ }
|
|
3667
|
-
}
|
|
3668
|
-
// Section 1 "Where you are now" = the LIVE conversation window.
|
|
3669
|
-
// Sub-agents + ctx_execute sub-process sessions write to this
|
|
3670
|
-
// SAME worktree DB (same worktreeHash = sha256(cwd)) under their
|
|
3671
|
-
// own session_ids; their retrieval hit their own disposable
|
|
3672
|
-
// windows, not yours. getConversationWindowStats credits the
|
|
3673
|
-
// whole worktree's kept-out bytes while counting only THIS
|
|
3674
|
-
// session's retrieval as "With context-mode", and the
|
|
3675
|
-
// worktreeHash scope keeps the user's OTHER parallel worktrees
|
|
3676
|
-
// out. projectDirForSid is intentionally dropped — it
|
|
3677
|
-
// under-counted (missed empty-project_dir sub-process sessions)
|
|
3678
|
-
// and could not separate sub-agent retrieval from the window's.
|
|
3679
|
-
void projectDirForSid;
|
|
3680
|
-
convReal = getConversationWindowStats({ sessionId: sid, worktreeHash: dbHash, sessionsDir: getSessionDir(), contentDbPath });
|
|
3681
|
-
}
|
|
3682
|
-
catch {
|
|
3683
|
-
convReal = getConversationWindowStats({ sessionId: sid, worktreeHash: dbHash, sessionsDir: getSessionDir(), contentDbPath });
|
|
3684
|
-
}
|
|
3685
|
-
const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
|
|
3686
|
-
// v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
|
|
3687
|
-
// session_id filter). Without this fold, lifetime "kept out"
|
|
3688
|
-
// only counts session_events.bytes_avoided and ignores the
|
|
3689
|
-
// bulk of indexed payload across every prior conversation.
|
|
3690
|
-
const lifeContentBytes = getContentBytesAllSessions(contentDbPath);
|
|
3691
|
-
const lifeReal = {
|
|
3692
|
-
...lifeRealBase,
|
|
3693
|
-
contentBytes: lifeRealBase.contentBytes + lifeContentBytes,
|
|
3694
|
-
bytesAvoided: lifeRealBase.bytesAvoided + lifeContentBytes,
|
|
3695
|
-
totalSavedTokens: Math.floor((lifeRealBase.eventDataBytes
|
|
3696
|
-
+ lifeRealBase.bytesAvoided
|
|
3697
|
-
+ lifeContentBytes
|
|
3698
|
-
+ lifeRealBase.snapshotBytes) / 4),
|
|
3699
|
-
};
|
|
3700
|
-
realBytes = { conversation: convReal, lifetime: lifeReal };
|
|
3701
|
-
}
|
|
3702
|
-
}
|
|
3703
|
-
catch { /* never block ctx_stats */ }
|
|
3704
|
-
// Pi byte accounting: patch lifetime from stats-*.json files
|
|
3705
|
-
// (actual bytes_sandboxed, not events × 256 heuristic).
|
|
3706
|
-
if (_detectedAdapter?.name === "Pi") {
|
|
3707
|
-
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3708
|
-
}
|
|
3709
|
-
// v1.0.117: pass projectDir as cwd so the narrative renderer's
|
|
3710
|
-
// "started in <path>" line matches the user's actual project.
|
|
3711
|
-
// Snapshot the persistent store so the renderer can show
|
|
3712
|
-
// total_chunks / last_indexed_at without callers having to query
|
|
3713
|
-
// separately. Best-effort — getStore() is process-local and may
|
|
3714
|
-
// be unavailable on cold paths; failures are absorbed.
|
|
3715
|
-
let indexState;
|
|
3716
|
-
try {
|
|
3717
|
-
indexState = getStore().getIndexState();
|
|
3718
|
-
}
|
|
3719
|
-
catch { /* never block ctx_stats */ }
|
|
3720
|
-
text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage, multiAdapter, conversation, realBytes, indexState, cwd: projectDir });
|
|
3721
|
-
}
|
|
3722
|
-
finally {
|
|
3723
|
-
sdb.close();
|
|
3724
|
-
}
|
|
3725
|
-
}
|
|
3726
|
-
else {
|
|
3727
|
-
// No session DB — build a minimal report from runtime stats only.
|
|
3728
|
-
// Lifetime still meaningful (other projects, auto-memory) so include it.
|
|
3729
|
-
const engine = new AnalyticsEngine(createMinimalDb());
|
|
3730
|
-
const report = engine.queryAll(sessionStats);
|
|
3731
|
-
const lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
3732
|
-
if (_detectedAdapter?.name === "Pi") {
|
|
3733
|
-
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3734
|
-
}
|
|
3735
|
-
let multiAdapter;
|
|
3736
|
-
try {
|
|
3737
|
-
multiAdapter = getMultiAdapterLifetimeStats();
|
|
3738
|
-
}
|
|
3739
|
-
catch { /* never block ctx_stats */ }
|
|
3740
|
-
let indexState;
|
|
3741
|
-
try {
|
|
3742
|
-
indexState = getStore().getIndexState();
|
|
3743
|
-
}
|
|
3744
|
-
catch { /* never block ctx_stats */ }
|
|
3745
|
-
text = formatReport(report, VERSION, _latestVersion, { lifetime, multiAdapter, indexState });
|
|
3746
|
-
}
|
|
3747
|
-
}
|
|
3748
|
-
catch {
|
|
3749
|
-
// Session DB not available or incompatible — build minimal report from runtime stats
|
|
3750
|
-
const engine = new AnalyticsEngine(createMinimalDb());
|
|
3751
|
-
const report = engine.queryAll(sessionStats);
|
|
3752
|
-
let lifetime;
|
|
3753
|
-
try {
|
|
3754
|
-
lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
|
|
3755
|
-
}
|
|
3756
|
-
catch { /* never block ctx_stats */ }
|
|
3757
|
-
if (_detectedAdapter?.name === "Pi" && lifetime) {
|
|
3758
|
-
patchPiLifetimeFromStatsFiles(lifetime, getSessionDir());
|
|
3759
|
-
}
|
|
3760
|
-
let multiAdapter;
|
|
3761
|
-
try {
|
|
3762
|
-
multiAdapter = getMultiAdapterLifetimeStats();
|
|
3763
|
-
}
|
|
3764
|
-
catch { /* never block ctx_stats */ }
|
|
3765
|
-
text = formatReport(report, VERSION, _latestVersion, (lifetime || multiAdapter) ? { lifetime, multiAdapter } : undefined);
|
|
3766
|
-
}
|
|
3767
|
-
return trackResponse("ctx_stats", {
|
|
3768
|
-
content: [{ type: "text", text }],
|
|
3769
|
-
});
|
|
3770
|
-
});
|
|
3771
|
-
// ── ctx-doctor: diagnostics (server-side) ─────────────────────────────────
|
|
3772
|
-
server.registerTool("ctx_doctor", {
|
|
3773
|
-
title: "Run Diagnostics",
|
|
3774
|
-
// #846: read-only diagnostics (runs an internal self-test, mutates nothing).
|
|
3775
|
-
// Was cancelled by Codex when unannotated.
|
|
3776
|
-
annotations: {
|
|
3777
|
-
readOnlyHint: true,
|
|
3778
|
-
destructiveHint: false,
|
|
3779
|
-
idempotentHint: true,
|
|
3780
|
-
openWorldHint: false,
|
|
3781
|
-
},
|
|
3782
|
-
description: "Diagnose context-mode installation. Runs all checks server-side and " +
|
|
3783
|
-
"returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
|
|
3784
|
-
"(renderer-safe across MCP clients). No CLI execution needed.",
|
|
3785
|
-
inputSchema: z.object({}),
|
|
3786
|
-
}, async () => {
|
|
3787
|
-
// Renderer-safe output (Mickey #3 — Z.ai GLM 4.7 ReferenceError):
|
|
3788
|
-
// Z.ai's MCP renderer mounts a custom React component for GitHub-flavored
|
|
3789
|
-
// markdown task-list syntax (`- [x]` / `- [ ]` / `- [-]`) that depends on
|
|
3790
|
-
// a missing `client` context, throwing `ReferenceError: client is not
|
|
3791
|
-
// defined`. We avoid both task-list syntax AND `## ` h2 headings to stay
|
|
3792
|
-
// safe across all MCP renderers — using plain-text status prefixes
|
|
3793
|
-
// (`[OK]` / `[FAIL]` / `[WARN]`) instead.
|
|
3794
|
-
const lines = ["context-mode doctor", ""];
|
|
3795
|
-
let currentPlatform;
|
|
3796
|
-
try {
|
|
3797
|
-
currentPlatform = detectPlatform(server.server.getClientVersion() ?? undefined).platform;
|
|
3798
|
-
}
|
|
3799
|
-
catch {
|
|
3800
|
-
currentPlatform = detectPlatform().platform;
|
|
3801
|
-
}
|
|
3802
|
-
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
|
|
3803
|
-
// Codex is special: when plugin-manager runtime root differs from the
|
|
3804
|
-
// current package root, diagnose the root Codex will actually execute.
|
|
3805
|
-
const pluginRoot = getRuntimeAwarePackageRoot(currentPlatform);
|
|
3806
|
-
// Runtimes
|
|
3807
|
-
const total = 11;
|
|
3808
|
-
const pct = ((available.length / total) * 100).toFixed(0);
|
|
3809
|
-
lines.push(`[OK] Runtimes: ${available.length}/${total} (${pct}%) — ${available.join(", ")}`);
|
|
3810
|
-
// Performance
|
|
3811
|
-
if (hasBunRuntime()) {
|
|
3812
|
-
lines.push("[OK] Performance: FAST (Bun)");
|
|
3813
|
-
}
|
|
3814
|
-
else {
|
|
3815
|
-
lines.push("[WARN] Performance: NORMAL — install Bun for 3-5x speed boost");
|
|
3816
|
-
}
|
|
3817
|
-
const sessionStorage = resolveSessionStorageDir(getDefaultSessionDir);
|
|
3818
|
-
const contentStorage = resolveContentStorageDir(getDefaultSessionDir);
|
|
3819
|
-
const statsStorage = resolveStatsStorageDir(getDefaultSessionDir);
|
|
3820
|
-
lines.push(`[OK] Storage sessions: ${sessionStorage.path} (${describeStorageDirectorySource(sessionStorage)})`);
|
|
3821
|
-
lines.push(`[OK] Storage content: ${contentStorage.path} (${describeStorageDirectorySource(contentStorage)})`);
|
|
3822
|
-
lines.push(`[OK] Storage stats: ${statsStorage.path} (${describeStorageDirectorySource(statsStorage)})`);
|
|
3823
|
-
// Server test — cleanup executor to prevent resource leaks (#247)
|
|
3824
|
-
{
|
|
3825
|
-
const testExecutor = new PolyglotExecutor({ runtimes });
|
|
3826
|
-
try {
|
|
3827
|
-
const result = await testExecutor.execute({ language: "javascript", code: 'console.log("ok");', timeout: 5000 });
|
|
3828
|
-
if (result.exitCode === 0 && result.stdout.trim() === "ok") {
|
|
3829
|
-
lines.push("[OK] Server test: PASS");
|
|
3830
|
-
}
|
|
3831
|
-
else {
|
|
3832
|
-
const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
|
|
3833
|
-
lines.push(`[FAIL] Server test: FAIL — exit ${result.exitCode}${detail}`);
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
|
-
catch (err) {
|
|
3837
|
-
lines.push(`[FAIL] Server test: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
3838
|
-
}
|
|
3839
|
-
finally {
|
|
3840
|
-
testExecutor.cleanupBackgrounded();
|
|
3841
|
-
}
|
|
3842
|
-
}
|
|
3843
|
-
// FTS5 / SQLite — close in finally to prevent GC segfault (#247)
|
|
3844
|
-
{
|
|
3845
|
-
let testDb;
|
|
3846
|
-
try {
|
|
3847
|
-
const Database = loadDatabase();
|
|
3848
|
-
testDb = new Database(":memory:");
|
|
3849
|
-
testDb.exec("CREATE VIRTUAL TABLE fts_test USING fts5(content)");
|
|
3850
|
-
testDb.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
|
|
3851
|
-
const row = testDb.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get();
|
|
3852
|
-
if (row && row.content === "hello world") {
|
|
3853
|
-
lines.push("[OK] FTS5 / SQLite: PASS — native module works");
|
|
3854
|
-
}
|
|
3855
|
-
else {
|
|
3856
|
-
lines.push("[FAIL] FTS5 / SQLite: FAIL — unexpected result");
|
|
3857
|
-
}
|
|
3858
|
-
}
|
|
3859
|
-
catch (err) {
|
|
3860
|
-
lines.push(`[FAIL] FTS5 / SQLite: FAIL — ${err instanceof Error ? err.message : err}`);
|
|
3861
|
-
}
|
|
3862
|
-
finally {
|
|
3863
|
-
try {
|
|
3864
|
-
testDb?.close();
|
|
3865
|
-
}
|
|
3866
|
-
catch { /* best effort */ }
|
|
3867
|
-
}
|
|
3868
|
-
}
|
|
3869
|
-
// Hooks
|
|
3870
|
-
const diagnosticAdapter = await getDiagnosticAdapter();
|
|
3871
|
-
if (diagnosticAdapter) {
|
|
3872
|
-
for (const result of diagnosticAdapter.validateHooks(pluginRoot)) {
|
|
3873
|
-
const prefix = result.status === "pass" ? "[OK]" : result.status === "warn" ? "[WARN]" : "[FAIL]";
|
|
3874
|
-
const fix = result.fix ? ` — fix: ${result.fix}` : "";
|
|
3875
|
-
lines.push(`${prefix} ${result.check}: ${result.message}${fix}`);
|
|
3876
|
-
}
|
|
3877
|
-
const hookScriptPaths = getHookScriptPaths(diagnosticAdapter, pluginRoot);
|
|
3878
|
-
if (hookScriptPaths.length === 0) {
|
|
3879
|
-
lines.push("[OK] Hook scripts: no direct .mjs script paths to verify");
|
|
3880
|
-
}
|
|
3881
|
-
for (const scriptPath of hookScriptPaths) {
|
|
3882
|
-
const hookPath = resolve(pluginRoot, scriptPath);
|
|
3883
|
-
if (existsSync(hookPath)) {
|
|
3884
|
-
lines.push(`[OK] Hook script: PASS — ${hookPath}`);
|
|
3885
|
-
}
|
|
3886
|
-
else {
|
|
3887
|
-
lines.push(`[FAIL] Hook script: FAIL — not found at ${hookPath}`);
|
|
3888
|
-
}
|
|
3889
|
-
}
|
|
3890
|
-
}
|
|
3891
|
-
else {
|
|
3892
|
-
lines.push("[WARN] Hooks: adapter detection unavailable");
|
|
3893
|
-
}
|
|
3894
|
-
// Version
|
|
3895
|
-
lines.push(`[OK] Version: v${VERSION}`);
|
|
3896
|
-
return trackResponse("ctx_doctor", {
|
|
3897
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
3898
|
-
});
|
|
3899
|
-
});
|
|
3900
|
-
// ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
|
|
3901
|
-
server.registerTool("ctx_upgrade", {
|
|
3902
|
-
title: "Upgrade Plugin",
|
|
3903
|
-
// #846: an action tool (returns an upgrade command to run); not read-only,
|
|
3904
|
-
// but non-destructive and idempotent. No direct network from the call.
|
|
3905
|
-
annotations: {
|
|
3906
|
-
readOnlyHint: false,
|
|
3907
|
-
destructiveHint: false,
|
|
3908
|
-
idempotentHint: true,
|
|
3909
|
-
openWorldHint: false,
|
|
3910
|
-
},
|
|
3911
|
-
description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
|
|
3912
|
-
"You MUST run the returned command using your shell tool (Bash, shell_execute, " +
|
|
3913
|
-
"run_in_terminal, etc.) and display the output as a checklist. " +
|
|
3914
|
-
"Tell the user to restart their session after upgrade.",
|
|
3915
|
-
inputSchema: z.object({}),
|
|
3916
|
-
}, async () => {
|
|
3917
|
-
// Issue #542 — thread MCP clientInfo into the spawned upgrade
|
|
3918
|
-
// process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
|
|
3919
|
-
// so clientInfo from the MCP handshake is the highest-confidence
|
|
3920
|
-
// signal available. We forward the resolved PlatformId as a
|
|
3921
|
-
// --platform flag (cross-shell safe on POSIX, Git Bash, PowerShell,
|
|
3922
|
-
// and cmd.exe — unlike env-var prefixes). If detection fails we
|
|
3923
|
-
// skip the flag and let upgrade()'s own detectPlatform() fall back.
|
|
3924
|
-
let platformFlag = "";
|
|
3925
|
-
let nodeOpts = undefined;
|
|
3926
|
-
let platformId;
|
|
3927
|
-
try {
|
|
3928
|
-
const clientInfo = server.server.getClientVersion();
|
|
3929
|
-
const signal = detectPlatform(clientInfo ?? undefined);
|
|
3930
|
-
platformId = signal.platform;
|
|
3931
|
-
platformFlag = ` --platform ${signal.platform}`;
|
|
3932
|
-
nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
|
|
3933
|
-
? { platform: signal.platform, jsRuntime: runtimes.javascript }
|
|
3934
|
-
: undefined;
|
|
3935
|
-
}
|
|
3936
|
-
catch {
|
|
3937
|
-
try {
|
|
3938
|
-
platformId = detectPlatform().platform;
|
|
3939
|
-
}
|
|
3940
|
-
catch { /* best effort — fall back to upgrade()'s own detect */ }
|
|
3941
|
-
}
|
|
3942
|
-
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
|
|
3943
|
-
// Only Codex may replace it with the plugin-manager runtime root; other
|
|
3944
|
-
// adapters can coexist with Codex on the same machine.
|
|
3945
|
-
const pluginRoot = getRuntimeAwarePackageRoot(platformId);
|
|
3946
|
-
const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
|
|
3947
|
-
const fallbackPath = resolve(pluginRoot, "build", "cli.js");
|
|
3948
|
-
// Insight pivoted to the hosted dashboard (context-mode.com/insight), so
|
|
3949
|
-
// ctx_insight no longer builds a local cache. On upgrade, sweep the legacy
|
|
3950
|
-
// insight-cache and stop any stale local dashboard left from old versions.
|
|
3951
|
-
try {
|
|
3952
|
-
const sessDir = getSessionDir();
|
|
3953
|
-
const insightCacheDir = join(dirname(sessDir), "insight-cache");
|
|
3954
|
-
if (existsSync(insightCacheDir)) {
|
|
3955
|
-
// Kill any running insight server first via the shared helper —
|
|
3956
|
-
// this is locale-independent on Windows (PR #469) and isolates per-pid
|
|
3957
|
-
// failures. We ignore the structured result: cache cleanup is
|
|
3958
|
-
// best-effort and must never block ctx_upgrade.
|
|
3959
|
-
killProcessOnPort(4747);
|
|
3960
|
-
rmSync(insightCacheDir, { recursive: true, force: true });
|
|
3961
|
-
}
|
|
3962
|
-
}
|
|
3963
|
-
catch { /* best effort — don't block upgrade */ }
|
|
3964
|
-
let cmd;
|
|
3965
|
-
if (existsSync(bundlePath)) {
|
|
3966
|
-
cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
|
|
3967
|
-
}
|
|
3968
|
-
else if (existsSync(fallbackPath)) {
|
|
3969
|
-
cmd = `${buildNodeCommand(fallbackPath, nodeOpts)} upgrade${platformFlag}`;
|
|
3970
|
-
}
|
|
3971
|
-
else {
|
|
3972
|
-
// Inline fallback: neither CLI file exists (e.g. marketplace installs).
|
|
3973
|
-
// Generate a self-contained node -e script that performs the upgrade.
|
|
3974
|
-
const repoUrl = "https://github.com/mksglu/context-mode.git";
|
|
3975
|
-
// Write inline script to a temp .mjs file — avoids quote-escaping issues
|
|
3976
|
-
// across cmd.exe, PowerShell, and bash (node -e '...' breaks on Windows).
|
|
3977
|
-
const scriptLines = [
|
|
3978
|
-
`import{execFileSync}from"node:child_process";`,
|
|
3979
|
-
`import{cpSync,rmSync,existsSync,mkdtempSync,readFileSync,writeFileSync,lstatSync}from"node:fs";`,
|
|
3980
|
-
`import{join,resolve,sep}from"node:path";`,
|
|
3981
|
-
`import{tmpdir}from"node:os";`,
|
|
3982
|
-
`const P=${JSON.stringify(pluginRoot)};`,
|
|
3983
|
-
`const T=mkdtempSync(join(tmpdir(),"ctx-upgrade-"));`,
|
|
3984
|
-
`try{`,
|
|
3985
|
-
`console.log("- [x] Starting inline upgrade (no CLI found)");`,
|
|
3986
|
-
`execFileSync("git",["clone","--depth","1","${repoUrl}",T],{stdio:"inherit"});`,
|
|
3987
|
-
`console.log("- [x] Cloned latest source");`,
|
|
3988
|
-
`execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
|
|
3989
|
-
`execFileSync(process.platform==="win32"?"npm.cmd":"npm",["run","build"],{cwd:T,stdio:"inherit",shell:process.platform==="win32"});`,
|
|
3990
|
-
`console.log("- [x] Built from source");`,
|
|
3991
|
-
`const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
|
|
3992
|
-
`const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
|
|
3993
|
-
// Supply-chain containment on items[]. Mirror the cli.ts upgrade()
|
|
3994
|
-
// guard: a compromised upstream package.json with files:["../etc"]
|
|
3995
|
-
// would otherwise let path.join follow ".." out of pluginRoot.
|
|
3996
|
-
// path.resolve normalizes "..", so the lexical startsWith catches
|
|
3997
|
-
// both relative-".." traversal and absolute-path bypass. Plus a
|
|
3998
|
-
// symlink filter so a committed symlink inside the clone can't
|
|
3999
|
-
// plant itself in pluginRoot (cpSync default preserves source
|
|
4000
|
-
// symlinks; a planted symlink in pluginRoot/src then redirects
|
|
4001
|
-
// every subsequent load through to an attacker target).
|
|
4002
|
-
`const PW=resolve(P)+sep;const TW=resolve(T)+sep;`,
|
|
4003
|
-
`const noSymlink=(src)=>{try{return !lstatSync(src).isSymbolicLink()}catch{return false}};`,
|
|
4004
|
-
`for(const item of items){const from=resolve(T,item);const to=resolve(P,item);if(!(to+sep).startsWith(PW))continue;if(!(from+sep).startsWith(TW))continue;if(!noSymlink(from))continue;if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true,filter:noSymlink});}}`,
|
|
4005
|
-
// Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
|
|
4006
|
-
// .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
|
|
4007
|
-
// per-version .mcp.json file is a stale-write vector. Same architectural
|
|
4008
|
-
// fix as the cli.ts upgrade() path; both writers were the only producers.
|
|
4009
|
-
`console.log("- [x] Copied package files");`,
|
|
4010
|
-
`execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install","--production"],{cwd:P,stdio:"inherit",shell:process.platform==="win32"});`,
|
|
4011
|
-
`console.log("- [x] Installed production dependencies");`,
|
|
4012
|
-
`console.log("## context-mode upgrade complete");`,
|
|
4013
|
-
`}catch(e){`,
|
|
4014
|
-
`console.error("- [ ] Upgrade failed:",e.message);`,
|
|
4015
|
-
`process.exit(1);`,
|
|
4016
|
-
`}finally{`,
|
|
4017
|
-
`try{rmSync(T,{recursive:true,force:true})}catch{}`,
|
|
4018
|
-
`}`,
|
|
4019
|
-
].join("\n");
|
|
4020
|
-
// Server writes the temp script file — avoids shell quoting issues entirely
|
|
4021
|
-
const tmpScript = resolve(pluginRoot, ".ctx-upgrade-inline.mjs");
|
|
4022
|
-
const { writeFileSync: writeTmp } = await import("node:fs");
|
|
4023
|
-
writeTmp(tmpScript, scriptLines);
|
|
4024
|
-
cmd = buildNodeCommand(tmpScript, nodeOpts);
|
|
4025
|
-
}
|
|
4026
|
-
const text = [
|
|
4027
|
-
"## ctx-upgrade",
|
|
4028
|
-
"",
|
|
4029
|
-
"Run this command using your shell execution tool:",
|
|
4030
|
-
"",
|
|
4031
|
-
"```",
|
|
4032
|
-
cmd,
|
|
4033
|
-
"```",
|
|
4034
|
-
"",
|
|
4035
|
-
"After the command completes, display results as a markdown checklist:",
|
|
4036
|
-
"- `[x]` for success, `[ ]` for failure",
|
|
4037
|
-
"- Example format:",
|
|
4038
|
-
" ```",
|
|
4039
|
-
" ## context-mode upgrade",
|
|
4040
|
-
" - [x] Pulled latest from GitHub",
|
|
4041
|
-
" - [x] Built and installed v0.9.24",
|
|
4042
|
-
" - [x] npm global updated",
|
|
4043
|
-
" - [x] Hooks configured",
|
|
4044
|
-
" - [x] Doctor: all checks PASS",
|
|
4045
|
-
" ```",
|
|
4046
|
-
"- Tell the user to restart their session to pick up the new version.",
|
|
4047
|
-
].join("\n");
|
|
4048
|
-
return trackResponse("ctx_upgrade", {
|
|
4049
|
-
content: [{ type: "text", text }],
|
|
4050
|
-
});
|
|
4051
|
-
});
|
|
4052
|
-
// ── ctx-purge: explicit knowledge base wipe ─────────────────────────────────
|
|
4053
|
-
//
|
|
4054
|
-
// Issue #520 — scoped purge.
|
|
4055
|
-
// The schema is ADDITIVE: bare {confirm:true} preserves the legacy
|
|
4056
|
-
// project-wide wipe verbatim (with a stderr deprecation warning so
|
|
4057
|
-
// future callers migrate to explicit scope). When sessionId is given,
|
|
4058
|
-
// only that session's rows + FTS5 chunks are removed; project-wide
|
|
4059
|
-
// files (events.md, FTS5 store file, stats file) are preserved.
|
|
4060
|
-
// Passing both sessionId AND scope:"project" is ambiguous (does the
|
|
4061
|
-
// caller want a per-session wipe or a project-wide one?) and is
|
|
4062
|
-
// rejected by an explicit check in the handler body — NOT a schema-level
|
|
4063
|
-
// .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
|
|
4064
|
-
// inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
|
|
4065
|
-
// has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
|
|
4066
|
-
// Code's strict-input-validation gate then rejects EVERY call to this
|
|
4067
|
-
// tool with "input_schema does not support fields". Issue #563.
|
|
4068
|
-
server.registerTool("ctx_purge", {
|
|
4069
|
-
title: "Purge Knowledge Base",
|
|
4070
|
-
// #846: permanently deletes indexed content — destructive. Purging an
|
|
4071
|
-
// already-purged scope has no further effect (idempotent). No network.
|
|
4072
|
-
annotations: {
|
|
4073
|
-
readOnlyHint: false,
|
|
4074
|
-
destructiveHint: true,
|
|
4075
|
-
idempotentHint: true,
|
|
4076
|
-
openWorldHint: false,
|
|
4077
|
-
},
|
|
4078
|
-
description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
|
|
4079
|
-
|
|
4080
|
-
WHEN:
|
|
4081
|
-
- User explicitly asks to clear a specific session ('purge this session', 'wipe this conversation')
|
|
4082
|
-
- User explicitly asks to reset the whole project ('reset everything', 'wipe the knowledge base')
|
|
4083
|
-
|
|
4084
|
-
WHEN NOT:
|
|
4085
|
-
- User says 'reset', 'clear', or 'wipe' without naming a scope -> ask which scope before calling
|
|
4086
|
-
- User wants to free memory or improve performance -> recommend ctx_stats first, do not purge
|
|
4087
|
-
|
|
4088
|
-
SCOPES (pass exactly one):
|
|
4089
|
-
- Per-session: ctx_purge(confirm: true, sessionId: "<uuid>") deletes that session's events (auto-captured decisions, errors, plans, user prompts, rejected approaches, etc.) and per-session FTS5 chunks; sibling sessions and stats file are preserved.
|
|
4090
|
-
- Per-project: ctx_purge(confirm: true, scope: "project") wipes FTS5 knowledge base, every session DB row, events markdown, and resets the stats file. Use ctx_stats first to preview category counts before purging.
|
|
4091
|
-
|
|
4092
|
-
CONTRACT:
|
|
4093
|
-
- confirm:true is required; confirm:false returns 'purge cancelled'.
|
|
4094
|
-
- sessionId and scope:'project' together return 'ambiguous - pick one'.
|
|
4095
|
-
- scope:'session' without sessionId throws (sessionId required).
|
|
4096
|
-
- Bare {confirm:true} is deprecated: maps to scope:'project' with a stderr warning; will hard-error in a future major.
|
|
4097
|
-
|
|
4098
|
-
RETURNS:
|
|
4099
|
-
A summary of removed rows + the resolved scope.
|
|
4100
|
-
|
|
4101
|
-
EXAMPLE: ctx_purge(confirm: true, sessionId: "7c8a-1234-5678-9abc-def012345678")
|
|
4102
|
-
EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
|
|
4103
|
-
// NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
|
|
4104
|
-
// .superRefine() wrapper. See block comment above & issue #563. The
|
|
4105
|
-
// cross-field ambiguity check lives in the handler body below.
|
|
4106
|
-
inputSchema: z.object({
|
|
4107
|
-
// confirm: wrapped in coerceBoolean preprocessor — OpenCode's native
|
|
4108
|
-
// plugin bridge can deliver `confirm:"true"` / `confirm:"false"` as
|
|
4109
|
-
// string literals. Without this, v1.0.139's inputSchema.parse() path
|
|
4110
|
-
// rejects valid intent as "Expected boolean, received string" (#627).
|
|
4111
|
-
confirm: z.preprocess(coerceBoolean, z.boolean()).describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
|
|
4112
|
-
sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
|
|
4113
|
-
"session's events + per-session FTS5 chunks. Sibling sessions and the " +
|
|
4114
|
-
"stats file are preserved. MUST NOT be combined with scope:'project'."),
|
|
4115
|
-
scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
|
|
4116
|
-
"the entire project (FTS5 + every session + stats). Omit only for the " +
|
|
4117
|
-
"deprecated bare-{confirm:true} back-compat path."),
|
|
4118
|
-
}),
|
|
4119
|
-
}, async ({ confirm, sessionId, scope }) => {
|
|
4120
|
-
// Cross-field ambiguity check — formerly a schema .refine(), moved
|
|
4121
|
-
// into the handler so the inputSchema stays a plain ZodObject and
|
|
4122
|
-
// the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
|
|
4123
|
-
// Same human-readable message as the original refine() preserved.
|
|
4124
|
-
if (sessionId && scope === "project") {
|
|
4125
|
-
return trackResponse("ctx_purge", {
|
|
4126
|
-
content: [{
|
|
4127
|
-
type: "text",
|
|
4128
|
-
text: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
|
|
4129
|
-
"Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
|
|
4130
|
-
}],
|
|
4131
|
-
isError: true,
|
|
4132
|
-
});
|
|
4133
|
-
}
|
|
4134
|
-
if (!confirm) {
|
|
4135
|
-
return trackResponse("ctx_purge", {
|
|
4136
|
-
content: [{
|
|
4137
|
-
type: "text",
|
|
4138
|
-
text: "Purge cancelled. Pass confirm: true to proceed.",
|
|
4139
|
-
}],
|
|
4140
|
-
});
|
|
4141
|
-
}
|
|
4142
|
-
// Effective scope resolution:
|
|
4143
|
-
// - explicit scope wins
|
|
4144
|
-
// - else "session" iff sessionId is given
|
|
4145
|
-
// - else "project" (back-compat — emit deprecation warning so
|
|
4146
|
-
// callers migrate to the explicit form before a future major).
|
|
4147
|
-
const effectiveScope = scope ?? (sessionId ? "session" : "project");
|
|
4148
|
-
if (!scope && !sessionId) {
|
|
4149
|
-
console.warn("[context-mode] ctx_purge: bare {confirm:true} is deprecated. " +
|
|
4150
|
-
"Pass scope:'project' for the whole-project wipe, or scope:'session' + sessionId " +
|
|
4151
|
-
"for a scoped wipe. See issue #520.");
|
|
4152
|
-
}
|
|
4153
|
-
// Close the persistent FTS5 content store handle BEFORE delegating to
|
|
4154
|
-
// purgeSession so the store's lock is released on Windows. The handle
|
|
4155
|
-
// is recreated lazily on the next getStore() call.
|
|
4156
|
-
let storePathForPurge;
|
|
4157
|
-
try {
|
|
4158
|
-
storePathForPurge = getStorePath();
|
|
4159
|
-
}
|
|
4160
|
-
catch { /* best effort — store path may be unresolvable on fresh install */ }
|
|
4161
|
-
if (_store) {
|
|
4162
|
-
try {
|
|
4163
|
-
_store.cleanup();
|
|
4164
|
-
}
|
|
4165
|
-
catch { /* best effort */ }
|
|
4166
|
-
_store = null;
|
|
4167
|
-
}
|
|
4168
|
-
// FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
|
|
4169
|
-
// and legacy raw-casing variants (dual-hash, mirrors session events).
|
|
4170
|
-
// storePath is also passed for the rare case where the resolver picked
|
|
4171
|
-
// an absolute path that differs from the dual-hash pair (e.g. caller
|
|
4172
|
-
// pre-migrated). Both paths are de-duped during unlink.
|
|
4173
|
-
const contentDir = storePathForPurge ? dirname(storePathForPurge) : undefined;
|
|
4174
|
-
const { deleted } = purgeSession({
|
|
4175
|
-
projectDir: getProjectDir(),
|
|
4176
|
-
sessionsDir: getSessionDir(),
|
|
4177
|
-
storePath: storePathForPurge,
|
|
4178
|
-
contentDir,
|
|
4179
|
-
legacyContentDir: join(homedir(), ".context-mode", "content"),
|
|
4180
|
-
// hashProjectDirLegacy mirrors the deployed (≤ v1.0.111) raw-casing
|
|
4181
|
-
// hash that named files under ~/.context-mode/content/. Using the
|
|
4182
|
-
// legacy hash here is correct: that pre-pre-legacy directory was
|
|
4183
|
-
// never migrated and still uses raw casing.
|
|
4184
|
-
contentHash: hashProjectDirLegacy(getProjectDir()),
|
|
4185
|
-
scope: effectiveScope,
|
|
4186
|
-
sessionId,
|
|
4187
|
-
});
|
|
4188
|
-
// Stats are PROJECT-scoped (one stats file per project, summing all
|
|
4189
|
-
// sessions). A scoped per-session purge MUST leave stats alone — they
|
|
4190
|
-
// still belong to other sessions in the same project. Stats reset
|
|
4191
|
-
// happens ONLY when scope === "project".
|
|
4192
|
-
if (effectiveScope === "project") {
|
|
4193
|
-
// Reset in-memory session stats
|
|
4194
|
-
sessionStats.calls = {};
|
|
4195
|
-
sessionStats.bytesReturned = {};
|
|
4196
|
-
sessionStats.bytesIndexed = 0;
|
|
4197
|
-
sessionStats.bytesSandboxed = 0;
|
|
4198
|
-
sessionStats.cacheHits = 0;
|
|
4199
|
-
sessionStats.cacheBytesSaved = 0;
|
|
4200
|
-
sessionStats.sessionStart = Date.now();
|
|
4201
|
-
deleted.push("session stats");
|
|
4202
|
-
// Also drop the persisted stats file so external readers see a fresh state
|
|
4203
|
-
try {
|
|
4204
|
-
const statsFile = getStatsFilePath();
|
|
4205
|
-
if (existsSync(statsFile))
|
|
4206
|
-
unlinkSync(statsFile);
|
|
4207
|
-
}
|
|
4208
|
-
catch { /* best effort */ }
|
|
4209
|
-
}
|
|
4210
|
-
const message = effectiveScope === "session"
|
|
4211
|
-
? `Purged session ${sessionId}: ${deleted.length ? deleted.join(", ") : "no matching rows"}. ` +
|
|
4212
|
-
`Other sessions and project-wide stats preserved.`
|
|
4213
|
-
: `Purged: ${deleted.join(", ")}. All session data for this project has been permanently deleted.`;
|
|
4214
|
-
return trackResponse("ctx_purge", {
|
|
4215
|
-
content: [{
|
|
4216
|
-
type: "text",
|
|
4217
|
-
text: message,
|
|
4218
|
-
}],
|
|
4219
|
-
});
|
|
4220
|
-
});
|
|
4221
|
-
// Hard upper bound on every helper-internal spawnSync call. Caps tail-latency
|
|
4222
|
-
// when an external binary hangs (xdg-open waiting for an X11 session, lsof
|
|
4223
|
-
// stalling on /proc, taskkill blocking on an unresponsive process, etc.) so
|
|
4224
|
-
// the MCP tool surfaces a diagnostic instead of blocking the agent loop.
|
|
4225
|
-
// 5s is comfortably above the 99th-percentile completion of every command we
|
|
4226
|
-
// invoke; anything past that is hung.
|
|
4227
|
-
const HELPER_SPAWN_TIMEOUT_MS = 5000;
|
|
4228
|
-
// Returns the argv attempts for opening `url` on `platform`, in fall-back order.
|
|
4229
|
-
// Pure data — no I/O.
|
|
4230
|
-
export function browserOpenArgv(url, platform) {
|
|
4231
|
-
if (platform === "darwin")
|
|
4232
|
-
return [{ cmd: "open", args: [url] }];
|
|
4233
|
-
if (platform === "win32") {
|
|
4234
|
-
// `start` is a cmd.exe builtin; the empty title arg ("") prevents the URL
|
|
4235
|
-
// from being consumed as the window title.
|
|
4236
|
-
return [{ cmd: "cmd", args: ["/c", "start", "", url] }];
|
|
4237
|
-
}
|
|
4238
|
-
// linux/bsd: try xdg-open, then sensible-browser (Debian/Ubuntu).
|
|
4239
|
-
return [
|
|
4240
|
-
{ cmd: "xdg-open", args: [url] },
|
|
4241
|
-
{ cmd: "sensible-browser", args: [url] },
|
|
4242
|
-
];
|
|
4243
|
-
}
|
|
4244
|
-
// Opens a browser synchronously, waiting for each attempt to complete.
|
|
4245
|
-
// Returns a structured result so callers can surface auto-open failures
|
|
4246
|
-
// to the user instead of falsely reporting success.
|
|
4247
|
-
export function openBrowserSync(url, platform = process.platform, runner = spawnSync) {
|
|
4248
|
-
const attempts = browserOpenArgv(url, platform);
|
|
4249
|
-
const errors = [];
|
|
4250
|
-
for (const { cmd, args } of attempts) {
|
|
4251
|
-
try {
|
|
4252
|
-
const r = runner(cmd, args, { stdio: "ignore", timeout: HELPER_SPAWN_TIMEOUT_MS });
|
|
4253
|
-
// Treat signal-kill (status === null) and any non-zero status as failure
|
|
4254
|
-
// so the next fallback fires.
|
|
4255
|
-
if (!r.error && r.status === 0)
|
|
4256
|
-
return { ok: true, method: cmd };
|
|
4257
|
-
const reason = r.error?.message ?? `status=${r.status === null ? "signaled" : r.status}`;
|
|
4258
|
-
errors.push(`${cmd}: ${reason}`);
|
|
4259
|
-
}
|
|
4260
|
-
catch (e) {
|
|
4261
|
-
errors.push(`${cmd}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4262
|
-
}
|
|
4263
|
-
}
|
|
4264
|
-
return { ok: false, method: "none", reason: errors.join("; ") };
|
|
4265
|
-
}
|
|
4266
|
-
// Kills any process listening on `port`. Returns a structured result so
|
|
4267
|
-
// the caller can distinguish between (a) port was free, (b) kill succeeded,
|
|
4268
|
-
// (c) kill failed (perms, missing binary, or per-pid failure mid-loop).
|
|
4269
|
-
//
|
|
4270
|
-
// On Windows the netstat parser is locale-independent: the STATE column
|
|
4271
|
-
// ("LISTENING" / "ESTABLISHED" / ...) is translated on non-English Windows
|
|
4272
|
-
// (Windows-FR shows "À l'écoute", Windows-DE "ABHÖREN", etc.), but the REMOTE
|
|
4273
|
-
// ADDRESS column is not. A listening TCP socket always has remote
|
|
4274
|
-
// "0.0.0.0:0" (IPv4) or "[::]:0" (IPv6); a connected one has a real
|
|
4275
|
-
// addr:port. We therefore key off the remote column instead of the state
|
|
4276
|
-
// string. This also rules out the pre-fix bug where matching only the local
|
|
4277
|
-
// port number cross-matched a remote :port from an outbound connection and
|
|
4278
|
-
// taskkill'd an unrelated process.
|
|
4279
|
-
export function killProcessOnPort(port, platform = process.platform, runner = spawnSync) {
|
|
4280
|
-
const result = { killedPids: [], attemptedPids: [], errors: [] };
|
|
4281
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
4282
|
-
result.errors.push(`invalid port: ${port}`);
|
|
4283
|
-
return result;
|
|
4284
|
-
}
|
|
4285
|
-
try {
|
|
4286
|
-
if (platform === "win32") {
|
|
4287
|
-
const r = runner("netstat", ["-ano"], {
|
|
4288
|
-
encoding: "utf-8",
|
|
4289
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
4290
|
-
timeout: HELPER_SPAWN_TIMEOUT_MS,
|
|
4291
|
-
});
|
|
4292
|
-
if (r.error) {
|
|
4293
|
-
result.errors.push(`netstat: ${r.error.message}`);
|
|
4294
|
-
return result;
|
|
4295
|
-
}
|
|
4296
|
-
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4297
|
-
return result;
|
|
4298
|
-
const portSuffix = `:${port}`;
|
|
4299
|
-
const pids = new Set();
|
|
4300
|
-
for (const rawLine of r.stdout.split(/\r?\n/)) {
|
|
4301
|
-
const line = rawLine.trim();
|
|
4302
|
-
if (!line)
|
|
4303
|
-
continue;
|
|
4304
|
-
const tokens = line.split(/\s+/);
|
|
4305
|
-
// netstat -ano LISTENING row (en-US): "TCP 0.0.0.0:4747 0.0.0.0:0 LISTENING 1234"
|
|
4306
|
-
// The STATE column is locale-translated and may itself contain spaces
|
|
4307
|
-
// (Windows-FR `À l'écoute` splits into two tokens), so we cannot index
|
|
4308
|
-
// STATE by position. PID is always the trailing column; PROTO/LOCAL/
|
|
4309
|
-
// REMOTE are the first three. We anchor on those + a remote-wildcard
|
|
4310
|
-
// check that's locale-independent.
|
|
4311
|
-
if (tokens.length < 5)
|
|
4312
|
-
continue;
|
|
4313
|
-
const proto = tokens[0];
|
|
4314
|
-
const local = tokens[1];
|
|
4315
|
-
const remote = tokens[2];
|
|
4316
|
-
const pid = tokens[tokens.length - 1];
|
|
4317
|
-
if (proto !== "TCP")
|
|
4318
|
-
continue;
|
|
4319
|
-
if (!local.endsWith(portSuffix))
|
|
4320
|
-
continue;
|
|
4321
|
-
// Listening sockets carry a wildcard remote; anything else is a
|
|
4322
|
-
// connection (and matching it would kill an unrelated process).
|
|
4323
|
-
if (remote !== "0.0.0.0:0" && remote !== "[::]:0")
|
|
4324
|
-
continue;
|
|
4325
|
-
if (!/^\d+$/.test(pid))
|
|
4326
|
-
continue;
|
|
4327
|
-
pids.add(pid);
|
|
4328
|
-
}
|
|
4329
|
-
for (const pid of pids) {
|
|
4330
|
-
result.attemptedPids.push(pid);
|
|
4331
|
-
try {
|
|
4332
|
-
const k = runner("taskkill", ["/F", "/PID", pid], {
|
|
4333
|
-
stdio: "ignore",
|
|
4334
|
-
timeout: HELPER_SPAWN_TIMEOUT_MS,
|
|
4335
|
-
});
|
|
4336
|
-
if (k.error || k.status !== 0) {
|
|
4337
|
-
result.errors.push(`taskkill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
|
|
4338
|
-
}
|
|
4339
|
-
else {
|
|
4340
|
-
result.killedPids.push(pid);
|
|
4341
|
-
}
|
|
4342
|
-
}
|
|
4343
|
-
catch (e) {
|
|
4344
|
-
result.errors.push(`taskkill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
}
|
|
4348
|
-
else {
|
|
4349
|
-
const r = runner("lsof", ["-ti", `:${port}`], {
|
|
4350
|
-
encoding: "utf-8",
|
|
4351
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
4352
|
-
timeout: HELPER_SPAWN_TIMEOUT_MS,
|
|
4353
|
-
});
|
|
4354
|
-
if (r.error) {
|
|
4355
|
-
// ENOENT (lsof not installed) is a real diagnostic; surface it.
|
|
4356
|
-
result.errors.push(`lsof: ${r.error.message}`);
|
|
4357
|
-
return result;
|
|
4358
|
-
}
|
|
4359
|
-
// lsof exits 1 with empty stdout when the port is free — not an error.
|
|
4360
|
-
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
4361
|
-
return result;
|
|
4362
|
-
const pids = r.stdout.split(/\r?\n/).filter(p => /^\d+$/.test(p));
|
|
4363
|
-
for (const pid of pids) {
|
|
4364
|
-
result.attemptedPids.push(pid);
|
|
4365
|
-
try {
|
|
4366
|
-
const k = runner("kill", [pid], {
|
|
4367
|
-
stdio: "ignore",
|
|
4368
|
-
timeout: HELPER_SPAWN_TIMEOUT_MS,
|
|
4369
|
-
});
|
|
4370
|
-
if (k.error || k.status !== 0) {
|
|
4371
|
-
result.errors.push(`kill ${pid}: ${k.error?.message ?? `status=${k.status}`}`);
|
|
4372
|
-
}
|
|
4373
|
-
else {
|
|
4374
|
-
result.killedPids.push(pid);
|
|
4375
|
-
}
|
|
4376
|
-
}
|
|
4377
|
-
catch (e) {
|
|
4378
|
-
result.errors.push(`kill ${pid}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4379
|
-
}
|
|
4380
|
-
}
|
|
4381
|
-
}
|
|
4382
|
-
}
|
|
4383
|
-
catch (e) {
|
|
4384
|
-
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
4385
|
-
}
|
|
4386
|
-
return result;
|
|
4387
|
-
}
|
|
4388
|
-
// ── ctx-insight: open the hosted Insight dashboard ───────────────────────────
|
|
4389
|
-
// Insight pivoted from a locally-built dashboard to the hosted B2B product at
|
|
4390
|
-
// context-mode.com/insight (the landing page is the single source of truth).
|
|
4391
|
-
// The tool now simply opens that URL in the user default browser via the same
|
|
4392
|
-
// cross-platform helper (openBrowserSync) used elsewhere.
|
|
4393
|
-
const INSIGHT_URL = "https://context-mode.com/insight";
|
|
4394
|
-
server.registerTool("ctx_insight", {
|
|
4395
|
-
title: "Open Insight Dashboard",
|
|
4396
|
-
// #846: opens a hosted dashboard URL in the browser — an external side
|
|
4397
|
-
// effect (open world), not a read-only query; safe to repeat.
|
|
4398
|
-
annotations: {
|
|
4399
|
-
readOnlyHint: false,
|
|
4400
|
-
destructiveHint: false,
|
|
4401
|
-
idempotentHint: true,
|
|
4402
|
-
openWorldHint: true,
|
|
4403
|
-
},
|
|
4404
|
-
description: "Opens the context-mode Insight dashboard (https://context-mode.com/insight) in your " +
|
|
4405
|
-
"default browser — a dashboard launcher for the hosted analytics layer, not a Q&A engine. " +
|
|
4406
|
-
"Insight surfaces per-engineer productive rate, retry waste, blocker detection, and " +
|
|
4407
|
-
"role-narrowed views for CTO, EM, IC, CISO, FinOps, and DevOps. " +
|
|
4408
|
-
"For natural-language queries over your indexed content, use ctx_search.",
|
|
4409
|
-
inputSchema: z.object({}),
|
|
4410
|
-
}, async () => {
|
|
4411
|
-
const open = openBrowserSync(INSIGHT_URL);
|
|
4412
|
-
const text = open.ok
|
|
4413
|
-
? `Opening Insight in your browser: ${INSIGHT_URL}`
|
|
4414
|
-
: `Could not auto-open your browser (${open.reason}).\nOpen Insight manually: ${INSIGHT_URL}`;
|
|
4415
|
-
return trackResponse("ctx_insight", {
|
|
4416
|
-
content: [{ type: "text", text }],
|
|
4417
|
-
});
|
|
4418
|
-
});
|
|
4419
|
-
// ─────────────────────────────────────────────────────────
|
|
4420
|
-
// Server startup
|
|
4421
|
-
// ─────────────────────────────────────────────────────────
|
|
4422
|
-
async function main() {
|
|
4423
|
-
// Clean up stale DB files from previous sessions
|
|
4424
|
-
const cleaned = cleanupStaleDBs();
|
|
4425
|
-
if (cleaned > 0) {
|
|
4426
|
-
console.error(`Cleaned up ${cleaned} stale DB file(s) from previous sessions`);
|
|
4427
|
-
}
|
|
4428
|
-
// MCP readiness sentinel path (#230, #347)
|
|
4429
|
-
// Uses process.pid (not ppid) — hooks use directory-scan to find any live sentinel.
|
|
4430
|
-
// Hardcoded /tmp on Unix to avoid TMPDIR mismatch (#347).
|
|
4431
|
-
const mcpSentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
4432
|
-
const mcpSentinel = join(mcpSentinelDir, `context-mode-mcp-ready-${process.pid}`);
|
|
4433
|
-
// #844: handle to the periodic sentinel refresh timer (started after connect).
|
|
4434
|
-
let sentinelRefresh;
|
|
4435
|
-
// Clean up own DB + backgrounded processes + preload script on shutdown
|
|
4436
|
-
const shutdown = () => {
|
|
4437
|
-
executor.cleanupBackgrounded();
|
|
4438
|
-
if (_store)
|
|
4439
|
-
_store.close(); // persist DB for --continue sessions
|
|
4440
|
-
try {
|
|
4441
|
-
unlinkSync(CM_FS_PRELOAD);
|
|
4442
|
-
}
|
|
4443
|
-
catch { /* best effort */ }
|
|
4444
|
-
// Remove MCP readiness sentinel (#230)
|
|
4445
|
-
try {
|
|
4446
|
-
unlinkSync(mcpSentinel);
|
|
4447
|
-
}
|
|
4448
|
-
catch { /* best effort */ }
|
|
4449
|
-
// #844: stop refreshing the sentinel mtime on shutdown.
|
|
4450
|
-
if (sentinelRefresh)
|
|
4451
|
-
clearInterval(sentinelRefresh);
|
|
4452
|
-
};
|
|
4453
|
-
const gracefulShutdown = async () => {
|
|
4454
|
-
// Final stats flush — bypass throttle so the last 0-500ms of
|
|
4455
|
-
// bytes_indexed / bytes_returned aren't silently lost on SIGTERM/SIGINT
|
|
4456
|
-
// (PR #401 grill-me review B1: persistStats early-returns inside throttle
|
|
4457
|
-
// window; gracefulShutdown previously did NOT bypass).
|
|
4458
|
-
try {
|
|
4459
|
-
_lastStatsPersist = 0;
|
|
4460
|
-
persistStats();
|
|
4461
|
-
}
|
|
4462
|
-
catch { /* best effort — never block shutdown */ }
|
|
4463
|
-
shutdown();
|
|
4464
|
-
process.exit(0);
|
|
4465
|
-
};
|
|
4466
|
-
process.on("exit", shutdown);
|
|
4467
|
-
process.on("SIGINT", () => { gracefulShutdown(); });
|
|
4468
|
-
process.on("SIGTERM", () => { gracefulShutdown(); });
|
|
4469
|
-
// Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
|
|
4470
|
-
startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
4471
|
-
const transport = new StdioServerTransport();
|
|
4472
|
-
await server.connect(transport);
|
|
4473
|
-
// #854: refresh the bridge-child idle clock on each inbound MCP message so an
|
|
4474
|
-
// abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
|
|
4475
|
-
// of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
|
|
4476
|
-
attachMcpActivityTap(transport);
|
|
4477
|
-
// Write MCP readiness sentinel (#230)
|
|
4478
|
-
try {
|
|
4479
|
-
writeFileSync(mcpSentinel, String(process.pid));
|
|
4480
|
-
}
|
|
4481
|
-
catch { /* best effort */ }
|
|
4482
|
-
// #844: refresh the sentinel mtime while the server is alive so readiness
|
|
4483
|
-
// probes from a foreign PID namespace (shared /tmp) can trust a recent
|
|
4484
|
-
// sentinel even when process.kill(pid, 0) cannot see this PID. The reader's
|
|
4485
|
-
// freshness window is 90s (hooks/core/mcp-ready.mjs); refresh at 30s (3x).
|
|
4486
|
-
// unref() so this timer never keeps the event loop alive on its own.
|
|
4487
|
-
sentinelRefresh = setInterval(() => {
|
|
4488
|
-
try {
|
|
4489
|
-
writeFileSync(mcpSentinel, String(process.pid));
|
|
4490
|
-
}
|
|
4491
|
-
catch { /* best effort */ }
|
|
4492
|
-
}, 30_000);
|
|
4493
|
-
sentinelRefresh.unref();
|
|
4494
|
-
// Detect platform adapter — stored for platform-aware session paths
|
|
4495
|
-
try {
|
|
4496
|
-
const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
|
|
4497
|
-
const clientInfo = server.server.getClientVersion();
|
|
4498
|
-
const signal = detectPlatform(clientInfo ?? undefined);
|
|
4499
|
-
_detectedAdapter = await getAdapter(signal.platform);
|
|
4500
|
-
if (clientInfo) {
|
|
4501
|
-
console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
|
|
4502
|
-
}
|
|
4503
|
-
}
|
|
4504
|
-
catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
|
|
4505
|
-
// Restore tool-call counters from SessionDB BEFORE the heartbeat fires
|
|
4506
|
-
// so the very first persistStats() carries the prior PID's totals into
|
|
4507
|
-
// the sidecar JSON the statusline reads. Otherwise `/ctx-upgrade` flashes
|
|
4508
|
-
// `0 calls / $0.00` until the user makes another MCP tool call. Wrapped
|
|
4509
|
-
// in try/catch — a stats-restore failure must never block server startup.
|
|
4510
|
-
try {
|
|
4511
|
-
const restored = restoreSessionStats(getSessionDbPath());
|
|
4512
|
-
if (restored) {
|
|
4513
|
-
for (const [tool, count] of Object.entries(restored.calls)) {
|
|
4514
|
-
sessionStats.calls[tool] = count;
|
|
4515
|
-
}
|
|
4516
|
-
for (const [tool, bytes] of Object.entries(restored.bytesReturned)) {
|
|
4517
|
-
sessionStats.bytesReturned[tool] = bytes;
|
|
4518
|
-
}
|
|
4519
|
-
// Anchor uptime_ms to the original session start so `/ctx-upgrade`
|
|
4520
|
-
// doesn't reset the "session age" the statusline shows.
|
|
4521
|
-
if (restored.sessionStart > 0) {
|
|
4522
|
-
sessionStats.sessionStart = restored.sessionStart;
|
|
4523
|
-
}
|
|
4524
|
-
}
|
|
4525
|
-
}
|
|
4526
|
-
catch { /* best effort — never block startup on a stats restore failure */ }
|
|
4527
|
-
// Non-blocking version check — result stored for trackResponse warnings.
|
|
4528
|
-
// First fetch at startup, then refresh every hour so long-running sessions
|
|
4529
|
-
// (some users keep the MCP server alive 24h+) catch new releases without a
|
|
4530
|
-
// restart. `.unref()` lets the process exit normally on SIGTERM regardless
|
|
4531
|
-
// of pending intervals.
|
|
4532
|
-
fetchLatestVersion().then(v => { if (v !== "unknown")
|
|
4533
|
-
_latestVersion = v; });
|
|
4534
|
-
setInterval(() => {
|
|
4535
|
-
fetchLatestVersion().then(v => { if (v !== "unknown")
|
|
4536
|
-
_latestVersion = v; });
|
|
4537
|
-
}, 60 * 60 * 1000).unref();
|
|
4538
|
-
// Stats heartbeat — keep the statusline truthful while the user works in
|
|
4539
|
-
// tools other than MCP (Bash/Read/Edit during long sessions or post-/compact
|
|
4540
|
-
// pauses). Without this, stats.updated_at only advances on MCP tool calls,
|
|
4541
|
-
// so bin/statusline.mjs falsely flips to "stale — restart to resume saving"
|
|
4542
|
-
// even though the server is alive. Heartbeat refreshes updated_at every 60s;
|
|
4543
|
-
// statusline staleness threshold is 30min (cliff is 30 missed ticks away).
|
|
4544
|
-
setInterval(() => persistStats(), 60_000).unref();
|
|
4545
|
-
if (process.stdin.isTTY) {
|
|
4546
|
-
console.error(`Context Mode MCP server v${VERSION} running on stdio`);
|
|
4547
|
-
console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
|
|
4548
|
-
if (!hasBunRuntime()) {
|
|
4549
|
-
console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
|
|
4550
|
-
console.error(" curl -fsSL https://bun.sh/install | bash");
|
|
4551
|
-
}
|
|
4552
|
-
}
|
|
4553
|
-
}
|
|
4554
|
-
// Runs after every registerTool() above, so the SDK's default tools/list handler
|
|
4555
|
-
// exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
|
|
4556
|
-
// function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
|
|
4557
|
-
installStrictClientSchemaCompat();
|
|
4558
|
-
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
4559
|
-
main().catch((err) => {
|
|
4560
|
-
console.error("Fatal:", err);
|
|
4561
|
-
process.exit(1);
|
|
4562
|
-
});
|
|
4563
|
-
}
|