context-mode 1.0.136 → 1.0.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +9 -24
  7. package/build/adapters/codex/index.js +24 -3
  8. package/build/adapters/jetbrains-copilot/hooks.d.ts +11 -3
  9. package/build/adapters/jetbrains-copilot/hooks.js +11 -7
  10. package/build/adapters/opencode/index.d.ts +1 -0
  11. package/build/adapters/opencode/index.js +25 -0
  12. package/build/adapters/opencode/plugin.d.ts +22 -0
  13. package/build/adapters/opencode/plugin.js +52 -0
  14. package/build/adapters/pi/extension.js +20 -4
  15. package/build/adapters/pi/mcp-bridge.d.ts +2 -1
  16. package/build/adapters/pi/mcp-bridge.js +49 -3
  17. package/build/adapters/vscode-copilot/hooks.d.ts +27 -3
  18. package/build/adapters/vscode-copilot/hooks.js +27 -12
  19. package/build/cli.js +199 -32
  20. package/build/lifecycle.d.ts +2 -51
  21. package/build/lifecycle.js +3 -67
  22. package/build/openclaw-plugin.d.ts +130 -0
  23. package/build/openclaw-plugin.js +626 -0
  24. package/build/opencode-plugin.d.ts +122 -0
  25. package/build/opencode-plugin.js +372 -0
  26. package/build/pi-extension.d.ts +14 -0
  27. package/build/pi-extension.js +451 -0
  28. package/build/server.d.ts +19 -0
  29. package/build/server.js +145 -59
  30. package/build/session/db.d.ts +6 -0
  31. package/build/session/db.js +17 -3
  32. package/build/util/db-lock.d.ts +65 -0
  33. package/build/util/db-lock.js +166 -0
  34. package/build/util/sibling-mcp.d.ts +0 -40
  35. package/build/util/sibling-mcp.js +11 -116
  36. package/cli.bundle.mjs +181 -166
  37. package/configs/kilo/kilo.json +0 -11
  38. package/configs/opencode/opencode.json +0 -11
  39. package/hooks/normalize-hooks.mjs +101 -19
  40. package/hooks/session-db.bundle.mjs +3 -3
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/heal-installed-plugins.mjs +115 -1
  44. package/scripts/postinstall.mjs +16 -18
  45. package/server.bundle.mjs +112 -110
  46. package/start.mjs +11 -14
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
- process.on("unhandledRejection", (err) => {
48
- process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
49
- });
50
- process.on("uncaughtException", (err) => {
51
- process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
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
@@ -2908,7 +3034,10 @@ server.registerTool("ctx_upgrade", {
2908
3034
  `const pkg=JSON.parse(readFileSync(join(T,"package.json"),"utf8"));`,
2909
3035
  `const items=[...(Array.isArray(pkg.files)?pkg.files:[]),"src","package.json"];`,
2910
3036
  `for(const item of items){const from=join(T,item);const to=join(P,item);if(existsSync(from)){rmSync(to,{recursive:true,force:true});cpSync(from,to,{recursive:true,force:true});}}`,
2911
- `writeFileSync(join(P,".mcp.json"),JSON.stringify({mcpServers:{"context-mode":{command:"node",args:["\${CLAUDE_PLUGIN_ROOT}/start.mjs"]}}},null,2)+"\\n");`,
3037
+ // Issue #609: do NOT write .mcp.json into the cache dir. Claude Code reads
3038
+ // .claude-plugin/plugin.json.mcpServers as the canonical MCP source — the
3039
+ // per-version .mcp.json file is a stale-write vector. Same architectural
3040
+ // fix as the cli.ts upgrade() path; both writers were the only producers.
2912
3041
  `console.log("- [x] Copied package files");`,
2913
3042
  `execFileSync(process.platform==="win32"?"npm.cmd":"npm",["install","--production"],{cwd:P,stdio:"inherit",shell:process.platform==="win32"});`,
2914
3043
  `console.log("- [x] Installed production dependencies");`,
@@ -3480,20 +3609,6 @@ server.registerTool("ctx_insight", {
3480
3609
  // Server startup
3481
3610
  // ─────────────────────────────────────────────────────────
3482
3611
  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
3612
  // Clean up stale DB files from previous sessions
3498
3613
  const cleaned = cleanupStaleDBs();
3499
3614
  if (cleaned > 0) {
@@ -3543,38 +3658,7 @@ async function main() {
3543
3658
  process.on("SIGINT", () => { gracefulShutdown(); });
3544
3659
  process.on("SIGTERM", () => { gracefulShutdown(); });
3545
3660
  // Lifecycle guard: detect parent death + stdin close to prevent orphaned processes (#103)
3546
- // Also: idle self-shutdown (#565) — OpenCode/KiloCode open one MCP child per
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 */ }
3661
+ startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
3578
3662
  const transport = new StdioServerTransport();
3579
3663
  await server.connect(transport);
3580
3664
  // Write MCP readiness sentinel (#230)
@@ -3642,7 +3726,9 @@ async function main() {
3642
3726
  }
3643
3727
  }
3644
3728
  }
3645
- main().catch((err) => {
3646
- console.error("Fatal:", err);
3647
- process.exit(1);
3648
- });
3729
+ if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
3730
+ main().catch((err) => {
3731
+ console.error("Fatal:", err);
3732
+ process.exit(1);
3733
+ });
3734
+ }
@@ -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
  *
@@ -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
- ?? "").trim();
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
  *
@@ -0,0 +1,65 @@
1
+ /**
2
+ * db-lock — Per-DB lockfile primitive for single-writer enforcement (#560).
3
+ *
4
+ * Issue #560: multiple context-mode MCP servers writing the same on-disk
5
+ * SQLite content store unbounded the WAL — readers held shared locks
6
+ * indefinitely so `wal_checkpoint(TRUNCATE)` never fired, and the only
7
+ * existing truncation path is `closeDB`'s checkpoint on graceful exit
8
+ * (which #559's zombie servers never reach). Result: 238MB+ WAL files
9
+ * and ctx_search hangs.
10
+ *
11
+ * This module provides a tiny atomic-write primitive sitting in front of
12
+ * `new Database(...)`. The first opener writes its PID into
13
+ * `<dbPath>.lock` via O_EXCL (`flag: 'wx'`). Subsequent openers either:
14
+ *
15
+ * - find the lockfile + see the PID is alive → throw
16
+ * DatabaseLockedError with the reporter's verbatim message;
17
+ * - find the lockfile + see the PID is dead → claim it, with a re-read
18
+ * check to resolve a same-instant race between two stale-claimers.
19
+ *
20
+ * The lockfile is the PRIMARY single-writer defense. The SQLiteBase ctor
21
+ * also applies `locking_mode = EXCLUSIVE` as a SECONDARY defense
22
+ * (belt-and-braces) — the lockfile owns the user-facing UX, EXCLUSIVE
23
+ * catches the narrow race window between the lockfile check and the
24
+ * actual `Database(...)` open.
25
+ *
26
+ * Per-process tmp DBs (those under `os.tmpdir()`) skip the lockfile
27
+ * entirely — those are the existing `defaultDBPath()` shape and embed
28
+ * `process.pid` already, so cross-instance contention is impossible.
29
+ *
30
+ * `isProcessAlive` is COPIED from `store.ts:187` — not imported — to
31
+ * keep `db-base.ts` (which imports this module) free of any dependency
32
+ * on `store.ts` (which itself imports from `db-base.ts`). See
33
+ * PR-559-560-FIX-DESIGN.md regression risks #4.
34
+ */
35
+ /** User-facing failure used by SQLiteBase to surface the contention. */
36
+ export declare class DatabaseLockedError extends Error {
37
+ readonly pid: number;
38
+ readonly dbPath: string;
39
+ constructor(pid: number, dbPath: string);
40
+ }
41
+ export interface AcquireOptions {
42
+ dbPath: string;
43
+ }
44
+ export interface AcquireResult {
45
+ /** True when the lockfile was skipped because dbPath is under tmpdir. */
46
+ skipped: boolean;
47
+ }
48
+ /**
49
+ * Atomically claim the lockfile for `dbPath`. Throws `DatabaseLockedError`
50
+ * if another live process holds it. Silently claims stale lockfiles whose
51
+ * owning PID is dead.
52
+ */
53
+ export declare function acquireDbLock(opts: AcquireOptions): AcquireResult;
54
+ export interface ReleaseOptions {
55
+ dbPath: string;
56
+ }
57
+ /**
58
+ * Drop the lockfile for `dbPath`. Swallows all errors so callers can
59
+ * always invoke this in a finally / cleanup path without try/catch —
60
+ * mirrors the shape of `db-base.ts closeDB`.
61
+ *
62
+ * Skipped (no-op) when `dbPath` is under tmpdir — symmetric with
63
+ * `acquireDbLock`'s skip-gate.
64
+ */
65
+ export declare function releaseDbLock(opts: ReleaseOptions): void;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * db-lock — Per-DB lockfile primitive for single-writer enforcement (#560).
3
+ *
4
+ * Issue #560: multiple context-mode MCP servers writing the same on-disk
5
+ * SQLite content store unbounded the WAL — readers held shared locks
6
+ * indefinitely so `wal_checkpoint(TRUNCATE)` never fired, and the only
7
+ * existing truncation path is `closeDB`'s checkpoint on graceful exit
8
+ * (which #559's zombie servers never reach). Result: 238MB+ WAL files
9
+ * and ctx_search hangs.
10
+ *
11
+ * This module provides a tiny atomic-write primitive sitting in front of
12
+ * `new Database(...)`. The first opener writes its PID into
13
+ * `<dbPath>.lock` via O_EXCL (`flag: 'wx'`). Subsequent openers either:
14
+ *
15
+ * - find the lockfile + see the PID is alive → throw
16
+ * DatabaseLockedError with the reporter's verbatim message;
17
+ * - find the lockfile + see the PID is dead → claim it, with a re-read
18
+ * check to resolve a same-instant race between two stale-claimers.
19
+ *
20
+ * The lockfile is the PRIMARY single-writer defense. The SQLiteBase ctor
21
+ * also applies `locking_mode = EXCLUSIVE` as a SECONDARY defense
22
+ * (belt-and-braces) — the lockfile owns the user-facing UX, EXCLUSIVE
23
+ * catches the narrow race window between the lockfile check and the
24
+ * actual `Database(...)` open.
25
+ *
26
+ * Per-process tmp DBs (those under `os.tmpdir()`) skip the lockfile
27
+ * entirely — those are the existing `defaultDBPath()` shape and embed
28
+ * `process.pid` already, so cross-instance contention is impossible.
29
+ *
30
+ * `isProcessAlive` is COPIED from `store.ts:187` — not imported — to
31
+ * keep `db-base.ts` (which imports this module) free of any dependency
32
+ * on `store.ts` (which itself imports from `db-base.ts`). See
33
+ * PR-559-560-FIX-DESIGN.md regression risks #4.
34
+ */
35
+ import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ /** User-facing failure used by SQLiteBase to surface the contention. */
38
+ export class DatabaseLockedError extends Error {
39
+ pid;
40
+ dbPath;
41
+ constructor(pid, dbPath) {
42
+ super(`Another context-mode server is already running (PID: ${pid}). ` +
43
+ `Stop it before starting a new instance.`);
44
+ this.name = "DatabaseLockedError";
45
+ this.pid = pid;
46
+ this.dbPath = dbPath;
47
+ }
48
+ }
49
+ /**
50
+ * Liveness probe — a 6-line copy of `store.ts:187 isProcessAlive`.
51
+ * Sends signal 0 (no-op kill) which only verifies that the kernel
52
+ * recognizes the PID + that the caller has permission to signal it.
53
+ *
54
+ * Copied (not imported) so this module stays leaf-level and `db-base.ts`
55
+ * does not pick up a transitive dependency on `store.ts` — `store.ts`
56
+ * already imports from `db-base.ts`, so the reverse would create a
57
+ * circular dep that breaks under bun:sqlite's lazy load path.
58
+ */
59
+ function isProcessAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function lockPathFor(dbPath) {
69
+ return `${dbPath}.lock`;
70
+ }
71
+ /**
72
+ * tmpdir skip-gate — per-process DBs (e.g. defaultDBPath() output) embed
73
+ * `process.pid` so cross-instance contention is impossible by
74
+ * construction. We never want to install a lockfile on the test runner's
75
+ * tmp scratch path either.
76
+ */
77
+ function isUnderTmpdir(dbPath) {
78
+ // Trailing-slash normalize — tmpdir() may or may not include it on the
79
+ // current platform, and dbPath may be exactly tmpdir() when callers
80
+ // join() with no separator (rare but cheap to guard).
81
+ const tmp = tmpdir();
82
+ return dbPath === tmp || dbPath.startsWith(tmp + "/") || dbPath.startsWith(tmp + "\\");
83
+ }
84
+ /**
85
+ * Atomically claim the lockfile for `dbPath`. Throws `DatabaseLockedError`
86
+ * if another live process holds it. Silently claims stale lockfiles whose
87
+ * owning PID is dead.
88
+ */
89
+ export function acquireDbLock(opts) {
90
+ const { dbPath } = opts;
91
+ if (isUnderTmpdir(dbPath))
92
+ return { skipped: true };
93
+ const lockPath = lockPathFor(dbPath);
94
+ const ownPid = String(process.pid);
95
+ // Fast path: O_EXCL atomic create — succeeds iff the lockfile did not
96
+ // exist. This is the single race-free moment that grants ownership.
97
+ try {
98
+ writeFileSync(lockPath, ownPid, { flag: "wx" });
99
+ return { skipped: false };
100
+ }
101
+ catch (err) {
102
+ const code = err?.code;
103
+ if (code !== "EEXIST")
104
+ throw err;
105
+ // Fall through to liveness check.
106
+ }
107
+ // Slow path: lockfile exists. Read the PID, probe liveness.
108
+ let existingPidStr;
109
+ try {
110
+ existingPidStr = readFileSync(lockPath, "utf-8").trim();
111
+ }
112
+ catch {
113
+ // Lockfile vanished between EEXIST and read — race won by another
114
+ // claimer that already finished cleanup. Retry once via the fast
115
+ // path; if even that fails, surface as locked (best-effort).
116
+ try {
117
+ writeFileSync(lockPath, ownPid, { flag: "wx" });
118
+ return { skipped: false };
119
+ }
120
+ catch {
121
+ throw new DatabaseLockedError(0, dbPath);
122
+ }
123
+ }
124
+ const existingPid = Number.parseInt(existingPidStr, 10);
125
+ if (Number.isFinite(existingPid) && existingPid > 0 && isProcessAlive(existingPid)) {
126
+ throw new DatabaseLockedError(existingPid, dbPath);
127
+ }
128
+ // Stale lockfile — owning PID is dead (or unparseable). Claim it.
129
+ // We do NOT use { flag: 'wx' } here because we deliberately want to
130
+ // overwrite the dead-PID record. Then re-read to confirm we won the
131
+ // race against any other process also seeing the same stale lock.
132
+ writeFileSync(lockPath, ownPid, { flag: "w" });
133
+ let writtenPid;
134
+ try {
135
+ writtenPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
136
+ }
137
+ catch {
138
+ // Vanished again — extremely unlikely. Surface as locked rather than
139
+ // proceeding with no guarantee.
140
+ throw new DatabaseLockedError(0, dbPath);
141
+ }
142
+ if (writtenPid !== process.pid) {
143
+ // Lost the stale-claim race to another concurrent claimer.
144
+ throw new DatabaseLockedError(writtenPid, dbPath);
145
+ }
146
+ return { skipped: false };
147
+ }
148
+ /**
149
+ * Drop the lockfile for `dbPath`. Swallows all errors so callers can
150
+ * always invoke this in a finally / cleanup path without try/catch —
151
+ * mirrors the shape of `db-base.ts closeDB`.
152
+ *
153
+ * Skipped (no-op) when `dbPath` is under tmpdir — symmetric with
154
+ * `acquireDbLock`'s skip-gate.
155
+ */
156
+ export function releaseDbLock(opts) {
157
+ const { dbPath } = opts;
158
+ if (isUnderTmpdir(dbPath))
159
+ return;
160
+ try {
161
+ unlinkSync(lockPathFor(dbPath));
162
+ }
163
+ catch {
164
+ // Already gone, permission denied, etc. — best-effort.
165
+ }
166
+ }
@@ -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>;