@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -23,6 +23,7 @@ import { clearableDeadline, idleDeadline } from "../lib/abort";
23
23
  import { readBoundedResponseBody } from "../lib/bounded-body";
24
24
  import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
25
25
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
26
+ import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing";
26
27
  import {
27
28
  createTranslatorBudget,
28
29
  isTranslatorBudgetExceededError,
@@ -309,7 +310,7 @@ export interface ImageBridgeDeps {
309
310
  /** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */
310
311
  fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof globalThis.fetch;
311
312
  /** Reserve the routed provider's next request-start slot before each adapter dispatch. */
312
- waitForRequestSlot?: (signal?: AbortSignal) => Promise<void>;
313
+ waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
313
314
  /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
314
315
  onUsage?: (usage: OcxUsage | undefined) => void;
315
316
  /**
@@ -334,6 +335,7 @@ export interface ImageBridgeDeps {
334
335
  retryAfterHeader: string | null,
335
336
  responseHeaders?: Headers,
336
337
  retryParsed?: OcxParsedRequest,
338
+ originalResponse?: Response,
337
339
  ) =>
338
340
  | { adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind }
339
341
  | null
@@ -466,7 +468,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
466
468
  // expose buildRequest/fetchResponse/parseStream to the bridge, so collect their events through
467
469
  // an AdapterEventQueue and pass the bounded collection directly to the common scanner.
468
470
  if (adapter.runTurn) {
469
- await deps.waitForRequestSlot?.(signal);
471
+ const pacingSlot = (await deps.waitForRequestSlot?.(signal)) || undefined;
470
472
  const queue = createAdapterEventQueue({
471
473
  onBacklogExceeded: () => internalAbort.abort("runTurn backlog exceeded"),
472
474
  });
@@ -514,6 +516,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
514
516
  headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(),
515
517
  abortSignal: signal,
516
518
  translatorBudget,
519
+ pacingSlot,
517
520
  }, emit).then(closeOnAbort).catch(err => {
518
521
  if (accepting) {
519
522
  collectionError = err;
@@ -528,6 +531,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
528
531
  if (event.type !== "heartbeat") events.push(event);
529
532
  }
530
533
  } finally {
534
+ releaseProviderRequestSlot(pacingSlot);
531
535
  accepting = false;
532
536
  idle.cancel();
533
537
  signal.removeEventListener("abort", closeOnAbort);
@@ -561,10 +565,11 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
561
565
  }
562
566
 
563
567
  let headerDeadline = clearableDeadline(connectTimeoutMs, signal);
564
- const paceThenResetHeaderDeadline = async (): Promise<void> => {
568
+ const paceThenResetHeaderDeadline = async (): Promise<ProviderRequestSlot | undefined> => {
565
569
  headerDeadline.clear();
566
- await deps.waitForRequestSlot?.(signal);
570
+ const slot = (await deps.waitForRequestSlot?.(signal)) || undefined;
567
571
  headerDeadline = clearableDeadline(connectTimeoutMs, signal);
572
+ return slot;
568
573
  };
569
574
  try {
570
575
  /**
@@ -597,35 +602,43 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
597
602
  let response: Response;
598
603
  try {
599
604
  if (requestAdapter.fetchResponse) {
600
- await paceThenResetHeaderDeadline();
601
- deps.onAttemptSend?.(recovery);
602
- response = await requestAdapter.fetchResponse(request, {
603
- abortSignal: headerDeadline.signal,
604
- timeoutMs: connectTimeoutMs,
605
- returnRawErrors: true,
606
- stream: true,
607
- executor: requestFetch,
608
- });
605
+ const slot = await paceThenResetHeaderDeadline();
606
+ try {
607
+ deps.onAttemptSend?.(recovery);
608
+ response = await sendTrackingRequestSlot(slot, () => requestAdapter.fetchResponse!(request, {
609
+ abortSignal: headerDeadline.signal,
610
+ timeoutMs: connectTimeoutMs,
611
+ returnRawErrors: true,
612
+ stream: true,
613
+ executor: requestFetch,
614
+ }));
615
+ } finally {
616
+ releaseProviderRequestSlot(slot);
617
+ }
609
618
  } else {
610
619
  response = await fetchWithResetRetry(
611
620
  async (retryRecovery) => {
612
- await paceThenResetHeaderDeadline();
613
- // Record every helper-driven send (the callback runs for the first attempt and
614
- // each connection-reset replay); preserve the caller's recovery kind
615
- // (rate-limit-429 / key-429) when the retry layer supplies none.
616
- deps.onAttemptSend?.(retryRecovery ?? recovery);
617
- const h = new Headers(request.headers);
618
- if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
619
- // Same reset-recovery parity as the web-search loop: the replay needs
620
- // `keepalive: false` to abandon the pooled socket, because Bun has ignored the
621
- // hop-by-hop header alone (oven-sh/bun#20492).
622
- return requestFetch(request.url, applyUpstreamRecoveryInit({
623
- method: request.method,
624
- redirect: "manual",
625
- headers: h,
626
- body: request.body,
627
- signal: headerDeadline.signal,
628
- }, retryRecovery));
621
+ const slot = await paceThenResetHeaderDeadline();
622
+ try {
623
+ // Record every helper-driven send (the callback runs for the first attempt and
624
+ // each connection-reset replay); preserve the caller's recovery kind
625
+ // (rate-limit-429 / key-429) when the retry layer supplies none.
626
+ deps.onAttemptSend?.(retryRecovery ?? recovery);
627
+ const h = new Headers(request.headers);
628
+ if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
629
+ // Same reset-recovery parity as the web-search loop: the replay needs
630
+ // `keepalive: false` to abandon the pooled socket, because Bun has ignored the
631
+ // hop-by-hop header alone (oven-sh/bun#20492).
632
+ return await sendTrackingRequestSlot(slot, () => requestFetch(request.url, applyUpstreamRecoveryInit({
633
+ method: request.method,
634
+ redirect: "manual",
635
+ headers: h,
636
+ body: request.body,
637
+ signal: headerDeadline.signal,
638
+ }, retryRecovery)));
639
+ } finally {
640
+ releaseProviderRequestSlot(slot);
641
+ }
629
642
  },
630
643
  { replaySafe: true, abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
631
644
  );
@@ -671,8 +684,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
671
684
  prepared = await fetchOnce(adapter, "rate-limit-429");
672
685
  }
673
686
  // 429 key-failover parity with web-search / normal routed path.
674
- while (prepared.response.status === 429 && deps.on429) {
675
- const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers, iterParsed);
687
+ while ((prepared.response.status === 429
688
+ || (iterParsed._kiroAuthContext && (prepared.response.status === 400 || prepared.response.status === 403))) && deps.on429) {
689
+ const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
690
+ iterParsed, prepared.response);
676
691
  if (!rotated) break;
677
692
  try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
678
693
  adapter = rotated.adapter;
@@ -1,3 +1,4 @@
1
+ import { siblingOfLivePort } from "../codex/sibling-start";
1
2
  import { redactSecretString } from "../lib/redact";
2
3
  import type { ExportModel } from "../clients/config-export";
3
4
  import type { IntegrationClientId } from "./registry";
@@ -12,6 +13,9 @@ export async function refreshOwnedCatalogIntegrations(
12
13
  input: Omit<OwnedIntegrationRefreshInput, "clientId">,
13
14
  clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"],
14
15
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
16
+ // Client files are shared with the live proxy a sibling instance runs beside; their entries
17
+ // point at the owner's port, and refreshing them here would re-point them at this one.
18
+ if (siblingOfLivePort() !== null) return [];
15
19
  let models: Promise<readonly ExportModel[]> | undefined;
16
20
  const loadModels = () => models ??= Promise.resolve().then(() =>
17
21
  typeof input.models === "function" ? input.models() : input.models);
@@ -274,6 +274,7 @@ function childEnd(
274
274
  const nextSpaces = leadingSpaces(lines[ahead]!.body);
275
275
  if (nextSpaces === null) return null;
276
276
  if (nextSpaces <= indent) return index;
277
+ index = ahead - 1;
277
278
  continue;
278
279
  }
279
280
  if (quote === null && isComment(body)) return spaces <= indent ? index : null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Project a Messages request body onto the content a settled route actually serializes.
3
+ *
4
+ * `estimateClaudeRequestTokens` measures the caller's body as JSON. That is exactly right for the
5
+ * Anthropic-native wire, where a replayed `thinking` block — signature included — is forwarded
6
+ * verbatim. A routed wire may serialize far less, and on the Chat wire it does: replayed thinking
7
+ * becomes an optional `reasoning_content` string, the signature is never sent at all
8
+ * (`src/adapters/openai-chat/messages.ts` has no `signature` reference), and the text is dropped
9
+ * outright unless the model is on the provider's `preserveReasoningContentModels` list.
10
+ *
11
+ * The gap that opens is not a rounding error. On a captured 260-message Claude Code turn, replayed
12
+ * thinking blocks were 78.8% of the body's JSON characters and 56.7% of those characters were
13
+ * base64 signatures — bytes that are not prompt text under any tokenizer. Counting them made the
14
+ * published `message_start.usage.input_tokens` 3.28x the count the upstream actually reported
15
+ * (#4857 + the Paseo context meter it feeds), well past the >2x drift bound this estimator is held
16
+ * to (devlog 260711_claude_inbound 040 §3).
17
+ *
18
+ * The projection is therefore applied to the ESTIMATE only. The body the caller sent is never
19
+ * rewritten; this is a measurement that describes the route, not a transformation of the request.
20
+ */
21
+
22
+ /** Which replayed reasoning blocks and fields a wire serializes. */
23
+ export interface ClaudeThinkingProjection {
24
+ /** Serialize `thinking.thinking`, the model's own replayed text. */
25
+ text: boolean;
26
+ /**
27
+ * Serialize `thinking.signature`, the provider's base64 replay token. Real prompt size for
28
+ * wires that carry it; pure overhead for wires that do not.
29
+ */
30
+ signature: boolean;
31
+ /**
32
+ * Serialize a `redacted_thinking` block, whose `data` is an opaque provider blob. It rides
33
+ * alongside `thinking` rather than inside it: a wire can reconstruct the model's reasoning
34
+ * without carrying the encrypted form, and the Chat wire does exactly that.
35
+ */
36
+ redacted: boolean;
37
+ }
38
+
39
+ /**
40
+ * The Anthropic-native wire forwards a replayed thinking block verbatim, signature included, so
41
+ * nothing is projected away. This is the default: an unknown route keeps the measured body.
42
+ */
43
+ export const CLAUDE_NATIVE_THINKING: ClaudeThinkingProjection = { text: true, signature: true, redacted: true };
44
+
45
+ interface ProjectableBody {
46
+ system?: unknown;
47
+ messages?: unknown;
48
+ tools?: unknown;
49
+ }
50
+
51
+ /**
52
+ * One content block as the given wire would carry it.
53
+ *
54
+ * `undefined` means the wire sends nothing for it, and the caller drops the entry. A `thinking`
55
+ * block that keeps its text but not its signature is returned as a copy rather than edited: the
56
+ * block object is shared with the outbound request builder, which must stay untouched.
57
+ */
58
+ function projectBlock(block: unknown, thinking: ClaudeThinkingProjection): unknown {
59
+ if (!block || typeof block !== "object") return block;
60
+ if (!("type" in block)) return block;
61
+ if (block.type === "redacted_thinking") return thinking.redacted ? block : undefined;
62
+ if (block.type !== "thinking") return block;
63
+ if (!thinking.text) return undefined;
64
+ if (thinking.signature || !("signature" in block)) return block;
65
+ // Copy without the signature: the caller's block object is shared with the outbound request
66
+ // builder, so it must never be edited in place.
67
+ const { signature: _dropped, ...rest } = block;
68
+ return rest;
69
+ }
70
+
71
+ /**
72
+ * A copy of `raw` whose message content carries only the replayed thinking this route serializes.
73
+ *
74
+ * Pure and idempotent; the input is never mutated. Blocks are dropped only inside array-valued
75
+ * message `content`, which is the sole protocol position a replayed thinking block occupies.
76
+ * A message whose content array empties out is left as an empty array rather than deleted: the
77
+ * adapter's own "nothing left to send" rule is keyed on text, tool calls and reasoning together,
78
+ * and re-deriving it here would be a second copy of that rule to keep in sync.
79
+ */
80
+ export function projectClaudeRequest(
81
+ raw: ProjectableBody,
82
+ thinking: ClaudeThinkingProjection,
83
+ ): ProjectableBody {
84
+ if (thinking.text && thinking.signature && thinking.redacted) return raw;
85
+ if (!Array.isArray(raw.messages)) return raw;
86
+ const messages = raw.messages as unknown[];
87
+ return {
88
+ ...raw,
89
+ messages: messages.map(message => {
90
+ if (!message || typeof message !== "object") return message;
91
+ if (!("content" in message) || !Array.isArray(message.content)) return message;
92
+ const content = message.content as unknown[];
93
+ return {
94
+ ...message,
95
+ content: content
96
+ .map(block => projectBlock(block, thinking))
97
+ .filter(block => block !== undefined),
98
+ };
99
+ }),
100
+ };
101
+ }
@@ -0,0 +1,48 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isLocalAttestationSecret } from "./local-management-attestation";
3
+ import { LOCAL_MANAGEMENT_CAPABILITY_TTL_MS } from "./local-management-capability";
4
+
5
+ export const LOCAL_ACCOUNT_SWITCH_PATHS = [
6
+ "/api/codex-auth/active",
7
+ "/api/oauth/accounts/active",
8
+ "/api/providers/keys/active",
9
+ ] as const;
10
+ export const LOCAL_ACCOUNT_SWITCH_BODY_HEADER = "x-opencodex-account-switch-sha256";
11
+ const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
12
+
13
+ export function isLocalAccountSwitchPath(path: string): boolean {
14
+ return LOCAL_ACCOUNT_SWITCH_PATHS.some(candidate => candidate === path);
15
+ }
16
+
17
+ /** Digest the exact bytes on the wire, before parsing or reserialization. */
18
+ export function accountSwitchBodyDigest(body: Uint8Array): string {
19
+ return createHash("sha256").update(body).digest("base64url");
20
+ }
21
+
22
+ export function createLocalAccountSwitchCapability(
23
+ secret: string, nonce: string, method: string, path: string,
24
+ pid: number, port: number, expiresAt: number, bodyDigest: string,
25
+ ): string | null {
26
+ if (!isLocalAttestationSecret(secret) || !BASE64URL_256.test(nonce)) return null;
27
+ if (method !== "PUT" || !isLocalAccountSwitchPath(path)) return null;
28
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
29
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
30
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
31
+ if (!BASE64URL_256.test(bodyDigest)) return null;
32
+ const payload = `opencodex-local-account-switch-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}\n${bodyDigest}`;
33
+ return createHmac("sha256", secret).update(payload).digest("base64url");
34
+ }
35
+
36
+ export function verifyLocalAccountSwitchCapability(
37
+ secret: string, nonce: string | null, method: string, path: string,
38
+ pid: number, port: number, expiresAt: number, bodyDigest: string | null,
39
+ capability: string | null, now = Date.now(),
40
+ ): boolean {
41
+ if (!nonce || !bodyDigest || !capability || !BASE64URL_256.test(capability)) return false;
42
+ if (!Number.isSafeInteger(now) || expiresAt <= now
43
+ || expiresAt > now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS) return false;
44
+ const expected = createLocalAccountSwitchCapability(
45
+ secret, nonce, method, path, pid, port, expiresAt, bodyDigest,
46
+ );
47
+ return expected !== null && timingSafeEqual(Buffer.from(expected), Buffer.from(capability));
48
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Classify an upstream provider destination as local or public.
3
+ *
4
+ * A *local* upstream is operator-trusted infrastructure on the same machine or LAN — LM Studio,
5
+ * Ollama, a self-hosted vLLM, a local gateway. Those models are frequently CPU-bound and their
6
+ * time-to-first-token or thinking phases can go silent for minutes, so a stall budget applied
7
+ * there would kill healthy turns. The stall resolver (`src/stall-timeout.ts`) therefore treats an
8
+ * unset budget on a local upstream as *disabled* while public upstreams keep the 300 s default.
9
+ *
10
+ * The destination is the provider's `baseUrl` (or the built request URL) — where the proxy
11
+ * actually dials upstream. This is a different question from
12
+ * `src/lib/local-destinations.ts`, which answers where local clients dial *this hub*; do not
13
+ * conflate them.
14
+ *
15
+ * Conservative on purpose: only clear local evidence (loopback, private, link-local, or a
16
+ * `.local`/`.lan` name) earns the disabled budget. Anything else — a public DNS name, an
17
+ * unreachable host, a bare domain — stays public and keeps its stall budget, because being local
18
+ * is what switches OFF a safety clock.
19
+ */
20
+ import { isIP } from "node:net";
21
+
22
+ /** True when the provider destination is local infrastructure and its stall budget should default to disabled. */
23
+ export function isLocalUpstream(destination: string | null | undefined): boolean {
24
+ if (!destination) return false;
25
+ // A bare IP literal must be classified before URL parsing: `new URL("fe80::1")` reads the first
26
+ // hextet as a scheme and yields an empty host, and `new URL("::1")` throws — either way the host
27
+ // would be lost. Zone ids (`fe80::1%eth0`) are stripped for the family check.
28
+ const trimmed = destination.trim();
29
+ const literalFamily = isIP(trimmed.split("%")[0]);
30
+ if (literalFamily === 4) return isLocalIpv4(trimmed);
31
+ if (literalFamily === 6) return isLocalIpv6(trimmed.split("%")[0]);
32
+
33
+ const host = hostnameOf(destination);
34
+ if (!host) return false;
35
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
36
+ const normalized = bare.toLowerCase().split("%")[0].replace(/\.$/, "");
37
+ if (normalized === "") return false;
38
+ if (normalized === "localhost") return true;
39
+ if (normalized.endsWith(".local") || normalized.endsWith(".lan")) return true;
40
+ const family = isIP(normalized);
41
+ if (family === 4) return isLocalIpv4(normalized);
42
+ if (family === 6) return isLocalIpv6(normalized);
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Extract a hostname from a full URL or a schemeless `host:port` / bare host.
48
+ *
49
+ * `new URL` handles the scheme-bearing forms (it keeps IPv6 brackets in `hostname`); the fallback
50
+ * recovers the host from a schemeless string by dropping a port or bracketed IPv6 suffix.
51
+ */
52
+ function hostnameOf(destination: string): string | null {
53
+ const trimmed = destination.trim();
54
+ if (!trimmed) return null;
55
+ // Only trust `new URL` when it produces a non-empty host. `new URL("localhost:1234")` succeeds
56
+ // but reads `localhost` as a scheme and leaves the host empty, so fall through to the schemeless
57
+ // parse in that case rather than returning null.
58
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
59
+ try {
60
+ const parsed = new URL(trimmed).hostname;
61
+ if (parsed) return parsed;
62
+ } catch {
63
+ // Not an absolute URL after all: fall through to the schemeless forms.
64
+ }
65
+ }
66
+ let host = trimmed;
67
+ if (host.startsWith("[")) {
68
+ const end = host.indexOf("]");
69
+ host = end > 0 ? host.slice(1, end) : host.slice(1);
70
+ } else {
71
+ const colon = host.indexOf(":");
72
+ if (colon >= 0) host = host.slice(0, colon);
73
+ }
74
+ return host || null;
75
+ }
76
+
77
+ /** 0.0.0.0/8, 10/8, 127/8 (loopback), 169.254/16 (link-local), 172.16/12, 192.168/16. */
78
+ function isLocalIpv4(host: string): boolean {
79
+ const parts = host.split(".");
80
+ if (parts.length !== 4) return false;
81
+ const octets: number[] = [];
82
+ for (const part of parts) {
83
+ if (!/^\d{1,3}$/.test(part)) return false;
84
+ const value = Number(part);
85
+ if (value > 255) return false;
86
+ octets.push(value);
87
+ }
88
+ const [a, b] = octets;
89
+ if (a === 0) return true;
90
+ if (a === 10) return true;
91
+ if (a === 127) return true;
92
+ if (a === 169 && b === 254) return true;
93
+ if (a === 172 && b >= 16 && b <= 31) return true;
94
+ if (a === 192 && b === 168) return true;
95
+ return false;
96
+ }
97
+
98
+ /** ::1 (loopback), `::ffff:a.b.c.d` (mapped), fe80::/10 (link-local), fc00::/7 (unique-local). */
99
+ function isLocalIpv6(host: string): boolean {
100
+ const lower = host.toLowerCase();
101
+ const groups = ipv6Groups(lower);
102
+ if (!groups) return false;
103
+ // Loopback in any spelling: every group zero except a trailing one.
104
+ if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true;
105
+ const mapped = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
106
+ if (mapped) return isLocalIpv4(mapped[1]);
107
+ const first = groups[0];
108
+ if ((first & 0xffc0) === 0xfe80) return true;
109
+ if ((first & 0xfe00) === 0xfc00) return true;
110
+ return false;
111
+ }
112
+
113
+ /** Expand a (possibly `::`-compressed) IPv6 literal into eight 16-bit groups, or null if malformed. */
114
+ function ipv6Groups(host: string): number[] | null {
115
+ const marker = host.indexOf("::");
116
+ const compressed = marker >= 0;
117
+ const head = compressed ? host.slice(0, marker) : host;
118
+ const tail = compressed ? host.slice(marker + 2) : "";
119
+ const headGroups = head === "" ? [] : head.split(":");
120
+ let tailGroups = tail === "" ? [] : tail.split(":");
121
+ // A trailing embedded IPv4 (e.g. `64:ff9b::1.2.3.4`) counts as two groups.
122
+ const last = tailGroups[tailGroups.length - 1];
123
+ if (last !== undefined && last.includes(".")) {
124
+ const v4 = last.split(".");
125
+ if (v4.length === 4 && v4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) {
126
+ const [o0, o1, o2, o3] = v4.map(Number);
127
+ tailGroups = [...tailGroups.slice(0, -1), ((o0 << 8) | o1).toString(16), ((o2 << 8) | o3).toString(16)];
128
+ } else return null;
129
+ }
130
+ let groups: string[];
131
+ if (compressed) {
132
+ // `::` fills the gap *between* head and tail with zero groups, not at either end.
133
+ const fill = 8 - headGroups.length - tailGroups.length;
134
+ if (fill < 1) return null;
135
+ groups = [...headGroups, ...Array<string>(fill).fill("0"), ...tailGroups];
136
+ } else {
137
+ groups = [...headGroups, ...tailGroups];
138
+ if (groups.length !== 8) return null;
139
+ }
140
+ const numbers = groups.map((group) => (isHextet(group) ? parseInt(group, 16) : Number.NaN));
141
+ return numbers.some((group) => Number.isNaN(group)) ? null : numbers;
142
+ }
143
+
144
+ function isHextet(token: string): boolean {
145
+ return /^[0-9a-f]{1,4}$/i.test(token);
146
+ }
@@ -1,4 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { isStandaloneBinary } from "./standalone";
3
5
 
4
6
  export interface PackageTreeObservation {
@@ -63,9 +65,19 @@ const packageManifestUrl = new URL("../../package.json", import.meta.url);
63
65
 
64
66
  const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
65
67
 
68
+ /** Directory of the package this process was loaded from, as the module loader spells it. */
69
+ export function runningPackageRoot(): string {
70
+ return dirname(fileURLToPath(packageManifestUrl));
71
+ }
72
+
66
73
  function readInstalledManifestVersion(): string | undefined {
74
+ return readPackageManifestVersionAt(fileURLToPath(packageManifestUrl));
75
+ }
76
+
77
+ /** The `version` of the manifest at `manifestPath`, or undefined when unreadable or malformed. */
78
+ export function readPackageManifestVersionAt(manifestPath: string): string | undefined {
67
79
  try {
68
- const version = (JSON.parse(readFileSync(packageManifestUrl, "utf8")) as { version?: unknown }).version;
80
+ const version = (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: unknown }).version;
69
81
  return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version)
70
82
  ? version
71
83
  : undefined;
@@ -75,8 +87,13 @@ function readInstalledManifestVersion(): string | undefined {
75
87
  }
76
88
 
77
89
  function observePackageManifest(): PackageTreeObservation | null {
90
+ return observePackageManifestAt(fileURLToPath(packageManifestUrl));
91
+ }
92
+
93
+ /** Identity of the manifest at `manifestPath`, or null when it cannot be stat'd. */
94
+ export function observePackageManifestAt(manifestPath: string): PackageTreeObservation | null {
78
95
  try {
79
- const stat = statSync(packageManifestUrl, { bigint: true });
96
+ const stat = statSync(manifestPath, { bigint: true });
80
97
  return {
81
98
  device: stat.dev,
82
99
  inode: stat.ino,
@@ -99,7 +116,12 @@ function observePackageManifest(): PackageTreeObservation | null {
99
116
  }
100
117
  }
101
118
 
102
- function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean {
119
+ /** The manifest path inside a package root. */
120
+ export function packageManifestPathIn(root: string): string {
121
+ return join(root, "package.json");
122
+ }
123
+
124
+ export function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean {
103
125
  return left.device === right.device
104
126
  && left.inode === right.inode
105
127
  && left.contentTimeNs === right.contentTimeNs
@@ -0,0 +1,130 @@
1
+ import {
2
+ packageManifestPathIn,
3
+ readPackageManifestVersionAt,
4
+ sameObservation,
5
+ type PackageTreeObservation,
6
+ } from "./package-tree-integrity";
7
+
8
+ /**
9
+ * The package a service launcher would start right now: its canonical root and the identity of
10
+ * the manifest inside it. Null when the launcher, its target or the manifest cannot be resolved.
11
+ */
12
+ export interface LauncherTarget {
13
+ readonly root: string;
14
+ readonly manifest: PackageTreeObservation;
15
+ }
16
+
17
+ export type ResolveLauncherTarget = () => LauncherTarget | null;
18
+
19
+ export interface PackageTreeRetargetOptions {
20
+ /** How often the launcher is re-resolved. Detection must not depend on inbound requests. */
21
+ pollIntervalMs?: number;
22
+ /** How long one complete target identity must hold before the restart is requested. */
23
+ settleMs?: number;
24
+ now?: () => number;
25
+ /** Test seam; production uses an unref'd timer and returns its cancellation. */
26
+ schedule?: (callback: () => void, delayMs: number) => () => void;
27
+ /** Test seam for the settled target's version; production reads its package manifest. */
28
+ readVersion?: (root: string) => string | undefined;
29
+ }
30
+
31
+ export interface PackageTreeRetargetWatch {
32
+ /**
33
+ * Version of the package the launcher now starts, once that target has held for the full
34
+ * settle interval and still resolves to the same identity. A fenced `/healthz` reports it
35
+ * when the running tree itself has gone, so `ocx restart` compares against what the
36
+ * supervisor will actually start.
37
+ */
38
+ settledVersion(): string | undefined;
39
+ /** Stops polling and invalidates any pending restart request. */
40
+ dispose(): void;
41
+ }
42
+
43
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
44
+ const DEFAULT_SETTLE_MS = 5_000;
45
+
46
+ function sameTarget(left: LauncherTarget, right: LauncherTarget): boolean {
47
+ return left.root === right.root && sameObservation(left.manifest, right.manifest);
48
+ }
49
+
50
+ /**
51
+ * Watches a version-manager launcher whose target can move to a different package tree while the
52
+ * running tree stays intact.
53
+ *
54
+ * The package-tree integrity fence only sees its own manifest. A manager that installs every
55
+ * version into its own directory and then repoints a floating link (mise's `latest`) never
56
+ * touches that manifest, so the fence neither restarts onto the new version nor recovers when
57
+ * the old version is later pruned from under it. This watch compares what the launcher resolves
58
+ * to with the running root instead.
59
+ *
60
+ * `onRetargeted` runs once, after one complete target identity (canonical root plus manifest
61
+ * identity) has held for `settleMs`. Any change of either part, an unresolvable launcher, or a
62
+ * return to the running root restarts the wait. A throwing handler is retried after another full
63
+ * interval. After the supervisor restarts through the launcher, the new process's running root
64
+ * is the target, so it never requests a second restart.
65
+ */
66
+ export function createPackageTreeRetargetWatch(
67
+ runningRoot: string,
68
+ resolveTarget: ResolveLauncherTarget,
69
+ onRetargeted: () => void,
70
+ options: PackageTreeRetargetOptions = {},
71
+ ): PackageTreeRetargetWatch {
72
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
73
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
74
+ const now = options.now ?? Date.now;
75
+ const readVersion = options.readVersion ?? (root => readPackageManifestVersionAt(packageManifestPathIn(root)));
76
+ const schedule = options.schedule ?? ((callback, delayMs) => {
77
+ const timer = setTimeout(callback, delayMs);
78
+ timer.unref?.();
79
+ return () => clearTimeout(timer);
80
+ });
81
+
82
+ let stopped = false;
83
+ let cancelPoll: (() => void) | null = null;
84
+ let candidate: LauncherTarget | null = null;
85
+ let candidateSince = 0;
86
+ let settled: LauncherTarget | null = null;
87
+
88
+ const poll = (): void => {
89
+ cancelPoll = null;
90
+ if (stopped) return;
91
+ const target = resolveTarget();
92
+ if (target === null || target.root === runningRoot) {
93
+ candidate = null;
94
+ settled = null;
95
+ } else if (candidate === null || !sameTarget(candidate, target)) {
96
+ candidate = target;
97
+ candidateSince = now();
98
+ settled = null;
99
+ } else if (now() - candidateSince >= settleMs) {
100
+ settled = target;
101
+ try {
102
+ onRetargeted();
103
+ stopped = true;
104
+ return;
105
+ } catch {
106
+ // Restart admission refused (for example, the service home changed owner). Keep the
107
+ // settled identity and ask again only after another full interval.
108
+ candidateSince = now();
109
+ }
110
+ }
111
+ cancelPoll = schedule(poll, pollIntervalMs);
112
+ };
113
+
114
+ cancelPoll = schedule(poll, pollIntervalMs);
115
+
116
+ return {
117
+ settledVersion: () => {
118
+ if (settled === null) return undefined;
119
+ const current = resolveTarget();
120
+ if (current === null || !sameTarget(settled, current)) return undefined;
121
+ return readVersion(settled.root);
122
+ },
123
+ dispose: () => {
124
+ stopped = true;
125
+ const cancel = cancelPoll;
126
+ cancelPoll = null;
127
+ cancel?.();
128
+ },
129
+ };
130
+ }