context-mode 1.0.165 → 1.0.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.165"
9
+ "version": "1.0.166"
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.165",
16
+ "version": "1.0.166",
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.165",
3
+ "version": "1.0.166",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.165",
3
+ "version": "1.0.166",
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.165",
6
+ "version": "1.0.166",
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.165",
3
+ "version": "1.0.166",
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",
@@ -18,7 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { resolveSessionDbPath, SessionDB } from "../../session/db.js";
19
19
  import { extractEvents, extractUserEvents } from "../../session/extract.js";
20
20
  import { buildResumeSnapshot } from "../../session/snapshot.js";
21
- import { bootstrapMCPTools } from "./mcp-bridge.js";
21
+ import { bootstrapMCPTools, makeBridgeDiag, isForegroundSession } from "./mcp-bridge.js";
22
22
  import { PiAdapter } from "./index.js";
23
23
  // ── Pi Tool Name Mapping ─────────────────────────────────
24
24
  // Pi uses lowercase; shared extractors expect PascalCase (Claude Code convention).
@@ -298,9 +298,9 @@ function handleCommandText(text, ctx) {
298
298
  // print-mode subagents (`pi --mode json -p --no-session`). We start and await
299
299
  // the bridge from that hook so ctx_* tools are present before Pi snapshots the
300
300
  // tool registry, while CLI-only commands never spawn a bridge at all.
301
- function startPiMCPBridge(pi, serverBundle, shouldKeepHandle) {
301
+ function startPiMCPBridge(pi, serverBundle, shouldKeepHandle, foreground) {
302
302
  if (existsSync(serverBundle)) {
303
- _mcpBridgeReady = bootstrapMCPTools(pi, serverBundle).then((handle) => {
303
+ _mcpBridgeReady = bootstrapMCPTools(pi, serverBundle, { foreground }).then((handle) => {
304
304
  if (shouldKeepHandle()) {
305
305
  _mcpBridge = handle;
306
306
  }
@@ -319,8 +319,9 @@ function startPiMCPBridge(pi, serverBundle, shouldKeepHandle) {
319
319
  if (!shouldKeepHandle())
320
320
  return;
321
321
  const msg = err instanceof Error ? err.message : String(err);
322
- process.stderr.write(`[context-mode] WARNING: failed to bridge MCP tools to Pi (${msg}). ` +
323
- `ctx_* tools will not be callable from this session.\n`);
322
+ // #868: route to Pi's file logger, never process.stderr (raw-mode TUI).
323
+ makeBridgeDiag(pi)(`[context-mode] WARNING: failed to bridge MCP tools to Pi (${msg}). ` +
324
+ `ctx_* tools will not be callable from this session.`);
324
325
  });
325
326
  }
326
327
  else {
@@ -382,12 +383,12 @@ export default function piExtension(pi) {
382
383
  const serverBundle = resolve(pluginRoot, "server.bundle.mjs");
383
384
  let mcpBridgeStarted = false;
384
385
  let mcpBridgeGeneration = 0;
385
- const ensureMCPBridge = () => {
386
+ const ensureMCPBridge = (foreground) => {
386
387
  if (mcpBridgeStarted)
387
388
  return _mcpBridgeReady;
388
389
  mcpBridgeStarted = true;
389
390
  const generation = ++mcpBridgeGeneration;
390
- return startPiMCPBridge(pi, serverBundle, () => mcpBridgeStarted && mcpBridgeGeneration === generation);
391
+ return startPiMCPBridge(pi, serverBundle, () => mcpBridgeStarted && mcpBridgeGeneration === generation, foreground);
391
392
  };
392
393
  // Issue #545 — Pi workspace resolver. PI_CONFIG_DIR is Pi's CONFIG dir
393
394
  // (~/.pi), NOT the user's workspace; using it as the project anchor
@@ -527,7 +528,7 @@ export default function piExtension(pi) {
527
528
  }
528
529
  });
529
530
  // ── 4. before_agent_start — Routing + active_memory + resume injection ─
530
- pi.on("before_agent_start", async (event) => {
531
+ pi.on("before_agent_start", async (event, ctx) => {
531
532
  try {
532
533
  _pendingContext = ""; // Reset — will be filled below if events exist
533
534
  // Lazily start and await the MCP bridge only when Pi is about to
@@ -538,7 +539,24 @@ export default function piExtension(pi) {
538
539
  // here ensures ctx_* tools are registered before Pi snapshots the tool
539
540
  // registry for the model call. Resolves on bootstrap failure too — the
540
541
  // bridge is best-effort.
541
- await ensureMCPBridge();
542
+ //
543
+ // #868: the FOREGROUND interactive session's bridge child is spawned with
544
+ // the #854 idle reaper disabled (via ctx.hasUI), so a multi-minute human
545
+ // pause never drops its ctx_* tools. Subagents (hasUI:false) keep the
546
+ // reaper so abandoned children can't accumulate (#854).
547
+ //
548
+ // INVARIANT — deciding foreground on the FIRST before_agent_start is safe
549
+ // even though the bridge spawns single-flight (first-wins, no sticky
550
+ // latch): Pi wires the interactive uiContext inside `mode.init()`, which
551
+ // main.ts AWAITS before dispatching the first prompt — and
552
+ // before_agent_start is emitted only from the per-turn prompt path. So the
553
+ // foreground session's first hook ALWAYS observes hasUI:true; subagents are
554
+ // provably hasUI:false. There is no early-init window where the foreground
555
+ // transiently reads hasUI:false (that window is scoped to a separate,
556
+ // buffered credential event). Do NOT add a latch here — it would guard an
557
+ // unreachable state. (Verified against oh-my-pi: main.ts init→prompt order,
558
+ // interactive-mode.ts uiContext wiring, executor.ts subagent hasUI:false.)
559
+ await ensureMCPBridge(isForegroundSession(ctx));
542
560
  if (!_sessionId)
543
561
  return;
544
562
  const prompt = String(event?.prompt ?? "");
@@ -78,6 +78,12 @@ export declare class MCPStdioClient {
78
78
  private readonly serverScript;
79
79
  private readonly env;
80
80
  private readonly runtimeOverride;
81
+ /**
82
+ * TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
83
+ * no-op so direct callers (skippedBridge, tests) never leak to the
84
+ * terminal; bootstrapMCPTools wires this to the Pi host's file logger.
85
+ */
86
+ private readonly diag;
81
87
  private child;
82
88
  private requestId;
83
89
  private readonly pending;
@@ -99,7 +105,13 @@ export declare class MCPStdioClient {
99
105
  * without needing to attach a process-tree probe.
100
106
  */
101
107
  _spawnEnv: NodeJS.ProcessEnv | null;
102
- constructor(serverScript: string, env?: NodeJS.ProcessEnv, runtimeOverride?: string | null);
108
+ constructor(serverScript: string, env?: NodeJS.ProcessEnv, runtimeOverride?: string | null,
109
+ /**
110
+ * TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
111
+ * no-op so direct callers (skippedBridge, tests) never leak to the
112
+ * terminal; bootstrapMCPTools wires this to the Pi host's file logger.
113
+ */
114
+ diag?: BridgeDiag);
103
115
  /** Spawn the MCP child. Idempotent. */
104
116
  start(): void;
105
117
  private onExit;
@@ -161,7 +173,64 @@ export interface PiToolRegistration {
161
173
  }
162
174
  export interface PiLikeAPI {
163
175
  registerTool: (tool: PiToolRegistration) => void;
176
+ /**
177
+ * Pi's rotating file logger (`~/.omp/logs/`). Pi runs a raw-mode TUI that
178
+ * owns the terminal, so an in-process extension MUST NOT write to
179
+ * process.stdout/stderr — any console write is rendered straight into the
180
+ * editor input box and blocks typing (#868, confirmed against
181
+ * oh-my-pi tui/terminal.ts raw-mode + docs/skills/authoring-extensions.md:
182
+ * "nothing is written to the console, which would corrupt the TUI"). All
183
+ * bridge diagnostics route here instead. Optional: absent in tests / minimal
184
+ * hosts, in which case diagnostics are dropped — we NEVER fall back to the
185
+ * terminal.
186
+ */
187
+ logger?: {
188
+ debug?: (message: string, context?: Record<string, unknown>) => void;
189
+ info?: (message: string, context?: Record<string, unknown>) => void;
190
+ warn?: (message: string, context?: Record<string, unknown>) => void;
191
+ error?: (message: string, context?: Record<string, unknown>) => void;
192
+ };
164
193
  }
194
+ /** TUI-safe diagnostics sink: routes to Pi's file logger, never the terminal. */
195
+ export type BridgeDiag = (line: string, level?: "warn" | "debug") => void;
196
+ /**
197
+ * Build a {@link BridgeDiag} bound to a Pi host's file logger (#868). Writing to
198
+ * process.stderr from inside Pi's raw-mode TUI corrupts the editor, so every
199
+ * bridge diagnostic — the forwarded MCP child stderr included — goes to
200
+ * `pi.logger` instead. When no logger is reachable (tests, non-Pi hosts) the
201
+ * line is dropped; we never touch the terminal as a fallback.
202
+ */
203
+ export declare function makeBridgeDiag(pi: PiLikeAPI | null | undefined): BridgeDiag;
204
+ /**
205
+ * Split a chunk of forwarded child output into lines without a regex (the repo
206
+ * forbids regex in source). Trailing `\r` is stripped so CRLF traces stay clean
207
+ * in the log; the final partial line (no trailing newline) is preserved.
208
+ */
209
+ export declare function splitDiagLines(text: string): string[];
210
+ /**
211
+ * #868: is this the FOREGROUND interactive Pi session (vs a subagent / print /
212
+ * RPC session)? Pi passes an ExtensionContext as the 2nd arg to
213
+ * `before_agent_start`; `ctx.hasUI === true` only for the interactive session
214
+ * with a real UI attached (refs oh-my-pi runner.ts:330-331), while subagents
215
+ * are provably `hasUI: false` (refs executor.ts:2052). Fail-safe: treat anything
216
+ * that is NOT an explicit `hasUI === false` as foreground, so an
217
+ * ambiguous/absent ctx keeps the session's bridge ALIVE rather than risking the
218
+ * #868 idle-drop. Mis-classifying an abandoned non-interactive child as
219
+ * foreground only costs one lingering child until parent-death/ session_shutdown
220
+ * reaps it; the opposite error re-drops the user's tools mid-session.
221
+ */
222
+ export declare function isForegroundSession(ctx: unknown): boolean;
223
+ /**
224
+ * #868: derive the bridge child's spawn env for a session kind. The FOREGROUND
225
+ * interactive session's child must never be idle-reaped — a multi-minute human
226
+ * pause should not drop its ctx_* tools — so we disable the #854 reaper for it
227
+ * via `CONTEXT_MODE_BRIDGE_IDLE_MS=0` (lifecycle.ts honors 0 → reaper not armed).
228
+ * Sub-context / non-interactive children keep the default reaper so abandoned
229
+ * children still can't accumulate (#854). The foreground child is still reaped
230
+ * on actual parent death by the ppid/​signal watchdog (#311/#388) — only the
231
+ * idle-time path is disabled. Pure; does not mutate the input env.
232
+ */
233
+ export declare function foregroundBridgeEnv(baseEnv: NodeJS.ProcessEnv, foreground: boolean): NodeJS.ProcessEnv;
165
234
  /** Result of bootstrapping the bridge. */
166
235
  export interface BridgeHandle {
167
236
  /** Names of tools registered with Pi (for diagnostics / tests). */
@@ -186,6 +255,14 @@ export interface BootstrapOptions {
186
255
  env?: NodeJS.ProcessEnv;
187
256
  /** DI hook for tests: override the runtime resolver entirely. */
188
257
  _resolveJsRuntime?: () => string | null;
258
+ /**
259
+ * #868: true for the foreground interactive Pi session → spawn the child with
260
+ * the #854 idle reaper DISABLED so a human pause never drops its tools.
261
+ * Defaults to false (keep the reaper) for any non-foreground / unspecified
262
+ * caller; the pi extension resolves this from `ctx.hasUI` via
263
+ * {@link isForegroundSession}.
264
+ */
265
+ foreground?: boolean;
189
266
  }
190
267
  export declare function bootstrapMCPTools(pi: PiLikeAPI, serverScript: string, options?: BootstrapOptions): Promise<BridgeHandle>;
191
268
  export {};
@@ -323,6 +323,7 @@ export class MCPStdioClient {
323
323
  serverScript;
324
324
  env;
325
325
  runtimeOverride;
326
+ diag;
326
327
  child = null;
327
328
  requestId = 0;
328
329
  pending = new Map();
@@ -344,10 +345,17 @@ export class MCPStdioClient {
344
345
  * without needing to attach a process-tree probe.
345
346
  */
346
347
  _spawnEnv = null;
347
- constructor(serverScript, env = process.env, runtimeOverride = null) {
348
+ constructor(serverScript, env = process.env, runtimeOverride = null,
349
+ /**
350
+ * TUI-safe sink for the child's forwarded stderr (#868). Defaults to a
351
+ * no-op so direct callers (skippedBridge, tests) never leak to the
352
+ * terminal; bootstrapMCPTools wires this to the Pi host's file logger.
353
+ */
354
+ diag = () => { }) {
348
355
  this.serverScript = serverScript;
349
356
  this.env = env;
350
357
  this.runtimeOverride = runtimeOverride;
358
+ this.diag = diag;
351
359
  }
352
360
  /** Spawn the MCP child. Idempotent. */
353
361
  start() {
@@ -434,21 +442,23 @@ export class MCPStdioClient {
434
442
  this.child = spawn(runtime, [this.serverScript], {
435
443
  // Pipe stderr (#472 round-3): swallowing it via "ignore" hides
436
444
  // server crash diagnostics — the user only saw "ctx_* tools will
437
- // not be callable" with no clue WHY. Forwarding to process.stderr
438
- // with a [mcp-bridge] prefix lets ops grep across session noise.
445
+ // not be callable" with no clue WHY. We capture it so the diagnostic
446
+ // is preserved, but route it through `diag` (Pi's file logger), NOT
447
+ // process.stderr — Pi's raw-mode TUI owns the terminal and any console
448
+ // write is rendered into the editor input box, blocking typing (#868).
439
449
  stdio: ["pipe", "pipe", "pipe"],
440
450
  env: childEnv,
441
451
  });
442
452
  this.child.stdout?.on("data", (chunk) => this.onData(chunk));
443
453
  this.child.stderr?.on("data", (chunk) => {
444
454
  const text = chunk.toString("utf-8");
445
- // Preserve original line breaks; prefix every non-empty line so
446
- // multi-line traces stay grep-friendly.
447
- const prefixed = text
448
- .split(/\r?\n/)
449
- .map((line, i, arr) => i === arr.length - 1 && line === "" ? "" : `[mcp-bridge] ${line}`)
450
- .join("\n");
451
- process.stderr.write(prefixed);
455
+ // Forward each non-empty line, [mcp-bridge]-prefixed so it stays
456
+ // grep-friendly in ~/.omp/logs. debug level: this is mostly routine
457
+ // child chatter (e.g. the #854 idle-reaper notice), not an alert.
458
+ for (const line of splitDiagLines(text)) {
459
+ if (line !== "")
460
+ this.diag(`[mcp-bridge] ${line}`, "debug");
461
+ }
452
462
  });
453
463
  this.child.on("exit", () => this.onExit());
454
464
  this.child.on("error", () => this.onExit());
@@ -690,6 +700,78 @@ export class MCPStdioClient {
690
700
  this.exited = true;
691
701
  }
692
702
  }
703
+ /**
704
+ * Build a {@link BridgeDiag} bound to a Pi host's file logger (#868). Writing to
705
+ * process.stderr from inside Pi's raw-mode TUI corrupts the editor, so every
706
+ * bridge diagnostic — the forwarded MCP child stderr included — goes to
707
+ * `pi.logger` instead. When no logger is reachable (tests, non-Pi hosts) the
708
+ * line is dropped; we never touch the terminal as a fallback.
709
+ */
710
+ export function makeBridgeDiag(pi) {
711
+ const logger = pi?.logger;
712
+ return (line, level = "warn") => {
713
+ try {
714
+ const fn = level === "debug" ? logger?.debug : logger?.warn;
715
+ if (typeof fn === "function")
716
+ fn(line);
717
+ }
718
+ catch {
719
+ /* never throw from diagnostics — and never write to the TUI terminal */
720
+ }
721
+ };
722
+ }
723
+ /**
724
+ * Split a chunk of forwarded child output into lines without a regex (the repo
725
+ * forbids regex in source). Trailing `\r` is stripped so CRLF traces stay clean
726
+ * in the log; the final partial line (no trailing newline) is preserved.
727
+ */
728
+ export function splitDiagLines(text) {
729
+ const lines = [];
730
+ let start = 0;
731
+ for (let i = 0; i < text.length; i++) {
732
+ if (text[i] === "\n") {
733
+ let end = i;
734
+ if (end > start && text[end - 1] === "\r")
735
+ end--;
736
+ lines.push(text.slice(start, end));
737
+ start = i + 1;
738
+ }
739
+ }
740
+ if (start < text.length)
741
+ lines.push(text.slice(start));
742
+ return lines;
743
+ }
744
+ /**
745
+ * #868: is this the FOREGROUND interactive Pi session (vs a subagent / print /
746
+ * RPC session)? Pi passes an ExtensionContext as the 2nd arg to
747
+ * `before_agent_start`; `ctx.hasUI === true` only for the interactive session
748
+ * with a real UI attached (refs oh-my-pi runner.ts:330-331), while subagents
749
+ * are provably `hasUI: false` (refs executor.ts:2052). Fail-safe: treat anything
750
+ * that is NOT an explicit `hasUI === false` as foreground, so an
751
+ * ambiguous/absent ctx keeps the session's bridge ALIVE rather than risking the
752
+ * #868 idle-drop. Mis-classifying an abandoned non-interactive child as
753
+ * foreground only costs one lingering child until parent-death/ session_shutdown
754
+ * reaps it; the opposite error re-drops the user's tools mid-session.
755
+ */
756
+ export function isForegroundSession(ctx) {
757
+ const hasUI = ctx?.hasUI;
758
+ return hasUI !== false;
759
+ }
760
+ /**
761
+ * #868: derive the bridge child's spawn env for a session kind. The FOREGROUND
762
+ * interactive session's child must never be idle-reaped — a multi-minute human
763
+ * pause should not drop its ctx_* tools — so we disable the #854 reaper for it
764
+ * via `CONTEXT_MODE_BRIDGE_IDLE_MS=0` (lifecycle.ts honors 0 → reaper not armed).
765
+ * Sub-context / non-interactive children keep the default reaper so abandoned
766
+ * children still can't accumulate (#854). The foreground child is still reaped
767
+ * on actual parent death by the ppid/​signal watchdog (#311/#388) — only the
768
+ * idle-time path is disabled. Pure; does not mutate the input env.
769
+ */
770
+ export function foregroundBridgeEnv(baseEnv, foreground) {
771
+ if (!foreground)
772
+ return baseEnv;
773
+ return { ...baseEnv, CONTEXT_MODE_BRIDGE_IDLE_MS: "0" };
774
+ }
693
775
  /**
694
776
  * Empty-but-valid handle returned when bootstrap is skipped (#516).
695
777
  * Keeps the shutdown contract intact so callers do not need null checks.
@@ -705,14 +787,16 @@ function skippedBridge() {
705
787
  }
706
788
  export async function bootstrapMCPTools(pi, serverScript, options = {}) {
707
789
  const env = options.env ?? process.env;
790
+ // #868: all bridge diagnostics go to Pi's file logger, never the TUI terminal.
791
+ const diag = makeBridgeDiag(pi);
708
792
  // Recursion guard (#516): if an ancestor bridge already incremented
709
793
  // the depth counter, refuse to spawn another child — even if the
710
794
  // binary-name check would let us through. Catches `node` shims that
711
795
  // re-exec Pi and other host swaps that bypass basename detection.
712
796
  const depth = Number.parseInt(env[BRIDGE_DEPTH_ENV] ?? "0", 10);
713
797
  if (Number.isFinite(depth) && depth > 0) {
714
- process.stderr.write(`[context-mode] WARNING: skipping MCP bridge — ${BRIDGE_DEPTH_ENV}=${depth} ` +
715
- `indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable.\n`);
798
+ diag(`[context-mode] WARNING: skipping MCP bridge — ${BRIDGE_DEPTH_ENV}=${depth} ` +
799
+ `indicates recursion (fork-bomb guard, #516). ctx_* tools will not be callable.`);
716
800
  return skippedBridge();
717
801
  }
718
802
  // Runtime guard (#516): when neither node nor bun is on PATH and the
@@ -720,11 +804,15 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
720
804
  // return an empty handle — the rest of the extension keeps working.
721
805
  const runtime = (options._resolveJsRuntime ?? resolveJsRuntimeForBridge)();
722
806
  if (runtime === null) {
723
- process.stderr.write(`[context-mode] WARNING: no JS runtime found (need node or bun on PATH). ` +
724
- `Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable.\n`);
807
+ diag(`[context-mode] WARNING: no JS runtime found (need node or bun on PATH). ` +
808
+ `Skipping MCP bridge to avoid fork bomb (#516). ctx_* tools will not be callable.`);
725
809
  return skippedBridge();
726
810
  }
727
- const client = new MCPStdioClient(serverScript, env, runtime);
811
+ // #868: the foreground interactive session's child runs with the #854 idle
812
+ // reaper disabled (CONTEXT_MODE_BRIDGE_IDLE_MS=0) so a human pause never drops
813
+ // its tools; sub-context / non-interactive children keep the reaper (#854).
814
+ const spawnEnv = foregroundBridgeEnv(env, options.foreground ?? false);
815
+ const client = new MCPStdioClient(serverScript, spawnEnv, runtime, diag);
728
816
  // Retry-on-slow-initialize (#647).
729
817
  //
730
818
  // Each attempt is independently bounded by DEFAULT_REQUEST_TIMEOUT_MS
@@ -747,8 +835,8 @@ export async function bootstrapMCPTools(pi, serverScript, options = {}) {
747
835
  if (attempt === MAX_INIT_RETRIES)
748
836
  break;
749
837
  const msg = err instanceof Error ? err.message : String(err);
750
- process.stderr.write(`[context-mode] WARNING: MCP bridge initialize failed ` +
751
- `(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying…\n`);
838
+ diag(`[context-mode] WARNING: MCP bridge initialize failed ` +
839
+ `(attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}): ${msg}. Retrying…`);
752
840
  // Reclaim the failed child's fds before respawning. shutdown() is
753
841
  // idempotent and bounded by a 5s SIGKILL fallback (#472 round-3),
754
842
  // so a child stuck in an uninterruptible syscall cannot block the
@@ -84,6 +84,16 @@ export declare function lifecycleGuardIntervalForEnv(env?: NodeJS.ProcessEnv): n
84
84
  * Exported for unit-testing.
85
85
  */
86
86
  export declare function bridgeChildIdleTimeoutMs(env?: NodeJS.ProcessEnv): number;
87
+ /**
88
+ * #854 / #868: human-readable notice emitted when an idle bridge child is
89
+ * released. DX-tuned — human units (seconds, not raw ms), reassures that the
90
+ * helper reconnects automatically (it respawns on the next ctx_* call, #583),
91
+ * and drops the alarming "self-shutdown" jargon. Pure + exported so the wording
92
+ * is pinned by a test and stays grep-friendly via the #854 tag. Note: after the
93
+ * #868 fix this fires ONLY for sub-context / non-interactive children — the
94
+ * foreground interactive session's child runs with the reaper disabled.
95
+ */
96
+ export declare function idleReapMessage(idleMs: number): string;
87
97
  /**
88
98
  * #854: record MCP activity (inbound message or response). The server calls this
89
99
  * so the bridge-child idle reaper in {@link startLifecycleGuard} can distinguish
@@ -123,6 +123,19 @@ export function bridgeChildIdleTimeoutMs(env = process.env) {
123
123
  }
124
124
  return 180_000;
125
125
  }
126
+ /**
127
+ * #854 / #868: human-readable notice emitted when an idle bridge child is
128
+ * released. DX-tuned — human units (seconds, not raw ms), reassures that the
129
+ * helper reconnects automatically (it respawns on the next ctx_* call, #583),
130
+ * and drops the alarming "self-shutdown" jargon. Pure + exported so the wording
131
+ * is pinned by a test and stays grep-friendly via the #854 tag. Note: after the
132
+ * #868 fix this fires ONLY for sub-context / non-interactive children — the
133
+ * foreground interactive session's child runs with the reaper disabled.
134
+ */
135
+ export function idleReapMessage(idleMs) {
136
+ const seconds = Math.round(idleMs / 1000);
137
+ return `[context-mode] Released an idle MCP helper after ${seconds}s of inactivity to free memory; it reconnects automatically on next use. (#854)`;
138
+ }
126
139
  // #854 idle-reaper state, module-level by design: an MCP server is exactly one
127
140
  // process (one StdioServerTransport + one lifecycle guard), so these are never
128
141
  // shared across concurrent servers in production. Multiple startLifecycleGuard()
@@ -245,7 +258,9 @@ export function startLifecycleGuard(opts) {
245
258
  // further messages (#643 unbounded calls) — the false-reap regression the
246
259
  // adversarial review flagged.
247
260
  if (_inFlight === 0 && Date.now() - _lastMcpActivity >= idleMs) {
248
- process.stderr.write(`[context-mode] idle MCP bridge child self-shutdown after ${idleMs}ms with no activity (#854)\n`);
261
+ // Child's own stderr the pi bridge forwards it to pi.logger, never the
262
+ // TUI terminal (#868). DX-tuned wording via idleReapMessage.
263
+ process.stderr.write(idleReapMessage(idleMs) + "\n");
249
264
  shutdown();
250
265
  }
251
266
  }, Math.max(1000, Math.min(Math.floor(idleMs / 4), 30_000)));