context-mode 1.0.124 → 1.0.126
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +3 -3
- package/build/adapters/claude-code/hooks.d.ts +22 -17
- package/build/adapters/claude-code/hooks.js +33 -24
- package/build/adapters/claude-code/index.d.ts +24 -1
- package/build/adapters/claude-code/index.js +67 -5
- package/build/adapters/codex/hooks.d.ts +13 -14
- package/build/adapters/codex/hooks.js +13 -14
- package/build/adapters/codex/index.js +19 -8
- package/build/adapters/types.d.ts +57 -0
- package/build/adapters/types.js +29 -0
- package/build/cli.js +38 -13
- package/build/db-base.d.ts +19 -2
- package/build/db-base.js +49 -15
- package/build/executor.js +40 -3
- package/build/runtime.d.ts +2 -1
- package/build/runtime.js +10 -0
- package/build/server.js +4 -2
- package/build/util/hook-config.d.ts +24 -1
- package/build/util/hook-config.js +39 -2
- package/build/util/plugin-cache-integrity.d.ts +37 -0
- package/build/util/plugin-cache-integrity.js +105 -0
- package/cli.bundle.mjs +141 -138
- package/configs/codex/hooks.json +1 -1
- package/hooks/core/routing.mjs +8 -4
- package/hooks/hooks.json +1 -1
- package/hooks/session-db.bundle.mjs +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/plugin-cache-integrity.mjs +168 -0
- package/server.bundle.mjs +97 -94
- package/start.mjs +37 -0
- package/skills/UPSTREAM-CREDITS.md +0 -51
- package/skills/diagnose/SKILL.md +0 -122
- package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
- package/skills/grill-me/SKILL.md +0 -15
- package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
- package/skills/grill-with-docs/SKILL.md +0 -93
- package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
- package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
- package/skills/improve-codebase-architecture/SKILL.md +0 -76
- package/skills/tdd/SKILL.md +0 -114
- package/skills/tdd/deep-modules.md +0 -33
- package/skills/tdd/interface-design.md +0 -31
- package/skills/tdd/mocking.md +0 -59
- package/skills/tdd/refactoring.md +0 -10
- package/skills/tdd/tests.md +0 -61
package/configs/codex/hooks.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"hooks": {
|
|
3
3
|
"PreToolUse": [
|
|
4
4
|
{
|
|
5
|
-
"matcher": "local_shell|shell|shell_command|exec_command|
|
|
5
|
+
"matcher": "local_shell|shell|shell_command|exec_command|Bash|Shell|apply_patch|Edit|Write|grep_files|ctx_execute|ctx_execute_file|ctx_batch_execute|ctx_fetch_and_index|ctx_search|ctx_index|mcp__",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{ "type": "command", "command": "context-mode hook codex pretooluse" }
|
|
8
8
|
]
|
package/hooks/core/routing.mjs
CHANGED
|
@@ -185,12 +185,16 @@ const SAFE_COMMAND_PATTERNS = [
|
|
|
185
185
|
/^cd(?:\s+[^\r\n]+)?$/,
|
|
186
186
|
/^mkdir(?:\s+[^\r\n]+)?$/,
|
|
187
187
|
/^touch\s+[^\r\n]+$/,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
// #517 follow-up: the original `(?!\s+-[a-zA-Z]*v\b)` required `v` to be
|
|
189
|
+
// the LAST alpha char in the flag bundle, so `-vs`, `-vfr`, `-rvf`,
|
|
190
|
+
// `-sfvr`, etc. silently slipped past the carve-out and flooded.
|
|
191
|
+
// `(?!\s+-[a-zA-Z]*v[a-zA-Z]*)` catches `v` anywhere in the bundle.
|
|
192
|
+
/^mv(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
|
|
193
|
+
/^cp(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
|
|
194
|
+
/^rm(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
|
|
191
195
|
// ln (#517): silent on success — same `-v` / `--verbose` carve-out as
|
|
192
196
|
// cp/mv/rm. Bulk symlink operations with -v flood one line per link.
|
|
193
|
-
/^ln(?!\s+-[a-zA-Z]*v
|
|
197
|
+
/^ln(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
|
|
194
198
|
// ls — refuse recursive (-R / --recursive) to keep output bounded.
|
|
195
199
|
/^ls(?!\s+-[a-zA-Z]*R)(?!\s+--recursive)(?:\s+[^\r\n]+)?$/,
|
|
196
200
|
// git read-only / status subcommands
|
package/hooks/hooks.json
CHANGED
|
@@ -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
|
|
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__"),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(`
|
|
2
2
|
CREATE TABLE IF NOT EXISTS session_events (
|
|
3
3
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
4
|
session_id TEXT NOT NULL,
|
|
@@ -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(),
|
|
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};
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
6
|
+
"version": "1.0.126",
|
|
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.
|
|
3
|
+
"version": "1.0.126",
|
|
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",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"scripts/postinstall.mjs",
|
|
80
80
|
"scripts/heal-better-sqlite3.mjs",
|
|
81
81
|
"scripts/heal-installed-plugins.mjs",
|
|
82
|
+
"scripts/plugin-cache-integrity.mjs",
|
|
82
83
|
"README.md",
|
|
83
84
|
"LICENSE"
|
|
84
85
|
],
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin cache integrity check (Algo-D4 + Algo-D5).
|
|
3
|
+
*
|
|
4
|
+
* Algorithmic defense against #550: a partial install (interrupted npm
|
|
5
|
+
* install, broken marketplace pull, half-finished /ctx-upgrade) leaves
|
|
6
|
+
* start.mjs spawnable but a critical sibling (server.bundle.mjs,
|
|
7
|
+
* cli.bundle.mjs, hooks/<event>.mjs, …) missing. The MCP child then
|
|
8
|
+
* dies silently downstream and the user sees an opaque "MCP server
|
|
9
|
+
* failed to start" with no actionable signal.
|
|
10
|
+
*
|
|
11
|
+
* The expected sibling tree is DERIVED from `package.json files[]` —
|
|
12
|
+
* the npm publish source of truth. Adding a new entry there auto-
|
|
13
|
+
* extends the integrity check; no parallel hardcoded list to maintain
|
|
14
|
+
* (the trap that bites every project that hand-rolls "list of files
|
|
15
|
+
* that must exist at runtime").
|
|
16
|
+
*
|
|
17
|
+
* Two consumers:
|
|
18
|
+
* 1. start.mjs at boot — calls assertPluginCacheIntegrity, on !ok
|
|
19
|
+
* writes a structured CONTEXT_MODE_PARTIAL_INSTALL stderr block
|
|
20
|
+
* and exits 2. Fail-fast — the alternative is a downstream stack
|
|
21
|
+
* trace from `import("./server.bundle.mjs")` that hides the
|
|
22
|
+
* actual root cause.
|
|
23
|
+
* 2. src/cli.ts ctx doctor (Algo-D5) — same helper, same answer,
|
|
24
|
+
* surfaced as a HealthCheck so users get the diagnostic without
|
|
25
|
+
* restarting the MCP server.
|
|
26
|
+
*
|
|
27
|
+
* Pure JS, Node.js built-ins only. Ships in package.json files[] so
|
|
28
|
+
* users running off the npm tarball get the same code path the
|
|
29
|
+
* developer ran during `pretest`.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
32
|
+
import { join, relative } from "node:path";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk a directory recursively, returning a flat list of relative file
|
|
36
|
+
* paths (using `/` as separator inside the returned strings). Skips
|
|
37
|
+
* unreadable entries silently — the integrity check operates on what
|
|
38
|
+
* IS readable; missing entries are reported by the caller.
|
|
39
|
+
*/
|
|
40
|
+
function listFilesRecursive(absDir, baseAbs) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = readdirSync(absDir);
|
|
45
|
+
} catch {
|
|
46
|
+
return out; // unreadable — caller will report the parent as missing
|
|
47
|
+
}
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
const full = join(absDir, name);
|
|
50
|
+
let st;
|
|
51
|
+
try {
|
|
52
|
+
st = statSync(full);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (st.isDirectory()) {
|
|
57
|
+
out.push(...listFilesRecursive(full, baseAbs));
|
|
58
|
+
} else {
|
|
59
|
+
out.push(relative(baseAbs, full));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compute the expected sibling tree for a given pluginRoot, derived
|
|
67
|
+
* from the supplied `package.json files[]` array.
|
|
68
|
+
*
|
|
69
|
+
* Algorithm:
|
|
70
|
+
* - Each entry in files[] is resolved against pluginRoot.
|
|
71
|
+
* - If it points to a directory → list every file inside recursively.
|
|
72
|
+
* - If it points to a file → kept as-is.
|
|
73
|
+
* - Entries that don't exist at probe-time are EXCLUDED from the
|
|
74
|
+
* manifest (they show up as `missing` in the assert step instead).
|
|
75
|
+
* This avoids the trap of "manifest contains paths that have never
|
|
76
|
+
* existed" — the manifest is a snapshot of WHAT IS, not WHAT WAS
|
|
77
|
+
* PUBLISHED.
|
|
78
|
+
*
|
|
79
|
+
* Returns relative paths (relative to pluginRoot). Used by both
|
|
80
|
+
* assertPluginCacheIntegrity and the doctor surface.
|
|
81
|
+
*/
|
|
82
|
+
export function derivePluginManifest({ pkg, pluginRoot }) {
|
|
83
|
+
if (!pkg || !Array.isArray(pkg.files)) return [];
|
|
84
|
+
const manifest = new Set();
|
|
85
|
+
for (const entry of pkg.files) {
|
|
86
|
+
if (typeof entry !== "string" || !entry) continue;
|
|
87
|
+
const absEntry = join(pluginRoot, entry);
|
|
88
|
+
if (!existsSync(absEntry)) continue;
|
|
89
|
+
let st;
|
|
90
|
+
try {
|
|
91
|
+
st = statSync(absEntry);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (st.isDirectory()) {
|
|
96
|
+
for (const f of listFilesRecursive(absEntry, pluginRoot)) manifest.add(f);
|
|
97
|
+
} else {
|
|
98
|
+
manifest.add(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...manifest];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* REQUIRED_RUNTIME_SIBLINGS — the minimum set of files start.mjs must
|
|
106
|
+
* find at boot. These are the files start.mjs actively `import()`s or
|
|
107
|
+
* needs to re-symlink against. The check is intentionally narrower
|
|
108
|
+
* than the full manifest:
|
|
109
|
+
*
|
|
110
|
+
* - server.bundle.mjs / cli.bundle.mjs are produced by `npm run
|
|
111
|
+
* bundle`. Without server.bundle.mjs the server can't start;
|
|
112
|
+
* without cli.bundle.mjs `context-mode doctor` can't run.
|
|
113
|
+
* - hooks/{5 hook scripts}.mjs are spawned per Claude Code event.
|
|
114
|
+
* Missing any one produces a silent hook failure.
|
|
115
|
+
*
|
|
116
|
+
* Other files in package.json files[] (insight/, configs/, README, …)
|
|
117
|
+
* are not boot-critical, so missing them is a "warn"-class issue
|
|
118
|
+
* surfaced only via the doctor — never enough to fail-fast at boot.
|
|
119
|
+
*/
|
|
120
|
+
const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
|
|
121
|
+
"server.bundle.mjs",
|
|
122
|
+
"cli.bundle.mjs",
|
|
123
|
+
join("hooks", "pretooluse.mjs"),
|
|
124
|
+
join("hooks", "posttooluse.mjs"),
|
|
125
|
+
join("hooks", "precompact.mjs"),
|
|
126
|
+
join("hooks", "sessionstart.mjs"),
|
|
127
|
+
join("hooks", "userpromptsubmit.mjs"),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Verify boot-critical siblings exist at pluginRoot.
|
|
132
|
+
*
|
|
133
|
+
* Returns `{ ok, missing }`. Pure — does NOT touch process.exit or
|
|
134
|
+
* stderr. The caller (start.mjs at boot, src/cli.ts at doctor) decides
|
|
135
|
+
* the failure surface (fail-fast exit 2 vs. doctor diagnostic).
|
|
136
|
+
*
|
|
137
|
+
* Uses package.json (read from pluginRoot) only as a source-of-truth
|
|
138
|
+
* cross-check; the actual REQUIRED list is hardcoded above to keep the
|
|
139
|
+
* runtime contract independent of package.json being readable. If
|
|
140
|
+
* package.json IS readable AND files[] omits something we require, the
|
|
141
|
+
* check fails — that's the "drift between contract and tarball" trap.
|
|
142
|
+
*/
|
|
143
|
+
export function assertPluginCacheIntegrity({ pluginRoot }) {
|
|
144
|
+
const missing = [];
|
|
145
|
+
for (const rel of REQUIRED_RUNTIME_SIBLINGS) {
|
|
146
|
+
const abs = join(pluginRoot, rel);
|
|
147
|
+
if (!existsSync(abs)) missing.push(abs);
|
|
148
|
+
}
|
|
149
|
+
return { ok: missing.length === 0, missing };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Format the structured stderr block start.mjs emits when integrity
|
|
154
|
+
* fails. Marker line `CONTEXT_MODE_PARTIAL_INSTALL` lets external
|
|
155
|
+
* monitoring grep for the exact failure mode without parsing free-form
|
|
156
|
+
* text. Keep the format stable across versions.
|
|
157
|
+
*/
|
|
158
|
+
export function formatPartialInstallReport({ pluginRoot, missing }) {
|
|
159
|
+
const lines = [
|
|
160
|
+
"CONTEXT_MODE_PARTIAL_INSTALL",
|
|
161
|
+
` pluginRoot: ${pluginRoot}`,
|
|
162
|
+
" missing:",
|
|
163
|
+
...missing.map((m) => ` - ${m}`),
|
|
164
|
+
" fix: rm -rf the install dir and re-pull (marketplace) or run `npm install -g context-mode` again.",
|
|
165
|
+
"",
|
|
166
|
+
];
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|