@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -34,6 +34,20 @@ export function markResponseNonReplayable(response: Response): void {
34
34
  nonReplayableResponses.add(response);
35
35
  }
36
36
 
37
+ /**
38
+ * A server-instructed wait longer than the retry budget: same-target 429 replay loops stop and
39
+ * return the original response, while key rotation and ordinary error delivery are unaffected.
40
+ */
41
+ const sameTargetRetryHolds = new WeakSet<Response>();
42
+
43
+ export function markSameTargetRetryHold(response: Response): void {
44
+ sameTargetRetryHolds.add(response);
45
+ }
46
+
47
+ export function isSameTargetRetryHold(response: Response): boolean {
48
+ return sameTargetRetryHolds.has(response);
49
+ }
50
+
37
51
  export function isNonReplayableResponse(response: Response): boolean {
38
52
  return nonReplayableResponses.has(response);
39
53
  }
@@ -144,6 +158,7 @@ export function retainReplayRefusal<T extends Response>(response: T): T {
144
158
 
145
159
  /** Carry the verdict from a response onto the one that replaces it. */
146
160
  export function carryReplayRefusal<T extends Response>(source: Response, rewrapped: T): T {
161
+ if (sameTargetRetryHolds.has(source)) sameTargetRetryHolds.add(rewrapped);
147
162
  return isReplayRefusalResponse(source) ? retainReplayRefusal(rewrapped) : rewrapped;
148
163
  }
149
164
 
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What a Remote Link Child tells its Home about the last settings apply (OCX-6).
3
+ *
4
+ * The Child knows when it last applied the Home's Codex settings, which digest it applied and when
5
+ * its runtime started; the Home knows none of it. The Child reports these facts over the link
6
+ * (`POST` to {@link LINK_CHILD_PARITY_PATH}, admitted by its link key) and the Home shows them on
7
+ * each link row of `GET /api/link/status` (`lastApplyAt`, `runtimeStartedAt`,
8
+ * `appliedCodexSettingsDigest`), so a peer can compare apply time with runtime start, and the
9
+ * applied digest with the Home's own, without reading the Child.
10
+ *
11
+ * Display only. A report never admits, routes or changes anything on the Home, carries no setting
12
+ * value, and lives in memory: a Home restart forgets it until the Child's next report (at most a
13
+ * minute later).
14
+ */
15
+ import { CODEX_SETTINGS_DIGEST_PATTERN } from "./codex-settings";
16
+ import { LINK_MANAGEMENT_PREFIX } from "./management-routes";
17
+
18
+ /** The one write a Child may send its Home over the link. */
19
+ export const LINK_CHILD_PARITY_PATH = `${LINK_MANAGEMENT_PREFIX}/child-parity`;
20
+ /** A report is four short fields; anything larger is refused while it is still being read. */
21
+ export const LINK_CHILD_PARITY_MAX_BYTES = 1024;
22
+
23
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
24
+ /** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */
25
+ const MIN_EPOCH_MS = 1_000_000_000_000;
26
+ const MAX_EPOCH_MS = 9_999_999_999_999;
27
+
28
+ export interface LinkChildParityReport {
29
+ linkId: string;
30
+ /** Epoch ms of the Child's last successful apply of a different Home settings document. */
31
+ lastApplyAt: number | null;
32
+ /** Epoch ms of the Child runtime's process start. */
33
+ runtimeStartedAt: number;
34
+ /** `appliedCodexSettingsDigest` of the applied settings; null before any apply. */
35
+ appliedCodexSettingsDigest: string | null;
36
+ /**
37
+ * Increases with every report this runtime process sends. With `runtimeStartedAt` it orders
38
+ * reports, so a late POST from an older runtime or an earlier send never replaces a newer one.
39
+ */
40
+ sequence: number;
41
+ }
42
+
43
+ /** The per-link fields `GET /api/link/status` carries; every one is null until a report arrives. */
44
+ export interface LinkChildParity {
45
+ lastApplyAt: number | null;
46
+ runtimeStartedAt: number | null;
47
+ appliedCodexSettingsDigest: string | null;
48
+ /** Epoch ms the Home received the report (the Home's clock; the other times are the Child's). */
49
+ reportedAt: number | null;
50
+ }
51
+
52
+ export const EMPTY_LINK_CHILD_PARITY: LinkChildParity = Object.freeze({
53
+ lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null, reportedAt: null,
54
+ });
55
+
56
+ export function isEpochMs(value: unknown): value is number {
57
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= MIN_EPOCH_MS && value <= MAX_EPOCH_MS;
58
+ }
59
+
60
+ function exactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
62
+ const actual = Object.keys(value);
63
+ return actual.length === keys.length && actual.every(key => keys.includes(key));
64
+ }
65
+
66
+ /** Strict: an unknown field, a non-integer time or a malformed digest rejects the report. */
67
+ export function parseLinkChildParityReport(value: unknown): LinkChildParityReport | null {
68
+ if (!exactKeys(value, ["linkId", "lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "sequence"])) return null;
69
+ const { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence } = value;
70
+ if (typeof linkId !== "string" || !LINK_ID.test(linkId)) return null;
71
+ if (lastApplyAt !== null && !isEpochMs(lastApplyAt)) return null;
72
+ if (!isEpochMs(runtimeStartedAt)) return null;
73
+ if (digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) return null;
74
+ // An apply has both a time and a digest; before the first apply it has neither.
75
+ if ((lastApplyAt === null) !== (digest === null)) return null;
76
+ if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 0) return null;
77
+ return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence };
78
+ }
79
+
80
+ export interface LinkChildParityStore {
81
+ /**
82
+ * Keep the report for its link unless a newer one is already kept (by `runtimeStartedAt`, then
83
+ * `sequence`), dropping every entry whose link is not in `currentLinkIds`. True when kept.
84
+ */
85
+ record(report: LinkChildParityReport, currentLinkIds: ReadonlySet<string>): boolean;
86
+ get(linkId: string): LinkChildParity;
87
+ clear(): void;
88
+ }
89
+
90
+ export function createLinkChildParityStore(now: () => number = Date.now): LinkChildParityStore {
91
+ const reports = new Map<string, LinkChildParity & { sequence: number }>();
92
+ return {
93
+ record(report, currentLinkIds) {
94
+ for (const id of reports.keys()) if (!currentLinkIds.has(id)) reports.delete(id);
95
+ if (!currentLinkIds.has(report.linkId)) return false;
96
+ const kept = reports.get(report.linkId);
97
+ if (kept && (kept.runtimeStartedAt! > report.runtimeStartedAt
98
+ || (kept.runtimeStartedAt === report.runtimeStartedAt && kept.sequence >= report.sequence))) return false;
99
+ reports.set(report.linkId, {
100
+ lastApplyAt: report.lastApplyAt,
101
+ runtimeStartedAt: report.runtimeStartedAt,
102
+ appliedCodexSettingsDigest: report.appliedCodexSettingsDigest,
103
+ reportedAt: now(),
104
+ sequence: report.sequence,
105
+ });
106
+ return true;
107
+ },
108
+ get(linkId) {
109
+ const kept = reports.get(linkId);
110
+ if (!kept) return { ...EMPTY_LINK_CHILD_PARITY };
111
+ const { sequence: _sequence, ...parity } = kept;
112
+ return parity;
113
+ },
114
+ clear() { reports.clear(); },
115
+ };
116
+ }
117
+
118
+ /** The Home's reports: one process-wide store, bounded by the link store on every write. */
119
+ export const homeLinkChildParity: LinkChildParityStore = createLinkChildParityStore();
120
+
121
+ /** Home link listener: is this the Child's report (exact path, POST, no query, no upgrade)? */
122
+ export function linkChildParityRouteAllowed(url: URL, req: Request): boolean {
123
+ return url.pathname === LINK_CHILD_PARITY_PATH && req.method === "POST" && url.search === "" && !req.headers.has("upgrade");
124
+ }
@@ -23,6 +23,7 @@
23
23
  * It rides on its own authenticated link read rather than inside the catalog because the
24
24
  * catalog bytes are written verbatim to `model_catalog_json`, which Codex parses itself.
25
25
  */
26
+ import { createHash } from "node:crypto";
26
27
  import { hostname } from "node:os";
27
28
  import type { OcxConfig } from "../types";
28
29
 
@@ -153,3 +154,39 @@ export function describeHomeCodexSettings(settings: HomeCodexSettings): string[]
153
154
  `native sub-agent defaults: ${d ? `${d.model}${d.reasoningEffort ? ` (${d.reasoningEffort})` : ""}` : "not synced"}`,
154
155
  ];
155
156
  }
157
+
158
+ /** 64 lowercase hex digits: the only form a settings digest takes on the wire. */
159
+ export const CODEX_SETTINGS_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
160
+
161
+ /**
162
+ * The exact bytes {@link codexSettingsDigest} hashes: compact UTF-8 JSON in a fixed key order,
163
+ *
164
+ * {"version":1,"settings":{"fastMode":…,"multiAgentMode":…,"webSearchSidecarEnabled":…,
165
+ * "subagentDefaults":null|{"model":…,"reasoningEffort":…}}}
166
+ *
167
+ * This is a published contract (GOAT's hub recomputes it byte for byte), so the order is written
168
+ * out here rather than left to a canonicalizer, and every value is copied by name: whatever key
169
+ * order or extra field the input object has, the output is the same.
170
+ */
171
+ export function codexSettingsDigestPayload(settings: HomeCodexSettings): string {
172
+ const d = settings.subagentDefaults;
173
+ return JSON.stringify({
174
+ version: HOME_CODEX_SETTINGS_VERSION,
175
+ settings: {
176
+ fastMode: settings.fastMode,
177
+ multiAgentMode: settings.multiAgentMode,
178
+ webSearchSidecarEnabled: settings.webSearchSidecarEnabled,
179
+ subagentDefaults: d ? { model: d.model, reasoningEffort: d.reasoningEffort } : null,
180
+ },
181
+ });
182
+ }
183
+
184
+ /**
185
+ * `appliedCodexSettingsDigest` (OCX-6b): lowercase hex SHA-256 of
186
+ * {@link codexSettingsDigestPayload}. It covers the applied values only, never the Home's name or
187
+ * version, so a Home and a Child that applied the same values agree, and any value change differs.
188
+ * It carries no setting value, so it is safe wherever `ocx status --json` goes.
189
+ */
190
+ export function codexSettingsDigest(settings: HomeCodexSettings): string {
191
+ return createHash("sha256").update(codexSettingsDigestPayload(settings), "utf8").digest("hex");
192
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Home and Child compatibility over a Remote Link.
3
+ *
4
+ * `LINK_PROTOCOL_RELEASES` is the published record of which release first spoke each part of the
5
+ * link protocol. It is history, so it cannot be derived at run time; `tests/clients/link-peer-
6
+ * compatibility.test.ts` holds every id to the live feature registry and every release to the
7
+ * package version. The verdict uses only what the link carries: the relay-auth version of a
8
+ * verified challenge and, from 2.91.0, the Child's own version and feature report
9
+ * (`src/link/peer-report.ts`).
10
+ */
11
+ import { LINK_RELAY_AUTH_VERSION } from "./relay-auth";
12
+ import { LINK_PEER_REPORT_VERSION, type LinkPeerEntry } from "./peer-report";
13
+
14
+ export interface LinkProtocolRelease {
15
+ /** The peer feature id, as `GET /readyz` lists it. */
16
+ readonly id: string;
17
+ /** First release that speaks it on both sides of a link. */
18
+ readonly since: string;
19
+ readonly summary: string;
20
+ }
21
+
22
+ export const LINK_PEER_REPORT_FEATURE = `link-peer-report/${LINK_PEER_REPORT_VERSION}`;
23
+
24
+ export const LINK_PROTOCOL_RELEASES: readonly LinkProtocolRelease[] = [
25
+ { id: "relay-auth/2", since: "2.69.0", summary: "Connection-bound relay authentication before every relayed request." },
26
+ { id: "link-management-relay", since: "2.84.0", summary: "Read-only Home management and Home Codex settings for the Child." },
27
+ { id: LINK_PEER_REPORT_FEATURE, since: "2.91.0", summary: "The Child reports its version and features on the relay challenge." },
28
+ ];
29
+
30
+ export type LinkCompatibilityVerdict = "compatible" | "degraded" | "incompatible" | "unknown";
31
+
32
+ export interface LinkLocalPeer {
33
+ version: string;
34
+ relayAuth: string;
35
+ features: string[];
36
+ }
37
+
38
+ export interface LinkPeerStatus {
39
+ observedAt: string | null;
40
+ /** The Child's reported release, or null when it predates the report. */
41
+ version: string | null;
42
+ /** For an unreported version: the oldest release the observed protocol implies, and the first that would report. */
43
+ versionRange: { min: string | null; below: string } | null;
44
+ relayAuth: string | null;
45
+ features: string[] | null;
46
+ compatibility: LinkCompatibilityVerdict;
47
+ reasons: string[];
48
+ }
49
+
50
+ function releaseParts(version: string): number[] {
51
+ return version.split(/[-+]/, 1)[0]!.split(".").map(part => Number(part));
52
+ }
53
+
54
+ export function compareReleases(a: string, b: string): number {
55
+ const left = releaseParts(a), right = releaseParts(b);
56
+ for (let i = 0; i < 3; i++) {
57
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
58
+ if (diff !== 0) return Math.sign(diff);
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ function sinceOf(id: string): string | null {
64
+ return LINK_PROTOCOL_RELEASES.find(release => release.id === id)?.since ?? null;
65
+ }
66
+
67
+ function relayAuthNumber(value: string): number {
68
+ return /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
69
+ }
70
+
71
+ export function linkPeerStatus(local: LinkLocalPeer, entry: LinkPeerEntry): LinkPeerStatus {
72
+ const { observed, refused } = entry;
73
+ if (refused && (!observed || observed.observedAt <= refused.refusedAt)) {
74
+ const newerChild = relayAuthNumber(refused.relayAuth) > relayAuthNumber(local.relayAuth);
75
+ return {
76
+ observedAt: refused.refusedAt, version: null, versionRange: null, relayAuth: refused.relayAuth, features: null,
77
+ compatibility: "incompatible",
78
+ reasons: [`The Child speaks relay-auth/${refused.relayAuth} and this Home speaks relay-auth/${local.relayAuth}, so relayed requests are refused. Upgrade the ${newerChild ? "Home" : "Child"}.`],
79
+ };
80
+ }
81
+ if (!observed) {
82
+ return {
83
+ observedAt: null, version: null, versionRange: null, relayAuth: null, features: null,
84
+ compatibility: "unknown", reasons: ["No relayed request from this Child since the Home started."],
85
+ };
86
+ }
87
+ const base = { observedAt: observed.observedAt, relayAuth: observed.relayAuth, features: observed.features };
88
+ if (!observed.reported) {
89
+ return {
90
+ ...base, version: null,
91
+ versionRange: { min: sinceOf(`relay-auth/${observed.relayAuth}`), below: sinceOf(LINK_PEER_REPORT_FEATURE)! },
92
+ compatibility: "compatible",
93
+ reasons: [`relay-auth/${observed.relayAuth} matches. The Child does not report its version; it predates ${LINK_PEER_REPORT_FEATURE}.`],
94
+ };
95
+ }
96
+ if (observed.version === null || observed.features === null) {
97
+ // A reporting Child whose version or feature list was missing or rejected: the Home cannot tell what it uses.
98
+ return {
99
+ ...base, version: observed.version, versionRange: null, compatibility: "unknown",
100
+ reasons: [`relay-auth/${observed.relayAuth} matches, but the Child's ${observed.version === null && observed.features === null ? "version and feature list were" : observed.version === null ? "version was" : "feature list was"} missing or invalid, so its features cannot be checked.`],
101
+ };
102
+ }
103
+ const childOnly = observed.features.filter(id => !local.features.includes(id));
104
+ if (childOnly.length > 0) {
105
+ return {
106
+ ...base, version: observed.version, versionRange: null, compatibility: "degraded",
107
+ reasons: [`The Child (${observed.version}) uses ${childOnly.join(", ")}, which this Home (${local.version}) does not serve. Upgrade the Home.`],
108
+ };
109
+ }
110
+ return {
111
+ ...base, version: observed.version, versionRange: null, compatibility: "compatible",
112
+ reasons: [compareReleases(observed.version, local.version) === 0 ? "Same release as this Home."
113
+ : `relay-auth/${observed.relayAuth} matches and this Home (${local.version}) serves every feature the Child (${observed.version}) uses.`],
114
+ };
115
+ }
116
+
117
+ export function localLinkPeer(version: string, features: readonly string[]): LinkLocalPeer {
118
+ return { version, relayAuth: LINK_RELAY_AUTH_VERSION, features: [...features] };
119
+ }
@@ -29,6 +29,8 @@ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [
29
29
  "/api/sidecar-settings",
30
30
  "/api/v2",
31
31
  "/api/provider-quotas",
32
+ "/api/quota-pool",
33
+ "/api/fallback",
32
34
  "/api/codex-auth/active",
33
35
  "/api/codex-auth/accounts",
34
36
  "/api/oauth/accounts",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a Remote Link Child tells its Home about itself, and what the Home remembers of it.
3
+ *
4
+ * A Child sends its release version and its peer feature ids (`src/remote/features.ts`) as two
5
+ * headers on the credential-free relay-auth challenge it already sends before every relayed
6
+ * request (`src/client/link-relay-transport.ts`). The Home records them only after the caller
7
+ * proof verifies, so an observation is bound to the link key; it is still the Child's own claim
8
+ * and is used for display and compatibility verdicts, never for admission.
9
+ *
10
+ * A Home older than this report ignores the headers, and a Child older than it sends none: the
11
+ * Home then knows only the relay-auth version the challenge itself names. Observations live in
12
+ * the Home's memory and start empty after a restart; the next relayed request refills them.
13
+ */
14
+ export const LINK_PEER_REPORT_VERSION = "1";
15
+ export const LINK_PEER_VERSION_HEADER = "x-opencodex-link-peer-version";
16
+ export const LINK_PEER_FEATURES_HEADER = "x-opencodex-link-peer-features";
17
+
18
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/;
19
+ const FEATURE_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,79}$/;
20
+ const MAX_FEATURES = 64;
21
+ const MAX_LINKS = 256;
22
+
23
+ export interface LinkPeerReport {
24
+ /** True when the challenge carried either report header, valid or not; false for a pre-report Child. */
25
+ reported: boolean;
26
+ version: string | null;
27
+ features: string[] | null;
28
+ }
29
+
30
+ /** Header values a Child sends. Invalid input yields no header rather than a malformed one. */
31
+ export function linkPeerReportHeaders(version: string, features: readonly string[]): Record<string, string> {
32
+ const headers: Record<string, string> = {};
33
+ if (RELEASE_VERSION.test(version)) headers[LINK_PEER_VERSION_HEADER] = version;
34
+ const ids = parseFeatureList(features.join(","));
35
+ if (ids) headers[LINK_PEER_FEATURES_HEADER] = ids.join(",");
36
+ return headers;
37
+ }
38
+
39
+ function parseFeatureList(value: string | null): string[] | null {
40
+ if (value === null || value === "") return null;
41
+ const ids = value.split(",");
42
+ if (ids.length > MAX_FEATURES || new Set(ids).size !== ids.length || ids.some(id => !FEATURE_ID.test(id))) return null;
43
+ return ids;
44
+ }
45
+
46
+ /** Home side: read a report from challenge headers. Each field is dropped on its own when invalid. */
47
+ export function parseLinkPeerReport(headers: Headers): LinkPeerReport {
48
+ const version = headers.get(LINK_PEER_VERSION_HEADER);
49
+ const features = headers.get(LINK_PEER_FEATURES_HEADER);
50
+ return {
51
+ reported: version !== null || features !== null,
52
+ version: version !== null && RELEASE_VERSION.test(version) ? version : null,
53
+ features: parseFeatureList(features),
54
+ };
55
+ }
56
+
57
+ export interface LinkPeerObservation extends LinkPeerReport {
58
+ /** The relay-auth version of the verified challenge. */
59
+ relayAuth: string;
60
+ observedAt: string;
61
+ }
62
+
63
+ /** A challenge for a known link and key that named a relay-auth version this Home does not speak. */
64
+ export interface LinkPeerRefusal {
65
+ relayAuth: string;
66
+ refusedAt: string;
67
+ }
68
+
69
+ export interface LinkPeerEntry {
70
+ observed: LinkPeerObservation | null;
71
+ refused: LinkPeerRefusal | null;
72
+ }
73
+
74
+ export interface LinkPeerObservations {
75
+ observe(linkId: string, relayAuth: string, report: LinkPeerReport): void;
76
+ refuse(linkId: string, relayAuth: string): void;
77
+ get(linkId: string): LinkPeerEntry;
78
+ }
79
+
80
+ export function createLinkPeerObservations(now: () => Date = () => new Date()): LinkPeerObservations {
81
+ const entries = new Map<string, LinkPeerEntry>();
82
+ const update = (linkId: string, change: Partial<LinkPeerEntry>) => {
83
+ const current = entries.get(linkId) ?? { observed: null, refused: null };
84
+ entries.delete(linkId);
85
+ entries.set(linkId, { ...current, ...change });
86
+ // Only known link ids reach here; the cap bounds memory if the store churns for a long time.
87
+ if (entries.size > MAX_LINKS) entries.delete(entries.keys().next().value!);
88
+ };
89
+ return {
90
+ observe(linkId, relayAuth, report) {
91
+ update(linkId, { observed: { relayAuth, ...report, observedAt: now().toISOString() }, refused: null });
92
+ },
93
+ refuse(linkId, relayAuth) {
94
+ update(linkId, { refused: { relayAuth, refusedAt: now().toISOString() } });
95
+ },
96
+ get(linkId) {
97
+ return entries.get(linkId) ?? { observed: null, refused: null };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Windows recognition for Remote Link Children.
3
+ *
4
+ * Remote Link drives its Child through a POSIX `sh` prelude, so a Windows Child is refused
5
+ * before any link key is issued or tunnel started. A Windows OpenSSH server shows up in one of
6
+ * three ways, depending on its default shell:
7
+ * - Git Bash, MSYS2 or Cygwin: `uname -s` reports MINGW*, MSYS*, CYGWIN* (or Windows*), and the
8
+ * ocx prelude exits with REMOTE_PLATFORM_UNSUPPORTED.
9
+ * - cmd.exe: a POSIX command such as `true` or `sh` is "not recognized as an internal or
10
+ * external command".
11
+ * - PowerShell: the same command "is not recognized as the name of a cmdlet".
12
+ *
13
+ * Hub mode (`ocx hub invite` on a Hub, `ocx connect` on the client) supports Windows clients, so
14
+ * every refusal points there.
15
+ */
16
+ import { buildWindowsProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED } from "./ssh-argv";
17
+ import type { SshRunner, SshRunResult } from "./ssh-runner";
18
+
19
+ export type RemotePlatform = "windows" | "posix" | "unknown";
20
+
21
+ const WINDOWS_UNAME = /^(?:Windows|MSYS|MINGW|CYGWIN)/i;
22
+ const POSIX_UNAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
23
+ // Windows PowerShell says "the name of a cmdlet", PowerShell 7 "a name of a cmdlet". Localized
24
+ // installs say neither, which is what confirmWindowsShell is for.
25
+ const WINDOWS_SHELL_REFUSAL = /is not recognized as (?:an internal or external command|(?:the|a) name of a cmdlet)/i;
26
+ /** ssh's own failure status: the remote shell never ran, so there is nothing to ask. */
27
+ const SSH_TRANSPORT_FAILURE = 255;
28
+
29
+ /** Remote argv that prints the kernel name; under cmd or PowerShell, `sh` itself is refused. */
30
+ export const REMOTE_UNAME_ARGV: readonly string[] = ["sh", "-c", "uname -s 2>/dev/null", "ocx-platform"];
31
+
32
+ /** Server-side refusal text, shared by the dashboard and the CLI's JSON consumers. */
33
+ export const WINDOWS_CHILD_REFUSAL = "Remote Link Children require macOS or Linux; Windows is unsupported. Connect a Windows computer in Hub mode: run ocx hub invite on a Hub, then the printed ocx connect command on the Windows computer.";
34
+
35
+ /** Does this output come from cmd.exe or PowerShell refusing a POSIX command? */
36
+ export function isWindowsShellRefusal(output: string): boolean {
37
+ return WINDOWS_SHELL_REFUSAL.test(output);
38
+ }
39
+
40
+ /** Classify one remote command result. Transport failures and odd output stay unknown. */
41
+ export function classifyRemotePlatform(result: SshRunResult): RemotePlatform {
42
+ if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return "windows";
43
+ if (isWindowsShellRefusal(result.stderr) || isWindowsShellRefusal(result.stdout)) return "windows";
44
+ if (result.code !== 0) return "unknown";
45
+ const name = result.stdout.trim().split(/\r?\n/)[0] ?? "";
46
+ if (WINDOWS_UNAME.test(name)) return "windows";
47
+ return POSIX_UNAME.test(name) ? "posix" : "unknown";
48
+ }
49
+
50
+ /**
51
+ * Could this failure be a Windows shell whose refusal text is localized? Success, the prelude's own
52
+ * 125/127 answers and ssh transport failures are already explained, so they never cost another call.
53
+ */
54
+ export function mayBeWindowsShell(result: SshRunResult): boolean {
55
+ return result.code !== 0 && result.code !== REMOTE_PLATFORM_UNSUPPORTED
56
+ && result.code !== REMOTE_COMMAND_NOT_FOUND && result.code !== SSH_TRANSPORT_FAILURE;
57
+ }
58
+
59
+ /** Ask the host the fixed, locale-independent WINDOWS_OS_PROBE. Any failure answers false. */
60
+ export async function confirmWindowsShell(runner: SshRunner, alias: string, knownHostsFile: string, timeoutMs = 30_000): Promise<boolean> {
61
+ try {
62
+ const result = await runner.run(buildWindowsProbeArgv({ alias, knownHostsFile }), { timeoutMs });
63
+ return result.code === 0 && result.stdout.trim().split(/\r?\n/)[0]?.trim() === "Windows_NT";
64
+ } catch { return false; }
65
+ }
66
+
67
+ /** classifyRemotePlatform, plus the locale-independent follow-up when the answer could be Windows. */
68
+ export async function detectRemotePlatform(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<RemotePlatform> {
69
+ const platform = classifyRemotePlatform(result);
70
+ if (platform !== "unknown" || !mayBeWindowsShell(result)) return platform;
71
+ return await confirmWindowsShell(runner, alias, knownHostsFile) ? "windows" : "unknown";
72
+ }
73
+
74
+ /** The CLI sentence for a refused Windows Child. */
75
+ export function windowsChildRefusal(alias: string): string {
76
+ return `${alias} is a Windows computer. Remote Link Children require macOS or Linux, so no link key was issued and no tunnel was started. `
77
+ + `Connect it in Hub mode instead: run 'ocx hub invite' on a Hub, then run the printed 'ocx connect' command on ${alias}.`;
78
+ }
@@ -191,6 +191,25 @@ export function buildProbeArgv(options: ProbeArgvOptions): string[] {
191
191
  ];
192
192
  }
193
193
 
194
+ /**
195
+ * Locale-independent Windows check for a host whose SSH shell is cmd.exe or PowerShell. Both run
196
+ * `cmd /c echo %OS%`, and cmd expands it to `Windows_NT` regardless of display language. The
197
+ * command is a fixed literal; a POSIX shell answers "command not found" and nothing else.
198
+ */
199
+ export const WINDOWS_OS_PROBE = "cmd /c echo %OS%";
200
+
201
+ /** Run WINDOWS_OS_PROBE against an already trusted (pinned or just-probed) host key only. */
202
+ export function buildWindowsProbeArgv(options: { alias: string; knownHostsFile: string }): string[] {
203
+ const alias = assertSshAlias(options.alias);
204
+ return [
205
+ "ssh", "-T",
206
+ ...commonOptions(alias, options.knownHostsFile, "yes"),
207
+ "-o", "ClearAllForwardings=yes",
208
+ "--", alias,
209
+ WINDOWS_OS_PROBE,
210
+ ];
211
+ }
212
+
194
213
  /** Read the fingerprint of the host key recorded by a probe. */
195
214
  export function buildFingerprintArgv(tempKnownHostsFile: string): string[] {
196
215
  return ["ssh-keygen", "-l", "-f", assertKnownHostsPath(tempKnownHostsFile)];
@@ -2,6 +2,9 @@ import type { LinkStore } from "./store";
2
2
  import type { LinkTunnelStatus } from "./supervisor";
3
3
  import type { TunnelState } from "./tunnel-state";
4
4
  import { readCompensation, type CompensationStore } from "./compensation";
5
+ import { EMPTY_LINK_CHILD_PARITY, type LinkChildParity } from "./child-parity";
6
+ import { linkPeerStatus, type LinkLocalPeer, type LinkPeerStatus } from "./compatibility";
7
+ import type { LinkPeerEntry } from "./peer-report";
5
8
  import type { OcxClientConnectionConfig } from "../types";
6
9
 
7
10
  export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
@@ -9,7 +12,7 @@ export type LinkWireState = "connecting" | "connected" | "reconnecting" | "faile
9
12
  export interface LinkStatusDto {
10
13
  role: "standalone" | "home" | "child";
11
14
  listener: { state: "off" | "listening" | "failed"; port: number | null };
12
- links: Array<{
15
+ links: Array<LinkChildParity & {
13
16
  id: string;
14
17
  alias: string;
15
18
  direction: "hub-initiated" | "client-initiated";
@@ -17,6 +20,8 @@ export interface LinkStatusDto {
17
20
  since: string;
18
21
  reason: string | null;
19
22
  tunnelPort: number;
23
+ /** Home only: what the link carried from this Child, and the compatibility verdict. */
24
+ peer?: LinkPeerStatus;
20
25
  }>;
21
26
  child: null | {
22
27
  alias: string;
@@ -24,6 +29,17 @@ export interface LinkStatusDto {
24
29
  since: string;
25
30
  reason: string | null;
26
31
  };
32
+ /** This build's version and link protocol, the side every `peer` verdict compares against. */
33
+ local?: LinkLocalPeer;
34
+ }
35
+
36
+ /** Add this build's identity and, on a Home, each Child's observed peer status. */
37
+ export function attachLinkPeers(dto: LinkStatusDto, local: LinkLocalPeer, observation: (linkId: string) => LinkPeerEntry): LinkStatusDto {
38
+ if (dto.role === "home") {
39
+ for (const link of dto.links) link.peer = linkPeerStatus(local, observation(link.id));
40
+ }
41
+ dto.local = local;
42
+ return dto;
27
43
  }
28
44
 
29
45
  export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
@@ -75,6 +91,8 @@ export function projectLinkStatus(
75
91
  compensation: CompensationStore = readCompensation(),
76
92
  clientChild: LinkStatusDto["child"] = null,
77
93
  identity: ClientLinkIdentity | null = null,
94
+ /** The Home's view of each Child's last apply (OCX-6); every field is null without a report. */
95
+ parity: (linkId: string) => LinkChildParity = () => EMPTY_LINK_CHILD_PARITY,
78
96
  ): LinkStatusDto {
79
97
  const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
80
98
  const links = store.links.map(record => ({
@@ -83,6 +101,7 @@ export function projectLinkStatus(
83
101
  direction: record.direction,
84
102
  ...stateDetails(record, byId.get(record.id), compensation),
85
103
  tunnelPort: record.tunnelPort,
104
+ ...parity(record.id),
86
105
  }));
87
106
  const childRecord = config.runtimeRole === "client"
88
107
  ? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
@@ -101,7 +120,10 @@ export function projectLinkStatus(
101
120
  : client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
102
121
  if (client?.link && child && identity) {
103
122
  const active = links.find(link => link.id === client.link!.linkId);
104
- const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
123
+ const projected = {
124
+ id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias,
125
+ ...parity(client.link.linkId),
126
+ };
105
127
  if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
106
128
  else links.push(projected);
107
129
  }