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.
@@ -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
- const lifetimeTokensWith = Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
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 — the screenshottable proof for THIS conversation.
1332
- const convTokensWith = Math.max(1, Math.round(conversationTokens * 0.02));
1333
- const withoutBar = dataBar(conversationTokens, conversationTokens, 32);
1334
- const withBar = dataBar(convTokensWith, conversationTokens, 32);
1335
- const convPct = conversationTokens > 0 ? (1 - convTokensWith / conversationTokens) * 100 : 0;
1336
- out.push(` Without context-mode ${kb(convBytes).padStart(8)} ${withoutBar} ${fmtNum(conversationTokens).padStart(7)} tokens`);
1337
- out.push(` With context-mode ${kb(Math.max(1, Math.round(convBytes * 0.02))).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
1338
- out.push(` ${convPct.toFixed(0)}% kept out of context · your AI ran ${Math.max(1, Math.round(conversationTokens / convTokensWith))}× longer before /compact fired`);
1339
- out.push("");
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;
@@ -678,12 +678,28 @@ function extractExternalRef(input) {
678
678
  }
679
679
  if (refs.size === 0)
680
680
  return [];
681
- return [{
682
- type: "external_ref",
683
- category: "external-ref",
684
- data: safeString(Array.from(refs).join(", ")),
685
- priority: 3,
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): IndexResult;
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): IndexResult;
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, null, null, now);
823
- this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, null, null, now);
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 BOTH `~/.claude/plugins/cache/context-mode/context-mode/<v>/start.mjs`
30
- // AND `~/.claude/plugins/marketplaces/context-mode/start.mjs` shapes.
31
- // Both can be alive concurrently — VERDICT R1 dump confirmed all four
32
- // PIDs simultaneously across three different versions on a real Mac.
33
- const POSIX_PGREP_PATTERN = "node.*plugins/(cache|marketplaces)/.*context-mode.*start\\.mjs";
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 on CommandLine because Win32_Process.Name is just "node.exe".
36
- // Two backslashes inside `start\.mjs` are needed because the Like operator
37
- // uses regex-ish escaping at the JS layer.
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 'plugins[\\\\/](cache|marketplaces)[\\\\/].*context-mode.*start\\.mjs' } | " +
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
- return parsePidList(stdout).filter((pid) => pid !== opts.ownPid && pid !== opts.ownPpid);
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
+ }