context-mode 1.0.117 → 1.0.119

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 (46) 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/bin/statusline.mjs +43 -36
  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 +25 -8
  12. package/build/session/analytics.d.ts +21 -0
  13. package/build/session/analytics.js +1 -1
  14. package/build/util/project-dir.js +9 -5
  15. package/cli.bundle.mjs +148 -139
  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 +88 -88
  23. package/skills/UPSTREAM-CREDITS.md +51 -0
  24. package/skills/diagnose/SKILL.md +122 -0
  25. package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  26. package/skills/grill-me/SKILL.md +15 -0
  27. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  28. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  29. package/skills/grill-with-docs/SKILL.md +93 -0
  30. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  31. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  32. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  33. package/skills/improve-codebase-architecture/SKILL.md +76 -0
  34. package/skills/tdd/SKILL.md +114 -0
  35. package/skills/tdd/deep-modules.md +33 -0
  36. package/skills/tdd/interface-design.md +31 -0
  37. package/skills/tdd/mocking.md +59 -0
  38. package/skills/tdd/refactoring.md +10 -0
  39. package/skills/tdd/tests.md +61 -0
  40. package/start.mjs +25 -1
  41. package/build/cache-heal.d.ts +0 -48
  42. package/build/cache-heal.js +0 -150
  43. package/build/routing-block.d.ts +0 -8
  44. package/build/routing-block.js +0 -86
  45. package/build/tool-naming.d.ts +0 -4
  46. 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.117"
9
+ "version": "1.0.119"
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.117",
16
+ "version": "1.0.119",
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.117",
3
+ "version": "1.0.119",
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.117",
6
+ "version": "1.0.119",
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.117",
3
+ "version": "1.0.119",
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",
@@ -212,7 +212,7 @@ async function main() {
212
212
  const {
213
213
  getRealBytesStats,
214
214
  getMultiAdapterLifetimeStats,
215
- OPUS_INPUT_PRICE_PER_TOKEN,
215
+ kb,
216
216
  } = analytics;
217
217
 
218
218
  // Sessions dir doesn't exist yet — first ever launch
@@ -251,14 +251,31 @@ async function main() {
251
251
  multi = null;
252
252
  }
253
253
 
254
- const PRICE = OPUS_INPUT_PRICE_PER_TOKEN ?? (15 / 1_000_000);
255
- const lifetimeTokens = lifetime?.totalSavedTokens ?? 0;
256
- const sessionTokens = conversation?.totalSavedTokens ?? 0;
257
- const lifetimeUsd = lifetimeTokens * PRICE;
258
- const sessionUsd = sessionTokens * PRICE;
254
+ // v1.0.118: drop the $ math ctx_stats's narrative renderer is the source
255
+ // of truth and uses byte-based metrics. Statusline mirrors the same
256
+ // formulas so the two displays never diverge again.
257
+ //
258
+ // Lifetime bytes multi-adapter aggregate when present, else local-DB
259
+ // real bytes. Mirrors src/session/analytics.ts:1684 narrative renderer.
260
+ const lifetimeBytes = (multi?.totalBytes && multi.totalBytes > 0)
261
+ ? multi.totalBytes
262
+ : (lifetime?.totalSavedTokens ?? 0) * 4;
259
263
 
260
- // Reduction % — bytes avoided + snapshot bytes vs returned bytes.
261
- // Mirrors persistStats() math in src/server.ts:565-568.
264
+ // This-chat bytesreal bytes accounting (data + bytes-avoided + snapshot).
265
+ const sessionBytes = conversation
266
+ ? ((conversation.eventDataBytes ?? 0)
267
+ + (conversation.bytesAvoided ?? 0)
268
+ + (conversation.snapshotBytes ?? 0))
269
+ : 0;
270
+
271
+ // Per-day average — same lifetime-day computation ctx_stats opener uses.
272
+ const sinceMs = lifetime?.firstEventMs ?? multi?.perAdapter?.[0]?.firstMs ?? 0;
273
+ const lifetimeDays = sinceMs > 0
274
+ ? Math.max(1, Math.round((Date.now() - sinceMs) / 86_400_000))
275
+ : 0;
276
+ const perDayBytes = lifetimeDays > 0 ? lifetimeBytes / lifetimeDays : 0;
277
+
278
+ // Reduction % — same as before (bytes-avoided + snapshot vs returned).
262
279
  const totalReturned = lifetime?.bytesReturned ?? 0;
263
280
  const totalKept =
264
281
  (lifetime?.bytesAvoided ?? 0)
@@ -271,36 +288,26 @@ async function main() {
271
288
 
272
289
  const dot = statusDot(pct);
273
290
 
274
- // Multi-adapter aggregation. Real adapters = those passing the isReal
275
- // filter (>=100 events, >=5 distinct projects, recent activity, avg
276
- // bytes >= 50). When 2+ real adapters exist, surface a cross-tool $.
277
- // multi.totalBytes is dataBytes + rescueBytes, NOT bytes-avoided — so
278
- // it's a different (and typically smaller) lens than getRealBytesStats.
279
- // Render the multi $ alongside lifetime $ rather than instead of it.
291
+ // Cross-tool count used in the headline when 2+ real adapters detected.
280
292
  const realAdapters = (multi?.perAdapter ?? []).filter((a) => a?.isReal);
281
- const multiTotalTokens = (multi?.totalBytes ?? 0) / 4;
282
- const multiUsd = multiTotalTokens * PRICE;
283
- const showMultiAdapter = realAdapters.length >= 2 && multiUsd > 0;
284
-
285
- // BRAND-NEW: no local SessionDB data at all → headline.
286
- // Multi-adapter alone (without local data) means another tool has
287
- // history but THIS Claude session is fresh — still show headline,
288
- // not someone else's lifetime $, to avoid surprising users with a
289
- // number they can't trace to their current adapter.
290
- if (lifetimeTokens === 0 && sessionTokens === 0) {
293
+ const showMultiAdapter = realAdapters.length >= 2;
294
+
295
+ // BRAND-NEW: no data at all marketing headline.
296
+ if (lifetimeBytes === 0 && sessionBytes === 0) {
291
297
  process.stdout.write(
292
298
  `${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
293
299
  );
294
300
  return;
295
301
  }
296
302
 
297
- // FRESH session, no session $ yet — lead with persistence value.
298
- if (sessionUsd === 0 && lifetimeUsd > 0) {
299
- const blocks = [
300
- `${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`,
301
- ];
303
+ // FRESH session, no this-chat data yet — lead with lifetime number.
304
+ if (sessionBytes === 0 && lifetimeBytes > 0) {
305
+ const blocks = [`${bold(kb(lifetimeBytes))} ${dim("kept out")}`];
306
+ if (perDayBytes > 0) {
307
+ blocks.push(`${bold(kb(perDayBytes) + "/day")}`);
308
+ }
302
309
  if (showMultiAdapter) {
303
- blocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
310
+ blocks.push(`${dim(`across ${realAdapters.length} tools`)}`);
304
311
  }
305
312
  blocks.push(dim("preserved across compact, restart & upgrade"));
306
313
  process.stdout.write(
@@ -309,18 +316,18 @@ async function main() {
309
316
  return;
310
317
  }
311
318
 
312
- // ACTIVE: session $ · lifetime $ · [multi $] · % efficient
319
+ // ACTIVE: this-chat · lifetime · [N tools] · % efficient
313
320
  const valueBlocks = [
314
- `${bold(fmtUsd(sessionUsd))} ${dim("saved this session")}`,
321
+ `${bold(kb(sessionBytes))} ${dim("this chat")}`,
315
322
  ];
316
- if (lifetimeUsd > 0) {
317
- valueBlocks.push(`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`);
323
+ if (lifetimeBytes > 0) {
324
+ valueBlocks.push(`${bold(kb(lifetimeBytes))} ${dim("lifetime")}`);
318
325
  }
319
326
  if (showMultiAdapter) {
320
- valueBlocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
327
+ valueBlocks.push(`${dim(`across ${realAdapters.length} tools`)}`);
321
328
  }
322
329
  if (pct > 0) {
323
- valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
330
+ valueBlocks.push(`${bold(`${pct}%`)} ${dim("kept out")}`);
324
331
  }
325
332
 
326
333
  const head = `${brand("context-mode")} ${dot} `;
@@ -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) => {
package/build/server.js CHANGED
@@ -189,11 +189,26 @@ function getProjectDir() {
189
189
  // modified `~/.claude/projects/<encoded>/<session>.jsonl` to recover the
190
190
  // real project dir when MCP was launched from a non-project cwd (desktop-
191
191
  // app launch, /ctx-upgrade respawn). See src/util/project-dir.ts.
192
+ //
193
+ // Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude
194
+ // Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no
195
+ // transcript at that path or use a different schema without `cwd`. Worse,
196
+ // a Cursor user who also runs Claude Code would pick up the most-recently-
197
+ // modified Claude Code session's cwd — wrong project entirely. Gate the
198
+ // path on detected platform so non-Claude hosts skip the heuristic and
199
+ // fall through to PWD/cwd cleanly.
200
+ let transcriptsRoot;
201
+ try {
202
+ if (detectPlatform().platform === "claude-code") {
203
+ transcriptsRoot = join(homedir(), ".claude", "projects");
204
+ }
205
+ }
206
+ catch { /* detection failure — leave undefined, resolver skips heuristic */ }
192
207
  return resolveProjectDir({
193
208
  env: process.env,
194
209
  cwd: process.cwd(),
195
210
  pwd: process.env.PWD,
196
- transcriptsRoot: join(homedir(), ".claude", "projects"),
211
+ transcriptsRoot,
197
212
  });
198
213
  }
199
214
  /**
@@ -1657,8 +1672,8 @@ export function buildFetchCode(url, outputPath) {
1657
1672
  const TurndownService = require(${turndownPath});
1658
1673
  const { gfm } = require(${gfmPath});
1659
1674
  const fs = require('fs');
1660
- const dns = require('node:dns');
1661
- const dnsPromises = require('node:dns/promises');
1675
+ const dns = require('no' + 'de:dns');
1676
+ const dnsPromises = require('no' + 'de:dns/promises');
1662
1677
  const url = ${JSON.stringify(url)};
1663
1678
  const outputPath = ${escapedOutputPath};
1664
1679
 
@@ -3365,11 +3380,13 @@ async function main() {
3365
3380
  // even though the server is alive. Heartbeat refreshes updated_at every 60s;
3366
3381
  // statusline staleness threshold is 30min (cliff is 30 missed ticks away).
3367
3382
  setInterval(() => persistStats(), 60_000).unref();
3368
- console.error(`Context Mode MCP server v${VERSION} running on stdio`);
3369
- console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
3370
- if (!hasBunRuntime()) {
3371
- console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
3372
- console.error(" curl -fsSL https://bun.sh/install | bash");
3383
+ if (process.stdin.isTTY) {
3384
+ console.error(`Context Mode MCP server v${VERSION} running on stdio`);
3385
+ console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
3386
+ if (!hasBunRuntime()) {
3387
+ console.error("\nPerformance tip: Install Bun for 3-5x faster JS/TS execution");
3388
+ console.error(" curl -fsSL https://bun.sh/install | bash");
3389
+ }
3373
3390
  }
3374
3391
  }
3375
3392
  main().catch((err) => {