@mysten-incubation/memwal-mcp 0.0.14-dev.0 → 0.0.14-dev.10

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.
Files changed (50) hide show
  1. package/README.md +238 -1
  2. package/dist/auth-required.d.ts +325 -0
  3. package/dist/auth-required.d.ts.map +1 -1
  4. package/dist/auth-required.js +84 -16
  5. package/dist/auth-required.js.map +1 -1
  6. package/dist/auth.d.ts +179 -18
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +458 -35
  9. package/dist/auth.js.map +1 -1
  10. package/dist/auto-save.d.ts +100 -0
  11. package/dist/auto-save.d.ts.map +1 -0
  12. package/dist/auto-save.js +290 -0
  13. package/dist/auto-save.js.map +1 -0
  14. package/dist/bridge.d.ts +14 -2
  15. package/dist/bridge.d.ts.map +1 -1
  16. package/dist/bridge.js +233 -25
  17. package/dist/bridge.js.map +1 -1
  18. package/dist/consent.d.ts +36 -0
  19. package/dist/consent.d.ts.map +1 -0
  20. package/dist/consent.js +129 -0
  21. package/dist/consent.js.map +1 -0
  22. package/dist/health-probe.d.ts +35 -0
  23. package/dist/health-probe.d.ts.map +1 -0
  24. package/dist/health-probe.js +87 -0
  25. package/dist/health-probe.js.map +1 -0
  26. package/dist/index.d.ts +11 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +266 -5
  29. package/dist/index.js.map +1 -1
  30. package/dist/instructions.d.ts +17 -18
  31. package/dist/instructions.d.ts.map +1 -1
  32. package/dist/instructions.js +84 -20
  33. package/dist/instructions.js.map +1 -1
  34. package/dist/login.d.ts +15 -1
  35. package/dist/login.d.ts.map +1 -1
  36. package/dist/login.js +109 -9
  37. package/dist/login.js.map +1 -1
  38. package/dist/memory-policy.d.ts +38 -0
  39. package/dist/memory-policy.d.ts.map +1 -0
  40. package/dist/memory-policy.js +80 -0
  41. package/dist/memory-policy.js.map +1 -0
  42. package/dist/messages.d.ts +10 -3
  43. package/dist/messages.d.ts.map +1 -1
  44. package/dist/messages.js +16 -5
  45. package/dist/messages.js.map +1 -1
  46. package/dist/recovery.d.ts +1 -0
  47. package/dist/recovery.d.ts.map +1 -1
  48. package/dist/recovery.js +15 -13
  49. package/dist/recovery.js.map +1 -1
  50. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -3,8 +3,9 @@ import { TOOL_DEFINITIONS } from "./auth-required.js";
3
3
  import { clientInfoHeaders, lastClientInfoHeaders, rememberInitializeClientInfo, } from "./client-info.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
6
- import { PROACTIVE_INSTRUCTIONS } from "./instructions.js";
7
- import { startOrReuseLoginFlow, resolveLoginTimeoutMs } from "./login.js";
6
+ import { describeHealthProbe, probeRelayerHealth, resolveHealthProbeMs, } from "./health-probe.js";
7
+ import { proactiveInstructions } from "./instructions.js";
8
+ import { startOrReuseLoginFlow, resolveLoginTimeoutMs, isLoginSuperseded } from "./login.js";
8
9
  import { log, note } from "./logger.js";
9
10
  import { loginPrompt, loginSuccessNotice, loginSuccessNotification, } from "./messages.js";
10
11
  import { openStreamableSession, resolveTransport } from "./streamable.js";
@@ -33,7 +34,8 @@ const NAMESPACE_TOOLS = new Set([
33
34
  * per-call namespace always wins over the configured default.
34
35
  */
35
36
  /**
36
- * Name the relayer this process dialled in a `memwal_health` result.
37
+ * Name the destination this process is bound to in a `memwal_health` result:
38
+ * the relayer it dialled, and the account it signs for.
37
39
  *
38
40
  * The relayer-side text can only report an origin its deployment published, and
39
41
  * stays silent on a self-hosted or local one, where the sidecar knows nothing
@@ -45,8 +47,15 @@ const NAMESPACE_TOOLS = new Set([
45
47
  * Rewrites an existing `relayer=` field rather than appending a second one: when
46
48
  * both sides know the origin they describe the same session, and two
47
49
  * conflicting fields would be worse than neither.
50
+ *
51
+ * `account=` rides along for the same reason (WALM-639). A project-local
52
+ * credentials file can point this process at a different account than the one
53
+ * the user signed in with, and "which account am I writing to" was otherwise
54
+ * only visible in stderr the MCP host usually hides — so the half of the
55
+ * destination that decides WHOSE memory this is now shows up beside the half
56
+ * that decides where it is stored.
48
57
  */
49
- export function annotateHealthResult(result, relayerUrl) {
58
+ export function annotateHealthResult(result, relayerUrl, accountId) {
50
59
  // A failed health call has no session to describe; naming a relayer beside
51
60
  // an error reads as though that relayer answered.
52
61
  if (result.isError)
@@ -60,6 +69,12 @@ export function annotateHealthResult(result, relayerUrl) {
60
69
  block.text = existing.test(block.text)
61
70
  ? block.text.replace(existing, `relayer=${relayerUrl}`)
62
71
  : `${block.text} relayer=${relayerUrl}`;
72
+ if (!accountId)
73
+ return;
74
+ const existingAccount = /\baccount=\S+/;
75
+ block.text = existingAccount.test(block.text)
76
+ ? block.text.replace(existingAccount, `account=${accountId}`)
77
+ : `${block.text} account=${accountId}`;
63
78
  }
64
79
  export function applyDefaultNamespace(msg, namespace) {
65
80
  if (!namespace)
@@ -88,7 +103,13 @@ const LOCAL_TOOL_DEFINITIONS = [
88
103
  description: "Sign in (or re-sign in) to Walrus Memory by opening a browser. Use to switch wallets, refresh credentials, or sign in for the first time. Returns a click-able URL — the user must approve in their browser.",
89
104
  inputSchema: {
90
105
  type: "object",
91
- properties: {},
106
+ properties: {
107
+ freshKey: {
108
+ type: "boolean",
109
+ default: false,
110
+ description: "Mint a new delegate key instead of reusing the one a previous unfinished sign-in left pending. Leave false for an ordinary login. Set it only after a normal sign-in has already failed on the pending key — it abandons that key, which must then be removed from the dashboard if it was approved on-chain, and the new one needs its own add_delegate_key transaction.",
111
+ },
112
+ },
92
113
  additionalProperties: false,
93
114
  },
94
115
  },
@@ -129,7 +150,11 @@ function buildLocalInitializeResult(params) {
129
150
  // client: this local answer wins and the upstream initialize reply is
130
151
  // suppressed. Omitting it here silently strips the proactive contract
131
152
  // from every stdio client, which is the WALM-324 regression itself.
132
- instructions: PROACTIVE_INSTRUCTIONS,
153
+ //
154
+ // Resolved per handshake, not read from a module const: whether the
155
+ // model is told to save unprompted depends on the user's automatic-save
156
+ // opt-in, which lives on disk and can change between spawns (WALM-642).
157
+ instructions: proactiveInstructions(),
133
158
  };
134
159
  }
135
160
  /** Names of the tools we serve locally, so we can de-dup them out of the
@@ -152,6 +177,15 @@ const SIGNED_OUT_FAILURE = {
152
177
  * "connection dropped, please retry", which is advice that cannot work. Name
153
178
  * the cause and the way back in instead (GH #365 / WALM-602). */
154
179
  const UNAUTHORIZED_TEXT = "❌ Walrus Memory rejected the saved credentials (HTTP 401). The delegate key may have been revoked or is no longer registered on this account. Call `memwal_login` to sign in again — saved credentials were NOT modified.";
180
+ /** Reply for a `tools/call` naming a tool the connected relayer does not
181
+ * serve. Names what IS on offer, because the agent's next move is to pick one
182
+ * of those — "unknown tool" alone leaves it guessing or retrying. */
183
+ export function unknownToolText(name, available) {
184
+ return (`❌ \`${name}\` is not a tool this Walrus Memory server offers. ` +
185
+ `Available: ${available.join(", ")}. ` +
186
+ `Your tool list is stale — re-read \`tools/list\` and use one of those instead. ` +
187
+ `Nothing ran, so nothing was saved or changed.`);
188
+ }
155
189
  /** `failRequest` options for every credentials-rejected refusal, so one refused
156
190
  * at handshake time and one refused on arrival afterwards read identically. */
157
191
  const UNAUTHORIZED_FAILURE = {
@@ -175,6 +209,10 @@ const LOCAL_TOOLS_LIST = {
175
209
  ...LOCAL_TOOL_DEFINITIONS,
176
210
  ],
177
211
  };
212
+ /** Names advertised at cold start (baseline relayer + locally-served). A
213
+ * `tools/call` for anything else is refused until an upstream `tools/list`
214
+ * has been seen — fail-closed, not fail-open. */
215
+ const COLD_START_TOOL_NAMES = new Set(LOCAL_TOOLS_LIST.tools.map((t) => t.name));
178
216
  const URL_READY_TIMEOUT_MS = 5_000;
179
217
  /** Maximum silence we tolerate on the SSE stream before assuming the
180
218
  * relayer-side session has gone dead. The relayer sends keepalive events
@@ -218,8 +256,13 @@ const MIN_CALL_TIMEOUT_MS = 1_000;
218
256
  * `MAX_STATUS_WAIT_MS` for `memwal_remember_status`, and the fixed `timeoutMs`
219
257
  * the bulk and analyze tools pass to the SDK. Keep them in lockstep: a value
220
258
  * below a tool's real ceiling abandons healthy work. Unlisted tools keep the
221
- * default. */
259
+ * default.
260
+ *
261
+ * `memwal_recall` is the exception to lockstep: the SDK aborts the recall
262
+ * request itself at 15s, but the checks it runs first (`/version`, `/config`)
263
+ * carry their own deadlines, or none on older SDKs. 90s covers those. */
222
264
  const TOOL_DEADLINE_MS = {
265
+ memwal_recall: 90_000,
223
266
  memwal_remember: 90_000,
224
267
  memwal_remember_status: 60_000,
225
268
  memwal_remember_bulk: 120_000,
@@ -700,13 +743,18 @@ function writeStdoutMessage(msg) {
700
743
  * mode, but available even when creds already exist (so user can re-login,
701
744
  * switch wallets, or refresh). Returns a click-able URL near-instantly;
702
745
  * listener stays alive in the background until callback or timeout. */
703
- async function handleLocalLogin(config, onCredentials) {
746
+ async function handleLocalLogin(config, onCredentials, freshKey) {
747
+ let discardedKey;
704
748
  const session = startOrReuseLoginFlow({
705
749
  relayerUrl: config.relayerUrl,
706
750
  webUrl: config.webUrl,
707
751
  label: config.label,
708
752
  timeoutMs: resolveLoginTimeoutMs(),
709
753
  openBrowser: false,
754
+ freshKey,
755
+ onDiscarded: (key) => {
756
+ discardedKey = key;
757
+ },
710
758
  }, async (creds) => {
711
759
  await onCredentials(creds);
712
760
  log.info("memwal_login.bridge.success", {
@@ -750,8 +798,10 @@ async function handleLocalLogin(config, onCredentials) {
750
798
  // cannot help that key, since it reuses it and the
751
799
  // dashboard cannot register it twice.
752
800
  data: `Walrus Memory sign-in did not complete: ${msg}. Existing credentials are ` +
753
- `unchanged. If you approved the wallet step, the next start reclaims that ` +
754
- `key; otherwise call memwal_login again to retry.`,
801
+ `unchanged. The key is kept on disk and a later start reclaims it only if ` +
802
+ `the relayer can confirm it was registered, which it cannot always do. ` +
803
+ `Call memwal_login again to retry with that key, or with freshKey: true to ` +
804
+ `mint a new one and abandon it.`,
755
805
  },
756
806
  });
757
807
  });
@@ -761,6 +811,9 @@ async function handleLocalLogin(config, onCredentials) {
761
811
  url = await Promise.race([session.url, timeoutPromise]);
762
812
  }
763
813
  catch (err) {
814
+ if (isLoginSuperseded(err)) {
815
+ return { isError: true, text: "This sign-in was replaced by a newer memwal_login. Open that result instead of starting another login." };
816
+ }
764
817
  return {
765
818
  isError: true,
766
819
  text: `❌ Failed to start login: ${err instanceof Error ? err.message : String(err)}`,
@@ -776,6 +829,7 @@ async function handleLocalLogin(config, onCredentials) {
776
829
  url,
777
830
  credentialsPath: credsPath(),
778
831
  signedIn: loadCreds() !== null,
832
+ discardedKey,
779
833
  }),
780
834
  };
781
835
  }
@@ -1148,15 +1202,23 @@ pendingLines = []) {
1148
1202
  const inFlight = new Map();
1149
1203
  const callTimeoutMs = resolveCallTimeoutMs();
1150
1204
  const stalledHandshakeMs = resolveStalledHandshakeMs(callTimeoutMs);
1205
+ const healthProbeMs = resolveHealthProbeMs();
1151
1206
  /** IDs of `tools/list` requests we've forwarded to the relayer. When
1152
1207
  * the response comes back through the SSE pump, we splice in the
1153
1208
  * locally-served `memwal_login` + `memwal_logout` tools so the MCP
1154
1209
  * client surfaces them in its tool palette. */
1155
1210
  const pendingListIds = new Set();
1156
- /** IDs of forwarded `memwal_health` calls, each against the relayer URL the
1157
- * call went out on. Captured at send time rather than read at reply time so
1158
- * a reconnect that swapped credentials mid-flight cannot label the answer
1159
- * with a relayer it did not come from. */
1211
+ /** Tool names the CONNECTED relayer advertised on its last `tools/list`,
1212
+ * minus the ones we serve locally. Empty until the client has listed tools
1213
+ * at least once over the *current* session. `reconnect()` clears it: a
1214
+ * stale allow would re-forward a tool the new relayer does not serve
1215
+ * (GH #928), and a stale deny would refuse a tool it does. Until the
1216
+ * next upstream list, the gate allows only the cold-start floor. */
1217
+ const upstreamToolNames = new Set();
1218
+ /** IDs of forwarded `memwal_health` calls, each against the destination the
1219
+ * call went out on — relayer URL and account. Captured at send time rather
1220
+ * than read at reply time so a reconnect that swapped credentials mid-flight
1221
+ * cannot label the answer with a destination it did not come from. */
1160
1222
  const pendingHealthIds = new Map();
1161
1223
  /** Record a 429 and tell the user ONCE that this is a rate limit rather
1162
1224
  * than a broken config — the distinction the MCP host cannot make for
@@ -1273,6 +1335,15 @@ pendingLines = []) {
1273
1335
  throttleNoticed = false;
1274
1336
  clearHandshakeFailure();
1275
1337
  endConnectEpisode();
1338
+ // This session has not advertised anything yet. Keep the
1339
+ // previous set and a login that swapped relayerUrl would
1340
+ // re-forward a tool the new one does not serve, or refuse
1341
+ // one it does, until the client happened to re-list.
1342
+ upstreamToolNames.clear();
1343
+ writeStdoutMessage({
1344
+ jsonrpc: "2.0",
1345
+ method: "notifications/tools/list_changed",
1346
+ });
1276
1347
  // An accepted handshake retires any earlier rejection —
1277
1348
  // `memwal_login` re-registers a key and lands here, not on
1278
1349
  // the background connect's publish path, so clearing only
@@ -1311,6 +1382,15 @@ pendingLines = []) {
1311
1382
  log.info("bridge.replay_halted_signed_out", { id });
1312
1383
  break;
1313
1384
  }
1385
+ // The sweeper already declared this call's reply lost
1386
+ // and is answering it as failed. Posting it again would
1387
+ // run it a second time — a duplicate write for a tool
1388
+ // with no idempotency key — and the reply would be
1389
+ // dropped anyway, as a late answer for a closed-out id.
1390
+ if (entry.orphaned) {
1391
+ log.info("bridge.replay_skipped_orphaned", { id });
1392
+ continue;
1393
+ }
1314
1394
  const msg = entry.msg;
1315
1395
  try {
1316
1396
  // A replayed `initialize` produces a fresh upstream
@@ -1641,6 +1721,16 @@ pendingLines = []) {
1641
1721
  // duplicate tool name. Mirrors LOCAL_TOOLS_LIST.
1642
1722
  const upstream = result.tools.filter((t) => !LOCAL_TOOL_NAMES.has(t.name ?? ""));
1643
1723
  result.tools = [...upstream, ...LOCAL_TOOL_DEFINITIONS];
1724
+ // Record what this relayer actually serves. A
1725
+ // later call for a name absent here is answered
1726
+ // locally instead of being forwarded into a wait
1727
+ // no reply will ever end.
1728
+ upstreamToolNames.clear();
1729
+ for (const t of upstream) {
1730
+ if (typeof t.name === "string" && t.name !== "") {
1731
+ upstreamToolNames.add(t.name);
1732
+ }
1733
+ }
1644
1734
  }
1645
1735
  }
1646
1736
  if (value &&
@@ -1652,7 +1742,7 @@ pendingLines = []) {
1652
1742
  const dialled = pendingHealthIds.get(value.id);
1653
1743
  pendingHealthIds.delete(value.id);
1654
1744
  if (dialled !== undefined) {
1655
- annotateHealthResult(value.result, dialled);
1745
+ annotateHealthResult(value.result, dialled.relayerUrl, dialled.accountId);
1656
1746
  }
1657
1747
  }
1658
1748
  // Health annotation runs BEFORE the sign-in banner. Both
@@ -1755,7 +1845,7 @@ pendingLines = []) {
1755
1845
  id: msg.id,
1756
1846
  });
1757
1847
  if (params.name === "memwal_login") {
1758
- const result = await handleLocalLogin(config, adoptCredentials);
1848
+ const result = await handleLocalLogin(config, adoptCredentials, params.arguments?.freshKey === true);
1759
1849
  writeStdoutMessage({
1760
1850
  jsonrpc: "2.0",
1761
1851
  id: msg.id,
@@ -1811,6 +1901,45 @@ pendingLines = []) {
1811
1901
  failRequest(msg, "credentials rejected", UNAUTHORIZED_FAILURE);
1812
1902
  return;
1813
1903
  }
1904
+ // Version skew: this bridge ships on npm and updates itself,
1905
+ // while a relayer ships per environment and does not, so the
1906
+ // bridge is routinely newer than the server it dials. A tool
1907
+ // named in initialize instructions or a cached list can be
1908
+ // missing from the session that actually came up —
1909
+ // `memwal_remember_status` against a prod relayer, which is
1910
+ // GH #928. Forwarding that call parks it in `inFlight` until
1911
+ // the orphan sweeper's deadline (60s + 30s headroom for that
1912
+ // tool), and the user reads the 90s as a hang.
1913
+ //
1914
+ // Fail closed. Before any upstream `tools/list`, only the
1915
+ // cold-start floor is callable. After one, only what that
1916
+ // relayer advertised (plus locally-served tools). A stale
1917
+ // tool list is not a transport fault: say so now, while the
1918
+ // agent can still act on it.
1919
+ if (msg.method === "tools/call" && msg.id != null) {
1920
+ const called = msg.params?.name;
1921
+ if (typeof called === "string" && !LOCAL_TOOL_NAMES.has(called)) {
1922
+ const known = upstreamToolNames.size > 0
1923
+ ? upstreamToolNames
1924
+ : COLD_START_TOOL_NAMES;
1925
+ if (!known.has(called)) {
1926
+ const available = [
1927
+ ...(upstreamToolNames.size > 0
1928
+ ? [...upstreamToolNames, ...LOCAL_TOOL_NAMES]
1929
+ : COLD_START_TOOL_NAMES),
1930
+ ].sort();
1931
+ log.warn("bridge.tool_not_served", {
1932
+ tool: called,
1933
+ window: upstreamToolNames.size > 0 ? "upstream" : "cold-start",
1934
+ });
1935
+ failRequest(msg, "tool not served", {
1936
+ toolText: unknownToolText(called, available),
1937
+ errorMessage: `${called} is not served by this Walrus Memory relayer`,
1938
+ });
1939
+ return;
1940
+ }
1941
+ }
1942
+ }
1814
1943
  // Fill in the configured default namespace for memory tool
1815
1944
  // calls that didn't pass one. Mutates msg in place so the
1816
1945
  // forwarded — and any replayed-on-reconnect — copy carries it.
@@ -1825,7 +1954,10 @@ pendingLines = []) {
1825
1954
  if (msg.method === "tools/call" &&
1826
1955
  msg.id != null &&
1827
1956
  msg.params?.name === "memwal_health") {
1828
- pendingHealthIds.set(msg.id, creds?.relayerUrl ?? config.relayerUrl);
1957
+ pendingHealthIds.set(msg.id, {
1958
+ relayerUrl: creds?.relayerUrl ?? config.relayerUrl,
1959
+ accountId: creds?.accountId,
1960
+ });
1829
1961
  }
1830
1962
  // Track requests (have both method and id) so we can replay
1831
1963
  // them on reconnect. Notifications and responses are not
@@ -2079,13 +2211,19 @@ pendingLines = []) {
2079
2211
  "memwal_remember_bulk",
2080
2212
  "memwal_analyze",
2081
2213
  ]);
2082
- function expiredRequestReport(neverSent, now, tool) {
2214
+ function expiredRequestReport(neverSent, now, tool, health) {
2083
2215
  if (!neverSent) {
2216
+ // What the relayer's `/health` said just now: tells a dead
2217
+ // relayer from a wrong URL from one stuck call.
2218
+ const described = health ? describeHealthProbe(health.probe, health.relayerUrl) : null;
2084
2219
  // The request reached the relayer. What is missing is the reply,
2085
2220
  // and for a write that distinction is the whole message: the work
2086
2221
  // may have completed, may still be running, and cannot be assumed
2087
2222
  // undone. "Please retry" is only safe advice for a read.
2088
2223
  if (tool !== null && MUTATING_TOOLS.has(tool)) {
2224
+ const healthNote = described
2225
+ ? `\nRelayer health: ${described.health}. ${described.verdict}`
2226
+ : "";
2089
2227
  return {
2090
2228
  reason: "no response to a sent write",
2091
2229
  opts: {
@@ -2095,20 +2233,36 @@ pendingLines = []) {
2095
2233
  "it and does not mean nothing was stored. Do NOT simply repeat the " +
2096
2234
  "call: run `memwal_recall` for this content first, and only re-save " +
2097
2235
  "what is genuinely missing. Repeating a bulk save that already " +
2098
- "landed stores a second paid copy.",
2236
+ "landed stores a second paid copy." +
2237
+ healthNote,
2099
2238
  errorMessage: `Walrus Memory ${tool} was sent but its reply never arrived. The write ` +
2100
- "may have completed; verify with recall before retrying.",
2239
+ "may have completed; verify with recall before retrying." +
2240
+ healthNote,
2101
2241
  },
2102
2242
  };
2103
2243
  }
2244
+ // Not "this call only reads": `memwal_restore` re-indexes. What
2245
+ // makes a retry safe is that none of these can store a duplicate.
2246
+ const nextStep = described === null || described.reachable
2247
+ ? "Repeating this call cannot store a duplicate, so it is safe to retry " +
2248
+ "once. If it keeps happening, report it with the time of the call."
2249
+ : "Repeating this call cannot store a duplicate, so it is safe to retry " +
2250
+ "after the relayer is reachable again — wait a minute if the problem " +
2251
+ "is on the relayer's side.";
2252
+ const healthValue = described?.health ?? "not checked";
2104
2253
  return {
2105
2254
  reason: "no response",
2106
2255
  opts: {
2107
- toolText: "❌ Walrus Memory did not answer this call. The request reached the " +
2108
- "relayer but the reply never came back. This call only reads, so it is " +
2109
- "safe to retry.",
2110
- errorMessage: "Walrus Memory call was orphaned by a reconnect and never " +
2111
- "received a response. Safe to retry: this call only reads.",
2256
+ toolText: [
2257
+ "❌ Walrus Memory did not answer this call.",
2258
+ "Cause: the request reached the relayer but no reply came back." +
2259
+ (described ? ` ${described.verdict}` : ""),
2260
+ `Relayer health: ${healthValue}`,
2261
+ `Next step: ${nextStep}`,
2262
+ ].join("\n"),
2263
+ errorMessage: "Walrus Memory call reached the relayer but never received a " +
2264
+ `response (relayer health: ${healthValue}). Safe to retry: it cannot ` +
2265
+ "store a duplicate.",
2112
2266
  },
2113
2267
  };
2114
2268
  }
@@ -2148,6 +2302,8 @@ pendingLines = []) {
2148
2302
  const orphanSweeper = setInterval(() => {
2149
2303
  const now = Date.now();
2150
2304
  const handshakeStalledMs = handshakeStalledForMs(now);
2305
+ // One `/health` request per sweep, however many calls expired in it.
2306
+ let sweepProbe = null;
2151
2307
  for (const [id, entry] of Array.from(inFlight.entries())) {
2152
2308
  const elapsedMs = now - entry.startedAt;
2153
2309
  // Never sent = no POST was ever issued for it. Read from the entry
@@ -2172,6 +2328,58 @@ pendingLines = []) {
2172
2328
  : entry.deadlineMs;
2173
2329
  if (elapsedMs <= deadlineMs)
2174
2330
  continue;
2331
+ // `initialize` is answered locally and gets no reply here, so
2332
+ // there is nothing to explain and nothing to probe for.
2333
+ if (!neverSent && entry.msg.method !== "initialize") {
2334
+ // A sent call: ask the relayer's `/health` before answering,
2335
+ // so the message can say whether it is down, unreachable, or
2336
+ // up with this one call stuck. Only the answer waits on the
2337
+ // probe; the bookkeeping stays synchronous.
2338
+ if (entry.probing)
2339
+ continue;
2340
+ entry.probing = true;
2341
+ // From here the call is the sweeper's to answer: a reconnect
2342
+ // must not replay it behind the probe's back.
2343
+ entry.orphaned = true;
2344
+ const relayerUrl = creds?.relayerUrl ?? config.relayerUrl;
2345
+ // `probeRelayerHealth` does not reject, but a call left with
2346
+ // `probing` set and no answer is the one outcome this path
2347
+ // must never have, so a rejection still answers it.
2348
+ sweepProbe ??= probeRelayerHealth(relayerUrl, healthProbeMs).catch(() => null);
2349
+ void sweepProbe
2350
+ .then((probe) => {
2351
+ // A late reply, a logout or a shutdown may have answered
2352
+ // it while the probe ran. Answering again would be a
2353
+ // second response for the same id — and after stdin has
2354
+ // closed there is nobody left to answer.
2355
+ if (stdinClosed || inFlight.get(id) !== entry)
2356
+ return;
2357
+ const settledAt = Date.now();
2358
+ const { reason, opts } = expiredRequestReport(false, settledAt, toolNameOf(entry.msg), probe ? { probe, relayerUrl } : undefined);
2359
+ log.warn("bridge.call_orphaned", {
2360
+ id,
2361
+ method: entry.msg.method ?? null,
2362
+ elapsedMs: settledAt - entry.startedAt,
2363
+ deadlineMs,
2364
+ reason,
2365
+ health: probe?.kind ?? null,
2366
+ healthMs: probe?.ms ?? null,
2367
+ handshakeStalledMs: handshakeStalledForMs(settledAt),
2368
+ lastHandshakeError,
2369
+ });
2370
+ failRequest(entry.msg, reason, opts);
2371
+ })
2372
+ .catch((err) => {
2373
+ // Never leave a call marked `probing` with no answer:
2374
+ // clearing it lets the next sweep answer it.
2375
+ entry.probing = false;
2376
+ log.warn("bridge.call_orphaned_answer_failed", {
2377
+ id,
2378
+ error: err instanceof Error ? err.message : String(err),
2379
+ });
2380
+ });
2381
+ continue;
2382
+ }
2175
2383
  // Built only for what actually expired: this walks `pendingForward`
2176
2384
  // and interpolates two user-facing strings, and the branch it
2177
2385
  // serves fires roughly never.