context-mode 1.0.126 → 1.0.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/cli.js +31 -0
- package/build/db-base.js +53 -4
- package/build/server.js +7 -0
- package/build/util/db-lock.d.ts +65 -0
- package/build/util/db-lock.js +166 -0
- package/build/util/project-dir.d.ts +13 -0
- package/build/util/project-dir.js +11 -2
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +131 -131
- package/hooks/core/routing.mjs +114 -22
- package/hooks/gemini-cli/sessionstart.mjs +8 -6
- package/hooks/security.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +2 -2
- package/hooks/sessionstart.mjs +18 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/scripts/plugin-cache-integrity.mjs +101 -21
- package/server.bundle.mjs +92 -92
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.128"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "context-mode",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
16
|
-
"version": "1.0.
|
|
16
|
+
"version": "1.0.128",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Mert Koseoğlu"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.128",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Context Mode",
|
|
4
4
|
"kind": "tool",
|
|
5
5
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.128",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.128",
|
|
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/build/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
22
22
|
import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
|
|
23
23
|
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
24
24
|
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
25
|
+
// v1.0.128 — Issue #559 sibling MCP kill helpers (see PR-559-560-FIX-DESIGN.md).
|
|
26
|
+
import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mcp.js";
|
|
25
27
|
// v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
|
|
26
28
|
// mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
|
|
27
29
|
// @ts-expect-error — JS module, no TS declarations
|
|
@@ -703,6 +705,35 @@ async function upgrade(opts) {
|
|
|
703
705
|
}
|
|
704
706
|
else {
|
|
705
707
|
p.log.info(`Update available: ${color.yellow("v" + localVersion)} → ${color.green("v" + newVersion)}`);
|
|
708
|
+
// v1.0.128 — Issue #559: terminate sibling MCP servers BEFORE installing
|
|
709
|
+
// new files. Historically /ctx-upgrade rsynced new code over the old
|
|
710
|
+
// tree but never signalled the running MCP server, so the previous
|
|
711
|
+
// version stayed alive holding stdio + DB handles. Across enough
|
|
712
|
+
// upgrades users observed 5+ context-mode start.mjs processes pinned
|
|
713
|
+
// to RAM. Discovery + kill must happen before npm install to avoid
|
|
714
|
+
// racing against the EXCLUSIVE lock the new server claims on first
|
|
715
|
+
// ctx_search (see #560 fix). Wrapped in try/catch so a missing pgrep
|
|
716
|
+
// (stripped Linux distro) or unavailable PowerShell (weird Windows)
|
|
717
|
+
// can never block the upgrade itself.
|
|
718
|
+
try {
|
|
719
|
+
const siblingPids = discoverSiblingMcpPids({
|
|
720
|
+
ownPid: process.pid,
|
|
721
|
+
ownPpid: process.ppid,
|
|
722
|
+
});
|
|
723
|
+
if (siblingPids.length > 0) {
|
|
724
|
+
const killReport = await killSiblingMcpServers({ pids: siblingPids });
|
|
725
|
+
if (killReport.totalKilled > 0) {
|
|
726
|
+
// Concise summary only — no PIDs in the user-facing log to keep
|
|
727
|
+
// the line readable. Plural-aware so "1 sibling MCP server" reads
|
|
728
|
+
// naturally alongside "3 sibling MCP servers".
|
|
729
|
+
const noun = killReport.totalKilled === 1
|
|
730
|
+
? "sibling MCP server"
|
|
731
|
+
: "sibling MCP servers";
|
|
732
|
+
p.log.info(color.dim(`Stopped ${killReport.totalKilled} ${noun} (SIGTERM: ${killReport.terminatedBySigterm}, SIGKILL: ${killReport.terminatedBySigkill})`));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch { /* never block upgrade on discovery/kill failure */ }
|
|
706
737
|
// Step 2: Install dependencies + build
|
|
707
738
|
s.start("Installing dependencies & building");
|
|
708
739
|
npmExecFile(["install", "--no-audit", "--no-fund"], {
|
package/build/db-base.js
CHANGED
|
@@ -9,6 +9,12 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { existsSync, unlinkSync, renameSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
// v1.0.128 — Issue #560 single-writer enforcement.
|
|
13
|
+
// Lockfile is the PRIMARY defense (clean UX with conflicting PID),
|
|
14
|
+
// `locking_mode = EXCLUSIVE` (applied in applyWALPragmas below) is the
|
|
15
|
+
// SECONDARY defense for the narrow race window between lockfile claim
|
|
16
|
+
// and the actual `new Database(...)` open. Both skip-gate on tmpdir paths.
|
|
17
|
+
import { acquireDbLock, releaseDbLock } from "./util/db-lock.js";
|
|
12
18
|
// ─────────────────────────────────────────────────────────
|
|
13
19
|
// bun:sqlite adapter (#45)
|
|
14
20
|
// ─────────────────────────────────────────────────────────
|
|
@@ -310,6 +316,18 @@ export function applyWALPragmas(db) {
|
|
|
310
316
|
db.pragma("mmap_size = 268435456");
|
|
311
317
|
}
|
|
312
318
|
catch { /* unsupported runtime */ }
|
|
319
|
+
// v1.0.128 — Issue #560 SECONDARY defense for single-writer enforcement.
|
|
320
|
+
// The .lock file (acquireDbLock in SQLiteBase ctor) is PRIMARY — it
|
|
321
|
+
// surfaces the conflicting PID with a clear UX message. EXCLUSIVE locking
|
|
322
|
+
// closes the narrow race window between lockfile claim + the actual
|
|
323
|
+
// `new Database(...)` open: a parallel process passing the lockfile
|
|
324
|
+
// check would still get SQLITE_BUSY from this pragma. Wrapped in
|
|
325
|
+
// try/catch identical to mmap_size — backends that don't expose
|
|
326
|
+
// locking_mode (or pragma at all) still get the lockfile floor.
|
|
327
|
+
try {
|
|
328
|
+
db.pragma("locking_mode = EXCLUSIVE");
|
|
329
|
+
}
|
|
330
|
+
catch { /* unsupported runtime */ }
|
|
313
331
|
}
|
|
314
332
|
// ─────────────────────────────────────────────────────────
|
|
315
333
|
// DB file helpers
|
|
@@ -450,14 +468,26 @@ export function renameCorruptDB(dbPath) {
|
|
|
450
468
|
* re-imports within the same fork process (ESM isolate mode clears
|
|
451
469
|
* module-level state but globalThis persists).
|
|
452
470
|
*/
|
|
453
|
-
|
|
471
|
+
// v1.0.128 — symbol name versioned because the value type changed from
|
|
472
|
+
// Set<DatabaseInstance> to Map<DatabaseInstance, string> (issue #560).
|
|
473
|
+
// A persistent global slot from a pre-v128 module would deserialize as
|
|
474
|
+
// the wrong shape and crash the exit hook iteration.
|
|
475
|
+
const _kLiveDBs = Symbol.for("__context_mode_live_dbs_v2__");
|
|
476
|
+
// v1.0.128 — issue #560: pair each DatabaseInstance with the dbPath that
|
|
477
|
+
// owns its lockfile. The exit hook needs both — closeDB(db) handles the
|
|
478
|
+
// WAL checkpoint, releaseDbLock(dbPath) drops the .lock file. We use a
|
|
479
|
+
// Map keyed by DatabaseInstance to keep API call sites unchanged.
|
|
454
480
|
const _liveDBs = (() => {
|
|
455
481
|
const g = globalThis;
|
|
456
482
|
if (!g[_kLiveDBs]) {
|
|
457
|
-
g[_kLiveDBs] = new
|
|
483
|
+
g[_kLiveDBs] = new Map();
|
|
458
484
|
process.on("exit", () => {
|
|
459
|
-
for (const db of g[_kLiveDBs]) {
|
|
485
|
+
for (const [db, dbPath] of g[_kLiveDBs]) {
|
|
460
486
|
closeDB(db);
|
|
487
|
+
// Release lock AFTER close so the WAL checkpoint inside closeDB
|
|
488
|
+
// runs while we still own the writer slot (no second-opener can
|
|
489
|
+
// race in mid-checkpoint).
|
|
490
|
+
releaseDbLock({ dbPath });
|
|
461
491
|
}
|
|
462
492
|
g[_kLiveDBs].clear();
|
|
463
493
|
});
|
|
@@ -470,6 +500,13 @@ export class SQLiteBase {
|
|
|
470
500
|
constructor(dbPath) {
|
|
471
501
|
const Database = loadDatabase();
|
|
472
502
|
this.#dbPath = dbPath;
|
|
503
|
+
// v1.0.128 — Issue #560 PRIMARY single-writer guard. Must claim
|
|
504
|
+
// BEFORE `new Database(...)` so a contending opener gets the clean
|
|
505
|
+
// DatabaseLockedError UX (PID + verbatim message) instead of the
|
|
506
|
+
// SQLITE_BUSY surfaced by EXCLUSIVE locking. Skip-gate via
|
|
507
|
+
// tmpdir-prefix check inside the helper — defaultDBPath() output
|
|
508
|
+
// (per-process tmp DBs) does not contend, so it never claims a lock.
|
|
509
|
+
acquireDbLock({ dbPath });
|
|
473
510
|
cleanOrphanedWALFiles(dbPath);
|
|
474
511
|
let db;
|
|
475
512
|
try {
|
|
@@ -486,15 +523,19 @@ export class SQLiteBase {
|
|
|
486
523
|
applyWALPragmas(db);
|
|
487
524
|
}
|
|
488
525
|
catch (retryErr) {
|
|
526
|
+
// Free the lock before bubbling — caller can never reach
|
|
527
|
+
// close()/cleanup() if the ctor throws.
|
|
528
|
+
releaseDbLock({ dbPath });
|
|
489
529
|
throw new Error(`Failed to create fresh DB after renaming corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
490
530
|
}
|
|
491
531
|
}
|
|
492
532
|
else {
|
|
533
|
+
releaseDbLock({ dbPath });
|
|
493
534
|
throw err;
|
|
494
535
|
}
|
|
495
536
|
}
|
|
496
537
|
this.#db = db;
|
|
497
|
-
_liveDBs.
|
|
538
|
+
_liveDBs.set(this.#db, dbPath);
|
|
498
539
|
this.initSchema();
|
|
499
540
|
this.prepareStatements();
|
|
500
541
|
}
|
|
@@ -510,6 +551,10 @@ export class SQLiteBase {
|
|
|
510
551
|
close() {
|
|
511
552
|
_liveDBs.delete(this.#db);
|
|
512
553
|
closeDB(this.#db);
|
|
554
|
+
// v1.0.128 — Issue #560: drop the .lock file AFTER closeDB so the
|
|
555
|
+
// WAL checkpoint inside closeDB completes while we still own the
|
|
556
|
+
// writer slot. releaseDbLock is no-op for tmpdir paths (skip-gate).
|
|
557
|
+
releaseDbLock({ dbPath: this.#dbPath });
|
|
513
558
|
}
|
|
514
559
|
withRetry(fn) {
|
|
515
560
|
return withRetry(fn);
|
|
@@ -522,5 +567,9 @@ export class SQLiteBase {
|
|
|
522
567
|
_liveDBs.delete(this.#db);
|
|
523
568
|
closeDB(this.#db);
|
|
524
569
|
deleteDBFiles(this.#dbPath);
|
|
570
|
+
// v1.0.128 — Issue #560: also drop the lockfile during cleanup. Per-
|
|
571
|
+
// process tmp DBs (defaultDBPath()) skip-gate inside the helper, so
|
|
572
|
+
// this is only a side-effect for shared on-disk content stores.
|
|
573
|
+
releaseDbLock({ dbPath: this.#dbPath });
|
|
525
574
|
}
|
|
526
575
|
}
|
package/build/server.js
CHANGED
|
@@ -198,6 +198,12 @@ function getProjectDir() {
|
|
|
198
198
|
// path on detected platform so non-Claude hosts skip the heuristic and
|
|
199
199
|
// fall through to PWD/cwd cleanly.
|
|
200
200
|
//
|
|
201
|
+
// The Claude heuristic must also be fresh. Hosts such as Pi can be
|
|
202
|
+
// misdetected as Claude Code solely because ~/.claude exists; without a
|
|
203
|
+
// freshness guard an old Claude transcript can globally hijack ctx shell cwd
|
|
204
|
+
// after reboot. Active Claude sessions update their transcript as the user
|
|
205
|
+
// interacts, so stale transcripts should fall through to PWD/cwd.
|
|
206
|
+
//
|
|
201
207
|
// Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the
|
|
202
208
|
// env-var cascade is built ALGORITHMICALLY from the platform's own
|
|
203
209
|
// workspace vars + universal escape hatch — foreign workspace vars (e.g.
|
|
@@ -220,6 +226,7 @@ function getProjectDir() {
|
|
|
220
226
|
cwd: process.cwd(),
|
|
221
227
|
pwd: process.env.PWD,
|
|
222
228
|
transcriptsRoot,
|
|
229
|
+
transcriptMaxAgeMs: 5 * 60 * 1000,
|
|
223
230
|
strictPlatform,
|
|
224
231
|
});
|
|
225
232
|
}
|
|
@@ -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
|
+
}
|
|
@@ -50,6 +50,15 @@ export declare function isPluginInstallPath(p: string): boolean;
|
|
|
50
50
|
*/
|
|
51
51
|
export declare function resolveProjectDirFromTranscript(opts: {
|
|
52
52
|
projectsRoot: string;
|
|
53
|
+
/**
|
|
54
|
+
* Optional freshness guard. Claude Code updates the active transcript while
|
|
55
|
+
* the session is being used; stale transcripts from previous days must not
|
|
56
|
+
* become a global project-dir signal for other hosts that merely have
|
|
57
|
+
* ~/.claude on disk.
|
|
58
|
+
*/
|
|
59
|
+
maxAgeMs?: number;
|
|
60
|
+
/** Test seam for maxAgeMs. Defaults to Date.now(). */
|
|
61
|
+
nowMs?: number;
|
|
53
62
|
}): string | undefined;
|
|
54
63
|
/**
|
|
55
64
|
* Pure project-dir resolver. Mirror of the env-var chain inside
|
|
@@ -77,6 +86,10 @@ export declare function resolveProjectDir(opts: {
|
|
|
77
86
|
pwd: string | undefined;
|
|
78
87
|
/** Optional override; production code passes `~/.claude/projects`. */
|
|
79
88
|
transcriptsRoot?: string;
|
|
89
|
+
/** Optional freshness guard for Claude Code transcript project recovery. */
|
|
90
|
+
transcriptMaxAgeMs?: number;
|
|
91
|
+
/** Test seam for transcriptMaxAgeMs. Defaults to Date.now(). */
|
|
92
|
+
nowMs?: number;
|
|
80
93
|
/**
|
|
81
94
|
* Issue #545 — opt-in tightening. When set, the candidate list is built
|
|
82
95
|
* algorithmically from `workspaceEnvVarsFor(strictPlatform)` plus the
|
|
@@ -124,6 +124,11 @@ export function resolveProjectDirFromTranscript(opts) {
|
|
|
124
124
|
}
|
|
125
125
|
if (!bestPath)
|
|
126
126
|
return undefined;
|
|
127
|
+
if (typeof opts.maxAgeMs === "number") {
|
|
128
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
129
|
+
if (nowMs - bestMtime > opts.maxAgeMs)
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
127
132
|
// Read first ~10 lines until we find a cwd field. The jsonl is
|
|
128
133
|
// append-only and can be huge (60+ MB on long sessions) — never load it
|
|
129
134
|
// into memory; stream a small head buffer.
|
|
@@ -172,7 +177,7 @@ export function resolveProjectDirFromTranscript(opts) {
|
|
|
172
177
|
* operation of project-independent tools (sandbox execute, fetch).
|
|
173
178
|
*/
|
|
174
179
|
export function resolveProjectDir(opts) {
|
|
175
|
-
const { env, cwd, pwd, transcriptsRoot, strictPlatform } = opts;
|
|
180
|
+
const { env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform } = opts;
|
|
176
181
|
// Build candidate list. Strict path: own workspace vars + universal escape
|
|
177
182
|
// hatch — NO foreign workspace vars, in any order, can win. Non-strict
|
|
178
183
|
// path: frozen legacy literal order for backwards compatibility.
|
|
@@ -185,7 +190,11 @@ export function resolveProjectDir(opts) {
|
|
|
185
190
|
return v;
|
|
186
191
|
}
|
|
187
192
|
if (transcriptsRoot) {
|
|
188
|
-
const fromTranscript = resolveProjectDirFromTranscript({
|
|
193
|
+
const fromTranscript = resolveProjectDirFromTranscript({
|
|
194
|
+
projectsRoot: transcriptsRoot,
|
|
195
|
+
maxAgeMs: transcriptMaxAgeMs,
|
|
196
|
+
nowMs,
|
|
197
|
+
});
|
|
189
198
|
if (fromTranscript && !isPluginInstallPath(fromTranscript))
|
|
190
199
|
return fromTranscript;
|
|
191
200
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sibling-mcp — discover & terminate previous-version MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* Issue #559: `/ctx-upgrade` historically left the running MCP server
|
|
5
|
+
* alive after copying new files in-place + updating npm global. The next
|
|
6
|
+
* Claude Code launch spawned a fresh process from the new version, but
|
|
7
|
+
* the old one kept its open stdio + DB handles. Across enough upgrades
|
|
8
|
+
* users observed 5+ context-mode `start.mjs` processes pinned to RAM.
|
|
9
|
+
*
|
|
10
|
+
* This module provides two pure helpers:
|
|
11
|
+
*
|
|
12
|
+
* 1. `discoverSiblingMcpPids({ ownPid, ownPpid, platform, runCommand })`
|
|
13
|
+
* — enumerates node processes whose argv mentions the plugin
|
|
14
|
+
* `start.mjs` path under `~/.claude/plugins/{cache,marketplaces}/`.
|
|
15
|
+
* Excludes the caller's own pid + parent pid (Claude Code or the
|
|
16
|
+
* shell that spawned `/ctx-upgrade`). Cross-platform: POSIX uses
|
|
17
|
+
* `pgrep -f`, Windows uses PowerShell + Get-CimInstance.
|
|
18
|
+
*
|
|
19
|
+
* 2. `killSiblingMcpServers({ pids, ... })` — sends SIGTERM, polls
|
|
20
|
+
* liveness, escalates to SIGKILL after `timeoutMs` (default 1500
|
|
21
|
+
* ms) on stragglers. Returns a kill report so callers can surface
|
|
22
|
+
* a concise summary without leaking PIDs to user-facing logs.
|
|
23
|
+
*
|
|
24
|
+
* Both helpers accept dependency-injected `runCommand`, `isAlive`, and
|
|
25
|
+
* `sendSignal` parameters so tests can exercise the full behavior tree
|
|
26
|
+
* cross-platform without spawning real processes.
|
|
27
|
+
*/
|
|
28
|
+
/** Inject `child_process.execFileSync` for tests. Must return stdout as utf-8. */
|
|
29
|
+
export type RunCommand = (cmd: string, args: readonly string[]) => string;
|
|
30
|
+
/** Inject `process.kill(pid, 0)` for tests. */
|
|
31
|
+
export type IsAlive = (pid: number) => boolean;
|
|
32
|
+
/** Inject `process.kill(pid, signal)` for tests. */
|
|
33
|
+
export type SendSignal = (pid: number, signal: NodeJS.Signals) => void;
|
|
34
|
+
export interface DiscoverOptions {
|
|
35
|
+
ownPid: number;
|
|
36
|
+
ownPpid: number;
|
|
37
|
+
/** `process.platform` injection. Defaults to live process.platform. */
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
/** Test injection point — defaults to `child_process.execFileSync`. */
|
|
40
|
+
runCommand?: RunCommand;
|
|
41
|
+
}
|
|
42
|
+
export interface KillOptions {
|
|
43
|
+
pids: readonly number[];
|
|
44
|
+
/** Time to wait for SIGTERM to take effect before escalating. */
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
/** Poll interval while waiting for SIGTERM. */
|
|
47
|
+
pollIntervalMs?: number;
|
|
48
|
+
isAlive?: IsAlive;
|
|
49
|
+
sendSignal?: SendSignal;
|
|
50
|
+
}
|
|
51
|
+
export interface KillReport {
|
|
52
|
+
/** PIDs that died after SIGTERM within `timeoutMs`. */
|
|
53
|
+
terminatedBySigterm: number;
|
|
54
|
+
/** PIDs that required SIGKILL escalation. */
|
|
55
|
+
terminatedBySigkill: number;
|
|
56
|
+
/** Sum of the two — used by the cli summary line. */
|
|
57
|
+
totalKilled: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Enumerate node MCP-server processes spawned from this plugin's
|
|
61
|
+
* start.mjs. Always returns an empty array on tool absence — never
|
|
62
|
+
* throws — so an upgrade is never blocked by a missing pgrep/PowerShell.
|
|
63
|
+
*/
|
|
64
|
+
export declare function discoverSiblingMcpPids(opts: DiscoverOptions): number[];
|
|
65
|
+
/**
|
|
66
|
+
* Send SIGTERM to each PID, then poll for liveness. PIDs still alive
|
|
67
|
+
* after `timeoutMs` receive SIGKILL. Returns a per-signal report.
|
|
68
|
+
*
|
|
69
|
+
* Algorithm:
|
|
70
|
+
* 1. Fire SIGTERM at every pid (swallow ESRCH — already dead).
|
|
71
|
+
* 2. Poll every `pollIntervalMs` until either all pids are dead
|
|
72
|
+
* OR `timeoutMs` elapses.
|
|
73
|
+
* 3. For survivors: SIGKILL (swallow ESRCH).
|
|
74
|
+
* 4. Count via "died-while-we-watched": only PIDs that were observed
|
|
75
|
+
* alive at any point and then died are reported. PIDs that were
|
|
76
|
+
* already dead before SIGTERM (ESRCH on first send) are not
|
|
77
|
+
* counted — they were not ours to kill.
|
|
78
|
+
*/
|
|
79
|
+
export declare function killSiblingMcpServers(opts: KillOptions): Promise<KillReport>;
|