agent-embassy 1.8.2 → 1.9.2

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 (76) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +23 -3
  3. package/README.zh-CN.md +23 -3
  4. package/SECURITY.md +30 -6
  5. package/dist/src/gateway/cli-copy.en.d.ts +1 -1
  6. package/dist/src/gateway/cli-copy.en.js +8 -0
  7. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  8. package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
  9. package/dist/src/gateway/cli-copy.zh-CN.js +8 -0
  10. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  11. package/dist/src/gateway/cli.d.ts +8 -3
  12. package/dist/src/gateway/cli.js +191 -19
  13. package/dist/src/gateway/cli.js.map +1 -1
  14. package/dist/src/gateway/config.d.ts +2 -0
  15. package/dist/src/gateway/config.js +1 -1
  16. package/dist/src/gateway/config.js.map +1 -1
  17. package/dist/src/gateway/control.d.ts +79 -25
  18. package/dist/src/gateway/control.js +98 -18
  19. package/dist/src/gateway/control.js.map +1 -1
  20. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  21. package/dist/src/gateway/dashboard-copy.en.d.ts +2 -0
  22. package/dist/src/gateway/dashboard-copy.en.js +2 -0
  23. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  24. package/dist/src/gateway/dashboard-copy.js +2 -0
  25. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  26. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +2 -0
  27. package/dist/src/gateway/dashboard-copy.zh-CN.js +2 -0
  28. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  29. package/dist/src/gateway/dashboard-model.d.ts +3 -0
  30. package/dist/src/gateway/dashboard-model.js +3 -0
  31. package/dist/src/gateway/dashboard-model.js.map +1 -1
  32. package/dist/src/gateway/federation-nodes.d.ts +18 -0
  33. package/dist/src/gateway/federation-nodes.js +142 -0
  34. package/dist/src/gateway/federation-nodes.js.map +1 -0
  35. package/dist/src/gateway/live-dashboard-app/app.js +5 -4
  36. package/dist/src/gateway/peer-client.d.ts +57 -0
  37. package/dist/src/gateway/peer-client.js +208 -0
  38. package/dist/src/gateway/peer-client.js.map +1 -0
  39. package/dist/src/gateway/peer-mailbox.d.ts +78 -0
  40. package/dist/src/gateway/peer-mailbox.js +168 -0
  41. package/dist/src/gateway/peer-mailbox.js.map +1 -0
  42. package/dist/src/gateway/peer-protocol.d.ts +107 -0
  43. package/dist/src/gateway/peer-protocol.js +72 -0
  44. package/dist/src/gateway/peer-protocol.js.map +1 -0
  45. package/dist/src/gateway/peer-stdio.d.ts +23 -0
  46. package/dist/src/gateway/peer-stdio.js +100 -0
  47. package/dist/src/gateway/peer-stdio.js.map +1 -0
  48. package/dist/src/gateway/provenance-envelope.js +6 -1
  49. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  50. package/dist/src/gateway/providers.d.ts +5 -2
  51. package/dist/src/gateway/providers.js +10 -7
  52. package/dist/src/gateway/providers.js.map +1 -1
  53. package/dist/src/gateway/server.d.ts +4 -1
  54. package/dist/src/gateway/server.js +21 -8
  55. package/dist/src/gateway/server.js.map +1 -1
  56. package/dist/src/gateway/service.d.ts +19 -0
  57. package/dist/src/gateway/service.js +437 -37
  58. package/dist/src/gateway/service.js.map +1 -1
  59. package/dist/src/gateway/state-v2-to-v3.js +1 -0
  60. package/dist/src/gateway/state-v2-to-v3.js.map +1 -1
  61. package/dist/src/gateway/store.d.ts +9 -1
  62. package/dist/src/gateway/store.js +183 -43
  63. package/dist/src/gateway/store.js.map +1 -1
  64. package/dist/src/gateway/types.d.ts +7 -6
  65. package/dist/src/gateway/types.js +5 -10
  66. package/dist/src/gateway/types.js.map +1 -1
  67. package/docs/CONFIGURATION.md +23 -8
  68. package/docs/CONFIGURATION.zh-CN.md +12 -4
  69. package/docs/DASHBOARD.md +2 -2
  70. package/docs/DASHBOARD.zh-CN.md +2 -2
  71. package/docs/DELIVERY.md +3 -1
  72. package/docs/DELIVERY.zh-CN.md +3 -1
  73. package/docs/GATEWAY-ARCHITECTURE.md +6 -4
  74. package/package.json +1 -1
  75. package/skills/embassy-peer/SKILL.md +32 -4
  76. package/skills/embassy-peer/agents/openai.yaml +2 -2
@@ -1,15 +1,19 @@
1
- import { createHash, randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { BridgeError } from "../errors.js";
3
3
  import { createGatewayConversationId, startGatewayControlServer, pairParamAliases, pairParamThreadAttestation, } from "./control.js";
4
4
  import { publishGatewayDashboard } from "./dashboard.js";
5
+ import { spawnPeerClient } from "./peer-client.js";
6
+ import { peerEdgeRef } from "./peer-protocol.js";
5
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";
6
8
  import { GatewayStore } from "./store.js";
7
9
  import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
8
10
  const SAFE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
9
11
  const PRIVATE_HANDLE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
10
12
  const PUBLIC_ALIAS = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
13
+ const PEER_TOKEN = /^peer_[A-Za-z0-9_-]{32}$/;
11
14
  const DISCOVERY_INTERVAL_MS = 30_000;
12
15
  const CODEX_DOCTOR_INTERVAL_MS = 30_000;
16
+ const PEER_REFRESH_INTERVAL_MS = 30_000;
13
17
  const CLEAN_RETRY_DELAY_MS = 500;
14
18
  const MAX_CONVERSATIONS = 1_024;
15
19
  const MAX_PENDING_CLAUDE_REPLIES = MAX_CONVERSATIONS;
@@ -24,6 +28,7 @@ const CLEAN_RETRY_CODES = new Set([
24
28
  "SPAWN_TIMEOUT",
25
29
  "TRANSPORT_CONNECT_FAILED",
26
30
  "REQUEST_TIMEOUT",
31
+ "PEER_NOT_AWAITING",
27
32
  ]);
28
33
  function registrationId() {
29
34
  return `reg_${randomBytes(18).toString("base64url")}`;
@@ -34,7 +39,16 @@ function aliasHost(alias) {
34
39
  function safeCode(value, fallback) {
35
40
  return value !== undefined && SAFE_CODE.test(value) ? value : fallback;
36
41
  }
37
- function decisionFor(error) {
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
+ function decisionFor(error, peerPrincipal = false) {
50
+ if (error instanceof ConsentOwnerError)
51
+ return { accepted: false, code: "conflict", ownerHost: error.ownerHost };
38
52
  if (!(error instanceof BridgeError))
39
53
  return { accepted: false, code: "rejected" };
40
54
  if (error.code.includes("NOT_FOUND") || error.code.includes("NOT_AVAILABLE")) {
@@ -49,7 +63,7 @@ function decisionFor(error) {
49
63
  if (error.code.includes("UNAVAILABLE") || error.code.includes("OFFLINE") || error.code.includes("UNOBSERVED")) {
50
64
  return { accepted: false, code: "unavailable" };
51
65
  }
52
- if (error.code.includes("MISMATCH") || error.code.includes("ADDRESS")) {
66
+ if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") || error.code.includes("ADDRESS")) {
53
67
  return { accepted: false, code: "route_mismatch" };
54
68
  }
55
69
  return { accepted: false, code: "rejected" };
@@ -81,6 +95,26 @@ function conversationIdForSuffix(suffix) {
81
95
  function bodyHash(body) {
82
96
  return createHash("sha256").update(body, "utf8").digest("hex");
83
97
  }
98
+ function peerCatalogAuthorityChanged(prior, current) {
99
+ const authority = (catalog) => JSON.stringify([
100
+ catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
101
+ [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
102
+ ]);
103
+ return prior === undefined || authority(prior) !== authority(current);
104
+ }
105
+ function peerCatalogViewChanged(prior, current) {
106
+ const view = (catalog) => JSON.stringify([
107
+ catalog.health,
108
+ catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
109
+ catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
110
+ [...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
111
+ catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
112
+ ]);
113
+ return prior === undefined || view(prior) !== view(current);
114
+ }
115
+ function peerHandle(uid, alias, token) {
116
+ return `peer:${createHash("sha256").update(`${uid}\0${alias}\0${token}`).digest("hex")}`;
117
+ }
84
118
  export class GatewayService {
85
119
  config;
86
120
  store;
@@ -90,6 +124,12 @@ export class GatewayService {
90
124
  timers;
91
125
  nativePeerCwd;
92
126
  codexDoctor;
127
+ spawnPeer;
128
+ peerClients = new Map();
129
+ peerCatalogs = new Map();
130
+ peerCleanupAliases = new Set();
131
+ /** View-only freshness; never consulted by routing or write authorization. */
132
+ peerRouteViews = new Map();
93
133
  connectors = new Map();
94
134
  routeObservations = new Map();
95
135
  candidates = new Map();
@@ -98,6 +138,7 @@ export class GatewayService {
98
138
  activeAttempts = new Map();
99
139
  reserveOperations = new Set();
100
140
  inboundOperations = new Set();
141
+ peerRefreshOperations = new Set();
101
142
  pendingClaudeReplies = new Map();
102
143
  nativeReceipts = new Map();
103
144
  progressWatches = new Map();
@@ -110,6 +151,7 @@ export class GatewayService {
110
151
  wakeTimer;
111
152
  nextDiscoveryAt = 0;
112
153
  nextDoctorAt = 0;
154
+ nextPeerRefreshAt = 0;
113
155
  codexDoctorResult;
114
156
  revision = 0;
115
157
  snapshotRevision = 0;
@@ -125,6 +167,7 @@ export class GatewayService {
125
167
  this.now = options.now ?? (() => new Date());
126
168
  this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
127
169
  this.codexDoctor = options.codexDoctor;
170
+ this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
128
171
  this.timers = options.timers ?? {
129
172
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
130
173
  clearTimeout: (timer) => clearTimeout(timer),
@@ -188,6 +231,7 @@ export class GatewayService {
188
231
  const now = this.now().getTime();
189
232
  this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
190
233
  this.nextDoctorAt = now + CODEX_DOCTOR_INTERVAL_MS;
234
+ this.nextPeerRefreshAt = (this.config.peerNodes?.length ?? 0) > 0 ? now : 0;
191
235
  for (const target of await this.store.inspectDispatchableTargets())
192
236
  this.kick(target);
193
237
  await this.publish();
@@ -239,6 +283,12 @@ export class GatewayService {
239
283
  const failures = [];
240
284
  if (control !== undefined)
241
285
  await control.close().catch((error) => failures.push(error));
286
+ for (const peer of this.peerClients.values())
287
+ peer.close();
288
+ this.peerClients.clear();
289
+ await Promise.all([...this.peerRefreshOperations]).catch((error) => failures.push(error));
290
+ this.peerCatalogs.clear();
291
+ this.peerRouteViews.clear();
242
292
  // Provider close aborts exact owned operations. It must never join a turn.
243
293
  await Promise.all(this.adapters.map(async (adapter) => {
244
294
  await adapter.close().catch((error) => failures.push(error));
@@ -252,7 +302,7 @@ export class GatewayService {
252
302
  }
253
303
  }
254
304
  handlers() {
255
- const decide = async (operation) => {
305
+ const decide = async (operation, peerPrincipal = false) => {
256
306
  try {
257
307
  this.assertWritable();
258
308
  await operation();
@@ -261,12 +311,27 @@ export class GatewayService {
261
311
  return { accepted: true, code: "ok" };
262
312
  }
263
313
  catch (error) {
264
- return decisionFor(error);
314
+ return decisionFor(error, peerPrincipal);
265
315
  }
266
316
  };
267
317
  return {
268
318
  health: () => ({ status: this.health(), revision: this.revision }),
269
319
  registerCodex: (params) => decide(async () => this.registerCodex(params)),
320
+ registerPeer: async (params) => {
321
+ try {
322
+ this.assertWritable();
323
+ const token = await this.registerPeer(params);
324
+ this.revision += 1;
325
+ await this.publish();
326
+ return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
327
+ }
328
+ catch (error) {
329
+ return decisionFor(error, true);
330
+ }
331
+ },
332
+ unregisterPeer: (params) => decide(async () => this.unregisterPeer(params), true),
333
+ awaitPeer: (params) => this.awaitPeer(params),
334
+ peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
270
335
  unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
271
336
  removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
272
337
  selectClaude: (params) => decide(async () => this.selectClaude(params)),
@@ -289,12 +354,81 @@ export class GatewayService {
289
354
  await this.publish();
290
355
  return { accepted: true, code: "ok", revision: this.revision };
291
356
  },
357
+ peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
358
+ peerHandoff: ({ peerHost, handoff }) => this.receivePeerHandoff(peerHost, handoff),
292
359
  };
293
360
  }
361
+ async buildPeerCatalog(peerHost) {
362
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
363
+ if (!(this.config.peerNodes ?? []).includes(peerHost))
364
+ throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
365
+ const [snapshot, privateRoutes, privateEdges] = await Promise.all([
366
+ this.snapshot(), this.store.inspectPrivateRoutes(), this.store.inspectPrivateConsentEdges(),
367
+ ]);
368
+ const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost);
369
+ const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
370
+ const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
371
+ const routes = localRoutes.map((route) => {
372
+ const row = publicRoutes.get(route.alias);
373
+ return {
374
+ ref: route.binding.registrationId, alias: route.alias, provider: route.binding.provider, host: localHost,
375
+ enabled: route.enabled, state: row?.state ?? (route.enabled ? "stale" : "disabled"),
376
+ queueDepth: row?.queueDepth ?? 0, ...(row?.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
377
+ ...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
378
+ };
379
+ });
380
+ const consentEdges = privateEdges.flatMap((edge) => {
381
+ if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
382
+ return [];
383
+ const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
384
+ if (rows[0] === undefined || rows[1] === undefined ||
385
+ !rows.some((route) => route?.binding.hostId === peerHost) ||
386
+ !rows.some((route) => route?.binding.hostId === localHost))
387
+ return [];
388
+ const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
389
+ host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
390
+ ? route.binding.routeHandle : route.binding.registrationId });
391
+ const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
392
+ return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
393
+ });
394
+ return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
395
+ health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
396
+ provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
397
+ ...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
398
+ ...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
399
+ ...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
400
+ })), routes, consentEdges, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
401
+ }
402
+ async receivePeerHandoff(peerHost, handoff) {
403
+ if (!(this.config.peerNodes ?? []).includes(peerHost))
404
+ throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
405
+ const enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
406
+ if (enqueued.messageId !== undefined) {
407
+ const source = await this.store.inspectPrivateRoute(handoff.source.alias);
408
+ const target = await this.store.inspectPrivateRoute(handoff.target.alias);
409
+ if (source === undefined || target === undefined)
410
+ throw new BridgeError("ROUTE_UNREGISTERED", "The committed peer handoff endpoint is no longer current.");
411
+ const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
412
+ this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
413
+ sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
414
+ pair: true, nextSequence: 1 });
415
+ this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
416
+ this.kick(target.alias, target.binding.registrationId);
417
+ }
418
+ this.revision += 1;
419
+ await this.publish();
420
+ return { accepted: true };
421
+ }
294
422
  async snapshot() {
295
423
  const base = await this.store.publicSnapshot();
296
424
  const now = this.now();
297
425
  const routes = base.routes.map((route) => {
426
+ const peer = this.peerRouteViews.get(route.alias);
427
+ if (peer !== undefined) {
428
+ const age = now.getTime() - Date.parse(peer.observedAt);
429
+ return { ...route, state: peer.state === "unobserved" || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS ? "stale" : peer.state,
430
+ lastSeenAt: peer.observedAt, ...(peer.safeErrorCode === undefined ? {} : { safeErrorCode: peer.safeErrorCode }) };
431
+ }
298
432
  const persisted = this.routeObservations.get(route.alias);
299
433
  if (persisted === undefined)
300
434
  return route;
@@ -420,6 +554,9 @@ export class GatewayService {
420
554
  }
421
555
  async observeLoadedRoutes() {
422
556
  for (const route of await this.store.listLogicalRoutes()) {
557
+ if (route.registrationMode === "federated_peer")
558
+ this.peerRouteViews.set(route.alias, { route: route.binding,
559
+ state: "unobserved", observedAt: this.now().toISOString(), safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
423
560
  this.observeRoute(route);
424
561
  if (route.binding.provider !== "claude") {
425
562
  this.reconcileAdvertisement(route);
@@ -456,20 +593,24 @@ export class GatewayService {
456
593
  return this.adapterFor({ provider: "claude", hostId });
457
594
  }
458
595
  async advertise(route) {
459
- await this.claudeAdapter(route.binding.hostId)?.advertiseNativeSourcePeer?.({
596
+ const hostId = route.registrationMode === "federated_peer"
597
+ ? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
598
+ await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
460
599
  alias: route.alias,
461
600
  sourceProvider: route.binding.provider,
462
601
  cwd: this.nativePeerCwd,
463
602
  });
464
603
  }
465
604
  async unadvertise(route) {
466
- await this.claudeAdapter(route.binding.hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
605
+ const hostId = route.registrationMode === "federated_peer"
606
+ ? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
607
+ await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
467
608
  }
468
609
  reconcileAdvertisement(route) {
469
610
  void this.advertise(route).catch((error) => this.alert("NATIVE_ADVERTISEMENT_FAILED", route, error));
470
611
  }
471
- reconcileUnadvertisement(route) {
472
- void this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
612
+ async reconcileUnadvertisement(route) {
613
+ await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
473
614
  }
474
615
  async removeOwnedRoute(route, notFoundCode, releaseProviderRoute) {
475
616
  this.assertWritable();
@@ -487,7 +628,7 @@ export class GatewayService {
487
628
  await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
488
629
  }
489
630
  else {
490
- this.reconcileUnadvertisement(route);
631
+ await this.reconcileUnadvertisement(route);
491
632
  }
492
633
  }
493
634
  async recordActivity(kind, action, aliases, operatorAction) {
@@ -559,6 +700,79 @@ export class GatewayService {
559
700
  this.reconcileAdvertisement(installed);
560
701
  this.kick(params.alias);
561
702
  }
703
+ async registerPeer(params) {
704
+ if (this.peerCleanupAliases.has(params.alias)) {
705
+ throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
706
+ }
707
+ const existing = await this.store.inspectPrivateRoute(params.alias);
708
+ if (existing !== undefined) {
709
+ if (params.token === undefined)
710
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
711
+ await this.assertPeer({ alias: params.alias, token: params.token });
712
+ this.observeRoute(existing);
713
+ this.reconcileAdvertisement(existing);
714
+ this.kick(existing.alias, existing.binding.registrationId);
715
+ return undefined;
716
+ }
717
+ if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId ?? this.config.allowedHosts[0]}`)) {
718
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
719
+ }
720
+ const token = `peer_${randomBytes(24).toString("base64url")}`;
721
+ await this.store.registerRoute({ alias: params.alias, binding: {
722
+ provider: "peer", hostId: this.config.hostId ?? this.config.allowedHosts[0],
723
+ routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
724
+ }, registrationMode: "explicit_opt_in" });
725
+ const installed = (await this.store.inspectPrivateRoute(params.alias));
726
+ this.observeRoute(installed);
727
+ this.reconcileAdvertisement(installed);
728
+ return token;
729
+ }
730
+ async assertPeer(params) {
731
+ const route = await this.store.inspectPrivateRoute(params.alias);
732
+ const expected = Buffer.from(peerHandle(process.getuid(), params.alias, params.token));
733
+ const actualHandle = route?.binding.provider === "peer" && /^peer:[0-9a-f]{64}$/.test(route.binding.routeHandle)
734
+ ? route.binding.routeHandle : `peer:${"0".repeat(64)}`;
735
+ const matches = timingSafeEqual(Buffer.from(actualHandle), expected);
736
+ if (!PEER_TOKEN.test(params.token) || route?.binding.provider !== "peer" || !matches) {
737
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
738
+ }
739
+ return route;
740
+ }
741
+ async unregisterPeer(params) {
742
+ const route = await this.assertPeer(params);
743
+ if (this.peerCleanupAliases.has(route.alias))
744
+ throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
745
+ this.peerCleanupAliases.add(route.alias);
746
+ try {
747
+ const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
748
+ if (!result.removed)
749
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
750
+ await this.finishSettlements(result.settlements);
751
+ this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
752
+ this.forgetRoute(route);
753
+ await this.reconcileUnadvertisement(route);
754
+ }
755
+ finally {
756
+ this.peerCleanupAliases.delete(route.alias);
757
+ }
758
+ }
759
+ async awaitPeer(params) {
760
+ this.assertWritable();
761
+ const route = await this.assertPeer(params);
762
+ const adapter = this.adapterFor(route.binding);
763
+ if (adapter?.awaitMessage === undefined)
764
+ throw new BridgeError("PROVIDER_UNAVAILABLE", "The provider is unavailable.", true);
765
+ const pending = adapter.awaitMessage({ alias: route.alias, ...route.binding });
766
+ this.kick(route.alias, route.binding.registrationId);
767
+ return await pending;
768
+ }
769
+ async peerReceipt(params) {
770
+ const route = await this.assertPeer(params);
771
+ const adapter = this.adapterFor(route.binding);
772
+ if (adapter?.acknowledgeReceipt({ alias: route.alias, ...route.binding, receipt: params.receipt }) === "rejected") {
773
+ throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
774
+ }
775
+ }
562
776
  async unregisterCodex(params) {
563
777
  const route = await this.store.inspectPrivateRoute(params.alias);
564
778
  if (route === undefined ||
@@ -570,7 +784,7 @@ export class GatewayService {
570
784
  }
571
785
  async removeCodexRegistration(alias) {
572
786
  const route = await this.store.inspectPrivateRoute(alias);
573
- if (route === undefined || route.binding.provider !== "codex") {
787
+ if (route === undefined || route.binding.provider !== "codex" || route.registrationMode === "federated_peer") {
574
788
  throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The Codex registration is absent.");
575
789
  }
576
790
  await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
@@ -624,13 +838,16 @@ export class GatewayService {
624
838
  }
625
839
  async unselectClaude(params) {
626
840
  const route = await this.resolveSelectedClaudeRoute(params.alias);
627
- if (route === undefined)
841
+ if (route === undefined || route.registrationMode === "federated_peer")
628
842
  throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
629
843
  await this.removeOwnedRoute(route, "CLAUDE_ROUTE_NOT_FOUND", true);
630
844
  }
631
845
  async pair(params) {
632
846
  const endpoints = await this.resolvePairEndpoints(params, true);
633
847
  const aliases = endpoints.aliases;
848
+ const ownerHost = aliases.map(aliasHost).sort()[0];
849
+ if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
850
+ throw new ConsentOwnerError(ownerHost);
634
851
  const attestation = pairParamThreadAttestation(params);
635
852
  if (attestation !== undefined)
636
853
  await this.assertThread(attestation.alias, attestation.threadId);
@@ -643,6 +860,9 @@ export class GatewayService {
643
860
  async unpair(params) {
644
861
  const endpoints = await this.resolvePairEndpoints(params, false);
645
862
  const aliases = endpoints.aliases;
863
+ const ownerHost = aliases.map(aliasHost).sort()[0];
864
+ if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
865
+ throw new ConsentOwnerError(ownerHost);
646
866
  const attestation = pairParamThreadAttestation(params);
647
867
  if (attestation !== undefined)
648
868
  await this.assertThread(attestation.alias, attestation.threadId);
@@ -693,8 +913,11 @@ export class GatewayService {
693
913
  async sendToClaude(params) {
694
914
  try {
695
915
  this.assertWritable();
696
- const source = await this.assertThread(params.fromAlias, params.threadId);
697
- const target = await this.resolveSelectedClaudeRoute(params.toAlias);
916
+ const source = "peerToken" in params && params.peerToken !== undefined
917
+ ? await this.assertPeer({ alias: params.fromAlias, token: params.peerToken })
918
+ : await this.assertThread(params.fromAlias, params.threadId);
919
+ const direct = await this.store.inspectPrivateRoute(params.toAlias);
920
+ const target = direct?.binding.provider === "peer" ? direct : await this.resolveSelectedClaudeRoute(params.toAlias);
698
921
  if (target === undefined)
699
922
  throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
700
923
  return await this.enqueueConversation({
@@ -710,29 +933,35 @@ export class GatewayService {
710
933
  });
711
934
  }
712
935
  catch (error) {
713
- return decisionFor(error);
936
+ return decisionFor(error, "peerToken" in params);
714
937
  }
715
938
  }
716
939
  async sendToCodex(params) {
717
940
  try {
718
941
  this.assertWritable();
719
942
  let source;
720
- if (params.replyAddress === undefined) {
943
+ if ("peerToken" in params && params.peerToken !== undefined) {
944
+ source = await this.assertPeer({ alias: params.fromAlias, token: params.peerToken });
945
+ }
946
+ else if (params.replyAddress === undefined) {
721
947
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
722
948
  }
723
- const adapter = this.claudeAdapter(aliasHost(params.toAlias));
724
- const resolved = await adapter?.resolveReplyAddress?.(params.replyAddress);
725
- if (resolved === undefined)
726
- throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
727
- source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
728
- route.alias === params.fromAlias &&
729
- route.binding.routeHandle === resolved.routeHandle);
730
- if (source === undefined)
731
- throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The reply route is not selected.");
949
+ else {
950
+ const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
951
+ if (resolved === undefined)
952
+ throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
953
+ source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
954
+ route.alias === params.fromAlias && route.binding.routeHandle === resolved.routeHandle);
955
+ if (source === undefined)
956
+ throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The reply route is not selected.");
957
+ }
732
958
  const target = await this.store.inspectPrivateRoute(params.toAlias);
733
959
  if (source === undefined || target === undefined) {
734
960
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The selected route is absent.");
735
961
  }
962
+ if (target.binding.provider !== "codex" && target.binding.provider !== "peer") {
963
+ throw new BridgeError("ROUTE_NOT_AVAILABLE", "The selected route is absent.");
964
+ }
736
965
  return await this.enqueueConversation({
737
966
  sourceAlias: params.fromAlias,
738
967
  targetAlias: params.toAlias,
@@ -746,7 +975,7 @@ export class GatewayService {
746
975
  });
747
976
  }
748
977
  catch (error) {
749
- return decisionFor(error);
978
+ return decisionFor(error, "peerToken" in params);
750
979
  }
751
980
  }
752
981
  async reply(params) {
@@ -801,7 +1030,7 @@ export class GatewayService {
801
1030
  });
802
1031
  }
803
1032
  catch (error) {
804
- return decisionFor(error);
1033
+ return decisionFor(error, params.caller.kind === "peer");
805
1034
  }
806
1035
  }
807
1036
  async assertReplyCaller(conversation, caller) {
@@ -829,6 +1058,8 @@ export class GatewayService {
829
1058
  throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
830
1059
  }
831
1060
  }
1061
+ if (caller.kind === "peer")
1062
+ await this.assertPeer({ alias: caller.alias, token: caller.token });
832
1063
  return route.binding;
833
1064
  }
834
1065
  async enqueueConversation(input) {
@@ -850,6 +1081,8 @@ export class GatewayService {
850
1081
  : undefined;
851
1082
  const sourceBinding = input.expectedSourceBinding ?? conversationSourceBinding ?? source?.binding;
852
1083
  const targetBinding = input.expectedTargetBinding ?? conversationTargetBinding ?? target?.binding;
1084
+ const steer = input.text.startsWith("STEER:") && this.config.steeringEnabled &&
1085
+ sourceBinding?.provider === "claude" && targetBinding?.provider === "codex";
853
1086
  if (input.existingConversation === undefined &&
854
1087
  this.conversations.size >= MAX_CONVERSATIONS &&
855
1088
  [...this.conversations.keys()].every((id) => this.conversationIsActive(id))) {
@@ -886,9 +1119,7 @@ export class GatewayService {
886
1119
  expectedSourceRegistrationId: sourceBinding.registrationId,
887
1120
  expectedTargetRegistrationId: targetBinding.registrationId,
888
1121
  }),
889
- ...(input.text.startsWith("STEER:") && this.config.steeringEnabled
890
- ? { steer: true }
891
- : {}),
1122
+ ...(steer ? { steer: true } : {}),
892
1123
  });
893
1124
  }
894
1125
  catch (error) {
@@ -909,9 +1140,8 @@ export class GatewayService {
909
1140
  if (enqueued.supersededSettlement !== undefined) {
910
1141
  await this.finishSettlement(enqueued.supersededSettlement);
911
1142
  }
912
- if (input.text.startsWith("STEER:") && this.config.steeringEnabled) {
1143
+ if (steer)
913
1144
  this.kickSteer(input.targetAlias);
914
- }
915
1145
  this.kick(input.targetAlias);
916
1146
  this.scheduleWake();
917
1147
  return {
@@ -1020,8 +1250,9 @@ export class GatewayService {
1020
1250
  else if (steer && this.running && !this.closing)
1021
1251
  this.kickSteer(targetAlias);
1022
1252
  else if (this.running)
1023
- void this.store.inspectDispatchableTargets().then((targets) => {
1024
- if (targets.includes(targetAlias))
1253
+ void Promise.all([this.store.inspectDispatchableTargets(), this.store.inspectPrivateRoute(targetAlias)]).then(([targets, route]) => {
1254
+ if (targets.includes(targetAlias) &&
1255
+ (route?.registrationMode !== "federated_peer" || this.peerClients.has(route.binding.hostId)))
1025
1256
  this.kick(targetAlias);
1026
1257
  }).catch(() => undefined);
1027
1258
  });
@@ -1093,6 +1324,72 @@ export class GatewayService {
1093
1324
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
1094
1325
  return false;
1095
1326
  }
1327
+ if (target.registrationMode === "federated_peer") {
1328
+ const peer = this.peerClients.get(target.binding.hostId);
1329
+ if (peer === undefined || source === undefined || source.registrationMode === "federated_peer") {
1330
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1331
+ return false;
1332
+ }
1333
+ const sourceEndpoint = { alias: source.alias, provider: source.binding.provider,
1334
+ host: source.binding.hostId, routeRef: source.binding.registrationId };
1335
+ const targetEndpoint = { alias: target.alias, provider: target.binding.provider,
1336
+ host: target.binding.hostId, routeRef: target.binding.routeHandle };
1337
+ const params = {
1338
+ originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
1339
+ source: sourceEndpoint, target: targetEndpoint,
1340
+ edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
1341
+ edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
1342
+ deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
1343
+ body: attempt.body,
1344
+ ...(attempt.steer === true ? { steer: true } : {}),
1345
+ ...(attempt.conversationIdSuffix === undefined ? {} : { conversationCorrelation: attempt.conversationIdSuffix }),
1346
+ };
1347
+ let armed = false, authorizationUncertain = false, acceptanceObserved = false, result;
1348
+ try {
1349
+ const prepared = peer.prepareHandoff(params);
1350
+ let authorized;
1351
+ try {
1352
+ authorized = await this.store.authorizeMessage({ messageId: attempt.messageId,
1353
+ attemptId: attempt.attemptId, sourceRegistrationId: attempt.sourceRegistrationId,
1354
+ targetRegistrationId: attempt.targetRegistrationId,
1355
+ prepared: { kind: "peer_handoff", bodyBytes: prepared.bodyBytes,
1356
+ bodySha256: prepared.bodySha256, frameBytes: prepared.frameBytes, sha256: prepared.sha256 } });
1357
+ }
1358
+ catch (error) {
1359
+ authorizationUncertain = true;
1360
+ throw error;
1361
+ }
1362
+ if (authorized.status !== "authorized") {
1363
+ prepared.cancel();
1364
+ if (authorized.status === "terminal")
1365
+ await this.finishSettlement(authorized.settlement);
1366
+ this.activeAttempts.delete(attempt.messageId);
1367
+ return false;
1368
+ }
1369
+ armed = true;
1370
+ await prepared.perform();
1371
+ acceptanceObserved = true;
1372
+ const accepted = await this.store.acceptMessage({ messageId: attempt.messageId,
1373
+ attemptId: attempt.attemptId, lossOutcome: "unconfirmed" });
1374
+ if (accepted.status !== "accepted")
1375
+ throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The peer acceptance fence is stale.");
1376
+ result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
1377
+ }
1378
+ catch {
1379
+ if (!armed && !authorizationUncertain) {
1380
+ peer.close();
1381
+ this.peerClients.delete(target.binding.hostId);
1382
+ await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
1383
+ return false;
1384
+ }
1385
+ result = acceptanceObserved
1386
+ ? { state: "unconfirmed", safeErrorCode: "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" }
1387
+ : { state: "ambiguous", safeErrorCode: authorizationUncertain ? "WRITE_AUTHORIZATION_UNCERTAIN" : "PEER_HANDOFF_OUTCOME_UNKNOWN" };
1388
+ }
1389
+ const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
1390
+ this.activeAttempts.delete(attempt.messageId);
1391
+ return requeued;
1392
+ }
1096
1393
  const adapter = this.adapterFor(target.binding);
1097
1394
  if (adapter === undefined) {
1098
1395
  await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
@@ -1177,7 +1474,7 @@ export class GatewayService {
1177
1474
  }
1178
1475
  },
1179
1476
  onAccepted: async (evidence) => {
1180
- if (target.binding.provider !== "codex" ||
1477
+ if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
1181
1478
  evidence.attemptId !== attempt.attemptId) {
1182
1479
  throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
1183
1480
  }
@@ -1344,7 +1641,9 @@ export class GatewayService {
1344
1641
  }
1345
1642
  async onClaudeMessage(event) {
1346
1643
  const target = await this.store.inspectPrivateRoute(event.targetAlias);
1347
- if (target === undefined || target.binding.hostId !== event.endpoint.hostId) {
1644
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
1645
+ if (target === undefined || (target.binding.hostId !== event.endpoint.hostId &&
1646
+ !(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
1348
1647
  throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
1349
1648
  }
1350
1649
  const selected = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
@@ -1358,7 +1657,7 @@ export class GatewayService {
1358
1657
  registrationId: `native_${bodyHash(`${event.endpoint.routeHandle}\0${event.sourceAlias}`).slice(0, 32)}`,
1359
1658
  };
1360
1659
  const conversationId = createGatewayConversationId();
1361
- const steer = event.text.startsWith("STEER:") && this.config.steeringEnabled;
1660
+ const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
1362
1661
  this.assertWritable();
1363
1662
  const enqueued = await this.store.enqueueNativeIngress({
1364
1663
  source: { alias: event.sourceAlias, binding: sourceBinding },
@@ -1820,6 +2119,9 @@ export class GatewayService {
1820
2119
  const due = [
1821
2120
  this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
1822
2121
  this.nextDoctorAt || now + CODEX_DOCTOR_INTERVAL_MS,
2122
+ ...((this.config.peerNodes?.length ?? 0) > 0
2123
+ ? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
2124
+ : []),
1823
2125
  ...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
1824
2126
  ...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
1825
2127
  ...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
@@ -1846,11 +2148,100 @@ export class GatewayService {
1846
2148
  await this.refreshCodexDoctor().catch(() => undefined);
1847
2149
  this.nextDoctorAt = now.getTime() + CODEX_DOCTOR_INTERVAL_MS;
1848
2150
  }
2151
+ if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
2152
+ const operation = this.refreshPeers();
2153
+ this.peerRefreshOperations.add(operation);
2154
+ try {
2155
+ await operation;
2156
+ }
2157
+ finally {
2158
+ this.peerRefreshOperations.delete(operation);
2159
+ }
2160
+ this.nextPeerRefreshAt = now.getTime() + PEER_REFRESH_INTERVAL_MS;
2161
+ }
2162
+ if (!this.running || this.closing)
2163
+ return;
1849
2164
  for (const target of await this.store.inspectDispatchableTargets())
1850
2165
  this.kick(target);
1851
2166
  await this.publish();
1852
2167
  this.scheduleWake();
1853
2168
  }
2169
+ async refreshPeers() {
2170
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
2171
+ await Promise.all((this.config.peerNodes ?? []).map(async (peerHost) => {
2172
+ let failureCode = "PEER_TUNNEL_UNAVAILABLE";
2173
+ try {
2174
+ let client = this.peerClients.get(peerHost);
2175
+ if (client === undefined) {
2176
+ failureCode = "PEER_DIAL_FAILED";
2177
+ client = await this.spawnPeer({ node: peerHost, localHost });
2178
+ if (!this.running || this.closing) {
2179
+ client.close();
2180
+ return;
2181
+ }
2182
+ this.peerClients.set(peerHost, client);
2183
+ }
2184
+ failureCode = "PEER_TUNNEL_UNAVAILABLE";
2185
+ const catalog = await client.catalog();
2186
+ if (!this.running || this.closing)
2187
+ return;
2188
+ this.recordPeerFailure(peerHost);
2189
+ const prior = this.peerCatalogs.get(peerHost);
2190
+ let routes = await this.store.inspectPrivateRoutes().then((rows) => rows.filter((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost));
2191
+ if (!catalog.complete || catalog.truncated) {
2192
+ const observedAt = this.now().toISOString();
2193
+ for (const route of routes)
2194
+ this.peerRouteViews.set(route.alias, { route: route.binding,
2195
+ state: "unobserved", observedAt, safeErrorCode: "PEER_CATALOG_INCOMPLETE" });
2196
+ return;
2197
+ }
2198
+ const authorityChanged = peerCatalogAuthorityChanged(prior, catalog);
2199
+ const viewChanged = peerCatalogViewChanged(prior, catalog);
2200
+ if (authorityChanged) {
2201
+ const before = routes;
2202
+ const reconciled = await this.store.reconcilePeerCatalog(peerHost, catalog);
2203
+ routes = [...reconciled.routes];
2204
+ await this.finishSettlements(reconciled.settlements);
2205
+ for (const old of before)
2206
+ if (!routes.some((route) => route.alias === old.alias &&
2207
+ route.binding.registrationId === old.binding.registrationId)) {
2208
+ this.peerRouteViews.delete(old.alias);
2209
+ await this.reconcileUnadvertisement(old);
2210
+ }
2211
+ for (const route of routes)
2212
+ if (!before.some((old) => old.alias === route.alias &&
2213
+ old.binding.registrationId === route.binding.registrationId))
2214
+ this.reconcileAdvertisement(route);
2215
+ }
2216
+ this.peerCatalogs.set(peerHost, catalog);
2217
+ const observedAt = this.now().toISOString();
2218
+ for (const route of routes) {
2219
+ const row = catalog.routes.find((candidate) => candidate.ref === route.binding.routeHandle);
2220
+ const state = row?.state === "idle" || row?.state === "busy" || row?.state === "awaiting_approval"
2221
+ ? row.state : "unobserved";
2222
+ this.peerRouteViews.set(route.alias, { route: route.binding, state, observedAt,
2223
+ ...(state === "unobserved" ? { safeErrorCode: row?.safeErrorCode ?? "PEER_ROUTE_STALE" } : {}) });
2224
+ if (row?.enabled === true)
2225
+ this.kick(route.alias, route.binding.registrationId);
2226
+ }
2227
+ if (viewChanged)
2228
+ this.revision += 1;
2229
+ }
2230
+ catch {
2231
+ this.peerClients.get(peerHost)?.close();
2232
+ this.peerClients.delete(peerHost);
2233
+ if (!this.running || this.closing)
2234
+ return;
2235
+ this.recordPeerFailure(peerHost, failureCode);
2236
+ const observedAt = this.now().toISOString();
2237
+ for (const route of await this.store.inspectPrivateRoutes())
2238
+ if (route.registrationMode === "federated_peer" &&
2239
+ route.binding.hostId === peerHost)
2240
+ this.peerRouteViews.set(route.alias, { route: route.binding,
2241
+ state: "unobserved", observedAt, safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
2242
+ }
2243
+ }));
2244
+ }
1854
2245
  async refreshClaudeDiscovery() {
1855
2246
  const seen = new Set();
1856
2247
  for (const adapter of this.adapters) {
@@ -1921,5 +2312,14 @@ export class GatewayService {
1921
2312
  while (this.runtimeAlerts.length > gatewayPublicSnapshotLimits.alerts)
1922
2313
  this.runtimeAlerts.shift();
1923
2314
  }
2315
+ recordPeerFailure(host, code) {
2316
+ for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
2317
+ const alert = this.runtimeAlerts[index];
2318
+ if (alert?.host === host && (alert.code === "PEER_DIAL_FAILED" || alert.code === "PEER_TUNNEL_UNAVAILABLE"))
2319
+ this.runtimeAlerts.splice(index, 1);
2320
+ }
2321
+ if (code !== undefined)
2322
+ this.runtimeAlerts.push({ code, severity: "warning", timestamp: this.now().toISOString(), host });
2323
+ }
1924
2324
  }
1925
2325
  //# sourceMappingURL=service.js.map