pi-lean-ctx 3.8.4 → 3.8.6

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.
@@ -15,6 +15,15 @@ import { resolve } from "node:path";
15
15
  export interface PiLeanCtxFileConfig {
16
16
  /** Tool exposure: "additive" (Pi builtins + ctx_*) or "replace" (ctx_* only). */
17
17
  mode?: string;
18
+ /**
19
+ * Suppress only the native `bash` builtin (keep the other Pi builtins) so all
20
+ * shell runs through `ctx_shell`. In `additive` mode both `bash` and
21
+ * `ctx_shell` are active and agents tend to pick the uncompressed native
22
+ * `bash` (the R1 finding: 102 bash / 0 ctx_shell), so build/test output never
23
+ * gets compressed or metered. Default `false`; `replace` mode already implies
24
+ * it. Equivalent to `LEAN_CTX_PI_ROUTE_SHELL=1`.
25
+ */
26
+ routeShell?: boolean;
18
27
  /**
19
28
  * Start the embedded MCP bridge (the persistent session cache). Default
20
29
  * `true`; set `false` (or `LEAN_CTX_PI_ENABLE_MCP=0`) to force the one-shot
@@ -53,6 +62,8 @@ export type PiMode = "additive" | "replace";
53
62
  /** Fully resolved configuration after merging file, env vars and defaults. */
54
63
  export interface ResolvedPiConfig {
55
64
  mode: PiMode;
65
+ /** Force shell through `ctx_shell` by suppressing the native `bash` builtin. */
66
+ routeShell: boolean;
56
67
  enableMcp: boolean;
57
68
  /** Binary path from the file; `LEAN_CTX_BIN` still takes precedence at use time. */
58
69
  binaryOverride?: string;
@@ -107,6 +118,20 @@ function resolveMode(fileMode: string | undefined): PiMode {
107
118
  return raw === "replace" ? "replace" : "additive";
108
119
  }
109
120
 
121
+ /**
122
+ * Whether the native `bash` builtin should be suppressed so shell runs through
123
+ * `ctx_shell`. `replace` mode already hides every builtin, so it implies this;
124
+ * otherwise the env var wins over the file flag, defaulting off (non-regressive
125
+ * — `additive` users keep native `bash` unless they opt in).
126
+ */
127
+ export function resolveRouteShell(mode: PiMode, fileRouteShell: unknown): boolean {
128
+ if (mode === "replace") return true;
129
+ if (process.env.LEAN_CTX_PI_ROUTE_SHELL !== undefined) {
130
+ return envFlag("LEAN_CTX_PI_ROUTE_SHELL");
131
+ }
132
+ return fileRouteShell === true;
133
+ }
134
+
110
135
  /** Split a comma/whitespace-separated tool list into trimmed, non-empty names. */
111
136
  function parseToolList(raw: string | undefined): string[] {
112
137
  if (!raw) return [];
@@ -174,8 +199,11 @@ export function loadPiConfig(): ResolvedPiConfig {
174
199
  const binaryOverride =
175
200
  typeof cfg.binary === "string" && cfg.binary.length > 0 ? cfg.binary : undefined;
176
201
 
202
+ const mode = resolveMode(cfg.mode);
203
+
177
204
  return {
178
- mode: resolveMode(cfg.mode),
205
+ mode,
206
+ routeShell: resolveRouteShell(mode, cfg.routeShell),
179
207
  enableMcp,
180
208
  binaryOverride,
181
209
  forwardedEnv,
@@ -333,12 +333,25 @@ export default async function (pi: ExtensionAPI) {
333
333
  process.env.LEAN_CTX_COMPRESS = "1";
334
334
  process.env.LEAN_CTX_SAVINGS_FOOTER ??= "always";
335
335
 
336
- // Defer setActiveTools to session_start — runtime actions aren't available during extension load
337
- // In "replace" mode, disable Pi builtins and only expose ctx_* tools.
338
- // In "additive" mode (default), keep Pi builtins alongside ctx_* tools.
339
- if (PI_MODE === "replace") {
336
+ // Defer setActiveTools to session_start — runtime actions aren't available
337
+ // during extension load. Which Pi builtins to suppress:
338
+ // - "replace" mode → all five (read/bash/ls/find/grep): expose only ctx_*.
339
+ // - "additive" + routeShell → just `bash`: route shell through ctx_shell so
340
+ // build/test output is compressed and metered, while the read/list/search
341
+ // builtins stay available next to ctx_*. Without this, an agent offered
342
+ // both `bash` and `ctx_shell` picks the uncompressed native `bash` (the R1
343
+ // finding: 102 bash / 0 ctx_shell), so the heaviest addressable surface in
344
+ // a fix task — make/reproducer/test logs — never reaches the compressor.
345
+ // - "additive" without routeShell → suppress nothing (keep Pi builtins).
346
+ const suppressedBuiltins = PI_MODE === "replace"
347
+ ? DISABLED_BUILTIN_TOOLS
348
+ : PI_CONFIG.routeShell
349
+ ? new Set(["bash"])
350
+ : new Set<string>();
351
+
352
+ if (suppressedBuiltins.size > 0) {
340
353
  pi.on("session_start", () => {
341
- const activeTools = pi.getActiveTools().filter((name) => !DISABLED_BUILTIN_TOOLS.has(name));
354
+ const activeTools = pi.getActiveTools().filter((name) => !suppressedBuiltins.has(name));
342
355
  pi.setActiveTools(activeTools);
343
356
  });
344
357
  }
@@ -356,8 +369,15 @@ export default async function (pi: ExtensionAPI) {
356
369
  // below register through this wrapper instead of pi.registerTool directly.
357
370
  const skippedExtensionTools: string[] = [];
358
371
  const disabledExtensionTools: string[] = [];
372
+ // The exact set of tool names this extension owns locally (CLI-first
373
+ // replacements). Handed to the embedded bridge so it skips precisely these
374
+ // MCP namesakes and can never suppress a tool without a local replacement —
375
+ // the root cause of #409. Recorded for every name we manage, so the set
376
+ // tracks the registrations automatically instead of a hand-maintained list.
377
+ const localToolNames = new Set<string>();
359
378
  const registerTool = ((def: { name?: unknown }): void => {
360
379
  const name = typeof def.name === "string" ? def.name : String(def.name);
380
+ localToolNames.add(name);
361
381
  if (PI_CONFIG.disabledTools.has(name.toLowerCase())) {
362
382
  disabledExtensionTools.push(name);
363
383
  return;
@@ -741,10 +761,15 @@ export default async function (pi: ExtensionAPI) {
741
761
  ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, {
742
762
  disabledTools: PI_CONFIG.disabledTools,
743
763
  toolPrefix: PI_CONFIG.toolPrefix,
764
+ localTools: localToolNames,
744
765
  })
745
766
  : null;
746
767
 
747
768
  if (mcpBridge) {
769
+ pi.on("session_shutdown", async () => {
770
+ await mcpBridge?.shutdown();
771
+ });
772
+
748
773
  try {
749
774
  await mcpBridge.start(pi);
750
775
  } catch (err) {
@@ -7,21 +7,11 @@ import type { McpBridgeRetryState, McpBridgeStatus } from "./types.js";
7
7
  /** Result shape returned by the MCP client's `callTool`. */
8
8
  type McpCallResult = Awaited<ReturnType<Client["callTool"]>>;
9
9
 
10
- const CLI_OVERRIDE_TOOLS = new Set([
11
- "ctx_read",
12
- "ctx_multi_read",
13
- "ctx_shell",
14
- "ctx_search",
15
- "ctx_tree",
16
- ]);
17
-
18
- // No additional prefix filter — CLI_OVERRIDE_TOOLS covers exactly the tools we overwrite
19
-
20
10
  const MAX_RECONNECT_ATTEMPTS = 3;
21
11
  const RECONNECT_DELAY_MS = 2000;
22
12
  const TOOL_CALL_TIMEOUT_MS = 120000;
23
13
 
24
- type McpTool = {
14
+ export type McpTool = {
25
15
  name: string;
26
16
  description?: string;
27
17
  inputSchema?: Record<string, unknown>;
@@ -35,11 +25,47 @@ type McpTool = {
35
25
  export type BridgeToolPolicy = {
36
26
  /** Lower-cased tool names the bridge must not register at all. */
37
27
  disabledTools: Set<string>;
28
+ /**
29
+ * Tool names already owned by a local CLI-first replacement in `index.ts`
30
+ * (e.g. `ctx_read`, `ctx_shell`). The bridge must not re-register their MCP
31
+ * namesakes. This is the *actual* set of locally registered names, supplied
32
+ * by `index.ts`, so a tool can never be suppressed without a replacement
33
+ * (the root cause of issue #409).
34
+ */
35
+ localTools: Set<string>;
38
36
  /** Optional prefix applied to the Pi-facing tool name (not the MCP call). */
39
37
  toolPrefix?: string;
40
38
  };
41
39
 
42
- const DEFAULT_TOOL_POLICY: BridgeToolPolicy = { disabledTools: new Set() };
40
+ const DEFAULT_TOOL_POLICY: BridgeToolPolicy = {
41
+ disabledTools: new Set(),
42
+ localTools: new Set(),
43
+ };
44
+
45
+ /**
46
+ * Partition discovered MCP tools into the ones the bridge should register and
47
+ * the ones it must skip. A tool is skipped if and only if it is owned by a
48
+ * local CLI-first replacement (`localTools`); anything in `disabledTools` is
49
+ * handed to another extension (#359). Pure and exported so the #409 invariant —
50
+ * never suppress a tool without a local replacement — is locked by unit tests.
51
+ */
52
+ export function selectBridgeTools(
53
+ tools: McpTool[],
54
+ localTools: Set<string>,
55
+ disabledTools: Set<string>,
56
+ ): { toRegister: McpTool[]; disabled: string[] } {
57
+ const toRegister: McpTool[] = [];
58
+ const disabled: string[] = [];
59
+ for (const tool of tools) {
60
+ if (localTools.has(tool.name)) continue;
61
+ if (disabledTools.has(tool.name.toLowerCase())) {
62
+ disabled.push(tool.name);
63
+ continue;
64
+ }
65
+ toRegister.push(tool);
66
+ }
67
+ return { toRegister, disabled };
68
+ }
43
69
 
44
70
  function isAbortLikeError(error: unknown): boolean {
45
71
  if (!(error instanceof Error)) return false;
@@ -78,6 +104,8 @@ export class McpBridge {
78
104
  private extraEnv: Record<string, string>;
79
105
  private policy: BridgeToolPolicy;
80
106
  private reconnectAttempts = 0;
107
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
108
+ private shuttingDown = false;
81
109
  private lastError: string | undefined;
82
110
  private lastHungTool: string | undefined;
83
111
  private lastRetry: McpBridgeRetryState | undefined;
@@ -104,6 +132,8 @@ export class McpBridge {
104
132
  }
105
133
 
106
134
  private async connect(): Promise<void> {
135
+ if (this.shuttingDown) return;
136
+
107
137
  this.transport = new StdioClientTransport({
108
138
  command: this.binary,
109
139
  args: [],
@@ -120,7 +150,7 @@ export class McpBridge {
120
150
  this.transport.onclose = () => {
121
151
  this.connected = false;
122
152
  this.lastError = "MCP transport closed";
123
- this.scheduleReconnect();
153
+ if (!this.shuttingDown) this.scheduleReconnect();
124
154
  };
125
155
 
126
156
  this.transport.onerror = (err) => {
@@ -135,6 +165,8 @@ export class McpBridge {
135
165
  }
136
166
 
137
167
  private scheduleReconnect(): void {
168
+ if (this.shuttingDown) return;
169
+ if (this.reconnectTimer) return;
138
170
  if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
139
171
  this.lastError = `Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached.`;
140
172
  console.error(
@@ -146,18 +178,22 @@ export class McpBridge {
146
178
  this.reconnectAttempts++;
147
179
  const delay = RECONNECT_DELAY_MS * this.reconnectAttempts;
148
180
 
149
- setTimeout(async () => {
181
+ this.reconnectTimer = setTimeout(async () => {
182
+ this.reconnectTimer = undefined;
183
+ if (this.shuttingDown) return;
150
184
  try {
151
185
  await this.connect();
152
- console.error("[lean-ctx MCP bridge] Reconnected successfully");
186
+ if (!this.shuttingDown) console.error("[lean-ctx MCP bridge] Reconnected successfully");
153
187
  } catch (error) {
154
188
  this.lastError = error instanceof Error ? error.message : String(error);
155
189
  this.scheduleReconnect();
156
190
  }
157
191
  }, delay);
192
+ (this.reconnectTimer as { unref?: () => void }).unref?.();
158
193
  }
159
194
 
160
195
  private async forceReconnect(): Promise<void> {
196
+ if (this.shuttingDown) return;
161
197
  this.connected = false;
162
198
  try {
163
199
  await this.client?.close();
@@ -175,12 +211,13 @@ export class McpBridge {
175
211
  const result = await this.client.listTools();
176
212
  const tools = (result.tools ?? []) as McpTool[];
177
213
 
178
- for (const tool of tools) {
179
- if (CLI_OVERRIDE_TOOLS.has(tool.name)) continue;
180
- if (this.policy.disabledTools.has(tool.name.toLowerCase())) {
181
- this.disabledToolNames.push(tool.name);
182
- continue;
183
- }
214
+ const { toRegister, disabled } = selectBridgeTools(
215
+ tools,
216
+ this.policy.localTools,
217
+ this.policy.disabledTools,
218
+ );
219
+ this.disabledToolNames.push(...disabled);
220
+ for (const tool of toRegister) {
184
221
  this.registerMcpTool(pi, tool);
185
222
  }
186
223
  }
@@ -405,7 +442,12 @@ export class McpBridge {
405
442
  }
406
443
 
407
444
  async shutdown(): Promise<void> {
445
+ this.shuttingDown = true;
408
446
  this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS;
447
+ if (this.reconnectTimer) {
448
+ clearTimeout(this.reconnectTimer);
449
+ this.reconnectTimer = undefined;
450
+ }
409
451
  try {
410
452
  await this.client?.close();
411
453
  } catch {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-lean-ctx",
3
- "version": "3.8.4",
4
- "description": "Pi Coding Agent extension \u2014 routes bash/read/grep/find/ls through lean-ctx for strong token savings. The embedded MCP bridge (on by default) adds a persistent session cache so unchanged re-reads cost ~13 tokens.",
3
+ "version": "3.8.6",
4
+ "description": "Pi Coding Agent extension routes bash/read/grep/find/ls through lean-ctx for strong token savings. The embedded MCP bridge (on by default) adds a persistent session cache so unchanged re-reads cost ~13 tokens.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "lean-ctx",
@@ -21,6 +21,10 @@
21
21
  "bugs": {
22
22
  "url": "https://github.com/yvgude/lean-ctx/issues"
23
23
  },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ },
24
28
  "dependencies": {
25
29
  "@modelcontextprotocol/sdk": "^1.29.0"
26
30
  },
@@ -39,6 +43,14 @@
39
43
  "README.md"
40
44
  ],
41
45
  "devDependencies": {
42
- "typescript": "^6.0.3"
46
+ "@earendil-works/pi-coding-agent": "^0.79.3",
47
+ "@earendil-works/pi-tui": "^0.79.3",
48
+ "typebox": "^1.2.9",
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^4.1.8"
51
+ },
52
+ "overrides": {
53
+ "vite": "^6.4.2",
54
+ "esbuild": "^0.28.1"
43
55
  }
44
56
  }