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.
- 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/bin/statusline.mjs +43 -36
- package/build/adapters/pi/mcp-bridge.d.ts +28 -3
- package/build/adapters/pi/mcp-bridge.js +127 -14
- package/build/adapters/qwen-code/index.js +6 -2
- package/build/cli.js +93 -5
- package/build/opencode-plugin.js +2 -5
- package/build/server.js +25 -8
- package/build/session/analytics.d.ts +21 -0
- package/build/session/analytics.js +1 -1
- package/build/util/project-dir.js +9 -5
- package/cli.bundle.mjs +148 -139
- package/hooks/core/routing.mjs +13 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -6
- package/scripts/heal-better-sqlite3.mjs +53 -6
- package/scripts/heal-installed-plugins.mjs +104 -0
- package/scripts/postinstall.mjs +35 -1
- package/server.bundle.mjs +88 -88
- package/skills/UPSTREAM-CREDITS.md +51 -0
- package/skills/diagnose/SKILL.md +122 -0
- package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/grill-me/SKILL.md +15 -0
- package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
- package/skills/grill-with-docs/SKILL.md +93 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +76 -0
- package/skills/tdd/SKILL.md +114 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +61 -0
- package/start.mjs +25 -1
- package/build/cache-heal.d.ts +0 -48
- package/build/cache-heal.js +0 -150
- package/build/routing-block.d.ts +0 -8
- package/build/routing-block.js +0 -86
- package/build/tool-naming.d.ts +0 -4
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/bin/statusline.mjs
CHANGED
|
@@ -212,7 +212,7 @@ async function main() {
|
|
|
212
212
|
const {
|
|
213
213
|
getRealBytesStats,
|
|
214
214
|
getMultiAdapterLifetimeStats,
|
|
215
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
261
|
-
|
|
264
|
+
// This-chat bytes — real 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
|
-
//
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
298
|
-
if (
|
|
299
|
-
const blocks = [
|
|
300
|
-
|
|
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(`${
|
|
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:
|
|
319
|
+
// ACTIVE: this-chat · lifetime · [N tools] · % efficient
|
|
313
320
|
const valueBlocks = [
|
|
314
|
-
`${bold(
|
|
321
|
+
`${bold(kb(sessionBytes))} ${dim("this chat")}`,
|
|
315
322
|
];
|
|
316
|
-
if (
|
|
317
|
-
valueBlocks.push(`${bold(
|
|
323
|
+
if (lifetimeBytes > 0) {
|
|
324
|
+
valueBlocks.push(`${bold(kb(lifetimeBytes))} ${dim("lifetime")}`);
|
|
318
325
|
}
|
|
319
326
|
if (showMultiAdapter) {
|
|
320
|
-
valueBlocks.push(`${
|
|
327
|
+
valueBlocks.push(`${dim(`across ${realAdapters.length} tools`)}`);
|
|
321
328
|
}
|
|
322
329
|
if (pct > 0) {
|
|
323
|
-
valueBlocks.push(`${bold(`${pct}%`)} ${dim("
|
|
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
|
-
|
|
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
|
|
133
|
+
export interface BootstrapOptions {
|
|
112
134
|
env?: NodeJS.ProcessEnv;
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
201
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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");
|
package/build/opencode-plugin.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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('
|
|
1661
|
-
const dnsPromises = require('
|
|
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
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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) => {
|