context-mode 1.0.130 → 1.0.132

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.130"
9
+ "version": "1.0.132"
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.130",
16
+ "version": "1.0.132",
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.130",
3
+ "version": "1.0.132",
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.130",
6
+ "version": "1.0.132",
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.130",
3
+ "version": "1.0.132",
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/README.md CHANGED
@@ -124,7 +124,7 @@ This gives you all 11 MCP tools without automatic routing. The model can still u
124
124
  <details>
125
125
  <summary><strong>Gemini CLI</strong> — one config file, hooks included</summary>
126
126
 
127
- **Prerequisites:** Node.js 18+, Gemini CLI installed.
127
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Gemini CLI installed.
128
128
 
129
129
  **Install:**
130
130
 
@@ -197,7 +197,7 @@ Full config reference: [`configs/gemini-cli/settings.json`](configs/gemini-cli/s
197
197
  <details>
198
198
  <summary><strong>VS Code Copilot</strong> — hooks with SessionStart</summary>
199
199
 
200
- **Prerequisites:** Node.js 18+, VS Code with Copilot Chat v0.32+.
200
+ **Prerequisites:** Node.js >= 22.5 (or Bun), VS Code with Copilot Chat v0.32+.
201
201
 
202
202
  **Install:**
203
203
 
@@ -254,7 +254,7 @@ Full hook config including PreCompact: [`configs/vscode-copilot/hooks.json`](con
254
254
  <details>
255
255
  <summary><strong>JetBrains Copilot</strong> — hooks with SessionStart</summary>
256
256
 
257
- **Prerequisites:** Node.js 18+, JetBrains IDE with GitHub Copilot plugin v1.5.57+.
257
+ **Prerequisites:** Node.js >= 22.5 (or Bun), JetBrains IDE with GitHub Copilot plugin v1.5.57+.
258
258
 
259
259
  **Install:**
260
260
 
@@ -305,7 +305,7 @@ Full setup guide: [`docs/jetbrains-copilot.md`](docs/jetbrains-copilot.md)
305
305
  <details>
306
306
  <summary><strong>Cursor</strong> — hooks with stop support</summary>
307
307
 
308
- **Prerequisites:** Node.js 18+, Cursor with agent mode.
308
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Cursor with agent mode.
309
309
 
310
310
  > **🚧 Work in progress** — the Marketplace plugin is **awaiting Cursor team review**. Until it's listed, install via the local-folder path described in Option A. Tracking in [#485](https://github.com/mksglu/context-mode/issues/485) / [#489](https://github.com/mksglu/context-mode/pull/489).
311
311
 
@@ -406,7 +406,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
406
406
  <details>
407
407
  <summary><strong>OpenCode</strong> — TypeScript plugin with hooks</summary>
408
408
 
409
- **Prerequisites:** Node.js 18+, OpenCode installed.
409
+ **Prerequisites:** Node.js >= 22.5 (or Bun), OpenCode installed.
410
410
 
411
411
  **Install:**
412
412
 
@@ -456,7 +456,7 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
456
456
  <details>
457
457
  <summary><strong>KiloCode</strong> — TypeScript plugin with hooks</summary>
458
458
 
459
- **Prerequisites:** Node.js 18+, KiloCode installed.
459
+ **Prerequisites:** Node.js >= 22.5 (or Bun), KiloCode installed.
460
460
 
461
461
  **Install:**
462
462
 
@@ -541,7 +541,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
541
541
  <details>
542
542
  <summary><strong>Codex CLI</strong> — MCP + hooks</summary>
543
543
 
544
- **Prerequisites:** Node.js 18+, Codex CLI installed.
544
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Codex CLI installed.
545
545
 
546
546
  **Install:**
547
547
 
@@ -606,7 +606,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
606
606
  <details>
607
607
  <summary><strong>Qwen Code</strong> — MCP + hooks (identical wire protocol to Claude Code)</summary>
608
608
 
609
- **Prerequisites:** Node.js 18+, Qwen Code installed (`npm install -g @qwen-code/qwen-code`).
609
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Qwen Code installed (`npm install -g @qwen-code/qwen-code`).
610
610
 
611
611
  1. Install context-mode:
612
612
 
@@ -660,7 +660,7 @@ Full documentation: [`docs/adapters/openclaw.md`](docs/adapters/openclaw.md)
660
660
  <details>
661
661
  <summary><strong>Antigravity</strong> — MCP-only, no hooks</summary>
662
662
 
663
- **Prerequisites:** Node.js 18+, Antigravity installed.
663
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Antigravity installed.
664
664
 
665
665
  **Install:**
666
666
 
@@ -701,7 +701,7 @@ Full configs: [`configs/antigravity/mcp_config.json`](configs/antigravity/mcp_co
701
701
  <details>
702
702
  <summary><strong>Kiro</strong> — hooks with steering file</summary>
703
703
 
704
- **Prerequisites:** Node.js 18+, Kiro with MCP enabled (Settings > search "MCP").
704
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Kiro with MCP enabled (Settings > search "MCP").
705
705
 
706
706
  **Install:**
707
707
 
@@ -759,7 +759,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
759
759
  <details>
760
760
  <summary><strong>Zed</strong> — MCP-only, no hooks</summary>
761
761
 
762
- **Prerequisites:** Node.js 18+, Zed installed.
762
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Zed installed.
763
763
 
764
764
  **Install:**
765
765
 
@@ -802,7 +802,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
802
802
  <details>
803
803
  <summary><strong>Pi Coding Agent</strong> — extension with full hook support</summary>
804
804
 
805
- **Prerequisites:** Node.js 18+, Pi Coding Agent installed.
805
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Pi Coding Agent installed.
806
806
 
807
807
  **Install:**
808
808
 
@@ -849,7 +849,7 @@ Full configs: [`configs/kiro/mcp.json`](configs/kiro/mcp.json) | [`configs/kiro/
849
849
  <details>
850
850
  <summary><strong>OMP (Oh My Pi)</strong> — plugin with full hook support</summary>
851
851
 
852
- **Prerequisites:** Node.js 18+, Oh My Pi installed.
852
+ **Prerequisites:** Node.js >= 22.5 (or Bun), Oh My Pi installed.
853
853
 
854
854
  **Install — plugin path (recommended):**
855
855
 
@@ -924,7 +924,7 @@ Full configs: [`configs/omp/mcp.json`](configs/omp/mcp.json) | [`configs/omp/SYS
924
924
 
925
925
  Context Mode uses [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) on Node.js, which ships prebuilt native binaries for most platforms. On glibc >= 2.31 systems (Ubuntu 20.04+, Debian 11+, Fedora 34+, macOS, Windows), `npm install` works without any build tools.
926
926
 
927
- **Linux + Node.js >= 22.13:** Context Mode automatically uses the built-in `node:sqlite` module instead of `better-sqlite3`. This eliminates the native addon entirely, avoiding [sporadic SIGSEGV crashes](https://github.com/nodejs/node/issues/62515) caused by V8's `madvise(MADV_DONTNEED)` corrupting the addon's `.got.plt` section on Linux. No configuration needed — detection is automatic. Falls back to `better-sqlite3` on older Node.js versions.
927
+ **Linux + Node.js >= 22.5:** Context Mode automatically uses the built-in `node:sqlite` module instead of `better-sqlite3`. This eliminates the native addon entirely, avoiding [sporadic SIGSEGV crashes](https://github.com/nodejs/node/issues/62515) caused by V8's `madvise(MADV_DONTNEED)` corrupting the addon's `.got.plt` section on Linux. No configuration needed — detection is automatic. **Linux + Node < 22.5 is unsupported** ([#564](https://github.com/mksglu/context-mode/issues/564)) — `npm install` will fail with remediation instructions.
928
928
 
929
929
  **Bun users:** No native compilation needed. Context Mode automatically detects Bun and uses the built-in `bun:sqlite` module via a compatibility adapter. `better-sqlite3` and all its build dependencies are skipped entirely.
930
930
 
@@ -986,7 +986,7 @@ When output exceeds 5 KB and an `intent` is provided, Context Mode switches to i
986
986
 
987
987
  ## How the Knowledge Base Works
988
988
 
989
- The `ctx_index` tool chunks markdown content by headings while keeping code blocks intact, then stores them in a **SQLite FTS5** (Full-Text Search 5) virtual table. The SQLite backend is selected automatically at runtime: `bun:sqlite` on Bun, `node:sqlite` on Linux + Node.js >= 22.13, and `better-sqlite3` everywhere else. Search uses **BM25 ranking** — a probabilistic relevance algorithm that scores documents based on term frequency, inverse document frequency, and document length normalization. **Porter stemming** is applied at index time so "running", "runs", and "ran" match the same stem. Titles and headings are weighted **5x** in BM25 scoring for precise navigational queries.
989
+ The `ctx_index` tool chunks markdown content by headings while keeping code blocks intact, then stores them in a **SQLite FTS5** (Full-Text Search 5) virtual table. The SQLite backend is selected automatically at runtime: `bun:sqlite` on Bun, `node:sqlite` on Node.js >= 22.5, and `better-sqlite3` everywhere else. Search uses **BM25 ranking** — a probabilistic relevance algorithm that scores documents based on term frequency, inverse document frequency, and document length normalization. **Porter stemming** is applied at index time so "running", "runs", and "ran" match the same stem. Titles and headings are weighted **5x** in BM25 scoring for precise navigational queries.
990
990
 
991
991
  When you call `ctx_search`, it returns relevant content snippets focused around matching query terms — not full documents, not approximations, the actual indexed content with smart extraction around what you're looking for. `ctx_fetch_and_index` extends this to URLs: fetch, convert HTML to markdown, chunk, index. The raw page never enters context. Use the `contentType` parameter to filter results by type (e.g. `code` or `prose`).
992
992
 
@@ -1361,6 +1361,17 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
1361
1361
 
1362
1362
  `tool_input` for any `mcp__*` tool call is also redacted before persistence — keys matching `authorization`, `token`, `secret`, `password`, `api_key`, `cookie`, `signature`, `private_key` get masked to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
1363
1363
 
1364
+ ### Lifecycle environment variables
1365
+
1366
+ Two runtime knobs control how MCP server processes self-manage. Defaults are safe — only set these to opt-out of the leak-fix introduced in v1.0.132 ([#565](https://github.com/mksglu/context-mode/issues/565) / [#568](https://github.com/mksglu/context-mode/pull/568)).
1367
+
1368
+ | Variable | Default | Purpose |
1369
+ |---|---|---|
1370
+ | `CONTEXT_MODE_IDLE_TIMEOUT_MS` | `900000` (15 min) | An MCP child self-exits cleanly after this many milliseconds of stdin/request inactivity. Hosts like OpenCode and KiloCode open one MCP child per session and per subagent — without this, idle children accumulate to 25+ processes / 1.6 GB RSS in long-lived shells. Set to `0` to disable self-shutdown (rarely needed; useful only for daemons that must outlive their parent). |
1371
+ | `CONTEXT_MODE_STARTUP_SWEEP` | `1` (enabled) | At boot, a newly-spawned MCP child reaps any other context-mode MCP server pids that share its parent process (`sameParentOnly: true` — never touches MCP children of a different host). This reclaims accumulated siblings immediately instead of waiting for each idle timer to fire. Set to `0` or `false` to disable (useful when you intentionally want multiple concurrent MCP children under the same host, e.g. multi-tenant test runners). |
1372
+
1373
+ Both vars are read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Invalid values (non-numeric `CONTEXT_MODE_IDLE_TIMEOUT_MS`, unrecognized `CONTEXT_MODE_STARTUP_SWEEP`) fall back to defaults silently.
1374
+
1364
1375
  ## Contributing
1365
1376
 
1366
1377
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and TDD guidelines.
package/build/cli.js CHANGED
@@ -28,6 +28,8 @@ import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mc
28
28
  // mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
29
29
  // @ts-expect-error — JS module, no TS declarations
30
30
  import { healPluginJsonMcpServers, healMcpJsonArgs } from "../scripts/heal-installed-plugins.mjs";
31
+ // @ts-expect-error — JS module, no TS declarations
32
+ import { detectWindowsVsYear } from "../scripts/heal-better-sqlite3.mjs";
31
33
  // Private 16-LOC copy of browserOpenArgv. Canonical version lives in src/server.ts;
32
34
  // duplicated here so the cli bundle does not pull server.ts top-level boot side effects.
33
35
  // Keep in sync — pure data, no I/O.
@@ -300,6 +302,34 @@ async function doctor() {
300
302
  s.stop("Diagnostics complete");
301
303
  // Runtime check
302
304
  p.note(getRuntimeSummary(runtimes), "Runtimes");
305
+ // ── Issue #564 — Linux + Node < 22.5 + no Bun is unsafe ────────────
306
+ // V8's madvise(MADV_DONTNEED) can corrupt better-sqlite3's native addon
307
+ // `.got.plt` on Linux, causing sporadic SIGSEGV (1-4/hour). The 22.5
308
+ // gate (`hasModernSqlite()` in src/db-base.ts:226-244) is the contract:
309
+ // at or above it we use node:sqlite (built-in, no native addon, no
310
+ // .got.plt to corrupt); below it we fall through to better-sqlite3
311
+ // which WILL crash. engines.node + a hard-fail postinstall guard this
312
+ // at install time, but doctor() surfaces it for already-installed users
313
+ // (and for adapters whose MCP host swallows stderr during install).
314
+ // Refs:
315
+ // - https://github.com/nodejs/node/issues/62515
316
+ // - https://github.com/mksglu/context-mode/issues/564
317
+ {
318
+ const { hasModernSqlite } = await import("./db-base.js");
319
+ if (process.platform === "linux" &&
320
+ !hasModernSqlite() &&
321
+ !hasBunRuntime()) {
322
+ criticalFails++;
323
+ p.log.error(color.red("Node version: FAIL") +
324
+ ` — Linux + Node ${process.versions.node} is unsafe (SIGSEGV)` +
325
+ color.dim("\n context-mode requires Node.js >= 22.5 (or Bun) on Linux to avoid the" +
326
+ "\n V8 madvise(MADV_DONTNEED) SIGSEGV in better-sqlite3 (1-4/hour)." +
327
+ "\n Refs: https://github.com/nodejs/node/issues/62515" +
328
+ "\n https://github.com/mksglu/context-mode/issues/564" +
329
+ "\n Fix: nvm install 22.5 && nvm use 22.5 && npm install -g context-mode" +
330
+ "\n Or: curl -fsSL https://bun.sh/install | bash && bun add -g context-mode"));
331
+ }
332
+ }
303
333
  // Speed tier
304
334
  if (hasBunRuntime()) {
305
335
  p.log.success(color.green("Performance: FAST") +
@@ -736,10 +766,12 @@ async function upgrade(opts) {
736
766
  catch { /* never block upgrade on discovery/kill failure */ }
737
767
  // Step 2: Install dependencies + build
738
768
  s.start("Installing dependencies & building");
769
+ const vsYear = detectWindowsVsYear();
739
770
  npmExecFile(["install", "--no-audit", "--no-fund"], {
740
771
  cwd: srcDir,
741
772
  stdio: "pipe",
742
773
  timeout: 120000,
774
+ ...(vsYear ? { env: { ...process.env, npm_config_msvs_version: vsYear } } : {}),
743
775
  });
744
776
  npmExecFile(["run", "build"], {
745
777
  cwd: srcDir,
@@ -20,7 +20,54 @@ export interface LifecycleGuardOptions {
20
20
  onShutdown: () => void;
21
21
  /** Injectable parent-alive check (for testing). Default: ppid-based check. */
22
22
  isParentAlive?: () => boolean;
23
+ /**
24
+ * Idle shutdown threshold in ms (#565). When the server has handled no
25
+ * MCP activity for this long, `onShutdown` fires. `0` disables.
26
+ * Default: env `CONTEXT_MODE_IDLE_TIMEOUT_MS`, else 15 minutes.
27
+ * Skipped on TTY stdin (interactive dev / OpenCode ts-plugin standalone).
28
+ *
29
+ * Pair with the returned `recordActivity()` callback — call it on every
30
+ * MCP request the server handles so genuinely busy servers never trip.
31
+ */
32
+ idleTimeoutMs?: number;
33
+ /** Test injection — defaults to `Date.now`. */
34
+ now?: () => number;
23
35
  }
36
+ /**
37
+ * Hybrid return type: callable like the original `() => void` cleanup (kept
38
+ * for backwards compatibility with #103/#236/#311/#388/#534 test suites),
39
+ * and additionally exposes `recordActivity` for the idle-timeout path (#565)
40
+ * and `stop` as an explicit alias.
41
+ */
42
+ export interface LifecycleGuardHandle {
43
+ /** Stop the guard. Calling the handle directly is equivalent. */
44
+ (): void;
45
+ /** Bumps the "last activity" timestamp so the idle timer doesn't fire. */
46
+ recordActivity: () => void;
47
+ /** Stop the guard. Alias for invoking the handle. */
48
+ stop: () => void;
49
+ }
50
+ /**
51
+ * Resolve the idle-shutdown threshold (#565).
52
+ *
53
+ * OpenCode + KiloCode open a fresh MCP client per session AND per subagent
54
+ * task, but never tear them down for the host's lifetime. A host alive for
55
+ * a working day accumulates one stdio child per session — observed live at
56
+ * 26 children / 1.6 GB RSS under a single `opencode serve` parent.
57
+ *
58
+ * None of the existing exit paths (ppid poll, grandparent reparent, stdin
59
+ * EOF, SIGTERM) fire while the host stays alive. Idle shutdown is the
60
+ * structural fix: a server with no work to do should release its memory.
61
+ *
62
+ * Default 15 min strikes a balance — long enough that a paused
63
+ * conversation does not pay a cold-start on every resume, short enough
64
+ * that 8 hours of unused sessions do not pin GB of RAM.
65
+ *
66
+ * Set env to `0` to disable entirely.
67
+ *
68
+ * Exported for unit-testing.
69
+ */
70
+ export declare function idleTimeoutForEnv(env?: NodeJS.ProcessEnv): number;
24
71
  /** Injectable dependencies for {@link makeDefaultIsParentAlive}. */
25
72
  export interface IsParentAliveDeps {
26
73
  /** Read the current ppid. Default: `() => process.ppid`. */
@@ -60,7 +107,9 @@ export declare function makeDefaultIsParentAlive(deps?: IsParentAliveDeps): () =
60
107
  */
61
108
  export declare function lifecycleGuardIntervalForEnv(env?: NodeJS.ProcessEnv): number;
62
109
  /**
63
- * Start the lifecycle guard. Returns a cleanup function.
110
+ * Start the lifecycle guard. Returns a handle with `recordActivity` (call
111
+ * on every MCP request to keep idle timer from firing) and `stop`.
112
+ *
64
113
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
65
114
  */
66
- export declare function startLifecycleGuard(opts: LifecycleGuardOptions): () => void;
115
+ export declare function startLifecycleGuard(opts: LifecycleGuardOptions): LifecycleGuardHandle;
@@ -14,6 +14,35 @@
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
+ * OpenCode + KiloCode open a fresh MCP client per session AND per subagent
21
+ * task, but never tear them down for the host's lifetime. A host alive for
22
+ * a working day accumulates one stdio child per session — observed live at
23
+ * 26 children / 1.6 GB RSS under a single `opencode serve` parent.
24
+ *
25
+ * None of the existing exit paths (ppid poll, grandparent reparent, stdin
26
+ * EOF, SIGTERM) fire while the host stays alive. Idle shutdown is the
27
+ * structural fix: a server with no work to do should release its memory.
28
+ *
29
+ * Default 15 min strikes a balance — long enough that a paused
30
+ * conversation does not pay a cold-start on every resume, short enough
31
+ * that 8 hours of unused sessions do not pin GB of RAM.
32
+ *
33
+ * Set env to `0` to disable entirely.
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 15 * 60 * 1000;
41
+ const n = Number.parseInt(raw, 10);
42
+ if (!Number.isFinite(n) || n < 0)
43
+ return 15 * 60 * 1000;
44
+ return n;
45
+ }
17
46
  /** Read grandparent PID via `ps -o ppid= -p $PPID`. Returns NaN on failure or Windows. */
18
47
  function readGrandparentPpidImpl() {
19
48
  if (process.platform === "win32")
@@ -95,25 +124,52 @@ export function lifecycleGuardIntervalForEnv(env = process.env) {
95
124
  return 1000;
96
125
  }
97
126
  /**
98
- * Start the lifecycle guard. Returns a cleanup function.
127
+ * Start the lifecycle guard. Returns a handle with `recordActivity` (call
128
+ * on every MCP request to keep idle timer from firing) and `stop`.
129
+ *
99
130
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
100
131
  */
101
132
  export function startLifecycleGuard(opts) {
102
133
  const interval = opts.checkIntervalMs ?? lifecycleGuardIntervalForEnv();
103
134
  const check = opts.isParentAlive ?? defaultIsParentAlive;
135
+ const idleTimeoutMs = opts.idleTimeoutMs ?? idleTimeoutForEnv();
136
+ const now = opts.now ?? Date.now;
104
137
  let stopped = false;
138
+ let lastActivity = now();
105
139
  const shutdown = () => {
106
140
  if (stopped)
107
141
  return;
108
142
  stopped = true;
109
143
  opts.onShutdown();
110
144
  };
111
- // P0: Periodic parent liveness check
145
+ const recordActivity = () => {
146
+ lastActivity = now();
147
+ };
148
+ // P0: Periodic parent liveness check.
112
149
  const timer = setInterval(() => {
113
150
  if (!check())
114
151
  shutdown();
115
152
  }, interval);
116
153
  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
+ }
117
173
  // P0: OS signals — terminal close, kill, ctrl+c
118
174
  const signals = ["SIGTERM", "SIGINT"];
119
175
  if (process.platform !== "win32")
@@ -142,11 +198,19 @@ export function startLifecycleGuard(opts) {
142
198
  if (!process.stdin.isTTY) {
143
199
  process.stdin.on("end", onStdinEnd);
144
200
  }
145
- return () => {
201
+ const cleanup = () => {
146
202
  stopped = true;
147
203
  clearInterval(timer);
204
+ if (idleTimer)
205
+ clearInterval(idleTimer);
148
206
  for (const sig of signals)
149
207
  process.removeListener(sig, shutdown);
150
208
  process.stdin.removeListener("end", onStdinEnd);
151
209
  };
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;
152
216
  }
package/build/server.js CHANGED
@@ -78,6 +78,20 @@ const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`);
78
78
  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`);
79
79
  // Lazy singleton — no DB overhead unless index/search is used
80
80
  let _store = null;
81
+ /**
82
+ * Build the FK-attribution object passed to every ContentStore.index*() call
83
+ * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
84
+ * the current session — eventId stays undefined because MCP tool invocations
85
+ * are not paired with PostToolUse event rows at index time (the hook fires
86
+ * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
87
+ * legacy unattributed rows readable.
88
+ */
89
+ function currentAttribution() {
90
+ const sessionId = process.env.CLAUDE_SESSION_ID;
91
+ if (!sessionId)
92
+ return undefined;
93
+ return { sessionId };
94
+ }
81
95
  /**
82
96
  * Auto-index session events files written by SessionStart hook.
83
97
  * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
@@ -95,7 +109,7 @@ function maybeIndexSessionEvents(store) {
95
109
  for (const file of files) {
96
110
  const filePath = join(sessionsDir, file);
97
111
  try {
98
- store.index({ path: filePath, source: "session-events" });
112
+ store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
99
113
  unlinkSync(filePath);
100
114
  }
101
115
  catch { /* best-effort per file */ }
@@ -1153,7 +1167,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
1153
1167
  function indexStdout(stdout, source) {
1154
1168
  const store = getStore();
1155
1169
  trackIndexed(Buffer.byteLength(stdout));
1156
- const indexed = store.index({ content: stdout, source });
1170
+ const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
1157
1171
  return {
1158
1172
  content: [
1159
1173
  {
@@ -1173,7 +1187,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
1173
1187
  const totalBytes = Buffer.byteLength(stdout);
1174
1188
  // Index into the PERSISTENT store so user can ctx_search() later
1175
1189
  const persistent = getStore();
1176
- const indexed = persistent.indexPlainText(stdout, source);
1190
+ const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution());
1177
1191
  // Search the persistent store directly (porter → trigram → fuzzy)
1178
1192
  let results = persistent.searchWithFallback(intent, maxResults, source);
1179
1193
  // Extract distinctive terms as vocabulary hints for the LLM
@@ -1407,7 +1421,7 @@ server.registerTool("ctx_index", {
1407
1421
  catch { /* ignore — file read errors handled by store */ }
1408
1422
  }
1409
1423
  const store = getStore();
1410
- const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath });
1424
+ const result = store.index({ content, path: resolvedPath, source: source ?? resolvedPath, attribution: currentAttribution() });
1411
1425
  return trackResponse("ctx_index", {
1412
1426
  content: [
1413
1427
  {
@@ -2145,15 +2159,16 @@ function indexFetched(f) {
2145
2159
  // `source` label do not overwrite each other (commit 1f1243e). ctx_search()
2146
2160
  // still finds both via LIKE-mode source filter on the `source` substring.
2147
2161
  const storageLabel = composeFetchCacheKey(f.source, f.url);
2162
+ const attribution = currentAttribution();
2148
2163
  let indexed;
2149
2164
  if (f.header === "__CM_CT__:json") {
2150
- indexed = store.indexJSON(f.markdown, storageLabel);
2165
+ indexed = store.indexJSON(f.markdown, storageLabel, undefined, attribution);
2151
2166
  }
2152
2167
  else if (f.header === "__CM_CT__:text") {
2153
- indexed = store.indexPlainText(f.markdown, storageLabel);
2168
+ indexed = store.indexPlainText(f.markdown, storageLabel, undefined, attribution);
2154
2169
  }
2155
2170
  else {
2156
- indexed = store.index({ content: f.markdown, source: storageLabel });
2171
+ indexed = store.index({ content: f.markdown, source: storageLabel, attribution });
2157
2172
  }
2158
2173
  // Track AFTER the FTS5 write succeeds — failed indexes shouldn't inflate the counter.
2159
2174
  trackIndexed(Buffer.byteLength(f.markdown));
@@ -2460,7 +2475,7 @@ server.registerTool("ctx_batch_execute", {
2460
2475
  .map((c) => c.label)
2461
2476
  .join(",")
2462
2477
  .slice(0, 80)}`;
2463
- const indexed = store.index({ content: stdout, source });
2478
+ const indexed = store.index({ content: stdout, source, attribution: currentAttribution() });
2464
2479
  // Build section inventory — direct query by source_id (no FTS5 MATCH needed)
2465
2480
  const allSections = store.getChunksBySource(indexed.sourceId);
2466
2481
  const inventory = ["## Indexed Sections", ""];
@@ -2865,7 +2880,12 @@ server.registerTool("ctx_upgrade", {
2865
2880
  // files (events.md, FTS5 store file, stats file) are preserved.
2866
2881
  // Passing both sessionId AND scope:"project" is ambiguous (does the
2867
2882
  // caller want a per-session wipe or a project-wide one?) and is
2868
- // rejected by the schema's refine().
2883
+ // rejected by an explicit check in the handler body — NOT a schema-level
2884
+ // .refine(). MCP SDK's normalizeObjectSchema() reads `.shape` to project
2885
+ // inputSchema → JSON Schema for tools/list; a ZodEffects (refine wrapper)
2886
+ // has no `.shape`, so the SDK silently emits `properties: {}`, and Claude
2887
+ // Code's strict-input-validation gate then rejects EVERY call to this
2888
+ // tool with "input_schema does not support fields". Issue #563.
2869
2889
  server.registerTool("ctx_purge", {
2870
2890
  title: "Purge Knowledge Base",
2871
2891
  description: "DESTRUCTIVE — permanently delete indexed content. CANNOT be undone.\n\n" +
@@ -2886,6 +2906,9 @@ server.registerTool("ctx_purge", {
2886
2906
  "Use sessionId when the user asks to clear a specific conversation's data.\n" +
2887
2907
  "Use scope:'project' ONLY when the user explicitly asks to reset everything.\n" +
2888
2908
  "NEVER call with bare {confirm:true} — always specify the scope.",
2909
+ // NOTE: schema MUST be a plain z.object — no .refine()/.transform()/
2910
+ // .superRefine() wrapper. See block comment above & issue #563. The
2911
+ // cross-field ambiguity check lives in the handler body below.
2889
2912
  inputSchema: z.object({
2890
2913
  confirm: z.boolean().describe("MUST be true. Destructive operation; false returns 'purge cancelled'."),
2891
2914
  sessionId: z.string().optional().describe("UUID of a single session. Pairs with confirm:true to wipe only that " +
@@ -2894,12 +2917,22 @@ server.registerTool("ctx_purge", {
2894
2917
  scope: z.enum(["session", "project"]).optional().describe("Explicit scope selector. 'session' REQUIRES sessionId. 'project' wipes " +
2895
2918
  "the entire project (FTS5 + every session + stats). Omit only for the " +
2896
2919
  "deprecated bare-{confirm:true} back-compat path."),
2897
- }).refine((v) => !(v.sessionId && v.scope === "project"), {
2898
- message: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
2899
- "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
2900
- path: ["scope"],
2901
2920
  }),
2902
2921
  }, async ({ confirm, sessionId, scope }) => {
2922
+ // Cross-field ambiguity check — formerly a schema .refine(), moved
2923
+ // into the handler so the inputSchema stays a plain ZodObject and
2924
+ // the MCP SDK can serialize `.shape` into JSON Schema (issue #563).
2925
+ // Same human-readable message as the original refine() preserved.
2926
+ if (sessionId && scope === "project") {
2927
+ return trackResponse("ctx_purge", {
2928
+ content: [{
2929
+ type: "text",
2930
+ text: "Ambiguous purge: sessionId implies scope:'session', cannot combine with scope:'project'. " +
2931
+ "Use scope:'project' WITHOUT sessionId for the legacy whole-project wipe.",
2932
+ }],
2933
+ isError: true,
2934
+ });
2935
+ }
2903
2936
  if (!confirm) {
2904
2937
  return trackResponse("ctx_purge", {
2905
2938
  content: [{
@@ -3365,6 +3398,20 @@ server.registerTool("ctx_insight", {
3365
3398
  // Server startup
3366
3399
  // ─────────────────────────────────────────────────────────
3367
3400
  async function main() {
3401
+ // Startup sibling sweep (#565). OpenCode/KiloCode spawn one MCP child
3402
+ // per session/subagent and never reap them. When a new MCP child boots
3403
+ // under a host that already has N stale idle siblings (sharing OUR
3404
+ // ppid), reclaim them before opening our own DB / sentinel / stdio.
3405
+ // Best effort — never blocks startup.
3406
+ try {
3407
+ const { startupSiblingSweep } = await import("./util/sibling-mcp.js");
3408
+ const report = await startupSiblingSweep();
3409
+ if (report.totalKilled > 0) {
3410
+ console.error(`Reaped ${report.totalKilled} stale sibling MCP server(s) ` +
3411
+ `(SIGTERM: ${report.terminatedBySigterm}, SIGKILL: ${report.terminatedBySigkill})`);
3412
+ }
3413
+ }
3414
+ catch { /* best effort */ }
3368
3415
  // Clean up stale DB files from previous sessions
3369
3416
  const cleaned = cleanupStaleDBs();
3370
3417
  if (cleaned > 0) {
@@ -3414,7 +3461,38 @@ async function main() {
3414
3461
  process.on("SIGINT", () => { gracefulShutdown(); });
3415
3462
  process.on("SIGTERM", () => { gracefulShutdown(); });
3416
3463
  // Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
3417
- startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
3464
+ // Also: idle self-shutdown (#565) — OpenCode/KiloCode open one MCP child per
3465
+ // session AND per subagent and never tear them down for the host's lifetime,
3466
+ // accumulating one stdio child per session (observed: 26 children / 1.6 GB
3467
+ // RSS under a single `opencode serve` parent). Idle timeout reaps quiescent
3468
+ // servers; live ones bump `recordActivity()` on every JSON-RPC request via
3469
+ // the MCP SDK's `_onrequest` hook wrapped below.
3470
+ const lifecycle = startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
3471
+ // Wrap the SDK's internal request entry so every JSON-RPC `tools/call`,
3472
+ // `tools/list`, etc. resets the idle timer. We intercept at this layer
3473
+ // rather than per-tool because (a) it covers ALL requests, including
3474
+ // listTools / listPrompts / listResources / ping, and (b) it survives
3475
+ // future tool additions without each handler needing to remember to opt in.
3476
+ //
3477
+ // The cast is necessary because `_onrequest` is intentionally undocumented
3478
+ // in the SDK's public types. Best effort — if the field shape changes in
3479
+ // a future SDK release the lifecycle still works, idle reset just degrades
3480
+ // to "untriggered" which simply means the server lives until the next
3481
+ // ppid/signal-based exit path fires. We never block the request path.
3482
+ try {
3483
+ const inner = server.server;
3484
+ const origOnRequest = inner._onrequest;
3485
+ if (typeof origOnRequest === "function") {
3486
+ inner._onrequest = function (...args) {
3487
+ try {
3488
+ lifecycle.recordActivity();
3489
+ }
3490
+ catch { /* never break request path */ }
3491
+ return origOnRequest.apply(this, args);
3492
+ };
3493
+ }
3494
+ }
3495
+ catch { /* best effort — see comment above */ }
3418
3496
  const transport = new StdioServerTransport();
3419
3497
  await server.connect(transport);
3420
3498
  // Write MCP readiness sentinel (#230)