context-mode 1.0.136 → 1.0.138

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 +2 -2
  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 +9 -24
  7. package/build/adapters/codex/index.js +24 -3
  8. package/build/adapters/jetbrains-copilot/hooks.d.ts +11 -3
  9. package/build/adapters/jetbrains-copilot/hooks.js +11 -7
  10. package/build/adapters/opencode/index.d.ts +1 -0
  11. package/build/adapters/opencode/index.js +25 -0
  12. package/build/adapters/opencode/plugin.d.ts +22 -0
  13. package/build/adapters/opencode/plugin.js +52 -0
  14. package/build/adapters/pi/extension.js +20 -4
  15. package/build/adapters/pi/mcp-bridge.d.ts +2 -1
  16. package/build/adapters/pi/mcp-bridge.js +49 -3
  17. package/build/adapters/vscode-copilot/hooks.d.ts +27 -3
  18. package/build/adapters/vscode-copilot/hooks.js +27 -12
  19. package/build/cli.js +199 -32
  20. package/build/lifecycle.d.ts +2 -51
  21. package/build/lifecycle.js +3 -67
  22. package/build/openclaw-plugin.d.ts +130 -0
  23. package/build/openclaw-plugin.js +626 -0
  24. package/build/opencode-plugin.d.ts +122 -0
  25. package/build/opencode-plugin.js +372 -0
  26. package/build/pi-extension.d.ts +14 -0
  27. package/build/pi-extension.js +451 -0
  28. package/build/server.d.ts +19 -0
  29. package/build/server.js +145 -59
  30. package/build/session/db.d.ts +6 -0
  31. package/build/session/db.js +17 -3
  32. package/build/util/db-lock.d.ts +65 -0
  33. package/build/util/db-lock.js +166 -0
  34. package/build/util/sibling-mcp.d.ts +0 -40
  35. package/build/util/sibling-mcp.js +11 -116
  36. package/cli.bundle.mjs +181 -166
  37. package/configs/kilo/kilo.json +0 -11
  38. package/configs/opencode/opencode.json +0 -11
  39. package/hooks/normalize-hooks.mjs +101 -19
  40. package/hooks/session-db.bundle.mjs +3 -3
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/heal-installed-plugins.mjs +115 -1
  44. package/scripts/postinstall.mjs +16 -18
  45. package/server.bundle.mjs +112 -110
  46. package/start.mjs +11 -14
@@ -1,16 +1,5 @@
1
1
  {
2
2
  "$schema": "https://app.kilo.ai/config.json",
3
- "mcp": {
4
- "context-mode": {
5
- "type": "local",
6
- "command": [
7
- "context-mode"
8
- ],
9
- "environment": {
10
- "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
11
- }
12
- }
13
- },
14
3
  "plugin": [
15
4
  "context-mode"
16
5
  ]
@@ -1,16 +1,5 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
- "mcp": {
4
- "context-mode": {
5
- "type": "local",
6
- "command": [
7
- "context-mode"
8
- ],
9
- "environment": {
10
- "CONTEXT_MODE_IDLE_TIMEOUT_MS": "900000"
11
- }
12
- }
13
- },
14
3
  "plugin": [
15
4
  "context-mode"
16
5
  ]
@@ -18,18 +18,70 @@ import { resolve } from "node:path";
18
18
 
19
19
  const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
20
20
 
21
+ // #604: matches a cache path segment `context-mode/context-mode/<version>`.
22
+ // Capture group is the X.Y.Z version. Used to detect command paths frozen on a
23
+ // previous-version dir that Claude Code's native plugin manager has since
24
+ // cleaned up. `/g` so a single content blob with multiple stale references is
25
+ // fully covered. Forward-slash only — callers convert beforehand.
26
+ const CACHE_VERSION_RE =
27
+ /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
28
+
21
29
  /** Convert any path string to forward slashes (MSYS-safe). */
22
30
  function fwd(p) {
23
31
  return String(p).replace(/\\/g, "/");
24
32
  }
25
33
 
26
34
  /**
27
- * Pure detection: does this content contain an unresolved CLAUDE_PLUGIN_ROOT
28
- * placeholder that should be normalized?
35
+ * Extract the X.Y.Z version segment from a pluginRoot under the context-mode
36
+ * cache layout. Returns null when running from npm-global, a dev checkout, or
37
+ * any layout that does not match the `<…>/context-mode/context-mode/<v>(/…)?`
38
+ * pattern — callers must treat null as "no stale-path check is possible".
39
+ */
40
+ function pluginRootVersion(pluginRoot) {
41
+ if (!pluginRoot) return null;
42
+ const m =
43
+ /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(
44
+ fwd(pluginRoot),
45
+ );
46
+ return m ? m[1] : null;
47
+ }
48
+
49
+ /**
50
+ * Does `content` reference any context-mode cache version segment that differs
51
+ * from `currentVersion`? Detects the #604 ratchet: already-normalized hooks.json
52
+ * / plugin.json carrying a previous version's absolute paths forward into a
53
+ * newer version's cache directory after Claude Code's auto-update.
54
+ */
55
+ function hasStaleCacheVersionSegment(content, currentVersion) {
56
+ if (!currentVersion || !content || typeof content !== "string") return false;
57
+ const safe = fwd(content);
58
+ CACHE_VERSION_RE.lastIndex = 0;
59
+ let m;
60
+ while ((m = CACHE_VERSION_RE.exec(safe)) !== null) {
61
+ if (m[1] !== currentVersion) return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /**
67
+ * Pure detection: does this content need to be (re-)normalized?
68
+ *
69
+ * Two triggers:
70
+ * 1. Fresh content still containing the `${CLAUDE_PLUGIN_ROOT}` placeholder
71
+ * — the original #378 first-boot path on any host.
72
+ * 2. (#604) Already-resolved content whose absolute paths point at a
73
+ * different version of the context-mode cache than the current
74
+ * `pluginRoot`. Breaks the ratchet that previously froze stale paths
75
+ * after Claude Code's native plugin manager copied a previous version's
76
+ * hooks.json forward.
77
+ *
78
+ * `pluginRoot` is optional for backwards compatibility with single-arg
79
+ * callers; without it, only the placeholder check runs.
29
80
  */
30
- export function needsHookNormalization(content) {
81
+ export function needsHookNormalization(content, pluginRoot) {
31
82
  if (!content || typeof content !== "string") return false;
32
- return content.includes(PLACEHOLDER);
83
+ if (content.includes(PLACEHOLDER)) return true;
84
+ return hasStaleCacheVersionSegment(content, pluginRootVersion(pluginRoot));
33
85
  }
34
86
 
35
87
  /**
@@ -41,10 +93,11 @@ export function needsHookNormalization(content) {
41
93
  * Idempotent — leaves already-normalized content unchanged.
42
94
  */
43
95
  export function normalizeHooksJson(content, nodePath, pluginRoot) {
44
- if (!needsHookNormalization(content)) return content;
96
+ if (!needsHookNormalization(content, pluginRoot)) return content;
45
97
 
46
98
  const safeNode = fwd(nodePath);
47
99
  const safeRoot = fwd(pluginRoot);
100
+ const currentVersion = pluginRootVersion(pluginRoot);
48
101
 
49
102
  let parsed;
50
103
  try {
@@ -65,12 +118,30 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
65
118
  if (!Array.isArray(inner)) continue;
66
119
  for (const h of inner) {
67
120
  if (typeof h?.command !== "string") continue;
68
- if (!h.command.includes(PLACEHOLDER)) continue;
69
- // Replace placeholder with absolute root (forward-slash).
70
- let next = h.command.replaceAll(PLACEHOLDER, safeRoot);
71
- // Replace bare `node ` prefix with quoted execPath. Match both
72
- // `node ` and `node\t` at start, with optional surrounding whitespace.
73
- next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
121
+
122
+ const hasPlaceholder = h.command.includes(PLACEHOLDER);
123
+ // #604: also rewrite when the command holds a stale absolute path under
124
+ // a previous-version cache dir (Claude Code's auto-update ratchet).
125
+ const hasStale = hasStaleCacheVersionSegment(h.command, currentVersion);
126
+ if (!hasPlaceholder && !hasStale) continue;
127
+
128
+ let next = h.command;
129
+ if (hasPlaceholder) {
130
+ // Replace placeholder with absolute root (forward-slash).
131
+ next = next.replaceAll(PLACEHOLDER, safeRoot);
132
+ // Replace bare `node ` prefix with quoted execPath. Match both
133
+ // `node ` and `node\t` at start, with optional surrounding whitespace.
134
+ next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
135
+ }
136
+ if (hasStale) {
137
+ // Re-point every `context-mode/context-mode/<old-version>/…` segment
138
+ // to the current pluginRoot's version. Operates on the forward-slash
139
+ // form so MSYS-mangled paths heal as well.
140
+ next = fwd(next).replace(
141
+ CACHE_VERSION_RE,
142
+ `context-mode/context-mode/${currentVersion}`,
143
+ );
144
+ }
74
145
  h.command = next;
75
146
  mutated = true;
76
147
  }
@@ -92,10 +163,11 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
92
163
  * Idempotent.
93
164
  */
94
165
  export function normalizePluginJson(content, nodePath, pluginRoot) {
95
- if (!needsHookNormalization(content)) return content;
166
+ if (!needsHookNormalization(content, pluginRoot)) return content;
96
167
 
97
168
  const safeNode = fwd(nodePath);
98
169
  const safeRoot = fwd(pluginRoot);
170
+ const currentVersion = pluginRootVersion(pluginRoot);
99
171
 
100
172
  let parsed;
101
173
  try {
@@ -114,11 +186,21 @@ export function normalizePluginJson(content, nodePath, pluginRoot) {
114
186
 
115
187
  if (Array.isArray(srv.args)) {
116
188
  const before = srv.args;
117
- const after = before.map((a) =>
118
- typeof a === "string" && a.includes(PLACEHOLDER)
119
- ? a.replaceAll(PLACEHOLDER, safeRoot)
120
- : a,
121
- );
189
+ const after = before.map((a) => {
190
+ if (typeof a !== "string") return a;
191
+ let next = a;
192
+ if (next.includes(PLACEHOLDER)) {
193
+ next = next.replaceAll(PLACEHOLDER, safeRoot);
194
+ }
195
+ // #604: same auto-update ratchet hits plugin.json args (see #523).
196
+ if (hasStaleCacheVersionSegment(next, currentVersion)) {
197
+ next = fwd(next).replace(
198
+ CACHE_VERSION_RE,
199
+ `context-mode/context-mode/${currentVersion}`,
200
+ );
201
+ }
202
+ return next;
203
+ });
122
204
  if (after.some((v, i) => v !== before[i])) {
123
205
  srv.args = after;
124
206
  mutated = true;
@@ -158,7 +240,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
158
240
  const hooksPath = resolve(pluginRoot, "hooks", "hooks.json");
159
241
  if (existsSync(hooksPath)) {
160
242
  const original = readFileSync(hooksPath, "utf-8");
161
- if (needsHookNormalization(original)) {
243
+ if (needsHookNormalization(original, pluginRoot)) {
162
244
  const next = normalizeHooksJson(original, nodePath, pluginRoot);
163
245
  if (next !== original) {
164
246
  writeFileSync(hooksPath, next, "utf-8");
@@ -174,7 +256,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
174
256
  const pluginPath = resolve(pluginRoot, ".claude-plugin", "plugin.json");
175
257
  if (existsSync(pluginPath)) {
176
258
  const original = readFileSync(pluginPath, "utf-8");
177
- if (needsHookNormalization(original)) {
259
+ if (needsHookNormalization(original, pluginRoot)) {
178
260
  const next = normalizePluginJson(original, nodePath, pluginRoot);
179
261
  if (next !== original) {
180
262
  writeFileSync(pluginPath, next, "utf-8");
@@ -1,4 +1,4 @@
1
- import{createRequire as Y}from"node:module";import{existsSync as G,unlinkSync as x,renameSync as q}from"node:fs";import{tmpdir as z}from"node:os";import{join as K}from"node:path";var N=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){let e="",s=null;for(let o=0;o<t.length;o++){let a=t[o];if(s)e+=a,a===s&&(s=null);else if(a==="'"||a==='"')e+=a,s=a;else if(a===";"){let c=e.trim();c&&this.#t.prepare(c).run(),e=""}else e+=a}let r=e.trim();return r&&this.#t.prepare(r).run(),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>{let r=e.get(...s);return r===null?void 0:r},all:(...s)=>e.all(...s),iterate:(...s)=>e.iterate(...s)}}transaction(t){return this.#t.transaction(t)}close(){this.#t.close()}},A=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){return this.#t.exec(t),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>e.get(...s),all:(...s)=>e.all(...s),iterate:(...s)=>typeof e.iterate=="function"?e.iterate(...s):e.all(...s)[Symbol.iterator]()}}transaction(t){return(...e)=>{this.#t.exec("BEGIN");try{let s=t(...e);return this.#t.exec("COMMIT"),s}catch(s){throw this.#t.exec("ROLLBACK"),s}}}close(){this.#t.close()}},l=null;function V(n){let t=null;try{return t=new n(":memory:"),t.exec("CREATE VIRTUAL TABLE __fts5_probe USING fts5(x)"),!0}catch{return!1}finally{try{t?.close()}catch{}}}function Q(n,t){let e=t!==void 0?t:globalThis.Bun;if(typeof e<"u"&&e!==null)return!0;let s=n??process.versions,[r,o]=(s.node??"0.0.0").split("."),a=Number(r),c=Number(o);return!Number.isFinite(a)||!Number.isFinite(c)?!1:a>22||a===22&&c>=5}function J(){if(!l){let n=Y(import.meta.url);if(globalThis.Bun){let t=n(["bun","sqlite"].join(":")).Database;l=function(s,r){let o=new t(s,{readonly:r?.readonly,create:!0}),a=new N(o);return r?.timeout&&a.pragma(`busy_timeout = ${r.timeout}`),a}}else if(Q()){let t=null;try{({DatabaseSync:t}=n(["node","sqlite"].join(":")))}catch{t=null}t&&V(t)?l=function(s,r){let o=new t(s,{readOnly:r?.readonly??!1});return new A(o)}:l=n("better-sqlite3")}else l=n("better-sqlite3")}return l}function I(n){n.pragma("journal_mode = WAL"),n.pragma("synchronous = NORMAL");try{n.pragma("mmap_size = 268435456")}catch{}}function U(n){if(!G(n))for(let t of["-wal","-shm"])try{x(n+t)}catch{}}function Z(n){for(let t of["","-wal","-shm"])try{x(n+t)}catch{}}function D(n){try{n.pragma("wal_checkpoint(TRUNCATE)")}catch{}try{n.close()}catch{}}function M(n="context-mode"){return K(z(),`${n}-${process.pid}.db`)}function tt(n,t=[100,500,2e3]){let e;for(let s=0;s<=t.length;s++)try{return n()}catch(r){let o=r instanceof Error?r.message:String(r);if(!o.includes("SQLITE_BUSY")&&!o.includes("database is locked"))throw r;if(e=r instanceof Error?r:new Error(o),s<t.length){let a=t[s],c=Date.now();for(;Date.now()-c<a;);}}throw new Error(`SQLITE_BUSY: database is locked after ${t.length} retries. Original error: ${e?.message}`)}function et(n){return n.includes("SQLITE_CORRUPT")||n.includes("SQLITE_NOTADB")||n.includes("database disk image is malformed")||n.includes("file is not a database")}function st(n){let t=Date.now();for(let e of["","-wal","-shm"])try{q(n+e,`${n}${e}.corrupt-${t}`)}catch{}}var _=Symbol.for("__context_mode_live_dbs_v3__"),v=(()=>{let n=globalThis;return n[_]||(n[_]=new Set,process.on("exit",()=>{for(let t of n[_])D(t);n[_].clear()})),n[_]})(),y=class{#t;#e;constructor(t){let e=J();this.#t=t,U(t);let s;try{s=new e(t,{timeout:3e4}),I(s)}catch(r){let o=r instanceof Error?r.message:String(r);if(et(o)){st(t),U(t);try{s=new e(t,{timeout:3e4}),I(s)}catch(a){throw new Error(`Failed to create fresh DB after renaming corrupt file: ${a instanceof Error?a.message:String(a)}`)}}else throw r}this.#e=s,v.add(this.#e),this.initSchema(),this.prepareStatements()}get db(){return this.#e}get dbPath(){return this.#t}close(){v.delete(this.#e),D(this.#e)}withRetry(t){return tt(t)}cleanup(){v.delete(this.#e),D(this.#e),Z(this.#t)}};import{createHash as p}from"node:crypto";import{execFileSync as nt}from"node:child_process";import{existsSync as f,realpathSync as rt,renameSync as C}from"node:fs";import{join as b}from"node:path";var E;function g(n){let t=n.replace(/\\/g,"/");return/^\/+$/.test(t)?"/":/^[A-Za-z]:\/+$/.test(t)?`${t.slice(0,2)}/`:t.replace(/\/+$/,"")}function F(n){let t=n;try{t=rt.native(n)}catch{}let e=g(t);return process.platform==="win32"||process.platform==="darwin"?e.toLowerCase():e}function j(n,t){return nt("git",["-C",n,...t],{encoding:"utf-8",timeout:2e3,stdio:["ignore","pipe","ignore"]}).trim()}function it(n){let t=j(n,["rev-parse","--show-toplevel"]);return t.length>0?g(t):null}function ot(n){let t=j(n,["worktree","list","--porcelain"]).split(/\r?\n/).find(e=>e.startsWith("worktree "))?.replace("worktree ","")?.trim();return t?g(t):null}function at(n=process.cwd()){let t=process.env.CONTEXT_MODE_SESSION_SUFFIX;if(E&&E.projectDir===n&&E.envSuffix===t)return E.suffix;let e="";if(t!==void 0)e=t?`__${t}`:"";else try{let s=it(n),r=ot(n);if(s&&r){let o=F(s),a=F(r);o!==a&&(e=`__${p("sha256").update(o).digest("hex").slice(0,8)}`)}}catch{}return E={projectDir:n,envSuffix:t,suffix:e},e}function ht(){E=void 0}function X(n){return p("sha256").update(g(n)).digest("hex").slice(0,16)}function W(n){let t=g(n),e=process.platform==="darwin"||process.platform==="win32"?t.toLowerCase():t;return p("sha256").update(e).digest("hex").slice(0,16)}function ft(n){let{projectDir:t,contentDir:e}=n,s=W(t),r=b(e,`${s}.db`);if(f(r))return r;let o=X(t);if(o===s)return r;let a=b(e,`${o}.db`);if(f(a))try{C(a,r);for(let c of["-wal","-shm"])try{C(a+c,r+c)}catch{}}catch{}return r}function bt(n){return ct({...n,ext:".db"})}function ct(n){let{projectDir:t,sessionsDir:e,ext:s}=n,r=n.suffix??at(t),o=W(t),a=b(e,`${o}${r}${s}`);if(f(a))return a;let c=X(t);if(c===o)return a;let d=b(e,`${c}${r}${s}`);if(f(d))try{C(d,a)}catch{}return a}var B=1e3,P=5;function h(n){let t=Number(n);return!Number.isFinite(t)||t<=0?0:Math.floor(t)}var i={insertEvent:"insertEvent",getEvents:"getEvents",getEventsByType:"getEventsByType",getEventsByPriority:"getEventsByPriority",getEventsByTypeAndPriority:"getEventsByTypeAndPriority",getEventCount:"getEventCount",getLatestAttributedProject:"getLatestAttributedProject",checkDuplicate:"checkDuplicate",evictLowestPriority:"evictLowestPriority",updateMetaLastEvent:"updateMetaLastEvent",ensureSession:"ensureSession",getSessionStats:"getSessionStats",incrementCompactCount:"incrementCompactCount",upsertResume:"upsertResume",getResume:"getResume",markResumeConsumed:"markResumeConsumed",claimLatestUnconsumedResume:"claimLatestUnconsumedResume",deleteEvents:"deleteEvents",deleteMeta:"deleteMeta",deleteResume:"deleteResume",getOldSessions:"getOldSessions",searchEvents:"searchEvents",incrementToolCall:"incrementToolCall",getToolCallTotals:"getToolCallTotals",getToolCallByTool:"getToolCallByTool",getEventBytesSummary:"getEventBytesSummary"},k=class extends y{constructor(t){super(t?.dbPath??M("session"))}stmt(t){return this.stmts.get(t)}initSchema(){try{let e=this.db.pragma("table_xinfo(session_events)").find(s=>s.name==="data_hash");e&&e.hidden!==0&&this.db.exec("DROP TABLE session_events")}catch{}this.db.exec(`
1
+ import{createRequire as Y}from"node:module";import{existsSync as G,unlinkSync as x,renameSync as q}from"node:fs";import{tmpdir as z}from"node:os";import{join as K}from"node:path";var N=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){let e="",s=null;for(let o=0;o<t.length;o++){let a=t[o];if(s)e+=a,a===s&&(s=null);else if(a==="'"||a==='"')e+=a,s=a;else if(a===";"){let c=e.trim();c&&this.#t.prepare(c).run(),e=""}else e+=a}let r=e.trim();return r&&this.#t.prepare(r).run(),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>{let r=e.get(...s);return r===null?void 0:r},all:(...s)=>e.all(...s),iterate:(...s)=>e.iterate(...s)}}transaction(t){return this.#t.transaction(t)}close(){this.#t.close()}},D=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){return this.#t.exec(t),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>e.get(...s),all:(...s)=>e.all(...s),iterate:(...s)=>typeof e.iterate=="function"?e.iterate(...s):e.all(...s)[Symbol.iterator]()}}transaction(t){return(...e)=>{this.#t.exec("BEGIN");try{let s=t(...e);return this.#t.exec("COMMIT"),s}catch(s){throw this.#t.exec("ROLLBACK"),s}}}close(){this.#t.close()}},l=null;function V(n){let t=null;try{return t=new n(":memory:"),t.exec("CREATE VIRTUAL TABLE __fts5_probe USING fts5(x)"),!0}catch{return!1}finally{try{t?.close()}catch{}}}function Q(n,t){let e=t!==void 0?t:globalThis.Bun;if(typeof e<"u"&&e!==null)return!0;let s=n??process.versions,[r,o]=(s.node??"0.0.0").split("."),a=Number(r),c=Number(o);return!Number.isFinite(a)||!Number.isFinite(c)?!1:a>22||a===22&&c>=5}function J(){if(!l){let n=Y(import.meta.url);if(globalThis.Bun){let t=n(["bun","sqlite"].join(":")).Database;l=function(s,r){let o=new t(s,{readonly:r?.readonly,create:!0}),a=new N(o);return r?.timeout&&a.pragma(`busy_timeout = ${r.timeout}`),a}}else if(Q()){let t=null;try{({DatabaseSync:t}=n(["node","sqlite"].join(":")))}catch{t=null}t&&V(t)?l=function(s,r){let o=new t(s,{readOnly:r?.readonly??!1});return new D(o)}:l=n("better-sqlite3")}else l=n("better-sqlite3")}return l}function U(n){n.pragma("journal_mode = WAL"),n.pragma("synchronous = NORMAL");try{n.pragma("mmap_size = 268435456")}catch{}}function I(n){if(!G(n))for(let t of["-wal","-shm"])try{x(n+t)}catch{}}function Z(n){for(let t of["","-wal","-shm"])try{x(n+t)}catch{}}function A(n){try{n.pragma("wal_checkpoint(TRUNCATE)")}catch{}try{n.close()}catch{}}function M(n="context-mode"){return K(z(),`${n}-${process.pid}.db`)}function tt(n,t=[100,500,2e3]){let e;for(let s=0;s<=t.length;s++)try{return n()}catch(r){let o=r instanceof Error?r.message:String(r);if(!o.includes("SQLITE_BUSY")&&!o.includes("database is locked"))throw r;if(e=r instanceof Error?r:new Error(o),s<t.length){let a=t[s],c=Date.now();for(;Date.now()-c<a;);}}throw new Error(`SQLITE_BUSY: database is locked after ${t.length} retries. Original error: ${e?.message}`)}function et(n){return n.includes("SQLITE_CORRUPT")||n.includes("SQLITE_NOTADB")||n.includes("database disk image is malformed")||n.includes("file is not a database")}function st(n){let t=Date.now();for(let e of["","-wal","-shm"])try{q(n+e,`${n}${e}.corrupt-${t}`)}catch{}}var _=Symbol.for("__context_mode_live_dbs_v3__"),v=(()=>{let n=globalThis;return n[_]||(n[_]=new Set,process.on("exit",()=>{for(let t of n[_])A(t);n[_].clear()})),n[_]})(),y=class{#t;#e;constructor(t){let e=J();this.#t=t,I(t);let s;try{s=new e(t,{timeout:3e4}),U(s)}catch(r){let o=r instanceof Error?r.message:String(r);if(et(o)){st(t),I(t);try{s=new e(t,{timeout:3e4}),U(s)}catch(a){throw new Error(`Failed to create fresh DB after renaming corrupt file: ${a instanceof Error?a.message:String(a)}`)}}else throw r}this.#e=s,v.add(this.#e),this.initSchema(),this.prepareStatements()}get db(){return this.#e}get dbPath(){return this.#t}close(){v.delete(this.#e),A(this.#e)}withRetry(t){return tt(t)}cleanup(){v.delete(this.#e),A(this.#e),Z(this.#t)}};import{createHash as p}from"node:crypto";import{execFileSync as nt}from"node:child_process";import{existsSync as f,realpathSync as rt,renameSync as C}from"node:fs";import{join as b}from"node:path";var E;function g(n){let t=n.replace(/\\/g,"/");return/^\/+$/.test(t)?"/":/^[A-Za-z]:\/+$/.test(t)?`${t.slice(0,2)}/`:t.replace(/\/+$/,"")}function F(n){let t=n;try{t=rt.native(n)}catch{}let e=g(t);return process.platform==="win32"||process.platform==="darwin"?e.toLowerCase():e}function k(n,t){return nt("git",["-C",n,...t],{encoding:"utf-8",timeout:2e3,stdio:["ignore","pipe","ignore"]}).trim()}function it(n){let t=k(n,["rev-parse","--show-toplevel"]);return t.length>0?g(t):null}function ot(n){let t=k(n,["worktree","list","--porcelain"]).split(/\r?\n/).find(e=>e.startsWith("worktree "))?.replace("worktree ","")?.trim();return t?g(t):null}function at(n=process.cwd()){let t=process.env.CONTEXT_MODE_SESSION_SUFFIX;if(E&&E.projectDir===n&&E.envSuffix===t)return E.suffix;let e="";if(t!==void 0)e=t?`__${t}`:"";else try{let s=it(n),r=ot(n);if(s&&r){let o=F(s),a=F(r);o!==a&&(e=`__${p("sha256").update(o).digest("hex").slice(0,8)}`)}}catch{}return E={projectDir:n,envSuffix:t,suffix:e},e}function ht(){E=void 0}function X(n){return p("sha256").update(g(n)).digest("hex").slice(0,16)}function W(n){let t=g(n),e=process.platform==="darwin"||process.platform==="win32"?t.toLowerCase():t;return p("sha256").update(e).digest("hex").slice(0,16)}function ft(n){let{projectDir:t,contentDir:e}=n,s=W(t),r=b(e,`${s}.db`);if(f(r))return r;let o=X(t);if(o===s)return r;let a=b(e,`${o}.db`);if(f(a))try{C(a,r);for(let c of["-wal","-shm"])try{C(a+c,r+c)}catch{}}catch{}return r}function bt(n){return ct({...n,ext:".db"})}function ct(n){let{projectDir:t,sessionsDir:e,ext:s}=n,r=n.suffix??at(t),o=W(t),a=b(e,`${o}${r}${s}`);if(f(a))return a;let c=X(t);if(c===o)return a;let d=b(e,`${c}${r}${s}`);if(f(d))try{C(d,a)}catch{}return a}var B=1e3,P=5;function h(n){let t=Number(n);return!Number.isFinite(t)||t<=0?0:Math.floor(t)}var i={insertEvent:"insertEvent",getEvents:"getEvents",getEventsByType:"getEventsByType",getEventsByPriority:"getEventsByPriority",getEventsByTypeAndPriority:"getEventsByTypeAndPriority",getEventCount:"getEventCount",getLatestAttributedProject:"getLatestAttributedProject",checkDuplicate:"checkDuplicate",evictLowestPriority:"evictLowestPriority",updateMetaLastEvent:"updateMetaLastEvent",ensureSession:"ensureSession",getSessionStats:"getSessionStats",incrementCompactCount:"incrementCompactCount",upsertResume:"upsertResume",getResume:"getResume",markResumeConsumed:"markResumeConsumed",claimLatestUnconsumedResume:"claimLatestUnconsumedResume",deleteEvents:"deleteEvents",deleteMeta:"deleteMeta",deleteResume:"deleteResume",getOldSessions:"getOldSessions",searchEvents:"searchEvents",incrementToolCall:"incrementToolCall",getToolCallTotals:"getToolCallTotals",getToolCallByTool:"getToolCallByTool",getEventBytesSummary:"getEventBytesSummary"},j=class extends y{constructor(t){super(t?.dbPath??M("session"))}stmt(t){return this.stmts.get(t)}initSchema(){try{let e=this.db.pragma("table_xinfo(session_events)").find(s=>s.name==="data_hash");e&&e.hidden!==0&&this.db.exec("DROP TABLE session_events")}catch{}this.db.exec(`
2
2
  CREATE TABLE IF NOT EXISTS session_events (
3
3
  id INTEGER PRIMARY KEY AUTOINCREMENT,
4
4
  session_id TEXT NOT NULL,
@@ -102,7 +102,7 @@ import{createRequire as Y}from"node:module";import{existsSync as G,unlinkSync as
102
102
  )
103
103
  RETURNING session_id, snapshot`),t(i.deleteEvents,"DELETE FROM session_events WHERE session_id = ?"),t(i.deleteMeta,"DELETE FROM session_meta WHERE session_id = ?"),t(i.deleteResume,"DELETE FROM session_resume WHERE session_id = ?"),t(i.searchEvents,`SELECT id, session_id, category, type, data, created_at
104
104
  FROM session_events
105
- WHERE project_dir = ?
105
+ WHERE (project_dir = ? OR project_dir = '')
106
106
  AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
107
107
  AND (? IS NULL OR category = ?)
108
108
  ORDER BY id ASC
@@ -116,4 +116,4 @@ import{createRequire as Y}from"node:module";import{existsSync as G,unlinkSync as
116
116
  FROM tool_calls WHERE session_id = ?`),t(i.getToolCallByTool,`SELECT tool, calls, bytes_returned
117
117
  FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`),t(i.getEventBytesSummary,`SELECT COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
118
118
  COALESCE(SUM(bytes_returned), 0) AS bytes_returned
119
- FROM session_events WHERE session_id = ?`)}insertEvent(t,e,s="PostToolUse",r,o){let a=p("sha256").update(e.data).digest("hex").slice(0,16).toUpperCase(),c=String(r?.projectDir??e.project_dir??"").trim(),d=String(r?.source??e.attribution_source??"unknown"),u=Number(r?.confidence??e.attribution_confidence??0),T=Number.isFinite(u)?Math.max(0,Math.min(1,u)):0,m=h(o?.bytesAvoided),L=h(o?.bytesReturned),S=this.db.transaction(()=>{if(this.stmt(i.checkDuplicate).get(t,P,e.type,a))return;this.stmt(i.getEventCount).get(t).cnt>=B&&this.stmt(i.evictLowestPriority).run(t),this.stmt(i.insertEvent).run(t,e.type,e.category,e.priority,e.data,c,d,T,m,L,s,a),this.stmt(i.updateMetaLastEvent).run(t)});this.withRetry(()=>S())}bulkInsertEvents(t,e,s="PostToolUse",r,o){if(!e||e.length===0)return;if(e.length===1){this.insertEvent(t,e[0],s,r?.[0],o?.[0]);return}let a=e.map((d,u)=>{let T=p("sha256").update(d.data).digest("hex").slice(0,16).toUpperCase(),m=r?.[u],L=String(m?.projectDir??d.project_dir??"").trim(),S=String(m?.source??d.attribution_source??"unknown"),R=Number(m?.confidence??d.attribution_confidence??0),O=Number.isFinite(R)?Math.max(0,Math.min(1,R)):0,w=o?.[u],H=h(w?.bytesAvoided),$=h(w?.bytesReturned);return{event:d,dataHash:T,projectDir:L,attributionSource:S,attributionConfidence:O,bytesAvoided:H,bytesReturned:$}}),c=this.db.transaction(()=>{let d=this.stmt(i.getEventCount).get(t).cnt;for(let u of a)this.stmt(i.checkDuplicate).get(t,P,u.event.type,u.dataHash)||(d>=B?this.stmt(i.evictLowestPriority).run(t):d++,this.stmt(i.insertEvent).run(t,u.event.type,u.event.category,u.event.priority,u.event.data,u.projectDir,u.attributionSource,u.attributionConfidence,u.bytesAvoided,u.bytesReturned,s,u.dataHash));this.stmt(i.updateMetaLastEvent).run(t)});this.withRetry(()=>c())}getEvents(t,e){let s=e?.limit??1e3,r=e?.type,o=e?.minPriority;return r&&o!==void 0?this.stmt(i.getEventsByTypeAndPriority).all(t,r,o,s):r?this.stmt(i.getEventsByType).all(t,r,s):o!==void 0?this.stmt(i.getEventsByPriority).all(t,o,s):this.stmt(i.getEvents).all(t,s)}getEventCount(t){return this.stmt(i.getEventCount).get(t).cnt}getEventBytesSummary(t){let e=this.stmt(i.getEventBytesSummary).get(t);return{bytesAvoided:Number(e?.bytes_avoided??0),bytesReturned:Number(e?.bytes_returned??0)}}getLatestAttributedProjectDir(t){return this.stmt(i.getLatestAttributedProject).get(t)?.project_dir||null}searchEvents(t,e,s,r){try{let o=t.replace(/[%_]/g,c=>"\\"+c),a=r??null;return this.stmt(i.searchEvents).all(s,o,o,a,a,e)}catch{return[]}}ensureSession(t,e){this.stmt(i.ensureSession).run(t,e)}getSessionStats(t){return this.stmt(i.getSessionStats).get(t)??null}incrementCompactCount(t){this.stmt(i.incrementCompactCount).run(t)}upsertResume(t,e,s){this.stmt(i.upsertResume).run(t,e,s??0)}getResume(t){return this.stmt(i.getResume).get(t)??null}markResumeConsumed(t){this.stmt(i.markResumeConsumed).run(t)}claimLatestUnconsumedResume(t){let e=this.stmt(i.claimLatestUnconsumedResume).get(t);return e?{sessionId:e.session_id,snapshot:e.snapshot}:null}getLatestSessionId(){try{return this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get()?.session_id??null}catch{return null}}incrementToolCall(t,e,s=0){let r=Number.isFinite(s)&&s>0?Math.round(s):0;try{this.stmt(i.incrementToolCall).run(t,e,r)}catch{}}getToolCallStats(t){try{let e=this.stmt(i.getToolCallTotals).get(t),s=this.stmt(i.getToolCallByTool).all(t),r={};for(let o of s)r[o.tool]={calls:o.calls,bytesReturned:o.bytes_returned};return{totalCalls:e?.calls??0,totalBytesReturned:e?.bytes_returned??0,byTool:r}}catch{return{totalCalls:0,totalBytesReturned:0,byTool:{}}}}deleteSession(t){this.db.transaction(()=>{this.stmt(i.deleteEvents).run(t),this.stmt(i.deleteResume).run(t),this.stmt(i.deleteMeta).run(t)})()}cleanupOldSessions(t=7){let e=`-${t}`,s=this.stmt(i.getOldSessions).all(e);for(let{session_id:r}of s)this.deleteSession(r);return s.length}};export{k as SessionDB,ht as _resetWorktreeSuffixCacheForTests,at as getWorktreeSuffix,W as hashProjectDirCanonical,X as hashProjectDirLegacy,g as normalizeWorktreePath,ft as resolveContentStorePath,bt as resolveSessionDbPath,ct as resolveSessionPath};
119
+ FROM session_events WHERE session_id = ?`)}insertEvent(t,e,s="PostToolUse",r,o){let a=p("sha256").update(e.data).digest("hex").slice(0,16).toUpperCase(),c=String(r?.projectDir??e.project_dir??this._getSessionProjectDir(t)).trim(),d=String(r?.source??e.attribution_source??"unknown"),u=Number(r?.confidence??e.attribution_confidence??0),T=Number.isFinite(u)?Math.max(0,Math.min(1,u)):0,m=h(o?.bytesAvoided),S=h(o?.bytesReturned),L=this.db.transaction(()=>{if(this.stmt(i.checkDuplicate).get(t,P,e.type,a))return;this.stmt(i.getEventCount).get(t).cnt>=B&&this.stmt(i.evictLowestPriority).run(t),this.stmt(i.insertEvent).run(t,e.type,e.category,e.priority,e.data,c,d,T,m,S,s,a),this.stmt(i.updateMetaLastEvent).run(t)});this.withRetry(()=>L())}bulkInsertEvents(t,e,s="PostToolUse",r,o){if(!e||e.length===0)return;if(e.length===1){this.insertEvent(t,e[0],s,r?.[0],o?.[0]);return}let a=e.map((d,u)=>{let T=p("sha256").update(d.data).digest("hex").slice(0,16).toUpperCase(),m=r?.[u],S=String(m?.projectDir??d.project_dir??this._getSessionProjectDir(t)??"").trim(),L=String(m?.source??d.attribution_source??"unknown"),R=Number(m?.confidence??d.attribution_confidence??0),O=Number.isFinite(R)?Math.max(0,Math.min(1,R)):0,w=o?.[u],H=h(w?.bytesAvoided),$=h(w?.bytesReturned);return{event:d,dataHash:T,projectDir:S,attributionSource:L,attributionConfidence:O,bytesAvoided:H,bytesReturned:$}}),c=this.db.transaction(()=>{let d=this.stmt(i.getEventCount).get(t).cnt;for(let u of a)this.stmt(i.checkDuplicate).get(t,P,u.event.type,u.dataHash)||(d>=B?this.stmt(i.evictLowestPriority).run(t):d++,this.stmt(i.insertEvent).run(t,u.event.type,u.event.category,u.event.priority,u.event.data,u.projectDir,u.attributionSource,u.attributionConfidence,u.bytesAvoided,u.bytesReturned,s,u.dataHash));this.stmt(i.updateMetaLastEvent).run(t)});this.withRetry(()=>c())}getEvents(t,e){let s=e?.limit??1e3,r=e?.type,o=e?.minPriority;return r&&o!==void 0?this.stmt(i.getEventsByTypeAndPriority).all(t,r,o,s):r?this.stmt(i.getEventsByType).all(t,r,s):o!==void 0?this.stmt(i.getEventsByPriority).all(t,o,s):this.stmt(i.getEvents).all(t,s)}getEventCount(t){return this.stmt(i.getEventCount).get(t).cnt}getEventBytesSummary(t){let e=this.stmt(i.getEventBytesSummary).get(t);return{bytesAvoided:Number(e?.bytes_avoided??0),bytesReturned:Number(e?.bytes_returned??0)}}getLatestAttributedProjectDir(t){return this.stmt(i.getLatestAttributedProject).get(t)?.project_dir||null}_getSessionProjectDir(t){try{return this.db.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?").get(t)?.project_dir||""}catch{return""}}searchEvents(t,e,s,r){try{let o=t.replace(/[%_]/g,c=>"\\"+c),a=r??null;return this.stmt(i.searchEvents).all(s,o,o,a,a,e)}catch{return[]}}ensureSession(t,e){this.stmt(i.ensureSession).run(t,e)}getSessionStats(t){return this.stmt(i.getSessionStats).get(t)??null}incrementCompactCount(t){this.stmt(i.incrementCompactCount).run(t)}upsertResume(t,e,s){this.stmt(i.upsertResume).run(t,e,s??0)}getResume(t){return this.stmt(i.getResume).get(t)??null}markResumeConsumed(t){this.stmt(i.markResumeConsumed).run(t)}claimLatestUnconsumedResume(t){let e=this.stmt(i.claimLatestUnconsumedResume).get(t);return e?{sessionId:e.session_id,snapshot:e.snapshot}:null}getLatestSessionId(){try{return this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get()?.session_id??null}catch{return null}}incrementToolCall(t,e,s=0){let r=Number.isFinite(s)&&s>0?Math.round(s):0;try{this.stmt(i.incrementToolCall).run(t,e,r)}catch{}}getToolCallStats(t){try{let e=this.stmt(i.getToolCallTotals).get(t),s=this.stmt(i.getToolCallByTool).all(t),r={};for(let o of s)r[o.tool]={calls:o.calls,bytesReturned:o.bytes_returned};return{totalCalls:e?.calls??0,totalBytesReturned:e?.bytes_returned??0,byTool:r}}catch{return{totalCalls:0,totalBytesReturned:0,byTool:{}}}}deleteSession(t){this.db.transaction(()=>{this.stmt(i.deleteEvents).run(t),this.stmt(i.deleteResume).run(t),this.stmt(i.deleteMeta).run(t)})()}cleanupOldSessions(t=7){let e=`-${t}`,s=this.stmt(i.getOldSessions).all(e);for(let{session_id:r}of s)this.deleteSession(r);return s.length}};export{j as SessionDB,ht as _resetWorktreeSuffixCacheForTests,at as getWorktreeSuffix,W as hashProjectDirCanonical,X as hashProjectDirLegacy,g as normalizeWorktreePath,ft as resolveContentStorePath,bt as resolveSessionDbPath,ct as resolveSessionPath};
@@ -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.136",
6
+ "version": "1.0.138",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.136",
3
+ "version": "1.0.138",
4
4
  "type": "module",
5
5
  "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",
@@ -17,7 +17,7 @@
17
17
  * @see https://github.com/anthropics/claude-code/issues/46915
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, statSync } from "node:fs";
21
21
  import { resolve, sep } from "node:path";
22
22
 
23
23
  /**
@@ -461,3 +461,117 @@ export function healClaudeJsonMcpArgs({ dotClaudeJsonPath, pluginCacheParent, ne
461
461
 
462
462
  return { healed: ["claude-json-mcp-args"] };
463
463
  }
464
+
465
+ // ─────────────────────────────────────────────────────────────────────────
466
+ // Issue #609 — sweepStaleMcpJson: remove cache-baked `.mcp.json` files.
467
+ //
468
+ // Background (per ISSUE-609-VERDICT, ISSUE-604-VERDICT):
469
+ // cli.ts upgrade() wrote `.mcp.json` into every per-version plugin-cache
470
+ // dir starting with #411. PR #531 (commit 9261377) removed `.mcp.json`
471
+ // from `package.json files[]` so the npm tarball no longer ships it,
472
+ // but the cli-side write persisted. Every `/ctx-upgrade` re-baked a
473
+ // per-version copy. When Claude Code's native plugin manager auto-update
474
+ // later copies a previous version's `.mcp.json` forward into a fresh
475
+ // version dir, the stale start.mjs absolute path goes with it →
476
+ // MODULE_NOT_FOUND on every MCP boot, and `ctx-doctor` stays green
477
+ // because nothing validates that path against current pluginRoot.
478
+ //
479
+ // The architectural fix is to STOP writing `.mcp.json` from the cache layer
480
+ // entirely. `.claude-plugin/plugin.json.mcpServers` is the canonical source
481
+ // (refs/platforms/claude-code/src/utils/plugins/mcpPluginIntegration.ts:131-212
482
+ // — Claude Code reads it first). This sweep removes any pre-existing
483
+ // `.mcp.json` from every per-version cache dir so the previous-version-
484
+ // carry vector cannot replay across upgrades.
485
+ //
486
+ // Single source of truth shared by:
487
+ // - `start.mjs` HEAL 5c (every MCP boot)
488
+ // - `scripts/postinstall.mjs` (every `npm install -g context-mode`)
489
+ // - `src/cli.ts` upgrade() (post-bump)
490
+ //
491
+ // Safety contracts:
492
+ // - Path-traversal guard: refuses to walk outside `pluginCacheRoot`.
493
+ // - Best-effort: NEVER throws; missing files / unreadable dirs are
494
+ // skipped silently and reported in the result.
495
+ // - Scope: deletes ONLY files named exactly `.mcp.json`; never touches
496
+ // sibling files in the same dir.
497
+ // ─────────────────────────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * @typedef {Object} SweepResult
501
+ * @property {string[]} removed - absolute paths of removed `.mcp.json` files
502
+ * @property {string} [skipped] - reason if no work performed (e.g. "no-cache-root")
503
+ */
504
+
505
+ /**
506
+ * Remove every `.mcp.json` from per-version directories under
507
+ * `<pluginCacheRoot>/<owner>/<plugin>/<X.Y.Z>/`.
508
+ *
509
+ * @param {{ pluginCacheRoot: string, pluginKey: string }} opts
510
+ * pluginKey is the "<owner>@<plugin>" form (e.g. "context-mode@context-mode").
511
+ * @returns {SweepResult}
512
+ */
513
+ export function sweepStaleMcpJson({ pluginCacheRoot, pluginKey }) {
514
+ /** @type {string[]} */
515
+ const removed = [];
516
+
517
+ if (!pluginCacheRoot || !pluginKey) {
518
+ return { removed, skipped: "missing-args" };
519
+ }
520
+
521
+ const resolvedCacheRoot = resolve(pluginCacheRoot);
522
+ if (!existsSync(resolvedCacheRoot)) {
523
+ return { removed, skipped: "no-cache-root" };
524
+ }
525
+
526
+ // pluginKey shape: "<owner>@<plugin>"
527
+ const [ownerSegment, pluginSegment] = pluginKey.split("@");
528
+ if (!ownerSegment || !pluginSegment) {
529
+ return { removed, skipped: "bad-plugin-key" };
530
+ }
531
+
532
+ // Path-traversal guard: refuse to walk outside the declared cache root,
533
+ // even if pluginKey contains `..` segments. Per Mert's standing Windows
534
+ // safety rule, resolve normalizes both `/` and `\` so the guard fires
535
+ // on either separator.
536
+ const ownerDir = resolve(resolvedCacheRoot, ownerSegment, pluginSegment);
537
+ const cacheRootWithSep = resolvedCacheRoot + sep;
538
+ if (!ownerDir.startsWith(cacheRootWithSep)) {
539
+ return { removed, skipped: "outside-cache-root" };
540
+ }
541
+
542
+ if (!existsSync(ownerDir)) {
543
+ return { removed, skipped: "no-plugin-dir" };
544
+ }
545
+
546
+ /** @type {string[]} */
547
+ let versionEntries = [];
548
+ try {
549
+ versionEntries = readdirSync(ownerDir);
550
+ } catch {
551
+ return { removed, skipped: "readdir-failed" };
552
+ }
553
+
554
+ for (const versionEntry of versionEntries) {
555
+ const versionDir = resolve(ownerDir, versionEntry);
556
+ // Per-version guard: only enter directories whose resolved path stays
557
+ // under the owner dir. Belt-and-braces against weird FS entries.
558
+ if (!versionDir.startsWith(ownerDir + sep)) continue;
559
+ try {
560
+ const stat = statSync(versionDir);
561
+ if (!stat.isDirectory()) continue;
562
+ } catch {
563
+ continue;
564
+ }
565
+ const mcpJsonPath = resolve(versionDir, ".mcp.json");
566
+ if (!existsSync(mcpJsonPath)) continue;
567
+ try {
568
+ unlinkSync(mcpJsonPath);
569
+ removed.push(mcpJsonPath);
570
+ } catch {
571
+ // best-effort: file may have been removed by a concurrent process
572
+ // between existsSync and unlinkSync. Silent skip.
573
+ }
574
+ }
575
+
576
+ return { removed };
577
+ }
@@ -14,7 +14,7 @@ import { dirname, resolve, join, sep } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { homedir } from "node:os";
16
16
  import { healBetterSqlite3Binding } from "./heal-better-sqlite3.mjs";
17
- import { healInstalledPlugins, healSettingsEnabledPlugins, healPluginJsonMcpServers, healMcpJsonArgs } from "./heal-installed-plugins.mjs";
17
+ import { healInstalledPlugins, healSettingsEnabledPlugins, healPluginJsonMcpServers, sweepStaleMcpJson } from "./heal-installed-plugins.mjs";
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const pkgRoot = resolve(__dirname, "..");
@@ -181,26 +181,24 @@ if (isGlobalInstall()) {
181
181
  healedAny = true;
182
182
  }
183
183
  } catch { /* per-entry best effort */ }
184
- // v1.0.122 — Issue #531 — Layer 6: asymmetric-heal sibling for
185
- // .mcp.json. The #253/aea633c regression shipped a bare `./start.mjs`
186
- // arg that Claude Code resolves against session CWD (not pluginRoot)
187
- // → MODULE_NOT_FOUND on every ctx_* tool. When MCP is dead, the
188
- // only escape hatch is `npm install -g context-mode` whose
189
- // postinstall MUST run this heal too.
190
- try {
191
- const r = healMcpJsonArgs({
192
- pluginRoot: installPath,
193
- pluginCacheRoot: cacheRoot,
194
- pluginKey: "context-mode@context-mode",
195
- });
196
- if (r && Array.isArray(r.healed) && r.healed.length > 0) {
197
- healedAny = true;
198
- }
199
- } catch { /* per-entry best effort */ }
200
184
  }
201
185
  }
186
+ // Issue #609 — Layer 6: sweep stale `.mcp.json` files from every
187
+ // per-version cache dir. Replaces the previous per-entry healMcpJsonArgs
188
+ // loop (v1.0.122) — `.mcp.json` is no longer written from cli.ts so
189
+ // remaining files in the cache are stale carry-forwards that block
190
+ // future auto-updates from working cleanly. Single sweep per install.
191
+ try {
192
+ const sweepResult = sweepStaleMcpJson({
193
+ pluginCacheRoot: cacheRoot,
194
+ pluginKey: "context-mode@context-mode",
195
+ });
196
+ if (sweepResult && Array.isArray(sweepResult.removed) && sweepResult.removed.length > 0) {
197
+ process.stderr.write(`context-mode: swept ${sweepResult.removed.length} stale .mcp.json file(s) (Issue #609)\n`);
198
+ }
199
+ } catch { /* never block install */ }
202
200
  if (healedAny) {
203
- process.stderr.write("context-mode: healed mcpServers args (Issues #523 + #531)\n");
201
+ process.stderr.write("context-mode: healed mcpServers args (Issue #523)\n");
204
202
  }
205
203
  }
206
204
  } catch { /* never block install */ }