agent-embassy 2.0.1 → 3.1.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 (147) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +23 -36
  3. package/README.md +123 -222
  4. package/SECURITY.md +72 -93
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
  9. package/dist/src/gateway/claude-helper-protocol.js +12 -11
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
  12. package/dist/src/gateway/claude-helper-supervisor.js +45 -17
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +6 -6
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +7 -19
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -7
  20. package/dist/src/gateway/cli.js +945 -265
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  23. package/dist/src/gateway/codex-socket-holder.js +76 -0
  24. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  25. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  26. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -10
  28. package/dist/src/gateway/config.js +4 -10
  29. package/dist/src/gateway/config.js.map +1 -1
  30. package/dist/src/gateway/control.d.ts +66 -80
  31. package/dist/src/gateway/control.js +92 -147
  32. package/dist/src/gateway/control.js.map +1 -1
  33. package/dist/src/gateway/federation-nodes.d.ts +29 -2
  34. package/dist/src/gateway/federation-nodes.js +177 -7
  35. package/dist/src/gateway/federation-nodes.js.map +1 -1
  36. package/dist/src/gateway/peer-client.d.ts +4 -3
  37. package/dist/src/gateway/peer-client.js +22 -13
  38. package/dist/src/gateway/peer-client.js.map +1 -1
  39. package/dist/src/gateway/peer-protocol.d.ts +13 -11
  40. package/dist/src/gateway/peer-protocol.js +14 -12
  41. package/dist/src/gateway/peer-protocol.js.map +1 -1
  42. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  43. package/dist/src/gateway/provenance-envelope.js +4 -19
  44. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  45. package/dist/src/gateway/providers.d.ts +11 -7
  46. package/dist/src/gateway/providers.js +49 -50
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +4 -14
  49. package/dist/src/gateway/server.js +32 -54
  50. package/dist/src/gateway/server.js.map +1 -1
  51. package/dist/src/gateway/service-agent.d.ts +187 -0
  52. package/dist/src/gateway/service-agent.js +758 -0
  53. package/dist/src/gateway/service-agent.js.map +1 -0
  54. package/dist/src/gateway/service.d.ts +119 -38
  55. package/dist/src/gateway/service.js +754 -798
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +119 -0
  58. package/dist/src/gateway/status-view.js +484 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +106 -21
  61. package/dist/src/gateway/store.js +463 -537
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +64 -102
  64. package/dist/src/gateway/types.js +40 -86
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +174 -45
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +292 -384
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +69 -92
  71. package/skills/embassy-peer/agents/openai.yaml +1 -1
  72. package/README.zh-CN.md +0 -275
  73. package/assets/live-dashboard/app.css +0 -1619
  74. package/assets/vendor/react/LICENSE +0 -21
  75. package/assets/vendor/react/react-dom.production.min.js +0 -267
  76. package/assets/vendor/react/react.production.min.js +0 -31
  77. package/dist/src/gateway/acp-client.d.ts +0 -110
  78. package/dist/src/gateway/acp-client.js +0 -407
  79. package/dist/src/gateway/acp-client.js.map +0 -1
  80. package/dist/src/gateway/acp-provider.d.ts +0 -66
  81. package/dist/src/gateway/acp-provider.js +0 -275
  82. package/dist/src/gateway/acp-provider.js.map +0 -1
  83. package/dist/src/gateway/claude-helper-client.d.ts +0 -2
  84. package/dist/src/gateway/claude-helper-client.js +0 -2
  85. package/dist/src/gateway/claude-helper-client.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 -22
  88. package/dist/src/gateway/cli-copy.en.js +0 -62
  89. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  90. package/dist/src/gateway/cli-copy.js +0 -27
  91. package/dist/src/gateway/cli-copy.js.map +0 -1
  92. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  93. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  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 -60
  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/docs/CONFIGURATION.zh-CN.md +0 -97
  145. package/docs/DASHBOARD.md +0 -98
  146. package/docs/DASHBOARD.zh-CN.md +0 -49
  147. package/docs/DELIVERY.zh-CN.md +0 -55
@@ -1,19 +1,22 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { BridgeError } from "../errors.js";
3
- import { createGatewayConversationId, startGatewayControlServer, } from "./control.js";
4
- import { publishGatewayDashboard } from "./dashboard.js";
5
- import { spawnPeerClient } from "./peer-client.js";
6
- import { peerEdgeRef, peerRouteRef } from "./peer-protocol.js";
7
- import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_DEFAULT_IDLE_MS, PROGRESS_WATCH_HARD_CAPACITY, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, recordProgressWatchActivity, } from "./progress-watch-machine.js";
3
+ import { createGatewayConversationId, startGatewayControlServer, GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS, } from "./control.js";
4
+ import { PeerConnectionLostError, PeerRequestError, spawnPeerClient } from "./peer-client.js";
5
+ import { isPeerHandoffRefusal, 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, gatewayHealthWord, directionId, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, 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;
@@ -96,14 +121,8 @@ function conversationIdForSuffix(suffix) {
96
121
  const fresh = createGatewayConversationId();
97
122
  return suffix === undefined ? fresh : `${fresh.slice(0, -8)}${suffix}`;
98
123
  }
99
- function bodyHash(body) {
100
- return createHash("sha256").update(body, "utf8").digest("hex");
101
- }
102
124
  function peerCatalogAuthorityChanged(prior, current) {
103
- const authority = (catalog) => JSON.stringify([
104
- catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
105
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
106
- ]);
125
+ const authority = (catalog) => JSON.stringify(catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)));
107
126
  return prior === undefined || authority(prior) !== authority(current);
108
127
  }
109
128
  function peerCatalogViewChanged(prior, current) {
@@ -111,7 +130,6 @@ function peerCatalogViewChanged(prior, current) {
111
130
  catalog.health,
112
131
  catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
113
132
  catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
114
- [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
115
133
  catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
116
134
  ]);
117
135
  return prior === undefined || view(prior) !== view(current);
@@ -123,15 +141,14 @@ export class GatewayService {
123
141
  config;
124
142
  store;
125
143
  adapters;
126
- publishDashboard;
127
144
  now;
128
145
  timers;
129
146
  nativePeerCwd;
130
- codexDoctor;
147
+ managedCodexSocketHeld;
131
148
  spawnPeer;
132
149
  peerClients = new Map();
133
150
  peerCatalogs = new Map();
134
- peerCleanupAliases = new Set();
151
+ routeCleanupAliases = new Set();
135
152
  /** View-only freshness; never consulted by routing or write authorization. */
136
153
  peerRouteViews = new Map();
137
154
  connectors = new Map();
@@ -146,18 +163,25 @@ export class GatewayService {
146
163
  peerRefreshOperations = new Set();
147
164
  pendingClaudeReplies = new Map();
148
165
  nativeReceipts = new Map();
149
- progressWatches = new Map();
150
- progressWatchEvents = [];
151
166
  dispatchRunners = new Map();
152
167
  steerRunners = new Map();
153
168
  startingTargets = new Set();
154
169
  runtimeAlerts = [];
170
+ /**
171
+ * Ephemeral peer registrations and the wall-clock instant each one dies.
172
+ * Nothing durable records them, so this map is the whole lifetime: it is
173
+ * swept on the ordinary maintenance tick, and a restart simply never sees
174
+ * the routes at all.
175
+ */
176
+ ephemeralPeerExpiries = new Map();
177
+ /** Consecutive failed retirements per ephemeral alias; cleared on success or removal. */
178
+ ephemeralExpiryFailures = new Map();
155
179
  control;
156
180
  wakeTimer;
157
181
  nextDiscoveryAt = 0;
158
- nextDoctorAt = 0;
182
+ nextManagedCodexCheckAt = 0;
159
183
  nextPeerRefreshAt = 0;
160
- codexDoctorResult;
184
+ managedCodexSocketHeldOutside = false;
161
185
  revision = 0;
162
186
  snapshotRevision = 0;
163
187
  snapshotFingerprint;
@@ -168,10 +192,9 @@ export class GatewayService {
168
192
  this.config = options.config;
169
193
  this.adapters = [...(options.adapters ?? [])];
170
194
  this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
171
- this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
172
195
  this.now = options.now ?? (() => new Date());
173
196
  this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
174
- this.codexDoctor = options.codexDoctor;
197
+ this.managedCodexSocketHeld = options.managedCodexSocketHeld;
175
198
  this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
176
199
  this.timers = options.timers ?? {
177
200
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -211,12 +234,9 @@ export class GatewayService {
211
234
  if (registry !== undefined)
212
235
  runtime.registry = publicRegistry(registry, false);
213
236
  this.connectors.set(key, runtime);
214
- if (started.ownedRoute !== undefined) {
215
- await this.installOwnedRoute(adapter, started.ownedRoute);
216
- }
217
237
  }
218
238
  await this.refreshClaudeDiscovery().catch(() => undefined);
219
- await this.refreshCodexDoctor().catch(() => undefined);
239
+ await this.refreshManagedCodexSocket().catch(() => undefined);
220
240
  const control = await startGatewayControlServer({
221
241
  stateDir: this.store.rootDir,
222
242
  socketPath: this.config.controlSocketPath,
@@ -235,11 +255,10 @@ export class GatewayService {
235
255
  await this.observeLoadedRoutes();
236
256
  const now = this.now().getTime();
237
257
  this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
238
- this.nextDoctorAt = now + CODEX_DOCTOR_INTERVAL_MS;
258
+ this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
239
259
  this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
240
260
  for (const target of await this.store.inspectDispatchableTargets())
241
261
  this.kick(target);
242
- await this.publish();
243
262
  this.scheduleWake();
244
263
  assertActive();
245
264
  }
@@ -310,9 +329,10 @@ export class GatewayService {
310
329
  const decide = async (operation, peerPrincipal = false) => {
311
330
  try {
312
331
  this.assertWritable();
313
- await operation();
332
+ const decision = await operation();
333
+ if (decision !== undefined)
334
+ return decision;
314
335
  this.revision += 1;
315
- await this.publish();
316
336
  return { accepted: true, code: "ok" };
317
337
  }
318
338
  catch (error) {
@@ -327,7 +347,6 @@ export class GatewayService {
327
347
  this.assertWritable();
328
348
  const token = await this.registerPeer(params);
329
349
  this.revision += 1;
330
- await this.publish();
331
350
  return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
332
351
  }
333
352
  catch (error) {
@@ -337,25 +356,33 @@ export class GatewayService {
337
356
  unregisterPeer: (params) => decide(async () => this.unregisterPeer(params), true),
338
357
  awaitPeer: (params) => this.awaitPeer(params),
339
358
  peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
359
+ retireRoute: async ({ alias }) => {
360
+ try {
361
+ return await this.retireRoute(alias);
362
+ }
363
+ catch (error) {
364
+ return sendRefusal(error);
365
+ }
366
+ },
340
367
  unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
341
- removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
342
- selectClaude: (params) => decide(async () => this.selectClaude(params)),
343
- unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
344
- pair: (params) => decide(async () => this.pair(params)),
345
- unpair: (params) => decide(async () => this.unpair(params)),
346
368
  listSnapshot: () => this.snapshot(),
347
369
  observeSnapshot: () => this.observeSnapshot(),
348
370
  deliveryStatus: (params) => this.deliveryStatus(params.token),
349
- untrack: (params) => decide(async () => this.untrack(params.conversationId)),
350
371
  send: (params) => this.send(params),
351
- reply: (params) => this.reply(params),
352
- refreshDashboard: async () => {
372
+ refreshDiscovery: async () => {
353
373
  if (this.closing) {
354
374
  return { accepted: false, code: "unavailable", revision: this.revision };
355
375
  }
356
- await this.refreshClaudeDiscovery().catch(() => undefined);
357
- await this.recordActivity("discovery", "discovery_refreshed", [], true);
358
- await this.publish();
376
+ try {
377
+ // An operator-triggered rescan reports its own failure. It must never
378
+ // journal `discovery_refreshed` for a scan that did not complete, and
379
+ // must never answer `accepted: true` for a provider that is down.
380
+ await this.refreshClaudeDiscovery();
381
+ await this.recordActivity("discovery", "discovery_refreshed", [], true);
382
+ }
383
+ catch (error) {
384
+ return { accepted: false, code: decisionFor(error).code, revision: this.revision };
385
+ }
359
386
  return { accepted: true, code: "ok", revision: this.revision };
360
387
  },
361
388
  peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
@@ -366,12 +393,15 @@ export class GatewayService {
366
393
  const localHost = this.config.hostId;
367
394
  if (!this.config.peerNodes.includes(peerHost))
368
395
  throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
369
- const [snapshot, privateRoutes, privateEdges] = await Promise.all([
370
- this.snapshot(), this.store.inspectPrivateRoutes(), this.store.inspectPrivateConsentEdges(),
396
+ const [snapshot, privateRoutes] = await Promise.all([
397
+ this.snapshot(), this.store.inspectPrivateRoutes(),
371
398
  ]);
372
- const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost);
399
+ // An ephemeral registration is never published: a peer node that mirrored
400
+ // one would hold a route this broker will delete on a timer and can never
401
+ // restore, and a self-test must not leave a trace on another machine.
402
+ const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" &&
403
+ route.binding.hostId === localHost && !this.store.isEphemeralRegistration(route.binding.registrationId));
373
404
  const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
374
- const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
375
405
  const routes = localRoutes.map((route) => {
376
406
  const row = publicRoutes.get(route.alias);
377
407
  return {
@@ -381,32 +411,28 @@ export class GatewayService {
381
411
  ...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
382
412
  };
383
413
  });
384
- const consentEdges = privateEdges.flatMap((edge) => {
385
- if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
386
- return [];
387
- const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
388
- if (rows[0] === undefined || rows[1] === undefined ||
389
- !rows.some((route) => route?.binding.hostId === peerHost) ||
390
- !rows.some((route) => route?.binding.hostId === localHost))
391
- return [];
392
- const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
393
- host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
394
- ? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
395
- const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
396
- return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
397
- });
398
414
  return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
399
415
  health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
400
416
  provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
401
417
  ...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
402
418
  ...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
403
419
  ...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
404
- })), routes, consentEdges, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
420
+ })), routes, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
405
421
  }
406
422
  async receivePeerHandoff(peerHost, handoff) {
407
423
  if (!this.config.peerNodes.includes(peerHost))
408
424
  throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
409
- const enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
425
+ let enqueued;
426
+ try {
427
+ enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
428
+ }
429
+ catch (error) {
430
+ const refusal = { accepted: false, reason: error instanceof BridgeError ? error.code : undefined };
431
+ if (isPeerHandoffRefusal(refusal))
432
+ return refusal;
433
+ throw error;
434
+ }
435
+ // Post-admission bookkeeping is deliberately outside the refusal catch.
410
436
  if (enqueued.messageId !== undefined) {
411
437
  const source = await this.store.inspectPrivateRoute(handoff.source.alias);
412
438
  const target = await this.store.inspectPrivateRoute(handoff.target.alias);
@@ -415,12 +441,11 @@ export class GatewayService {
415
441
  const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
416
442
  this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
417
443
  sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
418
- pair: true, nextSequence: 1 });
444
+ nextSequence: 1 });
419
445
  this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
420
446
  this.kick(target.alias, target.binding.registrationId);
421
447
  }
422
448
  this.revision += 1;
423
- await this.publish();
424
449
  return { accepted: true };
425
450
  }
426
451
  async snapshot() {
@@ -449,34 +474,8 @@ export class GatewayService {
449
474
  ...(persisted.safeErrorCode === undefined ? {} : { safeErrorCode: persisted.safeErrorCode }),
450
475
  };
451
476
  });
452
- const connectors = [...this.connectors.values()].map((runtime) => {
453
- const observedAt = runtime.observedAt;
454
- const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
455
- const stale = runtime.health === "healthy" &&
456
- (age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
457
- const doctor = runtime.adapter.identity.provider === "codex" ? this.codexDoctorResult : undefined;
458
- const doctorConditions = doctor === undefined
459
- ? [] : [...new Set(doctor.conditions)];
460
- if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
461
- doctorConditions.push("observation_stale");
462
- const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
463
- const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
464
- (managedLayoutMissing ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
465
- return {
466
- provider: runtime.adapter.identity.provider,
467
- host: runtime.adapter.identity.hostId,
468
- health: stale || managedLayoutMissing ? "degraded" : runtime.health,
469
- protocol: runtime.adapter.protocol,
470
- protocolVersion: runtime.adapter.protocolVersion,
471
- ...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
472
- ...(age === undefined ? {} : { observationAgeMs: age }),
473
- ...(doctorConditions.length === 0 ? {} : {
474
- codexDoctor: { conditions: doctorConditions.slice(0, 2) },
475
- }),
476
- ...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
477
- ...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
478
- };
479
- });
477
+ const connectors = this.publicConnectors(now);
478
+ const word = gatewayHealthWord(connectors);
480
479
  const availablePeers = [...this.candidates.values()]
481
480
  .filter((candidate) => !this.collidingClaudeAliases.has(candidate.alias))
482
481
  .slice(0, gatewayPublicSnapshotLimits.availablePeers)
@@ -486,39 +485,18 @@ export class GatewayService {
486
485
  host: candidate.adapter.identity.hostId,
487
486
  state: candidate.state,
488
487
  validated: true,
489
- selected: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
488
+ routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
490
489
  lastSeenAt: candidate.observedAt,
491
490
  }));
492
- const progressWatches = [...this.progressWatches.values()]
493
- .slice(0, gatewayPublicSnapshotLimits.progressWatches)
494
- .map((watch) => ({
495
- conversationIdSuffix: watch.conversationId.slice(-8),
496
- ownerAlias: watch.ownerAlias,
497
- workerAlias: watch.workerAlias,
498
- lastActivityAt: watch.lastActivityAt,
499
- nextActionAt: watch.nextActionAt,
500
- nudgeCount: watch.nudgeCount,
501
- }));
502
- const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
503
491
  return projectGatewayPublicSnapshot({
504
492
  ...base,
505
493
  generatedAt: now.toISOString(),
506
- health: connectors.some((connector) => connector.health === "degraded")
507
- ? "degraded"
508
- : connectors.length === 0
509
- ? "offline"
510
- : "healthy",
494
+ health: word === "ok" || word === "stale" ? "healthy" : word,
511
495
  connectors,
512
496
  availablePeers,
513
497
  routes,
514
- progressWatches,
515
- progressWatchEvents,
516
498
  alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
517
- truncation: {
518
- ...base.truncation,
519
- progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
520
- progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
521
- },
499
+ truncation: base.truncation,
522
500
  }, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
523
501
  }
524
502
  async observeSnapshot() {
@@ -531,30 +509,29 @@ export class GatewayService {
531
509
  return { snapshotRevision: this.snapshotRevision, snapshot };
532
510
  }
533
511
  health() {
534
- return this.connectors.size > 0 &&
535
- [...this.connectors.values()].every((runtime) => runtime.health === "healthy") ? "ok" : "degraded";
536
- }
537
- async installOwnedRoute(adapter, owned) {
538
- const prefix = gatewayRegistrationIngressPrefixes[adapter.identity.provider];
539
- if (!PUBLIC_ALIAS.test(owned.alias) ||
540
- !owned.alias.endsWith(`@${adapter.identity.hostId}`) ||
541
- (prefix !== undefined && !owned.alias.startsWith(prefix)) ||
542
- !PRIVATE_HANDLE.test(owned.routeHandle)) {
543
- throw new BridgeError("GATEWAY_OWNED_ROUTE_INVALID", "The configured route identity is invalid.");
544
- }
545
- const existing = await this.store.inspectPrivateRoute(owned.alias);
546
- if (existing !== undefined) {
547
- if (existing.binding.provider !== adapter.identity.provider ||
548
- existing.binding.hostId !== adapter.identity.hostId ||
549
- existing.binding.routeHandle !== owned.routeHandle) {
550
- throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The configured route conflicts with durable state.");
551
- }
552
- return;
553
- }
554
- await this.store.registerRoute({
555
- alias: owned.alias,
556
- binding: { ...adapter.identity, routeHandle: owned.routeHandle, registrationId: registrationId() },
557
- registrationMode: "explicit_opt_in",
512
+ const word = gatewayHealthWord(this.publicConnectors(this.now()));
513
+ return word === "ok" || word === "stale" ? "ok" : "degraded";
514
+ }
515
+ publicConnectors(now) {
516
+ return [...this.connectors.values()].map((runtime) => {
517
+ const observedAt = runtime.observedAt;
518
+ const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
519
+ const stale = runtime.health === "healthy" &&
520
+ (age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
521
+ const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
522
+ const connectorCode = runtime.safeErrorCode ??
523
+ (socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : stale ? "CONNECTOR_OBSERVATION_STALE" : undefined);
524
+ return {
525
+ provider: runtime.adapter.identity.provider,
526
+ host: runtime.adapter.identity.hostId,
527
+ health: stale || socketHeld ? "degraded" : runtime.health,
528
+ protocol: runtime.adapter.protocol,
529
+ protocolVersion: runtime.adapter.protocolVersion,
530
+ ...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
531
+ ...(age === undefined ? {} : { observationAgeMs: age }),
532
+ ...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
533
+ ...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
534
+ };
558
535
  });
559
536
  }
560
537
  async observeLoadedRoutes() {
@@ -598,18 +575,18 @@ export class GatewayService {
598
575
  return this.adapterFor({ provider: "claude", hostId });
599
576
  }
600
577
  async advertise(route) {
601
- const hostId = route.registrationMode === "federated_peer"
602
- ? this.config.hostId : route.binding.hostId;
603
- await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
578
+ if (route.registrationMode === "federated_peer")
579
+ return;
580
+ await this.claudeAdapter(route.binding.hostId)?.advertiseNativeSourcePeer?.({
604
581
  alias: route.alias,
605
582
  sourceProvider: route.binding.provider,
606
583
  cwd: this.nativePeerCwd,
607
584
  });
608
585
  }
609
586
  async unadvertise(route) {
610
- const hostId = route.registrationMode === "federated_peer"
611
- ? this.config.hostId : route.binding.hostId;
612
- await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
587
+ if (route.registrationMode === "federated_peer")
588
+ return;
589
+ await this.claudeAdapter(route.binding.hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
613
590
  }
614
591
  reconcileAdvertisement(route) {
615
592
  void this.advertise(route).catch((error) => this.alert("NATIVE_ADVERTISEMENT_FAILED", route, error));
@@ -617,7 +594,67 @@ export class GatewayService {
617
594
  async reconcileUnadvertisement(route) {
618
595
  await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
619
596
  }
620
- async removeOwnedRoute(route, notFoundCode, releaseProviderRoute) {
597
+ assertRouteNotCleaning(alias) {
598
+ if (this.routeCleanupAliases.has(alias))
599
+ throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
600
+ }
601
+ async retireRoute(alias) {
602
+ this.assertWritable();
603
+ const route = await this.store.inspectPrivateRoute(alias);
604
+ if (!route)
605
+ return { accepted: false, code: "not_found" };
606
+ if (route.registrationMode === "federated_peer" || route.binding.hostId !== this.config.hostId) {
607
+ throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A remote route is retired by its owner, not this broker.");
608
+ }
609
+ this.assertRouteNotCleaning(alias);
610
+ this.routeCleanupAliases.add(alias);
611
+ const expiry = this.ephemeralPeerExpiries.get(alias);
612
+ this.ephemeralPeerExpiries.delete(alias);
613
+ this.ephemeralExpiryFailures.delete(alias);
614
+ let removed = false;
615
+ try {
616
+ const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding,
617
+ activity: { operatorAction: true, action: "route_retired" } });
618
+ if (!result.removed)
619
+ return { accepted: false, code: "not_found" };
620
+ removed = true;
621
+ await this.finishSettlements(result.settlements);
622
+ const incident = (row) => row.sourceBinding?.registrationId === route.binding.registrationId || row.targetBinding?.registrationId === route.binding.registrationId;
623
+ for (const [id, conversation] of this.conversations)
624
+ if (incident(conversation))
625
+ this.conversations.delete(id);
626
+ for (const [id, rows] of this.pendingClaudeReplies) {
627
+ const retained = rows.filter((row) => !incident(row));
628
+ if (retained.length)
629
+ this.pendingClaudeReplies.set(id, retained);
630
+ else
631
+ this.pendingClaudeReplies.delete(id);
632
+ }
633
+ this.forgetRoute(route);
634
+ if (route.binding.provider === "claude") {
635
+ try {
636
+ await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
637
+ }
638
+ catch (error) {
639
+ this.alert("ROUTE_RELEASE_FAILED", route, error);
640
+ }
641
+ }
642
+ else
643
+ await this.reconcileUnadvertisement(route);
644
+ this.revision += 1;
645
+ return { accepted: true, code: "ok", settlements: {
646
+ cancelled: result.settlements.filter((row) => row.state === "cancelled").length,
647
+ ambiguous: result.settlements.filter((row) => row.state === "ambiguous").length,
648
+ unconfirmed: result.settlements.filter((row) => row.state === "unconfirmed").length,
649
+ } };
650
+ }
651
+ finally {
652
+ if (!removed && expiry !== undefined)
653
+ this.ephemeralPeerExpiries.set(alias, expiry);
654
+ this.routeCleanupAliases.delete(alias);
655
+ }
656
+ }
657
+ async removeOwnedRoute(route) {
621
658
  this.assertWritable();
622
659
  const result = await this.store.removeOwnedRouteAtomic({
623
660
  alias: route.alias,
@@ -625,16 +662,10 @@ export class GatewayService {
625
662
  activity: { operatorAction: true },
626
663
  });
627
664
  if (!result.removed)
628
- throw new BridgeError(notFoundCode, "The exact route is absent.");
665
+ throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
629
666
  await this.finishSettlements(result.settlements);
630
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
631
667
  this.forgetRoute(route);
632
- if (releaseProviderRoute) {
633
- await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
634
- }
635
- else {
636
- await this.reconcileUnadvertisement(route);
637
- }
668
+ await this.reconcileUnadvertisement(route);
638
669
  }
639
670
  async recordActivity(kind, action, aliases, operatorAction) {
640
671
  await this.store.recordActivity({
@@ -647,6 +678,7 @@ export class GatewayService {
647
678
  }
648
679
  async registerCodex(params) {
649
680
  const existing = await this.store.inspectPrivateRoute(params.alias);
681
+ this.assertRouteNotCleaning(params.alias);
650
682
  if (existing !== undefined && params.succeedsAlias === undefined) {
651
683
  if (existing.binding.provider !== "codex" || existing.binding.routeHandle !== params.threadId) {
652
684
  throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to another registration.");
@@ -667,6 +699,7 @@ export class GatewayService {
667
699
  };
668
700
  if (params.succeedsAlias === undefined) {
669
701
  this.assertWritable();
702
+ this.assertRouteNotCleaning(params.alias);
670
703
  await this.store.registerRoute(replacement);
671
704
  const installed = (await this.store.inspectPrivateRoute(params.alias));
672
705
  await this.recordActivity("registration", "codex_registered", [installed.alias], true);
@@ -688,6 +721,7 @@ export class GatewayService {
688
721
  throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The succeeded registration is absent.");
689
722
  }
690
723
  this.assertWritable();
724
+ this.assertRouteNotCleaning(params.alias);
691
725
  const result = await this.store.replaceCodexRegistrationAtomic({
692
726
  oldAlias: params.succeedsAlias,
693
727
  expectedOldRegistrationId: previous.binding.registrationId,
@@ -695,7 +729,6 @@ export class GatewayService {
695
729
  activity: { operatorAction: true },
696
730
  });
697
731
  await this.finishSettlements(result.settlements);
698
- this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
699
732
  if (!result.idempotent) {
700
733
  this.forgetRoute(previous);
701
734
  this.reconcileUnadvertisement(previous);
@@ -706,7 +739,7 @@ export class GatewayService {
706
739
  this.kick(params.alias);
707
740
  }
708
741
  async registerPeer(params) {
709
- if (this.peerCleanupAliases.has(params.alias)) {
742
+ if (this.routeCleanupAliases.has(params.alias)) {
710
743
  throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
711
744
  }
712
745
  const existing = await this.store.inspectPrivateRoute(params.alias);
@@ -723,11 +756,17 @@ export class GatewayService {
723
756
  throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
724
757
  }
725
758
  const token = `peer_${randomBytes(24).toString("base64url")}`;
759
+ this.assertRouteNotCleaning(params.alias);
726
760
  await this.store.registerRoute({ alias: params.alias, binding: {
727
761
  provider: "peer", hostId: this.config.hostId,
728
762
  routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
729
- }, registrationMode: "explicit_opt_in" });
763
+ }, registrationMode: "explicit_opt_in",
764
+ ...(params.ephemeral === true ? { ephemeral: true } : {}) });
730
765
  const installed = (await this.store.inspectPrivateRoute(params.alias));
766
+ if (params.ephemeral === true) {
767
+ this.ephemeralPeerExpiries.set(installed.alias, this.now().getTime() + (params.ttlMs ?? GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS));
768
+ this.scheduleWake();
769
+ }
731
770
  this.observeRoute(installed);
732
771
  this.reconcileAdvertisement(installed);
733
772
  return token;
@@ -745,20 +784,87 @@ export class GatewayService {
745
784
  }
746
785
  async unregisterPeer(params) {
747
786
  const route = await this.assertPeer(params);
748
- if (this.peerCleanupAliases.has(route.alias))
787
+ if (this.routeCleanupAliases.has(route.alias))
749
788
  throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
750
- this.peerCleanupAliases.add(route.alias);
789
+ this.routeCleanupAliases.add(route.alias);
790
+ // The expiry leaves the map before the removal, under the same cleanup
791
+ // guard, so a clock that fires mid-removal has nothing to act on; a
792
+ // removal that does not happen puts it back, so a refused unregister
793
+ // still leaves the registration to its own expiry.
794
+ const expiry = this.ephemeralPeerExpiries.get(route.alias);
795
+ this.ephemeralPeerExpiries.delete(route.alias);
796
+ this.ephemeralExpiryFailures.delete(route.alias);
797
+ let removed = false;
751
798
  try {
752
799
  const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
753
800
  if (!result.removed)
754
801
  throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
802
+ removed = true;
755
803
  await this.finishSettlements(result.settlements);
756
- this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
757
804
  this.forgetRoute(route);
758
805
  await this.reconcileUnadvertisement(route);
759
806
  }
760
807
  finally {
761
- this.peerCleanupAliases.delete(route.alias);
808
+ if (!removed && expiry !== undefined)
809
+ this.ephemeralPeerExpiries.set(route.alias, expiry);
810
+ this.routeCleanupAliases.delete(route.alias);
811
+ }
812
+ }
813
+ /**
814
+ * Retires every ephemeral peer registration whose lifetime has run out. It
815
+ * is the same removal an explicit `unregister-peer` performs — queued work
816
+ * is settled, not abandoned — minus the token check, because the authority
817
+ * here is the clock the broker set itself.
818
+ *
819
+ * The clock is keyed by alias, and an alias can be re-registered durably
820
+ * after its ephemeral holder left, so nothing is retired unless the route
821
+ * behind the alias is still an ephemeral one. A retirement that throws
822
+ * keeps its entry and is retried `EPHEMERAL_EXPIRY_RETRY_MS` later, with an
823
+ * alert each time; after `EPHEMERAL_EXPIRY_MAX_ATTEMPTS` consecutive
824
+ * failures the entry is dropped and the registration is left to the next
825
+ * restart, which clears it by construction.
826
+ */
827
+ async expireEphemeralPeers(now) {
828
+ for (const [alias, expiresAt] of [...this.ephemeralPeerExpiries]) {
829
+ if (expiresAt > now)
830
+ continue;
831
+ // Another owner is mid-removal; the entry is theirs to clear or restore.
832
+ if (this.routeCleanupAliases.has(alias))
833
+ continue;
834
+ const route = await this.store.inspectPrivateRoute(alias);
835
+ if (route === undefined || !this.store.isEphemeralRegistration(route.binding.registrationId)) {
836
+ this.ephemeralPeerExpiries.delete(alias);
837
+ this.ephemeralExpiryFailures.delete(alias);
838
+ continue;
839
+ }
840
+ this.routeCleanupAliases.add(alias);
841
+ try {
842
+ const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding });
843
+ if (result.removed) {
844
+ await this.finishSettlements(result.settlements);
845
+ this.forgetRoute(route);
846
+ await this.reconcileUnadvertisement(route);
847
+ }
848
+ // Not removed means the binding changed underneath: the alias belongs
849
+ // to someone else now, and this clock is spent either way.
850
+ this.ephemeralPeerExpiries.delete(alias);
851
+ this.ephemeralExpiryFailures.delete(alias);
852
+ }
853
+ catch (error) {
854
+ this.alert("EPHEMERAL_ROUTE_EXPIRY_FAILED", route, error);
855
+ const attempts = (this.ephemeralExpiryFailures.get(alias) ?? 0) + 1;
856
+ if (attempts >= EPHEMERAL_EXPIRY_MAX_ATTEMPTS) {
857
+ this.ephemeralPeerExpiries.delete(alias);
858
+ this.ephemeralExpiryFailures.delete(alias);
859
+ }
860
+ else {
861
+ this.ephemeralExpiryFailures.set(alias, attempts);
862
+ this.ephemeralPeerExpiries.set(alias, now + EPHEMERAL_EXPIRY_RETRY_MS);
863
+ }
864
+ }
865
+ finally {
866
+ this.routeCleanupAliases.delete(alias);
867
+ }
762
868
  }
763
869
  }
764
870
  async awaitPeer(params) {
@@ -775,7 +881,7 @@ export class GatewayService {
775
881
  const route = await this.assertPeer(params);
776
882
  const adapter = this.adapterFor(route.binding);
777
883
  if (adapter?.acknowledgeReceipt({ alias: route.alias, ...route.binding, receipt: params.receipt }) === "rejected") {
778
- throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
884
+ return { accepted: false, code: "not_found" };
779
885
  }
780
886
  }
781
887
  async unregisterCodex(params) {
@@ -785,33 +891,56 @@ export class GatewayService {
785
891
  route.binding.routeHandle !== params.threadId) {
786
892
  throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
787
893
  }
788
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
789
- }
790
- async removeCodexRegistration(alias) {
791
- const route = await this.store.inspectPrivateRoute(alias);
792
- if (route === undefined || route.binding.provider !== "codex" || route.registrationMode === "federated_peer") {
793
- throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The Codex registration is absent.");
794
- }
795
- await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
796
- }
797
- async selectClaude(params) {
798
- const selected = await this.resolveClaudeSelector(params.alias, true);
799
- if (selected === undefined) {
800
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude session is not currently selectable.");
801
- }
802
- const adapter = selected.adapter;
894
+ if (route.registrationMode === "federated_peer") {
895
+ throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node and cannot be unregistered here.");
896
+ }
897
+ await this.removeOwnedRoute(route);
898
+ }
899
+ /**
900
+ * The name this session may take. A name belongs to one route at a time, so
901
+ * when another Claude route already holds it and THAT route's session is
902
+ * still live, this session takes a disambiguated name rather than displacing
903
+ * a working session and cancelling its queued work. Twins that share a
904
+ * display name therefore both keep routes and both stay reachable by UUID;
905
+ * only a route whose session is actually gone is displaced.
906
+ */
907
+ claimableAlias(candidate, routes) {
908
+ const holder = routes.find((route) => route.alias === candidate.alias &&
909
+ route.binding.provider === "claude" &&
910
+ !(route.binding.hostId === candidate.adapter.identity.hostId &&
911
+ route.binding.routeHandle === candidate.routeHandle));
912
+ if (holder === undefined)
913
+ return candidate.alias;
914
+ const holderIsLive = [...this.candidates.values()].some((row) => row.adapter.identity.hostId === holder.binding.hostId &&
915
+ row.routeHandle === holder.binding.routeHandle);
916
+ return holderIsLive
917
+ ? disambiguatedAlias(candidate.alias, candidate.routeHandle)
918
+ : candidate.alias;
919
+ }
920
+ /**
921
+ * Installs (or brings up to date) the route of one discovered Claude
922
+ * session. The adapter pins the session's route handle, the workspace
923
+ * check keeps deliberately broad workspaces out, and the store reuses the
924
+ * registration of a session already bound under the same (host, UUID) so a
925
+ * re-anchored or renamed session keeps its identity and in-flight
926
+ * conversations. Any Claude route the install displaced is forgotten and
927
+ * released at the adapter; its work was settled `ENDPOINT_RETIRED` by the
928
+ * store — and only a route whose own session is gone is ever displaced.
929
+ */
930
+ async installClaudeRoute(candidate) {
931
+ const adapter = candidate.adapter;
803
932
  if (adapter.selectRoute === undefined) {
804
- throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot select routes.");
933
+ throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot install routes.");
805
934
  }
806
- await adapter.assertWorkspaceDisjoint?.(selected.routeHandle, this.store.rootDir);
807
- const chosen = await adapter.selectRoute({ alias: selected.alias, routeHandle: selected.routeHandle });
935
+ await adapter.assertWorkspaceDisjoint?.(candidate.routeHandle, this.store.rootDir);
936
+ const chosen = await adapter.selectRoute({ alias: candidate.alias, routeHandle: candidate.routeHandle });
808
937
  this.assertWritable();
809
938
  const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
810
- const existing = before.find((route) => route.binding.provider === "claude" &&
811
- route.binding.hostId === adapter.identity.hostId &&
939
+ const alias = this.claimableAlias(candidate, before);
940
+ const existing = before.find((route) => route.binding.hostId === adapter.identity.hostId &&
812
941
  route.binding.routeHandle === chosen.routeHandle);
813
- const result = await this.store.replaceClaudeSelection({
814
- alias: selected.alias,
942
+ const input = {
943
+ alias,
815
944
  binding: {
816
945
  provider: "claude",
817
946
  hostId: adapter.identity.hostId,
@@ -819,114 +948,163 @@ export class GatewayService {
819
948
  registrationId: existing?.binding.registrationId ?? registrationId(),
820
949
  },
821
950
  registrationMode: "selected_live_peer",
822
- });
951
+ };
952
+ this.assertRouteNotCleaning(alias);
953
+ const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
823
954
  await this.finishSettlements(result.settlements);
824
955
  const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
825
956
  for (const prior of before) {
826
- const retained = after.find((route) => route.alias === prior.alias);
827
- if (retained === undefined || !sameBinding(retained.binding, prior.binding)) {
828
- this.settleWatchesForAlias(prior.alias, "endpoint_retired", "operator");
957
+ const retained = after.find((route) => route.binding.registrationId === prior.binding.registrationId);
958
+ if (retained === undefined) {
959
+ // Displaced: the alias now names another session.
829
960
  this.forgetRoute(prior);
830
961
  void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
831
962
  .catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
832
963
  }
964
+ else if (retained.alias !== prior.alias) {
965
+ // Renamed: identity kept, the stale display-only observation goes and
966
+ // open conversations follow the session to its new name.
967
+ this.routeObservations.delete(prior.alias);
968
+ this.renameConversationCoordinates(prior.alias, retained.alias, prior.binding.registrationId);
969
+ }
833
970
  }
834
- const installed = (await this.store.inspectPrivateRoute(selected.alias));
971
+ const installed = (await this.store.inspectPrivateRoute(alias));
835
972
  this.observeRoute(installed);
836
- this.routeObservations.set(selected.alias, {
973
+ this.routeObservations.set(alias, {
837
974
  route: installed.binding,
838
975
  state: chosen.state,
839
976
  observedAt: this.now().toISOString(),
840
977
  });
841
- await this.recordActivity("selection", "claude_selected", [selected.alias], true);
842
- this.kick(selected.alias);
843
- }
844
- async unselectClaude(params) {
845
- const route = await this.resolveSelectedClaudeRoute(params.alias);
846
- if (route === undefined || route.registrationMode === "federated_peer")
847
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
848
- await this.removeOwnedRoute(route, "CLAUDE_ROUTE_NOT_FOUND", true);
849
- }
850
- async pair(params) {
851
- const endpoints = await this.resolvePairEndpoints(params, true);
852
- const aliases = endpoints.aliases;
853
- const ownerHost = aliases.map(aliasHost).sort()[0];
854
- if (this.config.hostId !== ownerHost)
855
- throw new ConsentOwnerError(ownerHost);
856
- this.assertWritable();
857
- const existed = await this.store.hasConsentEdge(endpoints);
858
- await this.store.addConsentEdge(endpoints);
859
- if (!existed)
860
- await this.recordActivity("pairing", "routes_paired", [...aliases], true);
861
- }
862
- async unpair(params) {
863
- const endpoints = await this.resolvePairEndpoints(params, false);
864
- const aliases = endpoints.aliases;
865
- const ownerHost = aliases.map(aliasHost).sort()[0];
866
- if (this.config.hostId !== ownerHost)
867
- throw new ConsentOwnerError(ownerHost);
868
- this.assertWritable();
869
- const existed = await this.store.hasConsentEdge(endpoints);
870
- const result = await this.store.removeConsentEdge(endpoints);
871
- await this.finishSettlements(result.settlements);
872
- for (const alias of aliases)
873
- this.settleWatchesForAlias(alias, "pair_removed", "operator");
874
- if (existed)
875
- await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
876
- for (const alias of aliases)
877
- this.kick(alias);
878
- }
879
- async resolvePairEndpoints(params, maySelect) {
880
- const raw = params.aliases;
881
- const aliases = [raw[0], raw[1]];
882
- if (maySelect && aliases.some((alias) => PUBLIC_ALIAS.test(alias)))
883
- await this.refreshClaudeDiscovery().catch(() => undefined);
884
- for (let index = 0; index < aliases.length; index += 1) {
885
- if (maySelect && this.collidingClaudeAliases.has(aliases[index]))
886
- throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
887
- if (PUBLIC_ALIAS.test(aliases[index]))
888
- continue;
889
- const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
890
- if (selected === undefined)
891
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
892
- if (maySelect)
893
- await this.selectClaude({ alias: aliases[index] });
894
- aliases[index] = selected.alias;
895
- }
896
- const left = await this.store.inspectPrivateRoute(aliases[0]);
897
- const right = await this.store.inspectPrivateRoute(aliases[1]);
898
- if (left === undefined || right === undefined) {
899
- throw new BridgeError("ROUTE_NOT_AVAILABLE", "The pair endpoint is absent.");
978
+ if (result.installed)
979
+ this.revision += 1;
980
+ this.kick(alias);
981
+ return installed;
982
+ }
983
+ /**
984
+ * Two first sends to the same never-installed session race: both read no
985
+ * route and both mint a registration, and the loser's write is refused
986
+ * because the identity is now bound. That is a race, not a conflict — the
987
+ * session is exactly the one the loser resolved — so re-read once and adopt
988
+ * the registration that won instead of failing an honest send.
989
+ */
990
+ async installWithConcurrentFirstSend(input, hostId, routeHandle) {
991
+ try {
992
+ return await this.store.installClaudeRoute(input);
993
+ }
994
+ catch (error) {
995
+ if (!(error instanceof BridgeError) || error.code !== "ROUTE_IDENTITY_ALREADY_REGISTERED")
996
+ throw error;
997
+ const bound = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
998
+ route.binding.hostId === hostId &&
999
+ route.binding.routeHandle === routeHandle);
1000
+ if (bound === undefined)
1001
+ throw error;
1002
+ return await this.store.installClaudeRoute({
1003
+ ...input,
1004
+ binding: { ...input.binding, registrationId: bound.binding.registrationId },
1005
+ });
900
1006
  }
901
- return {
902
- aliases,
903
- expectedRegistrationIds: [
904
- left.binding.registrationId,
905
- right.binding.registrationId,
906
- ],
907
- };
1007
+ }
1008
+ /**
1009
+ * Looks a Claude selector up without writing anything. Callers that may
1010
+ * still refuse — a `--from` alias that must match the sending session, a
1011
+ * native sender whose adapter-reported name must match discovery — resolve
1012
+ * here first, refuse on the read, and only then materialize.
1013
+ *
1014
+ * THE ALIAS-COLLISION FENCE lives here (emb-94, moved into the send path by
1015
+ * emb-104). Invariant: discovery is refreshed inside every send that
1016
+ * addresses a Claude session, never only on the timer; a NAME currently
1017
+ * shared by more than one live session is refused with a hard,
1018
+ * non-retryable `PEER_ALIAS_COLLISION` naming that alias, so the broker
1019
+ * never picks first and never installs a route under an ambiguous name;
1020
+ * collisions stay sticky while a scan is incomplete
1021
+ * (`collidingClaudeAliases` retention in refreshClaudeDiscovery); and
1022
+ * `availablePeers` keeps filtering colliders so `status` never lists an
1023
+ * unaddressable name.
1024
+ *
1025
+ * The fence is a fence on NAMES, not on sessions. A session UUID is
1026
+ * unambiguous by construction, so a UUID selector — an explicit
1027
+ * `send --to <uuid>`, an identity-pinned route addressed by its own handle,
1028
+ * or a sender resolved from its inherited socket — passes the fence even
1029
+ * while its display name collides. That is the emb-94 escape hatch: the
1030
+ * operator can always reach an ambiguously named session, and a collision on
1031
+ * a *sender's* name never silences that sender, whose identity was attested
1032
+ * rather than typed. In-flight conversations are likewise unaffected:
1033
+ * `reply` resolves by exact binding.
1034
+ */
1035
+ async lookUpClaudeRoute(selector, hostId) {
1036
+ await this.refreshClaudeDiscovery().catch(() => undefined);
1037
+ if (PUBLIC_ALIAS.test(selector) && this.collidingClaudeAliases.has(selector)) {
1038
+ throw new BridgeError("PEER_ALIAS_COLLISION", `The Claude alias ${selector} names more than one live session; rename one, or address the session by UUID.`);
1039
+ }
1040
+ const routed = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
1041
+ (hostId === undefined || route.binding.hostId === hostId) &&
1042
+ (route.alias === selector || route.binding.routeHandle === selector));
1043
+ const candidate = [...this.candidates.values()].find((row) => (hostId === undefined || row.adapter.identity.hostId === hostId) &&
1044
+ (row.routeHandle === selector || row.alias === selector));
1045
+ return { routed, candidate };
1046
+ }
1047
+ /**
1048
+ * The name discovery currently shows for a looked-up session, which a caller
1049
+ * compares against the alias it claimed before anything is written.
1050
+ */
1051
+ claimedAlias(resolution) {
1052
+ const alias = resolution.candidate?.alias ?? resolution.routed?.alias;
1053
+ if (alias === undefined) {
1054
+ throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
1055
+ }
1056
+ return alias;
1057
+ }
1058
+ /** Installs the looked-up session's route if it is not already current. */
1059
+ async materializeClaudeRoute(resolution) {
1060
+ const { routed, candidate } = resolution;
1061
+ if (candidate === undefined) {
1062
+ if (routed !== undefined)
1063
+ return routed;
1064
+ throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
1065
+ }
1066
+ if (routed !== undefined &&
1067
+ routed.binding.hostId === candidate.adapter.identity.hostId &&
1068
+ routed.binding.routeHandle === candidate.routeHandle &&
1069
+ routed.alias === this.claimableAlias(candidate, await this.store.listLogicalRoutes()))
1070
+ return routed;
1071
+ return await this.installClaudeRoute(candidate);
908
1072
  }
909
1073
  async assertThread(alias, threadId) {
910
1074
  const route = await this.store.inspectPrivateRoute(alias);
911
- if (route?.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
1075
+ // No registration at all is its own condition — the state every Codex
1076
+ // task is in after a private state reset — and is never dressed up as a
1077
+ // thread mismatch, which would send the operator hunting a wrong id.
1078
+ if (route === undefined)
1079
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route is not registered.");
1080
+ if (route.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
912
1081
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
913
1082
  }
914
1083
  return route;
915
1084
  }
1085
+ /**
1086
+ * Direction follows the inherited principal, not the route table: a Claude
1087
+ * session or its target may have no route yet, because routes install on
1088
+ * first send. A Codex task sends to Claude sessions and shell peers; a
1089
+ * Claude session sends to Codex tasks and shell peers (Claude-to-Claude is
1090
+ * native); a shell peer sends to either.
1091
+ */
916
1092
  async send(params) {
917
- const source = await this.store.inspectPrivateRoute(params.fromAlias);
918
- const directTarget = await this.store.inspectPrivateRoute(params.toAlias);
919
- const target = directTarget ?? await this.resolveSelectedClaudeRoute(params.toAlias);
920
- if (source?.binding.provider === "codex" && (target?.binding.provider === "claude" || target?.binding.provider === "peer") && "threadId" in params) {
921
- return this.sendToClaude(params);
922
- }
923
- if (source?.binding.provider === "claude" && (target?.binding.provider === "codex" || target?.binding.provider === "peer") && "replyAddress" in params) {
924
- return this.sendToCodex(params);
925
- }
926
- if (source?.binding.provider === "peer" && "peerToken" in params) {
927
- return target?.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
928
- }
929
- return { accepted: false, code: "route_mismatch" };
1093
+ if (params.conversationId !== undefined)
1094
+ return await this.replyToConversation(params);
1095
+ const target = await this.store.inspectPrivateRoute(params.toAlias);
1096
+ if ("replyAddress" in params && params.replyAddress !== undefined) {
1097
+ // A UUID names a Claude session as surely as a name does: recognize it
1098
+ // here, or a Claude-to-Claude send addressed by UUID falls through to the
1099
+ // Codex path and is answered as an unknown target.
1100
+ const claudeTarget = target?.binding.provider === "claude" ||
1101
+ (target === undefined && [...this.candidates.values()].some((row) => row.alias === params.toAlias || row.routeHandle === params.toAlias));
1102
+ return claudeTarget ? { accepted: false, code: "route_mismatch" } : this.sendToCodex(params);
1103
+ }
1104
+ if ("threadId" in params && params.threadId !== undefined) {
1105
+ return target?.binding.provider === "codex" ? { accepted: false, code: "route_mismatch" } : this.sendToClaude(params);
1106
+ }
1107
+ return target === undefined || target.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
930
1108
  }
931
1109
  async sendToClaude(params) {
932
1110
  try {
@@ -939,9 +1117,25 @@ export class GatewayService {
939
1117
  if (source === undefined)
940
1118
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
941
1119
  const direct = await this.store.inspectPrivateRoute(params.toAlias);
942
- const target = direct?.binding.provider === "peer" ? direct : await this.resolveSelectedClaudeRoute(params.toAlias);
943
- if (target === undefined)
944
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
1120
+ let target;
1121
+ if (direct?.binding.provider === "peer") {
1122
+ target = direct;
1123
+ }
1124
+ else {
1125
+ const seen = await this.lookUpClaudeRoute(params.toAlias);
1126
+ // Admission before materialization: a send that is already going to be
1127
+ // refused for its own reasons — too large, outside the deadline
1128
+ // window, rate limited, or into a full queue — must not install a
1129
+ // route or displace another session on its way to that refusal.
1130
+ await this.store.assertEnqueueAdmissible({
1131
+ sourceAlias: params.fromAlias,
1132
+ targetAlias: this.claimedAlias(seen),
1133
+ direction: directionId(source.binding.provider, "claude"),
1134
+ sourceRegistrationId: source.binding.registrationId,
1135
+ body: params.text,
1136
+ });
1137
+ target = await this.materializeClaudeRoute(seen);
1138
+ }
945
1139
  return await this.enqueueConversation({
946
1140
  sourceAlias: params.fromAlias,
947
1141
  targetAlias: target.alias,
@@ -949,13 +1143,10 @@ export class GatewayService {
949
1143
  expectsReply: params.expectsReply,
950
1144
  expectedSourceBinding: source.binding,
951
1145
  expectedTargetBinding: target.binding,
952
- ...(params.trackIdleMinutes === undefined
953
- ? {}
954
- : { trackIdleMinutes: params.trackIdleMinutes }),
955
1146
  });
956
1147
  }
957
1148
  catch (error) {
958
- return decisionFor(error, "peerToken" in params);
1149
+ return sendRefusal(error, "peerToken" in params);
959
1150
  }
960
1151
  }
961
1152
  async sendToCodex(params) {
@@ -969,13 +1160,18 @@ export class GatewayService {
969
1160
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
970
1161
  }
971
1162
  else {
1163
+ // The sending session's identity comes from its inherited socket, never
1164
+ // from --from. Look the session up read-only first: a send that is
1165
+ // about to be refused must not install, rename, or displace a route,
1166
+ // and must not settle another session's queued work.
972
1167
  const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
973
1168
  if (resolved === undefined)
974
1169
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
975
- source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
976
- route.alias === params.fromAlias && route.binding.routeHandle === resolved.routeHandle);
977
- if (source === undefined)
978
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The reply route is not selected.");
1170
+ const seen = await this.lookUpClaudeRoute(resolved.routeHandle, aliasHost(params.fromAlias));
1171
+ if (this.claimedAlias(seen) !== params.fromAlias) {
1172
+ throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The --from alias does not name the sending Claude session.");
1173
+ }
1174
+ source = await this.materializeClaudeRoute(seen);
979
1175
  }
980
1176
  const target = await this.store.inspectPrivateRoute(params.toAlias);
981
1177
  if (source === undefined || target === undefined) {
@@ -991,23 +1187,26 @@ export class GatewayService {
991
1187
  expectsReply: params.expectsReply,
992
1188
  expectedSourceBinding: source.binding,
993
1189
  expectedTargetBinding: target.binding,
994
- ...(params.trackIdleMinutes === undefined
995
- ? {}
996
- : { trackIdleMinutes: params.trackIdleMinutes }),
997
1190
  });
998
1191
  }
999
1192
  catch (error) {
1000
- return decisionFor(error, "peerToken" in params);
1193
+ return sendRefusal(error, "peerToken" in params);
1001
1194
  }
1002
1195
  }
1003
- async reply(params) {
1196
+ /**
1197
+ * A send addressed by conversation token: the caller must own one end, the
1198
+ * other end is the binding the conversation recorded, and the caller's
1199
+ * principal is the same attested one every send carries.
1200
+ */
1201
+ async replyToConversation(params) {
1004
1202
  try {
1005
1203
  this.assertWritable();
1006
1204
  const conversation = this.conversations.get(params.conversationId);
1007
1205
  if (conversation === undefined)
1008
1206
  throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
1009
- const callerBinding = await this.assertReplyCaller(conversation, params.caller);
1010
- const callerAlias = params.caller.alias;
1207
+ const caller = replyCaller(params);
1208
+ const callerBinding = await this.assertReplyCaller(conversation, caller);
1209
+ const callerAlias = caller.alias;
1011
1210
  const targetAlias = callerAlias === conversation.sourceAlias
1012
1211
  ? conversation.targetAlias
1013
1212
  : conversation.sourceAlias;
@@ -1017,27 +1216,9 @@ export class GatewayService {
1017
1216
  if (targetBinding === undefined) {
1018
1217
  throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
1019
1218
  }
1020
- if (params.caller.kind === "codex" &&
1021
- conversation.nativeTarget !== undefined &&
1022
- targetAlias === conversation.nativeTarget.alias) {
1023
- const enqueued = await this.enqueueNativeConversationReply({
1024
- conversation,
1025
- sourceAlias: callerAlias,
1026
- sourceBinding: callerBinding,
1027
- target: conversation.nativeTarget,
1028
- text: params.text,
1029
- exposeDeliveryToken: true,
1030
- });
1031
- if (enqueued.deliveryToken === undefined) {
1032
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1033
- }
1034
- return {
1035
- accepted: true,
1036
- code: "ok",
1037
- conversationId: conversation.id,
1038
- deliveryToken: enqueued.deliveryToken,
1039
- };
1040
- }
1219
+ // The target's route was installed when it first sent or was first
1220
+ // sent to; a reply never re-resolves it by alias. A retired binding is
1221
+ // a refusal at enqueue, never a silent retarget.
1041
1222
  return await this.enqueueConversation({
1042
1223
  sourceAlias: callerAlias,
1043
1224
  targetAlias,
@@ -1045,14 +1226,11 @@ export class GatewayService {
1045
1226
  expectsReply: true,
1046
1227
  expectedSourceBinding: callerBinding,
1047
1228
  expectedTargetBinding: targetBinding,
1048
- ...(params.trackIdleMinutes === undefined
1049
- ? {}
1050
- : { trackIdleMinutes: params.trackIdleMinutes }),
1051
1229
  existingConversation: conversation,
1052
1230
  });
1053
1231
  }
1054
1232
  catch (error) {
1055
- return decisionFor(error, params.caller.kind === "peer");
1233
+ return sendRefusal(error, params.peerToken !== undefined);
1056
1234
  }
1057
1235
  }
1058
1236
  async assertReplyCaller(conversation, caller) {
@@ -1071,7 +1249,7 @@ export class GatewayService {
1071
1249
  throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
1072
1250
  }
1073
1251
  if (caller.kind === "claude") {
1074
- if (caller.replyAddress === undefined || route.binding.provider !== "claude") {
1252
+ if (route.binding.provider !== "claude") {
1075
1253
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
1076
1254
  }
1077
1255
  const resolved = await this.claudeAdapter(route.binding.hostId)
@@ -1117,14 +1295,8 @@ export class GatewayService {
1117
1295
  ...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
1118
1296
  ...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
1119
1297
  expectsReply: input.expectsReply,
1120
- pair: true,
1121
1298
  nextSequence: 0,
1122
1299
  };
1123
- const previousWatch = this.progressWatches.get(conversation.id);
1124
- const previousWatchEvents = this.progressWatchEvents.length;
1125
- if (input.skipWatch !== true) {
1126
- await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
1127
- }
1128
1300
  const sequence = conversation.nextSequence++;
1129
1301
  let enqueued;
1130
1302
  try {
@@ -1145,12 +1317,10 @@ export class GatewayService {
1145
1317
  });
1146
1318
  }
1147
1319
  catch (error) {
1148
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1149
1320
  conversation.nextSequence -= 1;
1150
1321
  throw error;
1151
1322
  }
1152
1323
  if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
1153
- this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
1154
1324
  conversation.nextSequence -= 1;
1155
1325
  throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1156
1326
  }
@@ -1173,6 +1343,23 @@ export class GatewayService {
1173
1343
  deliveryToken: enqueued.deliveryToken,
1174
1344
  };
1175
1345
  }
1346
+ /**
1347
+ * A renamed Claude session is the same principal under a new display name,
1348
+ * so its open conversations follow it: the registration, not the alias, is
1349
+ * the identity, and a reply under the current name must still resolve.
1350
+ */
1351
+ renameConversationCoordinates(oldAlias, newAlias, registrationId) {
1352
+ for (const conversation of this.conversations.values()) {
1353
+ if (conversation.sourceAlias === oldAlias &&
1354
+ conversation.sourceBinding?.registrationId === registrationId) {
1355
+ conversation.sourceAlias = newAlias;
1356
+ }
1357
+ if (conversation.targetAlias === oldAlias &&
1358
+ conversation.targetBinding?.registrationId === registrationId) {
1359
+ conversation.targetAlias = newAlias;
1360
+ }
1361
+ }
1362
+ }
1176
1363
  rememberConversation(conversation) {
1177
1364
  this.conversations.delete(conversation.id);
1178
1365
  this.conversations.set(conversation.id, conversation);
@@ -1235,12 +1422,7 @@ export class GatewayService {
1235
1422
  if (!this.running || this.closing)
1236
1423
  return;
1237
1424
  const route = await this.store.inspectPrivateRoute(targetAlias);
1238
- const transient = [...this.messageContexts.values()].find((context) => this.conversations.get(context.conversationId)?.nativeTarget?.alias === targetAlias);
1239
- const transientTarget = transient === undefined
1240
- ? undefined
1241
- : this.conversations.get(transient.conversationId)?.nativeTarget;
1242
- const registration = preferredRegistrationId ?? route?.binding.registrationId ??
1243
- transientTarget?.binding.registrationId;
1425
+ const registration = preferredRegistrationId ?? route?.binding.registrationId;
1244
1426
  if (registration === undefined)
1245
1427
  return;
1246
1428
  this.startRunner(targetAlias, registration, "any");
@@ -1321,219 +1503,216 @@ export class GatewayService {
1321
1503
  const conversation = messageContext === undefined
1322
1504
  ? undefined
1323
1505
  : this.conversations.get(messageContext.conversationId);
1324
- const transientTarget = conversation?.nativeTarget?.alias === attempt.targetAlias
1325
- ? conversation.nativeTarget
1326
- : undefined;
1327
- const persistedTarget = await this.store.inspectPrivateRoute(attempt.targetAlias);
1328
- const target = transientTarget !== undefined
1329
- ? {
1330
- alias: transientTarget.alias,
1331
- binding: transientTarget.binding,
1332
- registrationMode: "selected_live_peer",
1333
- enabled: true,
1334
- }
1335
- : (persistedTarget === undefined
1336
- ? undefined
1337
- : persistedTarget);
1338
- const source = await this.store.inspectPrivateRoute(attempt.sourceAlias);
1506
+ // Resolve by registration, not by the alias captured at reserve: a
1507
+ // session renamed between reserve and dispatch is the same route and
1508
+ // must still be delivered to, under whatever name it carries now.
1509
+ const installed = await this.store.listLogicalRoutes();
1510
+ const byRegistration = (registration, alias) => installed.find((route) => route.binding.registrationId === registration) ??
1511
+ installed.find((route) => route.alias === alias);
1512
+ const target = byRegistration(attempt.targetRegistrationId, attempt.targetAlias);
1513
+ const source = byRegistration(attempt.sourceRegistrationId, attempt.sourceAlias);
1339
1514
  if (this.closing || !this.running) {
1340
1515
  await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
1341
1516
  return false;
1342
1517
  }
1343
1518
  if (target === undefined ||
1344
1519
  target.binding.registrationId !== attempt.targetRegistrationId ||
1345
- (attempt.sourceRegistrationId !== null && source?.binding.registrationId !== attempt.sourceRegistrationId)) {
1520
+ source?.binding.registrationId !== attempt.sourceRegistrationId) {
1346
1521
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
1347
1522
  return false;
1348
1523
  }
1349
- if (target.registrationMode === "federated_peer") {
1350
- const peer = this.peerClients.get(target.binding.hostId);
1351
- if (peer === undefined || source === undefined || source.registrationMode === "federated_peer") {
1352
- await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1353
- return false;
1354
- }
1355
- const sourceEndpoint = { alias: source.alias, provider: source.binding.provider,
1356
- host: source.binding.hostId, routeRef: peerRouteRef(source.binding.hostId, source.binding.registrationId) };
1357
- const targetEndpoint = { alias: target.alias, provider: target.binding.provider,
1358
- host: target.binding.hostId, routeRef: target.binding.routeHandle };
1359
- const params = {
1360
- originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
1361
- source: sourceEndpoint, target: targetEndpoint,
1362
- edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
1363
- edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
1364
- deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
1365
- body: attempt.body,
1366
- ...(attempt.steer === true ? { steer: true } : {}),
1367
- ...(attempt.conversationIdSuffix === undefined ? {} : { conversationCorrelation: attempt.conversationIdSuffix }),
1368
- };
1369
- let armed = false, authorizationUncertain = false, acceptanceObserved = false, result;
1370
- try {
1371
- const prepared = peer.prepareHandoff(params);
1372
- let authorized;
1524
+ return target.registrationMode === "federated_peer"
1525
+ ? this.runPeerAttempt(attempt, source, target, conversation)
1526
+ : this.runProviderAttempt(attempt, source, target, conversation, messageContext);
1527
+ }
1528
+ return false;
1529
+ }
1530
+ async runPeerAttempt(attempt, source, target, conversation) {
1531
+ const peer = this.peerClients.get(target.binding.hostId);
1532
+ if (peer === undefined || source.registrationMode === "federated_peer") {
1533
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1534
+ return false;
1535
+ }
1536
+ const sourceEndpoint = { alias: source.alias, provider: source.binding.provider,
1537
+ host: source.binding.hostId, routeRef: peerRouteRef(source.binding.hostId, source.binding.registrationId) };
1538
+ const targetEndpoint = { alias: target.alias, provider: target.binding.provider,
1539
+ host: target.binding.hostId, routeRef: target.binding.routeHandle };
1540
+ const params = {
1541
+ originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
1542
+ source: sourceEndpoint, target: targetEndpoint,
1543
+ deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
1544
+ body: attempt.body,
1545
+ ...(attempt.steer === true ? { steer: true } : {}),
1546
+ ...(attempt.conversationIdSuffix === undefined ? {} : { conversationCorrelation: attempt.conversationIdSuffix }),
1547
+ };
1548
+ let armed = false, authorizationUncertain = false, acceptanceObserved = false, result;
1549
+ try {
1550
+ const prepared = peer.prepareHandoff(params);
1551
+ let authorized;
1552
+ try {
1553
+ authorized = await this.store.authorizeMessage({ messageId: attempt.messageId,
1554
+ attemptId: attempt.attemptId, sourceRegistrationId: attempt.sourceRegistrationId,
1555
+ targetRegistrationId: attempt.targetRegistrationId,
1556
+ prepared: { kind: "peer_handoff", bodyBytes: prepared.bodyBytes,
1557
+ bodySha256: prepared.bodySha256, frameBytes: prepared.frameBytes, sha256: prepared.sha256 } });
1558
+ }
1559
+ catch (error) {
1560
+ authorizationUncertain = true;
1561
+ throw error;
1562
+ }
1563
+ if (authorized.status !== "authorized") {
1564
+ prepared.cancel();
1565
+ if (authorized.status === "terminal")
1566
+ await this.finishSettlement(authorized.settlement);
1567
+ this.activeAttempts.delete(attempt.messageId);
1568
+ return false;
1569
+ }
1570
+ armed = true;
1571
+ await prepared.perform();
1572
+ acceptanceObserved = true;
1573
+ const accepted = await this.store.acceptMessage({ messageId: attempt.messageId,
1574
+ attemptId: attempt.attemptId, lossOutcome: "unconfirmed" });
1575
+ if (accepted.status !== "accepted")
1576
+ throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The peer acceptance fence is stale.");
1577
+ result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
1578
+ }
1579
+ catch (error) {
1580
+ if (!armed && !authorizationUncertain) {
1581
+ peer.close();
1582
+ this.peerClients.delete(target.binding.hostId);
1583
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1584
+ return false;
1585
+ }
1586
+ result = armed && !authorizationUncertain && !acceptanceObserved &&
1587
+ error instanceof PeerRequestError && error.detail.code === -32000 && isPeerHandoffRefusal(error.detail.data)
1588
+ ? { state: "failed", safeErrorCode: error.detail.data.reason }
1589
+ : acceptanceObserved
1590
+ ? { state: "unconfirmed", safeErrorCode: "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" }
1591
+ : { state: "ambiguous", safeErrorCode: authorizationUncertain ? "WRITE_AUTHORIZATION_UNCERTAIN" : "PEER_HANDOFF_OUTCOME_UNKNOWN" };
1592
+ }
1593
+ const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, source.alias, target.alias);
1594
+ this.activeAttempts.delete(attempt.messageId);
1595
+ return requeued;
1596
+ }
1597
+ async runProviderAttempt(attempt, source, target, conversation, messageContext) {
1598
+ const adapter = this.adapterFor(target.binding);
1599
+ if (adapter === undefined) {
1600
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
1601
+ return false;
1602
+ }
1603
+ const active = this.activeAttempts.get(attempt.messageId);
1604
+ let authorizationUncertain = false;
1605
+ let armed = false;
1606
+ let accepted = false;
1607
+ const conversationId = messageContext?.conversationId ??
1608
+ conversationIdForSuffix(attempt.conversationIdSuffix);
1609
+ const parsed = parseDirection(attempt.direction);
1610
+ if (target.binding.provider === "claude" &&
1611
+ messageContext?.expectsReply === true &&
1612
+ ![...this.pendingClaudeReplies.values()].some((rows) => rows.some((row) => row.messageId === attempt.messageId)) &&
1613
+ [...this.pendingClaudeReplies.values()].reduce((count, rows) => count + rows.length, 0) >= MAX_PENDING_CLAUDE_REPLIES) {
1614
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "ROUTE_BUSY");
1615
+ return true;
1616
+ }
1617
+ let result;
1618
+ try {
1619
+ result = await adapter.dispatch({
1620
+ attemptId: attempt.attemptId,
1621
+ sourceAlias: source.alias,
1622
+ sourceProvider: parsed.sourceProvider,
1623
+ // The route's CURRENT alias, not the one captured at reserve. The
1624
+ // provider adapters compare this against the alias they hold for the
1625
+ // binding, so a session renamed between reserve and dispatch would
1626
+ // otherwise fail its own delivery over a stale display name.
1627
+ targetAlias: target.alias,
1628
+ conversationId,
1629
+ binding: target.binding,
1630
+ authorization: "selected_route",
1631
+ messageId: attempt.messageId,
1632
+ text: attempt.body,
1633
+ expectsReply: conversation?.expectsReply ?? true,
1634
+ deadlineAt: attempt.deadlineAt,
1635
+ ...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
1636
+ authorizeWrite: async (evidence) => {
1637
+ if (evidence.attemptId !== attempt.attemptId)
1638
+ return false;
1373
1639
  try {
1374
- authorized = await this.store.authorizeMessage({ messageId: attempt.messageId,
1375
- attemptId: attempt.attemptId, sourceRegistrationId: attempt.sourceRegistrationId,
1640
+ const authorized = await this.store.authorizeMessage({
1641
+ messageId: attempt.messageId,
1642
+ attemptId: attempt.attemptId,
1643
+ sourceRegistrationId: attempt.sourceRegistrationId,
1376
1644
  targetRegistrationId: attempt.targetRegistrationId,
1377
- prepared: { kind: "peer_handoff", bodyBytes: prepared.bodyBytes,
1378
- bodySha256: prepared.bodySha256, frameBytes: prepared.frameBytes, sha256: prepared.sha256 } });
1645
+ prepared: {
1646
+ kind: evidence.kind,
1647
+ bodyBytes: evidence.bodyBytes,
1648
+ bodySha256: evidence.bodySha256,
1649
+ frameBytes: evidence.frameBytes,
1650
+ sha256: evidence.sha256,
1651
+ },
1652
+ });
1653
+ if (authorized.status === "terminal") {
1654
+ await this.finishSettlement(authorized.settlement);
1655
+ }
1656
+ if (authorized.status === "authorized") {
1657
+ armed = true;
1658
+ if (target.binding.provider === "claude" &&
1659
+ messageContext?.expectsReply === true) {
1660
+ this.installPendingClaudeReply({
1661
+ messageId: attempt.messageId,
1662
+ conversationId,
1663
+ sourceAlias: source.alias,
1664
+ targetAlias: target.alias,
1665
+ sourceBinding: source.binding,
1666
+ targetBinding: target.binding,
1667
+ deadlineAt: attempt.deadlineAt,
1668
+ state: "armed",
1669
+ });
1670
+ }
1671
+ }
1672
+ return authorized.status === "authorized";
1379
1673
  }
1380
1674
  catch (error) {
1381
1675
  authorizationUncertain = true;
1382
1676
  throw error;
1383
1677
  }
1384
- if (authorized.status !== "authorized") {
1385
- prepared.cancel();
1386
- if (authorized.status === "terminal")
1387
- await this.finishSettlement(authorized.settlement);
1388
- this.activeAttempts.delete(attempt.messageId);
1389
- return false;
1678
+ },
1679
+ onAccepted: async (evidence) => {
1680
+ if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
1681
+ evidence.attemptId !== attempt.attemptId) {
1682
+ throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
1390
1683
  }
1391
- armed = true;
1392
- await prepared.perform();
1393
- acceptanceObserved = true;
1394
- const accepted = await this.store.acceptMessage({ messageId: attempt.messageId,
1395
- attemptId: attempt.attemptId, lossOutcome: "unconfirmed" });
1396
- if (accepted.status !== "accepted")
1397
- throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The peer acceptance fence is stale.");
1398
- result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
1399
- }
1400
- catch {
1401
- if (!armed && !authorizationUncertain) {
1402
- peer.close();
1403
- this.peerClients.delete(target.binding.hostId);
1404
- await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1405
- return false;
1684
+ const result = await this.store.acceptMessage({
1685
+ messageId: attempt.messageId,
1686
+ attemptId: attempt.attemptId,
1687
+ lossOutcome: attempt.steer === true ? "ambiguous" : "unconfirmed",
1688
+ });
1689
+ if (result.status !== "accepted") {
1690
+ throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The durable acceptance fence is stale.");
1406
1691
  }
1407
- result = acceptanceObserved
1408
- ? { state: "unconfirmed", safeErrorCode: "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" }
1409
- : { state: "ambiguous", safeErrorCode: authorizationUncertain ? "WRITE_AUTHORIZATION_UNCERTAIN" : "PEER_HANDOFF_OUTCOME_UNKNOWN" };
1410
- }
1411
- const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
1412
- this.activeAttempts.delete(attempt.messageId);
1413
- return requeued;
1414
- }
1415
- const adapter = this.adapterFor(target.binding);
1416
- if (adapter === undefined) {
1417
- await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
1418
- return false;
1419
- }
1420
- const active = this.activeAttempts.get(attempt.messageId);
1421
- let authorizationUncertain = false;
1422
- let armed = false;
1423
- let accepted = false;
1424
- const conversationId = messageContext?.conversationId ??
1425
- conversationIdForSuffix(attempt.conversationIdSuffix);
1426
- const parsed = parseDirection(attempt.direction);
1427
- if (target.binding.provider === "claude" &&
1428
- messageContext?.expectsReply === true &&
1429
- ![...this.pendingClaudeReplies.values()].some((rows) => rows.some((row) => row.messageId === attempt.messageId)) &&
1430
- [...this.pendingClaudeReplies.values()].reduce((count, rows) => count + rows.length, 0) >= MAX_PENDING_CLAUDE_REPLIES) {
1431
- await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "ROUTE_BUSY");
1432
- return true;
1433
- }
1434
- let result;
1435
- try {
1436
- result = await adapter.dispatch({
1437
- attemptId: attempt.attemptId,
1438
- sourceAlias: attempt.sourceAlias,
1439
- sourceProvider: parsed.sourceProvider,
1440
- targetAlias: attempt.targetAlias,
1441
- conversationId,
1442
- binding: target.binding,
1443
- authorization: transientTarget === undefined
1444
- ? "selected_route"
1445
- : "native_reply",
1446
- messageId: attempt.messageId,
1447
- text: attempt.body,
1448
- expectsReply: conversation?.expectsReply ?? true,
1449
- deadlineAt: attempt.deadlineAt,
1450
- ...(this.progressWatches.has(conversationId)
1451
- ? { progressWatchActive: true }
1452
- : {}),
1453
- ...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
1454
- authorizeWrite: async (evidence) => {
1455
- if (evidence.attemptId !== attempt.attemptId)
1456
- return false;
1457
- try {
1458
- const authorized = await this.store.authorizeMessage({
1459
- messageId: attempt.messageId,
1460
- attemptId: attempt.attemptId,
1461
- sourceRegistrationId: attempt.sourceRegistrationId,
1462
- targetRegistrationId: attempt.targetRegistrationId,
1463
- prepared: {
1464
- kind: evidence.kind,
1465
- bodyBytes: evidence.bodyBytes,
1466
- bodySha256: evidence.bodySha256,
1467
- frameBytes: evidence.frameBytes,
1468
- sha256: evidence.sha256,
1469
- },
1470
- });
1471
- if (authorized.status === "terminal") {
1472
- await this.finishSettlement(authorized.settlement);
1473
- }
1474
- if (authorized.status === "authorized") {
1475
- armed = true;
1476
- if (target.binding.provider === "claude" &&
1477
- messageContext?.expectsReply === true &&
1478
- source !== undefined) {
1479
- this.installPendingClaudeReply({
1480
- messageId: attempt.messageId,
1481
- conversationId,
1482
- sourceAlias: attempt.sourceAlias,
1483
- targetAlias: attempt.targetAlias,
1484
- sourceBinding: source.binding,
1485
- targetBinding: target.binding,
1486
- deadlineAt: attempt.deadlineAt,
1487
- state: "armed",
1488
- });
1489
- }
1490
- }
1491
- return authorized.status === "authorized";
1492
- }
1493
- catch (error) {
1494
- authorizationUncertain = true;
1495
- throw error;
1496
- }
1497
- },
1498
- onAccepted: async (evidence) => {
1499
- if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
1500
- evidence.attemptId !== attempt.attemptId) {
1501
- throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
1502
- }
1503
- const result = await this.store.acceptMessage({
1504
- messageId: attempt.messageId,
1505
- attemptId: attempt.attemptId,
1506
- lossOutcome: attempt.steer === true ? "ambiguous" : "unconfirmed",
1507
- });
1508
- if (result.status !== "accepted") {
1509
- throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The durable acceptance fence is stale.");
1510
- }
1511
- accepted = true;
1512
- },
1513
- });
1514
- }
1515
- catch {
1516
- result = {
1517
- state: accepted
1518
- ? attempt.steer === true
1519
- ? "ambiguous"
1520
- : "unconfirmed"
1521
- : armed || authorizationUncertain
1522
- ? "ambiguous"
1523
- : "failed",
1524
- safeErrorCode: accepted
1525
- ? "DELIVERY_UNCONFIRMED"
1526
- : authorizationUncertain || armed
1527
- ? "WRITE_AUTHORIZATION_UNCERTAIN"
1528
- : "PROVIDER_DISPATCH_FAILED",
1529
- };
1530
- }
1531
- const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
1532
- if (this.activeAttempts.get(attempt.messageId) === active)
1533
- this.activeAttempts.delete(attempt.messageId);
1534
- return requeued;
1692
+ accepted = true;
1693
+ },
1694
+ });
1535
1695
  }
1536
- return false;
1696
+ catch {
1697
+ result = {
1698
+ state: accepted
1699
+ ? attempt.steer === true
1700
+ ? "ambiguous"
1701
+ : "unconfirmed"
1702
+ : armed || authorizationUncertain
1703
+ ? "ambiguous"
1704
+ : "failed",
1705
+ safeErrorCode: accepted
1706
+ ? "DELIVERY_UNCONFIRMED"
1707
+ : authorizationUncertain || armed
1708
+ ? "WRITE_AUTHORIZATION_UNCERTAIN"
1709
+ : "PROVIDER_DISPATCH_FAILED",
1710
+ };
1711
+ }
1712
+ const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, source.alias, target.alias);
1713
+ if (this.activeAttempts.get(attempt.messageId) === active)
1714
+ this.activeAttempts.delete(attempt.messageId);
1715
+ return requeued;
1537
1716
  }
1538
1717
  async settleAttemptForShutdown(messageId, attemptId) {
1539
1718
  const result = await this.store.settleAttemptForShutdown({ messageId, attemptId });
@@ -1659,7 +1838,6 @@ export class GatewayService {
1659
1838
  }
1660
1839
  }
1661
1840
  this.revision += 1;
1662
- void this.publish().catch(() => undefined);
1663
1841
  }
1664
1842
  async onClaudeMessage(event) {
1665
1843
  const target = await this.store.inspectPrivateRoute(event.targetAlias);
@@ -1668,21 +1846,23 @@ export class GatewayService {
1668
1846
  !(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
1669
1847
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
1670
1848
  }
1671
- const selected = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
1672
- route.alias === event.sourceAlias &&
1673
- route.binding.routeHandle === event.endpoint.routeHandle &&
1674
- route.binding.hostId === event.endpoint.hostId);
1675
- const sourceBinding = selected?.binding ?? {
1676
- provider: "claude",
1677
- hostId: event.endpoint.hostId,
1678
- routeHandle: event.endpoint.routeHandle,
1679
- registrationId: `native_${bodyHash(`${event.endpoint.routeHandle}\0${event.sourceAlias}`).slice(0, 32)}`,
1680
- };
1849
+ // A native SendMessage is this session's send: its route installs here,
1850
+ // from the exact identity the adapter attested, so the Codex task can
1851
+ // reply through the ordinary path. The adapter's name for the sender must
1852
+ // be the name discovery shows for that UUID; the check runs on a read-only
1853
+ // lookup so a refusal writes nothing.
1854
+ this.assertWritable();
1855
+ const seen = await this.lookUpClaudeRoute(event.endpoint.routeHandle, event.endpoint.hostId);
1856
+ if (this.claimedAlias(seen) !== event.sourceAlias) {
1857
+ throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The native sender's name does not match its discovered session.");
1858
+ }
1859
+ const source = await this.materializeClaudeRoute(seen);
1860
+ const sourceBinding = source.binding;
1681
1861
  const conversationId = createGatewayConversationId();
1682
1862
  const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
1683
1863
  this.assertWritable();
1684
1864
  const enqueued = await this.store.enqueueNativeIngress({
1685
- source: { alias: event.sourceAlias, binding: sourceBinding },
1865
+ source: { alias: source.alias, binding: sourceBinding },
1686
1866
  targetAlias: event.targetAlias,
1687
1867
  expectedTargetRegistrationId: target.binding.registrationId,
1688
1868
  body: event.text,
@@ -1695,13 +1875,11 @@ export class GatewayService {
1695
1875
  }
1696
1876
  const conversation = {
1697
1877
  id: conversationId,
1698
- sourceAlias: event.sourceAlias,
1878
+ sourceAlias: source.alias,
1699
1879
  targetAlias: event.targetAlias,
1700
1880
  sourceBinding,
1701
1881
  targetBinding: target.binding,
1702
1882
  expectsReply: true,
1703
- ...(enqueued.pair === true ? { pair: true } : {}),
1704
- nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
1705
1883
  nextSequence: 1,
1706
1884
  };
1707
1885
  this.rememberConversation(conversation);
@@ -1806,65 +1984,21 @@ export class GatewayService {
1806
1984
  const targetBinding = targetAlias === conversation.targetAlias
1807
1985
  ? conversation.targetBinding
1808
1986
  : conversation.sourceBinding;
1809
- if (conversation.nativeTarget === undefined ||
1810
- sourceAlias !== conversation.nativeTarget.alias) {
1811
- if (sourceBinding === undefined || targetBinding === undefined)
1812
- return;
1813
- const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1814
- if (currentSource === undefined ||
1815
- !sameBinding(currentSource.binding, sourceBinding))
1816
- return;
1817
- await this.enqueueConversation({
1818
- sourceAlias: targetAlias,
1819
- targetAlias: sourceAlias,
1820
- text,
1821
- expectsReply: true,
1822
- expectedSourceBinding: targetBinding,
1823
- expectedTargetBinding: sourceBinding,
1824
- existingConversation: conversation,
1825
- });
1987
+ if (sourceBinding === undefined || targetBinding === undefined)
1826
1988
  return;
1827
- }
1828
- if (targetBinding === undefined)
1989
+ const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
1990
+ if (currentSource === undefined ||
1991
+ !sameBinding(currentSource.binding, sourceBinding))
1829
1992
  return;
1830
- await this.enqueueNativeConversationReply({
1831
- conversation,
1993
+ await this.enqueueConversation({
1832
1994
  sourceAlias: targetAlias,
1833
- sourceBinding: targetBinding,
1834
- target: conversation.nativeTarget,
1995
+ targetAlias: sourceAlias,
1835
1996
  text,
1836
- });
1837
- }
1838
- async enqueueNativeConversationReply(input) {
1839
- const sequence = input.conversation.nextSequence++;
1840
- let enqueued;
1841
- try {
1842
- enqueued = await this.store.enqueueNativeReply({
1843
- sourceAlias: input.sourceAlias,
1844
- expectedSourceRegistrationId: input.sourceBinding.registrationId,
1845
- target: input.target,
1846
- body: input.text,
1847
- dedupeKey: `${input.conversation.id}:${sequence}`,
1848
- conversationIdSuffix: input.conversation.id.slice(-8),
1849
- ...(input.conversation.pair === true ? { pair: true } : {}),
1850
- ...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
1851
- });
1852
- }
1853
- catch (error) {
1854
- input.conversation.nextSequence -= 1;
1855
- throw error;
1856
- }
1857
- if (!enqueued.accepted || enqueued.messageId === undefined) {
1858
- input.conversation.nextSequence -= 1;
1859
- throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
1860
- }
1861
- this.messageContexts.set(enqueued.messageId, {
1862
- conversationId: input.conversation.id,
1863
1997
  expectsReply: true,
1998
+ expectedSourceBinding: targetBinding,
1999
+ expectedTargetBinding: sourceBinding,
2000
+ existingConversation: conversation,
1864
2001
  });
1865
- this.kick(input.target.alias, input.target.binding.registrationId);
1866
- this.scheduleWake();
1867
- return enqueued;
1868
2002
  }
1869
2003
  installPendingClaudeReply(row) {
1870
2004
  const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
@@ -1931,173 +2065,6 @@ export class GatewayService {
1931
2065
  existingConversation: conversation,
1932
2066
  });
1933
2067
  }
1934
- async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
1935
- const existing = this.progressWatches.get(conversation.id);
1936
- if (this.config.trackingEnabled === false) {
1937
- if (existing !== undefined) {
1938
- this.settleProgressWatch(existing, "gateway", "tracking_disabled");
1939
- }
1940
- return;
1941
- }
1942
- if (text.startsWith("DONE:")) {
1943
- if (existing !== undefined) {
1944
- const actor = actorAlias === existing.ownerAlias
1945
- ? "owner"
1946
- : actorAlias === existing.workerAlias
1947
- ? "worker"
1948
- : undefined;
1949
- if (actor !== undefined)
1950
- this.settleProgressWatch(existing, actor, "done");
1951
- }
1952
- return;
1953
- }
1954
- const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
1955
- if (existing !== undefined && !explicit) {
1956
- this.progressWatches.set(conversation.id, {
1957
- ...existing,
1958
- ...recordProgressWatchActivity(existing, this.now().getTime()),
1959
- });
1960
- return;
1961
- }
1962
- if (!explicit)
1963
- return;
1964
- if (existing !== undefined && actorAlias !== existing.ownerAlias) {
1965
- throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
1966
- }
1967
- const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
1968
- if (existing === undefined && this.progressWatches.size >= capacity) {
1969
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
1970
- }
1971
- const owner = await this.store.inspectPrivateRoute(actorAlias);
1972
- const workerAlias = actorAlias === conversation.sourceAlias
1973
- ? conversation.targetAlias
1974
- : conversation.sourceAlias;
1975
- const worker = await this.store.inspectPrivateRoute(workerAlias);
1976
- if (owner === undefined || worker === undefined) {
1977
- throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
1978
- }
1979
- const idleMs = trackIdleMinutes === undefined
1980
- ? PROGRESS_WATCH_DEFAULT_IDLE_MS
1981
- : trackIdleMinutes * 60_000;
1982
- const watch = createProgressWatch({
1983
- conversationId: conversation.id,
1984
- ownerAlias: actorAlias,
1985
- workerAlias,
1986
- idleMs,
1987
- at: this.now().getTime(),
1988
- });
1989
- this.progressWatches.set(conversation.id, {
1990
- ...watch,
1991
- ownerRegistrationId: owner.binding.registrationId,
1992
- workerRegistrationId: worker.binding.registrationId,
1993
- });
1994
- this.recordProgressWatchEvent({
1995
- watch,
1996
- kind: existing === undefined ? "opened" : "replaced",
1997
- actor: "owner",
1998
- });
1999
- }
2000
- restoreProgressWatch(conversationId, previous, eventLength) {
2001
- if (previous === undefined)
2002
- this.progressWatches.delete(conversationId);
2003
- else
2004
- this.progressWatches.set(conversationId, previous);
2005
- this.progressWatchEvents.length = eventLength;
2006
- }
2007
- untrack(conversationId) {
2008
- const watch = this.progressWatches.get(conversationId);
2009
- if (watch === undefined) {
2010
- throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
2011
- }
2012
- this.settleProgressWatch(watch, "operator", "untracked");
2013
- }
2014
- settleWatchesForAlias(alias, reason, actor) {
2015
- for (const watch of [...this.progressWatches.values()]) {
2016
- if (watch.ownerAlias === alias || watch.workerAlias === alias) {
2017
- this.settleProgressWatch(watch, actor, reason);
2018
- }
2019
- }
2020
- }
2021
- settleProgressWatch(watch, actor, reason) {
2022
- if (!this.progressWatches.delete(watch.conversationId))
2023
- return;
2024
- this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
2025
- }
2026
- recordProgressWatchEvent(input) {
2027
- this.progressWatchEvents.push({
2028
- sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
2029
- timestamp: this.now().toISOString(),
2030
- conversationIdSuffix: input.watch.conversationId.slice(-8),
2031
- ownerAlias: input.watch.ownerAlias,
2032
- workerAlias: input.watch.workerAlias,
2033
- kind: input.kind,
2034
- actor: input.actor,
2035
- ...(input.reason === undefined ? {} : { reason: input.reason }),
2036
- });
2037
- while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
2038
- this.progressWatchEvents.shift();
2039
- }
2040
- }
2041
- async processProgressWatches() {
2042
- const at = this.now().getTime();
2043
- for (const watch of [...this.progressWatches.values()]) {
2044
- const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
2045
- const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
2046
- if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
2047
- worker?.binding.registrationId !== watch.workerRegistrationId) {
2048
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2049
- continue;
2050
- }
2051
- const ownerObservation = this.routeObservations.get(watch.ownerAlias);
2052
- const workerObservation = this.routeObservations.get(watch.workerAlias);
2053
- const due = inspectProgressWatchDue(watch, {
2054
- at,
2055
- bothIdle: ownerObservation?.state === "idle" &&
2056
- workerObservation?.state === "idle" &&
2057
- this.routeObservationStillCurrent(ownerObservation) &&
2058
- this.routeObservationStillCurrent(workerObservation),
2059
- });
2060
- if (due.kind === "not_due")
2061
- continue;
2062
- if (due.kind === "rescheduled") {
2063
- this.progressWatches.set(watch.conversationId, {
2064
- ...watch,
2065
- ...due.watch,
2066
- });
2067
- continue;
2068
- }
2069
- if (due.kind === "settled") {
2070
- this.settleProgressWatch(watch, "gateway", "idle_timeout");
2071
- continue;
2072
- }
2073
- const conversation = this.conversations.get(watch.conversationId);
2074
- if (conversation === undefined) {
2075
- this.settleProgressWatch(watch, "gateway", "endpoint_retired");
2076
- continue;
2077
- }
2078
- const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
2079
- try {
2080
- await this.enqueueConversation({
2081
- sourceAlias: watch.ownerAlias,
2082
- targetAlias: watch.workerAlias,
2083
- text,
2084
- expectsReply: false,
2085
- existingConversation: conversation,
2086
- skipWatch: true,
2087
- });
2088
- this.progressWatches.set(watch.conversationId, {
2089
- ...watch,
2090
- ...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
2091
- });
2092
- }
2093
- catch {
2094
- this.progressWatches.set(watch.conversationId, {
2095
- ...watch,
2096
- ...deferProgressWatchNudge(watch, at),
2097
- });
2098
- }
2099
- }
2100
- }
2101
2068
  async finishSettlements(settlements) {
2102
2069
  await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
2103
2070
  }
@@ -2140,13 +2107,13 @@ export class GatewayService {
2140
2107
  const now = this.now().getTime();
2141
2108
  const due = [
2142
2109
  this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
2143
- this.nextDoctorAt || now + CODEX_DOCTOR_INTERVAL_MS,
2110
+ this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
2144
2111
  ...(this.config.peerNodes.length > 0
2145
2112
  ? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
2146
2113
  : []),
2114
+ ...this.ephemeralPeerExpiries.values(),
2147
2115
  ...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
2148
2116
  ...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
2149
- ...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
2150
2117
  ].filter(Number.isFinite);
2151
2118
  const delay = Math.max(0, Math.min(...due) - now);
2152
2119
  this.wakeTimer = this.timers.setTimeout(() => {
@@ -2161,14 +2128,14 @@ export class GatewayService {
2161
2128
  const now = this.now();
2162
2129
  await this.finishSettlements(await this.store.expireDueMessages(now));
2163
2130
  this.pruneExpiredPendingClaudeReplies(now.getTime());
2164
- await this.processProgressWatches();
2131
+ await this.expireEphemeralPeers(now.getTime());
2165
2132
  if (now.getTime() >= this.nextDiscoveryAt) {
2166
2133
  await this.refreshClaudeDiscovery().catch(() => undefined);
2167
2134
  this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
2168
2135
  }
2169
- if (now.getTime() >= this.nextDoctorAt) {
2170
- await this.refreshCodexDoctor().catch(() => undefined);
2171
- this.nextDoctorAt = now.getTime() + CODEX_DOCTOR_INTERVAL_MS;
2136
+ if (now.getTime() >= this.nextManagedCodexCheckAt) {
2137
+ await this.refreshManagedCodexSocket().catch(() => undefined);
2138
+ this.nextManagedCodexCheckAt = now.getTime() + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
2172
2139
  }
2173
2140
  if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
2174
2141
  const operation = this.refreshPeers();
@@ -2185,7 +2152,6 @@ export class GatewayService {
2185
2152
  return;
2186
2153
  for (const target of await this.store.inspectDispatchableTargets())
2187
2154
  this.kick(target);
2188
- await this.publish();
2189
2155
  this.scheduleWake();
2190
2156
  }
2191
2157
  async refreshPeers() {
@@ -2249,18 +2215,23 @@ export class GatewayService {
2249
2215
  if (viewChanged)
2250
2216
  this.revision += 1;
2251
2217
  }
2252
- catch {
2218
+ catch (error) {
2253
2219
  this.peerClients.get(peerHost)?.close();
2254
2220
  this.peerClients.delete(peerHost);
2255
2221
  if (!this.running || this.closing)
2256
2222
  return;
2223
+ // A wire-version disagreement is not a tunnel fault: name it so an operator
2224
+ // upgrades the lagging node instead of debugging SSH.
2225
+ if (error instanceof PeerConnectionLostError && error.failureClass === "protocol")
2226
+ failureCode = "PEER_PROTOCOL_MISMATCH";
2257
2227
  this.recordPeerFailure(peerHost, failureCode);
2258
2228
  const observedAt = this.now().toISOString();
2229
+ const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
2259
2230
  for (const route of await this.store.inspectPrivateRoutes())
2260
2231
  if (route.registrationMode === "federated_peer" &&
2261
2232
  route.binding.hostId === peerHost)
2262
2233
  this.peerRouteViews.set(route.alias, { route: route.binding,
2263
- state: "unobserved", observedAt, safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
2234
+ state: "unobserved", observedAt, safeErrorCode: mirrorCode });
2264
2235
  }
2265
2236
  }));
2266
2237
  }
@@ -2323,34 +2294,19 @@ export class GatewayService {
2323
2294
  for (const alias of retainedCollisions)
2324
2295
  this.collidingClaudeAliases.add(alias);
2325
2296
  }
2326
- async refreshCodexDoctor() {
2327
- if (this.codexDoctor === undefined)
2297
+ async refreshManagedCodexSocket() {
2298
+ if (this.managedCodexSocketHeld === undefined)
2328
2299
  return;
2329
- this.codexDoctorResult = await this.codexDoctor();
2300
+ this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
2330
2301
  }
2331
2302
  assertWritable() {
2332
2303
  if (this.closing || !this.running) {
2333
2304
  throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
2334
2305
  }
2335
2306
  }
2336
- async resolveClaudeSelector(selector, refresh) {
2337
- if (refresh)
2338
- await this.refreshClaudeDiscovery().catch(() => undefined);
2339
- if (this.collidingClaudeAliases.has(selector))
2340
- throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
2341
- return [...this.candidates.values()].find((candidate) => candidate.routeHandle === selector ||
2342
- candidate.alias === selector);
2343
- }
2344
- async resolveSelectedClaudeRoute(selector) {
2345
- return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
2346
- (route.alias === selector || route.binding.routeHandle === selector));
2347
- }
2348
2307
  routeObservationStillCurrent(event) {
2349
2308
  return this.connectors.has(connectorKey(event.route));
2350
2309
  }
2351
- async publish() {
2352
- await this.publishDashboard(this.store.rootDir, await this.snapshot());
2353
- }
2354
2310
  alert(code, route, error) {
2355
2311
  const errorCode = error instanceof BridgeError ? error.code : undefined;
2356
2312
  this.runtimeAlerts.push({
@@ -2369,7 +2325,7 @@ export class GatewayService {
2369
2325
  recordPeerFailure(host, code) {
2370
2326
  for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
2371
2327
  const alert = this.runtimeAlerts[index];
2372
- if (alert?.host === host && (alert.code === "PEER_DIAL_FAILED" || alert.code === "PEER_TUNNEL_UNAVAILABLE"))
2328
+ if (alert?.host === host && PEER_FAILURE_CODES.includes(alert.code))
2373
2329
  this.runtimeAlerts.splice(index, 1);
2374
2330
  }
2375
2331
  if (code !== undefined)