context-mode 1.0.161 → 1.0.163

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 (153) 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 +142 -28
  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 +341 -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 +114 -96
  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/opencode-plugin.js +5 -2
  38. package/build/routing-block.d.ts +8 -0
  39. package/build/routing-block.js +86 -0
  40. package/build/runtime.d.ts +0 -36
  41. package/build/runtime.js +107 -27
  42. package/build/search/flood-guard.d.ts +57 -0
  43. package/build/search/flood-guard.js +80 -0
  44. package/build/security.d.ts +8 -3
  45. package/build/security.js +155 -29
  46. package/build/server.d.ts +14 -0
  47. package/build/server.js +368 -350
  48. package/build/session/analytics.d.ts +8 -8
  49. package/build/session/analytics.js +18 -13
  50. package/build/session/db.d.ts +1 -0
  51. package/build/session/db.js +37 -4
  52. package/build/session/extract.d.ts +46 -0
  53. package/build/session/extract.js +764 -13
  54. package/build/session/project-attribution.js +14 -0
  55. package/build/store.d.ts +1 -1
  56. package/build/store.js +139 -25
  57. package/build/tool-naming.d.ts +4 -0
  58. package/build/tool-naming.js +24 -0
  59. package/build/util/jsonc.d.ts +14 -0
  60. package/build/util/jsonc.js +104 -0
  61. package/cli.bundle.mjs +260 -254
  62. package/configs/antigravity/GEMINI.md +2 -2
  63. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  64. package/configs/antigravity-cli/hooks.json +37 -0
  65. package/configs/antigravity-cli/mcp_config.json +10 -0
  66. package/configs/antigravity-cli/plugin.json +14 -0
  67. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  68. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  69. package/configs/claude-code/CLAUDE.md +2 -2
  70. package/configs/codex/AGENTS.md +2 -2
  71. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  72. package/configs/copilot-cli/.mcp.json +12 -0
  73. package/configs/copilot-cli/README.md +47 -0
  74. package/configs/copilot-cli/hooks.json +41 -0
  75. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  76. package/configs/gemini-cli/GEMINI.md +2 -2
  77. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  78. package/configs/kilo/AGENTS.md +2 -2
  79. package/configs/kiro/KIRO.md +2 -2
  80. package/configs/omp/SYSTEM.md +2 -2
  81. package/configs/openclaw/AGENTS.md +2 -2
  82. package/configs/opencode/AGENTS.md +2 -2
  83. package/configs/qwen-code/QWEN.md +2 -2
  84. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  85. package/configs/zed/AGENTS.md +2 -2
  86. package/hooks/antigravity-cli/payload.mjs +98 -0
  87. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  88. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  89. package/hooks/antigravity-cli/stop.mjs +58 -0
  90. package/hooks/codex/pretooluse.mjs +14 -4
  91. package/hooks/codex/stop.mjs +12 -4
  92. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  93. package/hooks/copilot-cli/precompact.mjs +66 -0
  94. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  95. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  96. package/hooks/copilot-cli/stop.mjs +59 -0
  97. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  98. package/hooks/core/codex-caps.mjs +112 -0
  99. package/hooks/core/formatters.mjs +158 -7
  100. package/hooks/core/mcp-ready.mjs +37 -8
  101. package/hooks/core/routing.mjs +94 -8
  102. package/hooks/core/tool-naming.mjs +3 -0
  103. package/hooks/hooks.json +12 -1
  104. package/hooks/pretooluse.mjs +6 -2
  105. package/hooks/routing-block.mjs +2 -2
  106. package/hooks/security.bundle.mjs +2 -1
  107. package/hooks/session-db.bundle.mjs +11 -7
  108. package/hooks/session-directive.mjs +88 -20
  109. package/hooks/session-extract.bundle.mjs +2 -2
  110. package/hooks/session-helpers.mjs +21 -0
  111. package/hooks/session-loaders.mjs +8 -5
  112. package/hooks/sessionstart.mjs +53 -7
  113. package/hooks/stop.mjs +49 -0
  114. package/hooks/userpromptsubmit.mjs +9 -2
  115. package/openclaw.plugin.json +1 -1
  116. package/package.json +4 -10
  117. package/scripts/install-antigravity-cli-plugin.mjs +141 -0
  118. package/server.bundle.mjs +214 -205
  119. package/skills/ctx-insight/SKILL.md +12 -17
  120. package/build/util/db-lock.d.ts +0 -65
  121. package/build/util/db-lock.js +0 -166
  122. package/insight/index.html +0 -13
  123. package/insight/package.json +0 -55
  124. package/insight/server.mjs +0 -1265
  125. package/insight/src/components/analytics.tsx +0 -112
  126. package/insight/src/components/ui/badge.tsx +0 -52
  127. package/insight/src/components/ui/button.tsx +0 -58
  128. package/insight/src/components/ui/card.tsx +0 -103
  129. package/insight/src/components/ui/chart.tsx +0 -371
  130. package/insight/src/components/ui/collapsible.tsx +0 -19
  131. package/insight/src/components/ui/input.tsx +0 -20
  132. package/insight/src/components/ui/progress.tsx +0 -83
  133. package/insight/src/components/ui/scroll-area.tsx +0 -55
  134. package/insight/src/components/ui/separator.tsx +0 -23
  135. package/insight/src/components/ui/table.tsx +0 -114
  136. package/insight/src/components/ui/tabs.tsx +0 -82
  137. package/insight/src/components/ui/tooltip.tsx +0 -64
  138. package/insight/src/lib/api.ts +0 -144
  139. package/insight/src/lib/utils.ts +0 -6
  140. package/insight/src/main.tsx +0 -22
  141. package/insight/src/routeTree.gen.ts +0 -189
  142. package/insight/src/router.tsx +0 -19
  143. package/insight/src/routes/__root.tsx +0 -55
  144. package/insight/src/routes/enterprise.tsx +0 -316
  145. package/insight/src/routes/index.tsx +0 -1482
  146. package/insight/src/routes/knowledge.tsx +0 -221
  147. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  148. package/insight/src/routes/search.tsx +0 -97
  149. package/insight/src/routes/sessions.tsx +0 -179
  150. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  151. package/insight/src/styles.css +0 -104
  152. package/insight/tsconfig.json +0 -29
  153. 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";
@@ -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 = [
@@ -281,6 +277,69 @@ server.server.registerCapabilities({ prompts: { listChanged: false }, resources:
281
277
  server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
282
278
  server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
283
279
  server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
280
+ // ── Strict-client (Gemini function-calling) schema compatibility ──────────────
281
+ // Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
282
+ // — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
283
+ // schema makes the host SILENTLY DROP that tool from the model's function list,
284
+ // so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
285
+ // protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
286
+ // • `const: X` → `enum: [X]` — an identical single-value constraint
287
+ // • drop `additionalProperties` — advisory only; every ctx_* handler parses
288
+ // args with Zod (which strips unknown keys server-side), so removing it
289
+ // changes no validation and no call behavior.
290
+ // Both transforms are behavior-preserving for every other client (Claude Code,
291
+ // Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
292
+ // model sends undeclared properties. Only the wire schema changes — never
293
+ // validation or how any tool is invoked.
294
+ export function sanitizeSchemaForStrictClients(node) {
295
+ if (Array.isArray(node))
296
+ return node.map(sanitizeSchemaForStrictClients);
297
+ if (node === null || typeof node !== "object")
298
+ return node;
299
+ const out = {};
300
+ for (const [key, value] of Object.entries(node)) {
301
+ if (key === "additionalProperties")
302
+ continue;
303
+ if (key === "const") {
304
+ out.enum = [value];
305
+ continue;
306
+ }
307
+ out[key] = sanitizeSchemaForStrictClients(value);
308
+ }
309
+ return out;
310
+ }
311
+ // Wrap the SDK-installed tools/list handler so its generated schemas pass through
312
+ // the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
313
+ // the original handler is left untouched (no regression — strict clients stay as
314
+ // they were, every other client unaffected). Must run AFTER all registerTool()
315
+ // calls so the SDK's default tools/list handler already exists.
316
+ export function installStrictClientSchemaCompat(target = server) {
317
+ try {
318
+ const low = target.server;
319
+ const original = low._requestHandlers?.get("tools/list");
320
+ if (typeof original !== "function")
321
+ return;
322
+ target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
323
+ const result = (await original(req, extra));
324
+ if (result && Array.isArray(result.tools)) {
325
+ for (const tool of result.tools) {
326
+ if (!tool || tool.inputSchema == null)
327
+ continue;
328
+ try {
329
+ tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
330
+ }
331
+ catch {
332
+ /* leave this tool's schema unchanged */
333
+ }
334
+ }
335
+ }
336
+ return result;
337
+ });
338
+ }
339
+ catch {
340
+ /* best-effort — never break tools/list */
341
+ }
342
+ }
284
343
  const executor = new PolyglotExecutor({
285
344
  runtimes,
286
345
  projectRoot: () => getProjectDir(),
@@ -394,9 +453,6 @@ function maybeIndexSessionEvents(store) {
394
453
  // platform-specific paths. All session DB paths go through it — no
395
454
  // hardcoded configDir detection in tool handlers.
396
455
  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
456
  /**
401
457
  * Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl.
402
458
  * leading `~`) before falling back to `~/.claude`. Mirrors
@@ -712,8 +768,21 @@ function healCacheMidSession() {
712
768
  return;
713
769
  const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
714
770
  const cacheRoot = resolve(claudeRoot, "plugins", "cache");
771
+ // Issue #795: canonicalize cacheRoot so the traversal guard works when
772
+ // ~/.claude is a symlink to another volume. path.resolve() does not
773
+ // dereference symlinks, so installPath values stored as physical paths
774
+ // (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith
775
+ // check against a symlink-path cacheRoot (/Users/me/.claude/...).
776
+ // realpathSync follows the symlink chain to the canonical location.
777
+ let cacheRootCanon;
778
+ try {
779
+ cacheRootCanon = realpathSync(cacheRoot);
780
+ }
781
+ catch {
782
+ cacheRootCanon = cacheRoot;
783
+ }
715
784
  // Plugin root: build/ for tsc, plugin root for bundle
716
- const pluginRoot = existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir);
785
+ const pluginRoot = getPackageRoot();
717
786
  for (const [key, entries] of Object.entries((ip.plugins ?? {}))) {
718
787
  if (key !== "context-mode@context-mode")
719
788
  continue;
@@ -721,8 +790,8 @@ function healCacheMidSession() {
721
790
  const rp = entry.installPath;
722
791
  if (!rp || existsSync(rp))
723
792
  continue;
724
- // Path traversal guard
725
- if (!resolve(rp).startsWith(cacheRoot + sep))
793
+ // Path traversal guard (canonical comparison — see #795)
794
+ if (!resolve(rp).startsWith(cacheRootCanon + sep))
726
795
  continue;
727
796
  // Remove dangling symlink
728
797
  try {
@@ -1163,6 +1232,26 @@ function truncateCommandForEcho(command) {
1163
1232
  return cleaned;
1164
1233
  return cleaned.slice(0, COMMAND_ECHO_MAX) + "…";
1165
1234
  }
1235
+ /**
1236
+ * Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`).
1237
+ * agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or
1238
+ * blocking script hangs forever — the host never kills it and the user must
1239
+ * interrupt. Every other host enforces its own RPC timeout, so we keep the
1240
+ * no-server-timer behavior there (Issue #406 — long builds need an unbounded
1241
+ * run). A caller can still pass an explicit `timeout` to override on any host.
1242
+ */
1243
+ export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000;
1244
+ export function resolveExecTimeout(timeout) {
1245
+ if (timeout !== undefined)
1246
+ return timeout;
1247
+ // Only agy gets a default — every other host enforces its own RPC timeout, so
1248
+ // keep the unbounded behavior there. Detected via the env the agy bundle pins
1249
+ // (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS.
1250
+ if (detectPlatform().platform !== "antigravity-cli")
1251
+ return undefined;
1252
+ const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS);
1253
+ return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS;
1254
+ }
1166
1255
  /**
1167
1256
  * Per-call budget for the source-code echo prepended by `ctx_execute` and
1168
1257
  * `ctx_execute_file` (Issues #717 + #736). The full code always reaches the
@@ -1219,7 +1308,7 @@ function combineExecOutput(result) {
1219
1308
  * record `(timed out)` blocks without skipping siblings.
1220
1309
  */
1221
1310
  export async function runBatchCommands(commands, opts, executor) {
1222
- const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
1311
+ const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;
1223
1312
  if (concurrency <= 1) {
1224
1313
  // Serial path — shared timeout budget, cascading skip on timeout.
1225
1314
  // When `timeout` is undefined, no shared budget is enforced; each
@@ -1244,6 +1333,7 @@ export async function runBatchCommands(commands, opts, executor) {
1244
1333
  language: "shell",
1245
1334
  code: `${nodeOptsPrefix}${cmd.command}`,
1246
1335
  timeout: perCmdTimeout,
1336
+ cwd,
1247
1337
  });
1248
1338
  outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
1249
1339
  if (result.timedOut) {
@@ -1265,6 +1355,7 @@ export async function runBatchCommands(commands, opts, executor) {
1265
1355
  language: "shell",
1266
1356
  code: `${nodeOptsPrefix}${cmd.command}`,
1267
1357
  timeout,
1358
+ cwd,
1268
1359
  });
1269
1360
  // Always route partial output through formatCommandOutput so __CM_FS__
1270
1361
  // markers are stripped + counted, even when the command timed out.
@@ -1298,6 +1389,13 @@ export async function runBatchCommands(commands, opts, executor) {
1298
1389
  // ─────────────────────────────────────────────────────────
1299
1390
  server.registerTool("ctx_execute", {
1300
1391
  title: "Execute Code",
1392
+ // #846: runs arbitrary code in a sandbox with full network access.
1393
+ annotations: {
1394
+ readOnlyHint: false,
1395
+ destructiveHint: true,
1396
+ idempotentHint: false,
1397
+ openWorldHint: true,
1398
+ },
1301
1399
  description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}.
1302
1400
 
1303
1401
  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 +1426,7 @@ WHEN NOT:
1328
1426
  RETURNS:
1329
1427
  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
1428
 
1331
- EXAMPLE: ctx_execute(language: "shell", code: "npm test 2>&1 | grep -E '(FAIL|✗|×|Error:|Tests +.*(failed|passed))' | head -60")
1429
+ 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
1430
  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
1431
  inputSchema: z.object({
1334
1432
  language: z
@@ -1364,6 +1462,10 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
1364
1462
  .optional()
1365
1463
  .default(false)
1366
1464
  .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."),
1465
+ cwd: z
1466
+ .string()
1467
+ .optional()
1468
+ .describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
1367
1469
  intent: z
1368
1470
  .string()
1369
1471
  .optional()
@@ -1372,7 +1474,7 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
1372
1474
  "Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
1373
1475
  "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
1374
1476
  }),
1375
- }, async ({ language, code, timeout, background, intent }) => {
1477
+ }, async ({ language, code, timeout, background, cwd, intent }) => {
1376
1478
  // Security: deny-only firewall
1377
1479
  if (language === "shell") {
1378
1480
  const denied = checkDenyPolicy(code, "execute");
@@ -1452,7 +1554,8 @@ ${code}
1452
1554
  __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
1453
1555
  })(typeof require!=='undefined'?require:null);`;
1454
1556
  }
1455
- const result = await executor.execute({ language, code: instrumentedCode, timeout, background });
1557
+ const effTimeout = resolveExecTimeout(timeout);
1558
+ const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
1456
1559
  // Echo the executed source code before stdout so users can audit
1457
1560
  // and tooling can block command patterns (Issues #717 + #736).
1458
1561
  // Built from the user-supplied `code`, NOT the instrumented variant.
@@ -1478,7 +1581,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1478
1581
  content: [
1479
1582
  {
1480
1583
  type: "text",
1481
- text: `${echo}${partialOutput}\n\n_(process backgrounded after ${timeout}ms — still running)_`,
1584
+ text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`,
1482
1585
  },
1483
1586
  ],
1484
1587
  });
@@ -1489,7 +1592,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1489
1592
  content: [
1490
1593
  {
1491
1594
  type: "text",
1492
- text: `${echo}${partialOutput}\n\n_(timed out after ${timeout}ms — partial output shown above)_`,
1595
+ text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`,
1493
1596
  },
1494
1597
  ],
1495
1598
  });
@@ -1498,7 +1601,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1498
1601
  content: [
1499
1602
  {
1500
1603
  type: "text",
1501
- text: `${echo}Execution timed out after ${timeout}ms\n\nstderr:\n${result.stderr}`,
1604
+ text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`,
1502
1605
  },
1503
1606
  ],
1504
1607
  isError: true,
@@ -1639,6 +1742,13 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
1639
1742
  // ─────────────────────────────────────────────────────────
1640
1743
  server.registerTool("ctx_execute_file", {
1641
1744
  title: "Execute File Processing",
1745
+ // #846: runs arbitrary code over a file in a sandbox with full network access.
1746
+ annotations: {
1747
+ readOnlyHint: false,
1748
+ destructiveHint: true,
1749
+ idempotentHint: false,
1750
+ openWorldHint: true,
1751
+ },
1642
1752
  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
1753
 
1644
1754
  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.
@@ -1709,11 +1819,12 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1709
1819
  return codeDenied;
1710
1820
  }
1711
1821
  try {
1822
+ const effTimeout = resolveExecTimeout(timeout);
1712
1823
  const result = await executor.executeFile({
1713
1824
  path,
1714
1825
  language,
1715
1826
  code,
1716
- timeout,
1827
+ timeout: effTimeout,
1717
1828
  });
1718
1829
  // Echo path + executed source code before stdout for audit/debug
1719
1830
  // (Issues #717 + #736).
@@ -1723,7 +1834,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1723
1834
  content: [
1724
1835
  {
1725
1836
  type: "text",
1726
- text: `${echo}Timed out processing ${path} after ${timeout}ms`,
1837
+ text: `${echo}Timed out processing ${path} after ${effTimeout}ms`,
1727
1838
  },
1728
1839
  ],
1729
1840
  isError: true,
@@ -1800,6 +1911,14 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
1800
1911
  // ─────────────────────────────────────────────────────────
1801
1912
  server.registerTool("ctx_index", {
1802
1913
  title: "Index Content",
1914
+ // #846: writes content into the local FTS5 store (additive, not destructive;
1915
+ // re-indexing the same content adds rows, so not idempotent). No network.
1916
+ annotations: {
1917
+ readOnlyHint: false,
1918
+ destructiveHint: false,
1919
+ idempotentHint: false,
1920
+ openWorldHint: false,
1921
+ },
1803
1922
  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
1923
 
1805
1924
  WHEN:
@@ -1987,11 +2106,34 @@ function readPositiveEnv(name, defaultValue) {
1987
2106
  const parsed = Number(raw);
1988
2107
  return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
1989
2108
  }
1990
- let searchCallCount = 0;
1991
- let searchWindowStart = Date.now();
1992
2109
  const SEARCH_WINDOW_MS = readPositiveEnv("CONTEXT_MODE_SEARCH_WINDOW_MS", 60_000);
1993
2110
  const SEARCH_MAX_RESULTS_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_MAX_RESULTS_AFTER", 3); // after N calls: 1 result per query
1994
2111
  const SEARCH_BLOCK_AFTER = readPositiveEnv("CONTEXT_MODE_SEARCH_BLOCK_AFTER", 8); // after N calls: refuse, demand batching
2112
+ // #769: progressive throttle bucketed PER agent-context, not machine-global.
2113
+ // Concurrent subagents share ONE MCP server process; a single global counter
2114
+ // summed their independent searches into one budget and hard-blocked
2115
+ // legitimate parallel fan-out. The guard keys each actor's window separately
2116
+ // so single-actor flood protection is preserved while fan-out is not starved.
2117
+ const searchFloodGuard = new FloodGuard({
2118
+ windowMs: SEARCH_WINDOW_MS,
2119
+ softCapAfter: SEARCH_MAX_RESULTS_AFTER,
2120
+ blockAfter: SEARCH_BLOCK_AFTER,
2121
+ });
2122
+ /**
2123
+ * Per-agent flood-guard key. Each concurrent subagent in a Claude Code
2124
+ * Task/Workflow fan-out runs under its own session id (written to SessionDB
2125
+ * via hooks), so currentAttribution().sessionId is the per-agent discriminator
2126
+ * already available MCP-side. Falls back to a single shared bucket when no
2127
+ * identity is resolvable (preserves today's single-threaded behaviour).
2128
+ */
2129
+ function searchFloodGuardKey() {
2130
+ try {
2131
+ return currentAttribution()?.sessionId ?? "__default__";
2132
+ }
2133
+ catch {
2134
+ return "__default__";
2135
+ }
2136
+ }
1995
2137
  /**
1996
2138
  * Defensive coercion: parse stringified JSON arrays, AND lift a bare
1997
2139
  * non-empty string into a single-element array.
@@ -2060,6 +2202,13 @@ function coerceCommandsArray(val) {
2060
2202
  }
2061
2203
  server.registerTool("ctx_search", {
2062
2204
  title: "Search Indexed Content",
2205
+ // #846: read-only query over the local FTS5 store. No mutation, no network.
2206
+ annotations: {
2207
+ readOnlyHint: true,
2208
+ destructiveHint: false,
2209
+ idempotentHint: true,
2210
+ openWorldHint: false,
2211
+ },
2063
2212
  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
2213
 
2065
2214
  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 +2278,16 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2129
2278
  // per-project DB is naturally isolated by directory hash, so there is
2130
2279
  // nothing for an in-process filter to do.
2131
2280
  const projectScope = resolveProjectScope(project, CTX_SEARCH_SHARED_MODE, () => getProjectDir());
2132
- // Progressive throttling: track calls in time window
2281
+ // Progressive throttling: track calls per agent-context window (#769).
2133
2282
  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) {
2283
+ const flood = searchFloodGuard.record(searchFloodGuardKey(), now);
2284
+ const searchCallCount = flood.count;
2285
+ // After SEARCH_BLOCK_AFTER calls (for THIS agent): refuse
2286
+ if (flood.blocked) {
2141
2287
  return trackResponse("ctx_search", {
2142
2288
  content: [{
2143
2289
  type: "text",
2144
- text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - searchWindowStart) / 1000)}s. ` +
2290
+ text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - flood.windowStart) / 1000)}s. ` +
2145
2291
  "You're flooding context. STOP making individual search calls. " +
2146
2292
  "Use ctx_batch_execute(commands, queries) for your next research step.",
2147
2293
  }],
@@ -2149,8 +2295,8 @@ EXAMPLE: ctx_search(queries: ["last user prompt", "active skills", "open blocker
2149
2295
  });
2150
2296
  }
2151
2297
  // 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
2298
+ const effectiveLimit = flood.softCapped
2299
+ ? 1 // after soft cap: only 1 result per query
2154
2300
  : Math.min(limit, 2); // normal: max 2
2155
2301
  const MAX_TOTAL = 40 * 1024; // 40KB total cap
2156
2302
  let totalSize = 0;
@@ -2877,6 +3023,13 @@ function indexFetched(f) {
2877
3023
  }
2878
3024
  server.registerTool("ctx_fetch_and_index", {
2879
3025
  title: "Fetch & Index URL(s)",
3026
+ // #846: fetches external URLs (open world) and writes them into the store.
3027
+ annotations: {
3028
+ readOnlyHint: false,
3029
+ destructiveHint: false,
3030
+ idempotentHint: false,
3031
+ openWorldHint: true,
3032
+ },
2880
3033
  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
3034
 
2882
3035
  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 +3254,13 @@ EXAMPLE: ctx_fetch_and_index(
3101
3254
  // ─────────────────────────────────────────────────────────
3102
3255
  server.registerTool("ctx_batch_execute", {
3103
3256
  title: "Batch Execute & Search",
3257
+ // #846: runs arbitrary shell commands (with network) and indexes output.
3258
+ annotations: {
3259
+ readOnlyHint: false,
3260
+ destructiveHint: true,
3261
+ idempotentHint: false,
3262
+ openWorldHint: true,
3263
+ },
3104
3264
  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
3265
 
3106
3266
  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 +3322,10 @@ EXAMPLE: ctx_batch_execute(
3162
3322
  "Keep at 1 for CPU-bound (npm test, build, lint) or stateful commands (ports, locks). " +
3163
3323
  ">1 switches to per-command timeouts (no shared budget) and " +
3164
3324
  "individual `(timed out)` blocks instead of cascading skip."),
3325
+ cwd: z
3326
+ .string()
3327
+ .optional()
3328
+ .describe("Optional working directory for all shell commands in this batch."),
3165
3329
  query_scope: z
3166
3330
  .enum(["batch", "global"])
3167
3331
  .optional()
@@ -3173,7 +3337,7 @@ EXAMPLE: ctx_batch_execute(
3173
3337
  "— useful when you want the batch commands to enrich context and " +
3174
3338
  "the queries to also surface related prior knowledge in one round trip."),
3175
3339
  }),
3176
- }, async ({ commands, queries, timeout, concurrency, query_scope }) => {
3340
+ }, async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
3177
3341
  // Security: check each command against deny patterns
3178
3342
  for (const cmd of commands) {
3179
3343
  const denied = checkDenyPolicy(cmd.command, "batch_execute");
@@ -3187,10 +3351,12 @@ EXAMPLE: ctx_batch_execute(
3187
3351
  const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
3188
3352
  // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
3189
3353
  // Concurrency>1 switches to a worker pool with per-command timeouts.
3354
+ const effTimeout = resolveExecTimeout(timeout);
3190
3355
  const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
3191
- timeout,
3356
+ timeout: effTimeout,
3192
3357
  concurrency,
3193
3358
  nodeOptsPrefix,
3359
+ cwd,
3194
3360
  onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
3195
3361
  }, executor);
3196
3362
  const stdout = perCommandOutputs.join("\n");
@@ -3201,7 +3367,7 @@ EXAMPLE: ctx_batch_execute(
3201
3367
  content: [
3202
3368
  {
3203
3369
  type: "text",
3204
- text: `Batch timed out after ${timeout}ms. No output captured.`,
3370
+ text: `Batch timed out after ${effTimeout}ms. No output captured.`,
3205
3371
  },
3206
3372
  ],
3207
3373
  isError: true,
@@ -3316,6 +3482,13 @@ function createMinimalDb() {
3316
3482
  }
3317
3483
  server.registerTool("ctx_stats", {
3318
3484
  title: "Session Statistics",
3485
+ // #846: read-only diagnostics. Was cancelled by Codex when unannotated.
3486
+ annotations: {
3487
+ readOnlyHint: true,
3488
+ destructiveHint: false,
3489
+ idempotentHint: true,
3490
+ openWorldHint: false,
3491
+ },
3319
3492
  description: "Returns context consumption statistics for the current session. " +
3320
3493
  "Shows total bytes returned to context, breakdown by tool, call counts, " +
3321
3494
  "estimated token usage, and context savings ratio.",
@@ -3512,6 +3685,14 @@ server.registerTool("ctx_stats", {
3512
3685
  // ── ctx-doctor: diagnostics (server-side) ─────────────────────────────────
3513
3686
  server.registerTool("ctx_doctor", {
3514
3687
  title: "Run Diagnostics",
3688
+ // #846: read-only diagnostics (runs an internal self-test, mutates nothing).
3689
+ // Was cancelled by Codex when unannotated.
3690
+ annotations: {
3691
+ readOnlyHint: true,
3692
+ destructiveHint: false,
3693
+ idempotentHint: true,
3694
+ openWorldHint: false,
3695
+ },
3515
3696
  description: "Diagnose context-mode installation. Runs all checks server-side and " +
3516
3697
  "returns a plain-text status report with [OK]/[FAIL]/[WARN] prefixes " +
3517
3698
  "(renderer-safe across MCP clients). No CLI execution needed.",
@@ -3525,8 +3706,17 @@ server.registerTool("ctx_doctor", {
3525
3706
  // safe across all MCP renderers — using plain-text status prefixes
3526
3707
  // (`[OK]` / `[FAIL]` / `[WARN]`) instead.
3527
3708
  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);
3709
+ let currentPlatform;
3710
+ try {
3711
+ currentPlatform = detectPlatform(server.server.getClientVersion() ?? undefined).platform;
3712
+ }
3713
+ catch {
3714
+ currentPlatform = detectPlatform().platform;
3715
+ }
3716
+ // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3717
+ // Codex is special: when plugin-manager runtime root differs from the
3718
+ // current package root, diagnose the root Codex will actually execute.
3719
+ const pluginRoot = getRuntimeAwarePackageRoot(currentPlatform);
3530
3720
  // Runtimes
3531
3721
  const total = 11;
3532
3722
  const pct = ((available.length / total) * 100).toFixed(0);
@@ -3624,30 +3814,20 @@ server.registerTool("ctx_doctor", {
3624
3814
  // ── ctx-upgrade: upgrade meta-tool ─────────────────────────────────────────
3625
3815
  server.registerTool("ctx_upgrade", {
3626
3816
  title: "Upgrade Plugin",
3817
+ // #846: an action tool (returns an upgrade command to run); not read-only,
3818
+ // but non-destructive and idempotent. No direct network from the call.
3819
+ annotations: {
3820
+ readOnlyHint: false,
3821
+ destructiveHint: false,
3822
+ idempotentHint: true,
3823
+ openWorldHint: false,
3824
+ },
3627
3825
  description: "Upgrade context-mode to the latest version. Returns a shell command to execute. " +
3628
3826
  "You MUST run the returned command using your shell tool (Bash, shell_execute, " +
3629
3827
  "run_in_terminal, etc.) and display the output as a checklist. " +
3630
3828
  "Tell the user to restart their session after upgrade.",
3631
3829
  inputSchema: z.object({}),
3632
3830
  }, 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
3831
  // Issue #542 — thread MCP clientInfo into the spawned upgrade
3652
3832
  // process. detectPlatform() runs IN-PROCESS here (no spawn boundary)
3653
3833
  // so clientInfo from the MCP handshake is the highest-confidence
@@ -3657,16 +3837,44 @@ server.registerTool("ctx_upgrade", {
3657
3837
  // skip the flag and let upgrade()'s own detectPlatform() fall back.
3658
3838
  let platformFlag = "";
3659
3839
  let nodeOpts = undefined;
3840
+ let platformId;
3660
3841
  try {
3661
- const { detectPlatform } = await import("./adapters/detect.js");
3662
3842
  const clientInfo = server.server.getClientVersion();
3663
3843
  const signal = detectPlatform(clientInfo ?? undefined);
3844
+ platformId = signal.platform;
3664
3845
  platformFlag = ` --platform ${signal.platform}`;
3665
3846
  nodeOpts = isInProcessPluginPlatform(signal.platform) && runtimes.javascript
3666
3847
  ? { platform: signal.platform, jsRuntime: runtimes.javascript }
3667
3848
  : undefined;
3668
3849
  }
3669
- catch { /* best effort — fall back to upgrade()'s own detect */ }
3850
+ catch {
3851
+ try {
3852
+ platformId = detectPlatform().platform;
3853
+ }
3854
+ catch { /* best effort — fall back to upgrade()'s own detect */ }
3855
+ }
3856
+ // __pkg_dir is build/ for tsc, plugin root for bundle — resolve to plugin root.
3857
+ // Only Codex may replace it with the plugin-manager runtime root; other
3858
+ // adapters can coexist with Codex on the same machine.
3859
+ const pluginRoot = getRuntimeAwarePackageRoot(platformId);
3860
+ const bundlePath = resolve(pluginRoot, "cli.bundle.mjs");
3861
+ const fallbackPath = resolve(pluginRoot, "build", "cli.js");
3862
+ // Insight pivoted to the hosted dashboard (context-mode.com/insight), so
3863
+ // ctx_insight no longer builds a local cache. On upgrade, sweep the legacy
3864
+ // insight-cache and stop any stale local dashboard left from old versions.
3865
+ try {
3866
+ const sessDir = getSessionDir();
3867
+ const insightCacheDir = join(dirname(sessDir), "insight-cache");
3868
+ if (existsSync(insightCacheDir)) {
3869
+ // Kill any running insight server first via the shared helper —
3870
+ // this is locale-independent on Windows (PR #469) and isolates per-pid
3871
+ // failures. We ignore the structured result: cache cleanup is
3872
+ // best-effort and must never block ctx_upgrade.
3873
+ killProcessOnPort(4747);
3874
+ rmSync(insightCacheDir, { recursive: true, force: true });
3875
+ }
3876
+ }
3877
+ catch { /* best effort — don't block upgrade */ }
3670
3878
  let cmd;
3671
3879
  if (existsSync(bundlePath)) {
3672
3880
  cmd = `${buildNodeCommand(bundlePath, nodeOpts)} upgrade${platformFlag}`;
@@ -3773,6 +3981,14 @@ server.registerTool("ctx_upgrade", {
3773
3981
  // tool with "input_schema does not support fields". Issue #563.
3774
3982
  server.registerTool("ctx_purge", {
3775
3983
  title: "Purge Knowledge Base",
3984
+ // #846: permanently deletes indexed content — destructive. Purging an
3985
+ // already-purged scope has no further effect (idempotent). No network.
3986
+ annotations: {
3987
+ readOnlyHint: false,
3988
+ destructiveHint: true,
3989
+ idempotentHint: true,
3990
+ openWorldHint: false,
3991
+ },
3776
3992
  description: `DESTRUCTIVE: permanently delete indexed content. Cannot be undone. Requires confirm:true and exactly one scope.
3777
3993
 
3778
3994
  WHEN:
@@ -4083,248 +4299,36 @@ export function killProcessOnPort(port, platform = process.platform, runner = sp
4083
4299
  }
4084
4300
  return result;
4085
4301
  }
4086
- // ── ctx-insight: analytics dashboard ──────────────────────────────────────────
4302
+ // ── ctx-insight: open the hosted Insight dashboard ───────────────────────────
4303
+ // Insight pivoted from a locally-built dashboard to the hosted B2B product at
4304
+ // context-mode.com/insight (the landing page is the single source of truth).
4305
+ // The tool now simply opens that URL in the user default browser via the same
4306
+ // cross-platform helper (openBrowserSync) used elsewhere.
4307
+ const INSIGHT_URL = "https://context-mode.com/insight";
4087
4308
  server.registerTool("ctx_insight", {
4088
4309
  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
- }
4310
+ // #846: opens a hosted dashboard URL in the browser — an external side
4311
+ // effect (open world), not a read-only query; safe to repeat.
4312
+ annotations: {
4313
+ readOnlyHint: false,
4314
+ destructiveHint: false,
4315
+ idempotentHint: true,
4316
+ openWorldHint: true,
4317
+ },
4318
+ description: "Opens the context-mode Insight dashboard (https://context-mode.com/insight) in your " +
4319
+ "default browser a dashboard launcher for the hosted analytics layer, not a Q&A engine. " +
4320
+ "Insight surfaces per-engineer productive rate, retry waste, blocker detection, and " +
4321
+ "role-narrowed views for CTO, EM, IC, CISO, FinOps, and DevOps. " +
4322
+ "For natural-language queries over your indexed content, use ctx_search.",
4323
+ inputSchema: z.object({}),
4324
+ }, async () => {
4325
+ const open = openBrowserSync(INSIGHT_URL);
4326
+ const text = open.ok
4327
+ ? `Opening Insight in your browser: ${INSIGHT_URL}`
4328
+ : `Could not auto-open your browser (${open.reason}).\nOpen Insight manually: ${INSIGHT_URL}`;
4329
+ return trackResponse("ctx_insight", {
4330
+ content: [{ type: "text", text }],
4331
+ });
4328
4332
  });
4329
4333
  // ─────────────────────────────────────────────────────────
4330
4334
  // Server startup
@@ -4340,6 +4344,8 @@ async function main() {
4340
4344
  // Hardcoded /tmp on Unix to avoid TMPDIR mismatch (#347).
4341
4345
  const mcpSentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
4342
4346
  const mcpSentinel = join(mcpSentinelDir, `context-mode-mcp-ready-${process.pid}`);
4347
+ // #844: handle to the periodic sentinel refresh timer (started after connect).
4348
+ let sentinelRefresh;
4343
4349
  // Clean up own DB + backgrounded processes + preload script on shutdown
4344
4350
  const shutdown = () => {
4345
4351
  executor.cleanupBackgrounded();
@@ -4354,13 +4360,9 @@ async function main() {
4354
4360
  unlinkSync(mcpSentinel);
4355
4361
  }
4356
4362
  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
- }
4363
+ // #844: stop refreshing the sentinel mtime on shutdown.
4364
+ if (sentinelRefresh)
4365
+ clearInterval(sentinelRefresh);
4364
4366
  };
4365
4367
  const gracefulShutdown = async () => {
4366
4368
  // Final stats flush — bypass throttle so the last 0-500ms of
@@ -4387,6 +4389,18 @@ async function main() {
4387
4389
  writeFileSync(mcpSentinel, String(process.pid));
4388
4390
  }
4389
4391
  catch { /* best effort */ }
4392
+ // #844: refresh the sentinel mtime while the server is alive so readiness
4393
+ // probes from a foreign PID namespace (shared /tmp) can trust a recent
4394
+ // sentinel even when process.kill(pid, 0) cannot see this PID. The reader's
4395
+ // freshness window is 90s (hooks/core/mcp-ready.mjs); refresh at 30s (3x).
4396
+ // unref() so this timer never keeps the event loop alive on its own.
4397
+ sentinelRefresh = setInterval(() => {
4398
+ try {
4399
+ writeFileSync(mcpSentinel, String(process.pid));
4400
+ }
4401
+ catch { /* best effort */ }
4402
+ }, 30_000);
4403
+ sentinelRefresh.unref();
4390
4404
  // Detect platform adapter — stored for platform-aware session paths
4391
4405
  try {
4392
4406
  const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
@@ -4447,6 +4461,10 @@ async function main() {
4447
4461
  }
4448
4462
  }
4449
4463
  }
4464
+ // Runs after every registerTool() above, so the SDK's default tools/list handler
4465
+ // exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
4466
+ // function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
4467
+ installStrictClientSchemaCompat();
4450
4468
  if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
4451
4469
  main().catch((err) => {
4452
4470
  console.error("Fatal:", err);