agent-embassy 1.9.5 → 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 (150) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -221
  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 +9 -12
  9. package/dist/src/gateway/claude-helper-protocol.js +15 -13
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +2 -6
  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 +18 -10
  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 +18 -13
  20. package/dist/src/gateway/cli.js +942 -298
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-local-transport.d.ts +1 -1
  23. package/dist/src/gateway/codex-local-transport.js +2 -2
  24. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  25. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  26. package/dist/src/gateway/codex-socket-holder.js +76 -0
  27. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  28. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  29. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  30. package/dist/src/gateway/config.d.ts +5 -13
  31. package/dist/src/gateway/config.js +7 -19
  32. package/dist/src/gateway/config.js.map +1 -1
  33. package/dist/src/gateway/control.d.ts +55 -108
  34. package/dist/src/gateway/control.js +95 -222
  35. package/dist/src/gateway/control.js.map +1 -1
  36. package/dist/src/gateway/federation-nodes.d.ts +29 -3
  37. package/dist/src/gateway/federation-nodes.js +200 -15
  38. package/dist/src/gateway/federation-nodes.js.map +1 -1
  39. package/dist/src/gateway/peer-client.d.ts +4 -3
  40. package/dist/src/gateway/peer-client.js +22 -13
  41. package/dist/src/gateway/peer-client.js.map +1 -1
  42. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  43. package/dist/src/gateway/peer-protocol.js +5 -12
  44. package/dist/src/gateway/peer-protocol.js.map +1 -1
  45. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  46. package/dist/src/gateway/provenance-envelope.js +4 -19
  47. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  48. package/dist/src/gateway/providers.d.ts +10 -7
  49. package/dist/src/gateway/providers.js +21 -28
  50. package/dist/src/gateway/providers.js.map +1 -1
  51. package/dist/src/gateway/server.d.ts +4 -14
  52. package/dist/src/gateway/server.js +33 -59
  53. package/dist/src/gateway/server.js.map +1 -1
  54. package/dist/src/gateway/service-agent.d.ts +187 -0
  55. package/dist/src/gateway/service-agent.js +758 -0
  56. package/dist/src/gateway/service-agent.js.map +1 -0
  57. package/dist/src/gateway/service.d.ts +117 -32
  58. package/dist/src/gateway/service.js +525 -576
  59. package/dist/src/gateway/service.js.map +1 -1
  60. package/dist/src/gateway/status-view.d.ts +167 -0
  61. package/dist/src/gateway/status-view.js +488 -0
  62. package/dist/src/gateway/status-view.js.map +1 -0
  63. package/dist/src/gateway/store.d.ts +103 -21
  64. package/dist/src/gateway/store.js +473 -547
  65. package/dist/src/gateway/store.js.map +1 -1
  66. package/dist/src/gateway/types.d.ts +51 -102
  67. package/dist/src/gateway/types.js +15 -52
  68. package/dist/src/gateway/types.js.map +1 -1
  69. package/docs/CONFIGURATION.md +194 -59
  70. package/docs/DELIVERY.md +11 -11
  71. package/docs/GATEWAY-ARCHITECTURE.md +286 -394
  72. package/package.json +4 -12
  73. package/skills/embassy-peer/SKILL.md +72 -92
  74. package/skills/embassy-peer/agents/openai.yaml +1 -1
  75. package/README.zh-CN.md +0 -269
  76. package/assets/live-dashboard/app.css +0 -1619
  77. package/assets/vendor/react/LICENSE +0 -21
  78. package/assets/vendor/react/react-dom.production.min.js +0 -267
  79. package/assets/vendor/react/react.production.min.js +0 -31
  80. package/dist/src/gateway/acp-client.d.ts +0 -110
  81. package/dist/src/gateway/acp-client.js +0 -407
  82. package/dist/src/gateway/acp-client.js.map +0 -1
  83. package/dist/src/gateway/acp-provider.d.ts +0 -66
  84. package/dist/src/gateway/acp-provider.js +0 -275
  85. package/dist/src/gateway/acp-provider.js.map +0 -1
  86. package/dist/src/gateway/cli-copy.d.ts +0 -8
  87. package/dist/src/gateway/cli-copy.en.d.ts +0 -17
  88. package/dist/src/gateway/cli-copy.en.js +0 -58
  89. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  90. package/dist/src/gateway/cli-copy.js +0 -22
  91. package/dist/src/gateway/cli-copy.js.map +0 -1
  92. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -17
  93. package/dist/src/gateway/cli-copy.zh-CN.js +0 -58
  94. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  95. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  96. package/dist/src/gateway/codex-doctor.js +0 -127
  97. package/dist/src/gateway/codex-doctor.js.map +0 -1
  98. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  99. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  100. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  101. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  102. package/dist/src/gateway/dashboard-copy.js +0 -514
  103. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  104. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  105. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  106. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  107. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  108. package/dist/src/gateway/dashboard-model.js +0 -1061
  109. package/dist/src/gateway/dashboard-model.js.map +0 -1
  110. package/dist/src/gateway/dashboard.d.ts +0 -20
  111. package/dist/src/gateway/dashboard.js +0 -874
  112. package/dist/src/gateway/dashboard.js.map +0 -1
  113. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  114. package/dist/src/gateway/deepseek-detect.js +0 -41
  115. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  116. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  117. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  118. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  119. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-command.d.ts +0 -58
  121. package/dist/src/gateway/live-dashboard-command.js +0 -334
  122. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  124. package/dist/src/gateway/live-dashboard-http.js +0 -383
  125. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  127. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  128. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  130. package/dist/src/gateway/live-dashboard-server.js +0 -144
  131. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  133. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  134. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  135. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  136. package/dist/src/gateway/live-dashboard.js +0 -154
  137. package/dist/src/gateway/live-dashboard.js.map +0 -1
  138. package/dist/src/gateway/locale.d.ts +0 -4
  139. package/dist/src/gateway/locale.js +0 -10
  140. package/dist/src/gateway/locale.js.map +0 -1
  141. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  142. package/dist/src/gateway/progress-watch-machine.js +0 -70
  143. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  144. package/dist/src/gateway/state-v2-to-v3.d.ts +0 -24
  145. package/dist/src/gateway/state-v2-to-v3.js +0 -1034
  146. package/dist/src/gateway/state-v2-to-v3.js.map +0 -1
  147. package/docs/CONFIGURATION.zh-CN.md +0 -93
  148. package/docs/DASHBOARD.md +0 -98
  149. package/docs/DASHBOARD.zh-CN.md +0 -49
  150. 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, pairParamAliases, pairParamThreadAttestation, } 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;
@@ -76,9 +101,13 @@ function connectorKey(identity) {
76
101
  return `${identity.provider}\0${identity.hostId}`;
77
102
  }
78
103
  function publicRegistry(value, previouslyParseable) {
79
- const rejected = [...value.rejected]
80
- .filter((row) => SAFE_CODE.test(row.safeErrorCode) && Number.isSafeInteger(row.count) && row.count > 0)
81
- .sort((left, right) => left.safeErrorCode.localeCompare(right.safeErrorCode));
104
+ const counts = new Map();
105
+ for (const row of value.rejected)
106
+ if (SAFE_CODE.test(row.safeErrorCode) && Number.isSafeInteger(row.count) && row.count > 0)
107
+ counts.set(row.safeErrorCode, (counts.get(row.safeErrorCode) ?? 0) + row.count);
108
+ const rejected = [...counts].map(([safeErrorCode, count]) => ({ safeErrorCode, count }))
109
+ .sort((left, right) => left.safeErrorCode < right.safeErrorCode ? -1 :
110
+ left.safeErrorCode > right.safeErrorCode ? 1 : 0);
82
111
  const retained = rejected.slice(0, gatewayPublicSnapshotLimits.registryRejectionCodes);
83
112
  return {
84
113
  entriesScanned: Math.max(0, value.entriesScanned),
@@ -96,10 +125,7 @@ function bodyHash(body) {
96
125
  return createHash("sha256").update(body, "utf8").digest("hex");
97
126
  }
98
127
  function peerCatalogAuthorityChanged(prior, current) {
99
- const authority = (catalog) => JSON.stringify([
100
- catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
101
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
102
- ]);
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)));
103
129
  return prior === undefined || authority(prior) !== authority(current);
104
130
  }
105
131
  function peerCatalogViewChanged(prior, current) {
@@ -107,7 +133,6 @@ function peerCatalogViewChanged(prior, current) {
107
133
  catalog.health,
108
134
  catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
109
135
  catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
110
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
111
136
  catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
112
137
  ]);
113
138
  return prior === undefined || view(prior) !== view(current);
@@ -119,11 +144,10 @@ export class GatewayService {
119
144
  config;
120
145
  store;
121
146
  adapters;
122
- publishDashboard;
123
147
  now;
124
148
  timers;
125
149
  nativePeerCwd;
126
- codexDoctor;
150
+ managedCodexSocketHeld;
127
151
  spawnPeer;
128
152
  peerClients = new Map();
129
153
  peerCatalogs = new Map();
@@ -133,6 +157,7 @@ export class GatewayService {
133
157
  connectors = new Map();
134
158
  routeObservations = new Map();
135
159
  candidates = new Map();
160
+ collidingClaudeAliases = new Set();
136
161
  conversations = new Map();
137
162
  messageContexts = new Map();
138
163
  activeAttempts = new Map();
@@ -141,18 +166,25 @@ export class GatewayService {
141
166
  peerRefreshOperations = new Set();
142
167
  pendingClaudeReplies = new Map();
143
168
  nativeReceipts = new Map();
144
- progressWatches = new Map();
145
- progressWatchEvents = [];
146
169
  dispatchRunners = new Map();
147
170
  steerRunners = new Map();
148
171
  startingTargets = new Set();
149
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();
150
182
  control;
151
183
  wakeTimer;
152
184
  nextDiscoveryAt = 0;
153
- nextDoctorAt = 0;
185
+ nextManagedCodexCheckAt = 0;
154
186
  nextPeerRefreshAt = 0;
155
- codexDoctorResult;
187
+ managedCodexSocketHeldOutside = false;
156
188
  revision = 0;
157
189
  snapshotRevision = 0;
158
190
  snapshotFingerprint;
@@ -163,10 +195,9 @@ export class GatewayService {
163
195
  this.config = options.config;
164
196
  this.adapters = [...(options.adapters ?? [])];
165
197
  this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
166
- this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
167
198
  this.now = options.now ?? (() => new Date());
168
199
  this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
169
- this.codexDoctor = options.codexDoctor;
200
+ this.managedCodexSocketHeld = options.managedCodexSocketHeld;
170
201
  this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
171
202
  this.timers = options.timers ?? {
172
203
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -211,7 +242,7 @@ export class GatewayService {
211
242
  }
212
243
  }
213
244
  await this.refreshClaudeDiscovery().catch(() => undefined);
214
- await this.refreshCodexDoctor().catch(() => undefined);
245
+ await this.refreshManagedCodexSocket().catch(() => undefined);
215
246
  const control = await startGatewayControlServer({
216
247
  stateDir: this.store.rootDir,
217
248
  socketPath: this.config.controlSocketPath,
@@ -230,11 +261,10 @@ export class GatewayService {
230
261
  await this.observeLoadedRoutes();
231
262
  const now = this.now().getTime();
232
263
  this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
233
- this.nextDoctorAt = now + CODEX_DOCTOR_INTERVAL_MS;
234
- this.nextPeerRefreshAt = (this.config.peerNodes?.length ?? 0) > 0 ? now : 0;
264
+ this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
265
+ this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
235
266
  for (const target of await this.store.inspectDispatchableTargets())
236
267
  this.kick(target);
237
- await this.publish();
238
268
  this.scheduleWake();
239
269
  assertActive();
240
270
  }
@@ -307,7 +337,6 @@ export class GatewayService {
307
337
  this.assertWritable();
308
338
  await operation();
309
339
  this.revision += 1;
310
- await this.publish();
311
340
  return { accepted: true, code: "ok" };
312
341
  }
313
342
  catch (error) {
@@ -322,7 +351,6 @@ export class GatewayService {
322
351
  this.assertWritable();
323
352
  const token = await this.registerPeer(params);
324
353
  this.revision += 1;
325
- await this.publish();
326
354
  return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
327
355
  }
328
356
  catch (error) {
@@ -333,25 +361,24 @@ export class GatewayService {
333
361
  awaitPeer: (params) => this.awaitPeer(params),
334
362
  peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
335
363
  unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
336
- removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
337
- selectClaude: (params) => decide(async () => this.selectClaude(params)),
338
- unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
339
- pair: (params) => decide(async () => this.pair(params)),
340
- unpair: (params) => decide(async () => this.unpair(params)),
341
364
  listSnapshot: () => this.snapshot(),
342
365
  observeSnapshot: () => this.observeSnapshot(),
343
366
  deliveryStatus: (params) => this.deliveryStatus(params.token),
344
- untrack: (params) => decide(async () => this.untrack(params.conversationId)),
345
- sendToClaude: (params) => this.sendToClaude(params),
346
- sendToCodex: (params) => this.sendToCodex(params),
347
- reply: (params) => this.reply(params),
348
- refreshDashboard: async () => {
367
+ send: (params) => this.send(params),
368
+ refreshDiscovery: async () => {
349
369
  if (this.closing) {
350
370
  return { accepted: false, code: "unavailable", revision: this.revision };
351
371
  }
352
- await this.refreshClaudeDiscovery().catch(() => undefined);
353
- await this.recordActivity("discovery", "discovery_refreshed", [], true);
354
- 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
+ }
355
382
  return { accepted: true, code: "ok", revision: this.revision };
356
383
  },
357
384
  peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
@@ -359,15 +386,18 @@ export class GatewayService {
359
386
  };
360
387
  }
361
388
  async buildPeerCatalog(peerHost) {
362
- const localHost = this.config.hostId ?? this.config.allowedHosts[0];
363
- if (!(this.config.peerNodes ?? []).includes(peerHost))
389
+ const localHost = this.config.hostId;
390
+ if (!this.config.peerNodes.includes(peerHost))
364
391
  throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
365
- const [snapshot, privateRoutes, privateEdges] = await Promise.all([
366
- this.snapshot(), this.store.inspectPrivateRoutes(), this.store.inspectPrivateConsentEdges(),
392
+ const [snapshot, privateRoutes] = await Promise.all([
393
+ this.snapshot(), this.store.inspectPrivateRoutes(),
367
394
  ]);
368
- 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));
369
400
  const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
370
- const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
371
401
  const routes = localRoutes.map((route) => {
372
402
  const row = publicRoutes.get(route.alias);
373
403
  return {
@@ -377,30 +407,16 @@ export class GatewayService {
377
407
  ...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
378
408
  };
379
409
  });
380
- const consentEdges = privateEdges.flatMap((edge) => {
381
- if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
382
- return [];
383
- const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
384
- if (rows[0] === undefined || rows[1] === undefined ||
385
- !rows.some((route) => route?.binding.hostId === peerHost) ||
386
- !rows.some((route) => route?.binding.hostId === localHost))
387
- return [];
388
- const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
389
- host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
390
- ? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
391
- const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
392
- return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
393
- });
394
410
  return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
395
411
  health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
396
412
  provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
397
413
  ...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
398
414
  ...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
399
415
  ...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
400
- })), 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)), };
401
417
  }
402
418
  async receivePeerHandoff(peerHost, handoff) {
403
- if (!(this.config.peerNodes ?? []).includes(peerHost))
419
+ if (!this.config.peerNodes.includes(peerHost))
404
420
  throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
405
421
  const enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
406
422
  if (enqueued.messageId !== undefined) {
@@ -411,12 +427,11 @@ export class GatewayService {
411
427
  const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
412
428
  this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
413
429
  sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
414
- pair: true, nextSequence: 1 });
430
+ nextSequence: 1 });
415
431
  this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
416
432
  this.kick(target.alias, target.binding.registrationId);
417
433
  }
418
434
  this.revision += 1;
419
- await this.publish();
420
435
  return { accepted: true };
421
436
  }
422
437
  async snapshot() {
@@ -450,30 +465,23 @@ export class GatewayService {
450
465
  const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
451
466
  const stale = runtime.health === "healthy" &&
452
467
  (age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
453
- const doctor = runtime.adapter.identity.provider === "codex" ? this.codexDoctorResult : undefined;
454
- const doctorConditions = doctor === undefined
455
- ? [] : [...new Set(doctor.conditions)];
456
- if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
457
- doctorConditions.push("observation_stale");
458
- const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
468
+ const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
459
469
  const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
460
- (managedLayoutMissing ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
470
+ (socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
461
471
  return {
462
472
  provider: runtime.adapter.identity.provider,
463
473
  host: runtime.adapter.identity.hostId,
464
- health: stale || managedLayoutMissing ? "degraded" : runtime.health,
474
+ health: stale || socketHeld ? "degraded" : runtime.health,
465
475
  protocol: runtime.adapter.protocol,
466
476
  protocolVersion: runtime.adapter.protocolVersion,
467
477
  ...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
468
478
  ...(age === undefined ? {} : { observationAgeMs: age }),
469
- ...(doctorConditions.length === 0 ? {} : {
470
- codexDoctor: { conditions: doctorConditions.slice(0, 2) },
471
- }),
472
479
  ...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
473
480
  ...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
474
481
  };
475
482
  });
476
483
  const availablePeers = [...this.candidates.values()]
484
+ .filter((candidate) => !this.collidingClaudeAliases.has(candidate.alias))
477
485
  .slice(0, gatewayPublicSnapshotLimits.availablePeers)
478
486
  .map((candidate) => ({
479
487
  alias: candidate.alias,
@@ -481,20 +489,9 @@ export class GatewayService {
481
489
  host: candidate.adapter.identity.hostId,
482
490
  state: candidate.state,
483
491
  validated: true,
484
- selected: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
492
+ routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
485
493
  lastSeenAt: candidate.observedAt,
486
494
  }));
487
- const progressWatches = [...this.progressWatches.values()]
488
- .slice(0, gatewayPublicSnapshotLimits.progressWatches)
489
- .map((watch) => ({
490
- conversationIdSuffix: watch.conversationId.slice(-8),
491
- ownerAlias: watch.ownerAlias,
492
- workerAlias: watch.workerAlias,
493
- lastActivityAt: watch.lastActivityAt,
494
- nextActionAt: watch.nextActionAt,
495
- nudgeCount: watch.nudgeCount,
496
- }));
497
- const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
498
495
  return projectGatewayPublicSnapshot({
499
496
  ...base,
500
497
  generatedAt: now.toISOString(),
@@ -506,14 +503,8 @@ export class GatewayService {
506
503
  connectors,
507
504
  availablePeers,
508
505
  routes,
509
- progressWatches,
510
- progressWatchEvents,
511
506
  alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
512
- truncation: {
513
- ...base.truncation,
514
- progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
515
- progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
516
- },
507
+ truncation: base.truncation,
517
508
  }, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
518
509
  }
519
510
  async observeSnapshot() {
@@ -594,7 +585,7 @@ export class GatewayService {
594
585
  }
595
586
  async advertise(route) {
596
587
  const hostId = route.registrationMode === "federated_peer"
597
- ? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
588
+ ? this.config.hostId : route.binding.hostId;
598
589
  await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
599
590
  alias: route.alias,
600
591
  sourceProvider: route.binding.provider,
@@ -603,7 +594,7 @@ export class GatewayService {
603
594
  }
604
595
  async unadvertise(route) {
605
596
  const hostId = route.registrationMode === "federated_peer"
606
- ? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
597
+ ? this.config.hostId : route.binding.hostId;
607
598
  await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
608
599
  }
609
600
  reconcileAdvertisement(route) {
@@ -612,7 +603,7 @@ export class GatewayService {
612
603
  async reconcileUnadvertisement(route) {
613
604
  await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
614
605
  }
615
- async removeOwnedRoute(route, notFoundCode, releaseProviderRoute) {
606
+ async removeOwnedRoute(route) {
616
607
  this.assertWritable();
617
608
  const result = await this.store.removeOwnedRouteAtomic({
618
609
  alias: route.alias,
@@ -620,16 +611,10 @@ export class GatewayService {
620
611
  activity: { operatorAction: true },
621
612
  });
622
613
  if (!result.removed)
623
- throw new BridgeError(notFoundCode, "The exact route is absent.");
614
+ throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
624
615
  await this.finishSettlements(result.settlements);
625
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
626
616
  this.forgetRoute(route);
627
- if (releaseProviderRoute) {
628
- await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
629
- }
630
- else {
631
- await this.reconcileUnadvertisement(route);
632
- }
617
+ await this.reconcileUnadvertisement(route);
633
618
  }
634
619
  async recordActivity(kind, action, aliases, operatorAction) {
635
620
  await this.store.recordActivity({
@@ -690,7 +675,6 @@ export class GatewayService {
690
675
  activity: { operatorAction: true },
691
676
  });
692
677
  await this.finishSettlements(result.settlements);
693
- this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
694
678
  if (!result.idempotent) {
695
679
  this.forgetRoute(previous);
696
680
  this.reconcileUnadvertisement(previous);
@@ -714,15 +698,20 @@ export class GatewayService {
714
698
  this.kick(existing.alias, existing.binding.registrationId);
715
699
  return undefined;
716
700
  }
717
- if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId ?? this.config.allowedHosts[0]}`)) {
701
+ if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId}`)) {
718
702
  throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
719
703
  }
720
704
  const token = `peer_${randomBytes(24).toString("base64url")}`;
721
705
  await this.store.registerRoute({ alias: params.alias, binding: {
722
- provider: "peer", hostId: this.config.hostId ?? this.config.allowedHosts[0],
706
+ provider: "peer", hostId: this.config.hostId,
723
707
  routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
724
- }, registrationMode: "explicit_opt_in" });
708
+ }, registrationMode: "explicit_opt_in",
709
+ ...(params.ephemeral === true ? { ephemeral: true } : {}) });
725
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
+ }
726
715
  this.observeRoute(installed);
727
716
  this.reconcileAdvertisement(installed);
728
717
  return token;
@@ -743,19 +732,86 @@ export class GatewayService {
743
732
  if (this.peerCleanupAliases.has(route.alias))
744
733
  throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
745
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;
746
743
  try {
747
744
  const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
748
745
  if (!result.removed)
749
746
  throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
747
+ removed = true;
750
748
  await this.finishSettlements(result.settlements);
751
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
752
749
  this.forgetRoute(route);
753
750
  await this.reconcileUnadvertisement(route);
754
751
  }
755
752
  finally {
753
+ if (!removed && expiry !== undefined)
754
+ this.ephemeralPeerExpiries.set(route.alias, expiry);
756
755
  this.peerCleanupAliases.delete(route.alias);
757
756
  }
758
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
+ }
759
815
  async awaitPeer(params) {
760
816
  this.assertWritable();
761
817
  const route = await this.assertPeer(params);
@@ -780,33 +836,56 @@ export class GatewayService {
780
836
  route.binding.routeHandle !== params.threadId) {
781
837
  throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
782
838
  }
783
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
784
- }
785
- async removeCodexRegistration(alias) {
786
- const route = await this.store.inspectPrivateRoute(alias);
787
- if (route === undefined || route.binding.provider !== "codex" || route.registrationMode === "federated_peer") {
788
- throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The Codex registration is absent.");
789
- }
790
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
791
- }
792
- async selectClaude(params) {
793
- const selected = await this.resolveClaudeSelector(params.alias, true);
794
- if (selected === undefined) {
795
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude session is not currently selectable.");
796
- }
797
- 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;
798
877
  if (adapter.selectRoute === undefined) {
799
- 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.");
800
879
  }
801
- await adapter.assertWorkspaceDisjoint?.(selected.routeHandle, this.store.rootDir);
802
- 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 });
803
882
  this.assertWritable();
804
883
  const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
805
- const existing = before.find((route) => route.binding.provider === "claude" &&
806
- 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 &&
807
886
  route.binding.routeHandle === chosen.routeHandle);
808
- const result = await this.store.replaceClaudeSelection({
809
- alias: selected.alias,
887
+ const input = {
888
+ alias,
810
889
  binding: {
811
890
  provider: "claude",
812
891
  hostId: adapter.identity.hostId,
@@ -814,112 +893,196 @@ export class GatewayService {
814
893
  registrationId: existing?.binding.registrationId ?? registrationId(),
815
894
  },
816
895
  registrationMode: "selected_live_peer",
817
- });
896
+ };
897
+ const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
818
898
  await this.finishSettlements(result.settlements);
819
899
  const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
820
900
  for (const prior of before) {
821
- const retained = after.find((route) => route.alias === prior.alias);
822
- if (retained === undefined || !sameBinding(retained.binding, prior.binding)) {
823
- 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.
824
904
  this.forgetRoute(prior);
825
905
  void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
826
906
  .catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
827
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
+ }
828
914
  }
829
- const installed = (await this.store.inspectPrivateRoute(selected.alias));
915
+ const installed = (await this.store.inspectPrivateRoute(alias));
830
916
  this.observeRoute(installed);
831
- this.routeObservations.set(selected.alias, {
917
+ this.routeObservations.set(alias, {
832
918
  route: installed.binding,
833
919
  state: chosen.state,
834
920
  observedAt: this.now().toISOString(),
835
921
  });
836
- await this.recordActivity("selection", "claude_selected", [selected.alias], true);
837
- this.kick(selected.alias);
838
- }
839
- async unselectClaude(params) {
840
- const route = await this.resolveSelectedClaudeRoute(params.alias);
841
- if (route === undefined || route.registrationMode === "federated_peer")
842
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
843
- await this.removeOwnedRoute(route, "CLAUDE_ROUTE_NOT_FOUND", true);
844
- }
845
- async pair(params) {
846
- const endpoints = await this.resolvePairEndpoints(params, true);
847
- const aliases = endpoints.aliases;
848
- const ownerHost = aliases.map(aliasHost).sort()[0];
849
- if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
850
- throw new ConsentOwnerError(ownerHost);
851
- const attestation = pairParamThreadAttestation(params);
852
- if (attestation !== undefined)
853
- await this.assertThread(attestation.alias, attestation.threadId);
854
- this.assertWritable();
855
- const existed = await this.store.hasConsentEdge(endpoints);
856
- await this.store.addConsentEdge(endpoints);
857
- if (!existed)
858
- await this.recordActivity("pairing", "routes_paired", [...aliases], true);
859
- }
860
- async unpair(params) {
861
- const endpoints = await this.resolvePairEndpoints(params, false);
862
- const aliases = endpoints.aliases;
863
- const ownerHost = aliases.map(aliasHost).sort()[0];
864
- if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
865
- throw new ConsentOwnerError(ownerHost);
866
- const attestation = pairParamThreadAttestation(params);
867
- if (attestation !== undefined)
868
- await this.assertThread(attestation.alias, attestation.threadId);
869
- this.assertWritable();
870
- const existed = await this.store.hasConsentEdge(endpoints);
871
- const result = await this.store.removeConsentEdge(endpoints);
872
- await this.finishSettlements(result.settlements);
873
- for (const alias of aliases)
874
- this.settleWatchesForAlias(alias, "pair_removed", "operator");
875
- if (existed)
876
- await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
877
- for (const alias of aliases)
878
- this.kick(alias);
879
- }
880
- async resolvePairEndpoints(params, maySelect) {
881
- const raw = pairParamAliases(params);
882
- const aliases = [raw[0], raw[1]];
883
- for (let index = 0; index < aliases.length; index += 1) {
884
- if (PUBLIC_ALIAS.test(aliases[index]))
885
- continue;
886
- const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
887
- if (selected === undefined)
888
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
889
- if (maySelect)
890
- await this.selectClaude({ alias: aliases[index] });
891
- aliases[index] = selected.alias;
892
- }
893
- const left = await this.store.inspectPrivateRoute(aliases[0]);
894
- const right = await this.store.inspectPrivateRoute(aliases[1]);
895
- if (left === undefined || right === undefined) {
896
- 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);
937
+ }
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
+ });
897
950
  }
898
- return {
899
- aliases,
900
- expectedRegistrationIds: [
901
- left.binding.registrationId,
902
- right.binding.registrationId,
903
- ],
904
- };
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));
905
1019
  }
906
1020
  async assertThread(alias, threadId) {
907
1021
  const route = await this.store.inspectPrivateRoute(alias);
908
- 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) {
909
1028
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
910
1029
  }
911
1030
  return route;
912
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
+ */
1039
+ async send(params) {
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);
1055
+ }
913
1056
  async sendToClaude(params) {
914
1057
  try {
915
1058
  this.assertWritable();
916
1059
  const source = "peerToken" in params && params.peerToken !== undefined
917
1060
  ? await this.assertPeer({ alias: params.fromAlias, token: params.peerToken })
918
- : await this.assertThread(params.fromAlias, params.threadId);
1061
+ : params.threadId === undefined
1062
+ ? undefined
1063
+ : await this.assertThread(params.fromAlias, params.threadId);
1064
+ if (source === undefined)
1065
+ throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
919
1066
  const direct = await this.store.inspectPrivateRoute(params.toAlias);
920
- const target = direct?.binding.provider === "peer" ? direct : await this.resolveSelectedClaudeRoute(params.toAlias);
921
- if (target === undefined)
922
- 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
+ }
923
1086
  return await this.enqueueConversation({
924
1087
  sourceAlias: params.fromAlias,
925
1088
  targetAlias: target.alias,
@@ -927,13 +1090,10 @@ export class GatewayService {
927
1090
  expectsReply: params.expectsReply,
928
1091
  expectedSourceBinding: source.binding,
929
1092
  expectedTargetBinding: target.binding,
930
- ...(params.trackIdleMinutes === undefined
931
- ? {}
932
- : { trackIdleMinutes: params.trackIdleMinutes }),
933
1093
  });
934
1094
  }
935
1095
  catch (error) {
936
- return decisionFor(error, "peerToken" in params);
1096
+ return sendRefusal(error, "peerToken" in params);
937
1097
  }
938
1098
  }
939
1099
  async sendToCodex(params) {
@@ -947,13 +1107,18 @@ export class GatewayService {
947
1107
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
948
1108
  }
949
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.
950
1114
  const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
951
1115
  if (resolved === undefined)
952
1116
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
953
- source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
954
- route.alias === params.fromAlias && route.binding.routeHandle === resolved.routeHandle);
955
- if (source === undefined)
956
- 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);
957
1122
  }
958
1123
  const target = await this.store.inspectPrivateRoute(params.toAlias);
959
1124
  if (source === undefined || target === undefined) {
@@ -969,23 +1134,26 @@ export class GatewayService {
969
1134
  expectsReply: params.expectsReply,
970
1135
  expectedSourceBinding: source.binding,
971
1136
  expectedTargetBinding: target.binding,
972
- ...(params.trackIdleMinutes === undefined
973
- ? {}
974
- : { trackIdleMinutes: params.trackIdleMinutes }),
975
1137
  });
976
1138
  }
977
1139
  catch (error) {
978
- return decisionFor(error, "peerToken" in params);
1140
+ return sendRefusal(error, "peerToken" in params);
979
1141
  }
980
1142
  }
981
- 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) {
982
1149
  try {
983
1150
  this.assertWritable();
984
1151
  const conversation = this.conversations.get(params.conversationId);
985
1152
  if (conversation === undefined)
986
1153
  throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
987
- const callerBinding = await this.assertReplyCaller(conversation, params.caller);
988
- const callerAlias = params.caller.alias;
1154
+ const caller = replyCaller(params);
1155
+ const callerBinding = await this.assertReplyCaller(conversation, caller);
1156
+ const callerAlias = caller.alias;
989
1157
  const targetAlias = callerAlias === conversation.sourceAlias
990
1158
  ? conversation.targetAlias
991
1159
  : conversation.sourceAlias;
@@ -995,27 +1163,9 @@ export class GatewayService {
995
1163
  if (targetBinding === undefined) {
996
1164
  throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
997
1165
  }
998
- if (params.caller.kind === "codex" &&
999
- conversation.nativeTarget !== undefined &&
1000
- targetAlias === conversation.nativeTarget.alias) {
1001
- const enqueued = await this.enqueueNativeConversationReply({
1002
- conversation,
1003
- sourceAlias: callerAlias,
1004
- sourceBinding: callerBinding,
1005
- target: conversation.nativeTarget,
1006
- text: params.text,
1007
- exposeDeliveryToken: true,
1008
- });
1009
- if (enqueued.deliveryToken === undefined) {
1010
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1011
- }
1012
- return {
1013
- accepted: true,
1014
- code: "ok",
1015
- conversationId: conversation.id,
1016
- deliveryToken: enqueued.deliveryToken,
1017
- };
1018
- }
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.
1019
1169
  return await this.enqueueConversation({
1020
1170
  sourceAlias: callerAlias,
1021
1171
  targetAlias,
@@ -1023,14 +1173,11 @@ export class GatewayService {
1023
1173
  expectsReply: true,
1024
1174
  expectedSourceBinding: callerBinding,
1025
1175
  expectedTargetBinding: targetBinding,
1026
- ...(params.trackIdleMinutes === undefined
1027
- ? {}
1028
- : { trackIdleMinutes: params.trackIdleMinutes }),
1029
1176
  existingConversation: conversation,
1030
1177
  });
1031
1178
  }
1032
1179
  catch (error) {
1033
- return decisionFor(error, params.caller.kind === "peer");
1180
+ return sendRefusal(error, params.peerToken !== undefined);
1034
1181
  }
1035
1182
  }
1036
1183
  async assertReplyCaller(conversation, caller) {
@@ -1049,7 +1196,7 @@ export class GatewayService {
1049
1196
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
1050
1197
  }
1051
1198
  if (caller.kind === "claude") {
1052
- if (caller.replyAddress === undefined || route.binding.provider !== "claude") {
1199
+ if (route.binding.provider !== "claude") {
1053
1200
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
1054
1201
  }
1055
1202
  const resolved = await this.claudeAdapter(route.binding.hostId)
@@ -1095,14 +1242,8 @@ export class GatewayService {
1095
1242
  ...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
1096
1243
  ...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
1097
1244
  expectsReply: input.expectsReply,
1098
- pair: true,
1099
1245
  nextSequence: 0,
1100
1246
  };
1101
- const previousWatch = this.progressWatches.get(conversation.id);
1102
- const previousWatchEvents = this.progressWatchEvents.length;
1103
- if (input.skipWatch !== true) {
1104
- await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
1105
- }
1106
1247
  const sequence = conversation.nextSequence++;
1107
1248
  let enqueued;
1108
1249
  try {
@@ -1123,12 +1264,10 @@ export class GatewayService {
1123
1264
  });
1124
1265
  }
1125
1266
  catch (error) {
1126
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1127
1267
  conversation.nextSequence -= 1;
1128
1268
  throw error;
1129
1269
  }
1130
1270
  if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
1131
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1132
1271
  conversation.nextSequence -= 1;
1133
1272
  throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1134
1273
  }
@@ -1151,6 +1290,23 @@ export class GatewayService {
1151
1290
  deliveryToken: enqueued.deliveryToken,
1152
1291
  };
1153
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
+ }
1154
1310
  rememberConversation(conversation) {
1155
1311
  this.conversations.delete(conversation.id);
1156
1312
  this.conversations.set(conversation.id, conversation);
@@ -1213,12 +1369,7 @@ export class GatewayService {
1213
1369
  if (!this.running || this.closing)
1214
1370
  return;
1215
1371
  const route = await this.store.inspectPrivateRoute(targetAlias);
1216
- const transient = [...this.messageContexts.values()].find((context) => this.conversations.get(context.conversationId)?.nativeTarget?.alias === targetAlias);
1217
- const transientTarget = transient === undefined
1218
- ? undefined
1219
- : this.conversations.get(transient.conversationId)?.nativeTarget;
1220
- const registration = preferredRegistrationId ?? route?.binding.registrationId ??
1221
- transientTarget?.binding.registrationId;
1372
+ const registration = preferredRegistrationId ?? route?.binding.registrationId;
1222
1373
  if (registration === undefined)
1223
1374
  return;
1224
1375
  this.startRunner(targetAlias, registration, "any");
@@ -1299,34 +1450,27 @@ export class GatewayService {
1299
1450
  const conversation = messageContext === undefined
1300
1451
  ? undefined
1301
1452
  : this.conversations.get(messageContext.conversationId);
1302
- const transientTarget = conversation?.nativeTarget?.alias === attempt.targetAlias
1303
- ? conversation.nativeTarget
1304
- : undefined;
1305
- const persistedTarget = await this.store.inspectPrivateRoute(attempt.targetAlias);
1306
- const target = transientTarget !== undefined
1307
- ? {
1308
- alias: transientTarget.alias,
1309
- binding: transientTarget.binding,
1310
- registrationMode: "selected_live_peer",
1311
- enabled: true,
1312
- }
1313
- : (persistedTarget === undefined
1314
- ? undefined
1315
- : persistedTarget);
1316
- 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);
1317
1461
  if (this.closing || !this.running) {
1318
1462
  await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
1319
1463
  return false;
1320
1464
  }
1321
1465
  if (target === undefined ||
1322
1466
  target.binding.registrationId !== attempt.targetRegistrationId ||
1323
- (attempt.sourceRegistrationId !== null && source?.binding.registrationId !== attempt.sourceRegistrationId)) {
1467
+ source?.binding.registrationId !== attempt.sourceRegistrationId) {
1324
1468
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
1325
1469
  return false;
1326
1470
  }
1327
1471
  if (target.registrationMode === "federated_peer") {
1328
1472
  const peer = this.peerClients.get(target.binding.hostId);
1329
- if (peer === undefined || source === undefined || source.registrationMode === "federated_peer") {
1473
+ if (peer === undefined || source.registrationMode === "federated_peer") {
1330
1474
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1331
1475
  return false;
1332
1476
  }
@@ -1337,8 +1481,6 @@ export class GatewayService {
1337
1481
  const params = {
1338
1482
  originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
1339
1483
  source: sourceEndpoint, target: targetEndpoint,
1340
- edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
1341
- edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
1342
1484
  deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
1343
1485
  body: attempt.body,
1344
1486
  ...(attempt.steer === true ? { steer: true } : {}),
@@ -1415,19 +1557,18 @@ export class GatewayService {
1415
1557
  attemptId: attempt.attemptId,
1416
1558
  sourceAlias: attempt.sourceAlias,
1417
1559
  sourceProvider: parsed.sourceProvider,
1418
- 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,
1419
1565
  conversationId,
1420
1566
  binding: target.binding,
1421
- authorization: transientTarget === undefined
1422
- ? "selected_route"
1423
- : "native_reply",
1567
+ authorization: "selected_route",
1424
1568
  messageId: attempt.messageId,
1425
1569
  text: attempt.body,
1426
1570
  expectsReply: conversation?.expectsReply ?? true,
1427
1571
  deadlineAt: attempt.deadlineAt,
1428
- ...(this.progressWatches.has(conversationId)
1429
- ? { progressWatchActive: true }
1430
- : {}),
1431
1572
  ...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
1432
1573
  authorizeWrite: async (evidence) => {
1433
1574
  if (evidence.attemptId !== attempt.attemptId)
@@ -1452,8 +1593,7 @@ export class GatewayService {
1452
1593
  if (authorized.status === "authorized") {
1453
1594
  armed = true;
1454
1595
  if (target.binding.provider === "claude" &&
1455
- messageContext?.expectsReply === true &&
1456
- source !== undefined) {
1596
+ messageContext?.expectsReply === true) {
1457
1597
  this.installPendingClaudeReply({
1458
1598
  messageId: attempt.messageId,
1459
1599
  conversationId,
@@ -1637,30 +1777,31 @@ export class GatewayService {
1637
1777
  }
1638
1778
  }
1639
1779
  this.revision += 1;
1640
- void this.publish().catch(() => undefined);
1641
1780
  }
1642
1781
  async onClaudeMessage(event) {
1643
1782
  const target = await this.store.inspectPrivateRoute(event.targetAlias);
1644
- const localHost = this.config.hostId ?? this.config.allowedHosts[0];
1783
+ const localHost = this.config.hostId;
1645
1784
  if (target === undefined || (target.binding.hostId !== event.endpoint.hostId &&
1646
1785
  !(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
1647
1786
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
1648
1787
  }
1649
- const selected = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
1650
- route.alias === event.sourceAlias &&
1651
- route.binding.routeHandle === event.endpoint.routeHandle &&
1652
- route.binding.hostId === event.endpoint.hostId);
1653
- const sourceBinding = selected?.binding ?? {
1654
- provider: "claude",
1655
- hostId: event.endpoint.hostId,
1656
- routeHandle: event.endpoint.routeHandle,
1657
- registrationId: `native_${bodyHash(`${event.endpoint.routeHandle}\0${event.sourceAlias}`).slice(0, 32)}`,
1658
- };
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;
1659
1800
  const conversationId = createGatewayConversationId();
1660
1801
  const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
1661
1802
  this.assertWritable();
1662
1803
  const enqueued = await this.store.enqueueNativeIngress({
1663
- source: { alias: event.sourceAlias, binding: sourceBinding },
1804
+ source: { alias: source.alias, binding: sourceBinding },
1664
1805
  targetAlias: event.targetAlias,
1665
1806
  expectedTargetRegistrationId: target.binding.registrationId,
1666
1807
  body: event.text,
@@ -1673,13 +1814,11 @@ export class GatewayService {
1673
1814
  }
1674
1815
  const conversation = {
1675
1816
  id: conversationId,
1676
- sourceAlias: event.sourceAlias,
1817
+ sourceAlias: source.alias,
1677
1818
  targetAlias: event.targetAlias,
1678
1819
  sourceBinding,
1679
1820
  targetBinding: target.binding,
1680
1821
  expectsReply: true,
1681
- ...(enqueued.pair === true ? { pair: true } : {}),
1682
- nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
1683
1822
  nextSequence: 1,
1684
1823
  };
1685
1824
  this.rememberConversation(conversation);
@@ -1784,65 +1923,21 @@ export class GatewayService {
1784
1923
  const targetBinding = targetAlias === conversation.targetAlias
1785
1924
  ? conversation.targetBinding
1786
1925
  : conversation.sourceBinding;
1787
- if (conversation.nativeTarget === undefined ||
1788
- sourceAlias !== conversation.nativeTarget.alias) {
1789
- if (sourceBinding === undefined || targetBinding === undefined)
1790
- return;
1791
- const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1792
- if (currentSource === undefined ||
1793
- !sameBinding(currentSource.binding, sourceBinding))
1794
- return;
1795
- await this.enqueueConversation({
1796
- sourceAlias: targetAlias,
1797
- targetAlias: sourceAlias,
1798
- text,
1799
- expectsReply: true,
1800
- expectedSourceBinding: targetBinding,
1801
- expectedTargetBinding: sourceBinding,
1802
- existingConversation: conversation,
1803
- });
1926
+ if (sourceBinding === undefined || targetBinding === undefined)
1804
1927
  return;
1805
- }
1806
- if (targetBinding === undefined)
1928
+ const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1929
+ if (currentSource === undefined ||
1930
+ !sameBinding(currentSource.binding, sourceBinding))
1807
1931
  return;
1808
- await this.enqueueNativeConversationReply({
1809
- conversation,
1932
+ await this.enqueueConversation({
1810
1933
  sourceAlias: targetAlias,
1811
- sourceBinding: targetBinding,
1812
- target: conversation.nativeTarget,
1934
+ targetAlias: sourceAlias,
1813
1935
  text,
1814
- });
1815
- }
1816
- async enqueueNativeConversationReply(input) {
1817
- const sequence = input.conversation.nextSequence++;
1818
- let enqueued;
1819
- try {
1820
- enqueued = await this.store.enqueueNativeReply({
1821
- sourceAlias: input.sourceAlias,
1822
- expectedSourceRegistrationId: input.sourceBinding.registrationId,
1823
- target: input.target,
1824
- body: input.text,
1825
- dedupeKey: `${input.conversation.id}:${sequence}`,
1826
- conversationIdSuffix: input.conversation.id.slice(-8),
1827
- ...(input.conversation.pair === true ? { pair: true } : {}),
1828
- ...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
1829
- });
1830
- }
1831
- catch (error) {
1832
- input.conversation.nextSequence -= 1;
1833
- throw error;
1834
- }
1835
- if (!enqueued.accepted || enqueued.messageId === undefined) {
1836
- input.conversation.nextSequence -= 1;
1837
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1838
- }
1839
- this.messageContexts.set(enqueued.messageId, {
1840
- conversationId: input.conversation.id,
1841
1936
  expectsReply: true,
1937
+ expectedSourceBinding: targetBinding,
1938
+ expectedTargetBinding: sourceBinding,
1939
+ existingConversation: conversation,
1842
1940
  });
1843
- this.kick(input.target.alias, input.target.binding.registrationId);
1844
- this.scheduleWake();
1845
- return enqueued;
1846
1941
  }
1847
1942
  installPendingClaudeReply(row) {
1848
1943
  const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
@@ -1909,173 +2004,6 @@ export class GatewayService {
1909
2004
  existingConversation: conversation,
1910
2005
  });
1911
2006
  }
1912
- async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
1913
- const existing = this.progressWatches.get(conversation.id);
1914
- if (this.config.trackingEnabled === false) {
1915
- if (existing !== undefined) {
1916
- this.settleProgressWatch(existing, "gateway", "tracking_disabled");
1917
- }
1918
- return;
1919
- }
1920
- if (text.startsWith("DONE:")) {
1921
- if (existing !== undefined) {
1922
- const actor = actorAlias === existing.ownerAlias
1923
- ? "owner"
1924
- : actorAlias === existing.workerAlias
1925
- ? "worker"
1926
- : undefined;
1927
- if (actor !== undefined)
1928
- this.settleProgressWatch(existing, actor, "done");
1929
- }
1930
- return;
1931
- }
1932
- const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
1933
- if (existing !== undefined && !explicit) {
1934
- this.progressWatches.set(conversation.id, {
1935
- ...existing,
1936
- ...recordProgressWatchActivity(existing, this.now().getTime()),
1937
- });
1938
- return;
1939
- }
1940
- if (!explicit)
1941
- return;
1942
- if (existing !== undefined && actorAlias !== existing.ownerAlias) {
1943
- throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
1944
- }
1945
- const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
1946
- if (existing === undefined && this.progressWatches.size >= capacity) {
1947
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
1948
- }
1949
- const owner = await this.store.inspectPrivateRoute(actorAlias);
1950
- const workerAlias = actorAlias === conversation.sourceAlias
1951
- ? conversation.targetAlias
1952
- : conversation.sourceAlias;
1953
- const worker = await this.store.inspectPrivateRoute(workerAlias);
1954
- if (owner === undefined || worker === undefined) {
1955
- throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
1956
- }
1957
- const idleMs = trackIdleMinutes === undefined
1958
- ? PROGRESS_WATCH_DEFAULT_IDLE_MS
1959
- : trackIdleMinutes * 60_000;
1960
- const watch = createProgressWatch({
1961
- conversationId: conversation.id,
1962
- ownerAlias: actorAlias,
1963
- workerAlias,
1964
- idleMs,
1965
- at: this.now().getTime(),
1966
- });
1967
- this.progressWatches.set(conversation.id, {
1968
- ...watch,
1969
- ownerRegistrationId: owner.binding.registrationId,
1970
- workerRegistrationId: worker.binding.registrationId,
1971
- });
1972
- this.recordProgressWatchEvent({
1973
- watch,
1974
- kind: existing === undefined ? "opened" : "replaced",
1975
- actor: "owner",
1976
- });
1977
- }
1978
- restoreProgressWatch(conversationId, previous, eventLength) {
1979
- if (previous === undefined)
1980
- this.progressWatches.delete(conversationId);
1981
- else
1982
- this.progressWatches.set(conversationId, previous);
1983
- this.progressWatchEvents.length = eventLength;
1984
- }
1985
- untrack(conversationId) {
1986
- const watch = this.progressWatches.get(conversationId);
1987
- if (watch === undefined) {
1988
- throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
1989
- }
1990
- this.settleProgressWatch(watch, "operator", "untracked");
1991
- }
1992
- settleWatchesForAlias(alias, reason, actor) {
1993
- for (const watch of [...this.progressWatches.values()]) {
1994
- if (watch.ownerAlias === alias || watch.workerAlias === alias) {
1995
- this.settleProgressWatch(watch, actor, reason);
1996
- }
1997
- }
1998
- }
1999
- settleProgressWatch(watch, actor, reason) {
2000
- if (!this.progressWatches.delete(watch.conversationId))
2001
- return;
2002
- this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
2003
- }
2004
- recordProgressWatchEvent(input) {
2005
- this.progressWatchEvents.push({
2006
- sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
2007
- timestamp: this.now().toISOString(),
2008
- conversationIdSuffix: input.watch.conversationId.slice(-8),
2009
- ownerAlias: input.watch.ownerAlias,
2010
- workerAlias: input.watch.workerAlias,
2011
- kind: input.kind,
2012
- actor: input.actor,
2013
- ...(input.reason === undefined ? {} : { reason: input.reason }),
2014
- });
2015
- while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
2016
- this.progressWatchEvents.shift();
2017
- }
2018
- }
2019
- async processProgressWatches() {
2020
- const at = this.now().getTime();
2021
- for (const watch of [...this.progressWatches.values()]) {
2022
- const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
2023
- const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
2024
- if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
2025
- worker?.binding.registrationId !== watch.workerRegistrationId) {
2026
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2027
- continue;
2028
- }
2029
- const ownerObservation = this.routeObservations.get(watch.ownerAlias);
2030
- const workerObservation = this.routeObservations.get(watch.workerAlias);
2031
- const due = inspectProgressWatchDue(watch, {
2032
- at,
2033
- bothIdle: ownerObservation?.state === "idle" &&
2034
- workerObservation?.state === "idle" &&
2035
- this.routeObservationStillCurrent(ownerObservation) &&
2036
- this.routeObservationStillCurrent(workerObservation),
2037
- });
2038
- if (due.kind === "not_due")
2039
- continue;
2040
- if (due.kind === "rescheduled") {
2041
- this.progressWatches.set(watch.conversationId, {
2042
- ...watch,
2043
- ...due.watch,
2044
- });
2045
- continue;
2046
- }
2047
- if (due.kind === "settled") {
2048
- this.settleProgressWatch(watch, "gateway", "idle_timeout");
2049
- continue;
2050
- }
2051
- const conversation = this.conversations.get(watch.conversationId);
2052
- if (conversation === undefined) {
2053
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2054
- continue;
2055
- }
2056
- const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
2057
- try {
2058
- await this.enqueueConversation({
2059
- sourceAlias: watch.ownerAlias,
2060
- targetAlias: watch.workerAlias,
2061
- text,
2062
- expectsReply: false,
2063
- existingConversation: conversation,
2064
- skipWatch: true,
2065
- });
2066
- this.progressWatches.set(watch.conversationId, {
2067
- ...watch,
2068
- ...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
2069
- });
2070
- }
2071
- catch {
2072
- this.progressWatches.set(watch.conversationId, {
2073
- ...watch,
2074
- ...deferProgressWatchNudge(watch, at),
2075
- });
2076
- }
2077
- }
2078
- }
2079
2007
  async finishSettlements(settlements) {
2080
2008
  await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
2081
2009
  }
@@ -2118,13 +2046,13 @@ export class GatewayService {
2118
2046
  const now = this.now().getTime();
2119
2047
  const due = [
2120
2048
  this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
2121
- this.nextDoctorAt || now + CODEX_DOCTOR_INTERVAL_MS,
2122
- ...((this.config.peerNodes?.length ?? 0) > 0
2049
+ this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
2050
+ ...(this.config.peerNodes.length > 0
2123
2051
  ? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
2124
2052
  : []),
2053
+ ...this.ephemeralPeerExpiries.values(),
2125
2054
  ...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
2126
2055
  ...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
2127
- ...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
2128
2056
  ].filter(Number.isFinite);
2129
2057
  const delay = Math.max(0, Math.min(...due) - now);
2130
2058
  this.wakeTimer = this.timers.setTimeout(() => {
@@ -2139,14 +2067,14 @@ export class GatewayService {
2139
2067
  const now = this.now();
2140
2068
  await this.finishSettlements(await this.store.expireDueMessages(now));
2141
2069
  this.pruneExpiredPendingClaudeReplies(now.getTime());
2142
- await this.processProgressWatches();
2070
+ await this.expireEphemeralPeers(now.getTime());
2143
2071
  if (now.getTime() >= this.nextDiscoveryAt) {
2144
2072
  await this.refreshClaudeDiscovery().catch(() => undefined);
2145
2073
  this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
2146
2074
  }
2147
- if (now.getTime() >= this.nextDoctorAt) {
2148
- await this.refreshCodexDoctor().catch(() => undefined);
2149
- 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;
2150
2078
  }
2151
2079
  if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
2152
2080
  const operation = this.refreshPeers();
@@ -2163,12 +2091,11 @@ export class GatewayService {
2163
2091
  return;
2164
2092
  for (const target of await this.store.inspectDispatchableTargets())
2165
2093
  this.kick(target);
2166
- await this.publish();
2167
2094
  this.scheduleWake();
2168
2095
  }
2169
2096
  async refreshPeers() {
2170
- const localHost = this.config.hostId ?? this.config.allowedHosts[0];
2171
- await Promise.all((this.config.peerNodes ?? []).map(async (peerHost) => {
2097
+ const localHost = this.config.hostId;
2098
+ await Promise.all(this.config.peerNodes.map(async (peerHost) => {
2172
2099
  let failureCode = "PEER_TUNNEL_UNAVAILABLE";
2173
2100
  try {
2174
2101
  let client = this.peerClients.get(peerHost);
@@ -2227,33 +2154,52 @@ export class GatewayService {
2227
2154
  if (viewChanged)
2228
2155
  this.revision += 1;
2229
2156
  }
2230
- catch {
2157
+ catch (error) {
2231
2158
  this.peerClients.get(peerHost)?.close();
2232
2159
  this.peerClients.delete(peerHost);
2233
2160
  if (!this.running || this.closing)
2234
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";
2235
2166
  this.recordPeerFailure(peerHost, failureCode);
2236
2167
  const observedAt = this.now().toISOString();
2168
+ const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
2237
2169
  for (const route of await this.store.inspectPrivateRoutes())
2238
2170
  if (route.registrationMode === "federated_peer" &&
2239
2171
  route.binding.hostId === peerHost)
2240
2172
  this.peerRouteViews.set(route.alias, { route: route.binding,
2241
- state: "unobserved", observedAt, safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
2173
+ state: "unobserved", observedAt, safeErrorCode: mirrorCode });
2242
2174
  }
2243
2175
  }));
2244
2176
  }
2245
2177
  async refreshClaudeDiscovery() {
2246
2178
  const seen = new Set();
2179
+ const nextCollisions = new Set();
2180
+ const refreshedHosts = new Set();
2247
2181
  for (const adapter of this.adapters) {
2248
2182
  if (adapter.identity.provider !== "claude" || adapter.discoverClaudePeers === undefined)
2249
2183
  continue;
2250
2184
  const snapshot = await adapter.discoverClaudePeers();
2251
2185
  const observedAt = this.now().toISOString();
2252
- for (const peer of snapshot.peers) {
2253
- if (!PUBLIC_ALIAS.test(peer.alias) ||
2254
- !peer.alias.endsWith(`@${adapter.identity.hostId}`) ||
2255
- !PRIVATE_HANDLE.test(peer.routeHandle))
2256
- continue;
2186
+ const peers = snapshot.peers.filter((peer) => PUBLIC_ALIAS.test(peer.alias) &&
2187
+ peer.alias.endsWith(`@${adapter.identity.hostId}`) && PRIVATE_HANDLE.test(peer.routeHandle));
2188
+ const aliasHandles = new Map();
2189
+ for (const peer of peers) {
2190
+ const handles = aliasHandles.get(peer.alias) ?? new Set();
2191
+ handles.add(peer.routeHandle);
2192
+ aliasHandles.set(peer.alias, handles);
2193
+ }
2194
+ const collidingAliases = new Set([...aliasHandles].filter(([, handles]) => handles.size > 1).map(([alias]) => alias));
2195
+ refreshedHosts.add(adapter.identity.hostId);
2196
+ if (!snapshot.complete)
2197
+ for (const alias of this.collidingClaudeAliases)
2198
+ if (alias.endsWith(`@${adapter.identity.hostId}`))
2199
+ collidingAliases.add(alias);
2200
+ for (const alias of collidingAliases)
2201
+ nextCollisions.add(alias);
2202
+ for (const peer of peers) {
2257
2203
  const key = `${adapter.identity.hostId}\0${peer.routeHandle}`;
2258
2204
  seen.add(key);
2259
2205
  this.candidates.set(key, { ...peer, adapter, observedAt });
@@ -2264,39 +2210,42 @@ export class GatewayService {
2264
2210
  runtime.health = snapshot.complete ? "healthy" : "degraded";
2265
2211
  const registry = snapshot.registry ?? adapter.latestRegistryObservation?.();
2266
2212
  if (registry !== undefined) {
2267
- runtime.registry = publicRegistry(registry, runtime.registry?.parseableRecordSeenSinceBoot ?? false);
2213
+ runtime.registry = publicRegistry({ ...registry, rejected: [
2214
+ ...registry.rejected,
2215
+ ...(collidingAliases.size === 0 ? [] : [{ safeErrorCode: "PEER_ALIAS_COLLISION",
2216
+ count: collidingAliases.size }]),
2217
+ ] }, runtime.registry?.parseableRecordSeenSinceBoot ?? false);
2268
2218
  }
2269
2219
  }
2270
2220
  }
2271
2221
  for (const [key] of this.candidates)
2272
2222
  if (!seen.has(key))
2273
2223
  this.candidates.delete(key);
2224
+ for (const alias of this.collidingClaudeAliases)
2225
+ if (refreshedHosts.has(aliasHost(alias)))
2226
+ this.collidingClaudeAliases.delete(alias);
2227
+ const retainedCollisions = new Set([...nextCollisions].slice(0, gatewayPublicSnapshotLimits.availablePeers));
2228
+ // Discovery currently caps rows such that collisions cannot overflow this independent public bound.
2229
+ // If those constants diverge, discard overflow candidates rather than expose an unfenced alias.
2230
+ for (const [key, candidate] of this.candidates)
2231
+ if (nextCollisions.has(candidate.alias) && !retainedCollisions.has(candidate.alias))
2232
+ this.candidates.delete(key);
2233
+ for (const alias of retainedCollisions)
2234
+ this.collidingClaudeAliases.add(alias);
2274
2235
  }
2275
- async refreshCodexDoctor() {
2276
- if (this.codexDoctor === undefined)
2236
+ async refreshManagedCodexSocket() {
2237
+ if (this.managedCodexSocketHeld === undefined)
2277
2238
  return;
2278
- this.codexDoctorResult = await this.codexDoctor();
2239
+ this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
2279
2240
  }
2280
2241
  assertWritable() {
2281
2242
  if (this.closing || !this.running) {
2282
2243
  throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
2283
2244
  }
2284
2245
  }
2285
- async resolveClaudeSelector(selector, refresh) {
2286
- if (refresh)
2287
- await this.refreshClaudeDiscovery().catch(() => undefined);
2288
- return [...this.candidates.values()].find((candidate) => candidate.alias === selector || candidate.routeHandle === selector);
2289
- }
2290
- async resolveSelectedClaudeRoute(selector) {
2291
- return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
2292
- (route.alias === selector || route.binding.routeHandle === selector));
2293
- }
2294
2246
  routeObservationStillCurrent(event) {
2295
2247
  return this.connectors.has(connectorKey(event.route));
2296
2248
  }
2297
- async publish() {
2298
- await this.publishDashboard(this.store.rootDir, await this.snapshot());
2299
- }
2300
2249
  alert(code, route, error) {
2301
2250
  const errorCode = error instanceof BridgeError ? error.code : undefined;
2302
2251
  this.runtimeAlerts.push({
@@ -2315,7 +2264,7 @@ export class GatewayService {
2315
2264
  recordPeerFailure(host, code) {
2316
2265
  for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
2317
2266
  const alert = this.runtimeAlerts[index];
2318
- 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))
2319
2268
  this.runtimeAlerts.splice(index, 1);
2320
2269
  }
2321
2270
  if (code !== undefined)