agent-embassy 1.2.0 → 1.4.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTRIBUTING.md +50 -16
  3. package/README.md +57 -25
  4. package/README.zh-CN.md +45 -23
  5. package/SECURITY.md +68 -48
  6. package/dist/src/gateway/claude-helper-protocol.d.ts +1 -0
  7. package/dist/src/gateway/claude-helper-protocol.js +4 -2
  8. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  9. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -0
  10. package/dist/src/gateway/claude-helper-supervisor.js +3 -1
  11. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  12. package/dist/src/gateway/claude-runtime.d.ts +0 -2
  13. package/dist/src/gateway/claude-runtime.js +6 -17
  14. package/dist/src/gateway/claude-runtime.js.map +1 -1
  15. package/dist/src/gateway/cli-copy.d.ts +1 -1
  16. package/dist/src/gateway/cli-copy.en.d.ts +4 -1
  17. package/dist/src/gateway/cli-copy.en.js +7 -3
  18. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  19. package/dist/src/gateway/cli-copy.js +3 -0
  20. package/dist/src/gateway/cli-copy.js.map +1 -1
  21. package/dist/src/gateway/cli-copy.zh-CN.d.ts +4 -1
  22. package/dist/src/gateway/cli-copy.zh-CN.js +7 -3
  23. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  24. package/dist/src/gateway/cli.d.ts +2 -5
  25. package/dist/src/gateway/cli.js +41 -67
  26. package/dist/src/gateway/cli.js.map +1 -1
  27. package/dist/src/gateway/codex-app-server.d.ts +2 -8
  28. package/dist/src/gateway/codex-app-server.js +36 -62
  29. package/dist/src/gateway/codex-app-server.js.map +1 -1
  30. package/dist/src/gateway/codex-local-transport.d.ts +4 -10
  31. package/dist/src/gateway/codex-local-transport.js +13 -16
  32. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  33. package/dist/src/gateway/compatibility.d.ts +0 -27
  34. package/dist/src/gateway/compatibility.js +1 -101
  35. package/dist/src/gateway/compatibility.js.map +1 -1
  36. package/dist/src/gateway/config.d.ts +1 -4
  37. package/dist/src/gateway/config.js +0 -10
  38. package/dist/src/gateway/config.js.map +1 -1
  39. package/dist/src/gateway/control.d.ts +2 -21
  40. package/dist/src/gateway/control.js +7 -36
  41. package/dist/src/gateway/control.js.map +1 -1
  42. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  43. package/dist/src/gateway/dashboard-copy.en.d.ts +21 -8
  44. package/dist/src/gateway/dashboard-copy.en.js +40 -27
  45. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  46. package/dist/src/gateway/dashboard-copy.js +21 -8
  47. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  48. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +21 -8
  49. package/dist/src/gateway/dashboard-copy.zh-CN.js +40 -27
  50. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  51. package/dist/src/gateway/dashboard-model.d.ts +4 -9
  52. package/dist/src/gateway/dashboard-model.js +159 -46
  53. package/dist/src/gateway/dashboard-model.js.map +1 -1
  54. package/dist/src/gateway/dashboard.js +27 -16
  55. package/dist/src/gateway/dashboard.js.map +1 -1
  56. package/dist/src/gateway/instance-lease.d.ts +2 -3
  57. package/dist/src/gateway/instance-lease.js +17 -140
  58. package/dist/src/gateway/instance-lease.js.map +1 -1
  59. package/dist/src/gateway/live-dashboard-app/app.js +62 -63
  60. package/dist/src/gateway/live-dashboard-command.d.ts +5 -1
  61. package/dist/src/gateway/live-dashboard-command.js +8 -6
  62. package/dist/src/gateway/live-dashboard-command.js.map +1 -1
  63. package/dist/src/gateway/live-dashboard-http.d.ts +0 -4
  64. package/dist/src/gateway/live-dashboard-http.js +20 -94
  65. package/dist/src/gateway/live-dashboard-http.js.map +1 -1
  66. package/dist/src/gateway/live-dashboard-protocol.d.ts +1 -5
  67. package/dist/src/gateway/live-dashboard-protocol.js +0 -42
  68. package/dist/src/gateway/live-dashboard-protocol.js.map +1 -1
  69. package/dist/src/gateway/live-dashboard-server.d.ts +2 -2
  70. package/dist/src/gateway/live-dashboard-server.js +18 -4
  71. package/dist/src/gateway/live-dashboard-server.js.map +1 -1
  72. package/dist/src/gateway/live-dashboard.d.ts +3 -6
  73. package/dist/src/gateway/live-dashboard.js +6 -42
  74. package/dist/src/gateway/live-dashboard.js.map +1 -1
  75. package/dist/src/gateway/progress-watch-machine.d.ts +6 -5
  76. package/dist/src/gateway/progress-watch-machine.js +6 -6
  77. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  78. package/dist/src/gateway/provenance-envelope.d.ts +1 -0
  79. package/dist/src/gateway/provenance-envelope.js +11 -4
  80. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  81. package/dist/src/gateway/providers.d.ts +2 -21
  82. package/dist/src/gateway/providers.js +21 -261
  83. package/dist/src/gateway/providers.js.map +1 -1
  84. package/dist/src/gateway/server.js +0 -3
  85. package/dist/src/gateway/server.js.map +1 -1
  86. package/dist/src/gateway/service.d.ts +5 -15
  87. package/dist/src/gateway/service.js +101 -265
  88. package/dist/src/gateway/service.js.map +1 -1
  89. package/dist/src/gateway/store.d.ts +9 -21
  90. package/dist/src/gateway/store.js +360 -140
  91. package/dist/src/gateway/store.js.map +1 -1
  92. package/dist/src/gateway/types.d.ts +1 -6
  93. package/dist/src/gateway/types.js.map +1 -1
  94. package/docs/CONFIGURATION.md +102 -0
  95. package/docs/CONFIGURATION.zh-CN.md +72 -0
  96. package/docs/DASHBOARD.md +103 -0
  97. package/docs/DASHBOARD.zh-CN.md +47 -0
  98. package/docs/DELIVERY.md +84 -0
  99. package/docs/DELIVERY.zh-CN.md +53 -0
  100. package/docs/GATEWAY-ARCHITECTURE.md +249 -153
  101. package/package.json +8 -4
  102. package/skills/embassy-peer/SKILL.md +25 -10
  103. package/dist/src/gateway/claude-compatibility-scratch.d.ts +0 -18
  104. package/dist/src/gateway/claude-compatibility-scratch.js +0 -158
  105. package/dist/src/gateway/claude-compatibility-scratch.js.map +0 -1
  106. package/dist/src/gateway/live-dashboard-bootstrap.d.ts +0 -36
  107. package/dist/src/gateway/live-dashboard-bootstrap.js +0 -176
  108. package/dist/src/gateway/live-dashboard-bootstrap.js.map +0 -1
  109. package/docs/DESIGN.md +0 -134
@@ -7,7 +7,7 @@ import { BridgeError } from "../errors.js";
7
7
  import { KeyedMutex } from "../mutex.js";
8
8
  import { isCodexRegistrationGeneration } from "./codex-registration-generation.js";
9
9
  import { compatibilityCacheKey, isCompatibilityAttestation, } from "./compatibility.js";
10
- import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
10
+ import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
11
11
  import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, compatibilityStates, connectorHealthStates, deliveryStates, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
12
12
  const STATE_MARKER = ".agent-embassy-state";
13
13
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
@@ -271,7 +271,25 @@ function isPairRecord(value) {
271
271
  isIsoTimestamp(value.updatedAt) &&
272
272
  isRouteCounters(value.counters));
273
273
  }
274
- function isProgressWatchMachine(value) {
274
+ function progressWatchPairKey(watch) {
275
+ return [
276
+ `${watch.ownerAlias}\0${watch.ownerLease}`,
277
+ `${watch.workerAlias}\0${watch.workerLease}`,
278
+ ]
279
+ .sort()
280
+ .join("\0");
281
+ }
282
+ function progressWatchMatchesPair(watch, pair) {
283
+ return ((watch.ownerAlias === pair.claudeAlias &&
284
+ watch.ownerLease === pair.claudeOwnerLease &&
285
+ watch.workerAlias === pair.codexAlias &&
286
+ watch.workerLease === pair.codexOwnerLease) ||
287
+ (watch.ownerAlias === pair.codexAlias &&
288
+ watch.ownerLease === pair.codexOwnerLease &&
289
+ watch.workerAlias === pair.claudeAlias &&
290
+ watch.workerLease === pair.claudeOwnerLease));
291
+ }
292
+ function isLegacyProgressWatchMachine(value) {
275
293
  if (!isObject(value) ||
276
294
  !hasOnlyKeys(value, [
277
295
  "conversationId",
@@ -322,6 +340,10 @@ function isProgressWatchMachine(value) {
322
340
  (value.workerReportedCompleteAt === undefined ||
323
341
  isIsoTimestamp(value.workerReportedCompleteAt)));
324
342
  }
343
+ function isProgressWatchMachine(value) {
344
+ return (isLegacyProgressWatchMachine(value) &&
345
+ !Object.hasOwn(value, "workerReportedCompleteAt"));
346
+ }
325
347
  function isProgressWatchJournalEvent(value) {
326
348
  return (isObject(value) &&
327
349
  hasOnlyKeys(value, [
@@ -392,7 +414,6 @@ function isQueuedMetadata(value) {
392
414
  "enqueuedAt",
393
415
  "deadlineAt",
394
416
  "bytes",
395
- "hopCount",
396
417
  ], ["conversationIdSuffix", "body", "pair", "transientTarget", "steer"])) {
397
418
  return false;
398
419
  }
@@ -417,7 +438,6 @@ function isQueuedMetadata(value) {
417
438
  value.body.length > 0 &&
418
439
  !value.body.includes("\u0000") &&
419
440
  Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
420
- isNonNegativeInteger(value.hopCount) &&
421
441
  (value.pair === undefined || value.pair === true) &&
422
442
  (value.transientTarget === undefined || value.transientTarget === true) &&
423
443
  (value.steer === undefined || value.steer === true));
@@ -439,7 +459,6 @@ function isEvent(value) {
439
459
  "targetAlias",
440
460
  "state",
441
461
  "bytes",
442
- "hopCount",
443
462
  ], ["conversationIdSuffix", "body", "latencyMs", "safeErrorCode", "steer"])) {
444
463
  return false;
445
464
  }
@@ -464,7 +483,6 @@ function isEvent(value) {
464
483
  value.body.length > 0 &&
465
484
  !value.body.includes("\u0000") &&
466
485
  Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
467
- isNonNegativeInteger(value.hopCount) &&
468
486
  (value.latencyMs === undefined || isNonNegativeInteger(value.latencyMs)) &&
469
487
  (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
470
488
  (value.steer === undefined || value.steer === true));
@@ -717,6 +735,168 @@ function migratePreCodexOrphanRemovalJournal(value) {
717
735
  codexOrphanRemovalEvents: [],
718
736
  };
719
737
  }
738
+ /**
739
+ * v1.2.0 persisted hop counters on queue, in-flight, and event rows. Hop
740
+ * policy no longer exists; accept only the old bounded shape and discard the
741
+ * obsolete field before strict current-schema validation and persistence.
742
+ */
743
+ function migrateLegacyHopCounts(value) {
744
+ if (!isObject(value) ||
745
+ value.schemaVersion !== 1 ||
746
+ !Array.isArray(value.queue) ||
747
+ !Array.isArray(value.inFlight) ||
748
+ !Array.isArray(value.events)) {
749
+ return value;
750
+ }
751
+ const strip = (entry) => {
752
+ if (!isObject(entry) || !Object.hasOwn(entry, "hopCount"))
753
+ return entry;
754
+ if (!isNonNegativeInteger(entry.hopCount) || entry.hopCount > 64) {
755
+ return entry;
756
+ }
757
+ const { hopCount: _legacyHopCount, ...current } = entry;
758
+ return current;
759
+ };
760
+ return {
761
+ ...value,
762
+ queue: value.queue.map(strip),
763
+ inFlight: value.inFlight.map(strip),
764
+ events: value.events.map(strip),
765
+ };
766
+ }
767
+ function isLegacyCompatibilityCertification(value) {
768
+ if (!isObject(value))
769
+ return false;
770
+ const required = ["depth", "outcome", "certifiedAt"];
771
+ if (!hasOnlyKeys(value, required, ["safeErrorCode"]))
772
+ return false;
773
+ if ((value.depth !== "wire" &&
774
+ value.depth !== "thread_ops" &&
775
+ value.depth !== "turn") ||
776
+ (value.outcome !== "pass" && value.outcome !== "fail") ||
777
+ !isIsoTimestamp(value.certifiedAt) ||
778
+ new Date(Date.parse(value.certifiedAt)).toISOString() !==
779
+ value.certifiedAt ||
780
+ (value.safeErrorCode !== undefined && !isSafeCode(value.safeErrorCode))) {
781
+ return false;
782
+ }
783
+ return (value.outcome === "pass") === (value.safeErrorCode === undefined);
784
+ }
785
+ /**
786
+ * v1.1-v1.2 compatibility rows could contain manual live-certification
787
+ * evidence. The ceremony is gone: accept only its exact old shape, discard
788
+ * it, and retain the automatic bounded probe evidence.
789
+ */
790
+ function migrateLegacyCompatibilityCertifications(value) {
791
+ if (!isObject(value) ||
792
+ value.schemaVersion !== 1 ||
793
+ !Array.isArray(value.compatibilityAttestations)) {
794
+ return value;
795
+ }
796
+ let changed = false;
797
+ const compatibilityAttestations = value.compatibilityAttestations.map((entry) => {
798
+ if (!isObject(entry) || !Object.hasOwn(entry, "certification")) {
799
+ return entry;
800
+ }
801
+ if (!isLegacyCompatibilityCertification(entry.certification)) {
802
+ return entry;
803
+ }
804
+ const { certification: _legacyCertification, ...current } = entry;
805
+ if (!isCompatibilityAttestation(current))
806
+ return entry;
807
+ changed = true;
808
+ return current;
809
+ });
810
+ return changed ? { ...value, compatibilityAttestations } : value;
811
+ }
812
+ /**
813
+ * Before v1.4, separately tracked conversations could leave multiple active
814
+ * watches on one exact pair, and a worker completion could remain as a flag on
815
+ * an active watch. Settle both legacy shapes into the bounded journal. Losers
816
+ * are ordered by their stable original-array position, so a persisted result
817
+ * is idempotent when loaded again.
818
+ */
819
+ function migrateLegacyProgressWatchState(value, eventCapacity) {
820
+ if (!isObject(value) ||
821
+ value.schemaVersion !== 1 ||
822
+ !isIsoTimestamp(value.updatedAt) ||
823
+ !isNonNegativeInteger(value.watchSequence) ||
824
+ !Array.isArray(value.progressWatches) ||
825
+ value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
826
+ !value.progressWatches.every(isLegacyProgressWatchMachine) ||
827
+ !Array.isArray(value.progressWatchEvents) ||
828
+ !value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
829
+ value.progressWatchEvents.length > eventCapacity ||
830
+ new Set(value.progressWatches.map((watch) => watch.conversationId)).size !== value.progressWatches.length) {
831
+ return value;
832
+ }
833
+ const progressWatches = value.progressWatches;
834
+ const existingEvents = value.progressWatchEvents;
835
+ const watchSequenceBefore = value.watchSequence;
836
+ if (existingEvents.some((event, index) => event.sequence > watchSequenceBefore ||
837
+ (index > 0 && event.sequence <= existingEvents[index - 1].sequence))) {
838
+ return value;
839
+ }
840
+ const winnerByPair = new Map();
841
+ for (const [index, watch] of progressWatches.entries()) {
842
+ const key = progressWatchPairKey(watch);
843
+ const winnerIndex = winnerByPair.get(key);
844
+ const winner = winnerIndex === undefined
845
+ ? undefined
846
+ : progressWatches[winnerIndex];
847
+ if (winner === undefined ||
848
+ Date.parse(watch.createdAt) >= Date.parse(winner.createdAt)) {
849
+ winnerByPair.set(key, index);
850
+ }
851
+ }
852
+ const winnerIndexes = new Set(winnerByPair.values());
853
+ const loserIndexes = progressWatches
854
+ .map((_, index) => index)
855
+ .filter((index) => !winnerIndexes.has(index));
856
+ const completedWinnerIndexes = [...winnerByPair.values()].filter((index) => progressWatches[index]?.workerReportedCompleteAt !== undefined);
857
+ const additions = loserIndexes.length + completedWinnerIndexes.length;
858
+ if (additions === 0)
859
+ return value;
860
+ if (watchSequenceBefore > Number.MAX_SAFE_INTEGER - additions) {
861
+ throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
862
+ }
863
+ const retained = new Set(winnerIndexes);
864
+ for (const index of completedWinnerIndexes)
865
+ retained.delete(index);
866
+ let watchSequence = watchSequenceBefore;
867
+ const progressWatchEvents = [...existingEvents];
868
+ const append = (watch, kind, timestamp) => {
869
+ watchSequence += 1;
870
+ progressWatchEvents.push({
871
+ sequence: watchSequence,
872
+ timestamp,
873
+ conversationId: watch.conversationId,
874
+ ownerAlias: watch.ownerAlias,
875
+ workerAlias: watch.workerAlias,
876
+ kind,
877
+ });
878
+ while (progressWatchEvents.length > eventCapacity) {
879
+ progressWatchEvents.shift();
880
+ }
881
+ };
882
+ for (const index of loserIndexes) {
883
+ const watch = progressWatches[index];
884
+ const winner = progressWatches[winnerByPair.get(progressWatchPairKey(watch))];
885
+ append(watch, "replaced", winner.createdAt);
886
+ }
887
+ for (const index of completedWinnerIndexes) {
888
+ const watch = progressWatches[index];
889
+ append(watch, "worker_reported_complete", watch.workerReportedCompleteAt);
890
+ }
891
+ return {
892
+ ...value,
893
+ watchSequence,
894
+ progressWatches: progressWatches
895
+ .filter((_, index) => retained.has(index))
896
+ .map(({ workerReportedCompleteAt: _legacy, ...watch }) => watch),
897
+ progressWatchEvents,
898
+ };
899
+ }
720
900
  function isPersistedState(value) {
721
901
  if (!isObject(value) ||
722
902
  !hasOnlyKeys(value, [
@@ -802,6 +982,9 @@ function isPersistedState(value) {
802
982
  if (watchConversationIds.size !== candidate.progressWatches.length) {
803
983
  return false;
804
984
  }
985
+ const watchPairKeys = new Set(candidate.progressWatches.map(progressWatchPairKey));
986
+ if (watchPairKeys.size !== candidate.progressWatches.length)
987
+ return false;
805
988
  const compatibilityKeys = candidate.compatibilityAttestations.map(compatibilityCacheKey);
806
989
  if (new Set(compatibilityKeys).size !== compatibilityKeys.length) {
807
990
  return false;
@@ -874,6 +1057,10 @@ function isPersistedState(value) {
874
1057
  };
875
1058
  const sequencesStrictlyIncrease = candidate.events.every((event, index) => index === 0 ||
876
1059
  event.sequence > (candidate.events[index - 1]?.sequence ?? 0));
1060
+ const progressWatchSequencesStrictlyIncrease = candidate.progressWatchEvents.every((event, index) => event.sequence <= candidate.watchSequence &&
1061
+ (index === 0 ||
1062
+ event.sequence >
1063
+ (candidate.progressWatchEvents[index - 1]?.sequence ?? 0)));
877
1064
  const endpointRefreshSequenceConsistent = candidate.codexEndpointRefreshEvents.length === 0
878
1065
  ? candidate.codexEndpointRefreshSequence === 0
879
1066
  : candidate.codexEndpointRefreshEvents.at(-1)?.sequence ===
@@ -904,6 +1091,7 @@ function isPersistedState(value) {
904
1091
  candidate.rateBuckets.length &&
905
1092
  candidate.accounting.queuedBytes === expectedQueuedBytes &&
906
1093
  sequencesStrictlyIncrease &&
1094
+ progressWatchSequencesStrictlyIncrease &&
907
1095
  candidate.events.every((event) => event.sequence <= candidate.eventSequence) &&
908
1096
  endpointRefreshSequenceConsistent &&
909
1097
  orphanRemovalSequenceConsistent &&
@@ -1440,6 +1628,8 @@ export class GatewayStore {
1440
1628
  // selected Claude routes and their independent consent edges survive.
1441
1629
  const retired = selectedClaudeRoutes.filter((route) => route !== retained && route === aliasOwner);
1442
1630
  const retiredAliases = new Set(retired.map((route) => route.alias));
1631
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => retiredAliases.has(watch.ownerAlias) ||
1632
+ retiredAliases.has(watch.workerAlias)).length);
1443
1633
  const settlementPlan = this.validateAffectedInFlightSettlements(state, retiredAliases, input.inFlightSettlements ?? []);
1444
1634
  const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
1445
1635
  this.settleProgressWatchesForAliases(state, retiredAliases, now);
@@ -1816,6 +2006,7 @@ export class GatewayStore {
1816
2006
  return await this.mutate(async (state, now) => {
1817
2007
  const route = this.requireOwnedRoute(state, alias, ownerLease);
1818
2008
  const aliases = new Set([route.alias]);
2009
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
1819
2010
  const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
1820
2011
  route.enabled = false;
1821
2012
  route.state = "disabled";
@@ -2271,65 +2462,34 @@ export class GatewayStore {
2271
2462
  .sort((left, right) => pairKey(left.claudeAlias, left.codexAlias).localeCompare(pairKey(right.claudeAlias, right.codexAlias)));
2272
2463
  });
2273
2464
  }
2274
- /** Open one durable owner-ended watch on an exact consent edge. */
2275
- async openProgressWatch(input) {
2465
+ async inspectProgressWatches() {
2466
+ return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
2467
+ }
2468
+ /** Bind one restarted route-capability watch to its first exact owner send. */
2469
+ async bindProgressWatchConversation(input) {
2276
2470
  return this.mutate(async (state, now) => {
2277
- if (this.config.trackingEnabled === false) {
2278
- throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
2279
- }
2280
2471
  if (!CONVERSATION_ID_PATTERN.test(input.conversationId) ||
2281
2472
  !ALIAS_PATTERN.test(input.ownerAlias) ||
2282
2473
  !ALIAS_PATTERN.test(input.workerAlias) ||
2283
- input.ownerAlias === input.workerAlias ||
2284
- !Number.isSafeInteger(input.idleMs) ||
2285
- input.idleMs < PROGRESS_WATCH_MIN_IDLE_MS ||
2286
- input.idleMs > PROGRESS_WATCH_MAX_IDLE_MS) {
2287
- throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch request is malformed or outside its idle bound.");
2288
- }
2289
- const existing = state.progressWatches.find((watch) => watch.conversationId === input.conversationId);
2290
- if (existing !== undefined) {
2291
- if (existing.ownerAlias !== input.ownerAlias ||
2292
- existing.workerAlias !== input.workerAlias) {
2293
- throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The conversation watch belongs to different exact endpoints.");
2294
- }
2295
- return { created: false, watch: { ...existing } };
2474
+ input.ownerAlias === input.workerAlias) {
2475
+ throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch conversation binding is malformed.");
2296
2476
  }
2297
- if (state.progressWatches.length >=
2298
- (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
2299
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
2300
- }
2301
- const owner = this.requireAvailableRoute(state, input.ownerAlias);
2302
- const worker = this.requireAvailableRoute(state, input.workerAlias);
2303
- const pairAliases = pairAliasesForRoutes(owner, worker);
2304
- this.requireExactPair(state, owner.binding.provider === "claude" ? owner : worker, owner.binding.provider === "codex" ? owner : worker);
2305
- if (pairAliases.claudeAlias !==
2306
- (owner.binding.provider === "claude"
2307
- ? owner.alias
2308
- : worker.alias) ||
2309
- pairAliases.codexAlias !==
2310
- (owner.binding.provider === "codex" ? owner.alias : worker.alias)) {
2311
- throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
2312
- }
2313
- const watch = createProgressWatchMachine({
2314
- conversationId: input.conversationId,
2315
- ownerAlias: owner.alias,
2316
- workerAlias: worker.alias,
2317
- ownerLease: owner.binding.ownerLease,
2318
- workerLease: worker.binding.ownerLease,
2319
- idleMs: input.idleMs,
2320
- at: now.getTime(),
2321
- });
2322
- state.progressWatches.push(watch);
2323
- this.appendProgressWatchEvent(state, watch, {
2324
- kind: "opened",
2325
- timestamp: now.toISOString(),
2326
- });
2327
- return { created: true, watch: { ...watch } };
2477
+ const routeWatch = state.progressWatches.find((watch) => watch.capability === "route" &&
2478
+ watch.ownerAlias === input.ownerAlias &&
2479
+ watch.workerAlias === input.workerAlias);
2480
+ if (routeWatch === undefined)
2481
+ return false;
2482
+ const pair = state.pairs.find((candidate) => pairMatchesMessage(candidate, {
2483
+ sourceAlias: input.ownerAlias,
2484
+ targetAlias: input.workerAlias,
2485
+ pair: true,
2486
+ }) && progressWatchMatchesPair(routeWatch, candidate));
2487
+ if (pair === undefined) {
2488
+ throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "A restarted watch can bind only through its exact durable pair.");
2489
+ }
2490
+ return this.bindRouteProgressWatchConversation(state, now, input.conversationId, input.ownerAlias, input.workerAlias, pair);
2328
2491
  });
2329
2492
  }
2330
- async inspectProgressWatches() {
2331
- return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
2332
- }
2333
2493
  async inspectCompatibilityAttestation(surface, version) {
2334
2494
  return this.mutex.run("gateway", async () => {
2335
2495
  const attestation = this.requireState().compatibilityAttestations.find((candidate) => candidate.surface === surface && candidate.version === version);
@@ -2362,37 +2522,6 @@ export class GatewayStore {
2362
2522
  state.compatibilityAttestations.push(structuredClone(attestation));
2363
2523
  });
2364
2524
  }
2365
- async touchProgressWatch(input) {
2366
- return this.mutate(async (state, now) => {
2367
- const index = state.progressWatches.findIndex((watch) => watch.conversationId === input.conversationId);
2368
- const watch = state.progressWatches[index];
2369
- if (watch === undefined)
2370
- return false;
2371
- if (input.actorAlias !== watch.ownerAlias &&
2372
- input.actorAlias !== watch.workerAlias) {
2373
- throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watch activity did not come from an endpoint on its exact edge.");
2374
- }
2375
- const workerReportedComplete = input.workerReportedComplete === true &&
2376
- input.actorAlias === watch.workerAlias;
2377
- const transition = transitionProgressWatch(watch, {
2378
- type: "activity",
2379
- at: now.getTime(),
2380
- ...(workerReportedComplete
2381
- ? { workerReportedComplete: true }
2382
- : {}),
2383
- });
2384
- if (transition.state === null)
2385
- return false;
2386
- state.progressWatches[index] = transition.state;
2387
- if (workerReportedComplete) {
2388
- this.appendProgressWatchEvent(state, transition.state, {
2389
- kind: "worker_reported_complete",
2390
- timestamp: now.toISOString(),
2391
- });
2392
- }
2393
- return true;
2394
- });
2395
- }
2396
2525
  async touchProgressWatchesForAlias(alias) {
2397
2526
  return this.mutate(async (state, now) => {
2398
2527
  if (!ALIAS_PATTERN.test(alias))
@@ -2437,6 +2566,7 @@ export class GatewayStore {
2437
2566
  throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "Unresponsive settlement is owned only by the watch deadline machine.");
2438
2567
  }
2439
2568
  const transition = transitionProgressWatch(watch, event);
2569
+ this.requireProgressWatchJournalCapacity(state, 1);
2440
2570
  state.progressWatches.splice(index, 1);
2441
2571
  this.appendProgressWatchEvent(state, watch, {
2442
2572
  kind: input.outcome,
@@ -2448,6 +2578,12 @@ export class GatewayStore {
2448
2578
  /** Advance every due watch once under one durable store mutation. */
2449
2579
  async advanceDueProgressWatches() {
2450
2580
  return this.mutate(async (state, now) => {
2581
+ const terminalCount = state.progressWatches.filter((watch) => {
2582
+ if (Date.parse(watch.nextActionAt) > now.getTime())
2583
+ return false;
2584
+ return this.progressWatchDueTransition(state, watch, now).effects.some((effect) => effect.type === "settled");
2585
+ }).length;
2586
+ this.requireProgressWatchJournalCapacity(state, terminalCount);
2451
2587
  const actions = [];
2452
2588
  const retained = [];
2453
2589
  for (const watch of state.progressWatches) {
@@ -2550,6 +2686,7 @@ export class GatewayStore {
2550
2686
  return this.mutate(async (state, now) => {
2551
2687
  const { claude, codex } = this.requirePairRoutes(state, input, false);
2552
2688
  const pair = this.requireExactPair(state, claude, codex);
2689
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
2553
2690
  const plan = this.validateAffectedPairInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
2554
2691
  const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
2555
2692
  this.settleProgressWatchesForPair(state, pair, now);
@@ -2735,7 +2872,6 @@ export class GatewayStore {
2735
2872
  state: "dispatching",
2736
2873
  bytes: metadata.bytes,
2737
2874
  body,
2738
- hopCount: metadata.hopCount,
2739
2875
  ...(metadata.steer === true ? { steer: true } : {}),
2740
2876
  latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
2741
2877
  });
@@ -2864,7 +3000,6 @@ export class GatewayStore {
2864
3000
  state: "held",
2865
3001
  bytes: metadata.bytes,
2866
3002
  body,
2867
- hopCount: metadata.hopCount,
2868
3003
  ...(metadata.steer === true ? { steer: true } : {}),
2869
3004
  ...(safeErrorCode === undefined ? {} : { safeErrorCode }),
2870
3005
  latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
@@ -2896,7 +3031,6 @@ export class GatewayStore {
2896
3031
  state: progress,
2897
3032
  bytes: metadata.bytes,
2898
3033
  ...(metadata.body === undefined ? {} : { body: metadata.body }),
2899
- hopCount: metadata.hopCount,
2900
3034
  ...(metadata.steer === true ? { steer: true } : {}),
2901
3035
  latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
2902
3036
  });
@@ -3014,7 +3148,6 @@ export class GatewayStore {
3014
3148
  nextActionAt: watch.nextActionAt,
3015
3149
  idleMs: watch.idleMs,
3016
3150
  nudgeCount: watch.nudgeCount,
3017
- workerReportedComplete: watch.workerReportedCompleteAt !== undefined,
3018
3151
  }))
3019
3152
  .sort((left, right) => `${left.nextActionAt}\0${left.ownerAlias}\0${left.workerAlias}`.localeCompare(`${right.nextActionAt}\0${right.ownerAlias}\0${right.workerAlias}`));
3020
3153
  const progressWatchEvents = state.progressWatchEvents
@@ -3248,6 +3381,9 @@ export class GatewayStore {
3248
3381
  }
3249
3382
  replaceCodexRouteForSuccession(state, route, identity, now, routeState, compatibility) {
3250
3383
  const oldAlias = route.alias;
3384
+ const affectedAliases = new Set([oldAlias, identity.alias]);
3385
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => affectedAliases.has(watch.ownerAlias) ||
3386
+ affectedAliases.has(watch.workerAlias)).length);
3251
3387
  route.alias = identity.alias;
3252
3388
  route.binding = { ...identity.binding };
3253
3389
  route.registrationMode = "explicit_opt_in";
@@ -3272,8 +3408,8 @@ export class GatewayStore {
3272
3408
  record.targetAlias !== identity.alias);
3273
3409
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== oldAlias &&
3274
3410
  bucket.sourceAlias !== identity.alias);
3275
- this.settleProgressWatchesForAliases(state, new Set([oldAlias, identity.alias]), now);
3276
- removePairsForAliases(state, new Set([oldAlias, identity.alias]));
3411
+ this.settleProgressWatchesForAliases(state, affectedAliases, now);
3412
+ removePairsForAliases(state, affectedAliases);
3277
3413
  }
3278
3414
  assertAllowedIdentity(identity) {
3279
3415
  if (!isPrivateEndpointIdentity(identity)) {
@@ -3443,12 +3579,6 @@ export class GatewayStore {
3443
3579
  this.recordRejection(state, sides, bytes, now, "MESSAGE_TOO_LARGE", input.steer, input.conversationIdSuffix);
3444
3580
  throw new BridgeError("MESSAGE_TOO_LARGE", "The transient message exceeds the configured byte limit.");
3445
3581
  }
3446
- const hopCount = input.hopCount ?? 0;
3447
- if (!isNonNegativeInteger(hopCount) ||
3448
- hopCount > this.config.limits.maxHopCount) {
3449
- this.recordRejection(state, sides, bytes, now, "HOP_LIMIT_EXCEEDED", input.steer, input.conversationIdSuffix);
3450
- throw new BridgeError("HOP_LIMIT_EXCEEDED", "The message exceeds the configured gateway hop limit.");
3451
- }
3452
3582
  if (typeof input.dedupeKey !== "string" ||
3453
3583
  input.dedupeKey.length === 0 ||
3454
3584
  Buffer.byteLength(input.dedupeKey) > 512) {
@@ -3476,7 +3606,6 @@ export class GatewayStore {
3476
3606
  ? {}
3477
3607
  : { conversationIdSuffix: duplicate.conversationIdSuffix }),
3478
3608
  bytes,
3479
- hopCount,
3480
3609
  ...(input.steer === true ? { steer: true } : {}),
3481
3610
  });
3482
3611
  return {
@@ -3566,7 +3695,6 @@ export class GatewayStore {
3566
3695
  deadlineAt: deadline.toISOString(),
3567
3696
  bytes,
3568
3697
  body: input.body,
3569
- hopCount,
3570
3698
  ...(sides.pair === true ? { pair: true } : {}),
3571
3699
  ...(sides.transientTarget === true
3572
3700
  ? { transientTarget: true }
@@ -3618,7 +3746,6 @@ export class GatewayStore {
3618
3746
  state: "queued",
3619
3747
  bytes,
3620
3748
  body: input.body,
3621
- hopCount,
3622
3749
  ...(input.steer === true ? { steer: true } : {}),
3623
3750
  });
3624
3751
  return {
@@ -3684,7 +3811,6 @@ export class GatewayStore {
3684
3811
  targetAlias: sides.targetAlias,
3685
3812
  state: "rejected",
3686
3813
  bytes: Math.max(1, bytes),
3687
- hopCount: 0,
3688
3814
  safeErrorCode,
3689
3815
  ...(steer === true ? { steer: true } : {}),
3690
3816
  });
@@ -3772,43 +3898,62 @@ export class GatewayStore {
3772
3898
  activity.openIdleMs !== undefined)) {
3773
3899
  throw new BridgeError("INVALID_PROGRESS_WATCH", "The message watch activity is malformed or contradictory.");
3774
3900
  }
3775
- const index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3901
+ const pair = sides.pair === true
3902
+ ? state.pairs.find((candidate) => pairMatchesMessage(candidate, sides))
3903
+ : undefined;
3904
+ let index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
3905
+ if (index < 0 &&
3906
+ activity.openIdleMs === undefined &&
3907
+ activity.completionSignal !== true &&
3908
+ pair !== undefined) {
3909
+ const routeMatches = state.progressWatches
3910
+ .map((watch, watchIndex) => ({ watch, watchIndex }))
3911
+ .filter(({ watch }) => watch.capability === "route" &&
3912
+ watch.ownerAlias === sides.sourceAlias &&
3913
+ watch.workerAlias === sides.targetAlias &&
3914
+ progressWatchMatchesPair(watch, pair));
3915
+ if (routeMatches.length === 1) {
3916
+ this.bindRouteProgressWatchConversation(state, now, activity.conversationId, sides.sourceAlias, sides.targetAlias, pair);
3917
+ index = routeMatches[0].watchIndex;
3918
+ }
3919
+ }
3776
3920
  const existing = state.progressWatches[index];
3777
3921
  if (existing !== undefined) {
3778
- if (activity.actorAlias !== existing.ownerAlias &&
3779
- activity.actorAlias !== existing.workerAlias) {
3922
+ if (pair === undefined ||
3923
+ !progressWatchMatchesPair(existing, pair) ||
3924
+ !((sides.sourceAlias === existing.ownerAlias &&
3925
+ sides.targetAlias === existing.workerAlias) ||
3926
+ (sides.sourceAlias === existing.workerAlias &&
3927
+ sides.targetAlias === existing.ownerAlias))) {
3780
3928
  throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watched conversation belongs to different exact endpoints.");
3781
3929
  }
3782
3930
  if (activity.openIdleMs !== undefined &&
3783
3931
  activity.actorAlias !== existing.ownerAlias) {
3784
3932
  throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may reassert tracking options.");
3785
3933
  }
3786
- if (activity.completionSignal === true &&
3787
- activity.actorAlias === existing.ownerAlias) {
3934
+ if (activity.completionSignal === true) {
3935
+ const transition = transitionProgressWatch(existing, activity.actorAlias === existing.ownerAlias
3936
+ ? { type: "owner_done", at: now.getTime() }
3937
+ : { type: "worker_done", at: now.getTime() });
3938
+ if (transition.state !== null) {
3939
+ throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "The completion signal did not settle its exact watch.");
3940
+ }
3941
+ this.requireProgressWatchJournalCapacity(state, 1);
3788
3942
  state.progressWatches.splice(index, 1);
3789
3943
  this.appendProgressWatchEvent(state, existing, {
3790
- kind: "done",
3944
+ kind: activity.actorAlias === existing.ownerAlias
3945
+ ? "done"
3946
+ : "worker_reported_complete",
3791
3947
  timestamp: now.toISOString(),
3792
3948
  });
3793
3949
  return;
3794
3950
  }
3795
- const workerReportedComplete = activity.completionSignal === true &&
3796
- activity.actorAlias === existing.workerAlias;
3797
3951
  const transition = transitionProgressWatch(existing, {
3798
3952
  type: "activity",
3799
3953
  at: now.getTime(),
3800
- ...(workerReportedComplete
3801
- ? { workerReportedComplete: true }
3802
- : {}),
3803
3954
  });
3804
3955
  if (transition.state !== null) {
3805
3956
  state.progressWatches[index] = transition.state;
3806
- if (workerReportedComplete) {
3807
- this.appendProgressWatchEvent(state, transition.state, {
3808
- kind: "worker_reported_complete",
3809
- timestamp: now.toISOString(),
3810
- });
3811
- }
3812
3957
  }
3813
3958
  return;
3814
3959
  }
@@ -3817,13 +3962,9 @@ export class GatewayStore {
3817
3962
  if (this.config.trackingEnabled === false) {
3818
3963
  throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
3819
3964
  }
3820
- if (sides.pair !== true) {
3965
+ if (pair === undefined) {
3821
3966
  throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires one exact paired consent edge.");
3822
3967
  }
3823
- if (state.progressWatches.length >=
3824
- (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
3825
- throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
3826
- }
3827
3968
  const owner = state.routes.find((route) => route.alias === sides.sourceAlias);
3828
3969
  const worker = state.routes.find((route) => route.alias === sides.targetAlias);
3829
3970
  if (owner === undefined || worker === undefined) {
@@ -3838,11 +3979,10 @@ export class GatewayStore {
3838
3979
  idleMs: activity.openIdleMs,
3839
3980
  at: now.getTime(),
3840
3981
  });
3841
- state.progressWatches.push(watch);
3842
- this.appendProgressWatchEvent(state, watch, {
3843
- kind: "opened",
3844
- timestamp: now.toISOString(),
3845
- });
3982
+ if (!progressWatchMatchesPair(watch, pair)) {
3983
+ throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
3984
+ }
3985
+ this.installProgressWatch(state, watch, pair, now);
3846
3986
  }
3847
3987
  progressWatchDueTransition(state, watch, now) {
3848
3988
  const owner = state.routes.find((route) => route.alias === watch.ownerAlias &&
@@ -3886,6 +4026,7 @@ export class GatewayStore {
3886
4026
  effect.nudgeNumber === nudge.nudgeNumber)) {
3887
4027
  throw new BridgeError("PROGRESS_WATCH_NUDGE_NOT_DUE", "The requested progress-watch nudge is not currently due.", true);
3888
4028
  }
4029
+ this.requireProgressWatchJournalCapacity(state, 1);
3889
4030
  state.progressWatches[index] = transition.state;
3890
4031
  this.appendProgressWatchEvent(state, transition.state, {
3891
4032
  kind: "nudge",
@@ -3893,7 +4034,41 @@ export class GatewayStore {
3893
4034
  nudgeNumber: nudge.nudgeNumber,
3894
4035
  });
3895
4036
  }
4037
+ installProgressWatch(state, watch, pair, now) {
4038
+ const replacedIndex = state.progressWatches.findIndex((candidate) => progressWatchMatchesPair(candidate, pair));
4039
+ if (replacedIndex < 0 &&
4040
+ state.progressWatches.length >=
4041
+ (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
4042
+ throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
4043
+ }
4044
+ if (replacedIndex >= 0 &&
4045
+ state.progressWatches[replacedIndex].ownerAlias !== watch.ownerAlias) {
4046
+ throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the incumbent watch owner may replace this pair watch; ask that owner to untrack it first.");
4047
+ }
4048
+ this.requireProgressWatchJournalCapacity(state, replacedIndex < 0 ? 1 : 2);
4049
+ if (replacedIndex >= 0) {
4050
+ const replaced = state.progressWatches[replacedIndex];
4051
+ state.progressWatches.splice(replacedIndex, 1);
4052
+ this.appendProgressWatchEvent(state, replaced, {
4053
+ kind: "replaced",
4054
+ timestamp: now.toISOString(),
4055
+ });
4056
+ }
4057
+ state.progressWatches.push(watch);
4058
+ this.appendProgressWatchEvent(state, watch, {
4059
+ kind: "opened",
4060
+ timestamp: now.toISOString(),
4061
+ });
4062
+ }
4063
+ requireProgressWatchJournalCapacity(state, additions) {
4064
+ if (!Number.isSafeInteger(additions) ||
4065
+ additions < 0 ||
4066
+ state.watchSequence > Number.MAX_SAFE_INTEGER - additions) {
4067
+ throw new BridgeError("PROGRESS_WATCH_SEQUENCE_EXHAUSTED", "The bounded progress-watch journal sequence is exhausted.");
4068
+ }
4069
+ }
3896
4070
  appendProgressWatchEvent(state, watch, event) {
4071
+ this.requireProgressWatchJournalCapacity(state, 1);
3897
4072
  state.watchSequence += 1;
3898
4073
  state.progressWatchEvents.push({
3899
4074
  sequence: state.watchSequence,
@@ -3911,6 +4086,7 @@ export class GatewayStore {
3911
4086
  }
3912
4087
  }
3913
4088
  settleProgressWatchesForAliases(state, aliases, now) {
4089
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => aliases.has(watch.ownerAlias) || aliases.has(watch.workerAlias)).length);
3914
4090
  const retained = [];
3915
4091
  for (const watch of state.progressWatches) {
3916
4092
  if (!aliases.has(watch.ownerAlias) &&
@@ -3926,23 +4102,61 @@ export class GatewayStore {
3926
4102
  state.progressWatches = retained;
3927
4103
  }
3928
4104
  settleProgressWatchesForPair(state, pair, now) {
4105
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => progressWatchMatchesPair(watch, pair)).length);
3929
4106
  const retained = [];
3930
4107
  for (const watch of state.progressWatches) {
3931
- const matches = (watch.ownerAlias === pair.claudeAlias &&
3932
- watch.workerAlias === pair.codexAlias) ||
3933
- (watch.ownerAlias === pair.codexAlias &&
3934
- watch.workerAlias === pair.claudeAlias);
3935
- if (!matches) {
4108
+ if (!progressWatchMatchesPair(watch, pair)) {
3936
4109
  retained.push(watch);
3937
4110
  continue;
3938
4111
  }
3939
4112
  this.appendProgressWatchEvent(state, watch, {
3940
- kind: "endpoint_retired",
4113
+ kind: "pair_removed",
3941
4114
  timestamp: now.toISOString(),
3942
4115
  });
3943
4116
  }
3944
4117
  state.progressWatches = retained;
3945
4118
  }
4119
+ bindRouteProgressWatchConversation(state, now, conversationId, ownerAlias, workerAlias, pair) {
4120
+ const conversationOwner = state.progressWatches.find((watch) => watch.conversationId === conversationId);
4121
+ if (conversationOwner !== undefined &&
4122
+ (conversationOwner.capability !== "route" ||
4123
+ conversationOwner.ownerAlias !== ownerAlias ||
4124
+ conversationOwner.workerAlias !== workerAlias ||
4125
+ !progressWatchMatchesPair(conversationOwner, pair))) {
4126
+ throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The fresh conversation capability belongs to another exact watch.");
4127
+ }
4128
+ const matches = state.progressWatches
4129
+ .map((watch, index) => ({ watch, index }))
4130
+ .filter(({ watch }) => watch.capability === "route" &&
4131
+ watch.ownerAlias === ownerAlias &&
4132
+ watch.workerAlias === workerAlias &&
4133
+ progressWatchMatchesPair(watch, pair));
4134
+ if (matches.length === 0)
4135
+ return false;
4136
+ if (matches.length !== 1) {
4137
+ throw new BridgeError("CORRUPT_GATEWAY_STATE", "More than one route-capability watch claims the exact pair.");
4138
+ }
4139
+ const match = matches[0];
4140
+ this.requireProgressWatchJournalCapacity(state, 1);
4141
+ const restored = transitionProgressWatch(match.watch, {
4142
+ type: "restart",
4143
+ at: now.getTime(),
4144
+ conversationCapabilityRestored: true,
4145
+ });
4146
+ if (restored.state === null) {
4147
+ throw new BridgeError("INVALID_PROGRESS_WATCH_REBIND", "The exact progress watch could not restore conversation authority.");
4148
+ }
4149
+ const rebound = {
4150
+ ...restored.state,
4151
+ conversationId,
4152
+ };
4153
+ state.progressWatches[match.index] = rebound;
4154
+ this.appendProgressWatchEvent(state, rebound, {
4155
+ kind: "conversation_rebound",
4156
+ timestamp: now.toISOString(),
4157
+ });
4158
+ return true;
4159
+ }
3946
4160
  finishMetadata(state, metadata, deliveryState, now, safeErrorCode) {
3947
4161
  const counterKey = deliveryState;
3948
4162
  state.accounting[counterKey] += 1;
@@ -3966,7 +4180,6 @@ export class GatewayStore {
3966
4180
  state: deliveryState,
3967
4181
  bytes: metadata.bytes,
3968
4182
  ...(metadata.body === undefined ? {} : { body: metadata.body }),
3969
- hopCount: metadata.hopCount,
3970
4183
  ...(metadata.steer === true ? { steer: true } : {}),
3971
4184
  latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
3972
4185
  ...(safeErrorCode ? { safeErrorCode } : {}),
@@ -4109,6 +4322,7 @@ export class GatewayStore {
4109
4322
  const state = this.requireState();
4110
4323
  this.recoverCodexSuccessionAfterRestart(state, now);
4111
4324
  if (this.config.trackingEnabled === false) {
4325
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.length);
4112
4326
  for (const watch of state.progressWatches) {
4113
4327
  this.appendProgressWatchEvent(state, watch, {
4114
4328
  kind: "disabled",
@@ -4117,6 +4331,9 @@ export class GatewayStore {
4117
4331
  }
4118
4332
  state.progressWatches = [];
4119
4333
  }
4334
+ else {
4335
+ this.requireProgressWatchJournalCapacity(state, state.progressWatches.filter((watch) => !watch.degradedNoticeSent).length);
4336
+ }
4120
4337
  for (let index = 0; index < state.progressWatches.length; index += 1) {
4121
4338
  const watch = state.progressWatches[index];
4122
4339
  if (watch === undefined)
@@ -4129,7 +4346,7 @@ export class GatewayStore {
4129
4346
  if (transition.state !== null) {
4130
4347
  state.progressWatches[index] = transition.state;
4131
4348
  }
4132
- if (transition.effects.some((effect) => effect.type === "notify_capability_degraded")) {
4349
+ if (transition.effects.some((effect) => effect.type === "record_capability_degraded")) {
4133
4350
  this.appendProgressWatchEvent(state, transition.state ?? watch, {
4134
4351
  kind: "capability_degraded",
4135
4352
  timestamp: now.toISOString(),
@@ -4444,6 +4661,9 @@ export class GatewayStore {
4444
4661
  parsed = migratePreCompatibilityAttestations(parsed);
4445
4662
  parsed = migratePreCodexEndpointRefreshJournal(parsed);
4446
4663
  parsed = migratePreCodexOrphanRemovalJournal(parsed);
4664
+ parsed = migrateLegacyHopCounts(parsed);
4665
+ parsed = migrateLegacyCompatibilityCertifications(parsed);
4666
+ parsed = migrateLegacyProgressWatchState(parsed, this.config.limits.eventCapacity);
4447
4667
  if (!isPersistedState(parsed)) {
4448
4668
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict schema validation.");
4449
4669
  }