@mysten-incubation/memwal-mcp 0.0.8 → 0.0.9-dev.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/dist/auth-required.d.ts +145 -0
- package/dist/auth-required.d.ts.map +1 -1
- package/dist/auth-required.js +47 -2
- package/dist/auth-required.js.map +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +625 -34
- package/dist/bridge.js.map +1 -1
- package/dist/compatibility.d.ts +15 -1
- package/dist/compatibility.d.ts.map +1 -1
- package/dist/compatibility.js +46 -5
- package/dist/compatibility.js.map +1 -1
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { clearCreds, credsPath } from "./auth.js";
|
|
2
|
-
import {
|
|
2
|
+
import { TOOL_DEFINITIONS } from "./auth-required.js";
|
|
3
|
+
import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
|
|
3
4
|
import { loginFlow } from "./login.js";
|
|
4
5
|
import { log, note } from "./logger.js";
|
|
5
6
|
/** Memory tools that take a `namespace` argument. `memwal_remember`,
|
|
@@ -9,6 +10,7 @@ import { log, note } from "./logger.js";
|
|
|
9
10
|
* agent calls it without). */
|
|
10
11
|
const NAMESPACE_TOOLS = new Set([
|
|
11
12
|
"memwal_remember",
|
|
13
|
+
"memwal_remember_bulk",
|
|
12
14
|
"memwal_recall",
|
|
13
15
|
"memwal_analyze",
|
|
14
16
|
"memwal_restore",
|
|
@@ -65,6 +67,52 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
65
67
|
},
|
|
66
68
|
},
|
|
67
69
|
];
|
|
70
|
+
/** Protocol versions this local `initialize` responder can speak. We echo the
|
|
71
|
+
* client's requested version when it's one of these, else fall back to our
|
|
72
|
+
* baseline — the same negotiation shape a real MCP server does. */
|
|
73
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
|
|
74
|
+
const FALLBACK_PROTOCOL_VERSION = "2024-11-05";
|
|
75
|
+
/** Build the `initialize` result we answer LOCALLY and instantly, before the
|
|
76
|
+
* relayer session is up. Echoes the client's requested protocolVersion when we
|
|
77
|
+
* support it (otherwise the baseline) instead of hard-coding one and ignoring
|
|
78
|
+
* the request. `tools.listChanged: true` is a deliberate difference from
|
|
79
|
+
* auth-required mode: the bridge serves a static `tools/list` at cold start and
|
|
80
|
+
* then emits `notifications/tools/list_changed` once the background relayer
|
|
81
|
+
* connect completes, so the client re-lists and picks up the real upstream tool
|
|
82
|
+
* set. Advertising `listChanged: false` (as auth-required does, since it never
|
|
83
|
+
* refreshes) would let a client ignore that notification. */
|
|
84
|
+
function buildLocalInitializeResult(params) {
|
|
85
|
+
const requested = params?.protocolVersion;
|
|
86
|
+
const protocolVersion = typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.has(requested)
|
|
87
|
+
? requested
|
|
88
|
+
: FALLBACK_PROTOCOL_VERSION;
|
|
89
|
+
return {
|
|
90
|
+
protocolVersion,
|
|
91
|
+
capabilities: { tools: { listChanged: true } },
|
|
92
|
+
serverInfo: { name: "memwal", version: "0.0.1" },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Names of the tools we serve locally, so we can de-dup them out of the
|
|
96
|
+
* imported memory-tool list (which already carries its own `memwal_login`
|
|
97
|
+
* entry) before appending our canonical definitions. */
|
|
98
|
+
const LOCAL_TOOL_NAMES = new Set(LOCAL_TOOL_DEFINITIONS.map((t) => t.name));
|
|
99
|
+
/** The `tools/list` we serve LOCALLY at cold start: the memory tools (from the
|
|
100
|
+
* same source as auth-required mode) plus the locally-handled login/logout
|
|
101
|
+
* tools. We strip any locally-served name from the imported list first —
|
|
102
|
+
* `TOOL_DEFINITIONS` bundles its own `memwal_login`, and concatenating
|
|
103
|
+
* `LOCAL_TOOL_DEFINITIONS` blindly would advertise `memwal_login` twice. This
|
|
104
|
+
* yields the SAME shape as the post-connect spliced list (upstream memory tools
|
|
105
|
+
* + login + logout, each once), so the static→refreshed transition doesn't
|
|
106
|
+
* change the tool set out from under the client. OAuth-scoped sessions may
|
|
107
|
+
* over-advertise write tools until `tools/list_changed` refreshes from the
|
|
108
|
+
* relayer. Refreshed via
|
|
109
|
+
* `tools/list_changed` once the relayer session is up. */
|
|
110
|
+
const LOCAL_TOOLS_LIST = {
|
|
111
|
+
tools: [
|
|
112
|
+
...TOOL_DEFINITIONS.filter((t) => !LOCAL_TOOL_NAMES.has(t.name)),
|
|
113
|
+
...LOCAL_TOOL_DEFINITIONS,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
68
116
|
const LOGIN_BG_TIMEOUT_MS = 5 * 60_000;
|
|
69
117
|
const URL_READY_TIMEOUT_MS = 5_000;
|
|
70
118
|
/** Maximum silence we tolerate on the SSE stream before assuming the
|
|
@@ -91,19 +139,61 @@ function mcpAuthHeaders(creds) {
|
|
|
91
139
|
};
|
|
92
140
|
}
|
|
93
141
|
async function openSseStream(relayerUrl, creds) {
|
|
94
|
-
|
|
142
|
+
const connectTimeoutMs = resolveConnectTimeoutMs();
|
|
143
|
+
// One shared budget for the WHOLE attempt: the compatibility check (GET
|
|
144
|
+
// /version + /health fallback) and the SSE connect below both honour this
|
|
145
|
+
// single deadline, so an attempt is bounded by connectTimeoutMs in total
|
|
146
|
+
// rather than each step getting its own (which could sum to 2–3×).
|
|
147
|
+
const budgetSignal = AbortSignal.timeout(connectTimeoutMs);
|
|
148
|
+
await ensureCompatibleRelayer(relayerUrl, budgetSignal);
|
|
95
149
|
const url = `${relayerUrl.replace(/\/+$/, "")}/api/mcp/sse`;
|
|
96
150
|
const controller = new AbortController();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
151
|
+
// Bound the INITIAL connect (headers + the wait-for-`endpoint`-event loop
|
|
152
|
+
// below) on the SAME shared budget as the compat check above, so a hung
|
|
153
|
+
// relayer aborts well before the MCP client's ~30s timeout and one attempt
|
|
154
|
+
// never exceeds connectTimeoutMs total. This is distinct from the idle
|
|
155
|
+
// watchdog, which only bounds silence AFTER the stream is up. When the
|
|
156
|
+
// budget fires we abort `controller` (so the in-flight fetch/read unwinds);
|
|
157
|
+
// we detach the listener the instant the endpoint resolves — past that
|
|
158
|
+
// point the idle watchdog owns liveness and this must never fire, or it
|
|
159
|
+
// would tear down a healthy stream.
|
|
160
|
+
let connectTimedOut = false;
|
|
161
|
+
const onBudgetExpired = () => {
|
|
162
|
+
if (!controller.signal.aborted) {
|
|
163
|
+
connectTimedOut = true;
|
|
164
|
+
log.warn("bridge.connect_timeout", { url, timeoutMs: connectTimeoutMs });
|
|
165
|
+
controller.abort();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
// If the compat check already burned the whole budget, `budgetSignal` is
|
|
169
|
+
// already aborted — fire synchronously so we don't even attempt the SSE GET.
|
|
170
|
+
if (budgetSignal.aborted)
|
|
171
|
+
onBudgetExpired();
|
|
172
|
+
else
|
|
173
|
+
budgetSignal.addEventListener("abort", onBudgetExpired, { once: true });
|
|
174
|
+
const clearConnectTimer = () => budgetSignal.removeEventListener("abort", onBudgetExpired);
|
|
175
|
+
let resp;
|
|
176
|
+
try {
|
|
177
|
+
resp = await fetch(url, {
|
|
178
|
+
method: "GET",
|
|
179
|
+
headers: {
|
|
180
|
+
...mcpAuthHeaders(creds),
|
|
181
|
+
accept: "text/event-stream",
|
|
182
|
+
"cache-control": "no-cache",
|
|
183
|
+
},
|
|
184
|
+
signal: controller.signal,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
clearConnectTimer();
|
|
189
|
+
if (connectTimedOut) {
|
|
190
|
+
throw new Error(`Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` +
|
|
191
|
+
`(${url}). The relayer may be slow, cold-starting, or unreachable.`);
|
|
192
|
+
}
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
106
195
|
if (resp.status === 401) {
|
|
196
|
+
clearConnectTimer();
|
|
107
197
|
controller.abort();
|
|
108
198
|
log.warn("bridge.unauthorized", { url });
|
|
109
199
|
// DO NOT wipe creds here. A 401 from the relayer is *evidence* of
|
|
@@ -121,12 +211,14 @@ async function openSseStream(relayerUrl, creds) {
|
|
|
121
211
|
"Run `memwal-mcp login` if you need to rotate the key.");
|
|
122
212
|
}
|
|
123
213
|
if (!resp.ok || !resp.body) {
|
|
214
|
+
clearConnectTimer();
|
|
124
215
|
const body = resp.body ? await resp.text() : "";
|
|
125
216
|
controller.abort();
|
|
126
217
|
throw new Error(`Walrus Memory relayer SSE handshake failed: HTTP ${resp.status} ${body.slice(0, 200)}`);
|
|
127
218
|
}
|
|
128
219
|
const ct = resp.headers.get("content-type") ?? "";
|
|
129
220
|
if (!ct.includes("event-stream")) {
|
|
221
|
+
clearConnectTimer();
|
|
130
222
|
controller.abort();
|
|
131
223
|
throw new Error(`Walrus Memory relayer returned unexpected content-type "${ct}" for SSE endpoint`);
|
|
132
224
|
}
|
|
@@ -237,11 +329,21 @@ async function openSseStream(relayerUrl, creds) {
|
|
|
237
329
|
// Wait for the `endpoint` event (or first message) before returning.
|
|
238
330
|
while (!endpointResolved) {
|
|
239
331
|
if (streamEnded) {
|
|
332
|
+
clearConnectTimer();
|
|
240
333
|
controller.abort();
|
|
334
|
+
if (connectTimedOut) {
|
|
335
|
+
throw new Error(`Walrus Memory relayer SSE connect timed out after ${connectTimeoutMs}ms ` +
|
|
336
|
+
`(${url}) waiting for the endpoint event. The relayer may be slow, ` +
|
|
337
|
+
`cold-starting, or unreachable.`);
|
|
338
|
+
}
|
|
241
339
|
throw new Error(`Walrus Memory relayer SSE handshake ended before endpoint event${streamError ? `: ${streamError}` : ""}`);
|
|
242
340
|
}
|
|
243
341
|
await new Promise((r) => (queueResolver = r));
|
|
244
342
|
}
|
|
343
|
+
// Endpoint resolved — the stream is up. Hand liveness over to the idle
|
|
344
|
+
// watchdog and disarm the connect timer so it can never abort a healthy
|
|
345
|
+
// stream.
|
|
346
|
+
clearConnectTimer();
|
|
245
347
|
const iter = {
|
|
246
348
|
async next() {
|
|
247
349
|
while (events.length === 0) {
|
|
@@ -419,14 +521,110 @@ pendingLines = []) {
|
|
|
419
521
|
delegate: creds.delegateAddress,
|
|
420
522
|
});
|
|
421
523
|
// Live handle to the current SSE stream — replaced whenever we reconnect.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
524
|
+
// Starts null: we answer `initialize` / `tools/list` LOCALLY and wire stdin
|
|
525
|
+
// BEFORE the relayer session exists, so the MCP client's handshake never
|
|
526
|
+
// waits on a (possibly slow / cold) relayer round-trip. The connect runs in
|
|
527
|
+
// the background; anything that must reach the relayer (`tools/call`) is
|
|
528
|
+
// buffered in `pendingForward` until `sse` is live, then flushed.
|
|
529
|
+
let sse = null;
|
|
425
530
|
let stdinClosed = false;
|
|
426
531
|
let reconnectAttempt = 0;
|
|
427
532
|
let reconnectPromise = null;
|
|
428
533
|
let credentialGeneration = 0;
|
|
429
534
|
let activeCredentialGeneration = 0;
|
|
535
|
+
/** Callbacks to run once, the moment stdin closes — used to wake anything
|
|
536
|
+
* parked on a timer (e.g. the connect-retry backoff) so shutdown is prompt
|
|
537
|
+
* instead of waiting out the timer. `markStdinClosed` is the single writer
|
|
538
|
+
* of `stdinClosed`; call it instead of assigning the flag directly. */
|
|
539
|
+
const stdinCloseListeners = new Set();
|
|
540
|
+
/** Register a shutdown callback. Returns an unregister fn so a caller that
|
|
541
|
+
* only cares about shutdown *while it's parked* (e.g. one backoff sleep) can
|
|
542
|
+
* detach when it wakes normally — otherwise the set would grow one stale
|
|
543
|
+
* closure per retry for the whole session. Fires immediately if already
|
|
544
|
+
* closed (unregister is then a no-op). */
|
|
545
|
+
function onStdinClose(fn) {
|
|
546
|
+
if (stdinClosed) {
|
|
547
|
+
fn();
|
|
548
|
+
return () => { };
|
|
549
|
+
}
|
|
550
|
+
stdinCloseListeners.add(fn);
|
|
551
|
+
return () => stdinCloseListeners.delete(fn);
|
|
552
|
+
}
|
|
553
|
+
function markStdinClosed() {
|
|
554
|
+
if (stdinClosed)
|
|
555
|
+
return;
|
|
556
|
+
stdinClosed = true;
|
|
557
|
+
for (const fn of stdinCloseListeners) {
|
|
558
|
+
try {
|
|
559
|
+
fn();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
/* listener failure must not block shutdown */
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
stdinCloseListeners.clear();
|
|
566
|
+
}
|
|
567
|
+
/** Requests that arrived before the relayer session came up. Held here and
|
|
568
|
+
* flushed in order once `sse` is live. `tools/call` (and any other request
|
|
569
|
+
* that must reach the relayer) lands here; `tools/list` and
|
|
570
|
+
* `memwal_login|logout` are answered locally and never buffered. `initialize`
|
|
571
|
+
* IS buffered (to forward upstream for capability negotiation) but is never
|
|
572
|
+
* failed back — `failRequest` skips `method === "initialize"`. */
|
|
573
|
+
const pendingForward = [];
|
|
574
|
+
/** True while `flushPendingForward` is draining the buffer after the first
|
|
575
|
+
* connect. New stdin requests that arrive mid-drain must keep buffering
|
|
576
|
+
* rather than posting directly, or they'd overtake still-queued items and
|
|
577
|
+
* break arrival order. */
|
|
578
|
+
let flushing = false;
|
|
579
|
+
/** Resolves the first time the SSE stream is up (or when stdin closes before
|
|
580
|
+
* that ever happens). `serverPump` waits on this before reading from
|
|
581
|
+
* `sse.iter`; after it resolves, `sse` is either a live handle or null
|
|
582
|
+
* (stdin closed) — the pump loop guards on both. Idempotent. */
|
|
583
|
+
let signalFirstConnect = () => { };
|
|
584
|
+
let firstConnectSignaled = false;
|
|
585
|
+
const firstConnect = new Promise((r) => {
|
|
586
|
+
signalFirstConnect = () => {
|
|
587
|
+
if (firstConnectSignaled)
|
|
588
|
+
return;
|
|
589
|
+
firstConnectSignaled = true;
|
|
590
|
+
r();
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
/** Expected-suppression COUNT per id, for requests we answered locally but
|
|
594
|
+
* still forwarded upstream (currently just `initialize`, so the relayer
|
|
595
|
+
* session negotiates capabilities). The upstream reply must be dropped in
|
|
596
|
+
* the pump — the client already has our local reply, and a second response
|
|
597
|
+
* for the same id corrupts its JSON-RPC state.
|
|
598
|
+
*
|
|
599
|
+
* A count (not a bare Set) so suppression is EXACT and self-limiting: we
|
|
600
|
+
* expect exactly one upstream reply per forward, so we increment on each
|
|
601
|
+
* forward (initial + every reconnect replay) and decrement on each dropped
|
|
602
|
+
* reply, removing the id at zero. Once the initialize replies are all
|
|
603
|
+
* consumed, the id stops suppressing — so a client that later REUSES the
|
|
604
|
+
* initialize id for a real request gets that request's genuine reply
|
|
605
|
+
* (result OR error) through, instead of it being swallowed forever. */
|
|
606
|
+
const suppressUpstreamReplies = new Map();
|
|
607
|
+
/** IDs we've already answered with a shutdown "unavailable" envelope
|
|
608
|
+
* (`failRequest`). If a late upstream reply for one of these still arrives —
|
|
609
|
+
* e.g. a flush-404 reconnect re-posted the request onto a live session that
|
|
610
|
+
* answers just as we were closing out at shutdown — the pump must DROP it,
|
|
611
|
+
* or the client would get two responses for one id. */
|
|
612
|
+
const closedOutIds = new Set();
|
|
613
|
+
const expectSuppressedReply = (id) => {
|
|
614
|
+
suppressUpstreamReplies.set(id, (suppressUpstreamReplies.get(id) ?? 0) + 1);
|
|
615
|
+
};
|
|
616
|
+
/** Consume one expected suppression for `id`. Returns true if the reply
|
|
617
|
+
* should be dropped (an outstanding local-answer suppression existed). */
|
|
618
|
+
const consumeSuppressedReply = (id) => {
|
|
619
|
+
const n = suppressUpstreamReplies.get(id);
|
|
620
|
+
if (!n)
|
|
621
|
+
return false;
|
|
622
|
+
if (n <= 1)
|
|
623
|
+
suppressUpstreamReplies.delete(id);
|
|
624
|
+
else
|
|
625
|
+
suppressUpstreamReplies.set(id, n - 1);
|
|
626
|
+
return true;
|
|
627
|
+
};
|
|
430
628
|
// In-flight requests pending a response. We replay them after a forced
|
|
431
629
|
// reconnect so a server-side session swap doesn't strand a tool call
|
|
432
630
|
// forever waiting for a reply that will never come. Notifications
|
|
@@ -437,17 +635,23 @@ pendingLines = []) {
|
|
|
437
635
|
* locally-served `memwal_login` + `memwal_logout` tools so the MCP
|
|
438
636
|
* client surfaces them in its tool palette. */
|
|
439
637
|
const pendingListIds = new Set();
|
|
638
|
+
/** Reopen the SSE stream and replay outstanding `inFlight` requests against
|
|
639
|
+
* the fresh session. All callers await the SAME reconnect via
|
|
640
|
+
* `reconnectPromise` — returning immediately while one is active would let
|
|
641
|
+
* the server pump spin on the aborted stream and let client messages race
|
|
642
|
+
* the stale POST URL. Any reconnect replays the WHOLE `inFlight` map, so
|
|
643
|
+
* callers must treat every id-bearing request as reconnect-owned and never
|
|
644
|
+
* re-post it themselves. `immediate` skips the backoff (used right after a
|
|
645
|
+
* login credential swap). Credential-generation checks discard a session
|
|
646
|
+
* whose key rotated mid-handshake. */
|
|
440
647
|
async function reconnect(reason, immediate = false) {
|
|
441
648
|
if (stdinClosed)
|
|
442
649
|
return;
|
|
443
|
-
// All callers await the same reconnect. Returning immediately while a
|
|
444
|
-
// reconnect is active lets the server pump spin on the aborted stream
|
|
445
|
-
// and lets client messages race the stale POST URL.
|
|
446
650
|
if (reconnectPromise)
|
|
447
651
|
return reconnectPromise;
|
|
448
652
|
reconnectPromise = (async () => {
|
|
449
653
|
try {
|
|
450
|
-
sse
|
|
654
|
+
sse?.abort();
|
|
451
655
|
}
|
|
452
656
|
catch {
|
|
453
657
|
/* already dead */
|
|
@@ -461,8 +665,22 @@ pendingLines = []) {
|
|
|
461
665
|
backoffMs: backoff,
|
|
462
666
|
attempt: reconnectAttempt,
|
|
463
667
|
});
|
|
464
|
-
|
|
465
|
-
|
|
668
|
+
// Sleep, but wake immediately on stdin close so shutdown isn't held
|
|
669
|
+
// up for the whole backoff; unref'd so the timer never keeps the
|
|
670
|
+
// event loop alive on its own (mirrors the connect-retry backoff).
|
|
671
|
+
if (backoff > 0) {
|
|
672
|
+
await new Promise((resolve) => {
|
|
673
|
+
const timer = setTimeout(() => {
|
|
674
|
+
unregister();
|
|
675
|
+
resolve();
|
|
676
|
+
}, backoff);
|
|
677
|
+
timer.unref?.();
|
|
678
|
+
const unregister = onStdinClose(() => {
|
|
679
|
+
clearTimeout(timer);
|
|
680
|
+
resolve();
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
}
|
|
466
684
|
try {
|
|
467
685
|
while (!stdinClosed) {
|
|
468
686
|
const openingGeneration = credentialGeneration;
|
|
@@ -493,6 +711,19 @@ pendingLines = []) {
|
|
|
493
711
|
// start arriving on the new session.
|
|
494
712
|
for (const [id, msg] of Array.from(inFlight.entries())) {
|
|
495
713
|
try {
|
|
714
|
+
// A replayed `initialize` produces a fresh upstream
|
|
715
|
+
// reply on the NEW session that must also be dropped.
|
|
716
|
+
// REPLACE (not stack) any pending suppression for this
|
|
717
|
+
// id: the old session was aborted, so its initialize
|
|
718
|
+
// reply will never arrive to consume its own arm.
|
|
719
|
+
// Re-arming without clearing would leave that orphaned
|
|
720
|
+
// arm forever, and a later reused id would have its
|
|
721
|
+
// real reply wrongly dropped. Reset to exactly one —
|
|
722
|
+
// the single reply the new session will send.
|
|
723
|
+
if (msg.method === "initialize" && msg.id != null) {
|
|
724
|
+
suppressUpstreamReplies.delete(msg.id);
|
|
725
|
+
expectSuppressedReply(msg.id);
|
|
726
|
+
}
|
|
496
727
|
const status = await postMessage(sse.postUrl, msg, openingCreds);
|
|
497
728
|
log.info("bridge.replayed", { id, status });
|
|
498
729
|
}
|
|
@@ -508,6 +739,17 @@ pendingLines = []) {
|
|
|
508
739
|
// it passed the first generation check.
|
|
509
740
|
if (openingGeneration !== credentialGeneration) {
|
|
510
741
|
candidate.abort();
|
|
742
|
+
// The replay above armed one initialize suppression for
|
|
743
|
+
// THIS (now-discarded) candidate; its reply will never
|
|
744
|
+
// arrive to consume it. Clear those arms so the count
|
|
745
|
+
// doesn't leak if the loop exits before another replay
|
|
746
|
+
// re-arms (a leaked arm would swallow a later reused-id
|
|
747
|
+
// reply). A surviving candidate re-arms fresh next pass.
|
|
748
|
+
for (const [, msg] of inFlight) {
|
|
749
|
+
if (msg.method === "initialize" && msg.id != null) {
|
|
750
|
+
suppressUpstreamReplies.delete(msg.id);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
511
753
|
continue;
|
|
512
754
|
}
|
|
513
755
|
break;
|
|
@@ -535,23 +777,59 @@ pendingLines = []) {
|
|
|
535
777
|
// operations belong in the newly-selected account.
|
|
536
778
|
if (accountChanged) {
|
|
537
779
|
try {
|
|
538
|
-
sse
|
|
780
|
+
sse?.abort();
|
|
539
781
|
}
|
|
540
782
|
catch {
|
|
541
783
|
/* already dead */
|
|
542
784
|
}
|
|
543
|
-
|
|
785
|
+
// Purge EVERY structure that holds an account-A request. `inFlight`
|
|
786
|
+
// (tracked requests) AND `pendingForward` (cold-start / mid-flush
|
|
787
|
+
// buffered requests) — the latter is unique to the cold-start path
|
|
788
|
+
// and would otherwise be flushed to account B's session (a
|
|
789
|
+
// cross-account replay) since the flush posts with the current
|
|
790
|
+
// `creds`. For each, reply once with a retryable error and stop
|
|
791
|
+
// tracking; never write a second reply for a locally-answered
|
|
792
|
+
// `initialize`. Keep its one-shot suppress arm so a queued
|
|
793
|
+
// upstream initialize reply is consumed. Do not put initialize in
|
|
794
|
+
// closedOutIds — a later reused id must still get a real reply.
|
|
795
|
+
const purge = (msg) => {
|
|
796
|
+
if (msg.id == null)
|
|
797
|
+
return; // notification — nothing to reply to
|
|
798
|
+
pendingListIds.delete(msg.id);
|
|
799
|
+
if (msg.method === "initialize") {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
suppressUpstreamReplies.delete(msg.id);
|
|
803
|
+
// A request can be in BOTH inFlight and pendingForward (cold-start
|
|
804
|
+
// dual-tracking), so guard against answering the same id twice.
|
|
805
|
+
if (closedOutIds.has(msg.id))
|
|
806
|
+
return;
|
|
807
|
+
// Record the id so a late reply for it (e.g. one already
|
|
808
|
+
// in-flight on the aborted session, or a racing replay) is
|
|
809
|
+
// dropped by the pump rather than becoming a second response.
|
|
810
|
+
closedOutIds.add(msg.id);
|
|
544
811
|
writeStdoutMessage({
|
|
545
812
|
jsonrpc: "2.0",
|
|
546
|
-
id,
|
|
813
|
+
id: msg.id,
|
|
547
814
|
error: {
|
|
548
815
|
code: -32001,
|
|
549
816
|
message: "Walrus Memory account changed during login; retry this request for the new account",
|
|
550
817
|
},
|
|
551
818
|
});
|
|
552
|
-
|
|
553
|
-
|
|
819
|
+
};
|
|
820
|
+
for (const [, msg] of Array.from(inFlight.entries()))
|
|
821
|
+
purge(msg);
|
|
554
822
|
inFlight.clear();
|
|
823
|
+
for (const msg of pendingForward.splice(0, pendingForward.length))
|
|
824
|
+
purge(msg);
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
// Same account: reconnect() owns inFlight. Drop id-bearing
|
|
828
|
+
// pendingForward so a login mid-flush cannot POST the same
|
|
829
|
+
// remember/recall again after replay.
|
|
830
|
+
const leftover = pendingForward.filter((m) => m.id == null);
|
|
831
|
+
pendingForward.length = 0;
|
|
832
|
+
pendingForward.push(...leftover);
|
|
555
833
|
}
|
|
556
834
|
creds = nextCreds;
|
|
557
835
|
credentialGeneration += 1;
|
|
@@ -571,12 +849,55 @@ pendingLines = []) {
|
|
|
571
849
|
// Server → client: stream SSE messages to stdout. Loop forever, restart
|
|
572
850
|
// pump on stream end (which means SSE got cut → we already reconnected).
|
|
573
851
|
const serverPump = (async () => {
|
|
852
|
+
// Nothing to pump until the first relayer session is up. `firstConnect`
|
|
853
|
+
// resolves only on a SUCCESSFUL connect (the background connector
|
|
854
|
+
// retries failures with backoff), unless stdin closed first — in which
|
|
855
|
+
// case `sse` stays null and we exit the loop immediately.
|
|
856
|
+
await firstConnect;
|
|
574
857
|
while (!stdinClosed) {
|
|
575
858
|
try {
|
|
859
|
+
// Snapshot the current stream. `sse` is non-null here: set before
|
|
860
|
+
// signalFirstConnect(), and reconnect() only ever replaces it with
|
|
861
|
+
// another live handle. Reading through a local keeps us on one
|
|
862
|
+
// stream for the duration of this drain; a reconnect swaps `sse`
|
|
863
|
+
// and we pick up the new handle on the next outer iteration.
|
|
864
|
+
// Cast: TS control-flow narrows `sse` to `null` in the outer
|
|
865
|
+
// scope because every non-null assignment happens inside a
|
|
866
|
+
// sibling closure (connectInBackground / reconnect) that TS
|
|
867
|
+
// analyzes independently. At runtime `sse` is a live handle here.
|
|
868
|
+
const stream = sse;
|
|
869
|
+
if (!stream)
|
|
870
|
+
break; // stdin closed before we ever connected
|
|
576
871
|
while (true) {
|
|
577
|
-
const { value, done } = await
|
|
872
|
+
const { value, done } = await stream.iter.next();
|
|
578
873
|
if (done)
|
|
579
874
|
break;
|
|
875
|
+
// Drop the upstream reply to a request we already answered
|
|
876
|
+
// locally (e.g. `initialize`). Writing it would be a second
|
|
877
|
+
// response for the same id. We consume exactly ONE expected
|
|
878
|
+
// suppression per id (see suppressUpstreamReplies), so once
|
|
879
|
+
// the initialize reply(s) are drained the id stops
|
|
880
|
+
// suppressing — a client that later reuses that id for a real
|
|
881
|
+
// request still gets THAT request's reply (result or error).
|
|
882
|
+
if (value &&
|
|
883
|
+
value.id !== undefined &&
|
|
884
|
+
value.id !== null &&
|
|
885
|
+
(value.result !== undefined || value.error !== undefined) &&
|
|
886
|
+
consumeSuppressedReply(value.id)) {
|
|
887
|
+
inFlight.delete(value.id);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Drop a late reply for an id we already closed out at
|
|
891
|
+
// shutdown — writing it would be a second response for that
|
|
892
|
+
// id (see closedOutIds / failRequest).
|
|
893
|
+
if (value &&
|
|
894
|
+
value.id !== undefined &&
|
|
895
|
+
value.id !== null &&
|
|
896
|
+
(value.result !== undefined || value.error !== undefined) &&
|
|
897
|
+
closedOutIds.has(value.id)) {
|
|
898
|
+
inFlight.delete(value.id);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
580
901
|
// Clear in-flight tracking once the response lands.
|
|
581
902
|
if (value &&
|
|
582
903
|
(value.result !== undefined || value.error !== undefined) &&
|
|
@@ -596,7 +917,12 @@ pendingLines = []) {
|
|
|
596
917
|
pendingListIds.delete(value.id);
|
|
597
918
|
const result = value.result;
|
|
598
919
|
if (Array.isArray(result.tools)) {
|
|
599
|
-
|
|
920
|
+
// Strip any locally-served name from the upstream set
|
|
921
|
+
// before appending ours, so a relayer that ever
|
|
922
|
+
// advertises login/logout itself can't produce a
|
|
923
|
+
// duplicate tool name. Mirrors LOCAL_TOOLS_LIST.
|
|
924
|
+
const upstream = result.tools.filter((t) => !LOCAL_TOOL_NAMES.has(t.name ?? ""));
|
|
925
|
+
result.tools = [...upstream, ...LOCAL_TOOL_DEFINITIONS];
|
|
600
926
|
}
|
|
601
927
|
}
|
|
602
928
|
writeStdoutMessage(value);
|
|
@@ -609,7 +935,13 @@ pendingLines = []) {
|
|
|
609
935
|
}
|
|
610
936
|
if (stdinClosed)
|
|
611
937
|
break;
|
|
612
|
-
// Stream ended
|
|
938
|
+
// Stream ended. If a reconnect is ALREADY in progress (e.g. the
|
|
939
|
+
// flush hit a 404), await THAT one rather than hammering reconnect()
|
|
940
|
+
// — otherwise this loop would spin on the dead stream's immediate
|
|
941
|
+
// `done`. reconnect() itself returns the shared reconnectPromise when
|
|
942
|
+
// one is active, so awaiting it here is enough; on the next
|
|
943
|
+
// iteration `sse` has been swapped to the fresh session and we
|
|
944
|
+
// resume reading. If no reconnect is in progress, this starts one.
|
|
613
945
|
await reconnect("server-pump-eof");
|
|
614
946
|
}
|
|
615
947
|
})();
|
|
@@ -620,6 +952,34 @@ pendingLines = []) {
|
|
|
620
952
|
void (async () => {
|
|
621
953
|
try {
|
|
622
954
|
const msg = JSON.parse(line);
|
|
955
|
+
// Answer `initialize` LOCALLY and instantly so the MCP client's
|
|
956
|
+
// handshake never waits on the relayer connect (the cold-start
|
|
957
|
+
// bug). We STILL forward it upstream (below) so the relayer
|
|
958
|
+
// session negotiates capabilities — but suppress that upstream
|
|
959
|
+
// reply, since the client already has this one.
|
|
960
|
+
if (msg.method === "initialize" && msg.id != null) {
|
|
961
|
+
writeStdoutMessage({
|
|
962
|
+
jsonrpc: "2.0",
|
|
963
|
+
id: msg.id,
|
|
964
|
+
result: buildLocalInitializeResult(msg.params),
|
|
965
|
+
});
|
|
966
|
+
// Expect exactly one upstream reply to drop for this forward.
|
|
967
|
+
expectSuppressedReply(msg.id);
|
|
968
|
+
// Fall through: forward/buffer the initialize upstream too.
|
|
969
|
+
}
|
|
970
|
+
// Answer `tools/list` LOCALLY at cold start (before the relayer
|
|
971
|
+
// session exists) so tool discovery unblocks immediately. Once
|
|
972
|
+
// connected we emit `notifications/tools/list_changed` and the
|
|
973
|
+
// client re-lists — that re-list is forwarded upstream and gets
|
|
974
|
+
// the real tool set spliced (handled further down + in the pump).
|
|
975
|
+
if (msg.method === "tools/list" && msg.id != null && sse === null) {
|
|
976
|
+
writeStdoutMessage({
|
|
977
|
+
jsonrpc: "2.0",
|
|
978
|
+
id: msg.id,
|
|
979
|
+
result: LOCAL_TOOLS_LIST,
|
|
980
|
+
});
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
623
983
|
// Local interception: `memwal_login` and `memwal_logout`
|
|
624
984
|
// are handled here, never sent to the relayer. The user
|
|
625
985
|
// can call them any time to re-auth or sign out without
|
|
@@ -668,6 +1028,19 @@ pendingLines = []) {
|
|
|
668
1028
|
msg.id !== null) {
|
|
669
1029
|
inFlight.set(msg.id, msg);
|
|
670
1030
|
}
|
|
1031
|
+
// Relayer session not up yet, OR the post-connect flush is still
|
|
1032
|
+
// draining — buffer so this request stays behind everything that
|
|
1033
|
+
// arrived before it (posting directly here would let it overtake
|
|
1034
|
+
// a still-queued buffered item). The flush (or the next connect)
|
|
1035
|
+
// forwards it in order. Dropping it would strand the request.
|
|
1036
|
+
if (sse === null || flushing) {
|
|
1037
|
+
pendingForward.push(msg);
|
|
1038
|
+
log.info("bridge.buffered_pre_connect", {
|
|
1039
|
+
method: msg.method,
|
|
1040
|
+
id: msg.id ?? null,
|
|
1041
|
+
});
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
671
1044
|
// A successful background login swaps credentials and SSE
|
|
672
1045
|
// sessions asynchronously. Wait for that swap before sending a
|
|
673
1046
|
// new request so it cannot race the stale session URL/key.
|
|
@@ -689,21 +1062,239 @@ pendingLines = []) {
|
|
|
689
1062
|
}
|
|
690
1063
|
})();
|
|
691
1064
|
};
|
|
1065
|
+
/** Flush everything buffered before the session came up, in arrival order,
|
|
1066
|
+
* then announce the real tool set. Called once, right after the first
|
|
1067
|
+
* successful connect. `flushing` keeps concurrently-arriving stdin requests
|
|
1068
|
+
* buffering (rather than posting directly and overtaking the queue); we
|
|
1069
|
+
* drain until the buffer is empty so those late arrivals are forwarded too. */
|
|
1070
|
+
async function flushPendingForward() {
|
|
1071
|
+
flushing = true;
|
|
1072
|
+
try {
|
|
1073
|
+
if (pendingForward.length > 0) {
|
|
1074
|
+
log.info("bridge.flushing_pre_connect", { count: pendingForward.length });
|
|
1075
|
+
}
|
|
1076
|
+
while (pendingForward.length > 0) {
|
|
1077
|
+
if (stdinClosed) {
|
|
1078
|
+
// Shutting down mid-flush: don't post to a torn-down session.
|
|
1079
|
+
// Everything still buffered (plus what we've already shifted
|
|
1080
|
+
// into inFlight but not delivered) is closed out below.
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
if (!sse)
|
|
1084
|
+
break; // lost the session; reconnect replays inFlight
|
|
1085
|
+
const msg = pendingForward.shift();
|
|
1086
|
+
try {
|
|
1087
|
+
const status = await postMessage(sse.postUrl, msg, creds);
|
|
1088
|
+
if (status === 404) {
|
|
1089
|
+
// Stale session right after connect. EVERY id-bearing
|
|
1090
|
+
// request is in `inFlight`, and ANY reconnect — this
|
|
1091
|
+
// flush's own, or a concurrent `server-pump-eof` one that
|
|
1092
|
+
// shares the same `reconnectPromise` — replays the whole
|
|
1093
|
+
// `inFlight` map against the fresh session. So id-bearing
|
|
1094
|
+
// items are owned by reconnect, period; re-posting them
|
|
1095
|
+
// from the flush would duplicate them (double write +
|
|
1096
|
+
// two replies for one id). We therefore drop ALL
|
|
1097
|
+
// id-bearing items from the queue after reconnect and
|
|
1098
|
+
// keep only id-less notifications (never in `inFlight`,
|
|
1099
|
+
// so no reconnect carries them) to re-drain. `await
|
|
1100
|
+
// reconnect()` resolves the shared reconnectPromise, so
|
|
1101
|
+
// `inFlight` has been fully replayed by the time we
|
|
1102
|
+
// decide what's left to send — no matter which caller
|
|
1103
|
+
// owns the reconnect.
|
|
1104
|
+
log.warn("bridge.session_stale", { sessionUrl: sse.postUrl });
|
|
1105
|
+
if (msg.id == null)
|
|
1106
|
+
pendingForward.unshift(msg);
|
|
1107
|
+
await reconnect("post-404");
|
|
1108
|
+
const notifications = pendingForward.filter((m) => m.id == null);
|
|
1109
|
+
pendingForward.length = 0;
|
|
1110
|
+
pendingForward.push(...notifications);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
log.error("bridge.flush_failed", {
|
|
1116
|
+
id: msg.id ?? null,
|
|
1117
|
+
err: err instanceof Error ? err.message : String(err),
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
finally {
|
|
1123
|
+
flushing = false;
|
|
1124
|
+
}
|
|
1125
|
+
// If stdin closed while we were draining, close out anything still open
|
|
1126
|
+
// (buffered + already-in-inFlight-but-undelivered) so those calls get an
|
|
1127
|
+
// error envelope instead of hanging until the client's own timeout.
|
|
1128
|
+
if (stdinClosed) {
|
|
1129
|
+
failPendingForward("connection lost during shutdown");
|
|
1130
|
+
failInFlightRequests("connection lost during shutdown");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
// The client discovered tools from our static `tools/list`. Now that the
|
|
1134
|
+
// real relayer session is up, tell it to re-list so it picks up the
|
|
1135
|
+
// authoritative upstream set (spliced with login/logout in the pump).
|
|
1136
|
+
writeStdoutMessage({
|
|
1137
|
+
jsonrpc: "2.0",
|
|
1138
|
+
method: "notifications/tools/list_changed",
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/** Write the "relayer unavailable" reply for one open request, stop tracking
|
|
1142
|
+
* it, and never double-answer a locally-answered request. Shared by the
|
|
1143
|
+
* buffered (`failPendingForward`) and in-flight (`failInFlightRequests`)
|
|
1144
|
+
* close-outs. Skips:
|
|
1145
|
+
* - notifications (no id → nothing to reply to; also unforwardable now).
|
|
1146
|
+
* - `initialize` (we already answered it locally; a second response for
|
|
1147
|
+
* that id would corrupt the client's JSON-RPC state — just untrack).
|
|
1148
|
+
* Only `tools/call` shaped requests get the tool-result error envelope; any
|
|
1149
|
+
* other id-bearing request gets a JSON-RPC error object (the correct shape
|
|
1150
|
+
* for a non-tool request). */
|
|
1151
|
+
function failRequest(msg, reason) {
|
|
1152
|
+
if (msg.id == null)
|
|
1153
|
+
return; // notification — nothing to answer
|
|
1154
|
+
if (msg.method === "initialize") {
|
|
1155
|
+
// Locally answered already. Never write a second reply for this id.
|
|
1156
|
+
// Keep any suppress arm so a late upstream initialize result is
|
|
1157
|
+
// consumed; do not closedOut the id (clients may reuse it later).
|
|
1158
|
+
inFlight.delete(msg.id);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
inFlight.delete(msg.id);
|
|
1162
|
+
// Remember we answered this id, so a late genuine reply (e.g. from a
|
|
1163
|
+
// flush-404 reconnect that re-posted onto a live session) is dropped by
|
|
1164
|
+
// the pump instead of becoming a second response for the same id.
|
|
1165
|
+
closedOutIds.add(msg.id);
|
|
1166
|
+
if (msg.method === "tools/call") {
|
|
1167
|
+
writeStdoutMessage({
|
|
1168
|
+
jsonrpc: "2.0",
|
|
1169
|
+
id: msg.id,
|
|
1170
|
+
result: {
|
|
1171
|
+
content: [
|
|
1172
|
+
{
|
|
1173
|
+
type: "text",
|
|
1174
|
+
text: `❌ Walrus Memory relayer unavailable: ${reason}. The memory tool could not run. Please retry shortly.`,
|
|
1175
|
+
},
|
|
1176
|
+
],
|
|
1177
|
+
isError: true,
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
writeStdoutMessage({
|
|
1183
|
+
jsonrpc: "2.0",
|
|
1184
|
+
id: msg.id,
|
|
1185
|
+
error: {
|
|
1186
|
+
code: -32000,
|
|
1187
|
+
message: `Walrus Memory relayer unavailable: ${reason}`,
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
function failPendingForward(reason) {
|
|
1193
|
+
const queued = pendingForward.splice(0, pendingForward.length);
|
|
1194
|
+
for (const msg of queued)
|
|
1195
|
+
failRequest(msg, reason);
|
|
1196
|
+
}
|
|
1197
|
+
/** Close out requests that reached `inFlight` but were never delivered a
|
|
1198
|
+
* reply — the shutdown counterpart of `failPendingForward`. Used when stdin
|
|
1199
|
+
* closes mid-flush: items already shifted out of `pendingForward` and posted
|
|
1200
|
+
* to a torn-down session would otherwise hang, since no upstream reply is
|
|
1201
|
+
* coming. Idempotent w.r.t. ids already closed out (delete-then-skip). */
|
|
1202
|
+
function failInFlightRequests(reason) {
|
|
1203
|
+
for (const msg of Array.from(inFlight.values()))
|
|
1204
|
+
failRequest(msg, reason);
|
|
1205
|
+
}
|
|
1206
|
+
// Kick off the relayer connect in the BACKGROUND — do NOT await it before
|
|
1207
|
+
// wiring stdin below. This is the whole fix: `initialize` / `tools/list` are
|
|
1208
|
+
// answered locally the moment they arrive, while the (possibly slow / cold)
|
|
1209
|
+
// relayer round-trip proceeds off the handshake's critical path.
|
|
1210
|
+
//
|
|
1211
|
+
// Retry with backoff so a cold-starting relayer eventually connects. We do
|
|
1212
|
+
// NOT fail buffered requests between attempts: a request that the next
|
|
1213
|
+
// attempt would serve must not get a spurious "unavailable" error (that
|
|
1214
|
+
// would also drop the auth-required hot-handoff request). Buffered tool
|
|
1215
|
+
// calls stay queued and are flushed on the first SUCCESS; if they never
|
|
1216
|
+
// connect, the client's own per-tool timeout fires (graceful) — and on
|
|
1217
|
+
// shutdown `failPendingForward` closes out anything still open. `initialize`
|
|
1218
|
+
// is answered locally, so it never blocks and is only forwarded, not failed.
|
|
1219
|
+
// First connect stays on `openSseStream` + `flushPendingForward` so a
|
|
1220
|
+
// flush-time 404 still goes through the existing reconnect/replay path.
|
|
1221
|
+
// It must NOT publish if login already owns `sse`, or if the handshake
|
|
1222
|
+
// finished after `credentialGeneration` moved — that was the double-flush.
|
|
1223
|
+
const connectInBackground = (async () => {
|
|
1224
|
+
let attempt = 0;
|
|
1225
|
+
while (!stdinClosed) {
|
|
1226
|
+
if (reconnectPromise) {
|
|
1227
|
+
await reconnectPromise;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (sse) {
|
|
1231
|
+
signalFirstConnect();
|
|
1232
|
+
const notifications = pendingForward.filter((m) => m.id == null);
|
|
1233
|
+
pendingForward.length = 0;
|
|
1234
|
+
pendingForward.push(...notifications);
|
|
1235
|
+
await flushPendingForward();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const openingGeneration = credentialGeneration;
|
|
1239
|
+
try {
|
|
1240
|
+
const candidate = await openSseStream(creds.relayerUrl, creds);
|
|
1241
|
+
if (stdinClosed) {
|
|
1242
|
+
candidate.abort();
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
if (openingGeneration !== credentialGeneration || sse) {
|
|
1246
|
+
candidate.abort();
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
sse = candidate;
|
|
1250
|
+
note(`Connected. Bridging stdio MCP ↔ ${creds.relayerUrl}`);
|
|
1251
|
+
log.info("bridge.connected", { relayer: creds.relayerUrl });
|
|
1252
|
+
signalFirstConnect();
|
|
1253
|
+
await flushPendingForward();
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
catch (err) {
|
|
1257
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1258
|
+
attempt += 1;
|
|
1259
|
+
log.error("bridge.initial_connect_failed", { err: reason, attempt });
|
|
1260
|
+
if (stdinClosed)
|
|
1261
|
+
break;
|
|
1262
|
+
const backoff = Math.min(15_000, 500 * Math.pow(2, attempt - 1));
|
|
1263
|
+
await new Promise((resolve) => {
|
|
1264
|
+
const timer = setTimeout(() => {
|
|
1265
|
+
unregister();
|
|
1266
|
+
resolve();
|
|
1267
|
+
}, backoff);
|
|
1268
|
+
timer.unref?.();
|
|
1269
|
+
const unregister = onStdinClose(() => {
|
|
1270
|
+
clearTimeout(timer);
|
|
1271
|
+
resolve();
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
signalFirstConnect();
|
|
1277
|
+
failPendingForward("connection not established before shutdown");
|
|
1278
|
+
})();
|
|
692
1279
|
// Replay anything the auth-required server handed off (the tool call that
|
|
693
|
-
// triggered the hot-handoff, plus anything buffered behind it)
|
|
694
|
-
//
|
|
695
|
-
//
|
|
1280
|
+
// triggered the hot-handoff, plus anything buffered behind it). These run
|
|
1281
|
+
// through handleClientLine, which buffers them into pendingForward until the
|
|
1282
|
+
// background connect lands — so the triggering request is served for real
|
|
1283
|
+
// instead of being dropped in the mode switch.
|
|
696
1284
|
if (pendingLines.length > 0) {
|
|
697
1285
|
log.info("bridge.replaying_handoff", { count: pendingLines.length });
|
|
698
1286
|
for (const line of pendingLines)
|
|
699
1287
|
handleClientLine(line);
|
|
700
1288
|
}
|
|
701
1289
|
const clientPump = readStdinLines(handleClientLine).then(() => {
|
|
702
|
-
|
|
703
|
-
sse
|
|
1290
|
+
markStdinClosed();
|
|
1291
|
+
sse?.abort();
|
|
704
1292
|
});
|
|
705
1293
|
await Promise.race([serverPump, clientPump]);
|
|
706
|
-
|
|
1294
|
+
markStdinClosed();
|
|
1295
|
+
const finalStream = sse;
|
|
1296
|
+
finalStream?.abort();
|
|
1297
|
+
await connectInBackground.catch(() => { });
|
|
707
1298
|
log.info("bridge.closed", {});
|
|
708
1299
|
}
|
|
709
1300
|
//# sourceMappingURL=bridge.js.map
|