pi-lean-ctx 3.8.4 → 3.8.5

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.
@@ -356,8 +356,15 @@ export default async function (pi: ExtensionAPI) {
356
356
  // below register through this wrapper instead of pi.registerTool directly.
357
357
  const skippedExtensionTools: string[] = [];
358
358
  const disabledExtensionTools: string[] = [];
359
+ // The exact set of tool names this extension owns locally (CLI-first
360
+ // replacements). Handed to the embedded bridge so it skips precisely these
361
+ // MCP namesakes and can never suppress a tool without a local replacement —
362
+ // the root cause of #409. Recorded for every name we manage, so the set
363
+ // tracks the registrations automatically instead of a hand-maintained list.
364
+ const localToolNames = new Set<string>();
359
365
  const registerTool = ((def: { name?: unknown }): void => {
360
366
  const name = typeof def.name === "string" ? def.name : String(def.name);
367
+ localToolNames.add(name);
361
368
  if (PI_CONFIG.disabledTools.has(name.toLowerCase())) {
362
369
  disabledExtensionTools.push(name);
363
370
  return;
@@ -741,10 +748,15 @@ export default async function (pi: ExtensionAPI) {
741
748
  ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, {
742
749
  disabledTools: PI_CONFIG.disabledTools,
743
750
  toolPrefix: PI_CONFIG.toolPrefix,
751
+ localTools: localToolNames,
744
752
  })
745
753
  : null;
746
754
 
747
755
  if (mcpBridge) {
756
+ pi.on("session_shutdown", async () => {
757
+ await mcpBridge?.shutdown();
758
+ });
759
+
748
760
  try {
749
761
  await mcpBridge.start(pi);
750
762
  } 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.5",
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
  }