pi-lean-ctx 3.2.1 → 3.3.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.
@@ -490,10 +490,22 @@ export default function (pi: ExtensionAPI) {
490
490
  const lines: string[] = [];
491
491
  lines.push(found ? `Binary: ${bin}` : "Binary: NOT FOUND — install: cargo install lean-ctx");
492
492
  lines.push(`MCP bridge: ${status.mode} (${status.connected ? "connected" : "disconnected"})`);
493
+ lines.push(`Reconnect attempts: ${status.reconnectAttempts}`);
493
494
  lines.push(`MCP tools: ${status.toolCount} registered`);
494
495
  if (status.toolNames.length > 0) {
495
496
  lines.push(` ${status.toolNames.join(", ")}`);
496
497
  }
498
+ if (status.lastHungTool) {
499
+ lines.push(`Last hung tool: ${status.lastHungTool}`);
500
+ }
501
+ if (status.lastRetry) {
502
+ lines.push(
503
+ `Last retry: ${status.lastRetry.toolName} (${status.lastRetry.reason}) at ${status.lastRetry.timestamp}`,
504
+ );
505
+ }
506
+ if (status.lastError) {
507
+ lines.push(`Last bridge error: ${status.lastError}`);
508
+ }
497
509
 
498
510
  ctx.ui.notify(lines.join("\n"), found && status.connected ? "info" : "warning");
499
511
  },
@@ -2,7 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
- import type { McpBridgeStatus } from "./types.js";
5
+ import type { McpBridgeRetryState, McpBridgeStatus } from "./types.js";
6
6
 
7
7
  const CLI_OVERRIDE_TOOLS = new Set([
8
8
  "ctx_read",
@@ -14,6 +14,7 @@ const CLI_OVERRIDE_TOOLS = new Set([
14
14
 
15
15
  const MAX_RECONNECT_ATTEMPTS = 3;
16
16
  const RECONNECT_DELAY_MS = 2000;
17
+ const TOOL_CALL_TIMEOUT_MS = 120000;
17
18
 
18
19
  type McpTool = {
19
20
  name: string;
@@ -21,6 +22,32 @@ type McpTool = {
21
22
  inputSchema?: Record<string, unknown>;
22
23
  };
23
24
 
25
+ function isAbortLikeError(error: unknown): boolean {
26
+ if (!(error instanceof Error)) return false;
27
+ const msg = error.message.toLowerCase();
28
+ return error.name === "AbortError"
29
+ || msg.includes("aborted")
30
+ || msg.includes("cancelled")
31
+ || msg.includes("canceled");
32
+ }
33
+
34
+ function isHostToolRejection(error: unknown): boolean {
35
+ if (!(error instanceof Error)) return false;
36
+ const msg = error.message.toLowerCase();
37
+ return msg.includes("the user doesn't want to proceed with this tool use")
38
+ || msg.includes("tool use was rejected")
39
+ || msg.includes("stop what you are doing and wait for the user to tell you how to proceed");
40
+ }
41
+
42
+ function isRetrySafeTool(name: string): boolean {
43
+ const lower = name.toLowerCase();
44
+ const mutatingHints = [
45
+ "edit", "fill", "cache", "workflow",
46
+ "execute", "session", "knowledge", "response",
47
+ ];
48
+ return !mutatingHints.some((hint) => lower.includes(hint));
49
+ }
50
+
24
51
  export class McpBridge {
25
52
  private client: Client | null = null;
26
53
  private transport: StdioClientTransport | null = null;
@@ -28,6 +55,9 @@ export class McpBridge {
28
55
  private connected = false;
29
56
  private binary: string;
30
57
  private reconnectAttempts = 0;
58
+ private lastError: string | undefined;
59
+ private lastHungTool: string | undefined;
60
+ private lastRetry: McpBridgeRetryState | undefined;
31
61
 
32
62
  constructor(binary: string) {
33
63
  this.binary = binary;
@@ -39,6 +69,7 @@ export class McpBridge {
39
69
  await this.discoverAndRegisterTools(pi);
40
70
  } catch (err) {
41
71
  const msg = err instanceof Error ? err.message : String(err);
72
+ this.lastError = msg;
42
73
  console.error(`[lean-ctx MCP bridge] Failed to start: ${msg}`);
43
74
  }
44
75
  }
@@ -57,20 +88,24 @@ export class McpBridge {
57
88
 
58
89
  this.transport.onclose = () => {
59
90
  this.connected = false;
91
+ this.lastError = "MCP transport closed";
60
92
  this.scheduleReconnect();
61
93
  };
62
94
 
63
95
  this.transport.onerror = (err) => {
96
+ this.lastError = err.message;
64
97
  console.error(`[lean-ctx MCP bridge] Transport error: ${err.message}`);
65
98
  };
66
99
 
67
100
  await this.client.connect(this.transport);
68
101
  this.connected = true;
69
102
  this.reconnectAttempts = 0;
103
+ this.lastError = undefined;
70
104
  }
71
105
 
72
106
  private scheduleReconnect(): void {
73
107
  if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
108
+ this.lastError = `Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached.`;
74
109
  console.error(
75
110
  `[lean-ctx MCP bridge] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached. MCP tools unavailable.`,
76
111
  );
@@ -84,12 +119,25 @@ export class McpBridge {
84
119
  try {
85
120
  await this.connect();
86
121
  console.error("[lean-ctx MCP bridge] Reconnected successfully");
87
- } catch {
122
+ } catch (error) {
123
+ this.lastError = error instanceof Error ? error.message : String(error);
88
124
  this.scheduleReconnect();
89
125
  }
90
126
  }, delay);
91
127
  }
92
128
 
129
+ private async forceReconnect(): Promise<void> {
130
+ this.connected = false;
131
+ try {
132
+ await this.client?.close();
133
+ } catch {
134
+ // best-effort cleanup
135
+ }
136
+ this.client = null;
137
+ this.transport = null;
138
+ await this.connect();
139
+ }
140
+
93
141
  private async discoverAndRegisterTools(pi: ExtensionAPI): Promise<void> {
94
142
  if (!this.client) return;
95
143
 
@@ -112,8 +160,12 @@ export class McpBridge {
112
160
  description: tool.description ?? `lean-ctx MCP tool: ${tool.name}`,
113
161
  promptSnippet: tool.description ?? tool.name,
114
162
  parameters: schema,
115
- async execute(_toolCallId, params, _signal) {
116
- return bridge.callTool(tool.name, params as Record<string, unknown>);
163
+ async execute(_toolCallId, params, signal) {
164
+ return bridge.callTool(
165
+ tool.name,
166
+ params as Record<string, unknown>,
167
+ signal,
168
+ );
117
169
  },
118
170
  });
119
171
 
@@ -123,6 +175,7 @@ export class McpBridge {
123
175
  async callTool(
124
176
  name: string,
125
177
  args: Record<string, unknown>,
178
+ signal?: AbortSignal,
126
179
  ): Promise<{ content: Array<{ type: string; text: string }> }> {
127
180
  if (!this.client || !this.connected) {
128
181
  throw new Error(
@@ -130,8 +183,93 @@ export class McpBridge {
130
183
  );
131
184
  }
132
185
 
133
- const result = await this.client.callTool({ name, arguments: args });
186
+ if (signal?.aborted) {
187
+ throw new Error(`lean-ctx MCP tool "${name}" interrupted by host.`);
188
+ }
189
+
190
+ try {
191
+ const result = await this.callToolWithTimeout(name, args, signal);
192
+ this.lastError = undefined;
193
+ return this.toTextBlocks(result);
194
+ } catch (error) {
195
+ if (isHostToolRejection(error) || isAbortLikeError(error)) {
196
+ throw new Error(`lean-ctx MCP tool "${name}" interrupted by host.`);
197
+ }
198
+
199
+ if (this.isTimeoutError(error) && isRetrySafeTool(name)) {
200
+ this.lastRetry = {
201
+ toolName: name,
202
+ reason: "timeout",
203
+ retried: true,
204
+ timestamp: new Date().toISOString(),
205
+ };
206
+ await this.forceReconnect();
207
+ const retried = await this.callToolWithTimeout(name, args, signal);
208
+ this.lastError = undefined;
209
+ return this.toTextBlocks(retried);
210
+ }
211
+
212
+ this.lastError = error instanceof Error ? error.message : String(error);
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ private async callToolWithTimeout(
218
+ name: string,
219
+ args: Record<string, unknown>,
220
+ signal?: AbortSignal,
221
+ ) {
222
+ const call = this.client?.callTool({ name, arguments: args });
223
+ if (!call) {
224
+ throw new Error(`lean-ctx MCP bridge not connected. Tool "${name}" unavailable.`);
225
+ }
226
+
227
+ let timer: ReturnType<typeof setTimeout> | undefined;
228
+ const timeout = new Promise<never>((_, reject) => {
229
+ timer = setTimeout(() => {
230
+ this.lastHungTool = name;
231
+ reject(
232
+ new Error(
233
+ `lean-ctx MCP tool "${name}" timed out after ${Math.round(TOOL_CALL_TIMEOUT_MS / 1000)}s.`,
234
+ ),
235
+ );
236
+ }, TOOL_CALL_TIMEOUT_MS);
237
+ });
238
+
239
+ const promises: Promise<unknown>[] = [call, timeout];
240
+
241
+ if (signal) {
242
+ let onAbort: (() => void) | undefined;
243
+ const abortPromise = new Promise<never>((_, reject) => {
244
+ onAbort = () => {
245
+ reject(new Error(`lean-ctx MCP tool "${name}" interrupted by host.`));
246
+ };
247
+ signal.addEventListener("abort", onAbort, { once: true });
248
+ });
249
+ promises.push(abortPromise);
250
+
251
+ try {
252
+ return await Promise.race(promises);
253
+ } finally {
254
+ if (timer) clearTimeout(timer);
255
+ if (onAbort) signal.removeEventListener("abort", onAbort);
256
+ }
257
+ }
258
+
259
+ try {
260
+ return await Promise.race(promises);
261
+ } finally {
262
+ if (timer) clearTimeout(timer);
263
+ }
264
+ }
265
+
266
+ private isTimeoutError(error: unknown): boolean {
267
+ return error instanceof Error && error.message.includes("timed out after");
268
+ }
134
269
 
270
+ private toTextBlocks(
271
+ result: Awaited<ReturnType<Client["callTool"]>>,
272
+ ): { content: Array<{ type: string; text: string }> } {
135
273
  const content = (
136
274
  result.content as Array<{ type: string; text?: string }>
137
275
  ).map((block) => ({
@@ -198,6 +336,10 @@ export class McpBridge {
198
336
  connected: this.connected,
199
337
  toolCount: this.registeredTools.length,
200
338
  toolNames: [...this.registeredTools],
339
+ reconnectAttempts: this.reconnectAttempts,
340
+ lastError: this.lastError,
341
+ lastHungTool: this.lastHungTool,
342
+ lastRetry: this.lastRetry,
201
343
  };
202
344
  }
203
345
 
@@ -4,10 +4,21 @@ export type CompressionStats = {
4
4
  percentSaved: number;
5
5
  };
6
6
 
7
+ export type McpBridgeRetryState = {
8
+ toolName: string;
9
+ reason: "timeout";
10
+ retried: boolean;
11
+ timestamp: string;
12
+ };
13
+
7
14
  export type McpBridgeStatus = {
8
15
  mode: "embedded" | "adapter" | "disabled";
9
16
  connected: boolean;
10
17
  toolCount: number;
11
18
  toolNames: string[];
19
+ reconnectAttempts: number;
20
+ lastError?: string;
21
+ lastHungTool?: string;
22
+ lastRetry?: McpBridgeRetryState;
12
23
  error?: string;
13
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-ctx",
3
- "version": "3.2.1",
3
+ "version": "3.3.5",
4
4
  "description": "Pi Coding Agent extension with first-class MCP support — routes bash, read, grep, find, and ls through lean-ctx CLI, and exposes all 46 lean-ctx MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ctx_impact, ctx_architecture, ctx_workflow, ctx_gain, etc.) natively in Pi",
5
5
  "keywords": ["pi-package", "lean-ctx", "token-optimization", "compression", "mcp"],
6
6
  "license": "Apache-2.0",