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.
Files changed (149) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +149 -30
  7. package/bin/statusline.mjs +24 -4
  8. package/build/adapters/antigravity/index.d.ts +1 -1
  9. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  10. package/build/adapters/antigravity-cli/index.js +342 -0
  11. package/build/adapters/claude-code/hooks.d.ts +1 -0
  12. package/build/adapters/claude-code/hooks.js +3 -0
  13. package/build/adapters/claude-code/index.js +24 -5
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +5 -1
  16. package/build/adapters/codex/hooks.js +5 -1
  17. package/build/adapters/codex/index.d.ts +9 -1
  18. package/build/adapters/codex/index.js +87 -5
  19. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  20. package/build/adapters/copilot-cli/hooks.js +64 -0
  21. package/build/adapters/copilot-cli/index.d.ts +48 -0
  22. package/build/adapters/copilot-cli/index.js +341 -0
  23. package/build/adapters/detect.d.ts +1 -1
  24. package/build/adapters/detect.js +71 -3
  25. package/build/adapters/openclaw/mcp-tools.js +1 -1
  26. package/build/adapters/opencode/index.js +31 -17
  27. package/build/adapters/opencode/zod3tov4.js +27 -6
  28. package/build/adapters/pi/extension.d.ts +2 -12
  29. package/build/adapters/pi/extension.js +128 -109
  30. package/build/adapters/types.d.ts +5 -4
  31. package/build/adapters/types.js +4 -3
  32. package/build/cache-heal.d.ts +48 -0
  33. package/build/cache-heal.js +150 -0
  34. package/build/cli.js +37 -97
  35. package/build/executor.d.ts +25 -0
  36. package/build/executor.js +143 -22
  37. package/build/lifecycle.d.ts +48 -0
  38. package/build/lifecycle.js +111 -0
  39. package/build/opencode-plugin.js +5 -2
  40. package/build/routing-block.d.ts +8 -0
  41. package/build/routing-block.js +86 -0
  42. package/build/runtime.d.ts +0 -36
  43. package/build/runtime.js +107 -27
  44. package/build/search/flood-guard.d.ts +57 -0
  45. package/build/search/flood-guard.js +80 -0
  46. package/build/security.d.ts +73 -3
  47. package/build/security.js +293 -33
  48. package/build/server.d.ts +14 -0
  49. package/build/server.js +441 -354
  50. package/build/session/analytics.d.ts +1 -1
  51. package/build/session/analytics.js +5 -1
  52. package/build/session/db.js +23 -3
  53. package/build/session/extract.js +78 -0
  54. package/build/store.d.ts +1 -1
  55. package/build/store.js +139 -25
  56. package/build/tool-naming.d.ts +4 -0
  57. package/build/tool-naming.js +24 -0
  58. package/build/util/jsonc.d.ts +14 -0
  59. package/build/util/jsonc.js +104 -0
  60. package/cli.bundle.mjs +253 -250
  61. package/configs/antigravity/GEMINI.md +2 -2
  62. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  63. package/configs/antigravity-cli/hooks.json +37 -0
  64. package/configs/antigravity-cli/mcp_config.json +10 -0
  65. package/configs/antigravity-cli/plugin.json +14 -0
  66. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  67. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  68. package/configs/claude-code/CLAUDE.md +2 -2
  69. package/configs/codex/AGENTS.md +2 -2
  70. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  71. package/configs/copilot-cli/.mcp.json +12 -0
  72. package/configs/copilot-cli/README.md +47 -0
  73. package/configs/copilot-cli/hooks.json +41 -0
  74. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  75. package/configs/gemini-cli/GEMINI.md +2 -2
  76. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  77. package/configs/kilo/AGENTS.md +2 -2
  78. package/configs/kiro/KIRO.md +2 -2
  79. package/configs/omp/SYSTEM.md +2 -2
  80. package/configs/openclaw/AGENTS.md +2 -2
  81. package/configs/opencode/AGENTS.md +2 -2
  82. package/configs/qwen-code/QWEN.md +2 -2
  83. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  84. package/configs/zed/AGENTS.md +2 -2
  85. package/hooks/antigravity-cli/payload.mjs +98 -0
  86. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  87. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  88. package/hooks/antigravity-cli/stop.mjs +58 -0
  89. package/hooks/codex/pretooluse.mjs +14 -4
  90. package/hooks/codex/stop.mjs +12 -4
  91. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  92. package/hooks/copilot-cli/precompact.mjs +66 -0
  93. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  94. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  95. package/hooks/copilot-cli/stop.mjs +59 -0
  96. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  97. package/hooks/core/codex-caps.mjs +112 -0
  98. package/hooks/core/formatters.mjs +158 -7
  99. package/hooks/core/mcp-ready.mjs +37 -8
  100. package/hooks/core/routing.mjs +94 -8
  101. package/hooks/core/tool-naming.mjs +3 -0
  102. package/hooks/hooks.json +12 -1
  103. package/hooks/pretooluse.mjs +6 -2
  104. package/hooks/routing-block.mjs +3 -4
  105. package/hooks/security.bundle.mjs +2 -1
  106. package/hooks/session-db.bundle.mjs +5 -5
  107. package/hooks/session-directive.mjs +88 -20
  108. package/hooks/session-extract.bundle.mjs +2 -2
  109. package/hooks/session-helpers.mjs +21 -0
  110. package/hooks/sessionstart.mjs +37 -5
  111. package/hooks/stop.mjs +49 -0
  112. package/openclaw.plugin.json +1 -1
  113. package/package.json +2 -10
  114. package/server.bundle.mjs +206 -200
  115. package/skills/ctx-insight/SKILL.md +12 -17
  116. package/build/util/db-lock.d.ts +0 -65
  117. package/build/util/db-lock.js +0 -166
  118. package/insight/index.html +0 -13
  119. package/insight/package.json +0 -55
  120. package/insight/server.mjs +0 -1265
  121. package/insight/src/components/analytics.tsx +0 -112
  122. package/insight/src/components/ui/badge.tsx +0 -52
  123. package/insight/src/components/ui/button.tsx +0 -58
  124. package/insight/src/components/ui/card.tsx +0 -103
  125. package/insight/src/components/ui/chart.tsx +0 -371
  126. package/insight/src/components/ui/collapsible.tsx +0 -19
  127. package/insight/src/components/ui/input.tsx +0 -20
  128. package/insight/src/components/ui/progress.tsx +0 -83
  129. package/insight/src/components/ui/scroll-area.tsx +0 -55
  130. package/insight/src/components/ui/separator.tsx +0 -23
  131. package/insight/src/components/ui/table.tsx +0 -114
  132. package/insight/src/components/ui/tabs.tsx +0 -82
  133. package/insight/src/components/ui/tooltip.tsx +0 -64
  134. package/insight/src/lib/api.ts +0 -144
  135. package/insight/src/lib/utils.ts +0 -6
  136. package/insight/src/main.tsx +0 -22
  137. package/insight/src/routeTree.gen.ts +0 -189
  138. package/insight/src/router.tsx +0 -19
  139. package/insight/src/routes/__root.tsx +0 -55
  140. package/insight/src/routes/enterprise.tsx +0 -316
  141. package/insight/src/routes/index.tsx +0 -1482
  142. package/insight/src/routes/knowledge.tsx +0 -221
  143. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  144. package/insight/src/routes/search.tsx +0 -97
  145. package/insight/src/routes/sessions.tsx +0 -179
  146. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  147. package/insight/src/styles.css +0 -104
  148. package/insight/tsconfig.json +0 -29
  149. 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, cpSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
6
- import { execSync, spawnSync } from "node:child_process";
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
- process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
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 = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
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(cacheRoot + sep))
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
- title: "Execute Code",
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: "shell", code: "npm test 2>&1 | grep -E '(FAIL|✗|×|Error:|Tests +.*(failed|passed))' | head -60")
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 result = await executor.execute({ language, code: instrumentedCode, timeout, background });
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 ${timeout}ms — still running)_`,
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 ${timeout}ms — partial output shown above)_`,
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 ${timeout}ms\n\nstderr:\n${result.stderr}`,
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
- title: "Execute File Processing",
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 ${timeout}ms`,
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 in time window
2346
+ // Progressive throttling: track calls per agent-context window (#769).
2133
2347
  const now = Date.now();
2134
- if (now - searchWindowStart > SEARCH_WINDOW_MS) {
2135
- searchCallCount = 0;
2136
- searchWindowStart = now;
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 - searchWindowStart) / 1000)}s. ` +
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 = searchCallCount > SEARCH_MAX_RESULTS_AFTER
2153
- ? 1 // after 3 calls: only 1 result per query
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 ${timeout}ms. No output captured.`,
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
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
3529
- const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
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 { /* best effort — fall back to upgrade()'s own detect */ }
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: analytics dashboard ──────────────────────────────────────────
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
- description: "Opens the context-mode Insight dashboard in the browser — a dashboard launcher for session analytics; for natural-language queries over indexed content, use ctx_search. " +
4090
- "Shows personal analytics: session activity, tool usage, error rate, " +
4091
- "parallel work patterns, project focus, and actionable insights. " +
4092
- "First run installs dependencies (~30s). Subsequent runs open instantly. " +
4093
- "Defaults to port 4747; pass `port` to override. " +
4094
- "`sessionDir` and `contentDir` override the session/content storage roots " +
4095
- "(env aliases INSIGHT_SESSION_DIR / INSIGHT_CONTENT_DIR) for diagnosing " +
4096
- "multi-install setups or pointing at a sibling project's data.",
4097
- inputSchema: z.object({
4098
- port: z.coerce.number().int().min(1).max(65535).optional().describe("Port to serve on (default: 4747)"),
4099
- sessionDir: z.string().optional().describe("Override INSIGHT_SESSION_DIR: directory containing context-mode session .db files"),
4100
- contentDir: z.string().optional().describe("Override INSIGHT_CONTENT_DIR: directory containing context-mode content/index .db files"),
4101
- insightSessionDir: z.string().optional().describe("Alias for sessionDir / INSIGHT_SESSION_DIR"),
4102
- insightContentDir: z.string().optional().describe("Alias for contentDir / INSIGHT_CONTENT_DIR"),
4103
- }),
4104
- }, async ({ port: userPort, sessionDir, contentDir, insightSessionDir, insightContentDir }) => {
4105
- const port = userPort || 4747;
4106
- const explicitSessionDir = sessionDir || insightSessionDir;
4107
- const explicitContentDir = contentDir || insightContentDir;
4108
- // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root
4109
- const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
4110
- const insightSource = resolve(pluginRoot, "insight");
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
- // Stop ctx_insight dashboard so it does not outlive Claude.
4358
- if (_insightChild && _insightChild.pid && !_insightChild.killed) {
4359
- try {
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);