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.
- 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/README.md +26 -15
- package/build/cli.js +32 -0
- package/build/lifecycle.d.ts +51 -2
- package/build/lifecycle.js +67 -3
- package/build/server.js +92 -14
- package/build/session/analytics.js +36 -10
- package/build/session/extract.d.ts +7 -0
- package/build/session/extract.js +22 -6
- package/build/store.d.ts +17 -2
- package/build/store.js +17 -13
- package/build/util/sibling-mcp.d.ts +40 -0
- package/build/util/sibling-mcp.js +116 -11
- package/cli.bundle.mjs +167 -160
- package/configs/jetbrains-copilot/mcp.json +1 -2
- package/configs/vscode-copilot/mcp.json +1 -2
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-loaders.mjs +15 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
- package/scripts/heal-better-sqlite3.mjs +99 -2
- package/scripts/postinstall.mjs +58 -0
- package/server.bundle.mjs +117 -117
- package/skills/context-mode/SKILL.md +1 -0
- package/skills/context-mode/references/anti-patterns.md +26 -0
|
@@ -1256,7 +1256,16 @@ function renderNarrative5Section(args) {
|
|
|
1256
1256
|
const lifetimeLegacyTokens = lifetimeEventsTokens + lifetimeRescueTokens;
|
|
1257
1257
|
const lifetimeRealTokens = realBytes?.lifetime?.totalSavedTokens ?? 0;
|
|
1258
1258
|
const lifetimeTokensWithout = Math.max(lifetimeLegacyTokens, lifetimeRealTokens);
|
|
1259
|
-
|
|
1259
|
+
// Lifetime "with" — measured when available, else legacy 0.02 fallback.
|
|
1260
|
+
// Honest definition (matches conversation bar below):
|
|
1261
|
+
// "with" = bytes_returned (what the model actually re-saw)
|
|
1262
|
+
// "without" = bytes_returned + bytes_avoided
|
|
1263
|
+
// When the schema has measurement, derive `with` from `bytes_returned/4`.
|
|
1264
|
+
const lifeRet = realBytes?.lifetime?.bytesReturned ?? 0;
|
|
1265
|
+
const lifeAv = realBytes?.lifetime?.bytesAvoided ?? 0;
|
|
1266
|
+
const lifetimeTokensWith = (lifeRet + lifeAv) > 0
|
|
1267
|
+
? Math.max(1, Math.floor(lifeRet / 4))
|
|
1268
|
+
: Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
|
|
1260
1269
|
// Bytes from realBytes when present, else derive from tokens (×4 — same
|
|
1261
1270
|
// ratio Phase 8 uses everywhere). All-work bytes drives the opener tally
|
|
1262
1271
|
// + the section-3 receipt + section-4 cost example.
|
|
@@ -1328,15 +1337,32 @@ function renderNarrative5Section(args) {
|
|
|
1328
1337
|
out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
|
|
1329
1338
|
}
|
|
1330
1339
|
out.push("");
|
|
1331
|
-
// Without/With bars —
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
+
// Without/With bars — measured from real per-event bytes_returned / bytes_avoided.
|
|
1341
|
+
//
|
|
1342
|
+
// Honest definitions:
|
|
1343
|
+
// Without = bytes the model WOULD have re-seen with no filtering = bytes_returned + bytes_avoided
|
|
1344
|
+
// With = bytes the model ACTUALLY re-saw after context-mode = bytes_returned
|
|
1345
|
+
//
|
|
1346
|
+
// No fallback to heuristic. If the schema has zero signal for this
|
|
1347
|
+
// conversation (no hook ever populated bytes_avoided / bytes_returned),
|
|
1348
|
+
// the section is skipped entirely. Honesty over decoration.
|
|
1349
|
+
const realConv = realBytes?.conversation;
|
|
1350
|
+
const measuredAvoided = realConv?.bytesAvoided ?? 0;
|
|
1351
|
+
const measuredReturned = realConv?.bytesReturned ?? 0;
|
|
1352
|
+
if (measuredAvoided + measuredReturned > 0) {
|
|
1353
|
+
const convBytesWithout = measuredReturned + measuredAvoided;
|
|
1354
|
+
const convBytesWith = Math.max(1, measuredReturned);
|
|
1355
|
+
const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
|
|
1356
|
+
const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
|
|
1357
|
+
const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
|
|
1358
|
+
const withBar = dataBar(convTokensWith, convTokensWithout, 32);
|
|
1359
|
+
const convPct = (1 - convTokensWith / convTokensWithout) * 100;
|
|
1360
|
+
const convMult = Math.max(1, Math.round(convTokensWithout / convTokensWith));
|
|
1361
|
+
out.push(` Without context-mode ${kb(convBytesWithout).padStart(8)} ${withoutBar} ${fmtNum(convTokensWithout).padStart(7)} tokens`);
|
|
1362
|
+
out.push(` With context-mode ${kb(convBytesWith).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
|
|
1363
|
+
out.push(` ${convPct.toFixed(0)}% kept out of context · your AI ran ${convMult}× longer before /compact fired`);
|
|
1364
|
+
out.push("");
|
|
1365
|
+
}
|
|
1340
1366
|
// Timeline — drop-in if conversation has byDay.
|
|
1341
1367
|
if (conversation.byDay && conversation.byDay.length > 0) {
|
|
1342
1368
|
const totalConvDays = conversation.lastEventMs && conversation.firstEventMs
|
|
@@ -15,6 +15,13 @@ export interface SessionEvent {
|
|
|
15
15
|
data: string;
|
|
16
16
|
/** 1=critical (rules, files, tasks) … 5=low */
|
|
17
17
|
priority: number;
|
|
18
|
+
/**
|
|
19
|
+
* Optional — bytes context-mode prevented from entering the model context
|
|
20
|
+
* window for this event. Currently populated by external_ref when a
|
|
21
|
+
* ctx_fetch_and_index tool_response carries the
|
|
22
|
+
* `Fetched and indexed N sections (XKB)` preamble.
|
|
23
|
+
*/
|
|
24
|
+
bytes_avoided?: number;
|
|
18
25
|
}
|
|
19
26
|
export interface ToolCall {
|
|
20
27
|
toolName: string;
|
package/build/session/extract.js
CHANGED
|
@@ -678,12 +678,28 @@ function extractExternalRef(input) {
|
|
|
678
678
|
}
|
|
679
679
|
if (refs.size === 0)
|
|
680
680
|
return [];
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
681
|
+
// ctx_fetch_and_index returns a preamble like
|
|
682
|
+
// "Fetched and indexed **5 sections** (47.50KB) from: <label>"
|
|
683
|
+
// Parse the size to credit bytes_avoided on the event so per-session
|
|
684
|
+
// honest-savings stats reflect what was kept out of the context window.
|
|
685
|
+
// KB literal in the preamble is decimal (KB = 1024 bytes per the formatter).
|
|
686
|
+
let bytesAvoided;
|
|
687
|
+
const preambleMatch = safeString(input.tool_response).match(/Fetched and indexed[^\(]*\(([\d.]+)\s*KB\)/i);
|
|
688
|
+
if (preambleMatch) {
|
|
689
|
+
const kb = Number(preambleMatch[1]);
|
|
690
|
+
if (Number.isFinite(kb) && kb > 0) {
|
|
691
|
+
bytesAvoided = Math.round(kb * 1024);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const event = {
|
|
695
|
+
type: "external_ref",
|
|
696
|
+
category: "external-ref",
|
|
697
|
+
data: safeString(Array.from(refs).join(", ")),
|
|
698
|
+
priority: 3,
|
|
699
|
+
};
|
|
700
|
+
if (bytesAvoided !== undefined)
|
|
701
|
+
event.bytes_avoided = bytesAvoided;
|
|
702
|
+
return [event];
|
|
687
703
|
}
|
|
688
704
|
/**
|
|
689
705
|
* Category 8: env (worktree)
|
package/build/store.d.ts
CHANGED
|
@@ -41,13 +41,25 @@ export declare class ContentStore {
|
|
|
41
41
|
content?: string;
|
|
42
42
|
path?: string;
|
|
43
43
|
source?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Optional FK metadata recorded on each indexed chunk so per-session
|
|
46
|
+
* honest-savings stats can join chunks → session_events. When omitted,
|
|
47
|
+
* chunks fall back to empty-string columns (legacy behaviour).
|
|
48
|
+
*/
|
|
49
|
+
attribution?: {
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
eventId?: string;
|
|
52
|
+
};
|
|
44
53
|
}): IndexResult;
|
|
45
54
|
/**
|
|
46
55
|
* Index plain-text output (logs, build output, test results) by splitting
|
|
47
56
|
* into fixed-size line groups. Unlike markdown indexing, this does not
|
|
48
57
|
* look for headings — it chunks by line count with overlap.
|
|
49
58
|
*/
|
|
50
|
-
indexPlainText(content: string, source: string, linesPerChunk?: number
|
|
59
|
+
indexPlainText(content: string, source: string, linesPerChunk?: number, attribution?: {
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
eventId?: string;
|
|
62
|
+
}): IndexResult;
|
|
51
63
|
/**
|
|
52
64
|
* Index JSON content by walking the object tree and using key paths
|
|
53
65
|
* as chunk titles (analogous to heading hierarchy in markdown). Objects
|
|
@@ -55,7 +67,10 @@ export declare class ContentStore {
|
|
|
55
67
|
*
|
|
56
68
|
* Falls back to `indexPlainText` if the content is not valid JSON.
|
|
57
69
|
*/
|
|
58
|
-
indexJSON(content: string, source: string, maxChunkBytes?: number
|
|
70
|
+
indexJSON(content: string, source: string, maxChunkBytes?: number, attribution?: {
|
|
71
|
+
sessionId?: string;
|
|
72
|
+
eventId?: string;
|
|
73
|
+
}): IndexResult;
|
|
59
74
|
search(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
|
|
60
75
|
searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
|
|
61
76
|
fuzzyCorrect(query: string): string | null;
|
package/build/store.js
CHANGED
|
@@ -714,7 +714,7 @@ export class ContentStore {
|
|
|
714
714
|
}
|
|
715
715
|
// ── Index ──
|
|
716
716
|
index(options) {
|
|
717
|
-
const { content, path, source } = options;
|
|
717
|
+
const { content, path, source, attribution } = options;
|
|
718
718
|
// Treat empty string as "no content" so an empty `content` paired with a
|
|
719
719
|
// valid `path` falls back to reading the file. Some MCP clients
|
|
720
720
|
// materialize optional string fields as `""` and the previous
|
|
@@ -754,7 +754,7 @@ export class ContentStore {
|
|
|
754
754
|
// Stale detection: store file_path + SHA-256 for file-backed sources
|
|
755
755
|
const filePath = path ?? undefined;
|
|
756
756
|
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
757
|
-
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash));
|
|
757
|
+
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
|
|
758
758
|
}
|
|
759
759
|
// ── Index Plain Text ──
|
|
760
760
|
/**
|
|
@@ -762,12 +762,12 @@ export class ContentStore {
|
|
|
762
762
|
* into fixed-size line groups. Unlike markdown indexing, this does not
|
|
763
763
|
* look for headings — it chunks by line count with overlap.
|
|
764
764
|
*/
|
|
765
|
-
indexPlainText(content, source, linesPerChunk = 20) {
|
|
765
|
+
indexPlainText(content, source, linesPerChunk = 20, attribution) {
|
|
766
766
|
if (!content || content.trim().length === 0) {
|
|
767
|
-
return this.#insertChunks([], source, "");
|
|
767
|
+
return this.#insertChunks([], source, "", undefined, undefined, attribution);
|
|
768
768
|
}
|
|
769
769
|
const chunks = this.#chunkPlainText(content, linesPerChunk);
|
|
770
|
-
return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content));
|
|
770
|
+
return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
|
|
771
771
|
}
|
|
772
772
|
// ── Index JSON ──
|
|
773
773
|
/**
|
|
@@ -777,23 +777,23 @@ export class ContentStore {
|
|
|
777
777
|
*
|
|
778
778
|
* Falls back to `indexPlainText` if the content is not valid JSON.
|
|
779
779
|
*/
|
|
780
|
-
indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
780
|
+
indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES, attribution) {
|
|
781
781
|
if (!content || content.trim().length === 0) {
|
|
782
|
-
return this.indexPlainText("", source);
|
|
782
|
+
return this.indexPlainText("", source, undefined, attribution);
|
|
783
783
|
}
|
|
784
784
|
let parsed;
|
|
785
785
|
try {
|
|
786
786
|
parsed = JSON.parse(content);
|
|
787
787
|
}
|
|
788
788
|
catch {
|
|
789
|
-
return this.indexPlainText(content, source);
|
|
789
|
+
return this.indexPlainText(content, source, undefined, attribution);
|
|
790
790
|
}
|
|
791
791
|
const chunks = [];
|
|
792
792
|
this.#walkJSON(parsed, [], chunks, maxChunkBytes);
|
|
793
793
|
if (chunks.length === 0) {
|
|
794
|
-
return this.indexPlainText(content, source);
|
|
794
|
+
return this.indexPlainText(content, source, undefined, attribution);
|
|
795
795
|
}
|
|
796
|
-
return withRetry(() => this.#insertChunks(chunks, source, content));
|
|
796
|
+
return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
|
|
797
797
|
}
|
|
798
798
|
// ── Shared DB Insertion ──
|
|
799
799
|
/**
|
|
@@ -801,8 +801,12 @@ export class ContentStore {
|
|
|
801
801
|
* into both FTS5 tables within a transaction and extracts vocabulary.
|
|
802
802
|
* Uses cached prepared statements from #prepareStatements().
|
|
803
803
|
*/
|
|
804
|
-
#insertChunks(chunks, label, text, filePath, contentHash) {
|
|
804
|
+
#insertChunks(chunks, label, text, filePath, contentHash, attribution) {
|
|
805
805
|
const codeChunks = chunks.filter((c) => c.hasCode).length;
|
|
806
|
+
// FK columns on chunks. Empty-string fallback preserves the FTS5-friendly
|
|
807
|
+
// "not-null but unattributed" sentinel used by legacy rows.
|
|
808
|
+
const sessionIdCol = attribution?.sessionId ?? "";
|
|
809
|
+
const eventIdCol = attribution?.eventId ?? "";
|
|
806
810
|
// Atomic dedup + insert: delete previous source with same label,
|
|
807
811
|
// then insert new content — all within a single transaction.
|
|
808
812
|
// Prevents stale results in iterative workflows. (See: GitHub issue #67)
|
|
@@ -819,8 +823,8 @@ export class ContentStore {
|
|
|
819
823
|
const now = new Date().toISOString();
|
|
820
824
|
for (const chunk of chunks) {
|
|
821
825
|
const ct = chunk.hasCode ? "code" : "prose";
|
|
822
|
-
this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null,
|
|
823
|
-
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null,
|
|
826
|
+
this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
|
|
827
|
+
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
|
|
824
828
|
}
|
|
825
829
|
return sourceId;
|
|
826
830
|
});
|
|
@@ -38,6 +38,21 @@ 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;
|
|
41
56
|
}
|
|
42
57
|
export interface KillOptions {
|
|
43
58
|
pids: readonly number[];
|
|
@@ -77,3 +92,28 @@ export declare function discoverSiblingMcpPids(opts: DiscoverOptions): number[];
|
|
|
77
92
|
* counted — they were not ours to kill.
|
|
78
93
|
*/
|
|
79
94
|
export declare function killSiblingMcpServers(opts: KillOptions): Promise<KillReport>;
|
|
95
|
+
/**
|
|
96
|
+
* Startup-time sibling sweep (#565).
|
|
97
|
+
*
|
|
98
|
+
* Discovers any context-mode MCP server pids that share OUR parent process
|
|
99
|
+
* (i.e. other MCP children of the same host like `opencode serve`) and
|
|
100
|
+
* terminates them. The intent is "exactly one MCP child per host" — when a
|
|
101
|
+
* new MCP client spawns inside an opencode host that already has 25 stale
|
|
102
|
+
* idle siblings, this sweep reclaims them at boot rather than waiting for
|
|
103
|
+
* the idle timeout to fire on each one independently.
|
|
104
|
+
*
|
|
105
|
+
* Gated by env (default-on but easy to disable):
|
|
106
|
+
*
|
|
107
|
+
* CONTEXT_MODE_STARTUP_SWEEP=0 → disabled
|
|
108
|
+
* CONTEXT_MODE_STARTUP_SWEEP=1 → enabled (default)
|
|
109
|
+
*
|
|
110
|
+
* Safety:
|
|
111
|
+
* - `sameParentOnly: true` — never touches MCP children of a different host.
|
|
112
|
+
* - Best-effort throughout: failures never block server startup.
|
|
113
|
+
* - Composes with the idle-timeout path: if a sibling is actively in use
|
|
114
|
+
* by another session, the parent process will simply spawn a new MCP
|
|
115
|
+
* child on its next request. The cost is one cold-start (~1–3 s) for
|
|
116
|
+
* that session, which is identical to opencode's existing behaviour
|
|
117
|
+
* of spawning a fresh MCP child per session anyway.
|
|
118
|
+
*/
|
|
119
|
+
export declare function startupSiblingSweep(env?: NodeJS.ProcessEnv): Promise<KillReport>;
|
|
@@ -26,19 +26,70 @@
|
|
|
26
26
|
* cross-platform without spawning real processes.
|
|
27
27
|
*/
|
|
28
28
|
import { execFileSync } from "node:child_process";
|
|
29
|
-
// Match
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
29
|
+
// Match every shape an installed context-mode MCP server can take in argv:
|
|
30
|
+
//
|
|
31
|
+
// 1. `~/.claude/plugins/cache/context-mode/context-mode/<v>/start.mjs`
|
|
32
|
+
// and `~/.claude/plugins/marketplaces/context-mode/start.mjs` — Claude
|
|
33
|
+
// Code plugin shapes (original #559 case).
|
|
34
|
+
// 2. `<prefix>/node_modules/context-mode/start.mjs` or `.../server.bundle.mjs`
|
|
35
|
+
// — npm-global, marketplace, manual installs.
|
|
36
|
+
// 3. `<prefix>/bin/context-mode` — npm-global bin shim. This is the argv
|
|
37
|
+
// OpenCode + KiloCode see when they spawn `mcp.command = ["context-mode"]`.
|
|
38
|
+
// Without this entry, sibling discovery missed the 26-child / 1.6 GB
|
|
39
|
+
// RSS accumulation reported in #565.
|
|
40
|
+
// 4. `bun .../context-mode/server.bundle.mjs` — Pi/Bun hosts.
|
|
41
|
+
//
|
|
42
|
+
// All four can be alive concurrently — VERDICT R1 dump confirmed multi-version
|
|
43
|
+
// coexistence on real macOS, and #565 confirmed concurrent OpenCode children
|
|
44
|
+
// alongside Claude Code children on Linux.
|
|
45
|
+
const POSIX_PGREP_PATTERN = "(node|bun).*(plugins/(cache|marketplaces)/.*context-mode.*start\\.mjs" +
|
|
46
|
+
"|context-mode/start\\.mjs" +
|
|
47
|
+
"|context-mode/server\\.bundle\\.mjs" +
|
|
48
|
+
"|bin/context-mode($|[^a-zA-Z0-9_-]))";
|
|
34
49
|
// Windows: PowerShell + Get-CimInstance (wmic deprecated since Win11 22H2).
|
|
35
|
-
// Filter
|
|
36
|
-
//
|
|
37
|
-
// uses regex-ish escaping at the JS layer.
|
|
50
|
+
// Filter includes both node.exe and bun.exe (Pi/Bun hosts). CommandLine
|
|
51
|
+
// matching covers the same four install shapes as POSIX_PGREP_PATTERN.
|
|
38
52
|
const WIN_PS_SCRIPT = "Get-CimInstance Win32_Process " +
|
|
39
|
-
"-Filter \"Name='node.exe'\" | " +
|
|
40
|
-
"Where-Object { $_.CommandLine -match
|
|
53
|
+
"-Filter \"Name='node.exe' OR Name='bun.exe'\" | " +
|
|
54
|
+
"Where-Object { $_.CommandLine -match " +
|
|
55
|
+
"'plugins[\\\\/](cache|marketplaces)[\\\\/].*context-mode.*start\\.mjs" +
|
|
56
|
+
"|context-mode[\\\\/]start\\.mjs" +
|
|
57
|
+
"|context-mode[\\\\/]server\\.bundle\\.mjs" +
|
|
58
|
+
"|bin[\\\\/]context-mode($|[^a-zA-Z0-9_-])' } | " +
|
|
41
59
|
"Select-Object -ExpandProperty ProcessId";
|
|
60
|
+
/** POSIX ppid probe — empty / NaN on failure. */
|
|
61
|
+
function readPpidPosix(pid) {
|
|
62
|
+
try {
|
|
63
|
+
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
timeout: 2000,
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
}).trim();
|
|
68
|
+
const n = Number.parseInt(out, 10);
|
|
69
|
+
return Number.isFinite(n) ? n : NaN;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return NaN;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Windows ppid probe — empty / NaN on failure. */
|
|
76
|
+
function readPpidWin32(pid) {
|
|
77
|
+
try {
|
|
78
|
+
const out = execFileSync("powershell", [
|
|
79
|
+
"-NoProfile",
|
|
80
|
+
"-Command",
|
|
81
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`,
|
|
82
|
+
], { encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
83
|
+
const n = Number.parseInt(out, 10);
|
|
84
|
+
return Number.isFinite(n) ? n : NaN;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return NaN;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function defaultReadPpid(pid) {
|
|
91
|
+
return process.platform === "win32" ? readPpidWin32(pid) : readPpidPosix(pid);
|
|
92
|
+
}
|
|
42
93
|
const defaultRun = (cmd, args) => execFileSync(cmd, [...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
43
94
|
const defaultIsAlive = (pid) => {
|
|
44
95
|
try {
|
|
@@ -94,7 +145,18 @@ export function discoverSiblingMcpPids(opts) {
|
|
|
94
145
|
catch {
|
|
95
146
|
return [];
|
|
96
147
|
}
|
|
97
|
-
|
|
148
|
+
const candidates = parsePidList(stdout).filter((pid) => pid !== opts.ownPid && pid !== opts.ownPpid);
|
|
149
|
+
if (!opts.sameParentOnly)
|
|
150
|
+
return candidates;
|
|
151
|
+
// Startup-sweep mode (#565): only reap siblings sharing OUR ppid. This
|
|
152
|
+
// prevents an opencode-spawned MCP child from killing a Claude Code MCP
|
|
153
|
+
// child (or another concurrent opencode host's children) when both are
|
|
154
|
+
// present on the same machine.
|
|
155
|
+
const readPpid = opts.readPpid ?? defaultReadPpid;
|
|
156
|
+
return candidates.filter((pid) => {
|
|
157
|
+
const ppid = readPpid(pid);
|
|
158
|
+
return Number.isFinite(ppid) && ppid === opts.ownPpid;
|
|
159
|
+
});
|
|
98
160
|
}
|
|
99
161
|
/** Sleep helper — Promise-based for use inside the kill polling loop. */
|
|
100
162
|
function delay(ms) {
|
|
@@ -179,3 +241,46 @@ export async function killSiblingMcpServers(opts) {
|
|
|
179
241
|
totalKilled: terminatedBySigterm + terminatedBySigkill,
|
|
180
242
|
};
|
|
181
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Startup-time sibling sweep (#565).
|
|
246
|
+
*
|
|
247
|
+
* Discovers any context-mode MCP server pids that share OUR parent process
|
|
248
|
+
* (i.e. other MCP children of the same host like `opencode serve`) and
|
|
249
|
+
* terminates them. The intent is "exactly one MCP child per host" — when a
|
|
250
|
+
* new MCP client spawns inside an opencode host that already has 25 stale
|
|
251
|
+
* idle siblings, this sweep reclaims them at boot rather than waiting for
|
|
252
|
+
* the idle timeout to fire on each one independently.
|
|
253
|
+
*
|
|
254
|
+
* Gated by env (default-on but easy to disable):
|
|
255
|
+
*
|
|
256
|
+
* CONTEXT_MODE_STARTUP_SWEEP=0 → disabled
|
|
257
|
+
* CONTEXT_MODE_STARTUP_SWEEP=1 → enabled (default)
|
|
258
|
+
*
|
|
259
|
+
* Safety:
|
|
260
|
+
* - `sameParentOnly: true` — never touches MCP children of a different host.
|
|
261
|
+
* - Best-effort throughout: failures never block server startup.
|
|
262
|
+
* - Composes with the idle-timeout path: if a sibling is actively in use
|
|
263
|
+
* by another session, the parent process will simply spawn a new MCP
|
|
264
|
+
* child on its next request. The cost is one cold-start (~1–3 s) for
|
|
265
|
+
* that session, which is identical to opencode's existing behaviour
|
|
266
|
+
* of spawning a fresh MCP child per session anyway.
|
|
267
|
+
*/
|
|
268
|
+
export async function startupSiblingSweep(env = process.env) {
|
|
269
|
+
const empty = { terminatedBySigterm: 0, terminatedBySigkill: 0, totalKilled: 0 };
|
|
270
|
+
const raw = env.CONTEXT_MODE_STARTUP_SWEEP;
|
|
271
|
+
if (raw === "0" || raw === "false")
|
|
272
|
+
return empty;
|
|
273
|
+
try {
|
|
274
|
+
const pids = discoverSiblingMcpPids({
|
|
275
|
+
ownPid: process.pid,
|
|
276
|
+
ownPpid: process.ppid,
|
|
277
|
+
sameParentOnly: true,
|
|
278
|
+
});
|
|
279
|
+
if (pids.length === 0)
|
|
280
|
+
return empty;
|
|
281
|
+
return await killSiblingMcpServers({ pids });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return empty;
|
|
285
|
+
}
|
|
286
|
+
}
|