context-mode 1.0.162 → 1.0.164
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +149 -30
- package/bin/statusline.mjs +24 -4
- package/build/adapters/antigravity/index.d.ts +1 -1
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/claude-code/hooks.d.ts +1 -0
- package/build/adapters/claude-code/hooks.js +3 -0
- package/build/adapters/claude-code/index.js +24 -5
- package/build/adapters/client-map.js +5 -0
- package/build/adapters/codex/hooks.d.ts +5 -1
- package/build/adapters/codex/hooks.js +5 -1
- package/build/adapters/codex/index.d.ts +9 -1
- package/build/adapters/codex/index.js +87 -5
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/detect.d.ts +1 -1
- package/build/adapters/detect.js +71 -3
- package/build/adapters/openclaw/mcp-tools.js +1 -1
- package/build/adapters/opencode/index.js +31 -17
- package/build/adapters/opencode/zod3tov4.js +27 -6
- package/build/adapters/pi/extension.d.ts +2 -12
- package/build/adapters/pi/extension.js +128 -109
- package/build/adapters/types.d.ts +5 -4
- package/build/adapters/types.js +4 -3
- package/build/cache-heal.d.ts +48 -0
- package/build/cache-heal.js +150 -0
- package/build/cli.js +37 -97
- package/build/executor.d.ts +25 -0
- package/build/executor.js +143 -22
- package/build/lifecycle.d.ts +48 -0
- package/build/lifecycle.js +111 -0
- package/build/opencode-plugin.js +5 -2
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/runtime.d.ts +0 -36
- package/build/runtime.js +107 -27
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/security.d.ts +73 -3
- package/build/security.js +293 -33
- package/build/server.d.ts +14 -0
- package/build/server.js +441 -354
- package/build/session/analytics.d.ts +1 -1
- package/build/session/analytics.js +5 -1
- package/build/session/db.js +23 -3
- package/build/session/extract.js +78 -0
- package/build/store.d.ts +1 -1
- package/build/store.js +139 -25
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/cli.bundle.mjs +253 -250
- package/configs/antigravity/GEMINI.md +2 -2
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +2 -2
- package/configs/codex/AGENTS.md +2 -2
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/gemini-cli/GEMINI.md +2 -2
- package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
- package/configs/kilo/AGENTS.md +2 -2
- package/configs/kiro/KIRO.md +2 -2
- package/configs/omp/SYSTEM.md +2 -2
- package/configs/openclaw/AGENTS.md +2 -2
- package/configs/opencode/AGENTS.md +2 -2
- package/configs/qwen-code/QWEN.md +2 -2
- package/configs/vscode-copilot/copilot-instructions.md +2 -2
- package/configs/zed/AGENTS.md +2 -2
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/codex/pretooluse.mjs +14 -4
- package/hooks/codex/stop.mjs +12 -4
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +158 -7
- package/hooks/core/mcp-ready.mjs +37 -8
- package/hooks/core/routing.mjs +94 -8
- package/hooks/core/tool-naming.mjs +3 -0
- package/hooks/hooks.json +12 -1
- package/hooks/pretooluse.mjs +6 -2
- package/hooks/routing-block.mjs +3 -4
- package/hooks/security.bundle.mjs +2 -1
- package/hooks/session-db.bundle.mjs +5 -5
- package/hooks/session-directive.mjs +88 -20
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +21 -0
- package/hooks/sessionstart.mjs +37 -5
- package/hooks/stop.mjs +49 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -10
- package/server.bundle.mjs +206 -200
- package/skills/ctx-insight/SKILL.md +12 -17
- package/build/util/db-lock.d.ts +0 -65
- package/build/util/db-lock.js +0 -166
- package/insight/index.html +0 -13
- package/insight/package.json +0 -55
- package/insight/server.mjs +0 -1265
- package/insight/src/components/analytics.tsx +0 -112
- package/insight/src/components/ui/badge.tsx +0 -52
- package/insight/src/components/ui/button.tsx +0 -58
- package/insight/src/components/ui/card.tsx +0 -103
- package/insight/src/components/ui/chart.tsx +0 -371
- package/insight/src/components/ui/collapsible.tsx +0 -19
- package/insight/src/components/ui/input.tsx +0 -20
- package/insight/src/components/ui/progress.tsx +0 -83
- package/insight/src/components/ui/scroll-area.tsx +0 -55
- package/insight/src/components/ui/separator.tsx +0 -23
- package/insight/src/components/ui/table.tsx +0 -114
- package/insight/src/components/ui/tabs.tsx +0 -82
- package/insight/src/components/ui/tooltip.tsx +0 -64
- package/insight/src/lib/api.ts +0 -144
- package/insight/src/lib/utils.ts +0 -6
- package/insight/src/main.tsx +0 -22
- package/insight/src/routeTree.gen.ts +0 -189
- package/insight/src/router.tsx +0 -19
- package/insight/src/routes/__root.tsx +0 -55
- package/insight/src/routes/enterprise.tsx +0 -316
- package/insight/src/routes/index.tsx +0 -1482
- package/insight/src/routes/knowledge.tsx +0 -221
- package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
- package/insight/src/routes/search.tsx +0 -97
- package/insight/src/routes/sessions.tsx +0 -179
- package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
- package/insight/src/styles.css +0 -104
- package/insight/tsconfig.json +0 -29
- package/insight/vite.config.ts +0 -19
package/build/server.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, mkdirSync,
|
|
6
|
-
import {
|
|
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
7
|
import { join, dirname, resolve, sep, isAbsolute } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { homedir, tmpdir, cpus } from "node:os";
|
|
@@ -14,10 +14,10 @@ import { PolyglotExecutor } from "./executor.js";
|
|
|
14
14
|
import { runPool } from "./runPool.js";
|
|
15
15
|
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
|
|
16
16
|
import { composeFetchCacheKey } from "./fetch-cache.js";
|
|
17
|
-
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
|
|
17
|
+
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
|
|
18
18
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
19
19
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
20
|
-
import { startLifecycleGuard } from "./lifecycle.js";
|
|
20
|
+
import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
|
|
21
21
|
import { charSafePrefix } from "./truncate.js";
|
|
22
22
|
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
23
23
|
import { purgeSession } from "./session/purge.js";
|
|
@@ -25,9 +25,12 @@ import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from
|
|
|
25
25
|
import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
|
|
26
26
|
import { searchAllSources } from "./search/unified.js";
|
|
27
27
|
import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js";
|
|
28
|
+
import { FloodGuard } from "./search/flood-guard.js";
|
|
28
29
|
import { buildNodeCommand, isInProcessPluginPlatform } from "./adapters/types.js";
|
|
29
30
|
import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
|
|
31
|
+
import { parseCodexContextModePluginRoot } from "./adapters/codex/index.js";
|
|
30
32
|
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
33
|
+
import { stripJsonComments } from "./util/jsonc.js";
|
|
31
34
|
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
32
35
|
import { resolveProjectDir } from "./util/project-dir.js";
|
|
33
36
|
import { loadDatabase } from "./db-base.js";
|
|
@@ -45,6 +48,41 @@ const VERSION = (() => {
|
|
|
45
48
|
}
|
|
46
49
|
return "unknown";
|
|
47
50
|
})();
|
|
51
|
+
function getPackageRoot() {
|
|
52
|
+
return existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
53
|
+
}
|
|
54
|
+
function resolveCodexRuntimePluginRoot(fallbackRoot) {
|
|
55
|
+
try {
|
|
56
|
+
const probe = process.platform === "win32"
|
|
57
|
+
? spawnSync("cmd.exe", ["/d", "/s", "/c", "codex plugin list"], {
|
|
58
|
+
encoding: "utf-8",
|
|
59
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
})
|
|
62
|
+
: spawnSync("codex", ["plugin", "list"], {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
});
|
|
67
|
+
if (probe.status !== 0)
|
|
68
|
+
return fallbackRoot;
|
|
69
|
+
const runtimeRoot = parseCodexContextModePluginRoot(String(probe.stdout));
|
|
70
|
+
if (runtimeRoot && existsSync(resolve(runtimeRoot, ".codex-plugin", "hooks.json"))) {
|
|
71
|
+
return runtimeRoot;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Best effort only. Non-Codex hosts and older Codex builds may not expose
|
|
76
|
+
// plugin list; keep the package-root fallback for those environments.
|
|
77
|
+
}
|
|
78
|
+
return fallbackRoot;
|
|
79
|
+
}
|
|
80
|
+
function getRuntimeAwarePackageRoot(platformId) {
|
|
81
|
+
const packageRoot = getPackageRoot();
|
|
82
|
+
return platformId === "codex"
|
|
83
|
+
? resolveCodexRuntimePluginRoot(packageRoot)
|
|
84
|
+
: packageRoot;
|
|
85
|
+
}
|
|
48
86
|
// Prevent silent MCP server death from unhandled async errors.
|
|
49
87
|
//
|
|
50
88
|
// Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
|
|
@@ -57,7 +95,12 @@ if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
|
57
95
|
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
|
|
58
96
|
});
|
|
59
97
|
process.on("uncaughtException", (err) => {
|
|
60
|
-
|
|
98
|
+
try {
|
|
99
|
+
writeSync(2, `[context-mode] uncaughtException: ${err?.message ?? err}\n`);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
61
104
|
});
|
|
62
105
|
}
|
|
63
106
|
const runtimes = detectRuntimes();
|
|
@@ -77,53 +120,6 @@ export function shouldSuppressMcpToolsForNativePluginHost(opts = {}) {
|
|
|
77
120
|
const settings = opts.settings ?? readNativePluginHostSettings(platform);
|
|
78
121
|
return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
|
|
79
122
|
}
|
|
80
|
-
function stripJsonComments(str) {
|
|
81
|
-
let out = "";
|
|
82
|
-
let inString = false;
|
|
83
|
-
let escaped = false;
|
|
84
|
-
let inBlockComment = false;
|
|
85
|
-
for (let i = 0; i < str.length; i++) {
|
|
86
|
-
const c = str[i];
|
|
87
|
-
const next = str[i + 1];
|
|
88
|
-
if (inBlockComment) {
|
|
89
|
-
if (c === "*" && next === "/") {
|
|
90
|
-
inBlockComment = false;
|
|
91
|
-
i++;
|
|
92
|
-
}
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (escaped) {
|
|
96
|
-
out += c;
|
|
97
|
-
escaped = false;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (c === "\\") {
|
|
101
|
-
out += c;
|
|
102
|
-
escaped = inString;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (c === '"') {
|
|
106
|
-
inString = !inString;
|
|
107
|
-
out += c;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (!inString && c === "/" && next === "/") {
|
|
111
|
-
while (i < str.length && str[i] !== "\n")
|
|
112
|
-
i++;
|
|
113
|
-
if (i < str.length)
|
|
114
|
-
out += "\n";
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
if (!inString && c === "/" && next === "*") {
|
|
118
|
-
inBlockComment = true;
|
|
119
|
-
i++;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
out += c;
|
|
123
|
-
}
|
|
124
|
-
return out
|
|
125
|
-
.replace(/,(\s*[}\]])/g, "$1");
|
|
126
|
-
}
|
|
127
123
|
function readNativePluginHostSettings(platform) {
|
|
128
124
|
const base = platform === "kilo" ? "kilo" : "opencode";
|
|
129
125
|
const paths = [
|
|
@@ -239,6 +235,10 @@ server.registerTool = (...args) => {
|
|
|
239
235
|
};
|
|
240
236
|
function wrapToolHandler(name, handler) {
|
|
241
237
|
return async (toolArgs) => {
|
|
238
|
+
// #854: mark a tool call in-flight so the bridge-child idle reaper never
|
|
239
|
+
// shuts the server down mid-execution during a long ctx_execute/batch that
|
|
240
|
+
// emits no further inbound messages. Symmetric end in finally (success+error).
|
|
241
|
+
noteRequestStart();
|
|
242
242
|
try {
|
|
243
243
|
return await handler(toolArgs);
|
|
244
244
|
}
|
|
@@ -256,6 +256,9 @@ function wrapToolHandler(name, handler) {
|
|
|
256
256
|
}
|
|
257
257
|
throw err;
|
|
258
258
|
}
|
|
259
|
+
finally {
|
|
260
|
+
noteRequestEnd();
|
|
261
|
+
}
|
|
259
262
|
};
|
|
260
263
|
}
|
|
261
264
|
// Issue #637 — when suppression is active, install the empty tools/list handler
|
|
@@ -281,6 +284,69 @@ server.server.registerCapabilities({ prompts: { listChanged: false }, resources:
|
|
|
281
284
|
server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
282
285
|
server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
283
286
|
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
|
287
|
+
// ── Strict-client (Gemini function-calling) schema compatibility ──────────────
|
|
288
|
+
// Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
|
|
289
|
+
// — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
|
|
290
|
+
// schema makes the host SILENTLY DROP that tool from the model's function list,
|
|
291
|
+
// so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
|
|
292
|
+
// protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
|
|
293
|
+
// • `const: X` → `enum: [X]` — an identical single-value constraint
|
|
294
|
+
// • drop `additionalProperties` — advisory only; every ctx_* handler parses
|
|
295
|
+
// args with Zod (which strips unknown keys server-side), so removing it
|
|
296
|
+
// changes no validation and no call behavior.
|
|
297
|
+
// Both transforms are behavior-preserving for every other client (Claude Code,
|
|
298
|
+
// Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
|
|
299
|
+
// model sends undeclared properties. Only the wire schema changes — never
|
|
300
|
+
// validation or how any tool is invoked.
|
|
301
|
+
export function sanitizeSchemaForStrictClients(node) {
|
|
302
|
+
if (Array.isArray(node))
|
|
303
|
+
return node.map(sanitizeSchemaForStrictClients);
|
|
304
|
+
if (node === null || typeof node !== "object")
|
|
305
|
+
return node;
|
|
306
|
+
const out = {};
|
|
307
|
+
for (const [key, value] of Object.entries(node)) {
|
|
308
|
+
if (key === "additionalProperties")
|
|
309
|
+
continue;
|
|
310
|
+
if (key === "const") {
|
|
311
|
+
out.enum = [value];
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
out[key] = sanitizeSchemaForStrictClients(value);
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
// Wrap the SDK-installed tools/list handler so its generated schemas pass through
|
|
319
|
+
// the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
|
|
320
|
+
// the original handler is left untouched (no regression — strict clients stay as
|
|
321
|
+
// they were, every other client unaffected). Must run AFTER all registerTool()
|
|
322
|
+
// calls so the SDK's default tools/list handler already exists.
|
|
323
|
+
export function installStrictClientSchemaCompat(target = server) {
|
|
324
|
+
try {
|
|
325
|
+
const low = target.server;
|
|
326
|
+
const original = low._requestHandlers?.get("tools/list");
|
|
327
|
+
if (typeof original !== "function")
|
|
328
|
+
return;
|
|
329
|
+
target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
|
|
330
|
+
const result = (await original(req, extra));
|
|
331
|
+
if (result && Array.isArray(result.tools)) {
|
|
332
|
+
for (const tool of result.tools) {
|
|
333
|
+
if (!tool || tool.inputSchema == null)
|
|
334
|
+
continue;
|
|
335
|
+
try {
|
|
336
|
+
tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* leave this tool's schema unchanged */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
/* best-effort — never break tools/list */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
284
350
|
const executor = new PolyglotExecutor({
|
|
285
351
|
runtimes,
|
|
286
352
|
projectRoot: () => getProjectDir(),
|
|
@@ -394,9 +460,6 @@ function maybeIndexSessionEvents(store) {
|
|
|
394
460
|
// platform-specific paths. All session DB paths go through it — no
|
|
395
461
|
// hardcoded configDir detection in tool handlers.
|
|
396
462
|
let _detectedAdapter = null;
|
|
397
|
-
// Tracks the ctx_insight dashboard child so shutdown can terminate it.
|
|
398
|
-
// See ctx_insight handler + shutdown() in main().
|
|
399
|
-
let _insightChild = null;
|
|
400
463
|
/**
|
|
401
464
|
* Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl.
|
|
402
465
|
* leading `~`) before falling back to `~/.claude`. Mirrors
|
|
@@ -712,8 +775,21 @@ function healCacheMidSession() {
|
|
|
712
775
|
return;
|
|
713
776
|
const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
|
|
714
777
|
const cacheRoot = resolve(claudeRoot, "plugins", "cache");
|
|
778
|
+
// Issue #795: canonicalize cacheRoot so the traversal guard works when
|
|
779
|
+
// ~/.claude is a symlink to another volume. path.resolve() does not
|
|
780
|
+
// dereference symlinks, so installPath values stored as physical paths
|
|
781
|
+
// (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith
|
|
782
|
+
// check against a symlink-path cacheRoot (/Users/me/.claude/...).
|
|
783
|
+
// realpathSync follows the symlink chain to the canonical location.
|
|
784
|
+
let cacheRootCanon;
|
|
785
|
+
try {
|
|
786
|
+
cacheRootCanon = realpathSync(cacheRoot);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
cacheRootCanon = cacheRoot;
|
|
790
|
+
}
|
|
715
791
|
// Plugin root: build/ for tsc, plugin root for bundle
|
|
716
|
-
const pluginRoot =
|
|
792
|
+
const pluginRoot = getPackageRoot();
|
|
717
793
|
for (const [key, entries] of Object.entries((ip.plugins ?? {}))) {
|
|
718
794
|
if (key !== "context-mode@context-mode")
|
|
719
795
|
continue;
|
|
@@ -721,8 +797,8 @@ function healCacheMidSession() {
|
|
|
721
797
|
const rp = entry.installPath;
|
|
722
798
|
if (!rp || existsSync(rp))
|
|
723
799
|
continue;
|
|
724
|
-
// Path traversal guard
|
|
725
|
-
if (!resolve(rp).startsWith(
|
|
800
|
+
// Path traversal guard (canonical comparison — see #795)
|
|
801
|
+
if (!resolve(rp).startsWith(cacheRootCanon + sep))
|
|
726
802
|
continue;
|
|
727
803
|
// Remove dangling symlink
|
|
728
804
|
try {
|
|
@@ -742,6 +818,9 @@ function healCacheMidSession() {
|
|
|
742
818
|
catch { /* best effort */ }
|
|
743
819
|
}
|
|
744
820
|
function trackResponse(toolName, response) {
|
|
821
|
+
// #854: a response is activity too — refresh the bridge-child idle clock so a
|
|
822
|
+
// chatty/streaming call keeps its server alive even between inbound frames.
|
|
823
|
+
noteMcpActivity();
|
|
745
824
|
// Mid-session cache heal — one-shot, first tool call
|
|
746
825
|
healCacheMidSession();
|
|
747
826
|
// Prepend version outdated warning if needed
|
|
@@ -965,6 +1044,50 @@ function checkNonShellDenyPolicy(code, language, toolName) {
|
|
|
965
1044
|
}
|
|
966
1045
|
return null;
|
|
967
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Issue #852 — project-boundary containment for `ctx_execute_file`.
|
|
1049
|
+
*
|
|
1050
|
+
* The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
|
|
1051
|
+
* user approving a `ctx_execute_file` call cannot see that its `path` escapes
|
|
1052
|
+
* the workspace. This guard refuses a `path` that resolves outside the project
|
|
1053
|
+
* root (absolute escape, `../` traversal, or symlink-out), restoring the
|
|
1054
|
+
* boundary the host believes it is enforcing.
|
|
1055
|
+
*
|
|
1056
|
+
* Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
|
|
1057
|
+
* expressed in the SAME host config the user already maintains: a
|
|
1058
|
+
* `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
|
|
1059
|
+
* mechanism Claude Code uses to whitelist a path outside its sandbox, so the
|
|
1060
|
+
* grant lives in one place and stays meaningful instead of rotting into a
|
|
1061
|
+
* context-mode-only env flag nobody sets.
|
|
1062
|
+
*
|
|
1063
|
+
* Fail-open on resolver failure (consistent with the other deny checks): if the
|
|
1064
|
+
* project root cannot be resolved, containment evaluates as "inside" and the
|
|
1065
|
+
* path is allowed through rather than spuriously blocking legitimate work.
|
|
1066
|
+
*/
|
|
1067
|
+
function checkProjectBoundary(filePath, toolName) {
|
|
1068
|
+
try {
|
|
1069
|
+
const projectDir = getProjectDir();
|
|
1070
|
+
const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
|
|
1071
|
+
const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
|
|
1072
|
+
if (verdict.allowed)
|
|
1073
|
+
return null;
|
|
1074
|
+
return trackResponse(toolName, {
|
|
1075
|
+
content: [{
|
|
1076
|
+
type: "text",
|
|
1077
|
+
text: `File access blocked: "${filePath}" resolves outside the project root ` +
|
|
1078
|
+
`(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
|
|
1079
|
+
`cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
|
|
1080
|
+
`To intentionally process a file outside the project, add a host allow rule, ` +
|
|
1081
|
+
`e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
|
|
1082
|
+
}],
|
|
1083
|
+
isError: true,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
// Fail-open — resolver failure must not block legitimate in-project work.
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
968
1091
|
/**
|
|
969
1092
|
* Check a file path against Read deny patterns.
|
|
970
1093
|
* Returns an error ToolResult if denied, or null if allowed.
|
|
@@ -1163,6 +1286,26 @@ function truncateCommandForEcho(command) {
|
|
|
1163
1286
|
return cleaned;
|
|
1164
1287
|
return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
|
|
1165
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
|
|
1291
|
+
* agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
|
|
1292
|
+
* blocking script hangs forever — the host never kills it and the user must
|
|
1293
|
+
* interrupt. Every other host enforces its own RPC timeout, so we keep the
|
|
1294
|
+
* no-server-timer behavior there (Issue #406 — long builds need an unbounded
|
|
1295
|
+
* run). A caller can still pass an explicit `timeout` to override on any host.
|
|
1296
|
+
*/
|
|
1297
|
+
export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000;
|
|
1298
|
+
export function resolveExecTimeout(timeout) {
|
|
1299
|
+
if (timeout !== undefined)
|
|
1300
|
+
return timeout;
|
|
1301
|
+
// Only agy gets a default — every other host enforces its own RPC timeout, so
|
|
1302
|
+
// keep the unbounded behavior there. Detected via the env the agy bundle pins
|
|
1303
|
+
// (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS.
|
|
1304
|
+
if (detectPlatform().platform !== "antigravity-cli")
|
|
1305
|
+
return undefined;
|
|
1306
|
+
const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS);
|
|
1307
|
+
return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS;
|
|
1308
|
+
}
|
|
1166
1309
|
/**
|
|
1167
1310
|
* Per-call budget for the source-code echo prepended by `ctx_execute` and
|
|
1168
1311
|
* `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
|
|
@@ -1219,7 +1362,7 @@ function combineExecOutput(result) {
|
|
|
1219
1362
|
* record `(timed out)` blocks without skipping siblings.
|
|
1220
1363
|
*/
|
|
1221
1364
|
export async function runBatchCommands(commands, opts, executor) {
|
|
1222
|
-
const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
|
|
1365
|
+
const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;
|
|
1223
1366
|
if (concurrency <= 1) {
|
|
1224
1367
|
// Serial path — shared timeout budget, cascading skip on timeout.
|
|
1225
1368
|
// When `timeout` is undefined, no shared budget is enforced; each
|
|
@@ -1244,6 +1387,7 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1244
1387
|
language: "shell",
|
|
1245
1388
|
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1246
1389
|
timeout: perCmdTimeout,
|
|
1390
|
+
cwd,
|
|
1247
1391
|
});
|
|
1248
1392
|
outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
|
|
1249
1393
|
if (result.timedOut) {
|
|
@@ -1265,6 +1409,7 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1265
1409
|
language: "shell",
|
|
1266
1410
|
code: `${nodeOptsPrefix}${cmd.command}`,
|
|
1267
1411
|
timeout,
|
|
1412
|
+
cwd,
|
|
1268
1413
|
});
|
|
1269
1414
|
// Always route partial output through formatCommandOutput so __CM_FS__
|
|
1270
1415
|
// markers are stripped + counted, even when the command timed out.
|
|
@@ -1297,7 +1442,16 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1297
1442
|
// Tool: execute
|
|
1298
1443
|
// ─────────────────────────────────────────────────────────
|
|
1299
1444
|
server.registerTool("ctx_execute", {
|
|
1300
|
-
|
|
1445
|
+
// #852: surface code execution in the host approval prompt's title (the
|
|
1446
|
+
// only server-controlled field the MCP permission UI renders besides args).
|
|
1447
|
+
title: "Run code in a sandbox (executes the supplied code)",
|
|
1448
|
+
// #846: runs arbitrary code in a sandbox with full network access.
|
|
1449
|
+
annotations: {
|
|
1450
|
+
readOnlyHint: false,
|
|
1451
|
+
destructiveHint: true,
|
|
1452
|
+
idempotentHint: false,
|
|
1453
|
+
openWorldHint: true,
|
|
1454
|
+
},
|
|
1301
1455
|
description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
|
|
1302
1456
|
|
|
1303
1457
|
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.
|
|
@@ -1328,7 +1482,7 @@ WHEN NOT:
|
|
|
1328
1482
|
RETURNS:
|
|
1329
1483
|
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.
|
|
1330
1484
|
|
|
1331
|
-
EXAMPLE: ctx_execute(language: "
|
|
1485
|
+
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'))")
|
|
1332
1486
|
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\`)")`,
|
|
1333
1487
|
inputSchema: z.object({
|
|
1334
1488
|
language: z
|
|
@@ -1364,6 +1518,10 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
|
|
|
1364
1518
|
.optional()
|
|
1365
1519
|
.default(false)
|
|
1366
1520
|
.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."),
|
|
1521
|
+
cwd: z
|
|
1522
|
+
.string()
|
|
1523
|
+
.optional()
|
|
1524
|
+
.describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
|
|
1367
1525
|
intent: z
|
|
1368
1526
|
.string()
|
|
1369
1527
|
.optional()
|
|
@@ -1372,7 +1530,7 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
|
|
|
1372
1530
|
"Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
|
|
1373
1531
|
"\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
|
|
1374
1532
|
}),
|
|
1375
|
-
}, async ({ language, code, timeout, background, intent }) => {
|
|
1533
|
+
}, async ({ language, code, timeout, background, cwd, intent }) => {
|
|
1376
1534
|
// Security: deny-only firewall
|
|
1377
1535
|
if (language === "shell") {
|
|
1378
1536
|
const denied = checkDenyPolicy(code, "execute");
|
|
@@ -1452,7 +1610,8 @@ ${code}
|
|
|
1452
1610
|
__cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
|
|
1453
1611
|
})(typeof require!=='undefined'?require:null);`;
|
|
1454
1612
|
}
|
|
1455
|
-
const
|
|
1613
|
+
const effTimeout = resolveExecTimeout(timeout);
|
|
1614
|
+
const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
|
|
1456
1615
|
// Echo the executed source code before stdout so users can audit
|
|
1457
1616
|
// and tooling can block command patterns (Issues #717 + #736).
|
|
1458
1617
|
// Built from the user-supplied `code`, NOT the instrumented variant.
|
|
@@ -1478,7 +1637,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1478
1637
|
content: [
|
|
1479
1638
|
{
|
|
1480
1639
|
type: "text",
|
|
1481
|
-
text: `${echo}${partialOutput}\n\n_(process backgrounded after ${
|
|
1640
|
+
text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`,
|
|
1482
1641
|
},
|
|
1483
1642
|
],
|
|
1484
1643
|
});
|
|
@@ -1489,7 +1648,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1489
1648
|
content: [
|
|
1490
1649
|
{
|
|
1491
1650
|
type: "text",
|
|
1492
|
-
text: `${echo}${partialOutput}\n\n_(timed out after ${
|
|
1651
|
+
text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`,
|
|
1493
1652
|
},
|
|
1494
1653
|
],
|
|
1495
1654
|
});
|
|
@@ -1498,7 +1657,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
|
|
|
1498
1657
|
content: [
|
|
1499
1658
|
{
|
|
1500
1659
|
type: "text",
|
|
1501
|
-
text: `${echo}Execution timed out after ${
|
|
1660
|
+
text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`,
|
|
1502
1661
|
},
|
|
1503
1662
|
],
|
|
1504
1663
|
isError: true,
|
|
@@ -1638,7 +1797,17 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
|
|
|
1638
1797
|
// Tool: execute_file
|
|
1639
1798
|
// ─────────────────────────────────────────────────────────
|
|
1640
1799
|
server.registerTool("ctx_execute_file", {
|
|
1641
|
-
|
|
1800
|
+
// #852: the host's MCP approval prompt renders only the tool name/title +
|
|
1801
|
+
// raw args — the title is the one server-controlled signal, so make it
|
|
1802
|
+
// unambiguously announce code execution + file read for the reviewer.
|
|
1803
|
+
title: "Run code over a file (executes code, reads the given path)",
|
|
1804
|
+
// #846: runs arbitrary code over a file in a sandbox with full network access.
|
|
1805
|
+
annotations: {
|
|
1806
|
+
readOnlyHint: false,
|
|
1807
|
+
destructiveHint: true,
|
|
1808
|
+
idempotentHint: false,
|
|
1809
|
+
openWorldHint: true,
|
|
1810
|
+
},
|
|
1642
1811
|
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.
|
|
1643
1812
|
|
|
1644
1813
|
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.
|
|
@@ -1693,6 +1862,12 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1693
1862
|
"returns only matching sections via BM25 search instead of truncated output."),
|
|
1694
1863
|
}),
|
|
1695
1864
|
}, async ({ path, language, code, timeout, intent }) => {
|
|
1865
|
+
// Security (#852): confine the processed file to the project root so
|
|
1866
|
+
// ctx_execute_file cannot be used to escape the host's sandbox/permission
|
|
1867
|
+
// controls. Runs before the deny-glob check — boundary first, then policy.
|
|
1868
|
+
const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
|
|
1869
|
+
if (boundaryDenied)
|
|
1870
|
+
return boundaryDenied;
|
|
1696
1871
|
// Security: check file path against Read deny patterns
|
|
1697
1872
|
const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
|
|
1698
1873
|
if (pathDenied)
|
|
@@ -1709,11 +1884,12 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1709
1884
|
return codeDenied;
|
|
1710
1885
|
}
|
|
1711
1886
|
try {
|
|
1887
|
+
const effTimeout = resolveExecTimeout(timeout);
|
|
1712
1888
|
const result = await executor.executeFile({
|
|
1713
1889
|
path,
|
|
1714
1890
|
language,
|
|
1715
1891
|
code,
|
|
1716
|
-
timeout,
|
|
1892
|
+
timeout: effTimeout,
|
|
1717
1893
|
});
|
|
1718
1894
|
// Echo path + executed source code before stdout for audit/debug
|
|
1719
1895
|
// (Issues #717 + #736).
|
|
@@ -1723,7 +1899,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1723
1899
|
content: [
|
|
1724
1900
|
{
|
|
1725
1901
|
type: "text",
|
|
1726
|
-
text: `${echo}Timed out processing ${path} after ${
|
|
1902
|
+
text: `${echo}Timed out processing ${path} after ${effTimeout}ms`,
|
|
1727
1903
|
},
|
|
1728
1904
|
],
|
|
1729
1905
|
isError: true,
|
|
@@ -1800,6 +1976,14 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1800
1976
|
// ─────────────────────────────────────────────────────────
|
|
1801
1977
|
server.registerTool("ctx_index", {
|
|
1802
1978
|
title: "Index Content",
|
|
1979
|
+
// #846: writes content into the local FTS5 store (additive, not destructive;
|
|
1980
|
+
// re-indexing the same content adds rows, so not idempotent). No network.
|
|
1981
|
+
annotations: {
|
|
1982
|
+
readOnlyHint: false,
|
|
1983
|
+
destructiveHint: false,
|
|
1984
|
+
idempotentHint: false,
|
|
1985
|
+
openWorldHint: false,
|
|
1986
|
+
},
|
|
1803
1987
|
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.
|
|
1804
1988
|
|
|
1805
1989
|
WHEN:
|
|
@@ -1987,11 +2171,34 @@ function readPositiveEnv(name, defaultValue) {
|
|
|
1987
2171
|
const parsed = Number(raw);
|
|
1988
2172
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
1989
2173
|
}
|
|
1990
|
-
let searchCallCount = 0;
|
|
1991
|
-
let searchWindowStart = Date.now();
|
|
1992
2174
|
const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
|
|
1993
2175
|
const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
|
|
1994
2176
|
const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
|
|
2177
|
+
// #769: progressive throttle bucketed PER agent-context, not machine-global.
|
|
2178
|
+
// Concurrent subagents share ONE MCP server process; a single global counter
|
|
2179
|
+
// summed their independent searches into one budget and hard-blocked
|
|
2180
|
+
// legitimate parallel fan-out. The guard keys each actor's window separately
|
|
2181
|
+
// so single-actor flood protection is preserved while fan-out is not starved.
|
|
2182
|
+
const searchFloodGuard = new FloodGuard({
|
|
2183
|
+
windowMs: SEARCH_WINDOW_MS,
|
|
2184
|
+
softCapAfter: SEARCH_MAX_RESULTS_AFTER,
|
|
2185
|
+
blockAfter: SEARCH_BLOCK_AFTER,
|
|
2186
|
+
});
|
|
2187
|
+
/**
|
|
2188
|
+
* Per-agent flood-guard key. Each concurrent subagent in a Claude Code
|
|
2189
|
+
* Task/Workflow fan-out runs under its own session id (written to SessionDB
|
|
2190
|
+
* via hooks), so currentAttribution().sessionId is the per-agent discriminator
|
|
2191
|
+
* already available MCP-side. Falls back to a single shared bucket when no
|
|
2192
|
+
* identity is resolvable (preserves today's single-threaded behaviour).
|
|
2193
|
+
*/
|
|
2194
|
+
function searchFloodGuardKey() {
|
|
2195
|
+
try {
|
|
2196
|
+
return currentAttribution()?.sessionId ?? "__default__";
|
|
2197
|
+
}
|
|
2198
|
+
catch {
|
|
2199
|
+
return "__default__";
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
1995
2202
|
/**
|
|
1996
2203
|
* Defensive coercion: parse stringified JSON arrays, AND lift a bare
|
|
1997
2204
|
* non-empty string into a single-element array.
|
|
@@ -2060,6 +2267,13 @@ function coerceCommandsArray(val) {
|
|
|
2060
2267
|
}
|
|
2061
2268
|
server.registerTool("ctx_search", {
|
|
2062
2269
|
title: "Search Indexed Content",
|
|
2270
|
+
// #846: read-only query over the local FTS5 store. No mutation, no network.
|
|
2271
|
+
annotations: {
|
|
2272
|
+
readOnlyHint: true,
|
|
2273
|
+
destructiveHint: false,
|
|
2274
|
+
idempotentHint: true,
|
|
2275
|
+
openWorldHint: false,
|
|
2276
|
+
},
|
|
2063
2277
|
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.
|
|
2064
2278
|
|
|
2065
2279
|
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.
|
|
@@ -2129,19 +2343,16 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2129
2343
|
// per-project DB is naturally isolated by directory hash, so there is
|
|
2130
2344
|
// nothing for an in-process filter to do.
|
|
2131
2345
|
const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
|
|
2132
|
-
// Progressive throttling: track calls
|
|
2346
|
+
// Progressive throttling: track calls per agent-context window (#769).
|
|
2133
2347
|
const now = Date.now();
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
searchCallCount++;
|
|
2139
|
-
// After SEARCH_BLOCK_AFTER calls: refuse
|
|
2140
|
-
if (searchCallCount > SEARCH_BLOCK_AFTER) {
|
|
2348
|
+
const flood = searchFloodGuard.record(searchFloodGuardKey(), now);
|
|
2349
|
+
const searchCallCount = flood.count;
|
|
2350
|
+
// After SEARCH_BLOCK_AFTER calls (for THIS agent): refuse
|
|
2351
|
+
if (flood.blocked) {
|
|
2141
2352
|
return trackResponse("ctx_search", {
|
|
2142
2353
|
content: [{
|
|
2143
2354
|
type: "text",
|
|
2144
|
-
text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now -
|
|
2355
|
+
text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - flood.windowStart) / 1000)}s. ` +
|
|
2145
2356
|
"You're flooding context. STOP making individual search calls. " +
|
|
2146
2357
|
"Use ctx_batch_execute(commands, queries) for your next research step.",
|
|
2147
2358
|
}],
|
|
@@ -2149,8 +2360,8 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
|
|
|
2149
2360
|
});
|
|
2150
2361
|
}
|
|
2151
2362
|
// Determine per-query result limit based on throttle level
|
|
2152
|
-
const effectiveLimit =
|
|
2153
|
-
? 1 // after
|
|
2363
|
+
const effectiveLimit = flood.softCapped
|
|
2364
|
+
? 1 // after soft cap: only 1 result per query
|
|
2154
2365
|
: Math.min(limit, 2); // normal: max 2
|
|
2155
2366
|
const MAX_TOTAL = 40 * 1024; // 40KB total cap
|
|
2156
2367
|
let totalSize = 0;
|
|
@@ -2877,6 +3088,13 @@ function indexFetched(f) {
|
|
|
2877
3088
|
}
|
|
2878
3089
|
server.registerTool("ctx_fetch_and_index", {
|
|
2879
3090
|
title: "Fetch & Index URL(s)",
|
|
3091
|
+
// #846: fetches external URLs (open world) and writes them into the store.
|
|
3092
|
+
annotations: {
|
|
3093
|
+
readOnlyHint: false,
|
|
3094
|
+
destructiveHint: false,
|
|
3095
|
+
idempotentHint: false,
|
|
3096
|
+
openWorldHint: true,
|
|
3097
|
+
},
|
|
2880
3098
|
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.
|
|
2881
3099
|
|
|
2882
3100
|
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.
|
|
@@ -3101,6 +3319,13 @@ EXAMPLE: ctx_fetch_and_index(
|
|
|
3101
3319
|
// ─────────────────────────────────────────────────────────
|
|
3102
3320
|
server.registerTool("ctx_batch_execute", {
|
|
3103
3321
|
title: "Batch Execute & Search",
|
|
3322
|
+
// #846: runs arbitrary shell commands (with network) and indexes output.
|
|
3323
|
+
annotations: {
|
|
3324
|
+
readOnlyHint: false,
|
|
3325
|
+
destructiveHint: true,
|
|
3326
|
+
idempotentHint: false,
|
|
3327
|
+
openWorldHint: true,
|
|
3328
|
+
},
|
|
3104
3329
|
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.
|
|
3105
3330
|
|
|
3106
3331
|
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).
|
|
@@ -3162,6 +3387,10 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3162
3387
|
"Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
|
|
3163
3388
|
">1 switches to per-command timeouts (no shared budget) and " +
|
|
3164
3389
|
"individual `(timed out)` blocks instead of cascading skip."),
|
|
3390
|
+
cwd: z
|
|
3391
|
+
.string()
|
|
3392
|
+
.optional()
|
|
3393
|
+
.describe("Optional working directory for all shell commands in this batch."),
|
|
3165
3394
|
query_scope: z
|
|
3166
3395
|
.enum(["batch", "global"])
|
|
3167
3396
|
.optional()
|
|
@@ -3173,7 +3402,7 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3173
3402
|
"— useful when you want the batch commands to enrich context and " +
|
|
3174
3403
|
"the queries to also surface related prior knowledge in one round trip."),
|
|
3175
3404
|
}),
|
|
3176
|
-
}, async ({ commands, queries, timeout, concurrency, query_scope }) => {
|
|
3405
|
+
}, async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
|
|
3177
3406
|
// Security: check each command against deny patterns
|
|
3178
3407
|
for (const cmd of commands) {
|
|
3179
3408
|
const denied = checkDenyPolicy(cmd.command, "batch_execute");
|
|
@@ -3187,10 +3416,12 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3187
3416
|
const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
|
|
3188
3417
|
// Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
|
|
3189
3418
|
// Concurrency>1 switches to a worker pool with per-command timeouts.
|
|
3419
|
+
const effTimeout = resolveExecTimeout(timeout);
|
|
3190
3420
|
const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
|
|
3191
|
-
timeout,
|
|
3421
|
+
timeout: effTimeout,
|
|
3192
3422
|
concurrency,
|
|
3193
3423
|
nodeOptsPrefix,
|
|
3424
|
+
cwd,
|
|
3194
3425
|
onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
|
|
3195
3426
|
}, executor);
|
|
3196
3427
|
const stdout = perCommandOutputs.join("\n");
|
|
@@ -3201,7 +3432,7 @@ EXAMPLE: ctx_batch_execute(
|
|
|
3201
3432
|
content: [
|
|
3202
3433
|
{
|
|
3203
3434
|
type: "text",
|
|
3204
|
-
text: `Batch timed out after ${
|
|
3435
|
+
text: `Batch timed out after ${effTimeout}ms. No output captured.`,
|
|
3205
3436
|
},
|
|
3206
3437
|
],
|
|
3207
3438
|
isError: true,
|
|
@@ -3316,6 +3547,13 @@ function createMinimalDb() {
|
|
|
3316
3547
|
}
|
|
3317
3548
|
server.registerTool("ctx_stats", {
|
|
3318
3549
|
title: "Session Statistics",
|
|
3550
|
+
// #846: read-only diagnostics. Was cancelled by Codex when unannotated.
|
|
3551
|
+
annotations: {
|
|
3552
|
+
readOnlyHint: true,
|
|
3553
|
+
destructiveHint: false,
|
|
3554
|
+
idempotentHint: true,
|
|
3555
|
+
openWorldHint: false,
|
|
3556
|
+
},
|
|
3319
3557
|
description: "Returns context consumption statistics for the current session. " +
|
|
3320
3558
|
"Shows total bytes returned to context, breakdown by tool, call counts, " +
|
|
3321
3559
|
"estimated token usage, and context savings ratio.",
|
|
@@ -3512,6 +3750,14 @@ server.registerTool("ctx_stats", {
|
|
|
3512
3750
|
// ── ctx-doctor: diagnostics (server-side) ─────────────────────────────────
|
|
3513
3751
|
server.registerTool("ctx_doctor", {
|
|
3514
3752
|
title: "Run Diagnostics",
|
|
3753
|
+
// #846: read-only diagnostics (runs an internal self-test, mutates nothing).
|
|
3754
|
+
// Was cancelled by Codex when unannotated.
|
|
3755
|
+
annotations: {
|
|
3756
|
+
readOnlyHint: true,
|
|
3757
|
+
destructiveHint: false,
|
|
3758
|
+
idempotentHint: true,
|
|
3759
|
+
openWorldHint: false,
|
|
3760
|
+
},
|
|
3515
3761
|
description: "Diagnose context-mode installation. Runs all checks server-side and " +
|
|
3516
3762
|
"returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
|
|
3517
3763
|
"(renderer-safe across MCP clients). No CLI execution needed.",
|
|
@@ -3525,8 +3771,17 @@ server.registerTool("ctx_doctor", {
|
|
|
3525
3771
|
// safe across all MCP renderers — using plain-text status prefixes
|
|
3526
3772
|
// (`[OK]` / `[FAIL]` / `[WARN]`) instead.
|
|
3527
3773
|
const lines = ["context-mode doctor", ""];
|
|
3528
|
-
|
|
3529
|
-
|
|
3774
|
+
let currentPlatform;
|
|
3775
|
+
try {
|
|
3776
|
+
currentPlatform = detectPlatform(server.server.getClientVersion() ?? undefined).platform;
|
|
3777
|
+
}
|
|
3778
|
+
catch {
|
|
3779
|
+
currentPlatform = detectPlatform().platform;
|
|
3780
|
+
}
|
|
3781
|
+
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
|
|
3782
|
+
// Codex is special: when plugin-manager runtime root differs from the
|
|
3783
|
+
// current package root, diagnose the root Codex will actually execute.
|
|
3784
|
+
const pluginRoot = getRuntimeAwarePackageRoot(currentPlatform);
|
|
3530
3785
|
// Runtimes
|
|
3531
3786
|
const total = 11;
|
|
3532
3787
|
const pct = ((available.length / total) * 100).toFixed(0);
|
|
@@ -3624,30 +3879,20 @@ server.registerTool("ctx_doctor", {
|
|
|
3624
3879
|
// ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
|
|
3625
3880
|
server.registerTool("ctx_upgrade", {
|
|
3626
3881
|
title: "Upgrade Plugin",
|
|
3882
|
+
// #846: an action tool (returns an upgrade command to run); not read-only,
|
|
3883
|
+
// but non-destructive and idempotent. No direct network from the call.
|
|
3884
|
+
annotations: {
|
|
3885
|
+
readOnlyHint: false,
|
|
3886
|
+
destructiveHint: false,
|
|
3887
|
+
idempotentHint: true,
|
|
3888
|
+
openWorldHint: false,
|
|
3889
|
+
},
|
|
3627
3890
|
description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
|
|
3628
3891
|
"You MUST run the returned command using your shell tool (Bash, shell_execute, " +
|
|
3629
3892
|
"run_in_terminal, etc.) and display the output as a checklist. " +
|
|
3630
3893
|
"Tell the user to restart their session after upgrade.",
|
|
3631
3894
|
inputSchema: z.object({}),
|
|
3632
3895
|
}, async () => {
|
|
3633
|
-
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
|
|
3634
|
-
const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
|
|
3635
|
-
const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
|
|
3636
|
-
const fallbackPath = resolve(pluginRoot, "build", "cli.js");
|
|
3637
|
-
// Clean up insight-cache on upgrade so next ctx_insight does fresh build
|
|
3638
|
-
try {
|
|
3639
|
-
const sessDir = getSessionDir();
|
|
3640
|
-
const insightCacheDir = join(dirname(sessDir), "insight-cache");
|
|
3641
|
-
if (existsSync(insightCacheDir)) {
|
|
3642
|
-
// Kill any running insight server first via the shared helper —
|
|
3643
|
-
// this is locale-independent on Windows (PR #469) and isolates per-pid
|
|
3644
|
-
// failures. We ignore the structured result: cache cleanup is
|
|
3645
|
-
// best-effort and must never block ctx_upgrade.
|
|
3646
|
-
killProcessOnPort(4747);
|
|
3647
|
-
rmSync(insightCacheDir, { recursive: true, force: true });
|
|
3648
|
-
}
|
|
3649
|
-
}
|
|
3650
|
-
catch { /* best effort — don't block upgrade */ }
|
|
3651
3896
|
// Issue #542 — thread MCP clientInfo into the spawned upgrade
|
|
3652
3897
|
// process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
|
|
3653
3898
|
// so clientInfo from the MCP handshake is the highest-confidence
|
|
@@ -3657,16 +3902,44 @@ server.registerTool("ctx_upgrade", {
|
|
|
3657
3902
|
// skip the flag and let upgrade()'s own detectPlatform() fall back.
|
|
3658
3903
|
let platformFlag = "";
|
|
3659
3904
|
let nodeOpts = undefined;
|
|
3905
|
+
let platformId;
|
|
3660
3906
|
try {
|
|
3661
|
-
const { detectPlatform } = await import("./adapters/detect.js");
|
|
3662
3907
|
const clientInfo = server.server.getClientVersion();
|
|
3663
3908
|
const signal = detectPlatform(clientInfo ?? undefined);
|
|
3909
|
+
platformId = signal.platform;
|
|
3664
3910
|
platformFlag = ` --platform ${signal.platform}`;
|
|
3665
3911
|
nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
|
|
3666
3912
|
? { platform: signal.platform, jsRuntime: runtimes.javascript }
|
|
3667
3913
|
: undefined;
|
|
3668
3914
|
}
|
|
3669
|
-
catch {
|
|
3915
|
+
catch {
|
|
3916
|
+
try {
|
|
3917
|
+
platformId = detectPlatform().platform;
|
|
3918
|
+
}
|
|
3919
|
+
catch { /* best effort — fall back to upgrade()'s own detect */ }
|
|
3920
|
+
}
|
|
3921
|
+
// __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
|
|
3922
|
+
// Only Codex may replace it with the plugin-manager runtime root; other
|
|
3923
|
+
// adapters can coexist with Codex on the same machine.
|
|
3924
|
+
const pluginRoot = getRuntimeAwarePackageRoot(platformId);
|
|
3925
|
+
const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
|
|
3926
|
+
const fallbackPath = resolve(pluginRoot, "build", "cli.js");
|
|
3927
|
+
// Insight pivoted to the hosted dashboard (context-mode.com/insight), so
|
|
3928
|
+
// ctx_insight no longer builds a local cache. On upgrade, sweep the legacy
|
|
3929
|
+
// insight-cache and stop any stale local dashboard left from old versions.
|
|
3930
|
+
try {
|
|
3931
|
+
const sessDir = getSessionDir();
|
|
3932
|
+
const insightCacheDir = join(dirname(sessDir), "insight-cache");
|
|
3933
|
+
if (existsSync(insightCacheDir)) {
|
|
3934
|
+
// Kill any running insight server first via the shared helper —
|
|
3935
|
+
// this is locale-independent on Windows (PR #469) and isolates per-pid
|
|
3936
|
+
// failures. We ignore the structured result: cache cleanup is
|
|
3937
|
+
// best-effort and must never block ctx_upgrade.
|
|
3938
|
+
killProcessOnPort(4747);
|
|
3939
|
+
rmSync(insightCacheDir, { recursive: true, force: true });
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
catch { /* best effort — don't block upgrade */ }
|
|
3670
3943
|
let cmd;
|
|
3671
3944
|
if (existsSync(bundlePath)) {
|
|
3672
3945
|
cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
|
|
@@ -3773,6 +4046,14 @@ server.registerTool("ctx_upgrade", {
|
|
|
3773
4046
|
// tool with "input_schema does not support fields". Issue #563.
|
|
3774
4047
|
server.registerTool("ctx_purge", {
|
|
3775
4048
|
title: "Purge Knowledge Base",
|
|
4049
|
+
// #846: permanently deletes indexed content — destructive. Purging an
|
|
4050
|
+
// already-purged scope has no further effect (idempotent). No network.
|
|
4051
|
+
annotations: {
|
|
4052
|
+
readOnlyHint: false,
|
|
4053
|
+
destructiveHint: true,
|
|
4054
|
+
idempotentHint: true,
|
|
4055
|
+
openWorldHint: false,
|
|
4056
|
+
},
|
|
3776
4057
|
description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
|
|
3777
4058
|
|
|
3778
4059
|
WHEN:
|
|
@@ -4083,248 +4364,36 @@ export function killProcessOnPort(port, platform = process.platform, runner = sp
|
|
|
4083
4364
|
}
|
|
4084
4365
|
return result;
|
|
4085
4366
|
}
|
|
4086
|
-
// ── ctx-insight:
|
|
4367
|
+
// ── ctx-insight: open the hosted Insight dashboard ───────────────────────────
|
|
4368
|
+
// Insight pivoted from a locally-built dashboard to the hosted B2B product at
|
|
4369
|
+
// context-mode.com/insight (the landing page is the single source of truth).
|
|
4370
|
+
// The tool now simply opens that URL in the user default browser via the same
|
|
4371
|
+
// cross-platform helper (openBrowserSync) used elsewhere.
|
|
4372
|
+
const INSIGHT_URL = "https://context-mode.com/insight";
|
|
4087
4373
|
server.registerTool("ctx_insight", {
|
|
4088
4374
|
title: "Open Insight Dashboard",
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
const
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
// Use adapter-aware path by default, but allow MCP callers to pass explicit
|
|
4112
|
-
// Insight data dirs for hosts whose adapter/default detection is unavailable.
|
|
4113
|
-
const sessDir = explicitSessionDir ? resolve(explicitSessionDir) : getSessionDir();
|
|
4114
|
-
const insightContentDirResolved = explicitContentDir ? resolve(explicitContentDir) : join(dirname(sessDir), "content");
|
|
4115
|
-
const cacheDir = join(dirname(sessDir), "insight-cache");
|
|
4116
|
-
// Confused-deputy guard on explicit overrides. The spawned insight
|
|
4117
|
-
// server reads every .db file under sessDir and insightContentDir, and
|
|
4118
|
-
// its /api/content DELETE endpoint can rewrite hex-named .db files in
|
|
4119
|
-
// those trees. A prompt-injected caller passing sessionDir="~/.ssh"
|
|
4120
|
-
// or contentDir="~/.gnupg" would otherwise let the dashboard
|
|
4121
|
-
// enumerate (and, for hex-named SQLite files, mutate rows in) those
|
|
4122
|
-
// directories. Contain explicit overrides to the adapter's config
|
|
4123
|
-
// root: broad enough for the documented "multi-install setups or
|
|
4124
|
-
// pointing at a sibling project's data" use case, narrow enough to
|
|
4125
|
-
// block /etc, ~/.ssh, /tmp/<foreign-user>, etc.
|
|
4126
|
-
if (explicitSessionDir || explicitContentDir) {
|
|
4127
|
-
const defaultSessDir = getSessionDir();
|
|
4128
|
-
const containmentRoot = dirname(dirname(defaultSessDir));
|
|
4129
|
-
const containmentRootWithSep = resolve(containmentRoot) + sep;
|
|
4130
|
-
const isContained = (dir) => (resolve(dir) + sep).startsWith(containmentRootWithSep);
|
|
4131
|
-
if (explicitSessionDir && !isContained(sessDir)) {
|
|
4132
|
-
return trackResponse("ctx_insight", {
|
|
4133
|
-
content: [{
|
|
4134
|
-
type: "text",
|
|
4135
|
-
text: `Error: sessionDir must resolve under ${containmentRoot} (got ${sessDir}).`,
|
|
4136
|
-
}],
|
|
4137
|
-
});
|
|
4138
|
-
}
|
|
4139
|
-
if (explicitContentDir && !isContained(insightContentDirResolved)) {
|
|
4140
|
-
return trackResponse("ctx_insight", {
|
|
4141
|
-
content: [{
|
|
4142
|
-
type: "text",
|
|
4143
|
-
text: `Error: contentDir must resolve under ${containmentRoot} (got ${insightContentDirResolved}).`,
|
|
4144
|
-
}],
|
|
4145
|
-
});
|
|
4146
|
-
}
|
|
4147
|
-
}
|
|
4148
|
-
// Verify source exists
|
|
4149
|
-
if (!existsSync(join(insightSource, "server.mjs"))) {
|
|
4150
|
-
return trackResponse("ctx_insight", {
|
|
4151
|
-
content: [{ type: "text", text: "Error: Insight source not found in plugin. Try upgrading context-mode." }],
|
|
4152
|
-
});
|
|
4153
|
-
}
|
|
4154
|
-
try {
|
|
4155
|
-
const steps = [];
|
|
4156
|
-
let sourceUpdated = false;
|
|
4157
|
-
// Ensure cache dir
|
|
4158
|
-
mkdirSync(cacheDir, { recursive: true });
|
|
4159
|
-
// Copy source files if needed (check by comparing server.mjs mtime)
|
|
4160
|
-
const srcMtime = statSync(join(insightSource, "server.mjs")).mtimeMs;
|
|
4161
|
-
const cacheMtime = existsSync(join(cacheDir, "server.mjs"))
|
|
4162
|
-
? statSync(join(cacheDir, "server.mjs")).mtimeMs : 0;
|
|
4163
|
-
if (srcMtime > cacheMtime) {
|
|
4164
|
-
steps.push("Copying source files...");
|
|
4165
|
-
cpSync(insightSource, cacheDir, { recursive: true, force: true });
|
|
4166
|
-
steps.push("Source files copied.");
|
|
4167
|
-
sourceUpdated = true;
|
|
4168
|
-
}
|
|
4169
|
-
// Install deps if needed (also reinstall when source updated and package.json may have changed)
|
|
4170
|
-
const hasNodeModules = existsSync(join(cacheDir, "node_modules"));
|
|
4171
|
-
if (!hasNodeModules || sourceUpdated) {
|
|
4172
|
-
steps.push("Installing dependencies (first run, ~30s)...");
|
|
4173
|
-
try {
|
|
4174
|
-
execSync(process.platform === "win32" ? "npm.cmd install --production=false" : "npm install --production=false", {
|
|
4175
|
-
cwd: cacheDir,
|
|
4176
|
-
stdio: "pipe",
|
|
4177
|
-
timeout: 300000,
|
|
4178
|
-
});
|
|
4179
|
-
}
|
|
4180
|
-
catch {
|
|
4181
|
-
// Clean up partial install so next run retries fresh
|
|
4182
|
-
try {
|
|
4183
|
-
rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
|
|
4184
|
-
}
|
|
4185
|
-
catch { }
|
|
4186
|
-
throw new Error("npm install failed — please retry");
|
|
4187
|
-
}
|
|
4188
|
-
// Sentinel check: verify install completed (cold cache can timeout leaving partial node_modules)
|
|
4189
|
-
if (!existsSync(join(cacheDir, "node_modules", "vite")) || !existsSync(join(cacheDir, "node_modules", "better-sqlite3"))) {
|
|
4190
|
-
rmSync(join(cacheDir, "node_modules"), { recursive: true, force: true });
|
|
4191
|
-
throw new Error("npm install incomplete — please retry");
|
|
4192
|
-
}
|
|
4193
|
-
steps.push("Dependencies installed.");
|
|
4194
|
-
}
|
|
4195
|
-
// Build
|
|
4196
|
-
steps.push("Building dashboard...");
|
|
4197
|
-
execSync("npx vite build", {
|
|
4198
|
-
cwd: cacheDir,
|
|
4199
|
-
stdio: "pipe",
|
|
4200
|
-
timeout: 60000,
|
|
4201
|
-
});
|
|
4202
|
-
steps.push("Build complete.");
|
|
4203
|
-
// Pre-check: is port already in use?
|
|
4204
|
-
let portOccupied = false;
|
|
4205
|
-
try {
|
|
4206
|
-
const { request } = await import("node:http");
|
|
4207
|
-
await new Promise((resolve, reject) => {
|
|
4208
|
-
const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 2000 }, (res) => {
|
|
4209
|
-
res.resume();
|
|
4210
|
-
resolve(); // port is responding = already running
|
|
4211
|
-
});
|
|
4212
|
-
req.on("error", () => reject()); // port free
|
|
4213
|
-
req.on("timeout", () => { req.destroy(); reject(); });
|
|
4214
|
-
req.end();
|
|
4215
|
-
});
|
|
4216
|
-
portOccupied = true;
|
|
4217
|
-
}
|
|
4218
|
-
catch {
|
|
4219
|
-
// Port is free, proceed with spawn
|
|
4220
|
-
}
|
|
4221
|
-
if (portOccupied && sourceUpdated) {
|
|
4222
|
-
// Source was updated but stale server is running on port — kill it so fresh code runs
|
|
4223
|
-
steps.push("Killing stale dashboard server (source updated)...");
|
|
4224
|
-
const kill = killProcessOnPort(port);
|
|
4225
|
-
if (kill.attemptedPids.length > 0 && kill.killedPids.length === 0) {
|
|
4226
|
-
// Tried to kill, every attempt failed (perms, race, missing binary).
|
|
4227
|
-
// Surface so the agent doesn't loop on the same port forever.
|
|
4228
|
-
return trackResponse("ctx_insight", {
|
|
4229
|
-
content: [{
|
|
4230
|
-
type: "text",
|
|
4231
|
-
text: `Could not free port ${port} (kill failed for ${kill.attemptedPids.join(", ")}: ${kill.errors.join("; ")}). Try ctx_insight({ port: ${port + 1} }) or stop the process manually.`,
|
|
4232
|
-
}],
|
|
4233
|
-
});
|
|
4234
|
-
}
|
|
4235
|
-
if (kill.errors.length > 0 && kill.attemptedPids.length === 0) {
|
|
4236
|
-
// Couldn't even probe the port (e.g. lsof not installed).
|
|
4237
|
-
return trackResponse("ctx_insight", {
|
|
4238
|
-
content: [{
|
|
4239
|
-
type: "text",
|
|
4240
|
-
text: `Cannot reclaim port ${port}: ${kill.errors.join("; ")}. Stop the process manually or pick another port.`,
|
|
4241
|
-
}],
|
|
4242
|
-
});
|
|
4243
|
-
}
|
|
4244
|
-
await new Promise(r => setTimeout(r, 500)); // Wait for port to free
|
|
4245
|
-
steps.push(`Stale server killed (${kill.killedPids.length} pid${kill.killedPids.length === 1 ? "" : "s"}).`);
|
|
4246
|
-
}
|
|
4247
|
-
else if (portOccupied) {
|
|
4248
|
-
// Source unchanged, server is running fine — just open browser
|
|
4249
|
-
steps.push("Dashboard already running.");
|
|
4250
|
-
const url = `http://localhost:${port}`;
|
|
4251
|
-
const open = openBrowserSync(url);
|
|
4252
|
-
const tail = open.ok
|
|
4253
|
-
? ""
|
|
4254
|
-
: ` (auto-open failed: ${open.reason}; navigate manually)`;
|
|
4255
|
-
return trackResponse("ctx_insight", {
|
|
4256
|
-
content: [{ type: "text", text: `Dashboard already running at ${url}${tail}` }],
|
|
4257
|
-
});
|
|
4258
|
-
}
|
|
4259
|
-
// Kill any previous insight child this MCP spawned (e.g. re-invocation).
|
|
4260
|
-
if (_insightChild && _insightChild.pid && !_insightChild.killed) {
|
|
4261
|
-
try {
|
|
4262
|
-
_insightChild.kill("SIGTERM");
|
|
4263
|
-
}
|
|
4264
|
-
catch { /* best effort */ }
|
|
4265
|
-
}
|
|
4266
|
-
// Start server in background. `detached: true` keeps MCP stdio free, but
|
|
4267
|
-
// we track the handle and kill it in shutdown() so the dashboard does
|
|
4268
|
-
// not orphan when Claude closes. The child also watches INSIGHT_PARENT_PID
|
|
4269
|
-
// as a fallback for SIGKILL/crash paths.
|
|
4270
|
-
const { spawn } = await import("node:child_process");
|
|
4271
|
-
const child = spawn("node", [join(cacheDir, "server.mjs")], {
|
|
4272
|
-
cwd: cacheDir,
|
|
4273
|
-
env: {
|
|
4274
|
-
...process.env,
|
|
4275
|
-
PORT: String(port),
|
|
4276
|
-
INSIGHT_SESSION_DIR: sessDir,
|
|
4277
|
-
INSIGHT_CONTENT_DIR: insightContentDirResolved,
|
|
4278
|
-
INSIGHT_PARENT_PID: String(process.pid),
|
|
4279
|
-
},
|
|
4280
|
-
detached: true,
|
|
4281
|
-
stdio: "ignore",
|
|
4282
|
-
});
|
|
4283
|
-
child.on("error", () => { }); // prevent unhandled error crash
|
|
4284
|
-
child.unref();
|
|
4285
|
-
_insightChild = child;
|
|
4286
|
-
// Wait for server to be ready
|
|
4287
|
-
await new Promise(r => setTimeout(r, 1500));
|
|
4288
|
-
// Verify server is actually running
|
|
4289
|
-
try {
|
|
4290
|
-
const { request } = await import("node:http");
|
|
4291
|
-
await new Promise((resolve, reject) => {
|
|
4292
|
-
const req = request(`http://127.0.0.1:${port}/api/overview`, { timeout: 3000 }, (res) => {
|
|
4293
|
-
resolve();
|
|
4294
|
-
res.resume();
|
|
4295
|
-
});
|
|
4296
|
-
req.on("error", reject);
|
|
4297
|
-
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
|
4298
|
-
req.end();
|
|
4299
|
-
});
|
|
4300
|
-
}
|
|
4301
|
-
catch {
|
|
4302
|
-
// Server didn't start — likely port in use
|
|
4303
|
-
return trackResponse("ctx_insight", {
|
|
4304
|
-
content: [{
|
|
4305
|
-
type: "text",
|
|
4306
|
-
text: `Port ${port} appears to be in use. Either a previous dashboard is still running, or another service is using this port.\n\nTo fix:\n- Kill the existing process: ${process.platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -ti:${port} | xargs kill`}\n- Or use a different port: ctx_insight({ port: ${port + 1} })`,
|
|
4307
|
-
}],
|
|
4308
|
-
});
|
|
4309
|
-
}
|
|
4310
|
-
// Open browser (cross-platform)
|
|
4311
|
-
const url = `http://localhost:${port}`;
|
|
4312
|
-
const open = openBrowserSync(url);
|
|
4313
|
-
const openTail = open.ok ? "" : ` (auto-open failed: ${open.reason}; navigate manually)`;
|
|
4314
|
-
steps.push(`Dashboard running at ${url}${openTail}`);
|
|
4315
|
-
return trackResponse("ctx_insight", {
|
|
4316
|
-
content: [{
|
|
4317
|
-
type: "text",
|
|
4318
|
-
text: steps.map(s => `- ${s}`).join("\n") + `\n\nOpen: ${url}\nPID: ${child.pid} · Stop: ${process.platform === "win32" ? `taskkill /PID ${child.pid} /F` : `kill ${child.pid}`}`,
|
|
4319
|
-
}],
|
|
4320
|
-
});
|
|
4321
|
-
}
|
|
4322
|
-
catch (err) {
|
|
4323
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4324
|
-
return trackResponse("ctx_insight", {
|
|
4325
|
-
content: [{ type: "text", text: `Insight setup failed: ${msg}` }],
|
|
4326
|
-
});
|
|
4327
|
-
}
|
|
4375
|
+
// #846: opens a hosted dashboard URL in the browser — an external side
|
|
4376
|
+
// effect (open world), not a read-only query; safe to repeat.
|
|
4377
|
+
annotations: {
|
|
4378
|
+
readOnlyHint: false,
|
|
4379
|
+
destructiveHint: false,
|
|
4380
|
+
idempotentHint: true,
|
|
4381
|
+
openWorldHint: true,
|
|
4382
|
+
},
|
|
4383
|
+
description: "Opens the context-mode Insight dashboard (https://context-mode.com/insight) in your " +
|
|
4384
|
+
"default browser — a dashboard launcher for the hosted analytics layer, not a Q&A engine. " +
|
|
4385
|
+
"Insight surfaces per-engineer productive rate, retry waste, blocker detection, and " +
|
|
4386
|
+
"role-narrowed views for CTO, EM, IC, CISO, FinOps, and DevOps. " +
|
|
4387
|
+
"For natural-language queries over your indexed content, use ctx_search.",
|
|
4388
|
+
inputSchema: z.object({}),
|
|
4389
|
+
}, async () => {
|
|
4390
|
+
const open = openBrowserSync(INSIGHT_URL);
|
|
4391
|
+
const text = open.ok
|
|
4392
|
+
? `Opening Insight in your browser: ${INSIGHT_URL}`
|
|
4393
|
+
: `Could not auto-open your browser (${open.reason}).\nOpen Insight manually: ${INSIGHT_URL}`;
|
|
4394
|
+
return trackResponse("ctx_insight", {
|
|
4395
|
+
content: [{ type: "text", text }],
|
|
4396
|
+
});
|
|
4328
4397
|
});
|
|
4329
4398
|
// ─────────────────────────────────────────────────────────
|
|
4330
4399
|
// Server startup
|
|
@@ -4340,6 +4409,8 @@ async function main() {
|
|
|
4340
4409
|
// Hardcoded /tmp on Unix to avoid TMPDIR mismatch (#347).
|
|
4341
4410
|
const mcpSentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
4342
4411
|
const mcpSentinel = join(mcpSentinelDir, `context-mode-mcp-ready-${process.pid}`);
|
|
4412
|
+
// #844: handle to the periodic sentinel refresh timer (started after connect).
|
|
4413
|
+
let sentinelRefresh;
|
|
4343
4414
|
// Clean up own DB + backgrounded processes + preload script on shutdown
|
|
4344
4415
|
const shutdown = () => {
|
|
4345
4416
|
executor.cleanupBackgrounded();
|
|
@@ -4354,13 +4425,9 @@ async function main() {
|
|
|
4354
4425
|
unlinkSync(mcpSentinel);
|
|
4355
4426
|
}
|
|
4356
4427
|
catch { /* best effort */ }
|
|
4357
|
-
//
|
|
4358
|
-
if (
|
|
4359
|
-
|
|
4360
|
-
_insightChild.kill("SIGTERM");
|
|
4361
|
-
}
|
|
4362
|
-
catch { /* best effort */ }
|
|
4363
|
-
}
|
|
4428
|
+
// #844: stop refreshing the sentinel mtime on shutdown.
|
|
4429
|
+
if (sentinelRefresh)
|
|
4430
|
+
clearInterval(sentinelRefresh);
|
|
4364
4431
|
};
|
|
4365
4432
|
const gracefulShutdown = async () => {
|
|
4366
4433
|
// Final stats flush — bypass throttle so the last 0-500ms of
|
|
@@ -4382,11 +4449,27 @@ async function main() {
|
|
|
4382
4449
|
startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
4383
4450
|
const transport = new StdioServerTransport();
|
|
4384
4451
|
await server.connect(transport);
|
|
4452
|
+
// #854: refresh the bridge-child idle clock on each inbound MCP message so an
|
|
4453
|
+
// abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
|
|
4454
|
+
// of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
|
|
4455
|
+
attachMcpActivityTap(transport);
|
|
4385
4456
|
// Write MCP readiness sentinel (#230)
|
|
4386
4457
|
try {
|
|
4387
4458
|
writeFileSync(mcpSentinel, String(process.pid));
|
|
4388
4459
|
}
|
|
4389
4460
|
catch { /* best effort */ }
|
|
4461
|
+
// #844: refresh the sentinel mtime while the server is alive so readiness
|
|
4462
|
+
// probes from a foreign PID namespace (shared /tmp) can trust a recent
|
|
4463
|
+
// sentinel even when process.kill(pid, 0) cannot see this PID. The reader's
|
|
4464
|
+
// freshness window is 90s (hooks/core/mcp-ready.mjs); refresh at 30s (3x).
|
|
4465
|
+
// unref() so this timer never keeps the event loop alive on its own.
|
|
4466
|
+
sentinelRefresh = setInterval(() => {
|
|
4467
|
+
try {
|
|
4468
|
+
writeFileSync(mcpSentinel, String(process.pid));
|
|
4469
|
+
}
|
|
4470
|
+
catch { /* best effort */ }
|
|
4471
|
+
}, 30_000);
|
|
4472
|
+
sentinelRefresh.unref();
|
|
4390
4473
|
// Detect platform adapter — stored for platform-aware session paths
|
|
4391
4474
|
try {
|
|
4392
4475
|
const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
|
|
@@ -4447,6 +4530,10 @@ async function main() {
|
|
|
4447
4530
|
}
|
|
4448
4531
|
}
|
|
4449
4532
|
}
|
|
4533
|
+
// Runs after every registerTool() above, so the SDK's default tools/list handler
|
|
4534
|
+
// exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
|
|
4535
|
+
// function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
|
|
4536
|
+
installStrictClientSchemaCompat();
|
|
4450
4537
|
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
4451
4538
|
main().catch((err) => {
|
|
4452
4539
|
console.error("Fatal:", err);
|