agent-embassy 2.0.1 → 3.0.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -228
  4. package/SECURITY.md +55 -89
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
  9. package/dist/src/gateway/claude-helper-protocol.js +12 -11
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
  12. package/dist/src/gateway/claude-helper-supervisor.js +10 -9
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +6 -6
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +6 -18
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -6
  20. package/dist/src/gateway/cli.js +912 -261
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  23. package/dist/src/gateway/codex-socket-holder.js +76 -0
  24. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  25. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  26. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -10
  28. package/dist/src/gateway/config.js +4 -10
  29. package/dist/src/gateway/config.js.map +1 -1
  30. package/dist/src/gateway/control.d.ts +45 -77
  31. package/dist/src/gateway/control.js +56 -141
  32. package/dist/src/gateway/control.js.map +1 -1
  33. package/dist/src/gateway/federation-nodes.d.ts +29 -2
  34. package/dist/src/gateway/federation-nodes.js +177 -7
  35. package/dist/src/gateway/federation-nodes.js.map +1 -1
  36. package/dist/src/gateway/peer-client.d.ts +4 -3
  37. package/dist/src/gateway/peer-client.js +22 -13
  38. package/dist/src/gateway/peer-client.js.map +1 -1
  39. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  40. package/dist/src/gateway/peer-protocol.js +5 -12
  41. package/dist/src/gateway/peer-protocol.js.map +1 -1
  42. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  43. package/dist/src/gateway/provenance-envelope.js +4 -19
  44. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  45. package/dist/src/gateway/providers.d.ts +7 -4
  46. package/dist/src/gateway/providers.js +16 -20
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +3 -12
  49. package/dist/src/gateway/server.js +31 -50
  50. package/dist/src/gateway/server.js.map +1 -1
  51. package/dist/src/gateway/service-agent.d.ts +187 -0
  52. package/dist/src/gateway/service-agent.js +758 -0
  53. package/dist/src/gateway/service-agent.js.map +1 -0
  54. package/dist/src/gateway/service.d.ts +114 -31
  55. package/dist/src/gateway/service.js +461 -566
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +167 -0
  58. package/dist/src/gateway/status-view.js +488 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +103 -21
  61. package/dist/src/gateway/store.js +454 -529
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +48 -99
  64. package/dist/src/gateway/types.js +15 -52
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +173 -44
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +277 -375
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +65 -90
  71. package/skills/embassy-peer/agents/openai.yaml +1 -1
  72. package/README.zh-CN.md +0 -275
  73. package/assets/live-dashboard/app.css +0 -1619
  74. package/assets/vendor/react/LICENSE +0 -21
  75. package/assets/vendor/react/react-dom.production.min.js +0 -267
  76. package/assets/vendor/react/react.production.min.js +0 -31
  77. package/dist/src/gateway/acp-client.d.ts +0 -110
  78. package/dist/src/gateway/acp-client.js +0 -407
  79. package/dist/src/gateway/acp-client.js.map +0 -1
  80. package/dist/src/gateway/acp-provider.d.ts +0 -66
  81. package/dist/src/gateway/acp-provider.js +0 -275
  82. package/dist/src/gateway/acp-provider.js.map +0 -1
  83. package/dist/src/gateway/cli-copy.d.ts +0 -8
  84. package/dist/src/gateway/cli-copy.en.d.ts +0 -22
  85. package/dist/src/gateway/cli-copy.en.js +0 -62
  86. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  87. package/dist/src/gateway/cli-copy.js +0 -27
  88. package/dist/src/gateway/cli-copy.js.map +0 -1
  89. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  90. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  91. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  92. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  93. package/dist/src/gateway/codex-doctor.js +0 -127
  94. package/dist/src/gateway/codex-doctor.js.map +0 -1
  95. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  96. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  97. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  98. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  99. package/dist/src/gateway/dashboard-copy.js +0 -514
  100. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  101. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  102. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  103. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  104. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  105. package/dist/src/gateway/dashboard-model.js +0 -1061
  106. package/dist/src/gateway/dashboard-model.js.map +0 -1
  107. package/dist/src/gateway/dashboard.d.ts +0 -20
  108. package/dist/src/gateway/dashboard.js +0 -874
  109. package/dist/src/gateway/dashboard.js.map +0 -1
  110. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  111. package/dist/src/gateway/deepseek-detect.js +0 -41
  112. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  113. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  114. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  115. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  116. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  117. package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
  118. package/dist/src/gateway/live-dashboard-command.js +0 -334
  119. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  121. package/dist/src/gateway/live-dashboard-http.js +0 -383
  122. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  124. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  125. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  127. package/dist/src/gateway/live-dashboard-server.js +0 -144
  128. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  130. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  131. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  133. package/dist/src/gateway/live-dashboard.js +0 -154
  134. package/dist/src/gateway/live-dashboard.js.map +0 -1
  135. package/dist/src/gateway/locale.d.ts +0 -4
  136. package/dist/src/gateway/locale.js +0 -10
  137. package/dist/src/gateway/locale.js.map +0 -1
  138. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  139. package/dist/src/gateway/progress-watch-machine.js +0 -70
  140. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  141. package/docs/CONFIGURATION.zh-CN.md +0 -97
  142. package/docs/DASHBOARD.md +0 -98
  143. package/docs/DASHBOARD.zh-CN.md +0 -49
  144. package/docs/DELIVERY.zh-CN.md +0 -55
@@ -1,19 +1,22 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { BridgeError } from "../errors.js";
3
- import { createGatewayConversationId, startGatewayControlServer, } from "./control.js";
4
- import { publishGatewayDashboard } from "./dashboard.js";
5
- import { spawnPeerClient } from "./peer-client.js";
6
- import { peerEdgeRef, peerRouteRef } from "./peer-protocol.js";
7
- import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_DEFAULT_IDLE_MS, PROGRESS_WATCH_HARD_CAPACITY, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, recordProgressWatchActivity, } from "./progress-watch-machine.js";
3
+ import { createGatewayConversationId, startGatewayControlServer, GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS, } from "./control.js";
4
+ import { PeerConnectionLostError, spawnPeerClient } from "./peer-client.js";
5
+ import { peerRouteRef } from "./peer-protocol.js";
8
6
  import { GatewayStore } from "./store.js";
9
- import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
7
+ import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, directionId, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
10
8
  const SAFE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
11
9
  const PRIVATE_HANDLE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
12
10
  const PUBLIC_ALIAS = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
13
11
  const PEER_TOKEN = /^peer_[A-Za-z0-9_-]{32}$/;
14
12
  const DISCOVERY_INTERVAL_MS = 30_000;
15
- const CODEX_DOCTOR_INTERVAL_MS = 30_000;
13
+ const MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS = 30_000;
16
14
  const PEER_REFRESH_INTERVAL_MS = 30_000;
15
+ /** A failed timed retirement of an ephemeral peer is retried this much later… */
16
+ const EPHEMERAL_EXPIRY_RETRY_MS = 5_000;
17
+ /** …this many times in a row before the registration is left to the next restart. */
18
+ const EPHEMERAL_EXPIRY_MAX_ATTEMPTS = 3;
19
+ const PEER_FAILURE_CODES = ["PEER_DIAL_FAILED", "PEER_TUNNEL_UNAVAILABLE", "PEER_PROTOCOL_MISMATCH"];
17
20
  const CLEAN_RETRY_DELAY_MS = 500;
18
21
  const MAX_CONVERSATIONS = 1_024;
19
22
  const MAX_PENDING_CLAUDE_REPLIES = MAX_CONVERSATIONS;
@@ -39,16 +42,7 @@ function aliasHost(alias) {
39
42
  function safeCode(value, fallback) {
40
43
  return value !== undefined && SAFE_CODE.test(value) ? value : fallback;
41
44
  }
42
- class ConsentOwnerError extends BridgeError {
43
- ownerHost;
44
- constructor(ownerHost) {
45
- super("CONSENT_OWNER_HOST_REQUIRED", `Run pair or unpair on the edge owner host ${ownerHost}.`);
46
- this.ownerHost = ownerHost;
47
- }
48
- }
49
45
  function decisionFor(error, peerPrincipal = false) {
50
- if (error instanceof ConsentOwnerError)
51
- return { accepted: false, code: "conflict", ownerHost: error.ownerHost };
52
46
  if (!(error instanceof BridgeError))
53
47
  return { accepted: false, code: "rejected" };
54
48
  if (error.code.includes("NOT_FOUND") || error.code.includes("NOT_AVAILABLE")) {
@@ -63,11 +57,42 @@ function decisionFor(error, peerPrincipal = false) {
63
57
  if (error.code.includes("UNAVAILABLE") || error.code.includes("OFFLINE") || error.code.includes("UNOBSERVED")) {
64
58
  return { accepted: false, code: "unavailable" };
65
59
  }
66
- if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") || error.code.includes("ADDRESS")) {
60
+ if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") ||
61
+ error.code.includes("CHANGED") || error.code.includes("ADDRESS")) {
67
62
  return { accepted: false, code: "route_mismatch" };
68
63
  }
69
64
  return { accepted: false, code: "rejected" };
70
65
  }
66
+ /**
67
+ * The refusal a send returns: the coarse decision clients branch on,
68
+ * plus the safe code behind it so the CLI can name the one remedy that fits.
69
+ * Only a BridgeError's own code is carried; anything else stays anonymous.
70
+ */
71
+ function sendRefusal(error, peerPrincipal = false) {
72
+ const decision = decisionFor(error, peerPrincipal);
73
+ const reason = error instanceof BridgeError && SAFE_CODE.test(error.code) ? error.code : undefined;
74
+ return { accepted: false, code: decision.code, ...(reason === undefined ? {} : { reason }) };
75
+ }
76
+ /**
77
+ * A grammar-valid alias for a session that cannot have the name it discovered
78
+ * under, because a live session already holds it. The suffix is derived from
79
+ * the session's own route handle, so it is stable across sends, and it is a
80
+ * hash rather than the handle itself: a native session UUID must never enter a
81
+ * public alias.
82
+ */
83
+ function disambiguatedAlias(alias, routeHandle) {
84
+ const at = alias.lastIndexOf("@");
85
+ const name = alias.slice(0, at), host = alias.slice(at + 1);
86
+ const suffix = createHash("sha256").update(routeHandle).digest("hex").slice(0, 8);
87
+ return `${name.slice(0, 32 - suffix.length - 1)}-${suffix}@${host}`;
88
+ }
89
+ /** The decoder already accepted exactly one credential, so the last arm holds. */
90
+ function replyCaller(params) {
91
+ const { fromAlias, threadId, replyAddress, peerToken } = params;
92
+ return threadId !== undefined ? { kind: "codex", alias: fromAlias, threadId }
93
+ : replyAddress !== undefined ? { kind: "claude", alias: fromAlias, replyAddress }
94
+ : { kind: "peer", alias: fromAlias, token: peerToken };
95
+ }
71
96
  function sameBinding(left, right) {
72
97
  return left.provider === right.provider && left.hostId === right.hostId &&
73
98
  left.routeHandle === right.routeHandle && left.registrationId === right.registrationId;
@@ -100,10 +125,7 @@ function bodyHash(body) {
100
125
  return createHash("sha256").update(body, "utf8").digest("hex");
101
126
  }
102
127
  function peerCatalogAuthorityChanged(prior, current) {
103
- const authority = (catalog) => JSON.stringify([
104
- catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
105
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
106
- ]);
128
+ const authority = (catalog) => JSON.stringify(catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)));
107
129
  return prior === undefined || authority(prior) !== authority(current);
108
130
  }
109
131
  function peerCatalogViewChanged(prior, current) {
@@ -111,7 +133,6 @@ function peerCatalogViewChanged(prior, current) {
111
133
  catalog.health,
112
134
  catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
113
135
  catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
114
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
115
136
  catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
116
137
  ]);
117
138
  return prior === undefined || view(prior) !== view(current);
@@ -123,11 +144,10 @@ export class GatewayService {
123
144
  config;
124
145
  store;
125
146
  adapters;
126
- publishDashboard;
127
147
  now;
128
148
  timers;
129
149
  nativePeerCwd;
130
- codexDoctor;
150
+ managedCodexSocketHeld;
131
151
  spawnPeer;
132
152
  peerClients = new Map();
133
153
  peerCatalogs = new Map();
@@ -146,18 +166,25 @@ export class GatewayService {
146
166
  peerRefreshOperations = new Set();
147
167
  pendingClaudeReplies = new Map();
148
168
  nativeReceipts = new Map();
149
- progressWatches = new Map();
150
- progressWatchEvents = [];
151
169
  dispatchRunners = new Map();
152
170
  steerRunners = new Map();
153
171
  startingTargets = new Set();
154
172
  runtimeAlerts = [];
173
+ /**
174
+ * Ephemeral peer registrations and the wall-clock instant each one dies.
175
+ * Nothing durable records them, so this map is the whole lifetime: it is
176
+ * swept on the ordinary maintenance tick, and a restart simply never sees
177
+ * the routes at all.
178
+ */
179
+ ephemeralPeerExpiries = new Map();
180
+ /** Consecutive failed retirements per ephemeral alias; cleared on success or removal. */
181
+ ephemeralExpiryFailures = new Map();
155
182
  control;
156
183
  wakeTimer;
157
184
  nextDiscoveryAt = 0;
158
- nextDoctorAt = 0;
185
+ nextManagedCodexCheckAt = 0;
159
186
  nextPeerRefreshAt = 0;
160
- codexDoctorResult;
187
+ managedCodexSocketHeldOutside = false;
161
188
  revision = 0;
162
189
  snapshotRevision = 0;
163
190
  snapshotFingerprint;
@@ -168,10 +195,9 @@ export class GatewayService {
168
195
  this.config = options.config;
169
196
  this.adapters = [...(options.adapters ?? [])];
170
197
  this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
171
- this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
172
198
  this.now = options.now ?? (() => new Date());
173
199
  this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
174
- this.codexDoctor = options.codexDoctor;
200
+ this.managedCodexSocketHeld = options.managedCodexSocketHeld;
175
201
  this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
176
202
  this.timers = options.timers ?? {
177
203
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -216,7 +242,7 @@ export class GatewayService {
216
242
  }
217
243
  }
218
244
  await this.refreshClaudeDiscovery().catch(() => undefined);
219
- await this.refreshCodexDoctor().catch(() => undefined);
245
+ await this.refreshManagedCodexSocket().catch(() => undefined);
220
246
  const control = await startGatewayControlServer({
221
247
  stateDir: this.store.rootDir,
222
248
  socketPath: this.config.controlSocketPath,
@@ -235,11 +261,10 @@ export class GatewayService {
235
261
  await this.observeLoadedRoutes();
236
262
  const now = this.now().getTime();
237
263
  this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
238
- this.nextDoctorAt = now + CODEX_DOCTOR_INTERVAL_MS;
264
+ this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
239
265
  this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
240
266
  for (const target of await this.store.inspectDispatchableTargets())
241
267
  this.kick(target);
242
- await this.publish();
243
268
  this.scheduleWake();
244
269
  assertActive();
245
270
  }
@@ -312,7 +337,6 @@ export class GatewayService {
312
337
  this.assertWritable();
313
338
  await operation();
314
339
  this.revision += 1;
315
- await this.publish();
316
340
  return { accepted: true, code: "ok" };
317
341
  }
318
342
  catch (error) {
@@ -327,7 +351,6 @@ export class GatewayService {
327
351
  this.assertWritable();
328
352
  const token = await this.registerPeer(params);
329
353
  this.revision += 1;
330
- await this.publish();
331
354
  return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
332
355
  }
333
356
  catch (error) {
@@ -338,24 +361,24 @@ export class GatewayService {
338
361
  awaitPeer: (params) => this.awaitPeer(params),
339
362
  peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
340
363
  unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
341
- removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
342
- selectClaude: (params) => decide(async () => this.selectClaude(params)),
343
- unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
344
- pair: (params) => decide(async () => this.pair(params)),
345
- unpair: (params) => decide(async () => this.unpair(params)),
346
364
  listSnapshot: () => this.snapshot(),
347
365
  observeSnapshot: () => this.observeSnapshot(),
348
366
  deliveryStatus: (params) => this.deliveryStatus(params.token),
349
- untrack: (params) => decide(async () => this.untrack(params.conversationId)),
350
367
  send: (params) => this.send(params),
351
- reply: (params) => this.reply(params),
352
- refreshDashboard: async () => {
368
+ refreshDiscovery: async () => {
353
369
  if (this.closing) {
354
370
  return { accepted: false, code: "unavailable", revision: this.revision };
355
371
  }
356
- await this.refreshClaudeDiscovery().catch(() => undefined);
357
- await this.recordActivity("discovery", "discovery_refreshed", [], true);
358
- await this.publish();
372
+ try {
373
+ // An operator-triggered rescan reports its own failure. It must never
374
+ // journal `discovery_refreshed` for a scan that did not complete, and
375
+ // must never answer `accepted: true` for a provider that is down.
376
+ await this.refreshClaudeDiscovery();
377
+ await this.recordActivity("discovery", "discovery_refreshed", [], true);
378
+ }
379
+ catch (error) {
380
+ return { accepted: false, code: decisionFor(error).code, revision: this.revision };
381
+ }
359
382
  return { accepted: true, code: "ok", revision: this.revision };
360
383
  },
361
384
  peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
@@ -366,12 +389,15 @@ export class GatewayService {
366
389
  const localHost = this.config.hostId;
367
390
  if (!this.config.peerNodes.includes(peerHost))
368
391
  throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
369
- const [snapshot, privateRoutes, privateEdges] = await Promise.all([
370
- this.snapshot(), this.store.inspectPrivateRoutes(), this.store.inspectPrivateConsentEdges(),
392
+ const [snapshot, privateRoutes] = await Promise.all([
393
+ this.snapshot(), this.store.inspectPrivateRoutes(),
371
394
  ]);
372
- const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost);
395
+ // An ephemeral registration is never published: a peer node that mirrored
396
+ // one would hold a route this broker will delete on a timer and can never
397
+ // restore, and a self-test must not leave a trace on another machine.
398
+ const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" &&
399
+ route.binding.hostId === localHost && !this.store.isEphemeralRegistration(route.binding.registrationId));
373
400
  const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
374
- const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
375
401
  const routes = localRoutes.map((route) => {
376
402
  const row = publicRoutes.get(route.alias);
377
403
  return {
@@ -381,27 +407,13 @@ export class GatewayService {
381
407
  ...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
382
408
  };
383
409
  });
384
- const consentEdges = privateEdges.flatMap((edge) => {
385
- if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
386
- return [];
387
- const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
388
- if (rows[0] === undefined || rows[1] === undefined ||
389
- !rows.some((route) => route?.binding.hostId === peerHost) ||
390
- !rows.some((route) => route?.binding.hostId === localHost))
391
- return [];
392
- const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
393
- host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
394
- ? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
395
- const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
396
- return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
397
- });
398
410
  return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
399
411
  health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
400
412
  provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
401
413
  ...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
402
414
  ...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
403
415
  ...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
404
- })), routes, consentEdges, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
416
+ })), routes, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
405
417
  }
406
418
  async receivePeerHandoff(peerHost, handoff) {
407
419
  if (!this.config.peerNodes.includes(peerHost))
@@ -415,12 +427,11 @@ export class GatewayService {
415
427
  const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
416
428
  this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
417
429
  sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
418
- pair: true, nextSequence: 1 });
430
+ nextSequence: 1 });
419
431
  this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
420
432
  this.kick(target.alias, target.binding.registrationId);
421
433
  }
422
434
  this.revision += 1;
423
- await this.publish();
424
435
  return { accepted: true };
425
436
  }
426
437
  async snapshot() {
@@ -454,25 +465,17 @@ export class GatewayService {
454
465
  const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
455
466
  const stale = runtime.health === "healthy" &&
456
467
  (age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
457
- const doctor = runtime.adapter.identity.provider === "codex" ? this.codexDoctorResult : undefined;
458
- const doctorConditions = doctor === undefined
459
- ? [] : [...new Set(doctor.conditions)];
460
- if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
461
- doctorConditions.push("observation_stale");
462
- const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
468
+ const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
463
469
  const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
464
- (managedLayoutMissing ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
470
+ (socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
465
471
  return {
466
472
  provider: runtime.adapter.identity.provider,
467
473
  host: runtime.adapter.identity.hostId,
468
- health: stale || managedLayoutMissing ? "degraded" : runtime.health,
474
+ health: stale || socketHeld ? "degraded" : runtime.health,
469
475
  protocol: runtime.adapter.protocol,
470
476
  protocolVersion: runtime.adapter.protocolVersion,
471
477
  ...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
472
478
  ...(age === undefined ? {} : { observationAgeMs: age }),
473
- ...(doctorConditions.length === 0 ? {} : {
474
- codexDoctor: { conditions: doctorConditions.slice(0, 2) },
475
- }),
476
479
  ...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
477
480
  ...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
478
481
  };
@@ -486,20 +489,9 @@ export class GatewayService {
486
489
  host: candidate.adapter.identity.hostId,
487
490
  state: candidate.state,
488
491
  validated: true,
489
- selected: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
492
+ routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
490
493
  lastSeenAt: candidate.observedAt,
491
494
  }));
492
- const progressWatches = [...this.progressWatches.values()]
493
- .slice(0, gatewayPublicSnapshotLimits.progressWatches)
494
- .map((watch) => ({
495
- conversationIdSuffix: watch.conversationId.slice(-8),
496
- ownerAlias: watch.ownerAlias,
497
- workerAlias: watch.workerAlias,
498
- lastActivityAt: watch.lastActivityAt,
499
- nextActionAt: watch.nextActionAt,
500
- nudgeCount: watch.nudgeCount,
501
- }));
502
- const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
503
495
  return projectGatewayPublicSnapshot({
504
496
  ...base,
505
497
  generatedAt: now.toISOString(),
@@ -511,14 +503,8 @@ export class GatewayService {
511
503
  connectors,
512
504
  availablePeers,
513
505
  routes,
514
- progressWatches,
515
- progressWatchEvents,
516
506
  alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
517
- truncation: {
518
- ...base.truncation,
519
- progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
520
- progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
521
- },
507
+ truncation: base.truncation,
522
508
  }, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
523
509
  }
524
510
  async observeSnapshot() {
@@ -617,7 +603,7 @@ export class GatewayService {
617
603
  async reconcileUnadvertisement(route) {
618
604
  await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
619
605
  }
620
- async removeOwnedRoute(route, notFoundCode, releaseProviderRoute) {
606
+ async removeOwnedRoute(route) {
621
607
  this.assertWritable();
622
608
  const result = await this.store.removeOwnedRouteAtomic({
623
609
  alias: route.alias,
@@ -625,16 +611,10 @@ export class GatewayService {
625
611
  activity: { operatorAction: true },
626
612
  });
627
613
  if (!result.removed)
628
- throw new BridgeError(notFoundCode, "The exact route is absent.");
614
+ throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
629
615
  await this.finishSettlements(result.settlements);
630
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
631
616
  this.forgetRoute(route);
632
- if (releaseProviderRoute) {
633
- await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
634
- }
635
- else {
636
- await this.reconcileUnadvertisement(route);
637
- }
617
+ await this.reconcileUnadvertisement(route);
638
618
  }
639
619
  async recordActivity(kind, action, aliases, operatorAction) {
640
620
  await this.store.recordActivity({
@@ -695,7 +675,6 @@ export class GatewayService {
695
675
  activity: { operatorAction: true },
696
676
  });
697
677
  await this.finishSettlements(result.settlements);
698
- this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
699
678
  if (!result.idempotent) {
700
679
  this.forgetRoute(previous);
701
680
  this.reconcileUnadvertisement(previous);
@@ -726,8 +705,13 @@ export class GatewayService {
726
705
  await this.store.registerRoute({ alias: params.alias, binding: {
727
706
  provider: "peer", hostId: this.config.hostId,
728
707
  routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
729
- }, registrationMode: "explicit_opt_in" });
708
+ }, registrationMode: "explicit_opt_in",
709
+ ...(params.ephemeral === true ? { ephemeral: true } : {}) });
730
710
  const installed = (await this.store.inspectPrivateRoute(params.alias));
711
+ if (params.ephemeral === true) {
712
+ this.ephemeralPeerExpiries.set(installed.alias, this.now().getTime() + (params.ttlMs ?? GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS));
713
+ this.scheduleWake();
714
+ }
731
715
  this.observeRoute(installed);
732
716
  this.reconcileAdvertisement(installed);
733
717
  return token;
@@ -748,19 +732,86 @@ export class GatewayService {
748
732
  if (this.peerCleanupAliases.has(route.alias))
749
733
  throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
750
734
  this.peerCleanupAliases.add(route.alias);
735
+ // The expiry leaves the map before the removal, under the same cleanup
736
+ // guard, so a clock that fires mid-removal has nothing to act on; a
737
+ // removal that does not happen puts it back, so a refused unregister
738
+ // still leaves the registration to its own expiry.
739
+ const expiry = this.ephemeralPeerExpiries.get(route.alias);
740
+ this.ephemeralPeerExpiries.delete(route.alias);
741
+ this.ephemeralExpiryFailures.delete(route.alias);
742
+ let removed = false;
751
743
  try {
752
744
  const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
753
745
  if (!result.removed)
754
746
  throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
747
+ removed = true;
755
748
  await this.finishSettlements(result.settlements);
756
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
757
749
  this.forgetRoute(route);
758
750
  await this.reconcileUnadvertisement(route);
759
751
  }
760
752
  finally {
753
+ if (!removed && expiry !== undefined)
754
+ this.ephemeralPeerExpiries.set(route.alias, expiry);
761
755
  this.peerCleanupAliases.delete(route.alias);
762
756
  }
763
757
  }
758
+ /**
759
+ * Retires every ephemeral peer registration whose lifetime has run out. It
760
+ * is the same removal an explicit `unregister-peer` performs — queued work
761
+ * is settled, not abandoned — minus the token check, because the authority
762
+ * here is the clock the broker set itself.
763
+ *
764
+ * The clock is keyed by alias, and an alias can be re-registered durably
765
+ * after its ephemeral holder left, so nothing is retired unless the route
766
+ * behind the alias is still an ephemeral one. A retirement that throws
767
+ * keeps its entry and is retried `EPHEMERAL_EXPIRY_RETRY_MS` later, with an
768
+ * alert each time; after `EPHEMERAL_EXPIRY_MAX_ATTEMPTS` consecutive
769
+ * failures the entry is dropped and the registration is left to the next
770
+ * restart, which clears it by construction.
771
+ */
772
+ async expireEphemeralPeers(now) {
773
+ for (const [alias, expiresAt] of [...this.ephemeralPeerExpiries]) {
774
+ if (expiresAt > now)
775
+ continue;
776
+ // Another owner is mid-removal; the entry is theirs to clear or restore.
777
+ if (this.peerCleanupAliases.has(alias))
778
+ continue;
779
+ const route = await this.store.inspectPrivateRoute(alias);
780
+ if (route === undefined || !this.store.isEphemeralRegistration(route.binding.registrationId)) {
781
+ this.ephemeralPeerExpiries.delete(alias);
782
+ this.ephemeralExpiryFailures.delete(alias);
783
+ continue;
784
+ }
785
+ this.peerCleanupAliases.add(alias);
786
+ try {
787
+ const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding });
788
+ if (result.removed) {
789
+ await this.finishSettlements(result.settlements);
790
+ this.forgetRoute(route);
791
+ await this.reconcileUnadvertisement(route);
792
+ }
793
+ // Not removed means the binding changed underneath: the alias belongs
794
+ // to someone else now, and this clock is spent either way.
795
+ this.ephemeralPeerExpiries.delete(alias);
796
+ this.ephemeralExpiryFailures.delete(alias);
797
+ }
798
+ catch (error) {
799
+ this.alert("EPHEMERAL_ROUTE_EXPIRY_FAILED", route, error);
800
+ const attempts = (this.ephemeralExpiryFailures.get(alias) ?? 0) + 1;
801
+ if (attempts >= EPHEMERAL_EXPIRY_MAX_ATTEMPTS) {
802
+ this.ephemeralPeerExpiries.delete(alias);
803
+ this.ephemeralExpiryFailures.delete(alias);
804
+ }
805
+ else {
806
+ this.ephemeralExpiryFailures.set(alias, attempts);
807
+ this.ephemeralPeerExpiries.set(alias, now + EPHEMERAL_EXPIRY_RETRY_MS);
808
+ }
809
+ }
810
+ finally {
811
+ this.peerCleanupAliases.delete(alias);
812
+ }
813
+ }
814
+ }
764
815
  async awaitPeer(params) {
765
816
  this.assertWritable();
766
817
  const route = await this.assertPeer(params);
@@ -785,33 +836,56 @@ export class GatewayService {
785
836
  route.binding.routeHandle !== params.threadId) {
786
837
  throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
787
838
  }
788
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
789
- }
790
- async removeCodexRegistration(alias) {
791
- const route = await this.store.inspectPrivateRoute(alias);
792
- if (route === undefined || route.binding.provider !== "codex" || route.registrationMode === "federated_peer") {
793
- throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The Codex registration is absent.");
794
- }
795
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
796
- }
797
- async selectClaude(params) {
798
- const selected = await this.resolveClaudeSelector(params.alias, true);
799
- if (selected === undefined) {
800
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude session is not currently selectable.");
801
- }
802
- const adapter = selected.adapter;
839
+ if (route.registrationMode === "federated_peer") {
840
+ throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node and cannot be unregistered here.");
841
+ }
842
+ await this.removeOwnedRoute(route);
843
+ }
844
+ /**
845
+ * The name this session may take. A name belongs to one route at a time, so
846
+ * when another Claude route already holds it and THAT route's session is
847
+ * still live, this session takes a disambiguated name rather than displacing
848
+ * a working session and cancelling its queued work. Twins that share a
849
+ * display name therefore both keep routes and both stay reachable by UUID;
850
+ * only a route whose session is actually gone is displaced.
851
+ */
852
+ claimableAlias(candidate, routes) {
853
+ const holder = routes.find((route) => route.alias === candidate.alias &&
854
+ route.binding.provider === "claude" &&
855
+ !(route.binding.hostId === candidate.adapter.identity.hostId &&
856
+ route.binding.routeHandle === candidate.routeHandle));
857
+ if (holder === undefined)
858
+ return candidate.alias;
859
+ const holderIsLive = [...this.candidates.values()].some((row) => row.adapter.identity.hostId === holder.binding.hostId &&
860
+ row.routeHandle === holder.binding.routeHandle);
861
+ return holderIsLive
862
+ ? disambiguatedAlias(candidate.alias, candidate.routeHandle)
863
+ : candidate.alias;
864
+ }
865
+ /**
866
+ * Installs (or brings up to date) the route of one discovered Claude
867
+ * session. The adapter pins the session's route handle, the workspace
868
+ * check keeps deliberately broad workspaces out, and the store reuses the
869
+ * registration of a session already bound under the same (host, UUID) so a
870
+ * re-anchored or renamed session keeps its identity and in-flight
871
+ * conversations. Any Claude route the install displaced is forgotten and
872
+ * released at the adapter; its work was settled `ENDPOINT_RETIRED` by the
873
+ * store — and only a route whose own session is gone is ever displaced.
874
+ */
875
+ async installClaudeRoute(candidate) {
876
+ const adapter = candidate.adapter;
803
877
  if (adapter.selectRoute === undefined) {
804
- throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot select routes.");
878
+ throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot install routes.");
805
879
  }
806
- await adapter.assertWorkspaceDisjoint?.(selected.routeHandle, this.store.rootDir);
807
- const chosen = await adapter.selectRoute({ alias: selected.alias, routeHandle: selected.routeHandle });
880
+ await adapter.assertWorkspaceDisjoint?.(candidate.routeHandle, this.store.rootDir);
881
+ const chosen = await adapter.selectRoute({ alias: candidate.alias, routeHandle: candidate.routeHandle });
808
882
  this.assertWritable();
809
883
  const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
810
- const existing = before.find((route) => route.binding.provider === "claude" &&
811
- route.binding.hostId === adapter.identity.hostId &&
884
+ const alias = this.claimableAlias(candidate, before);
885
+ const existing = before.find((route) => route.binding.hostId === adapter.identity.hostId &&
812
886
  route.binding.routeHandle === chosen.routeHandle);
813
- const result = await this.store.replaceClaudeSelection({
814
- alias: selected.alias,
887
+ const input = {
888
+ alias,
815
889
  binding: {
816
890
  provider: "claude",
817
891
  hostId: adapter.identity.hostId,
@@ -819,114 +893,165 @@ export class GatewayService {
819
893
  registrationId: existing?.binding.registrationId ?? registrationId(),
820
894
  },
821
895
  registrationMode: "selected_live_peer",
822
- });
896
+ };
897
+ const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
823
898
  await this.finishSettlements(result.settlements);
824
899
  const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
825
900
  for (const prior of before) {
826
- const retained = after.find((route) => route.alias === prior.alias);
827
- if (retained === undefined || !sameBinding(retained.binding, prior.binding)) {
828
- this.settleWatchesForAlias(prior.alias, "endpoint_retired", "operator");
901
+ const retained = after.find((route) => route.binding.registrationId === prior.binding.registrationId);
902
+ if (retained === undefined) {
903
+ // Displaced: the alias now names another session.
829
904
  this.forgetRoute(prior);
830
905
  void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
831
906
  .catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
832
907
  }
908
+ else if (retained.alias !== prior.alias) {
909
+ // Renamed: identity kept, the stale display-only observation goes and
910
+ // open conversations follow the session to its new name.
911
+ this.routeObservations.delete(prior.alias);
912
+ this.renameConversationCoordinates(prior.alias, retained.alias, prior.binding.registrationId);
913
+ }
833
914
  }
834
- const installed = (await this.store.inspectPrivateRoute(selected.alias));
915
+ const installed = (await this.store.inspectPrivateRoute(alias));
835
916
  this.observeRoute(installed);
836
- this.routeObservations.set(selected.alias, {
917
+ this.routeObservations.set(alias, {
837
918
  route: installed.binding,
838
919
  state: chosen.state,
839
920
  observedAt: this.now().toISOString(),
840
921
  });
841
- await this.recordActivity("selection", "claude_selected", [selected.alias], true);
842
- this.kick(selected.alias);
843
- }
844
- async unselectClaude(params) {
845
- const route = await this.resolveSelectedClaudeRoute(params.alias);
846
- if (route === undefined || route.registrationMode === "federated_peer")
847
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
848
- await this.removeOwnedRoute(route, "CLAUDE_ROUTE_NOT_FOUND", true);
849
- }
850
- async pair(params) {
851
- const endpoints = await this.resolvePairEndpoints(params, true);
852
- const aliases = endpoints.aliases;
853
- const ownerHost = aliases.map(aliasHost).sort()[0];
854
- if (this.config.hostId !== ownerHost)
855
- throw new ConsentOwnerError(ownerHost);
856
- this.assertWritable();
857
- const existed = await this.store.hasConsentEdge(endpoints);
858
- await this.store.addConsentEdge(endpoints);
859
- if (!existed)
860
- await this.recordActivity("pairing", "routes_paired", [...aliases], true);
861
- }
862
- async unpair(params) {
863
- const endpoints = await this.resolvePairEndpoints(params, false);
864
- const aliases = endpoints.aliases;
865
- const ownerHost = aliases.map(aliasHost).sort()[0];
866
- if (this.config.hostId !== ownerHost)
867
- throw new ConsentOwnerError(ownerHost);
868
- this.assertWritable();
869
- const existed = await this.store.hasConsentEdge(endpoints);
870
- const result = await this.store.removeConsentEdge(endpoints);
871
- await this.finishSettlements(result.settlements);
872
- for (const alias of aliases)
873
- this.settleWatchesForAlias(alias, "pair_removed", "operator");
874
- if (existed)
875
- await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
876
- for (const alias of aliases)
877
- this.kick(alias);
878
- }
879
- async resolvePairEndpoints(params, maySelect) {
880
- const raw = params.aliases;
881
- const aliases = [raw[0], raw[1]];
882
- if (maySelect && aliases.some((alias) => PUBLIC_ALIAS.test(alias)))
883
- await this.refreshClaudeDiscovery().catch(() => undefined);
884
- for (let index = 0; index < aliases.length; index += 1) {
885
- if (maySelect && this.collidingClaudeAliases.has(aliases[index]))
886
- throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
887
- if (PUBLIC_ALIAS.test(aliases[index]))
888
- continue;
889
- const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
890
- if (selected === undefined)
891
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
892
- if (maySelect)
893
- await this.selectClaude({ alias: aliases[index] });
894
- aliases[index] = selected.alias;
895
- }
896
- const left = await this.store.inspectPrivateRoute(aliases[0]);
897
- const right = await this.store.inspectPrivateRoute(aliases[1]);
898
- if (left === undefined || right === undefined) {
899
- throw new BridgeError("ROUTE_NOT_AVAILABLE", "The pair endpoint is absent.");
922
+ if (result.installed)
923
+ this.revision += 1;
924
+ this.kick(alias);
925
+ return installed;
926
+ }
927
+ /**
928
+ * Two first sends to the same never-installed session race: both read no
929
+ * route and both mint a registration, and the loser's write is refused
930
+ * because the identity is now bound. That is a race, not a conflict — the
931
+ * session is exactly the one the loser resolved — so re-read once and adopt
932
+ * the registration that won instead of failing an honest send.
933
+ */
934
+ async installWithConcurrentFirstSend(input, hostId, routeHandle) {
935
+ try {
936
+ return await this.store.installClaudeRoute(input);
900
937
  }
901
- return {
902
- aliases,
903
- expectedRegistrationIds: [
904
- left.binding.registrationId,
905
- right.binding.registrationId,
906
- ],
907
- };
938
+ catch (error) {
939
+ if (!(error instanceof BridgeError) || error.code !== "ROUTE_IDENTITY_ALREADY_REGISTERED")
940
+ throw error;
941
+ const bound = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
942
+ route.binding.hostId === hostId &&
943
+ route.binding.routeHandle === routeHandle);
944
+ if (bound === undefined)
945
+ throw error;
946
+ return await this.store.installClaudeRoute({
947
+ ...input,
948
+ binding: { ...input.binding, registrationId: bound.binding.registrationId },
949
+ });
950
+ }
951
+ }
952
+ /**
953
+ * Looks a Claude selector up without writing anything. Callers that may
954
+ * still refuse — a `--from` alias that must match the sending session, a
955
+ * native sender whose adapter-reported name must match discovery — resolve
956
+ * here first, refuse on the read, and only then materialize.
957
+ *
958
+ * THE ALIAS-COLLISION FENCE lives here (emb-94, moved into the send path by
959
+ * emb-104). Invariant: discovery is refreshed inside every send that
960
+ * addresses a Claude session, never only on the timer; a NAME currently
961
+ * shared by more than one live session is refused with a hard,
962
+ * non-retryable `PEER_ALIAS_COLLISION` naming that alias, so the broker
963
+ * never picks first and never installs a route under an ambiguous name;
964
+ * collisions stay sticky while a scan is incomplete
965
+ * (`collidingClaudeAliases` retention in refreshClaudeDiscovery); and
966
+ * `availablePeers` keeps filtering colliders so `status` never lists an
967
+ * unaddressable name.
968
+ *
969
+ * The fence is a fence on NAMES, not on sessions. A session UUID is
970
+ * unambiguous by construction, so a UUID selector — an explicit
971
+ * `send --to <uuid>`, an identity-pinned route addressed by its own handle,
972
+ * or a sender resolved from its inherited socket — passes the fence even
973
+ * while its display name collides. That is the emb-94 escape hatch: the
974
+ * operator can always reach an ambiguously named session, and a collision on
975
+ * a *sender's* name never silences that sender, whose identity was attested
976
+ * rather than typed. In-flight conversations are likewise unaffected:
977
+ * `reply` resolves by exact binding.
978
+ */
979
+ async lookUpClaudeRoute(selector, hostId) {
980
+ await this.refreshClaudeDiscovery().catch(() => undefined);
981
+ if (PUBLIC_ALIAS.test(selector) && this.collidingClaudeAliases.has(selector)) {
982
+ throw new BridgeError("PEER_ALIAS_COLLISION", `The Claude alias ${selector} names more than one live session; rename one, or address the session by UUID.`);
983
+ }
984
+ const routed = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
985
+ (hostId === undefined || route.binding.hostId === hostId) &&
986
+ (route.alias === selector || route.binding.routeHandle === selector));
987
+ const candidate = [...this.candidates.values()].find((row) => (hostId === undefined || row.adapter.identity.hostId === hostId) &&
988
+ (row.routeHandle === selector || row.alias === selector));
989
+ return { routed, candidate };
990
+ }
991
+ /**
992
+ * The name discovery currently shows for a looked-up session, which a caller
993
+ * compares against the alias it claimed before anything is written.
994
+ */
995
+ claimedAlias(resolution) {
996
+ const alias = resolution.candidate?.alias ?? resolution.routed?.alias;
997
+ if (alias === undefined) {
998
+ throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
999
+ }
1000
+ return alias;
1001
+ }
1002
+ /** Installs the looked-up session's route if it is not already current. */
1003
+ async materializeClaudeRoute(resolution) {
1004
+ const { routed, candidate } = resolution;
1005
+ if (candidate === undefined) {
1006
+ if (routed !== undefined)
1007
+ return routed;
1008
+ throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
1009
+ }
1010
+ if (routed !== undefined &&
1011
+ routed.binding.hostId === candidate.adapter.identity.hostId &&
1012
+ routed.binding.routeHandle === candidate.routeHandle &&
1013
+ routed.alias === this.claimableAlias(candidate, await this.store.listLogicalRoutes()))
1014
+ return routed;
1015
+ return await this.installClaudeRoute(candidate);
1016
+ }
1017
+ async resolveClaudeRoute(selector, hostId) {
1018
+ return await this.materializeClaudeRoute(await this.lookUpClaudeRoute(selector, hostId));
908
1019
  }
909
1020
  async assertThread(alias, threadId) {
910
1021
  const route = await this.store.inspectPrivateRoute(alias);
911
- if (route?.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
1022
+ // No registration at all is its own condition — the state every Codex
1023
+ // task is in after a private state reset — and is never dressed up as a
1024
+ // thread mismatch, which would send the operator hunting a wrong id.
1025
+ if (route === undefined)
1026
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route is not registered.");
1027
+ if (route.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
912
1028
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
913
1029
  }
914
1030
  return route;
915
1031
  }
1032
+ /**
1033
+ * Direction follows the inherited principal, not the route table: a Claude
1034
+ * session or its target may have no route yet, because routes install on
1035
+ * first send. A Codex task sends to Claude sessions and shell peers; a
1036
+ * Claude session sends to Codex tasks and shell peers (Claude-to-Claude is
1037
+ * native); a shell peer sends to either.
1038
+ */
916
1039
  async send(params) {
917
- const source = await this.store.inspectPrivateRoute(params.fromAlias);
918
- const directTarget = await this.store.inspectPrivateRoute(params.toAlias);
919
- const target = directTarget ?? await this.resolveSelectedClaudeRoute(params.toAlias);
920
- if (source?.binding.provider === "codex" && (target?.binding.provider === "claude" || target?.binding.provider === "peer") && "threadId" in params) {
921
- return this.sendToClaude(params);
922
- }
923
- if (source?.binding.provider === "claude" && (target?.binding.provider === "codex" || target?.binding.provider === "peer") && "replyAddress" in params) {
924
- return this.sendToCodex(params);
925
- }
926
- if (source?.binding.provider === "peer" && "peerToken" in params) {
927
- return target?.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
928
- }
929
- return { accepted: false, code: "route_mismatch" };
1040
+ if (params.conversationId !== undefined)
1041
+ return await this.replyToConversation(params);
1042
+ const target = await this.store.inspectPrivateRoute(params.toAlias);
1043
+ if ("replyAddress" in params && params.replyAddress !== undefined) {
1044
+ // A UUID names a Claude session as surely as a name does: recognize it
1045
+ // here, or a Claude-to-Claude send addressed by UUID falls through to the
1046
+ // Codex path and is answered as an unknown target.
1047
+ const claudeTarget = target?.binding.provider === "claude" ||
1048
+ (target === undefined && [...this.candidates.values()].some((row) => row.alias === params.toAlias || row.routeHandle === params.toAlias));
1049
+ return claudeTarget ? { accepted: false, code: "route_mismatch" } : this.sendToCodex(params);
1050
+ }
1051
+ if ("threadId" in params && params.threadId !== undefined) {
1052
+ return target?.binding.provider === "codex" ? { accepted: false, code: "route_mismatch" } : this.sendToClaude(params);
1053
+ }
1054
+ return target === undefined || target.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
930
1055
  }
931
1056
  async sendToClaude(params) {
932
1057
  try {
@@ -939,9 +1064,25 @@ export class GatewayService {
939
1064
  if (source === undefined)
940
1065
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
941
1066
  const direct = await this.store.inspectPrivateRoute(params.toAlias);
942
- const target = direct?.binding.provider === "peer" ? direct : await this.resolveSelectedClaudeRoute(params.toAlias);
943
- if (target === undefined)
944
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
1067
+ let target;
1068
+ if (direct?.binding.provider === "peer") {
1069
+ target = direct;
1070
+ }
1071
+ else {
1072
+ const seen = await this.lookUpClaudeRoute(params.toAlias);
1073
+ // Admission before materialization: a send that is already going to be
1074
+ // refused for its own reasons — too large, outside the deadline
1075
+ // window, rate limited, or into a full queue — must not install a
1076
+ // route or displace another session on its way to that refusal.
1077
+ await this.store.assertEnqueueAdmissible({
1078
+ sourceAlias: params.fromAlias,
1079
+ targetAlias: this.claimedAlias(seen),
1080
+ direction: directionId(source.binding.provider, "claude"),
1081
+ sourceRegistrationId: source.binding.registrationId,
1082
+ body: params.text,
1083
+ });
1084
+ target = await this.materializeClaudeRoute(seen);
1085
+ }
945
1086
  return await this.enqueueConversation({
946
1087
  sourceAlias: params.fromAlias,
947
1088
  targetAlias: target.alias,
@@ -949,13 +1090,10 @@ export class GatewayService {
949
1090
  expectsReply: params.expectsReply,
950
1091
  expectedSourceBinding: source.binding,
951
1092
  expectedTargetBinding: target.binding,
952
- ...(params.trackIdleMinutes === undefined
953
- ? {}
954
- : { trackIdleMinutes: params.trackIdleMinutes }),
955
1093
  });
956
1094
  }
957
1095
  catch (error) {
958
- return decisionFor(error, "peerToken" in params);
1096
+ return sendRefusal(error, "peerToken" in params);
959
1097
  }
960
1098
  }
961
1099
  async sendToCodex(params) {
@@ -969,13 +1107,18 @@ export class GatewayService {
969
1107
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
970
1108
  }
971
1109
  else {
1110
+ // The sending session's identity comes from its inherited socket, never
1111
+ // from --from. Look the session up read-only first: a send that is
1112
+ // about to be refused must not install, rename, or displace a route,
1113
+ // and must not settle another session's queued work.
972
1114
  const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
973
1115
  if (resolved === undefined)
974
1116
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
975
- source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
976
- route.alias === params.fromAlias && route.binding.routeHandle === resolved.routeHandle);
977
- if (source === undefined)
978
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The reply route is not selected.");
1117
+ const seen = await this.lookUpClaudeRoute(resolved.routeHandle, aliasHost(params.fromAlias));
1118
+ if (this.claimedAlias(seen) !== params.fromAlias) {
1119
+ throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The --from alias does not name the sending Claude session.");
1120
+ }
1121
+ source = await this.materializeClaudeRoute(seen);
979
1122
  }
980
1123
  const target = await this.store.inspectPrivateRoute(params.toAlias);
981
1124
  if (source === undefined || target === undefined) {
@@ -991,23 +1134,26 @@ export class GatewayService {
991
1134
  expectsReply: params.expectsReply,
992
1135
  expectedSourceBinding: source.binding,
993
1136
  expectedTargetBinding: target.binding,
994
- ...(params.trackIdleMinutes === undefined
995
- ? {}
996
- : { trackIdleMinutes: params.trackIdleMinutes }),
997
1137
  });
998
1138
  }
999
1139
  catch (error) {
1000
- return decisionFor(error, "peerToken" in params);
1140
+ return sendRefusal(error, "peerToken" in params);
1001
1141
  }
1002
1142
  }
1003
- async reply(params) {
1143
+ /**
1144
+ * A send addressed by conversation token: the caller must own one end, the
1145
+ * other end is the binding the conversation recorded, and the caller's
1146
+ * principal is the same attested one every send carries.
1147
+ */
1148
+ async replyToConversation(params) {
1004
1149
  try {
1005
1150
  this.assertWritable();
1006
1151
  const conversation = this.conversations.get(params.conversationId);
1007
1152
  if (conversation === undefined)
1008
1153
  throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
1009
- const callerBinding = await this.assertReplyCaller(conversation, params.caller);
1010
- const callerAlias = params.caller.alias;
1154
+ const caller = replyCaller(params);
1155
+ const callerBinding = await this.assertReplyCaller(conversation, caller);
1156
+ const callerAlias = caller.alias;
1011
1157
  const targetAlias = callerAlias === conversation.sourceAlias
1012
1158
  ? conversation.targetAlias
1013
1159
  : conversation.sourceAlias;
@@ -1017,27 +1163,9 @@ export class GatewayService {
1017
1163
  if (targetBinding === undefined) {
1018
1164
  throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
1019
1165
  }
1020
- if (params.caller.kind === "codex" &&
1021
- conversation.nativeTarget !== undefined &&
1022
- targetAlias === conversation.nativeTarget.alias) {
1023
- const enqueued = await this.enqueueNativeConversationReply({
1024
- conversation,
1025
- sourceAlias: callerAlias,
1026
- sourceBinding: callerBinding,
1027
- target: conversation.nativeTarget,
1028
- text: params.text,
1029
- exposeDeliveryToken: true,
1030
- });
1031
- if (enqueued.deliveryToken === undefined) {
1032
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1033
- }
1034
- return {
1035
- accepted: true,
1036
- code: "ok",
1037
- conversationId: conversation.id,
1038
- deliveryToken: enqueued.deliveryToken,
1039
- };
1040
- }
1166
+ // The target's route was installed when it first sent or was first
1167
+ // sent to; a reply never re-resolves it by alias. A retired binding is
1168
+ // a refusal at enqueue, never a silent retarget.
1041
1169
  return await this.enqueueConversation({
1042
1170
  sourceAlias: callerAlias,
1043
1171
  targetAlias,
@@ -1045,14 +1173,11 @@ export class GatewayService {
1045
1173
  expectsReply: true,
1046
1174
  expectedSourceBinding: callerBinding,
1047
1175
  expectedTargetBinding: targetBinding,
1048
- ...(params.trackIdleMinutes === undefined
1049
- ? {}
1050
- : { trackIdleMinutes: params.trackIdleMinutes }),
1051
1176
  existingConversation: conversation,
1052
1177
  });
1053
1178
  }
1054
1179
  catch (error) {
1055
- return decisionFor(error, params.caller.kind === "peer");
1180
+ return sendRefusal(error, params.peerToken !== undefined);
1056
1181
  }
1057
1182
  }
1058
1183
  async assertReplyCaller(conversation, caller) {
@@ -1071,7 +1196,7 @@ export class GatewayService {
1071
1196
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
1072
1197
  }
1073
1198
  if (caller.kind === "claude") {
1074
- if (caller.replyAddress === undefined || route.binding.provider !== "claude") {
1199
+ if (route.binding.provider !== "claude") {
1075
1200
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
1076
1201
  }
1077
1202
  const resolved = await this.claudeAdapter(route.binding.hostId)
@@ -1117,14 +1242,8 @@ export class GatewayService {
1117
1242
  ...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
1118
1243
  ...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
1119
1244
  expectsReply: input.expectsReply,
1120
- pair: true,
1121
1245
  nextSequence: 0,
1122
1246
  };
1123
- const previousWatch = this.progressWatches.get(conversation.id);
1124
- const previousWatchEvents = this.progressWatchEvents.length;
1125
- if (input.skipWatch !== true) {
1126
- await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
1127
- }
1128
1247
  const sequence = conversation.nextSequence++;
1129
1248
  let enqueued;
1130
1249
  try {
@@ -1145,12 +1264,10 @@ export class GatewayService {
1145
1264
  });
1146
1265
  }
1147
1266
  catch (error) {
1148
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1149
1267
  conversation.nextSequence -= 1;
1150
1268
  throw error;
1151
1269
  }
1152
1270
  if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
1153
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1154
1271
  conversation.nextSequence -= 1;
1155
1272
  throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1156
1273
  }
@@ -1173,6 +1290,23 @@ export class GatewayService {
1173
1290
  deliveryToken: enqueued.deliveryToken,
1174
1291
  };
1175
1292
  }
1293
+ /**
1294
+ * A renamed Claude session is the same principal under a new display name,
1295
+ * so its open conversations follow it: the registration, not the alias, is
1296
+ * the identity, and a reply under the current name must still resolve.
1297
+ */
1298
+ renameConversationCoordinates(oldAlias, newAlias, registrationId) {
1299
+ for (const conversation of this.conversations.values()) {
1300
+ if (conversation.sourceAlias === oldAlias &&
1301
+ conversation.sourceBinding?.registrationId === registrationId) {
1302
+ conversation.sourceAlias = newAlias;
1303
+ }
1304
+ if (conversation.targetAlias === oldAlias &&
1305
+ conversation.targetBinding?.registrationId === registrationId) {
1306
+ conversation.targetAlias = newAlias;
1307
+ }
1308
+ }
1309
+ }
1176
1310
  rememberConversation(conversation) {
1177
1311
  this.conversations.delete(conversation.id);
1178
1312
  this.conversations.set(conversation.id, conversation);
@@ -1235,12 +1369,7 @@ export class GatewayService {
1235
1369
  if (!this.running || this.closing)
1236
1370
  return;
1237
1371
  const route = await this.store.inspectPrivateRoute(targetAlias);
1238
- const transient = [...this.messageContexts.values()].find((context) => this.conversations.get(context.conversationId)?.nativeTarget?.alias === targetAlias);
1239
- const transientTarget = transient === undefined
1240
- ? undefined
1241
- : this.conversations.get(transient.conversationId)?.nativeTarget;
1242
- const registration = preferredRegistrationId ?? route?.binding.registrationId ??
1243
- transientTarget?.binding.registrationId;
1372
+ const registration = preferredRegistrationId ?? route?.binding.registrationId;
1244
1373
  if (registration === undefined)
1245
1374
  return;
1246
1375
  this.startRunner(targetAlias, registration, "any");
@@ -1321,34 +1450,27 @@ export class GatewayService {
1321
1450
  const conversation = messageContext === undefined
1322
1451
  ? undefined
1323
1452
  : this.conversations.get(messageContext.conversationId);
1324
- const transientTarget = conversation?.nativeTarget?.alias === attempt.targetAlias
1325
- ? conversation.nativeTarget
1326
- : undefined;
1327
- const persistedTarget = await this.store.inspectPrivateRoute(attempt.targetAlias);
1328
- const target = transientTarget !== undefined
1329
- ? {
1330
- alias: transientTarget.alias,
1331
- binding: transientTarget.binding,
1332
- registrationMode: "selected_live_peer",
1333
- enabled: true,
1334
- }
1335
- : (persistedTarget === undefined
1336
- ? undefined
1337
- : persistedTarget);
1338
- const source = await this.store.inspectPrivateRoute(attempt.sourceAlias);
1453
+ // Resolve by registration, not by the alias captured at reserve: a
1454
+ // session renamed between reserve and dispatch is the same route and
1455
+ // must still be delivered to, under whatever name it carries now.
1456
+ const installed = await this.store.listLogicalRoutes();
1457
+ const byRegistration = (registration, alias) => installed.find((route) => route.binding.registrationId === registration) ??
1458
+ installed.find((route) => route.alias === alias);
1459
+ const target = byRegistration(attempt.targetRegistrationId, attempt.targetAlias);
1460
+ const source = byRegistration(attempt.sourceRegistrationId, attempt.sourceAlias);
1339
1461
  if (this.closing || !this.running) {
1340
1462
  await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
1341
1463
  return false;
1342
1464
  }
1343
1465
  if (target === undefined ||
1344
1466
  target.binding.registrationId !== attempt.targetRegistrationId ||
1345
- (attempt.sourceRegistrationId !== null && source?.binding.registrationId !== attempt.sourceRegistrationId)) {
1467
+ source?.binding.registrationId !== attempt.sourceRegistrationId) {
1346
1468
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
1347
1469
  return false;
1348
1470
  }
1349
1471
  if (target.registrationMode === "federated_peer") {
1350
1472
  const peer = this.peerClients.get(target.binding.hostId);
1351
- if (peer === undefined || source === undefined || source.registrationMode === "federated_peer") {
1473
+ if (peer === undefined || source.registrationMode === "federated_peer") {
1352
1474
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1353
1475
  return false;
1354
1476
  }
@@ -1359,8 +1481,6 @@ export class GatewayService {
1359
1481
  const params = {
1360
1482
  originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
1361
1483
  source: sourceEndpoint, target: targetEndpoint,
1362
- edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
1363
- edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
1364
1484
  deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
1365
1485
  body: attempt.body,
1366
1486
  ...(attempt.steer === true ? { steer: true } : {}),
@@ -1437,19 +1557,18 @@ export class GatewayService {
1437
1557
  attemptId: attempt.attemptId,
1438
1558
  sourceAlias: attempt.sourceAlias,
1439
1559
  sourceProvider: parsed.sourceProvider,
1440
- targetAlias: attempt.targetAlias,
1560
+ // The route's CURRENT alias, not the one captured at reserve. The
1561
+ // provider adapters compare this against the alias they hold for the
1562
+ // binding, so a session renamed between reserve and dispatch would
1563
+ // otherwise fail its own delivery over a stale display name.
1564
+ targetAlias: target.alias,
1441
1565
  conversationId,
1442
1566
  binding: target.binding,
1443
- authorization: transientTarget === undefined
1444
- ? "selected_route"
1445
- : "native_reply",
1567
+ authorization: "selected_route",
1446
1568
  messageId: attempt.messageId,
1447
1569
  text: attempt.body,
1448
1570
  expectsReply: conversation?.expectsReply ?? true,
1449
1571
  deadlineAt: attempt.deadlineAt,
1450
- ...(this.progressWatches.has(conversationId)
1451
- ? { progressWatchActive: true }
1452
- : {}),
1453
1572
  ...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
1454
1573
  authorizeWrite: async (evidence) => {
1455
1574
  if (evidence.attemptId !== attempt.attemptId)
@@ -1474,8 +1593,7 @@ export class GatewayService {
1474
1593
  if (authorized.status === "authorized") {
1475
1594
  armed = true;
1476
1595
  if (target.binding.provider === "claude" &&
1477
- messageContext?.expectsReply === true &&
1478
- source !== undefined) {
1596
+ messageContext?.expectsReply === true) {
1479
1597
  this.installPendingClaudeReply({
1480
1598
  messageId: attempt.messageId,
1481
1599
  conversationId,
@@ -1659,7 +1777,6 @@ export class GatewayService {
1659
1777
  }
1660
1778
  }
1661
1779
  this.revision += 1;
1662
- void this.publish().catch(() => undefined);
1663
1780
  }
1664
1781
  async onClaudeMessage(event) {
1665
1782
  const target = await this.store.inspectPrivateRoute(event.targetAlias);
@@ -1668,21 +1785,23 @@ export class GatewayService {
1668
1785
  !(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
1669
1786
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
1670
1787
  }
1671
- const selected = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
1672
- route.alias === event.sourceAlias &&
1673
- route.binding.routeHandle === event.endpoint.routeHandle &&
1674
- route.binding.hostId === event.endpoint.hostId);
1675
- const sourceBinding = selected?.binding ?? {
1676
- provider: "claude",
1677
- hostId: event.endpoint.hostId,
1678
- routeHandle: event.endpoint.routeHandle,
1679
- registrationId: `native_${bodyHash(`${event.endpoint.routeHandle}\0${event.sourceAlias}`).slice(0, 32)}`,
1680
- };
1788
+ // A native SendMessage is this session's send: its route installs here,
1789
+ // from the exact identity the adapter attested, so the Codex task can
1790
+ // reply through the ordinary path. The adapter's name for the sender must
1791
+ // be the name discovery shows for that UUID; the check runs on a read-only
1792
+ // lookup so a refusal writes nothing.
1793
+ this.assertWritable();
1794
+ const seen = await this.lookUpClaudeRoute(event.endpoint.routeHandle, event.endpoint.hostId);
1795
+ if (this.claimedAlias(seen) !== event.sourceAlias) {
1796
+ throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The native sender's name does not match its discovered session.");
1797
+ }
1798
+ const source = await this.materializeClaudeRoute(seen);
1799
+ const sourceBinding = source.binding;
1681
1800
  const conversationId = createGatewayConversationId();
1682
1801
  const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
1683
1802
  this.assertWritable();
1684
1803
  const enqueued = await this.store.enqueueNativeIngress({
1685
- source: { alias: event.sourceAlias, binding: sourceBinding },
1804
+ source: { alias: source.alias, binding: sourceBinding },
1686
1805
  targetAlias: event.targetAlias,
1687
1806
  expectedTargetRegistrationId: target.binding.registrationId,
1688
1807
  body: event.text,
@@ -1695,13 +1814,11 @@ export class GatewayService {
1695
1814
  }
1696
1815
  const conversation = {
1697
1816
  id: conversationId,
1698
- sourceAlias: event.sourceAlias,
1817
+ sourceAlias: source.alias,
1699
1818
  targetAlias: event.targetAlias,
1700
1819
  sourceBinding,
1701
1820
  targetBinding: target.binding,
1702
1821
  expectsReply: true,
1703
- ...(enqueued.pair === true ? { pair: true } : {}),
1704
- nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
1705
1822
  nextSequence: 1,
1706
1823
  };
1707
1824
  this.rememberConversation(conversation);
@@ -1806,65 +1923,21 @@ export class GatewayService {
1806
1923
  const targetBinding = targetAlias === conversation.targetAlias
1807
1924
  ? conversation.targetBinding
1808
1925
  : conversation.sourceBinding;
1809
- if (conversation.nativeTarget === undefined ||
1810
- sourceAlias !== conversation.nativeTarget.alias) {
1811
- if (sourceBinding === undefined || targetBinding === undefined)
1812
- return;
1813
- const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1814
- if (currentSource === undefined ||
1815
- !sameBinding(currentSource.binding, sourceBinding))
1816
- return;
1817
- await this.enqueueConversation({
1818
- sourceAlias: targetAlias,
1819
- targetAlias: sourceAlias,
1820
- text,
1821
- expectsReply: true,
1822
- expectedSourceBinding: targetBinding,
1823
- expectedTargetBinding: sourceBinding,
1824
- existingConversation: conversation,
1825
- });
1926
+ if (sourceBinding === undefined || targetBinding === undefined)
1826
1927
  return;
1827
- }
1828
- if (targetBinding === undefined)
1928
+ const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1929
+ if (currentSource === undefined ||
1930
+ !sameBinding(currentSource.binding, sourceBinding))
1829
1931
  return;
1830
- await this.enqueueNativeConversationReply({
1831
- conversation,
1932
+ await this.enqueueConversation({
1832
1933
  sourceAlias: targetAlias,
1833
- sourceBinding: targetBinding,
1834
- target: conversation.nativeTarget,
1934
+ targetAlias: sourceAlias,
1835
1935
  text,
1836
- });
1837
- }
1838
- async enqueueNativeConversationReply(input) {
1839
- const sequence = input.conversation.nextSequence++;
1840
- let enqueued;
1841
- try {
1842
- enqueued = await this.store.enqueueNativeReply({
1843
- sourceAlias: input.sourceAlias,
1844
- expectedSourceRegistrationId: input.sourceBinding.registrationId,
1845
- target: input.target,
1846
- body: input.text,
1847
- dedupeKey: `${input.conversation.id}:${sequence}`,
1848
- conversationIdSuffix: input.conversation.id.slice(-8),
1849
- ...(input.conversation.pair === true ? { pair: true } : {}),
1850
- ...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
1851
- });
1852
- }
1853
- catch (error) {
1854
- input.conversation.nextSequence -= 1;
1855
- throw error;
1856
- }
1857
- if (!enqueued.accepted || enqueued.messageId === undefined) {
1858
- input.conversation.nextSequence -= 1;
1859
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1860
- }
1861
- this.messageContexts.set(enqueued.messageId, {
1862
- conversationId: input.conversation.id,
1863
1936
  expectsReply: true,
1937
+ expectedSourceBinding: targetBinding,
1938
+ expectedTargetBinding: sourceBinding,
1939
+ existingConversation: conversation,
1864
1940
  });
1865
- this.kick(input.target.alias, input.target.binding.registrationId);
1866
- this.scheduleWake();
1867
- return enqueued;
1868
1941
  }
1869
1942
  installPendingClaudeReply(row) {
1870
1943
  const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
@@ -1931,173 +2004,6 @@ export class GatewayService {
1931
2004
  existingConversation: conversation,
1932
2005
  });
1933
2006
  }
1934
- async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
1935
- const existing = this.progressWatches.get(conversation.id);
1936
- if (this.config.trackingEnabled === false) {
1937
- if (existing !== undefined) {
1938
- this.settleProgressWatch(existing, "gateway", "tracking_disabled");
1939
- }
1940
- return;
1941
- }
1942
- if (text.startsWith("DONE:")) {
1943
- if (existing !== undefined) {
1944
- const actor = actorAlias === existing.ownerAlias
1945
- ? "owner"
1946
- : actorAlias === existing.workerAlias
1947
- ? "worker"
1948
- : undefined;
1949
- if (actor !== undefined)
1950
- this.settleProgressWatch(existing, actor, "done");
1951
- }
1952
- return;
1953
- }
1954
- const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
1955
- if (existing !== undefined && !explicit) {
1956
- this.progressWatches.set(conversation.id, {
1957
- ...existing,
1958
- ...recordProgressWatchActivity(existing, this.now().getTime()),
1959
- });
1960
- return;
1961
- }
1962
- if (!explicit)
1963
- return;
1964
- if (existing !== undefined && actorAlias !== existing.ownerAlias) {
1965
- throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
1966
- }
1967
- const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
1968
- if (existing === undefined && this.progressWatches.size >= capacity) {
1969
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
1970
- }
1971
- const owner = await this.store.inspectPrivateRoute(actorAlias);
1972
- const workerAlias = actorAlias === conversation.sourceAlias
1973
- ? conversation.targetAlias
1974
- : conversation.sourceAlias;
1975
- const worker = await this.store.inspectPrivateRoute(workerAlias);
1976
- if (owner === undefined || worker === undefined) {
1977
- throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
1978
- }
1979
- const idleMs = trackIdleMinutes === undefined
1980
- ? PROGRESS_WATCH_DEFAULT_IDLE_MS
1981
- : trackIdleMinutes * 60_000;
1982
- const watch = createProgressWatch({
1983
- conversationId: conversation.id,
1984
- ownerAlias: actorAlias,
1985
- workerAlias,
1986
- idleMs,
1987
- at: this.now().getTime(),
1988
- });
1989
- this.progressWatches.set(conversation.id, {
1990
- ...watch,
1991
- ownerRegistrationId: owner.binding.registrationId,
1992
- workerRegistrationId: worker.binding.registrationId,
1993
- });
1994
- this.recordProgressWatchEvent({
1995
- watch,
1996
- kind: existing === undefined ? "opened" : "replaced",
1997
- actor: "owner",
1998
- });
1999
- }
2000
- restoreProgressWatch(conversationId, previous, eventLength) {
2001
- if (previous === undefined)
2002
- this.progressWatches.delete(conversationId);
2003
- else
2004
- this.progressWatches.set(conversationId, previous);
2005
- this.progressWatchEvents.length = eventLength;
2006
- }
2007
- untrack(conversationId) {
2008
- const watch = this.progressWatches.get(conversationId);
2009
- if (watch === undefined) {
2010
- throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
2011
- }
2012
- this.settleProgressWatch(watch, "operator", "untracked");
2013
- }
2014
- settleWatchesForAlias(alias, reason, actor) {
2015
- for (const watch of [...this.progressWatches.values()]) {
2016
- if (watch.ownerAlias === alias || watch.workerAlias === alias) {
2017
- this.settleProgressWatch(watch, actor, reason);
2018
- }
2019
- }
2020
- }
2021
- settleProgressWatch(watch, actor, reason) {
2022
- if (!this.progressWatches.delete(watch.conversationId))
2023
- return;
2024
- this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
2025
- }
2026
- recordProgressWatchEvent(input) {
2027
- this.progressWatchEvents.push({
2028
- sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
2029
- timestamp: this.now().toISOString(),
2030
- conversationIdSuffix: input.watch.conversationId.slice(-8),
2031
- ownerAlias: input.watch.ownerAlias,
2032
- workerAlias: input.watch.workerAlias,
2033
- kind: input.kind,
2034
- actor: input.actor,
2035
- ...(input.reason === undefined ? {} : { reason: input.reason }),
2036
- });
2037
- while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
2038
- this.progressWatchEvents.shift();
2039
- }
2040
- }
2041
- async processProgressWatches() {
2042
- const at = this.now().getTime();
2043
- for (const watch of [...this.progressWatches.values()]) {
2044
- const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
2045
- const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
2046
- if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
2047
- worker?.binding.registrationId !== watch.workerRegistrationId) {
2048
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2049
- continue;
2050
- }
2051
- const ownerObservation = this.routeObservations.get(watch.ownerAlias);
2052
- const workerObservation = this.routeObservations.get(watch.workerAlias);
2053
- const due = inspectProgressWatchDue(watch, {
2054
- at,
2055
- bothIdle: ownerObservation?.state === "idle" &&
2056
- workerObservation?.state === "idle" &&
2057
- this.routeObservationStillCurrent(ownerObservation) &&
2058
- this.routeObservationStillCurrent(workerObservation),
2059
- });
2060
- if (due.kind === "not_due")
2061
- continue;
2062
- if (due.kind === "rescheduled") {
2063
- this.progressWatches.set(watch.conversationId, {
2064
- ...watch,
2065
- ...due.watch,
2066
- });
2067
- continue;
2068
- }
2069
- if (due.kind === "settled") {
2070
- this.settleProgressWatch(watch, "gateway", "idle_timeout");
2071
- continue;
2072
- }
2073
- const conversation = this.conversations.get(watch.conversationId);
2074
- if (conversation === undefined) {
2075
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2076
- continue;
2077
- }
2078
- const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
2079
- try {
2080
- await this.enqueueConversation({
2081
- sourceAlias: watch.ownerAlias,
2082
- targetAlias: watch.workerAlias,
2083
- text,
2084
- expectsReply: false,
2085
- existingConversation: conversation,
2086
- skipWatch: true,
2087
- });
2088
- this.progressWatches.set(watch.conversationId, {
2089
- ...watch,
2090
- ...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
2091
- });
2092
- }
2093
- catch {
2094
- this.progressWatches.set(watch.conversationId, {
2095
- ...watch,
2096
- ...deferProgressWatchNudge(watch, at),
2097
- });
2098
- }
2099
- }
2100
- }
2101
2007
  async finishSettlements(settlements) {
2102
2008
  await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
2103
2009
  }
@@ -2140,13 +2046,13 @@ export class GatewayService {
2140
2046
  const now = this.now().getTime();
2141
2047
  const due = [
2142
2048
  this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
2143
- this.nextDoctorAt || now + CODEX_DOCTOR_INTERVAL_MS,
2049
+ this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
2144
2050
  ...(this.config.peerNodes.length > 0
2145
2051
  ? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
2146
2052
  : []),
2053
+ ...this.ephemeralPeerExpiries.values(),
2147
2054
  ...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
2148
2055
  ...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
2149
- ...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
2150
2056
  ].filter(Number.isFinite);
2151
2057
  const delay = Math.max(0, Math.min(...due) - now);
2152
2058
  this.wakeTimer = this.timers.setTimeout(() => {
@@ -2161,14 +2067,14 @@ export class GatewayService {
2161
2067
  const now = this.now();
2162
2068
  await this.finishSettlements(await this.store.expireDueMessages(now));
2163
2069
  this.pruneExpiredPendingClaudeReplies(now.getTime());
2164
- await this.processProgressWatches();
2070
+ await this.expireEphemeralPeers(now.getTime());
2165
2071
  if (now.getTime() >= this.nextDiscoveryAt) {
2166
2072
  await this.refreshClaudeDiscovery().catch(() => undefined);
2167
2073
  this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
2168
2074
  }
2169
- if (now.getTime() >= this.nextDoctorAt) {
2170
- await this.refreshCodexDoctor().catch(() => undefined);
2171
- this.nextDoctorAt = now.getTime() + CODEX_DOCTOR_INTERVAL_MS;
2075
+ if (now.getTime() >= this.nextManagedCodexCheckAt) {
2076
+ await this.refreshManagedCodexSocket().catch(() => undefined);
2077
+ this.nextManagedCodexCheckAt = now.getTime() + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
2172
2078
  }
2173
2079
  if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
2174
2080
  const operation = this.refreshPeers();
@@ -2185,7 +2091,6 @@ export class GatewayService {
2185
2091
  return;
2186
2092
  for (const target of await this.store.inspectDispatchableTargets())
2187
2093
  this.kick(target);
2188
- await this.publish();
2189
2094
  this.scheduleWake();
2190
2095
  }
2191
2096
  async refreshPeers() {
@@ -2249,18 +2154,23 @@ export class GatewayService {
2249
2154
  if (viewChanged)
2250
2155
  this.revision += 1;
2251
2156
  }
2252
- catch {
2157
+ catch (error) {
2253
2158
  this.peerClients.get(peerHost)?.close();
2254
2159
  this.peerClients.delete(peerHost);
2255
2160
  if (!this.running || this.closing)
2256
2161
  return;
2162
+ // A wire-version disagreement is not a tunnel fault: name it so an operator
2163
+ // upgrades the lagging node instead of debugging SSH.
2164
+ if (error instanceof PeerConnectionLostError && error.failureClass === "protocol")
2165
+ failureCode = "PEER_PROTOCOL_MISMATCH";
2257
2166
  this.recordPeerFailure(peerHost, failureCode);
2258
2167
  const observedAt = this.now().toISOString();
2168
+ const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
2259
2169
  for (const route of await this.store.inspectPrivateRoutes())
2260
2170
  if (route.registrationMode === "federated_peer" &&
2261
2171
  route.binding.hostId === peerHost)
2262
2172
  this.peerRouteViews.set(route.alias, { route: route.binding,
2263
- state: "unobserved", observedAt, safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
2173
+ state: "unobserved", observedAt, safeErrorCode: mirrorCode });
2264
2174
  }
2265
2175
  }));
2266
2176
  }
@@ -2323,34 +2233,19 @@ export class GatewayService {
2323
2233
  for (const alias of retainedCollisions)
2324
2234
  this.collidingClaudeAliases.add(alias);
2325
2235
  }
2326
- async refreshCodexDoctor() {
2327
- if (this.codexDoctor === undefined)
2236
+ async refreshManagedCodexSocket() {
2237
+ if (this.managedCodexSocketHeld === undefined)
2328
2238
  return;
2329
- this.codexDoctorResult = await this.codexDoctor();
2239
+ this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
2330
2240
  }
2331
2241
  assertWritable() {
2332
2242
  if (this.closing || !this.running) {
2333
2243
  throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
2334
2244
  }
2335
2245
  }
2336
- async resolveClaudeSelector(selector, refresh) {
2337
- if (refresh)
2338
- await this.refreshClaudeDiscovery().catch(() => undefined);
2339
- if (this.collidingClaudeAliases.has(selector))
2340
- throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
2341
- return [...this.candidates.values()].find((candidate) => candidate.routeHandle === selector ||
2342
- candidate.alias === selector);
2343
- }
2344
- async resolveSelectedClaudeRoute(selector) {
2345
- return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
2346
- (route.alias === selector || route.binding.routeHandle === selector));
2347
- }
2348
2246
  routeObservationStillCurrent(event) {
2349
2247
  return this.connectors.has(connectorKey(event.route));
2350
2248
  }
2351
- async publish() {
2352
- await this.publishDashboard(this.store.rootDir, await this.snapshot());
2353
- }
2354
2249
  alert(code, route, error) {
2355
2250
  const errorCode = error instanceof BridgeError ? error.code : undefined;
2356
2251
  this.runtimeAlerts.push({
@@ -2369,7 +2264,7 @@ export class GatewayService {
2369
2264
  recordPeerFailure(host, code) {
2370
2265
  for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
2371
2266
  const alert = this.runtimeAlerts[index];
2372
- if (alert?.host === host && (alert.code === "PEER_DIAL_FAILED" || alert.code === "PEER_TUNNEL_UNAVAILABLE"))
2267
+ if (alert?.host === host && PEER_FAILURE_CODES.includes(alert.code))
2373
2268
  this.runtimeAlerts.splice(index, 1);
2374
2269
  }
2375
2270
  if (code !== undefined)