pi-lean-ctx 3.7.5 → 3.8.0

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/README.md CHANGED
@@ -31,9 +31,13 @@ env vars — `~/.pi/agent/extensions/pi-lean-ctx/config.json`:
31
31
  ```
32
32
 
33
33
  `mode` → `LEAN_CTX_PI_MODE`, `enableMcp` → `LEAN_CTX_PI_ENABLE_MCP`,
34
- `binary` → `LEAN_CTX_BIN`. The `env` map is forwarded to every `lean-ctx`
35
- subprocess, so it can override `~/.lean-ctx/config.toml` engine settings.
36
- Explicit env vars still win over the file; the file wins over defaults.
34
+ `binary` → `LEAN_CTX_BIN`, `disableTools` `LEAN_CTX_PI_DISABLE_TOOLS`,
35
+ `toolPrefix` `LEAN_CTX_PI_TOOL_PREFIX` (see
36
+ [Coexisting with AFT and magic-context](#coexisting-with-aft-and-magic-context)).
37
+ The `env` map is forwarded to every `lean-ctx` subprocess, so it can override
38
+ `~/.lean-ctx/config.toml` engine settings. Explicit env vars still win over the
39
+ file; the file wins over defaults. The deny-list is the one exception — the env
40
+ and file lists are **merged**, since a deny-list is additive by intent.
37
41
 
38
42
  ## What it does
39
43
 
@@ -140,6 +144,47 @@ pi
140
144
 
141
145
  …or set `"enableMcp": false` in `~/.pi/agent/extensions/pi-lean-ctx/config.json`.
142
146
 
147
+ ## Verifying token savings
148
+
149
+ The session cache's headline claim — an **unchanged re-read costs ~13 tokens** —
150
+ is now a one-command, machine-checkable self-test (issue #361). No manual
151
+ transcript inspection required:
152
+
153
+ ```bash
154
+ lean-ctx verify-cache
155
+ ```
156
+
157
+ It reads a file twice through the real session cache and asserts the second read
158
+ collapses to a `[unchanged …]` stub:
159
+
160
+ ```text
161
+ lean-ctx verify-cache
162
+
163
+ Target: src/main.rs
164
+ Cache policy: aggressive
165
+ Read #1 (full): 3731 tokens
166
+ Read #2 (re-read): 13 tokens [unchanged stub]
167
+ Re-read savings: 100%
168
+ Cache hits (run): 1/2
169
+ CEP sessions: 42 (88% cross-call hit ratio)
170
+
171
+ PASS — session cache engaged: the unchanged re-read cost 13 tokens (≈13-token stub).
172
+ ```
173
+
174
+ - Exit code `0` = cache proven, `1` = no stub (cache not engaging), `2` =
175
+ stubbing disabled by config (e.g. `cache_policy = safe`). Add `--json` for CI.
176
+ - Pass an explicit path to probe a real file: `lean-ctx verify-cache src/app.ts`.
177
+ - `lean-ctx doctor` also prints a **Session cache** line (CEP sessions +
178
+ cross-call hit ratio) so you can answer "is the cache engaging?" at a glance.
179
+
180
+ > On Pi specifically, the embedded MCP bridge (on by default) is what holds the
181
+ > cache across calls. If `verify-cache` fails, confirm the bridge is connected
182
+ > via `/lean-ctx`; the one-shot CLI path cannot cache across calls.
183
+
184
+ This check was added in response to the independent, pre-registered
185
+ [tokbench](https://github.com/Entelligentsia/tokbench) benchmark, where the
186
+ ~13-token re-read previously had to be verified by hand.
187
+
143
188
  ## pi-mcp-adapter compatibility
144
189
 
145
190
  If you prefer using [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) to manage your MCP servers, lean-ctx integrates automatically:
@@ -195,6 +240,8 @@ Use `/lean-ctx` in Pi to check:
195
240
  - Which binary is being used
196
241
  - MCP bridge status (disabled / embedded / adapter)
197
242
  - Active `ctx_` tool names
243
+ - Coexistence info (#359): active tool prefix, tools handed to other extensions
244
+ (`Disabled`), and tools skipped due to a name already taken (`Skipped`)
198
245
 
199
246
  ## Disabling specific tools
200
247
 
@@ -210,6 +257,75 @@ Or via environment variable:
210
257
  LEAN_CTX_DISABLED_TOOLS=ctx_graph,ctx_benchmark pi
211
258
  ```
212
259
 
260
+ ## Coexisting with AFT and magic-context
261
+
262
+ pi-lean-ctx is built to **stack** with other Pi extensions such as
263
+ [AFT](https://github.com/cortexkit/aft) and
264
+ [magic-context](https://github.com/cortexkit/magic-context) (issue #359).
265
+
266
+ **No more load crashes.** If another extension already registered a tool name
267
+ (e.g. magic-context's `ctx_expand`), pi-lean-ctx now **skips that tool with a
268
+ warning** instead of crashing the whole agent. The rest of lean-ctx keeps
269
+ working. Run `/lean-ctx` to see exactly which tools were skipped.
270
+
271
+ ### Hand tool names to another extension
272
+
273
+ Use a deny-list so the other extension owns shared names while lean-ctx keeps
274
+ its compression + session-cache core (`ctx_read`, `ctx_shell`, …):
275
+
276
+ ```bash
277
+ # env: comma/space separated, case-insensitive
278
+ export LEAN_CTX_PI_DISABLE_TOOLS="ctx_memory,ctx_expand,ctx_search"
279
+ ```
280
+
281
+ …or in `~/.pi/agent/extensions/pi-lean-ctx/config.json` (merged with the env list):
282
+
283
+ ```json
284
+ {
285
+ "disableTools": ["ctx_memory", "ctx_expand", "ctx_search"]
286
+ }
287
+ ```
288
+
289
+ > This is the **Pi-extension** deny-list — it controls which tools lean-ctx
290
+ > registers *in Pi* (including its own `ctx_*` tools like `ctx_grep`). It is
291
+ > separate from the engine-level `disabled_tools` / `LEAN_CTX_DISABLED_TOOLS`,
292
+ > which hides tools from the MCP server itself.
293
+
294
+ ### Or namespace them with a prefix
295
+
296
+ Keep every tool but expose the bridge tools under your own prefix, so nothing
297
+ collides and small models see no duplicate names:
298
+
299
+ ```bash
300
+ export LEAN_CTX_PI_TOOL_PREFIX="lc_" # ctx_expand → lc_ctx_expand
301
+ ```
302
+
303
+ The signature tools (`ctx_read`, `ctx_shell`, `ctx_ls`, `ctx_find`, `ctx_grep`)
304
+ keep their stable names; only the bridge-discovered MCP tools are prefixed.
305
+
306
+ ### Curated profile (recommended division of labor)
307
+
308
+ | Concern | Owner | Why |
309
+ |---------|-------|-----|
310
+ | File reads, shell, grep/find/ls — **compression + session cache** | **lean-ctx** | ~13-token re-reads, 60–90% savings on every read/shell |
311
+ | **Long-horizon memory** (`ctx_memory`, `ctx_expand`) | magic-context | purpose-built long-term memory |
312
+ | **Symbol-aware file ops** (`aft_*`) | AFT | precise AST edits |
313
+
314
+ Copy-paste config for the profile above
315
+ (`~/.pi/agent/extensions/pi-lean-ctx/config.json`):
316
+
317
+ ```json
318
+ {
319
+ "mode": "additive",
320
+ "enableMcp": true,
321
+ "disableTools": ["ctx_memory", "ctx_expand", "ctx_search"]
322
+ }
323
+ ```
324
+
325
+ Result: no duplicate search/memory tools in the tool list, no load crash, and
326
+ each extension does what it is best at. Verify with `/lean-ctx`, which now lists
327
+ the active prefix plus any handed-off (`Disabled`) and skipped tools.
328
+
213
329
  ## Links
214
330
 
215
331
  - [lean-ctx](https://leanctx.com) — the Cognitive Context Layer for AI coding agents
@@ -30,6 +30,22 @@ export interface PiLeanCtxFileConfig {
30
30
  * (e.g. `{ "LEAN_CTX_COMPRESSION": "aggressive" }`).
31
31
  */
32
32
  env?: Record<string, string>;
33
+ /**
34
+ * Tool names lean-ctx must NOT register, handing them to another Pi
35
+ * extension instead (issue #359). Use this when coexisting with
36
+ * magic-context / AFT so duplicate `ctx_memory` / `ctx_search` / `ctx_expand`
37
+ * tools don't confuse smaller models. Equivalent to
38
+ * `LEAN_CTX_PI_DISABLE_TOOLS` (the env list and this list are merged).
39
+ */
40
+ disableTools?: string[];
41
+ /**
42
+ * Optional prefix applied to bridge-registered MCP tools (e.g. `"lc_"` turns
43
+ * `ctx_expand` into `lc_expand`) to sidestep name collisions entirely while
44
+ * still exposing the tool (issue #359). The signature tools (`ctx_read`,
45
+ * `ctx_shell`, …) keep their stable names. Equivalent to
46
+ * `LEAN_CTX_PI_TOOL_PREFIX`.
47
+ */
48
+ toolPrefix?: string;
33
49
  }
34
50
 
35
51
  export type PiMode = "additive" | "replace";
@@ -42,6 +58,10 @@ export interface ResolvedPiConfig {
42
58
  binaryOverride?: string;
43
59
  /** Engine env overrides forwarded to lean-ctx subprocesses. */
44
60
  forwardedEnv: Record<string, string>;
61
+ /** Lower-cased tool names handed to other extensions / never registered (#359). */
62
+ disabledTools: Set<string>;
63
+ /** Optional prefix for bridge-registered MCP tools (#359). */
64
+ toolPrefix?: string;
45
65
  /** Absolute path the loader looked at (whether or not it existed). */
46
66
  configPath: string;
47
67
  /** True when the file existed and parsed into a JSON object. */
@@ -87,6 +107,42 @@ function resolveMode(fileMode: string | undefined): PiMode {
87
107
  return raw === "replace" ? "replace" : "additive";
88
108
  }
89
109
 
110
+ /** Split a comma/whitespace-separated tool list into trimmed, non-empty names. */
111
+ function parseToolList(raw: string | undefined): string[] {
112
+ if (!raw) return [];
113
+ return raw
114
+ .split(/[,\s]+/)
115
+ .map((t) => t.trim())
116
+ .filter((t) => t.length > 0);
117
+ }
118
+
119
+ /**
120
+ * Union of the file `disableTools` and the `LEAN_CTX_PI_DISABLE_TOOLS` env list,
121
+ * lower-cased. A deny-list is additive by nature, so both sources contribute
122
+ * (rather than env replacing file) — the intent is always "do not register X".
123
+ */
124
+ function resolveDisabledTools(fileList: unknown): Set<string> {
125
+ const set = new Set<string>();
126
+ if (Array.isArray(fileList)) {
127
+ for (const t of fileList) {
128
+ if (typeof t === "string" && t.trim().length > 0) set.add(t.trim().toLowerCase());
129
+ }
130
+ }
131
+ for (const t of parseToolList(process.env.LEAN_CTX_PI_DISABLE_TOOLS)) {
132
+ set.add(t.toLowerCase());
133
+ }
134
+ return set;
135
+ }
136
+
137
+ /** Env `LEAN_CTX_PI_TOOL_PREFIX` wins over the file `toolPrefix`; empty ⇒ none. */
138
+ function resolveToolPrefix(filePrefix: unknown): string | undefined {
139
+ const raw = process.env.LEAN_CTX_PI_TOOL_PREFIX
140
+ ?? (typeof filePrefix === "string" ? filePrefix : undefined);
141
+ if (typeof raw !== "string") return undefined;
142
+ const trimmed = raw.trim();
143
+ return trimmed.length > 0 ? trimmed : undefined;
144
+ }
145
+
90
146
  /**
91
147
  * Loads and resolves the Pi override config. Precedence per setting is
92
148
  * "most explicit wins": an explicit `LEAN_CTX_PI_*` / `LEAN_CTX_BIN` env var
@@ -123,6 +179,8 @@ export function loadPiConfig(): ResolvedPiConfig {
123
179
  enableMcp,
124
180
  binaryOverride,
125
181
  forwardedEnv,
182
+ disabledTools: resolveDisabledTools(cfg.disableTools),
183
+ toolPrefix: resolveToolPrefix(cfg.toolPrefix),
126
184
  configPath,
127
185
  loaded,
128
186
  };
@@ -224,6 +224,7 @@ function withFooter(text: string, opts?: {
224
224
  limit?: number;
225
225
  always?: boolean;
226
226
  preferEstimate?: boolean;
227
+ suppressIfNoSaving?: boolean;
227
228
  }) {
228
229
  const parsed = parseLeanCtxOutput(text);
229
230
  const limited = limitLines(parsed.text, opts?.limit);
@@ -238,6 +239,14 @@ function withFooter(text: string, opts?: {
238
239
  }
239
240
  if (!stats) return { text: limited.text, stats: undefined, truncated: limited.truncated };
240
241
 
242
+ // On tiny files compression cannot beat the envelope, so a "0%" footer would
243
+ // be pure overhead — larger payload than the source for no gain (#361). Keep
244
+ // the computed stats for telemetry (`details.compression`) but drop the
245
+ // visible footer when nothing was actually saved.
246
+ if (opts?.suppressIfNoSaving && stats.percentSaved <= 0) {
247
+ return { text: limited.text, stats, truncated: limited.truncated };
248
+ }
249
+
241
250
  const footer = formatFooter(stats);
242
251
  const base = limited.text.trimEnd();
243
252
  return {
@@ -332,6 +341,31 @@ export default async function (pi: ExtensionAPI) {
332
341
  // registered (the bridge is started at the end of this function).
333
342
  let mcpBridge: McpBridge | null = null;
334
343
 
344
+ // ── Collision-safe registration (#359) ───────────────────────────────────
345
+ // lean-ctx must coexist with other Pi extensions (AFT, magic-context). If a
346
+ // tool name is already claimed, skip it with a warning instead of letting the
347
+ // whole agent crash on load. Users can also hand a name to another extension
348
+ // via LEAN_CTX_PI_DISABLE_TOOLS / config.json `disableTools`. All ctx_* tools
349
+ // below register through this wrapper instead of pi.registerTool directly.
350
+ const skippedExtensionTools: string[] = [];
351
+ const disabledExtensionTools: string[] = [];
352
+ const registerTool = ((def: { name?: unknown }): void => {
353
+ const name = typeof def.name === "string" ? def.name : String(def.name);
354
+ if (PI_CONFIG.disabledTools.has(name.toLowerCase())) {
355
+ disabledExtensionTools.push(name);
356
+ return;
357
+ }
358
+ try {
359
+ (pi.registerTool as (d: unknown) => void)(def);
360
+ } catch (err) {
361
+ const msg = err instanceof Error ? err.message : String(err);
362
+ skippedExtensionTools.push(name);
363
+ console.error(
364
+ `[pi-lean-ctx] Skipped tool "${name}" — already registered elsewhere? (${msg})`,
365
+ );
366
+ }
367
+ }) as unknown as ExtensionAPI["registerTool"];
368
+
335
369
  const baseBashTool = createBashToolDefinition(process.cwd(), {
336
370
  spawnHook: ({ command, cwd, env }) => {
337
371
  const bin = resolveBinary();
@@ -352,7 +386,7 @@ export default async function (pi: ExtensionAPI) {
352
386
  });
353
387
 
354
388
  // ── ctx_shell (replaces bash) ─────────────────────────────────────────
355
- pi.registerTool({
389
+ registerTool({
356
390
  name: "ctx_shell",
357
391
  label: "ctx_shell",
358
392
  description:
@@ -413,7 +447,7 @@ export default async function (pi: ExtensionAPI) {
413
447
  // ── ctx_read (replaces read) ──────────────────────────────────────────
414
448
  const nativeReadTool = createReadToolDefinition(process.cwd());
415
449
 
416
- pi.registerTool({
450
+ registerTool({
417
451
  name: "ctx_read",
418
452
  label: "ctx_read",
419
453
  description:
@@ -505,7 +539,7 @@ export default async function (pi: ExtensionAPI) {
505
539
  const bridged = await mcpBridge.callTool("ctx_read", { path: absolutePath, mode }, signal);
506
540
  const bridgedText = bridged.content.map((block) => block.text).join("");
507
541
  const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
508
- const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true });
542
+ const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true });
509
543
  return {
510
544
  content: [{ type: "text", text: decorated.text }],
511
545
  details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx-bridge", mode, compression: decorated.stats },
@@ -518,7 +552,7 @@ export default async function (pi: ExtensionAPI) {
518
552
  try {
519
553
  const output = await execLeanCtx(pi, args);
520
554
  const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
521
- const decorated = withFooter(output, { originalText: originalSlice.text, always: true, preferEstimate: true });
555
+ const decorated = withFooter(output, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true });
522
556
  return {
523
557
  content: [{ type: "text", text: decorated.text }],
524
558
  details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx", mode, compression: decorated.stats },
@@ -555,7 +589,7 @@ export default async function (pi: ExtensionAPI) {
555
589
  );
556
590
  const bridgedText = bridged.content.map((block) => block.text).join("");
557
591
  const originalText = await readFile(absolutePath, "utf8");
558
- const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true });
592
+ const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
559
593
  return {
560
594
  content: [{ type: "text", text: decorated.text }],
561
595
  details: { path: absolutePath, source: "lean-ctx-bridge", mode, compression: decorated.stats },
@@ -568,7 +602,7 @@ export default async function (pi: ExtensionAPI) {
568
602
  const args = ["read", absolutePath, "-m", mode, ...(isExplicitFull ? ["--fresh"] : [])];
569
603
  const output = await execLeanCtx(pi, args);
570
604
  const originalText = await readFile(absolutePath, "utf8");
571
- const decorated = withFooter(output, { originalText, always: true, preferEstimate: true });
605
+ const decorated = withFooter(output, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
572
606
 
573
607
  return {
574
608
  content: [{ type: "text", text: decorated.text }],
@@ -578,7 +612,7 @@ export default async function (pi: ExtensionAPI) {
578
612
  });
579
613
 
580
614
  // ── ctx_ls (replaces ls) ──────────────────────────────────────────────
581
- pi.registerTool({
615
+ registerTool({
582
616
  name: "ctx_ls",
583
617
  label: "ctx_ls",
584
618
  description: "List a directory. Prefer over native ls (compact, summarized). Use limit to reduce output size.",
@@ -597,7 +631,7 @@ export default async function (pi: ExtensionAPI) {
597
631
  });
598
632
 
599
633
  // ── ctx_find (replaces find) ──────────────────────────────────────────
600
- pi.registerTool({
634
+ registerTool({
601
635
  name: "ctx_find",
602
636
  label: "ctx_find",
603
637
  description: "Find files by glob. Prefer over native find/fd (gitignore-aware). Use limit to reduce output size.",
@@ -616,7 +650,7 @@ export default async function (pi: ExtensionAPI) {
616
650
  });
617
651
 
618
652
  // ── ctx_grep (replaces grep) ──────────────────────────────────────────
619
- pi.registerTool({
653
+ registerTool({
620
654
  name: "ctx_grep",
621
655
  label: "ctx_grep",
622
656
  description: "Search code. Prefer over native Grep/ripgrep (compact, ranked). Use limit to cap matches, context for surrounding lines.",
@@ -649,7 +683,7 @@ export default async function (pi: ExtensionAPI) {
649
683
  });
650
684
 
651
685
  // ── lean_ctx (CLI passthrough) ────────────────────────────────────────
652
- pi.registerTool({
686
+ registerTool({
653
687
  name: "lean_ctx",
654
688
  label: "lean_ctx",
655
689
  description:
@@ -674,7 +708,10 @@ export default async function (pi: ExtensionAPI) {
674
708
  // --agent pi` writes that entry by default — so it must not silently disable the
675
709
  // bridge a user explicitly requested via LEAN_CTX_PI_ENABLE_MCP=1 / enableMcp.
676
710
  mcpBridge = enableMcpBridge
677
- ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv)
711
+ ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, {
712
+ disabledTools: PI_CONFIG.disabledTools,
713
+ toolPrefix: PI_CONFIG.toolPrefix,
714
+ })
678
715
  : null;
679
716
 
680
717
  if (mcpBridge) {
@@ -726,6 +763,22 @@ export default async function (pi: ExtensionAPI) {
726
763
  }
727
764
  }
728
765
 
766
+ // Coexistence diagnostics (#359): the active prefix plus which tools we
767
+ // handed off or skipped, so a user stacking AFT / magic-context can see
768
+ // the exact split at a glance.
769
+ const skipped = [...(status?.skippedTools ?? []), ...skippedExtensionTools];
770
+ const disabled = [...(status?.disabledTools ?? []), ...disabledExtensionTools];
771
+ const prefix = status?.toolPrefix ?? PI_CONFIG.toolPrefix;
772
+ if (prefix) {
773
+ lines.push(`Tool prefix: "${prefix}" (bridge tools exposed as ${prefix}<name>)`);
774
+ }
775
+ if (disabled.length > 0) {
776
+ lines.push(`Disabled (handed to other extensions): ${disabled.join(", ")}`);
777
+ }
778
+ if (skipped.length > 0) {
779
+ lines.push(`Skipped (name already taken): ${skipped.join(", ")}`);
780
+ }
781
+
729
782
  // Show active ctx_ tools
730
783
  const ctxTools = pi.getActiveTools().filter((n) => n.startsWith("ctx_") || n === "lean_ctx");
731
784
  if (ctxTools.length > 0) {
@@ -27,6 +27,20 @@ type McpTool = {
27
27
  inputSchema?: Record<string, unknown>;
28
28
  };
29
29
 
30
+ /**
31
+ * How the bridge should expose discovered MCP tools, so lean-ctx can coexist
32
+ * with other Pi extensions (AFT, magic-context) instead of crashing on a name
33
+ * collision (issue #359).
34
+ */
35
+ export type BridgeToolPolicy = {
36
+ /** Lower-cased tool names the bridge must not register at all. */
37
+ disabledTools: Set<string>;
38
+ /** Optional prefix applied to the Pi-facing tool name (not the MCP call). */
39
+ toolPrefix?: string;
40
+ };
41
+
42
+ const DEFAULT_TOOL_POLICY: BridgeToolPolicy = { disabledTools: new Set() };
43
+
30
44
  function isAbortLikeError(error: unknown): boolean {
31
45
  if (!(error instanceof Error)) return false;
32
46
  const msg = error.message.toLowerCase();
@@ -57,17 +71,25 @@ export class McpBridge {
57
71
  private client: Client | null = null;
58
72
  private transport: StdioClientTransport | null = null;
59
73
  private registeredTools: string[] = [];
74
+ private skippedTools: string[] = [];
75
+ private disabledToolNames: string[] = [];
60
76
  private connected = false;
61
77
  private binary: string;
62
78
  private extraEnv: Record<string, string>;
79
+ private policy: BridgeToolPolicy;
63
80
  private reconnectAttempts = 0;
64
81
  private lastError: string | undefined;
65
82
  private lastHungTool: string | undefined;
66
83
  private lastRetry: McpBridgeRetryState | undefined;
67
84
 
68
- constructor(binary: string, extraEnv: Record<string, string> = {}) {
85
+ constructor(
86
+ binary: string,
87
+ extraEnv: Record<string, string> = {},
88
+ policy: BridgeToolPolicy = DEFAULT_TOOL_POLICY,
89
+ ) {
69
90
  this.binary = binary;
70
91
  this.extraEnv = extraEnv;
92
+ this.policy = policy;
71
93
  }
72
94
 
73
95
  async start(pi: ExtensionAPI): Promise<void> {
@@ -155,6 +177,10 @@ export class McpBridge {
155
177
 
156
178
  for (const tool of tools) {
157
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
+ }
158
184
  this.registerMcpTool(pi, tool);
159
185
  }
160
186
  }
@@ -162,25 +188,41 @@ export class McpBridge {
162
188
  private registerMcpTool(pi: ExtensionAPI, tool: McpTool): void {
163
189
  const bridge = this;
164
190
  const schema = this.jsonSchemaToTypebox(tool.inputSchema);
191
+ // The prefix renames only the Pi-facing tool; the MCP call still targets
192
+ // the real `tool.name` captured in the closure below.
193
+ const exposedName = this.policy.toolPrefix
194
+ ? `${this.policy.toolPrefix}${tool.name}`
195
+ : tool.name;
165
196
 
166
- pi.registerTool({
167
- name: tool.name,
168
- label: tool.name,
169
- description: tool.description ?? `lean-ctx MCP tool: ${tool.name}`,
170
- promptSnippet: tool.description ?? tool.name,
171
- parameters: schema,
172
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
173
- const result = await bridge.callTool(
174
- tool.name,
175
- params as Record<string, unknown>,
176
- signal,
177
- );
178
- // Pi's AgentToolResult requires a `details` field; MCP tool output has none.
179
- return { ...result, details: undefined };
180
- },
181
- });
182
-
183
- this.registeredTools.push(tool.name);
197
+ try {
198
+ pi.registerTool({
199
+ name: exposedName,
200
+ label: exposedName,
201
+ description: tool.description ?? `lean-ctx MCP tool: ${tool.name}`,
202
+ promptSnippet: tool.description ?? tool.name,
203
+ parameters: schema,
204
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
205
+ const result = await bridge.callTool(
206
+ tool.name,
207
+ params as Record<string, unknown>,
208
+ signal,
209
+ );
210
+ // Pi's AgentToolResult requires a `details` field; MCP tool output has none.
211
+ return { ...result, details: undefined };
212
+ },
213
+ });
214
+ this.registeredTools.push(exposedName);
215
+ } catch (err) {
216
+ // Another extension (e.g. magic-context) already owns this name. Skip it
217
+ // and keep going so the whole agent doesn't crash on load (#359). Set a
218
+ // prefix (LEAN_CTX_PI_TOOL_PREFIX) or disable the tool to resolve cleanly.
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ this.skippedTools.push(exposedName);
221
+ console.error(
222
+ `[lean-ctx MCP bridge] Skipped tool "${exposedName}" — already registered by another extension? (${msg}). `
223
+ + "Set LEAN_CTX_PI_TOOL_PREFIX or add it to LEAN_CTX_PI_DISABLE_TOOLS to silence this.",
224
+ );
225
+ }
184
226
  }
185
227
 
186
228
  async callTool(
@@ -352,6 +394,9 @@ export class McpBridge {
352
394
  connected: this.connected,
353
395
  toolCount: this.registeredTools.length,
354
396
  toolNames: [...this.registeredTools],
397
+ skippedTools: [...this.skippedTools],
398
+ disabledTools: [...this.disabledToolNames],
399
+ toolPrefix: this.policy.toolPrefix,
355
400
  reconnectAttempts: this.reconnectAttempts,
356
401
  lastError: this.lastError,
357
402
  lastHungTool: this.lastHungTool,
@@ -16,6 +16,12 @@ export type McpBridgeStatus = {
16
16
  connected: boolean;
17
17
  toolCount: number;
18
18
  toolNames: string[];
19
+ /** Tools skipped because another extension already claimed the name (#359). */
20
+ skippedTools: string[];
21
+ /** Tools not registered because the user disabled them via config (#359). */
22
+ disabledTools: string[];
23
+ /** Active prefix applied to bridge tool names, if any (#359). */
24
+ toolPrefix?: string;
19
25
  reconnectAttempts: number;
20
26
  lastError?: string;
21
27
  lastHungTool?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-ctx",
3
- "version": "3.7.5",
3
+ "version": "3.8.0",
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.",
5
5
  "keywords": [
6
6
  "pi-package",