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.
- package/extensions/config.ts +29 -1
- package/extensions/index.ts +30 -5
- package/extensions/mcp-bridge.ts +63 -21
- package/package.json +15 -3
package/extensions/config.ts
CHANGED
|
@@ -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
|
|
205
|
+
mode,
|
|
206
|
+
routeShell: resolveRouteShell(mode, cfg.routeShell),
|
|
179
207
|
enableMcp,
|
|
180
208
|
binaryOverride,
|
|
181
209
|
forwardedEnv,
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
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) => !
|
|
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) {
|
package/extensions/mcp-bridge.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
"description": "Pi Coding Agent extension
|
|
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
|
-
"
|
|
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
|
}
|