@mysten-incubation/memwal-mcp 0.0.11 → 0.0.12-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/bridge.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { clearCreds, credsPath } from "./auth.js";
2
2
  import { TOOL_DEFINITIONS } from "./auth-required.js";
3
+ import { clientInfoHeaders, lastClientInfoHeaders, rememberInitializeClientInfo, } from "./client-info.js";
3
4
  import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
4
5
  import { PROACTIVE_INSTRUCTIONS } from "./instructions.js";
5
- import { startOrReuseLoginFlow } from "./login.js";
6
+ import { startOrReuseLoginFlow, resolveLoginTimeoutMs } from "./login.js";
6
7
  import { log, note } from "./logger.js";
7
8
  import { MEMWAL_MCP_VERSION } from "./version.js";
8
9
  /** Memory tools that take a `namespace` argument. `memwal_remember`,
@@ -61,7 +62,7 @@ const LOCAL_TOOL_DEFINITIONS = [
61
62
  },
62
63
  {
63
64
  name: "memwal_logout",
64
- description: "Remove the saved Walrus Memory credentials from this machine (~/.memwal/credentials.json). The on-chain delegate key registration is NOT revoked — visit the Walrus Memory dashboard to remove it from your account if needed.",
65
+ description: "Sign out of Walrus Memory: removes the saved credentials from this machine (~/.memwal/credentials.json) AND closes this connection's memory session, so memory tools stop working until you call memwal_login again. The on-chain delegate key registration is NOT revoked — visit the Walrus Memory dashboard to remove it from your account if needed.",
65
66
  inputSchema: {
66
67
  type: "object",
67
68
  properties: {},
@@ -103,6 +104,16 @@ function buildLocalInitializeResult(params) {
103
104
  * imported memory-tool list (which already carries its own `memwal_login`
104
105
  * entry) before appending our canonical definitions. */
105
106
  const LOCAL_TOOL_NAMES = new Set(LOCAL_TOOL_DEFINITIONS.map((t) => t.name));
107
+ /** Reply for every memory tool call once `memwal_logout` has torn the session
108
+ * down, and for anything still in flight at that moment. Names the way back in
109
+ * so the client isn't left guessing why the tools stopped working. */
110
+ const SIGNED_OUT_TEXT = "❌ Signed out of Walrus Memory. Memory tools are unavailable on this connection until you call `memwal_login` again.";
111
+ /** `failRequest` options for every signed-out refusal, so a request refused at
112
+ * logout time and one refused on arrival afterwards read identically. */
113
+ const SIGNED_OUT_FAILURE = {
114
+ toolText: SIGNED_OUT_TEXT,
115
+ errorMessage: SIGNED_OUT_TEXT,
116
+ };
106
117
  /** The `tools/list` we serve LOCALLY at cold start: the memory tools (from the
107
118
  * same source as auth-required mode) plus the locally-handled login/logout
108
119
  * tools. We strip any locally-served name from the imported list first —
@@ -120,7 +131,6 @@ const LOCAL_TOOLS_LIST = {
120
131
  ...LOCAL_TOOL_DEFINITIONS,
121
132
  ],
122
133
  };
123
- const LOGIN_BG_TIMEOUT_MS = 5 * 60_000;
124
134
  const URL_READY_TIMEOUT_MS = 5_000;
125
135
  /** Maximum silence we tolerate on the SSE stream before assuming the
126
136
  * relayer-side session has gone dead. The relayer sends keepalive events
@@ -163,13 +173,14 @@ function resolveCallTimeoutMs() {
163
173
  return DEFAULT_CALL_TIMEOUT_MS;
164
174
  return n;
165
175
  }
166
- function mcpAuthHeaders(creds) {
176
+ function mcpAuthHeaders(creds, extra = {}) {
167
177
  return {
168
178
  authorization: `Bearer ${creds.delegatePrivateKey}`,
169
179
  "x-memwal-account-id": creds.accountId,
180
+ ...extra,
170
181
  };
171
182
  }
172
- async function openSseStream(relayerUrl, creds) {
183
+ async function openSseStream(relayerUrl, creds, extraHeaders = {}) {
173
184
  const connectTimeoutMs = resolveConnectTimeoutMs();
174
185
  // One shared budget for the WHOLE attempt: the compatibility check (GET
175
186
  // /version + /health fallback) and the SSE connect below both honour this
@@ -208,7 +219,7 @@ async function openSseStream(relayerUrl, creds) {
208
219
  resp = await fetch(url, {
209
220
  method: "GET",
210
221
  headers: {
211
- ...mcpAuthHeaders(creds),
222
+ ...mcpAuthHeaders(creds, extraHeaders),
212
223
  accept: "text/event-stream",
213
224
  "cache-control": "no-cache",
214
225
  },
@@ -411,11 +422,11 @@ async function openSseStream(relayerUrl, creds) {
411
422
  },
412
423
  };
413
424
  }
414
- async function postMessage(postUrl, msg, creds) {
425
+ async function postMessage(postUrl, msg, creds, extraHeaders = {}) {
415
426
  const resp = await fetch(postUrl, {
416
427
  method: "POST",
417
428
  headers: {
418
- ...mcpAuthHeaders(creds),
429
+ ...mcpAuthHeaders(creds, extraHeaders),
419
430
  "content-type": "application/json",
420
431
  },
421
432
  body: JSON.stringify(msg),
@@ -456,7 +467,7 @@ async function handleLocalLogin(config, onCredentials) {
456
467
  relayerUrl: config.relayerUrl,
457
468
  webUrl: config.webUrl,
458
469
  label: config.label,
459
- timeoutMs: LOGIN_BG_TIMEOUT_MS,
470
+ timeoutMs: resolveLoginTimeoutMs(),
460
471
  openBrowser: false,
461
472
  }, async (creds) => {
462
473
  await onCredentials(creds);
@@ -464,6 +475,18 @@ async function handleLocalLogin(config, onCredentials) {
464
475
  accountId: creds.accountId,
465
476
  delegateAddress: creds.delegateAddress,
466
477
  });
478
+ }, (err) => {
479
+ const msg = err instanceof Error ? err.message : String(err);
480
+ log.warn("memwal_login.bridge.failed", { msg });
481
+ writeStdoutMessage({
482
+ jsonrpc: "2.0",
483
+ method: "notifications/message",
484
+ params: {
485
+ level: "warning",
486
+ logger: "memwal-mcp",
487
+ data: `Walrus Memory sign-in did not complete: ${msg}. Existing credentials are unchanged; call memwal_login again to retry.`,
488
+ },
489
+ });
467
490
  });
468
491
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Listener never started")), URL_READY_TIMEOUT_MS).unref?.());
469
492
  let url;
@@ -513,13 +536,19 @@ function handleLocalLogout() {
513
536
  if (!cleared.removedPath) {
514
537
  return {
515
538
  isError: false,
516
- text: `✅ Already signed out. No credentials at \`${credsPath()}\`.`,
539
+ // The bridge only runs with credentials loaded, so a missing
540
+ // file here still means a live in-memory session to tear down —
541
+ // exactly the GH #616 case. Say so rather than implying nothing
542
+ // happened.
543
+ text: `✅ Already signed out. No credentials at \`${credsPath()}\`, and this ` +
544
+ `connection's memory session has been closed — memory tools will refuse ` +
545
+ `to run until you sign in again.`,
517
546
  };
518
547
  }
519
548
  return {
520
549
  isError: false,
521
550
  text: [
522
- `✅ Signed out. Credentials removed from \`${cleared.removedPath}\`.`,
551
+ `✅ Signed out. Credentials removed from \`${cleared.removedPath}\`, and this connection's memory session has been closed — memory tools will refuse to run until you sign in again.`,
523
552
  ...(cleared.fallbackPath
524
553
  ? [
525
554
  ``,
@@ -556,18 +585,18 @@ function handleLocalLogout() {
556
585
  * file directly. They appear in `tools/list` by splicing them into the
557
586
  * relayer's response on the way back to the client.
558
587
  */
559
- export async function runBridge(creds, config,
588
+ export async function runBridge(initialCreds, config,
560
589
  /** Requests the auth-required server already read off stdin before it
561
590
  * detected fresh credentials and handed control here (e.g. the
562
591
  * `memwal_recall` that triggered the hot-handoff). Replayed once the SSE
563
592
  * stream is up so they're served for real instead of being lost in the
564
593
  * mode switch — this is what removes the historical "second restart". */
565
594
  pendingLines = []) {
566
- note(`Connecting to ${creds.relayerUrl}...`);
595
+ note(`Connecting to ${initialCreds.relayerUrl}...`);
567
596
  log.info("bridge.connecting", {
568
- relayer: creds.relayerUrl,
569
- accountId: creds.accountId,
570
- delegate: creds.delegateAddress,
597
+ relayer: initialCreds.relayerUrl,
598
+ accountId: initialCreds.accountId,
599
+ delegate: initialCreds.delegateAddress,
571
600
  });
572
601
  // Live handle to the current SSE stream — replaced whenever we reconnect.
573
602
  // Starts null: we answer `initialize` / `tools/list` LOCALLY and wire stdin
@@ -576,7 +605,37 @@ pendingLines = []) {
576
605
  // the background; anything that must reach the relayer (`tools/call`) is
577
606
  // buffered in `pendingForward` until `sse` is live, then flushed.
578
607
  let sse = null;
608
+ /** The delegate key this bridge is currently authorized to act with.
609
+ * Nulled by `invalidateSession()` so signing out drops the key itself
610
+ * rather than only setting a flag that every forwarding path has to
611
+ * remember to check — after logout there is simply nothing left to sign
612
+ * with. `adoptCredentials` republishes it on the next login. */
613
+ let creds = initialCreds;
614
+ /**
615
+ * Best-effort `x-memwal-client` / `x-memwal-client-version` forwarded to
616
+ * the sidecar. Seeded from `lastClientInfoHeaders()`, which is only
617
+ * populated after the auth-required → bridge handoff (that path does not
618
+ * replay `initialize`). On a normal already-signed-in start this object
619
+ * is empty at first SSE connect: `connectInBackground` opens the stream
620
+ * before stdin is wired. Headers are filled when we see `initialize` and
621
+ * then land on reconnects / later POSTs. The sidecar treats the MCP
622
+ * handshake (`initialize.clientInfo`) as the authoritative source.
623
+ */
624
+ const extraHeaders = { ...lastClientInfoHeaders() };
579
625
  let stdinClosed = false;
626
+ /** Set by `memwal_logout`. Unlike `stdinClosed` the process stays up and
627
+ * the client keeps talking to us — `memwal_login` must still work — but the
628
+ * relayer session is torn down and must never be re-established with the
629
+ * credentials the user just deleted. Every connect/reconnect path therefore
630
+ * checks this alongside `stdinClosed`; `adoptCredentials` clears it when a
631
+ * new login lands. */
632
+ let loggedOut = false;
633
+ /** Resolves once a post-logout `memwal_login` has published a fresh session
634
+ * (or stdin closed). The server pump parks on this instead of exiting, so
635
+ * signing back in resumes streaming without the user restarting their MCP
636
+ * client. Recreated on every logout; null while signed in. */
637
+ let logoutPark = null;
638
+ let releaseLogoutPark = null;
580
639
  let reconnectAttempt = 0;
581
640
  let reconnectPromise = null;
582
641
  let firstConnectDone = false;
@@ -593,7 +652,7 @@ pendingLines = []) {
593
652
  function postIfCurrent(epoch, postUrl, msg, postCreds) {
594
653
  if (epoch !== sessionEpoch)
595
654
  return Promise.resolve(0);
596
- return postMessage(postUrl, msg, postCreds);
655
+ return postMessage(postUrl, msg, postCreds, extraHeaders);
597
656
  }
598
657
  let credentialGeneration = 0;
599
658
  let activeCredentialGeneration = 0;
@@ -619,6 +678,10 @@ pendingLines = []) {
619
678
  if (stdinClosed)
620
679
  return;
621
680
  stdinClosed = true;
681
+ // Wake a pump parked on logout, or shutdown would block on a login
682
+ // that is never coming.
683
+ releaseLogoutPark?.();
684
+ releaseLogoutPark = null;
622
685
  for (const fn of stdinCloseListeners) {
623
686
  try {
624
687
  fn();
@@ -713,7 +776,7 @@ pendingLines = []) {
713
776
  * login credential swap). Credential-generation checks discard a session
714
777
  * whose key rotated mid-handshake. */
715
778
  async function reconnect(reason, immediate = false) {
716
- if (stdinClosed)
779
+ if (stdinClosed || loggedOut)
717
780
  return;
718
781
  if (reconnectPromise)
719
782
  return reconnectPromise;
@@ -751,10 +814,23 @@ pendingLines = []) {
751
814
  });
752
815
  }
753
816
  try {
754
- while (!stdinClosed) {
817
+ while (!stdinClosed && !loggedOut) {
755
818
  const openingGeneration = credentialGeneration;
756
819
  const openingCreds = creds;
757
- const candidate = await openSseStream(openingCreds.relayerUrl, openingCreds);
820
+ // Signed out between the guard above and here: the key is
821
+ // gone, so there is nothing to authorize a new session
822
+ // with. Belt-and-braces against `loggedOut` alone.
823
+ if (!openingCreds)
824
+ break;
825
+ const candidate = await openSseStream(openingCreds.relayerUrl, openingCreds, extraHeaders);
826
+ // Logout can also land mid-handshake. Same reasoning as the
827
+ // stale-credentials case below, except there is no new key
828
+ // to reconnect with — drop the session and stop.
829
+ if (loggedOut) {
830
+ candidate.abort();
831
+ log.info("bridge.reconnect_discarded_signed_out", {});
832
+ break;
833
+ }
758
834
  // Login can finish while an older handshake is awaiting its
759
835
  // endpoint event. Never publish that stale session: its GET
760
836
  // used the old key, while subsequent POSTs would use the new
@@ -788,6 +864,16 @@ pendingLines = []) {
788
864
  // and the SSE pump may delete entries concurrently as replies
789
865
  // start arriving on the new session.
790
866
  for (const [id, entry] of Array.from(inFlight.entries())) {
867
+ // Replay awaits a POST per entry, so a logout can land
868
+ // partway through this loop. The snapshot and
869
+ // `openingCreds` both predate it, so without this the
870
+ // remaining entries would still go out under the key the
871
+ // user just deleted — `invalidateSession` clearing
872
+ // `inFlight` cannot stop a snapshot already taken.
873
+ if (loggedOut || openingGeneration !== credentialGeneration) {
874
+ log.info("bridge.replay_halted_signed_out", { id });
875
+ break;
876
+ }
791
877
  const msg = entry.msg;
792
878
  try {
793
879
  // A replayed `initialize` produces a fresh upstream
@@ -851,7 +937,10 @@ pendingLines = []) {
851
937
  }
852
938
  }
853
939
  async function adoptCredentials(nextCreds) {
854
- const previousAccountId = creds.accountId;
940
+ // `null` after a logout — treated as an account change, which is the
941
+ // safe direction: it purges rather than replays. (`invalidateSession`
942
+ // already emptied both queues, so the purge is a no-op there.)
943
+ const previousAccountId = creds?.accountId ?? null;
855
944
  const accountChanged = previousAccountId !== nextCreds.accountId;
856
945
  // Never replay an operation authorized for account A against account B.
857
946
  // Return explicit retryable errors instead; the caller can decide which
@@ -915,6 +1004,10 @@ pendingLines = []) {
915
1004
  creds = nextCreds;
916
1005
  credentialGeneration += 1;
917
1006
  reconnectAttempt = 0;
1007
+ // Lift the logout halt BEFORE reconnecting — reconnect() refuses to run
1008
+ // while it is set. The parked pump is released further down, once the
1009
+ // new session actually exists.
1010
+ loggedOut = false;
918
1011
  log.info("bridge.credentials_updated", {
919
1012
  previousAccountId,
920
1013
  accountId: creds.accountId,
@@ -926,6 +1019,56 @@ pendingLines = []) {
926
1019
  if (activeCredentialGeneration !== credentialGeneration) {
927
1020
  await reconnect("login-credentials-generation-mismatch", true);
928
1021
  }
1022
+ // Session is live again: wake a pump parked by a previous logout.
1023
+ releaseLogoutPark?.();
1024
+ releaseLogoutPark = null;
1025
+ logoutPark = null;
1026
+ }
1027
+ /**
1028
+ * Tear the relayer session down after a successful `memwal_logout`.
1029
+ *
1030
+ * Deleting the credentials file is not revocation on its own: the bridge
1031
+ * holds the delegate key in memory and owns a live SSE session, so without
1032
+ * this every later memory tool call would still be forwarded and executed
1033
+ * under the key the user just removed (GH #616).
1034
+ *
1035
+ * `loggedOut` is set FIRST so the abort below cannot race the pump into
1036
+ * `reconnect("server-pump-eof")` and immediately re-authorize a new session
1037
+ * with those same in-memory credentials.
1038
+ */
1039
+ function invalidateSession() {
1040
+ if (loggedOut)
1041
+ return;
1042
+ loggedOut = true;
1043
+ logoutPark = new Promise((resolve) => {
1044
+ releaseLogoutPark = resolve;
1045
+ });
1046
+ try {
1047
+ sse?.abort();
1048
+ }
1049
+ catch {
1050
+ /* already dead */
1051
+ }
1052
+ sse = null;
1053
+ // Drop the delegate key itself, not just the flag. Revocation that
1054
+ // rests only on `loggedOut` is one missed check away from forwarding
1055
+ // under the key the user deleted; with `creds` null there is nothing
1056
+ // left to sign with and every forwarding path fails closed instead.
1057
+ creds = null;
1058
+ // Bump the generation so any handshake or replay that captured the old
1059
+ // key before this point discards its work on its next check, exactly as
1060
+ // it would for a mid-flight key rotation.
1061
+ credentialGeneration += 1;
1062
+ // Answer everything still outstanding rather than stranding it: these
1063
+ // were authorized under the old key and must not be replayed later.
1064
+ for (const [, entry] of Array.from(inFlight.entries())) {
1065
+ failRequest(entry.msg, "signed out", SIGNED_OUT_FAILURE);
1066
+ }
1067
+ inFlight.clear();
1068
+ for (const msg of pendingForward.splice(0, pendingForward.length)) {
1069
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1070
+ }
1071
+ log.info("bridge.session_invalidated", { reason: "logout" });
929
1072
  }
930
1073
  // Server → client: stream SSE messages to stdout. Loop forever, restart
931
1074
  // pump on stream end (which means SSE got cut → we already reconnected).
@@ -947,8 +1090,23 @@ pendingLines = []) {
947
1090
  // sibling closure (connectInBackground / reconnect) that TS
948
1091
  // analyzes independently. At runtime `sse` is a live handle here.
949
1092
  const stream = sse;
950
- if (!stream)
1093
+ if (!stream) {
1094
+ // Signed out: park rather than exit. Exiting would end the
1095
+ // pump for good, so a later `memwal_login` would reconnect a
1096
+ // session with nothing draining it — the client would hang
1097
+ // instead of recovering. `logoutPark` resolves once the new
1098
+ // session is published (or stdin closes).
1099
+ // Cast for the same reason as `stream` above: every
1100
+ // assignment to `logoutPark` happens in a sibling closure,
1101
+ // so TS narrows it to `null` here. No `!stdinClosed` guard:
1102
+ // the `while` above already established it and nothing is
1103
+ // awaited in between, so it cannot have changed.
1104
+ if (loggedOut) {
1105
+ await logoutPark;
1106
+ continue;
1107
+ }
951
1108
  break; // stdin closed before we ever connected
1109
+ }
952
1110
  while (true) {
953
1111
  const { value, done } = await stream.iter.next();
954
1112
  if (done)
@@ -1014,6 +1172,10 @@ pendingLines = []) {
1014
1172
  err: err instanceof Error ? err.message : String(err),
1015
1173
  });
1016
1174
  }
1175
+ // Deliberately NOT short-circuited on `loggedOut`: breaking here
1176
+ // would end the pump for good and strand a later re-login. Fall
1177
+ // through instead — `reconnect()` no-ops while signed out, and the
1178
+ // next iteration parks on `logoutPark` at the top of the loop.
1017
1179
  if (stdinClosed)
1018
1180
  break;
1019
1181
  // Stream ended. If a reconnect is ALREADY in progress (e.g. the
@@ -1039,11 +1201,25 @@ pendingLines = []) {
1039
1201
  // session negotiates capabilities — but suppress that upstream
1040
1202
  // reply, since the client already has this one.
1041
1203
  if (msg.method === "initialize" && msg.id != null) {
1204
+ const clientInfo = rememberInitializeClientInfo(msg.params);
1205
+ if (clientInfo) {
1206
+ Object.assign(extraHeaders, clientInfoHeaders(clientInfo));
1207
+ log.info("bridge.agent_client", {
1208
+ clientName: clientInfo.name,
1209
+ clientVersion: clientInfo.version,
1210
+ });
1211
+ }
1042
1212
  writeStdoutMessage({
1043
1213
  jsonrpc: "2.0",
1044
1214
  id: msg.id,
1045
1215
  result: buildLocalInitializeResult(msg.params),
1046
1216
  });
1217
+ // Signed out: the local reply is the whole answer. We will
1218
+ // not forward upstream, so do not arm a suppression that no
1219
+ // reply can ever consume — a leaked arm would swallow the
1220
+ // real reply if the client later reuses this id.
1221
+ if (loggedOut)
1222
+ return;
1047
1223
  // Expect exactly one upstream reply to drop for this forward.
1048
1224
  expectSuppressedReply(msg.id);
1049
1225
  // Fall through: forward/buffer the initialize upstream too.
@@ -1092,6 +1268,12 @@ pendingLines = []) {
1092
1268
  }
1093
1269
  if (params.name === "memwal_logout") {
1094
1270
  const result = handleLocalLogout();
1271
+ // Tear the session down before replying, so by the time
1272
+ // the client is told it is signed out that is actually
1273
+ // true. Only on success: if the credentials file could
1274
+ // not be removed the user is still signed in.
1275
+ if (!result.isError)
1276
+ invalidateSession();
1095
1277
  writeStdoutMessage({
1096
1278
  jsonrpc: "2.0",
1097
1279
  id: msg.id,
@@ -1103,6 +1285,22 @@ pendingLines = []) {
1103
1285
  return;
1104
1286
  }
1105
1287
  }
1288
+ // Signed out: refuse EVERY remaining request locally, not just
1289
+ // memory tool calls. `login`/`logout` returned above, and
1290
+ // `initialize`/`tools/list` are answered locally further up, so
1291
+ // anything still here would need the delegate key the user
1292
+ // deleted. Falling through instead would park it in
1293
+ // `pendingForward` — `sse` is null and `reconnect()` no-ops
1294
+ // while signed out — where it would either hang the client until
1295
+ // a login that may never come, or be flushed afterwards under a
1296
+ // NEW key the client never authorized it against. `failRequest`
1297
+ // picks the right shape per method: tool-result text for
1298
+ // `tools/call`, a JSON-RPC error for `ping` and friends, and
1299
+ // nothing at all for notifications.
1300
+ if (loggedOut) {
1301
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1302
+ return;
1303
+ }
1106
1304
  // Fill in the configured default namespace for memory tool
1107
1305
  // calls that didn't pass one. Mutates msg in place so the
1108
1306
  // forwarded — and any replayed-on-reconnect — copy carries it.
@@ -1147,6 +1345,12 @@ pendingLines = []) {
1147
1345
  if (activeCredentialGeneration !== credentialGeneration) {
1148
1346
  await reconnect("post-credential-generation-mismatch", true);
1149
1347
  }
1348
+ // A logout can land while the two awaits above are parked. The
1349
+ // key is gone by then, so answer locally instead of posting.
1350
+ if (loggedOut || !creds) {
1351
+ failRequest(msg, "signed out", SIGNED_OUT_FAILURE);
1352
+ return;
1353
+ }
1150
1354
  if (!sse) {
1151
1355
  await reconnect("sse-missing");
1152
1356
  return;
@@ -1185,7 +1389,9 @@ pendingLines = []) {
1185
1389
  // into inFlight but not delivered) is closed out below.
1186
1390
  break;
1187
1391
  }
1188
- if (!sse)
1392
+ // `invalidateSession` nulls both; either one means this queue
1393
+ // must not be drained onto the relayer.
1394
+ if (!sse || !creds)
1189
1395
  break; // lost the session; reconnect replays inFlight
1190
1396
  const msg = pendingForward.shift();
1191
1397
  try {
@@ -1258,6 +1464,9 @@ pendingLines = []) {
1258
1464
  * Only `tools/call` shaped requests get the tool-result error envelope; any
1259
1465
  * other id-bearing request gets a JSON-RPC error object (the correct shape
1260
1466
  * for a non-tool request). */
1467
+ /** `opts` overrides the default "relayer unavailable" wording for callers
1468
+ * whose failure is not an outage — logout, for one, where blaming the
1469
+ * relayer would be actively misleading. */
1261
1470
  function failRequest(msg, reason, opts = {}) {
1262
1471
  if (msg.id == null)
1263
1472
  return; // notification — nothing to answer
@@ -1359,7 +1568,7 @@ pendingLines = []) {
1359
1568
  // finished after `credentialGeneration` moved — that was the double-flush.
1360
1569
  const connectInBackground = (async () => {
1361
1570
  let attempt = 0;
1362
- while (!stdinClosed) {
1571
+ while (!stdinClosed && !loggedOut) {
1363
1572
  if (reconnectPromise) {
1364
1573
  await reconnectPromise;
1365
1574
  continue;
@@ -1374,11 +1583,19 @@ pendingLines = []) {
1374
1583
  }
1375
1584
  const openingGeneration = credentialGeneration;
1376
1585
  try {
1377
- const candidate = await openSseStream(creds.relayerUrl, creds);
1586
+ const candidate = await openSseStream(creds.relayerUrl, creds, extraHeaders);
1378
1587
  if (stdinClosed) {
1379
1588
  candidate.abort();
1380
1589
  break;
1381
1590
  }
1591
+ // Signed out while this handshake was in flight. The loop guard
1592
+ // above only runs between iterations, so without this the
1593
+ // session would be published — an open, authenticated stream
1594
+ // holding the delegate key the user just deleted.
1595
+ if (loggedOut) {
1596
+ candidate.abort();
1597
+ break;
1598
+ }
1382
1599
  if (openingGeneration !== credentialGeneration || sse) {
1383
1600
  candidate.abort();
1384
1601
  continue;