claude-ide-bridge 2.16.1 → 2.16.2
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/package.json +1 -1
- package/scripts/mcp-stdio-shim.cjs +169 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-ide-bridge",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.2",
|
|
4
4
|
"description": "Standalone MCP bridge for Claude Code IDE integration with any editor — 136+ tools for LSP, debugging, terminals, Git, GitHub, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -110,6 +110,75 @@ const RECONNECT_DEBOUNCE_MS = 500;
|
|
|
110
110
|
const POLL_INTERVAL_MS = 3000; // fallback poll when disconnected
|
|
111
111
|
const MAX_PENDING_LINES = 1000;
|
|
112
112
|
|
|
113
|
+
// Backoff state — differentiated retry delays by error category
|
|
114
|
+
const MAX_UNREACHABLE_MS =
|
|
115
|
+
Number(process.env.SHIM_MAX_UNREACHABLE_MS) || 5 * 60 * 1000;
|
|
116
|
+
let firstUnreachableAt = 0; // epoch ms of first consecutive failure in current streak
|
|
117
|
+
let currentBackoffMs = 0; // 0 = not in a backoff sequence
|
|
118
|
+
let backoffTimer = null; // pending setTimeout for next reconnect attempt
|
|
119
|
+
|
|
120
|
+
// --- Backoff helpers ---
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the next retry delay (ms) for a given error category and mutates
|
|
124
|
+
* currentBackoffMs so successive calls produce exponential growth.
|
|
125
|
+
*
|
|
126
|
+
* Categories:
|
|
127
|
+
* "429" – rate-limited: start 1s, double, cap 30s, full jitter
|
|
128
|
+
* "unreachable" – ECONNREFUSED / ETIMEDOUT: start 1s, double, cap 30s
|
|
129
|
+
* "other" – falls back to flat POLL_INTERVAL_MS
|
|
130
|
+
*/
|
|
131
|
+
function nextBackoffMs(errorType) {
|
|
132
|
+
if (errorType === "other") {
|
|
133
|
+
currentBackoffMs = 0;
|
|
134
|
+
return POLL_INTERVAL_MS;
|
|
135
|
+
}
|
|
136
|
+
const CAP_MS = 30_000;
|
|
137
|
+
currentBackoffMs =
|
|
138
|
+
currentBackoffMs === 0 ? 1000 : Math.min(currentBackoffMs * 2, CAP_MS);
|
|
139
|
+
// Full jitter for 429 (avoids thundering herd on a recovering bridge)
|
|
140
|
+
return errorType === "429"
|
|
141
|
+
? Math.floor(Math.random() * currentBackoffMs)
|
|
142
|
+
: currentBackoffMs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resetBackoff() {
|
|
146
|
+
currentBackoffMs = 0;
|
|
147
|
+
firstUnreachableAt = 0;
|
|
148
|
+
if (backoffTimer) {
|
|
149
|
+
clearTimeout(backoffTimer);
|
|
150
|
+
backoffTimer = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Schedule a reconnect attempt after a computed backoff delay.
|
|
156
|
+
* Replaces any pending backoffTimer. Does NOT call startPoll().
|
|
157
|
+
*/
|
|
158
|
+
function scheduleBackoffReconnect(errorType) {
|
|
159
|
+
if (backoffTimer) clearTimeout(backoffTimer);
|
|
160
|
+
const delay = nextBackoffMs(errorType);
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
`mcp-stdio-shim: Will retry in ${Math.round(delay / 1000)}s (reason: ${errorType}).\n`,
|
|
163
|
+
);
|
|
164
|
+
backoffTimer = setTimeout(() => {
|
|
165
|
+
backoffTimer = null;
|
|
166
|
+
const lockFile = findLockFile();
|
|
167
|
+
if (!lockFile) {
|
|
168
|
+
startPoll();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = parseLock(lockFile);
|
|
174
|
+
} catch {
|
|
175
|
+
startPoll();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
connect(parsed.port, parsed.authToken);
|
|
179
|
+
}, delay);
|
|
180
|
+
}
|
|
181
|
+
|
|
113
182
|
// --- Explicit args (bypass auto-discover and watcher) ---
|
|
114
183
|
const explicitPort =
|
|
115
184
|
process.argv[2] && process.argv[3] ? Number(process.argv[2]) : null;
|
|
@@ -139,18 +208,76 @@ function connect(port, authToken) {
|
|
|
139
208
|
headers: { "x-claude-code-ide-authorization": authToken },
|
|
140
209
|
});
|
|
141
210
|
|
|
211
|
+
// Per-connect flag: set when the error handler classifies this attempt as 429
|
|
212
|
+
// so the close handler knows not to call startPoll() and instead backs off.
|
|
213
|
+
let lastErrorWas429 = false;
|
|
214
|
+
|
|
142
215
|
ws.on("error", (err) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
216
|
+
const code = err.code; // e.g. "ECONNREFUSED", "ETIMEDOUT", or undefined
|
|
217
|
+
process.stderr.write(
|
|
218
|
+
`mcp-stdio-shim: WebSocket error [${code ?? "unknown"}]: ${err.message}\n`,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (explicitPort !== null) {
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (code === "ECONNREFUSED" || code === "ETIMEDOUT") {
|
|
226
|
+
if (firstUnreachableAt === 0) firstUnreachableAt = Date.now();
|
|
227
|
+
if (Date.now() - firstUnreachableAt > MAX_UNREACHABLE_MS) {
|
|
228
|
+
process.stderr.write(
|
|
229
|
+
"mcp-stdio-shim: Bridge unreachable for >5 minutes — giving up.\n",
|
|
230
|
+
);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
scheduleBackoffReconnect("unreachable");
|
|
234
|
+
} else if (err.message?.includes("429")) {
|
|
235
|
+
lastErrorWas429 = true;
|
|
236
|
+
if (firstUnreachableAt === 0) firstUnreachableAt = Date.now();
|
|
237
|
+
if (Date.now() - firstUnreachableAt > MAX_UNREACHABLE_MS) {
|
|
238
|
+
process.stderr.write(
|
|
239
|
+
"mcp-stdio-shim: Bridge rate-limiting for >5 minutes — giving up.\n",
|
|
240
|
+
);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
scheduleBackoffReconnect("429");
|
|
244
|
+
} else if (
|
|
245
|
+
err.message &&
|
|
246
|
+
(err.message.includes("401") || err.message.includes("403"))
|
|
247
|
+
) {
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
"mcp-stdio-shim: Auth failure (401/403) — exiting.\n",
|
|
250
|
+
);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
// Other errors fall through to the close handler
|
|
146
254
|
});
|
|
147
255
|
|
|
148
256
|
ws.on("close", (code, reason) => {
|
|
257
|
+
const reasonStr = reason?.toString() || "";
|
|
149
258
|
process.stderr.write(
|
|
150
|
-
`mcp-stdio-shim: Connection closed (${code} ${
|
|
259
|
+
`mcp-stdio-shim: Connection closed (${code} ${reasonStr})\n`,
|
|
151
260
|
);
|
|
152
261
|
if (explicitPort !== null) process.exit(0);
|
|
153
|
-
|
|
262
|
+
|
|
263
|
+
// Auth rejected — exit immediately, retrying will not help
|
|
264
|
+
if (code === 4001) {
|
|
265
|
+
process.stderr.write(
|
|
266
|
+
"mcp-stdio-shim: Authorization rejected (4001) — token mismatch. Exiting.\n",
|
|
267
|
+
);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If the error handler already scheduled a backoff reconnect, don't also start polling
|
|
272
|
+
if (backoffTimer) return;
|
|
273
|
+
|
|
274
|
+
// 1006 = abnormal closure (often accompanies a 429 rejection before handshake)
|
|
275
|
+
if (lastErrorWas429) {
|
|
276
|
+
scheduleBackoffReconnect("429");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Normal closure or other codes: resume flat polling (existing behavior)
|
|
154
281
|
startPoll();
|
|
155
282
|
});
|
|
156
283
|
|
|
@@ -162,6 +289,7 @@ function connect(port, authToken) {
|
|
|
162
289
|
ws.on("open", () => {
|
|
163
290
|
process.stderr.write("mcp-stdio-shim: Connected.\n");
|
|
164
291
|
stopPoll();
|
|
292
|
+
resetBackoff();
|
|
165
293
|
// On reconnect (after a prior successful connection), drop stale pending messages —
|
|
166
294
|
// they reference old session state (e.g. the previous initialize handshake) and must
|
|
167
295
|
// not be replayed. A failed first attempt does NOT count — pendingLines must be kept
|
|
@@ -280,7 +408,16 @@ process.stdin.on("data", (chunk) => {
|
|
|
280
408
|
const trimmed = line.trim();
|
|
281
409
|
if (!trimmed) continue;
|
|
282
410
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
283
|
-
|
|
411
|
+
try {
|
|
412
|
+
ws.send(trimmed);
|
|
413
|
+
} catch (sendErr) {
|
|
414
|
+
process.stderr.write(
|
|
415
|
+
`mcp-stdio-shim: ws.send failed (${sendErr.message}) — queuing message.\n`,
|
|
416
|
+
);
|
|
417
|
+
if (pendingLines.length < MAX_PENDING_LINES) {
|
|
418
|
+
pendingLines.push(trimmed);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
284
421
|
} else if (pendingLines.length < MAX_PENDING_LINES) {
|
|
285
422
|
pendingLines.push(trimmed);
|
|
286
423
|
} else {
|
|
@@ -292,5 +429,30 @@ process.stdin.on("data", (chunk) => {
|
|
|
292
429
|
});
|
|
293
430
|
|
|
294
431
|
process.stdin.on("end", () => {
|
|
295
|
-
if (ws)
|
|
432
|
+
if (ws) {
|
|
433
|
+
try {
|
|
434
|
+
ws.close();
|
|
435
|
+
} catch {
|
|
436
|
+
/* ignore */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
process.exit(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
process.stdin.on("error", (err) => {
|
|
443
|
+
// EPIPE / ERR_STREAM_DESTROYED means the MCP host closed the pipe — clean shutdown.
|
|
444
|
+
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
|
|
445
|
+
process.stderr.write(
|
|
446
|
+
`mcp-stdio-shim: stdin closed (${err.code}) — shutting down.\n`,
|
|
447
|
+
);
|
|
448
|
+
if (ws) {
|
|
449
|
+
try {
|
|
450
|
+
ws.close();
|
|
451
|
+
} catch {
|
|
452
|
+
/* ignore */
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
process.exit(0);
|
|
456
|
+
}
|
|
457
|
+
process.stderr.write(`mcp-stdio-shim: stdin error: ${err.message}\n`);
|
|
296
458
|
});
|