context-mode 1.0.118 → 1.0.120

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/build/adapters/openclaw/mcp-tools.js +10 -1
  6. package/build/adapters/pi/mcp-bridge.d.ts +28 -3
  7. package/build/adapters/pi/mcp-bridge.js +127 -14
  8. package/build/adapters/qwen-code/index.js +6 -2
  9. package/build/cli.js +93 -5
  10. package/build/opencode-plugin.js +2 -5
  11. package/build/server.js +104 -30
  12. package/build/session/purge.d.ts +27 -0
  13. package/build/session/purge.js +105 -3
  14. package/build/util/project-dir.js +9 -5
  15. package/cli.bundle.mjs +195 -164
  16. package/hooks/core/routing.mjs +13 -0
  17. package/openclaw.plugin.json +1 -1
  18. package/package.json +5 -6
  19. package/scripts/heal-better-sqlite3.mjs +53 -6
  20. package/scripts/heal-installed-plugins.mjs +104 -0
  21. package/scripts/postinstall.mjs +35 -1
  22. package/server.bundle.mjs +135 -113
  23. package/skills/UPSTREAM-CREDITS.md +51 -0
  24. package/skills/ctx-purge/SKILL.md +23 -9
  25. package/skills/diagnose/SKILL.md +122 -0
  26. package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  27. package/skills/grill-me/SKILL.md +15 -0
  28. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  29. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  30. package/skills/grill-with-docs/SKILL.md +93 -0
  31. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  32. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  33. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  34. package/skills/improve-codebase-architecture/SKILL.md +76 -0
  35. package/skills/tdd/SKILL.md +114 -0
  36. package/skills/tdd/deep-modules.md +33 -0
  37. package/skills/tdd/interface-design.md +31 -0
  38. package/skills/tdd/mocking.md +59 -0
  39. package/skills/tdd/refactoring.md +10 -0
  40. package/skills/tdd/tests.md +61 -0
  41. package/start.mjs +25 -1
  42. package/build/cache-heal.d.ts +0 -48
  43. package/build/cache-heal.js +0 -150
  44. package/build/routing-block.d.ts +0 -8
  45. package/build/routing-block.js +0 -86
  46. package/build/tool-naming.d.ts +0 -4
  47. package/build/tool-naming.js +0 -24
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.118"
9
+ "version": "1.0.120"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.118",
16
+ "version": "1.0.120",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.118",
3
+ "version": "1.0.120",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.118",
6
+ "version": "1.0.120",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.118",
3
+ "version": "1.0.120",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -175,7 +175,16 @@ export const OPENCLAW_TOOL_DEFS = [
175
175
  },
176
176
  {
177
177
  name: "ctx_purge",
178
- description: "Permanently delete all indexed content and reset session stats. Destructive.",
178
+ description: "DESTRUCTIVE — permanently delete indexed content. CANNOT be undone.\n\n" +
179
+ "MUST specify exactly ONE scope:\n" +
180
+ " • {confirm:true, sessionId:\"<uuid>\"} → wipes ONLY that session's events + chunks; preserves stats and other sessions\n" +
181
+ " • {confirm:true, scope:\"project\"} → wipes ENTIRE project: FTS5 KB + every session DB + stats file\n\n" +
182
+ "REFUSED:\n" +
183
+ " • confirm:false → 'purge cancelled'\n" +
184
+ " • sessionId AND scope:\"project\" together → 'ambiguous — pick one'\n" +
185
+ " • scope:\"session\" without sessionId → throws\n" +
186
+ " • bare {confirm:true} → DEPRECATED: maps to scope:\"project\" with stderr warning\n\n" +
187
+ "Use sessionId for clearing one conversation. Use scope:\"project\" only when the user explicitly resets everything. NEVER call with bare {confirm:true}.",
179
188
  parameters: {
180
189
  type: "object",
181
190
  properties: {},
@@ -20,6 +20,21 @@
20
20
  *
21
21
  * No external dependencies — pure node:child_process + JSON line frames.
22
22
  */
23
+ export interface ResolveDeps {
24
+ detect?: () => {
25
+ javascript: string | null;
26
+ };
27
+ which?: (cmd: string) => string | null;
28
+ execPath?: string;
29
+ }
30
+ /**
31
+ * Resolve a JS runtime safe to spawn the MCP server with.
32
+ *
33
+ * Returns `null` when no real runtime is reachable (caller must skip
34
+ * the bridge gracefully — see bootstrapMCPTools). Pi-named binaries are
35
+ * explicitly rejected at every step to prevent the #516 fork bomb.
36
+ */
37
+ export declare function resolveJsRuntimeForBridge(deps?: ResolveDeps): string | null;
23
38
  export interface MCPTool {
24
39
  name: string;
25
40
  description?: string;
@@ -47,13 +62,20 @@ export interface MCPCallResult {
47
62
  export declare class MCPStdioClient {
48
63
  private readonly serverScript;
49
64
  private readonly env;
65
+ private readonly runtimeOverride;
50
66
  private child;
51
67
  private requestId;
52
68
  private readonly pending;
53
69
  private buffer;
54
70
  private initialized;
55
71
  private exited;
56
- constructor(serverScript: string, env?: NodeJS.ProcessEnv);
72
+ /**
73
+ * Live env passed to the spawned child — exposed (read-only intent)
74
+ * so tests can pin the fork-bomb-prevention env counter (#516)
75
+ * without needing to attach a process-tree probe.
76
+ */
77
+ _spawnEnv: NodeJS.ProcessEnv | null;
78
+ constructor(serverScript: string, env?: NodeJS.ProcessEnv, runtimeOverride?: string | null);
57
79
  /** Spawn the MCP child. Idempotent. */
58
80
  start(): void;
59
81
  private onExit;
@@ -108,6 +130,9 @@ export interface BridgeHandle {
108
130
  * `execute()` callback — Pi's contract is "throw to mark the tool call
109
131
  * failed", which lets the LLM see and adapt.
110
132
  */
111
- export declare function bootstrapMCPTools(pi: PiLikeAPI, serverScript: string, options?: {
133
+ export interface BootstrapOptions {
112
134
  env?: NodeJS.ProcessEnv;
113
- }): Promise<BridgeHandle>;
135
+ /** DI hook for tests: override the runtime resolver entirely. */
136
+ _resolveJsRuntime?: () => string | null;
137
+ }
138
+ export declare function bootstrapMCPTools(pi: PiLikeAPI, serverScript: string, options?: BootstrapOptions): Promise<BridgeHandle>;
@@ -20,7 +20,77 @@
20
20
  *
21
21
  * No external dependencies — pure node:child_process + JSON line frames.
22
22
  */
23
- import { spawn } from "node:child_process";
23
+ import { spawn, execSync } from "node:child_process";
24
+ import { detectRuntimes } from "../../runtime.js";
25
+ // ── Fork-bomb prevention (#516) ──────────────────────────────────────
26
+ //
27
+ // Original bug: `spawn(process.execPath, [serverScript])` recursively
28
+ // re-executed the Pi binary on Bun-only systems where `process.execPath`
29
+ // IS pi itself. Each spawn re-loaded context-mode → spawned again →
30
+ // took the box down.
31
+ //
32
+ // Defence in depth:
33
+ // 1. resolveJsRuntimeForBridge() refuses pi-named binaries even when
34
+ // detectRuntimes() returns one, falling back to PATH-resolved
35
+ // node/bun.
36
+ // 2. Spawn passes CONTEXT_MODE_BRIDGE_DEPTH=1 in child env so any
37
+ // transitive bridge load can detect the recursion via env counter.
38
+ // 3. bootstrapMCPTools() aborts if CONTEXT_MODE_BRIDGE_DEPTH > 0 in
39
+ // its own env — catches recursion that bypasses the binary-name
40
+ // check (e.g. a `node` shim that re-execs Pi).
41
+ const PI_BINARY_BASENAME = /^pi(\.exe)?$/i;
42
+ const BRIDGE_DEPTH_ENV = "CONTEXT_MODE_BRIDGE_DEPTH";
43
+ const isWindows = process.platform === "win32";
44
+ function basename(p) {
45
+ const segs = p.split(/[\\/]/);
46
+ return segs[segs.length - 1] ?? "";
47
+ }
48
+ function whichOnPath(cmd) {
49
+ try {
50
+ const probe = isWindows ? `where ${cmd}` : `command -v ${cmd}`;
51
+ const out = execSync(probe, { encoding: "utf-8", stdio: "pipe" })
52
+ .trim()
53
+ .split(/\r?\n/)[0]
54
+ ?.trim();
55
+ return out && out.length > 0 ? out : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * Resolve a JS runtime safe to spawn the MCP server with.
63
+ *
64
+ * Returns `null` when no real runtime is reachable (caller must skip
65
+ * the bridge gracefully — see bootstrapMCPTools). Pi-named binaries are
66
+ * explicitly rejected at every step to prevent the #516 fork bomb.
67
+ */
68
+ export function resolveJsRuntimeForBridge(deps = {}) {
69
+ const detect = deps.detect ?? (() => detectRuntimes());
70
+ const which = deps.which ?? whichOnPath;
71
+ const execPath = deps.execPath ?? process.execPath;
72
+ const isPi = (p) => !!p && PI_BINARY_BASENAME.test(basename(p));
73
+ // 1. Prefer detectRuntimes().javascript when it is NOT pi.
74
+ let candidate = null;
75
+ try {
76
+ candidate = detect().javascript ?? null;
77
+ }
78
+ catch {
79
+ candidate = null;
80
+ }
81
+ if (candidate && !isPi(candidate))
82
+ return candidate;
83
+ // 2. Fall back to PATH-resolved node, then bun.
84
+ for (const cmd of ["node", "bun"]) {
85
+ const resolved = which(cmd);
86
+ if (resolved && !isPi(resolved))
87
+ return resolved;
88
+ }
89
+ // 3. Last resort: process.execPath only if it is not pi.
90
+ if (execPath && !isPi(execPath))
91
+ return execPath;
92
+ return null;
93
+ }
24
94
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
25
95
  // Tools/call may run shell commands or fetch URLs — wider window than
26
96
  // initialize/list, but still bounded so a hung server can't block Pi.
@@ -40,28 +110,49 @@ const DEFAULT_CALL_TIMEOUT_MS = 120_000;
40
110
  export class MCPStdioClient {
41
111
  serverScript;
42
112
  env;
113
+ runtimeOverride;
43
114
  child = null;
44
115
  requestId = 0;
45
116
  pending = new Map();
46
117
  buffer = "";
47
118
  initialized = false;
48
119
  exited = false;
49
- constructor(serverScript, env = process.env) {
120
+ /**
121
+ * Live env passed to the spawned child — exposed (read-only intent)
122
+ * so tests can pin the fork-bomb-prevention env counter (#516)
123
+ * without needing to attach a process-tree probe.
124
+ */
125
+ _spawnEnv = null;
126
+ constructor(serverScript, env = process.env, runtimeOverride = null) {
50
127
  this.serverScript = serverScript;
51
128
  this.env = env;
129
+ this.runtimeOverride = runtimeOverride;
52
130
  }
53
131
  /** Spawn the MCP child. Idempotent. */
54
132
  start() {
55
133
  if (this.child)
56
134
  return;
57
135
  this.exited = false;
58
- this.child = spawn(process.execPath, [this.serverScript], {
136
+ // Pick a JS runtime that is NOT the host process (#516). When Pi
137
+ // is the host binary, process.execPath would re-exec Pi and fork
138
+ // bomb the box. resolveJsRuntimeForBridge prefers bun/node and
139
+ // explicitly rejects pi-named binaries.
140
+ const runtime = this.runtimeOverride ?? resolveJsRuntimeForBridge() ?? process.execPath;
141
+ // Increment the depth counter so any transitive bridge load inside
142
+ // the child can short-circuit before spawning yet another server.
143
+ const depth = Number.parseInt(this.env[BRIDGE_DEPTH_ENV] ?? "0", 10);
144
+ const childEnv = {
145
+ ...this.env,
146
+ [BRIDGE_DEPTH_ENV]: String(Number.isFinite(depth) ? depth + 1 : 1),
147
+ };
148
+ this._spawnEnv = childEnv;
149
+ this.child = spawn(runtime, [this.serverScript], {
59
150
  // Pipe stderr (#472 round-3): swallowing it via "ignore" hides
60
151
  // server crash diagnostics — the user only saw "ctx_* tools will
61
152
  // not be callable" with no clue WHY. Forwarding to process.stderr
62
153
  // with a [mcp-bridge] prefix lets ops grep across session noise.
63
154
  stdio: ["pipe", "pipe", "pipe"],
64
- env: this.env,
155
+ env: childEnv,
65
156
  });
66
157
  this.child.stdout?.on("data", (chunk) => this.onData(chunk));
67
158
  this.child.stderr?.on("data", (chunk) => {
@@ -197,18 +288,40 @@ export class MCPStdioClient {
197
288
  }
198
289
  }
199
290
  /**
200
- * Spawn the MCP server and register each of its tools with Pi via
201
- * `pi.registerTool()`. The same JSON Schema returned by `tools/list` is
202
- * passed straight through as `parameters` — TypeBox emits JSON-Schema
203
- * compatible objects, so any Pi runtime that validates JSON Schema
204
- * accepts this shape (verified against pi 0.73.x).
205
- *
206
- * Errors during MCP `tools/call` are translated to a `throw` from the
207
- * `execute()` callback — Pi's contract is "throw to mark the tool call
208
- * failed", which lets the LLM see and adapt.
291
+ * Empty-but-valid handle returned when bootstrap is skipped (#516).
292
+ * Keeps the shutdown contract intact so callers do not need null checks.
209
293
  */
294
+ function skippedBridge() {
295
+ return {
296
+ tools: [],
297
+ shutdown: () => {
298
+ /* nothing to shut down */
299
+ },
300
+ client: new MCPStdioClient("/dev/null"),
301
+ };
302
+ }
210
303
  export async function bootstrapMCPTools(pi, serverScript, options = {}) {
211
- const client = new MCPStdioClient(serverScript, options.env);
304
+ const env = options.env ?? process.env;
305
+ // Recursion guard (#516): if an ancestor bridge already incremented
306
+ // the depth counter, refuse to spawn another child — even if the
307
+ // binary-name check would let us through. Catches `node` shims that
308
+ // re-exec Pi and other host swaps that bypass basename detection.
309
+ const depth = Number.parseInt(env[BRIDGE_DEPTH_ENV] ?? "0", 10);
310
+ if (Number.isFinite(depth) && depth > 0) {
311
+ process.stderr.write(`[context-mode] WARNING: skipping MCP bridge — ${BRIDGE_DEPTH_ENV}=${depth} ` +
312
+ `indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable.\n`);
313
+ return skippedBridge();
314
+ }
315
+ // Runtime guard (#516): when neither node nor bun is on PATH and the
316
+ // host process is pi, there is no safe binary to spawn. Log once and
317
+ // return an empty handle — the rest of the extension keeps working.
318
+ const runtime = (options._resolveJsRuntime ?? resolveJsRuntimeForBridge)();
319
+ if (runtime === null) {
320
+ process.stderr.write(`[context-mode] WARNING: no JS runtime found (need node or bun on PATH). ` +
321
+ `Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable.\n`);
322
+ return skippedBridge();
323
+ }
324
+ const client = new MCPStdioClient(serverScript, env, runtime);
212
325
  client.start();
213
326
  await client.initialize();
214
327
  const tools = await client.listTools();
@@ -12,7 +12,7 @@
12
12
  * - MCP clientInfo: qwen-cli-mcp-client-* (pattern)
13
13
  * - 12 hook events (superset of Claude's 5, but context-mode uses the shared 5)
14
14
  */
15
- import { readFileSync, existsSync, } from "node:fs";
15
+ import { readFileSync, writeFileSync, existsSync, } from "node:fs";
16
16
  import { resolve, join } from "node:path";
17
17
  import { homedir } from "node:os";
18
18
  import { ClaudeCodeBaseAdapter } from "../claude-code-base.js";
@@ -110,7 +110,11 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter {
110
110
  }
111
111
  }
112
112
  writeSettings(settings) {
113
- const { writeFileSync } = require("node:fs");
113
+ // Issue #511: use top-level static import (line 18) — never inline
114
+ // `require("node:fs")` in ESM-bundled sources. esbuild rewrites them to
115
+ // a `__require` shim that throws `Dynamic require of "node:fs" is not
116
+ // supported` under Node ESM/Bun (this adapter is pulled into both
117
+ // server.bundle.mjs and cli.bundle.mjs via adapter detect).
114
118
  writeFileSync(this.getSettingsPath(), JSON.stringify(settings, null, 2));
115
119
  }
116
120
  // ── Diagnostics (doctor) ───────────────────────────────
package/build/cli.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import * as p from "@clack/prompts";
15
15
  import color from "picocolors";
16
- import { execFileSync, execFile as nodeExecFile } from "node:child_process";
16
+ import { execFileSync, execSync, execFile as nodeExecFile } from "node:child_process";
17
17
  import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
18
18
  import { request as httpsRequest } from "node:https";
19
19
  import { resolve, dirname, join } from "node:path";
@@ -22,6 +22,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
23
23
  import { getHookScriptPaths } from "./util/hook-config.js";
24
24
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
25
+ // v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
26
+ // mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
27
+ // @ts-expect-error — JS module, no TS declarations
28
+ import { healPluginJsonMcpServers } from "../scripts/heal-installed-plugins.mjs";
25
29
  // Private 16-LOC copy of browserOpenArgv. Canonical version lives in src/server.ts;
26
30
  // duplicated here so the cli bundle does not pull server.ts top-level boot side effects.
27
31
  // Keep in sync — pure data, no I/O.
@@ -163,11 +167,16 @@ export function npmExecFile(args, opts = {}) {
163
167
  });
164
168
  }
165
169
  export function npmExec(command, opts = {}) {
166
- const { execSync: es } = require("node:child_process");
167
- es(isWin ? command.replace(/^npm /, "npm.cmd ") : command, {
170
+ // Issue #511: use top-level static import (line 17) — never inline `require("node:...")`
171
+ // in ESM-bundled sources. esbuild rewrites them to a `__require` shim that throws
172
+ // `Dynamic require of "node:child_process" is not supported` under Node ESM/Bun.
173
+ // Cast preserves the prior `require()`-as-`any` shape; `shell: true` is the documented
174
+ // Node behavior even though @types/node typed `shell` as `string | undefined`.
175
+ const execOpts = {
168
176
  ...opts,
169
177
  ...(isWin ? { shell: true } : {}),
170
- });
178
+ };
179
+ execSync(isWin ? command.replace(/^npm /, "npm.cmd ") : command, execOpts);
171
180
  }
172
181
  export function openInBrowser(url, platform = process.platform, runner = nodeExecFile) {
173
182
  const opts = { stdio: "ignore" };
@@ -398,7 +407,28 @@ async function doctor() {
398
407
  }
399
408
  catch (err) {
400
409
  const message = err instanceof Error ? err.message : String(err);
401
- if (message.includes("Cannot find module") || message.includes("MODULE_NOT_FOUND")) {
410
+ // Distinguish package-missing from binding-missing (#514). Both
411
+ // throw with similar shapes from `import("better-sqlite3")` but the
412
+ // recovery commands differ:
413
+ // - package-missing → `npm install better-sqlite3 --no-optional`
414
+ // (npm@7+ silently drops optionalDependencies on engine
415
+ // mismatch, e.g. Node 26 vs better-sqlite3@12.x — we name the
416
+ // package explicitly + flip the optional filter to recover)
417
+ // - binding-missing → `npm rebuild better-sqlite3` (#408 flow,
418
+ // Windows + missing prebuild-install shim)
419
+ const pluginRootForDoctor = getPluginRoot();
420
+ const bsqPackageDir = resolve(pluginRootForDoctor, "node_modules", "better-sqlite3");
421
+ const packageMissing = !existsSync(bsqPackageDir);
422
+ if (packageMissing) {
423
+ criticalFails++;
424
+ p.log.error(color.red("FTS5 / better-sqlite3: FAIL") +
425
+ color.dim(" — package-missing") +
426
+ color.dim(`\n Path: ${bsqPackageDir}` +
427
+ "\n Root cause: npm silently skipped better-sqlite3 because the package's `engines` field excluded the running Node (issue #514, e.g. Node 26 vs better-sqlite3@12.x)." +
428
+ `\n Try (primary): cd "${pluginRootForDoctor}" && npm install better-sqlite3 --no-optional` +
429
+ "\n Try (fallback): /context-mode:ctx-upgrade"));
430
+ }
431
+ else if (message.includes("Cannot find module") || message.includes("MODULE_NOT_FOUND")) {
402
432
  p.log.warn(color.yellow("FTS5 / better-sqlite3: SKIP") + color.dim(" — module not available (restart session after upgrade)"));
403
433
  }
404
434
  else {
@@ -732,6 +762,30 @@ async function upgrade() {
732
762
  const message = err instanceof Error ? err.message : String(err);
733
763
  throw new Error(`Registry consistency check failed: ${message}`);
734
764
  }
765
+ // v1.0.119 — Issue #523 — Layer 5 heal: assert .claude-plugin/plugin.json's
766
+ // mcpServers["context-mode"].args[0] is the literal ${CLAUDE_PLUGIN_ROOT}/start.mjs
767
+ // placeholder, not a tmpdir-prefixed absolute path. cli.ts already wrote .mcp.json
768
+ // with the placeholder (#411 fix), but plugin.json was never touched here — and
769
+ // start.mjs's normalize-hooks (Windows + #378) can bake in absolute paths that
770
+ // become stale across upgrades. We call the shared heal twice: first call cleans
771
+ // any drift; second call MUST return healed:[] or we throw. Single source of
772
+ // truth shared with start.mjs HEAL block + postinstall.
773
+ try {
774
+ const pluginCacheRoot = resolve(resolveClaudeConfigDir(), "plugins", "cache");
775
+ const pluginKey = "context-mode@context-mode";
776
+ const firstPass = healPluginJsonMcpServers({ pluginRoot, pluginCacheRoot, pluginKey });
777
+ if (firstPass && firstPass.error) {
778
+ throw new Error(firstPass.error);
779
+ }
780
+ const secondPass = healPluginJsonMcpServers({ pluginRoot, pluginCacheRoot, pluginKey });
781
+ if (secondPass && Array.isArray(secondPass.healed) && secondPass.healed.length > 0) {
782
+ throw new Error(`Plugin manifest drift: plugin.json mcpServers.args still poisoned after first heal pass (healed=${secondPass.healed.join(",")})`);
783
+ }
784
+ }
785
+ catch (err) {
786
+ const message = err instanceof Error ? err.message : String(err);
787
+ throw new Error(`plugin.json drift check failed: ${message}`);
788
+ }
735
789
  // v1.0.114 hotfix — marketplace post-pull assertion: clone (if
736
790
  // present) MUST be on newVersion. Mert's case showed marketplace
737
791
  // stuck at v1.0.89 — the sync block above swallowed that silently.
@@ -784,6 +838,40 @@ async function upgrade() {
784
838
  ` — ${message}` +
785
839
  color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
786
840
  }
841
+ // ── Post-install binding verifier (#514) ────────────────────
842
+ // npm@7+ silently drops optionalDependencies whose engines
843
+ // field excludes the running Node (e.g. Node 26 vs
844
+ // better-sqlite3@12.x). On a silent skip the package directory
845
+ // is missing entirely and ensure-deps cannot recover. Fail
846
+ // loud so /ctx-upgrade no longer reports success while the
847
+ // knowledge base is unusable.
848
+ const bsqBindingPath = resolve(pluginRoot, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
849
+ if (!existsSync(bsqBindingPath)) {
850
+ // Try one last self-heal — explicit, named install bypasses
851
+ // the optionalDependency silent-skip path even if the dep
852
+ // somehow regressed back to optional.
853
+ try {
854
+ const healPath = resolve(pluginRoot, "scripts", "heal-better-sqlite3.mjs");
855
+ if (existsSync(healPath)) {
856
+ const mod = await import(`${pathToFileURL(healPath).href}?upgrade=${Date.now()}`);
857
+ if (typeof mod.healBetterSqlite3Binding === "function") {
858
+ mod.healBetterSqlite3Binding(pluginRoot);
859
+ }
860
+ }
861
+ }
862
+ catch { /* best effort — verifier below will fail loud */ }
863
+ }
864
+ if (!existsSync(bsqBindingPath)) {
865
+ // Mark the upgrade process for a non-zero exit at completion.
866
+ // Stays in scope only for the rest of upgrade(); the actual
867
+ // exit-code wiring sits below the top-level changes report.
868
+ process.exitCode = 1;
869
+ p.log.error(color.red("better-sqlite3 native binding: MISSING") +
870
+ color.dim(`\n Path: ${bsqBindingPath}`) +
871
+ color.dim("\n Cause: npm silently skipped the package (Node engine mismatch, issue #514)") +
872
+ color.dim(`\n Try (primary): cd "${pluginRoot}" && npm install better-sqlite3 --no-optional`) +
873
+ color.dim("\n Try (fallback): /context-mode:ctx-doctor"));
874
+ }
787
875
  }
788
876
  // Update global npm
789
877
  s.start("Updating npm global package");
@@ -179,7 +179,7 @@ async function createContextModePlugin(ctx) {
179
179
  const toolInput = output.args ?? {};
180
180
  let decision;
181
181
  try {
182
- decision = routing.routePreToolUse(toolName, toolInput, projectDir, platform);
182
+ decision = routing.routePreToolUse(toolName, toolInput, projectDir, getPlatform());
183
183
  }
184
184
  catch {
185
185
  return; // Routing failure → allow passthrough
@@ -194,10 +194,7 @@ async function createContextModePlugin(ctx) {
194
194
  // Mutate output.args — OpenCode reads the mutated output object
195
195
  Object.assign(output.args, decision.updatedInput);
196
196
  }
197
- if (decision.action === "context" && decision.additionalContext) {
198
- // Mutate output.args — OpenCode reads the mutated output object
199
- output.args.additionalContext = decision.additionalContext;
200
- }
197
+ // "context" action no-op (OpenCode doesn't support context injection)
201
198
  },
202
199
  // ── PostToolUse: Session event capture ──────────────
203
200
  "tool.execute.after": async (input, output) => {