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.
- package/extensions/index.ts +12 -0
- package/extensions/mcp-bridge.ts +147 -5
- package/extensions/types.ts +11 -0
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -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
|
},
|
package/extensions/mcp-bridge.ts
CHANGED
|
@@ -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,
|
|
116
|
-
return bridge.callTool(
|
|
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
|
-
|
|
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
|
|
package/extensions/types.ts
CHANGED
|
@@ -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.
|
|
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",
|