context-mode 1.0.136 → 1.0.137
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 +2 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +4 -23
- package/build/adapters/codex/index.js +24 -3
- package/build/adapters/opencode/index.d.ts +1 -0
- package/build/adapters/opencode/index.js +25 -0
- package/build/adapters/opencode/plugin.d.ts +22 -0
- package/build/adapters/opencode/plugin.js +52 -0
- package/build/adapters/pi/extension.js +20 -4
- package/build/adapters/pi/mcp-bridge.d.ts +2 -1
- package/build/adapters/pi/mcp-bridge.js +49 -3
- package/build/lifecycle.d.ts +2 -51
- package/build/lifecycle.js +3 -67
- package/build/server.d.ts +19 -0
- package/build/server.js +141 -58
- package/build/session/db.d.ts +6 -0
- package/build/session/db.js +17 -3
- package/build/util/sibling-mcp.d.ts +0 -40
- package/build/util/sibling-mcp.js +11 -116
- package/cli.bundle.mjs +131 -129
- package/configs/kilo/kilo.json +0 -11
- package/configs/opencode/opencode.json +0 -11
- package/hooks/normalize-hooks.mjs +101 -19
- package/hooks/session-db.bundle.mjs +3 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +112 -110
package/build/lifecycle.js
CHANGED
|
@@ -14,35 +14,6 @@
|
|
|
14
14
|
* Cross-platform: macOS, Linux, Windows.
|
|
15
15
|
*/
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the idle-shutdown threshold (#565).
|
|
19
|
-
*
|
|
20
|
-
* Idle shutdown is OFF by default (#592) because most hosts (Claude
|
|
21
|
-
* Code, Codex, editor MCP clients) keep registered tool handles after a
|
|
22
|
-
* clean MCP child exit and do NOT transparently respawn on the next call.
|
|
23
|
-
* The global 15 min default introduced in #568 solved OpenCode's child
|
|
24
|
-
* accumulation, but stranded ctx_* tools in Claude Code/Codex-style
|
|
25
|
-
* hosts once the MCP server exited cleanly while the editor stayed alive.
|
|
26
|
-
*
|
|
27
|
-
* Hosts that are known to benefit from idle shutdown MUST opt in via
|
|
28
|
-
* CONTEXT_MODE_IDLE_TIMEOUT_MS in their MCP config. Today that is
|
|
29
|
-
* OpenCode/KiloCode (their configs set 900000 = 15 min). Users and test
|
|
30
|
-
* harnesses can also opt in explicitly with any positive integer.
|
|
31
|
-
*
|
|
32
|
-
* Missing or malformed env = 0 (disabled, safe default). Set env to
|
|
33
|
-
* `0` to disable explicitly.
|
|
34
|
-
*
|
|
35
|
-
* Exported for unit-testing.
|
|
36
|
-
*/
|
|
37
|
-
export function idleTimeoutForEnv(env = process.env) {
|
|
38
|
-
const raw = env.CONTEXT_MODE_IDLE_TIMEOUT_MS;
|
|
39
|
-
if (raw === undefined)
|
|
40
|
-
return 0;
|
|
41
|
-
const n = Number.parseInt(raw, 10);
|
|
42
|
-
if (!Number.isFinite(n) || n < 0)
|
|
43
|
-
return 0;
|
|
44
|
-
return n;
|
|
45
|
-
}
|
|
46
17
|
/** Read grandparent PID via `ps -o ppid= -p $PPID`. Returns NaN on failure or Windows. */
|
|
47
18
|
function readGrandparentPpidImpl() {
|
|
48
19
|
if (process.platform === "win32")
|
|
@@ -124,52 +95,25 @@ export function lifecycleGuardIntervalForEnv(env = process.env) {
|
|
|
124
95
|
return 1000;
|
|
125
96
|
}
|
|
126
97
|
/**
|
|
127
|
-
* Start the lifecycle guard. Returns a
|
|
128
|
-
* on every MCP request to keep idle timer from firing) and `stop`.
|
|
129
|
-
*
|
|
98
|
+
* Start the lifecycle guard. Returns a cleanup function.
|
|
130
99
|
* Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
|
|
131
100
|
*/
|
|
132
101
|
export function startLifecycleGuard(opts) {
|
|
133
102
|
const interval = opts.checkIntervalMs ?? lifecycleGuardIntervalForEnv();
|
|
134
103
|
const check = opts.isParentAlive ?? defaultIsParentAlive;
|
|
135
|
-
const idleTimeoutMs = opts.idleTimeoutMs ?? idleTimeoutForEnv();
|
|
136
|
-
const now = opts.now ?? Date.now;
|
|
137
104
|
let stopped = false;
|
|
138
|
-
let lastActivity = now();
|
|
139
105
|
const shutdown = () => {
|
|
140
106
|
if (stopped)
|
|
141
107
|
return;
|
|
142
108
|
stopped = true;
|
|
143
109
|
opts.onShutdown();
|
|
144
110
|
};
|
|
145
|
-
|
|
146
|
-
lastActivity = now();
|
|
147
|
-
};
|
|
148
|
-
// P0: Periodic parent liveness check.
|
|
111
|
+
// P0: Periodic parent liveness check
|
|
149
112
|
const timer = setInterval(() => {
|
|
150
113
|
if (!check())
|
|
151
114
|
shutdown();
|
|
152
115
|
}, interval);
|
|
153
116
|
timer.unref();
|
|
154
|
-
// P0+: Idle shutdown (#565). Runs on its OWN tick — distinct from the
|
|
155
|
-
// 30 s parent-liveness poll — so a 15 min idle timeout actually reacts
|
|
156
|
-
// close to 15 min instead of "next 30 s tick after 15 min". Pick the
|
|
157
|
-
// tick as min(idleTimeoutMs / 6, 30 s) so a short timeout (e.g. 3 s in
|
|
158
|
-
// e2e tests, 60 s in dev) reacts within ~16 % of its window while a
|
|
159
|
-
// production 15 min timeout still polls every 30 s (cheap).
|
|
160
|
-
//
|
|
161
|
-
// Skipped on TTY because interactive dev sessions are expected to
|
|
162
|
-
// sit idle between commands, and also when idleTimeoutMs is 0 (env
|
|
163
|
-
// opt-out via CONTEXT_MODE_IDLE_TIMEOUT_MS=0).
|
|
164
|
-
let idleTimer = null;
|
|
165
|
-
if (idleTimeoutMs > 0 && !process.stdin.isTTY) {
|
|
166
|
-
const idleTick = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 6), 30_000));
|
|
167
|
-
idleTimer = setInterval(() => {
|
|
168
|
-
if (now() - lastActivity > idleTimeoutMs)
|
|
169
|
-
shutdown();
|
|
170
|
-
}, idleTick);
|
|
171
|
-
idleTimer.unref();
|
|
172
|
-
}
|
|
173
117
|
// P0: OS signals — terminal close, kill, ctrl+c
|
|
174
118
|
const signals = ["SIGTERM", "SIGINT"];
|
|
175
119
|
if (process.platform !== "win32")
|
|
@@ -198,19 +142,11 @@ export function startLifecycleGuard(opts) {
|
|
|
198
142
|
if (!process.stdin.isTTY) {
|
|
199
143
|
process.stdin.on("end", onStdinEnd);
|
|
200
144
|
}
|
|
201
|
-
|
|
145
|
+
return () => {
|
|
202
146
|
stopped = true;
|
|
203
147
|
clearInterval(timer);
|
|
204
|
-
if (idleTimer)
|
|
205
|
-
clearInterval(idleTimer);
|
|
206
148
|
for (const sig of signals)
|
|
207
149
|
process.removeListener(sig, shutdown);
|
|
208
150
|
process.stdin.removeListener("end", onStdinEnd);
|
|
209
151
|
};
|
|
210
|
-
// Hybrid: callable for legacy `const cleanup = startLifecycleGuard(...)`
|
|
211
|
-
// sites, with `.recordActivity` / `.stop` properties for the new contract.
|
|
212
|
-
const handle = cleanup;
|
|
213
|
-
handle.recordActivity = recordActivity;
|
|
214
|
-
handle.stop = cleanup;
|
|
215
|
-
return handle;
|
|
216
152
|
}
|
package/build/server.d.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
|
|
3
4
|
import { ContentStore } from "./store.js";
|
|
5
|
+
import { type PlatformId } from "./adapters/types.js";
|
|
6
|
+
export declare const server: McpServer;
|
|
7
|
+
export interface RegisteredCtxTool {
|
|
8
|
+
name: string;
|
|
9
|
+
config: Record<string, unknown>;
|
|
10
|
+
handler: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare const REGISTERED_CTX_TOOLS: RegisteredCtxTool[];
|
|
13
|
+
export declare function shouldSuppressMcpToolsForNativePluginHost(opts?: {
|
|
14
|
+
embedded?: string;
|
|
15
|
+
platform?: PlatformId;
|
|
16
|
+
settings?: Record<string, unknown> | null;
|
|
17
|
+
}): boolean;
|
|
18
|
+
type ToolContextOverride = {
|
|
19
|
+
projectDir: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function withProjectDirOverride<T>(projectDir: string | ToolContextOverride, fn: () => Promise<T>): Promise<T>;
|
|
4
23
|
/**
|
|
5
24
|
* Build the FK-attribution object passed to every ContentStore.index*() call
|
|
6
25
|
* in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
|
package/build/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join, dirname, resolve, sep, isAbsolute } from "node:path";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { homedir, tmpdir, cpus } from "node:os";
|
|
10
10
|
import { request as httpsRequest } from "node:https";
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { PolyglotExecutor } from "./executor.js";
|
|
13
14
|
import { runPool } from "./runPool.js";
|
|
@@ -43,19 +44,130 @@ const VERSION = (() => {
|
|
|
43
44
|
}
|
|
44
45
|
return "unknown";
|
|
45
46
|
})();
|
|
46
|
-
// Prevent silent server death from unhandled async errors
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
process.
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// Prevent silent MCP server death from unhandled async errors.
|
|
48
|
+
//
|
|
49
|
+
// Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
|
|
50
|
+
// imported only to reuse the ctx_* tool registry, these handlers would become
|
|
51
|
+
// process-wide OpenCode/Kilo host handlers. In Node, adding an
|
|
52
|
+
// `uncaughtException` listener changes default crash behavior, so only the
|
|
53
|
+
// standalone MCP process may install them.
|
|
54
|
+
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
55
|
+
process.on("unhandledRejection", (err) => {
|
|
56
|
+
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
|
|
57
|
+
});
|
|
58
|
+
process.on("uncaughtException", (err) => {
|
|
59
|
+
process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
53
62
|
const runtimes = detectRuntimes();
|
|
54
63
|
const available = getAvailableLanguages(runtimes);
|
|
55
|
-
const server = new McpServer({
|
|
64
|
+
export const server = new McpServer({
|
|
56
65
|
name: "context-mode",
|
|
57
66
|
version: VERSION,
|
|
58
67
|
});
|
|
68
|
+
export const REGISTERED_CTX_TOOLS = [];
|
|
69
|
+
export function shouldSuppressMcpToolsForNativePluginHost(opts = {}) {
|
|
70
|
+
const embedded = opts.embedded ?? process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
|
|
71
|
+
if (embedded === "1")
|
|
72
|
+
return false;
|
|
73
|
+
const platform = opts.platform ?? detectPlatform().platform;
|
|
74
|
+
if (platform !== "opencode" && platform !== "kilo")
|
|
75
|
+
return false;
|
|
76
|
+
const settings = opts.settings ?? readNativePluginHostSettings(platform);
|
|
77
|
+
return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
|
|
78
|
+
}
|
|
79
|
+
function stripJsonComments(str) {
|
|
80
|
+
let out = "";
|
|
81
|
+
let inString = false;
|
|
82
|
+
let escaped = false;
|
|
83
|
+
let inBlockComment = false;
|
|
84
|
+
for (let i = 0; i < str.length; i++) {
|
|
85
|
+
const c = str[i];
|
|
86
|
+
const next = str[i + 1];
|
|
87
|
+
if (inBlockComment) {
|
|
88
|
+
if (c === "*" && next === "/") {
|
|
89
|
+
inBlockComment = false;
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (escaped) {
|
|
95
|
+
out += c;
|
|
96
|
+
escaped = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c === "\\") {
|
|
100
|
+
out += c;
|
|
101
|
+
escaped = inString;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === '"') {
|
|
105
|
+
inString = !inString;
|
|
106
|
+
out += c;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!inString && c === "/" && next === "/") {
|
|
110
|
+
while (i < str.length && str[i] !== "\n")
|
|
111
|
+
i++;
|
|
112
|
+
if (i < str.length)
|
|
113
|
+
out += "\n";
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!inString && c === "/" && next === "*") {
|
|
117
|
+
inBlockComment = true;
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
out += c;
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
125
|
+
}
|
|
126
|
+
function readNativePluginHostSettings(platform) {
|
|
127
|
+
const base = platform === "kilo" ? "kilo" : "opencode";
|
|
128
|
+
const paths = [
|
|
129
|
+
resolve(`${base}.json`),
|
|
130
|
+
resolve(`${base}.jsonc`),
|
|
131
|
+
resolve(`.${base}`, `${base}.json`),
|
|
132
|
+
resolve(`.${base}`, `${base}.jsonc`),
|
|
133
|
+
join(homedir(), ".config", base, `${base}.json`),
|
|
134
|
+
join(homedir(), ".config", base, `${base}.jsonc`),
|
|
135
|
+
];
|
|
136
|
+
for (const p of paths) {
|
|
137
|
+
try {
|
|
138
|
+
if (!existsSync(p))
|
|
139
|
+
continue;
|
|
140
|
+
return JSON.parse(stripJsonComments(readFileSync(p, "utf8")));
|
|
141
|
+
}
|
|
142
|
+
catch { /* try next config path */ }
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function settingsHasContextModePlugin(settings) {
|
|
147
|
+
const plugins = settings?.plugin;
|
|
148
|
+
return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
|
|
149
|
+
}
|
|
150
|
+
function settingsHasLegacyContextModeMcp(settings) {
|
|
151
|
+
const mcp = settings?.mcp;
|
|
152
|
+
return !!(mcp &&
|
|
153
|
+
typeof mcp === "object" &&
|
|
154
|
+
!Array.isArray(mcp) &&
|
|
155
|
+
Object.prototype.hasOwnProperty.call(mcp, "context-mode"));
|
|
156
|
+
}
|
|
157
|
+
const suppressMcpToolsForNativePluginHost = shouldSuppressMcpToolsForNativePluginHost();
|
|
158
|
+
const originalRegisterTool = server.registerTool.bind(server);
|
|
159
|
+
server.registerTool = (...args) => {
|
|
160
|
+
const [name, config, handler] = args;
|
|
161
|
+
if (suppressMcpToolsForNativePluginHost)
|
|
162
|
+
return undefined;
|
|
163
|
+
REGISTERED_CTX_TOOLS.push({ name, config, handler });
|
|
164
|
+
return originalRegisterTool(...args);
|
|
165
|
+
};
|
|
166
|
+
const projectDirOverride = new AsyncLocalStorage();
|
|
167
|
+
export async function withProjectDirOverride(projectDir, fn) {
|
|
168
|
+
const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
|
|
169
|
+
return projectDirOverride.run(ctx, fn);
|
|
170
|
+
}
|
|
59
171
|
// Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
|
|
60
172
|
// OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
|
|
61
173
|
// the SDK transport layer, causing subsequent listTools() calls to fail permanently.
|
|
@@ -76,6 +188,14 @@ const executor = new PolyglotExecutor({
|
|
|
76
188
|
// This temp file is loaded via --require when batch commands spawn Node processes.
|
|
77
189
|
const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
|
|
78
190
|
writeFileSync(CM_FS_PRELOAD, `(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`);
|
|
191
|
+
// In the stdio MCP path, main() also removes this file during graceful
|
|
192
|
+
// shutdown. Plugin-native OpenCode/Kilo imports skip main() (#574), so
|
|
193
|
+
// register a top-level best-effort cleanup too to avoid leaking preload
|
|
194
|
+
// snippets under /tmp when the host process exits.
|
|
195
|
+
process.on("exit", () => { try {
|
|
196
|
+
unlinkSync(CM_FS_PRELOAD);
|
|
197
|
+
}
|
|
198
|
+
catch { /* best effort */ } });
|
|
79
199
|
// Lazy singleton — no DB overhead unless index/search is used
|
|
80
200
|
let _store = null;
|
|
81
201
|
/**
|
|
@@ -87,6 +207,9 @@ let _store = null;
|
|
|
87
207
|
* legacy unattributed rows readable.
|
|
88
208
|
*/
|
|
89
209
|
export function currentAttribution() {
|
|
210
|
+
const override = projectDirOverride.getStore();
|
|
211
|
+
if (override?.sessionId)
|
|
212
|
+
return { sessionId: override.sessionId };
|
|
90
213
|
// CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
|
|
91
214
|
// Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
|
|
92
215
|
// and writes session_events via hooks. Read the most-recent session_id from
|
|
@@ -240,6 +363,9 @@ function getSessionDir() {
|
|
|
240
363
|
* that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed).
|
|
241
364
|
*/
|
|
242
365
|
function getProjectDir() {
|
|
366
|
+
const override = projectDirOverride.getStore();
|
|
367
|
+
if (override)
|
|
368
|
+
return override.projectDir;
|
|
243
369
|
// Delegated to the shared resolver so the env-var chain rejects plugin
|
|
244
370
|
// install paths (set by a prior MCP boot's start.mjs after `/ctx-upgrade`)
|
|
245
371
|
// and prefers the shell-set PWD before the chdir'd cwd. v1.0.115 adds
|
|
@@ -3480,20 +3606,6 @@ server.registerTool("ctx_insight", {
|
|
|
3480
3606
|
// Server startup
|
|
3481
3607
|
// ─────────────────────────────────────────────────────────
|
|
3482
3608
|
async function main() {
|
|
3483
|
-
// Startup sibling sweep (#565). OpenCode/KiloCode spawn one MCP child
|
|
3484
|
-
// per session/subagent and never reap them. When a new MCP child boots
|
|
3485
|
-
// under a host that already has N stale idle siblings (sharing OUR
|
|
3486
|
-
// ppid), reclaim them before opening our own DB / sentinel / stdio.
|
|
3487
|
-
// Best effort — never blocks startup.
|
|
3488
|
-
try {
|
|
3489
|
-
const { startupSiblingSweep } = await import("./util/sibling-mcp.js");
|
|
3490
|
-
const report = await startupSiblingSweep();
|
|
3491
|
-
if (report.totalKilled > 0) {
|
|
3492
|
-
console.error(`Reaped ${report.totalKilled} stale sibling MCP server(s) ` +
|
|
3493
|
-
`(SIGTERM: ${report.terminatedBySigterm}, SIGKILL: ${report.terminatedBySigkill})`);
|
|
3494
|
-
}
|
|
3495
|
-
}
|
|
3496
|
-
catch { /* best effort */ }
|
|
3497
3609
|
// Clean up stale DB files from previous sessions
|
|
3498
3610
|
const cleaned = cleanupStaleDBs();
|
|
3499
3611
|
if (cleaned > 0) {
|
|
@@ -3543,38 +3655,7 @@ async function main() {
|
|
|
3543
3655
|
process.on("SIGINT", () => { gracefulShutdown(); });
|
|
3544
3656
|
process.on("SIGTERM", () => { gracefulShutdown(); });
|
|
3545
3657
|
// Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
|
|
3546
|
-
|
|
3547
|
-
// session AND per subagent and never tear them down for the host's lifetime,
|
|
3548
|
-
// accumulating one stdio child per session (observed: 26 children / 1.6 GB
|
|
3549
|
-
// RSS under a single `opencode serve` parent). Idle timeout reaps quiescent
|
|
3550
|
-
// servers; live ones bump `recordActivity()` on every JSON-RPC request via
|
|
3551
|
-
// the MCP SDK's `_onrequest` hook wrapped below.
|
|
3552
|
-
const lifecycle = startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
3553
|
-
// Wrap the SDK's internal request entry so every JSON-RPC `tools/call`,
|
|
3554
|
-
// `tools/list`, etc. resets the idle timer. We intercept at this layer
|
|
3555
|
-
// rather than per-tool because (a) it covers ALL requests, including
|
|
3556
|
-
// listTools / listPrompts / listResources / ping, and (b) it survives
|
|
3557
|
-
// future tool additions without each handler needing to remember to opt in.
|
|
3558
|
-
//
|
|
3559
|
-
// The cast is necessary because `_onrequest` is intentionally undocumented
|
|
3560
|
-
// in the SDK's public types. Best effort — if the field shape changes in
|
|
3561
|
-
// a future SDK release the lifecycle still works, idle reset just degrades
|
|
3562
|
-
// to "untriggered" which simply means the server lives until the next
|
|
3563
|
-
// ppid/signal-based exit path fires. We never block the request path.
|
|
3564
|
-
try {
|
|
3565
|
-
const inner = server.server;
|
|
3566
|
-
const origOnRequest = inner._onrequest;
|
|
3567
|
-
if (typeof origOnRequest === "function") {
|
|
3568
|
-
inner._onrequest = function (...args) {
|
|
3569
|
-
try {
|
|
3570
|
-
lifecycle.recordActivity();
|
|
3571
|
-
}
|
|
3572
|
-
catch { /* never break request path */ }
|
|
3573
|
-
return origOnRequest.apply(this, args);
|
|
3574
|
-
};
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
catch { /* best effort — see comment above */ }
|
|
3658
|
+
startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
3578
3659
|
const transport = new StdioServerTransport();
|
|
3579
3660
|
await server.connect(transport);
|
|
3580
3661
|
// Write MCP readiness sentinel (#230)
|
|
@@ -3642,7 +3723,9 @@ async function main() {
|
|
|
3642
3723
|
}
|
|
3643
3724
|
}
|
|
3644
3725
|
}
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3726
|
+
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
3727
|
+
main().catch((err) => {
|
|
3728
|
+
console.error("Fatal:", err);
|
|
3729
|
+
process.exit(1);
|
|
3730
|
+
});
|
|
3731
|
+
}
|
package/build/session/db.d.ts
CHANGED
|
@@ -216,6 +216,12 @@ export declare class SessionDB extends SQLiteBase {
|
|
|
216
216
|
* Return the most recently attributed project dir for a session.
|
|
217
217
|
*/
|
|
218
218
|
getLatestAttributedProjectDir(sessionId: string): string | null;
|
|
219
|
+
/**
|
|
220
|
+
* Look up the project_dir from session_meta as a last-resort fallback
|
|
221
|
+
* for event attribution. Prevents project_dir='' orphans when the caller
|
|
222
|
+
* (e.g. pi adapter) omits the attribution parameter.
|
|
223
|
+
*/
|
|
224
|
+
_getSessionProjectDir(sessionId: string): string;
|
|
219
225
|
/**
|
|
220
226
|
* Search events by text query scoped to a project directory.
|
|
221
227
|
*
|
package/build/session/db.js
CHANGED
|
@@ -491,7 +491,7 @@ export class SessionDB extends SQLiteBase {
|
|
|
491
491
|
// ── Search ──
|
|
492
492
|
p(S.searchEvents, `SELECT id, session_id, category, type, data, created_at
|
|
493
493
|
FROM session_events
|
|
494
|
-
WHERE project_dir = ?
|
|
494
|
+
WHERE (project_dir = ? OR project_dir = '')
|
|
495
495
|
AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
|
|
496
496
|
AND (? IS NULL OR category = ?)
|
|
497
497
|
ORDER BY id ASC
|
|
@@ -536,7 +536,7 @@ export class SessionDB extends SQLiteBase {
|
|
|
536
536
|
.toUpperCase();
|
|
537
537
|
const projectDir = String(attribution?.projectDir
|
|
538
538
|
?? event.project_dir
|
|
539
|
-
??
|
|
539
|
+
?? this._getSessionProjectDir(sessionId)).trim();
|
|
540
540
|
const attributionSource = String(attribution?.source
|
|
541
541
|
?? event.attribution_source
|
|
542
542
|
?? "unknown");
|
|
@@ -595,7 +595,7 @@ export class SessionDB extends SQLiteBase {
|
|
|
595
595
|
.slice(0, 16)
|
|
596
596
|
.toUpperCase();
|
|
597
597
|
const attribution = attributions?.[i];
|
|
598
|
-
const projectDir = String(attribution?.projectDir ?? event.project_dir ?? "").trim();
|
|
598
|
+
const projectDir = String(attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "").trim();
|
|
599
599
|
const attributionSource = String(attribution?.source ?? event.attribution_source ?? "unknown");
|
|
600
600
|
const rawConfidence = Number(attribution?.confidence ?? event.attribution_confidence ?? 0);
|
|
601
601
|
const attributionConfidence = Number.isFinite(rawConfidence)
|
|
@@ -681,6 +681,20 @@ export class SessionDB extends SQLiteBase {
|
|
|
681
681
|
const row = this.stmt(S.getLatestAttributedProject).get(sessionId);
|
|
682
682
|
return row?.project_dir || null;
|
|
683
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Look up the project_dir from session_meta as a last-resort fallback
|
|
686
|
+
* for event attribution. Prevents project_dir='' orphans when the caller
|
|
687
|
+
* (e.g. pi adapter) omits the attribution parameter.
|
|
688
|
+
*/
|
|
689
|
+
_getSessionProjectDir(sessionId) {
|
|
690
|
+
try {
|
|
691
|
+
const row = this.db.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?").get(sessionId);
|
|
692
|
+
return row?.project_dir || "";
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return "";
|
|
696
|
+
}
|
|
697
|
+
}
|
|
684
698
|
/**
|
|
685
699
|
* Search events by text query scoped to a project directory.
|
|
686
700
|
*
|
|
@@ -38,21 +38,6 @@ export interface DiscoverOptions {
|
|
|
38
38
|
platform?: NodeJS.Platform;
|
|
39
39
|
/** Test injection point — defaults to `child_process.execFileSync`. */
|
|
40
40
|
runCommand?: RunCommand;
|
|
41
|
-
/**
|
|
42
|
-
* When true, only return pids whose parent (ppid) is the SAME as the
|
|
43
|
-
* caller's own ppid (i.e. siblings under the same host process).
|
|
44
|
-
*
|
|
45
|
-
* Used by the startup sweep (#565) so an opencode-spawned MCP child
|
|
46
|
-
* only reaps OTHER opencode-spawned MCP children, never the children
|
|
47
|
-
* of a different opencode/Claude host running in parallel.
|
|
48
|
-
*
|
|
49
|
-
* Requires a way to read each pid's ppid. Defaults to a `ps -o ppid=`
|
|
50
|
-
* probe on POSIX and PowerShell `Get-CimInstance` on Windows. Set
|
|
51
|
-
* `readPpid` to inject in tests.
|
|
52
|
-
*/
|
|
53
|
-
sameParentOnly?: boolean;
|
|
54
|
-
/** Test injection — read ppid for a given pid. Defaults to platform probe. */
|
|
55
|
-
readPpid?: (pid: number) => number;
|
|
56
41
|
}
|
|
57
42
|
export interface KillOptions {
|
|
58
43
|
pids: readonly number[];
|
|
@@ -92,28 +77,3 @@ export declare function discoverSiblingMcpPids(opts: DiscoverOptions): number[];
|
|
|
92
77
|
* counted — they were not ours to kill.
|
|
93
78
|
*/
|
|
94
79
|
export declare function killSiblingMcpServers(opts: KillOptions): Promise<KillReport>;
|
|
95
|
-
/**
|
|
96
|
-
* Startup-time sibling sweep (#565).
|
|
97
|
-
*
|
|
98
|
-
* Discovers any context-mode MCP server pids that share OUR parent process
|
|
99
|
-
* (i.e. other MCP children of the same host like `opencode serve`) and
|
|
100
|
-
* terminates them. The intent is "exactly one MCP child per host" — when a
|
|
101
|
-
* new MCP client spawns inside an opencode host that already has 25 stale
|
|
102
|
-
* idle siblings, this sweep reclaims them at boot rather than waiting for
|
|
103
|
-
* the idle timeout to fire on each one independently.
|
|
104
|
-
*
|
|
105
|
-
* Gated by env (default-on but easy to disable):
|
|
106
|
-
*
|
|
107
|
-
* CONTEXT_MODE_STARTUP_SWEEP=0 → disabled
|
|
108
|
-
* CONTEXT_MODE_STARTUP_SWEEP=1 → enabled (default)
|
|
109
|
-
*
|
|
110
|
-
* Safety:
|
|
111
|
-
* - `sameParentOnly: true` — never touches MCP children of a different host.
|
|
112
|
-
* - Best-effort throughout: failures never block server startup.
|
|
113
|
-
* - Composes with the idle-timeout path: if a sibling is actively in use
|
|
114
|
-
* by another session, the parent process will simply spawn a new MCP
|
|
115
|
-
* child on its next request. The cost is one cold-start (~1–3 s) for
|
|
116
|
-
* that session, which is identical to opencode's existing behaviour
|
|
117
|
-
* of spawning a fresh MCP child per session anyway.
|
|
118
|
-
*/
|
|
119
|
-
export declare function startupSiblingSweep(env?: NodeJS.ProcessEnv): Promise<KillReport>;
|
|
@@ -26,70 +26,19 @@
|
|
|
26
26
|
* cross-platform without spawning real processes.
|
|
27
27
|
*/
|
|
28
28
|
import { execFileSync } from "node:child_process";
|
|
29
|
-
// Match
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// 2. `<prefix>/node_modules/context-mode/start.mjs` or `.../server.bundle.mjs`
|
|
35
|
-
// — npm-global, marketplace, manual installs.
|
|
36
|
-
// 3. `<prefix>/bin/context-mode` — npm-global bin shim. This is the argv
|
|
37
|
-
// OpenCode + KiloCode see when they spawn `mcp.command = ["context-mode"]`.
|
|
38
|
-
// Without this entry, sibling discovery missed the 26-child / 1.6 GB
|
|
39
|
-
// RSS accumulation reported in #565.
|
|
40
|
-
// 4. `bun .../context-mode/server.bundle.mjs` — Pi/Bun hosts.
|
|
41
|
-
//
|
|
42
|
-
// All four can be alive concurrently — VERDICT R1 dump confirmed multi-version
|
|
43
|
-
// coexistence on real macOS, and #565 confirmed concurrent OpenCode children
|
|
44
|
-
// alongside Claude Code children on Linux.
|
|
45
|
-
const POSIX_PGREP_PATTERN = "(node|bun).*(plugins/(cache|marketplaces)/.*context-mode.*start\\.mjs" +
|
|
46
|
-
"|context-mode/start\\.mjs" +
|
|
47
|
-
"|context-mode/server\\.bundle\\.mjs" +
|
|
48
|
-
"|bin/context-mode($|[^a-zA-Z0-9_-]))";
|
|
29
|
+
// Match BOTH `~/.claude/plugins/cache/context-mode/context-mode/<v>/start.mjs`
|
|
30
|
+
// AND `~/.claude/plugins/marketplaces/context-mode/start.mjs` shapes.
|
|
31
|
+
// Both can be alive concurrently — VERDICT R1 dump confirmed all four
|
|
32
|
+
// PIDs simultaneously across three different versions on a real Mac.
|
|
33
|
+
const POSIX_PGREP_PATTERN = "node.*plugins/(cache|marketplaces)/.*context-mode.*start\\.mjs";
|
|
49
34
|
// Windows: PowerShell + Get-CimInstance (wmic deprecated since Win11 22H2).
|
|
50
|
-
// Filter
|
|
51
|
-
//
|
|
35
|
+
// Filter on CommandLine because Win32_Process.Name is just "node.exe".
|
|
36
|
+
// Two backslashes inside `start\.mjs` are needed because the Like operator
|
|
37
|
+
// uses regex-ish escaping at the JS layer.
|
|
52
38
|
const WIN_PS_SCRIPT = "Get-CimInstance Win32_Process " +
|
|
53
|
-
"-Filter \"Name='node.exe'
|
|
54
|
-
"Where-Object { $_.CommandLine -match " +
|
|
55
|
-
"'plugins[\\\\/](cache|marketplaces)[\\\\/].*context-mode.*start\\.mjs" +
|
|
56
|
-
"|context-mode[\\\\/]start\\.mjs" +
|
|
57
|
-
"|context-mode[\\\\/]server\\.bundle\\.mjs" +
|
|
58
|
-
"|bin[\\\\/]context-mode($|[^a-zA-Z0-9_-])' } | " +
|
|
39
|
+
"-Filter \"Name='node.exe'\" | " +
|
|
40
|
+
"Where-Object { $_.CommandLine -match 'plugins[\\\\/](cache|marketplaces)[\\\\/].*context-mode.*start\\.mjs' } | " +
|
|
59
41
|
"Select-Object -ExpandProperty ProcessId";
|
|
60
|
-
/** POSIX ppid probe — empty / NaN on failure. */
|
|
61
|
-
function readPpidPosix(pid) {
|
|
62
|
-
try {
|
|
63
|
-
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
64
|
-
encoding: "utf-8",
|
|
65
|
-
timeout: 2000,
|
|
66
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
-
}).trim();
|
|
68
|
-
const n = Number.parseInt(out, 10);
|
|
69
|
-
return Number.isFinite(n) ? n : NaN;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return NaN;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/** Windows ppid probe — empty / NaN on failure. */
|
|
76
|
-
function readPpidWin32(pid) {
|
|
77
|
-
try {
|
|
78
|
-
const out = execFileSync("powershell", [
|
|
79
|
-
"-NoProfile",
|
|
80
|
-
"-Command",
|
|
81
|
-
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`,
|
|
82
|
-
], { encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
83
|
-
const n = Number.parseInt(out, 10);
|
|
84
|
-
return Number.isFinite(n) ? n : NaN;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
return NaN;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
function defaultReadPpid(pid) {
|
|
91
|
-
return process.platform === "win32" ? readPpidWin32(pid) : readPpidPosix(pid);
|
|
92
|
-
}
|
|
93
42
|
const defaultRun = (cmd, args) => execFileSync(cmd, [...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
94
43
|
const defaultIsAlive = (pid) => {
|
|
95
44
|
try {
|
|
@@ -145,18 +94,7 @@ export function discoverSiblingMcpPids(opts) {
|
|
|
145
94
|
catch {
|
|
146
95
|
return [];
|
|
147
96
|
}
|
|
148
|
-
|
|
149
|
-
if (!opts.sameParentOnly)
|
|
150
|
-
return candidates;
|
|
151
|
-
// Startup-sweep mode (#565): only reap siblings sharing OUR ppid. This
|
|
152
|
-
// prevents an opencode-spawned MCP child from killing a Claude Code MCP
|
|
153
|
-
// child (or another concurrent opencode host's children) when both are
|
|
154
|
-
// present on the same machine.
|
|
155
|
-
const readPpid = opts.readPpid ?? defaultReadPpid;
|
|
156
|
-
return candidates.filter((pid) => {
|
|
157
|
-
const ppid = readPpid(pid);
|
|
158
|
-
return Number.isFinite(ppid) && ppid === opts.ownPpid;
|
|
159
|
-
});
|
|
97
|
+
return parsePidList(stdout).filter((pid) => pid !== opts.ownPid && pid !== opts.ownPpid);
|
|
160
98
|
}
|
|
161
99
|
/** Sleep helper — Promise-based for use inside the kill polling loop. */
|
|
162
100
|
function delay(ms) {
|
|
@@ -241,46 +179,3 @@ export async function killSiblingMcpServers(opts) {
|
|
|
241
179
|
totalKilled: terminatedBySigterm + terminatedBySigkill,
|
|
242
180
|
};
|
|
243
181
|
}
|
|
244
|
-
/**
|
|
245
|
-
* Startup-time sibling sweep (#565).
|
|
246
|
-
*
|
|
247
|
-
* Discovers any context-mode MCP server pids that share OUR parent process
|
|
248
|
-
* (i.e. other MCP children of the same host like `opencode serve`) and
|
|
249
|
-
* terminates them. The intent is "exactly one MCP child per host" — when a
|
|
250
|
-
* new MCP client spawns inside an opencode host that already has 25 stale
|
|
251
|
-
* idle siblings, this sweep reclaims them at boot rather than waiting for
|
|
252
|
-
* the idle timeout to fire on each one independently.
|
|
253
|
-
*
|
|
254
|
-
* Gated by env (default-on but easy to disable):
|
|
255
|
-
*
|
|
256
|
-
* CONTEXT_MODE_STARTUP_SWEEP=0 → disabled
|
|
257
|
-
* CONTEXT_MODE_STARTUP_SWEEP=1 → enabled (default)
|
|
258
|
-
*
|
|
259
|
-
* Safety:
|
|
260
|
-
* - `sameParentOnly: true` — never touches MCP children of a different host.
|
|
261
|
-
* - Best-effort throughout: failures never block server startup.
|
|
262
|
-
* - Composes with the idle-timeout path: if a sibling is actively in use
|
|
263
|
-
* by another session, the parent process will simply spawn a new MCP
|
|
264
|
-
* child on its next request. The cost is one cold-start (~1–3 s) for
|
|
265
|
-
* that session, which is identical to opencode's existing behaviour
|
|
266
|
-
* of spawning a fresh MCP child per session anyway.
|
|
267
|
-
*/
|
|
268
|
-
export async function startupSiblingSweep(env = process.env) {
|
|
269
|
-
const empty = { terminatedBySigterm: 0, terminatedBySigkill: 0, totalKilled: 0 };
|
|
270
|
-
const raw = env.CONTEXT_MODE_STARTUP_SWEEP;
|
|
271
|
-
if (raw === "0" || raw === "false")
|
|
272
|
-
return empty;
|
|
273
|
-
try {
|
|
274
|
-
const pids = discoverSiblingMcpPids({
|
|
275
|
-
ownPid: process.pid,
|
|
276
|
-
ownPpid: process.ppid,
|
|
277
|
-
sameParentOnly: true,
|
|
278
|
-
});
|
|
279
|
-
if (pids.length === 0)
|
|
280
|
-
return empty;
|
|
281
|
-
return await killSiblingMcpServers({ pids });
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
return empty;
|
|
285
|
-
}
|
|
286
|
-
}
|