@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
@@ -261,11 +261,14 @@ async function stopProxyGracefullyDetailed(
261
261
  if (!res.ok) return done(false);
262
262
  const body: unknown = await res.json().catch(() => null);
263
263
  const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
264
+ // A sibling instance answers `not-owned`: it has no shared teardown to perform, so there is
265
+ // nothing left over for this caller either. A deferral it was sent is still not confirmed.
264
266
  sharedTeardownConfirmed = body !== null
265
267
  && typeof body === "object"
266
268
  && !Array.isArray(body)
267
269
  && "success" in body && body.success === true
268
- && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
270
+ && "sharedTeardown" in body && (body.sharedTeardown === expectedTeardown
271
+ || (!io.deferSharedTeardownNonce && body.sharedTeardown === "not-owned"));
269
272
  } catch {
270
273
  return done(false);
271
274
  }
@@ -12,6 +12,112 @@ export const SYSTEM_RESTART_CAPABILITY_HEADER = "x-opencodex-restart-capability"
12
12
  export const MEMORY_DRAIN_RESTART_MS = 60_000;
13
13
  export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
14
14
 
15
+ /**
16
+ * The env var a restarting process hands its replacement `ocx start`: the restarting (parent) pid.
17
+ *
18
+ * The replacement can probe while its parent still answers on the port it is about to take over
19
+ * (a deadline or listener-stop-fallback handoff spawns before the old listener is gone). Without
20
+ * the marker that answer reads as "a proxy is already running" and the replacement refuses, which
21
+ * leaves no proxy at all once the parent exits. `handleStart` consumes the marker before any probe
22
+ * (`src/cli/restart-handoff.ts`), so no later child of the replacement inherits it.
23
+ */
24
+ export const RESTART_PARENT_PID_ENV = "OCX_RESTART_PARENT_PID";
25
+
26
+ type RestartEnv = Record<string, string | undefined>;
27
+
28
+ /** A copy of `env` for this process's own replacement, naming `parentPid` as its restart parent. */
29
+ export function withRestartParentMarker<T extends RestartEnv>(env: T, parentPid: number): T {
30
+ const next: RestartEnv = { ...env };
31
+ next[RESTART_PARENT_PID_ENV] = String(parentPid);
32
+ return next as T;
33
+ }
34
+
35
+ /**
36
+ * Consume an inherited restart-parent marker. The marker is removed from `env` either way, and the
37
+ * pid is honored only when it is this process's actual parent: a stale or hand-set value names a
38
+ * process that did not spawn this start and never earns the wait.
39
+ */
40
+ export function takeRestartParentMarker(
41
+ env: RestartEnv,
42
+ actualParentPid: number = process.ppid,
43
+ ownPid: number = process.pid,
44
+ ): number | null {
45
+ const raw = env[RESTART_PARENT_PID_ENV]?.trim() ?? "";
46
+ delete env[RESTART_PARENT_PID_ENV];
47
+ if (!/^[1-9]\d{0,9}$/.test(raw)) return null;
48
+ const pid = Number(raw);
49
+ if (!Number.isSafeInteger(pid) || pid === ownPid || pid !== actualParentPid) return null;
50
+ return pid;
51
+ }
52
+
53
+ /**
54
+ * The env var the desktop app sets on the `ocx start` it spawns and waits on
55
+ * (`desktop/src-tauri/src/sidecar.rs`). Under it a restart spawns no detached replacement: it marks
56
+ * recycling and exits {@link DESKTOP_RESTART_EXIT_CODE}, and the app starts the replacement itself.
57
+ * A detached grandchild is a process the app can neither see nor stop, and one that failed to start
58
+ * left no proxy until somebody relaunched the app. `handleStart` consumes the marker before its
59
+ * first probe, so no child of the runtime inherits it.
60
+ */
61
+ export const DESKTOP_SUPERVISED_ENV = "OCX_DESKTOP_SUPERVISED";
62
+ /** EX_TEMPFAIL, "run me again": the desktop's supervisor restarts promptly on exactly this code. */
63
+ export const DESKTOP_RESTART_EXIT_CODE = 75;
64
+ /**
65
+ * The link-mode port-reclaim budget under the desktop app. With the pinned port's prefer-retry after
66
+ * it (`PINNED_PREFER_RETRY_MS` in `src/client/runtime.ts`) a start that cannot bind gives up within
67
+ * 25 seconds, inside the app's 30-second startup deadline, so the app sees it exit instead of giving
68
+ * up on it while it still waits.
69
+ */
70
+ export const DESKTOP_SUPERVISED_PORT_WAIT_MS = 20_000;
71
+
72
+ /** The desktop app's pid, recorded when the marker was taken; null when nothing supervises this process. */
73
+ let desktopParentPid: number | null = null;
74
+
75
+ /**
76
+ * Consume an inherited desktop-supervision marker, removing it from `env` either way. It is recorded
77
+ * only against a real parent process, so {@link isDesktopSupervised} can later check that the same
78
+ * app is still there.
79
+ */
80
+ export function takeDesktopSupervisedMarker(env: RestartEnv, actualParentPid: number = process.ppid): boolean {
81
+ const raw = env[DESKTOP_SUPERVISED_ENV]?.trim();
82
+ delete env[DESKTOP_SUPERVISED_ENV];
83
+ desktopParentPid = raw === "1" && Number.isSafeInteger(actualParentPid) && actualParentPid > 1
84
+ ? actualParentPid
85
+ : null;
86
+ return desktopParentPid !== null;
87
+ }
88
+
89
+ export interface DesktopSupervisionIo {
90
+ parentPid?: () => number;
91
+ isAlive?: (pid: number) => boolean;
92
+ }
93
+
94
+ function processExists(pid: number): boolean {
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch (error) {
99
+ return (error as NodeJS.ErrnoException | null)?.code === "EPERM";
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Whether the desktop app that started this process is still there to start its replacement: the
105
+ * marker was taken, the parent recorded then is still this process's parent, and it is alive. An app
106
+ * that crashed leaves the runtime re-parented (POSIX) or its parent dead (Windows), and a restart then
107
+ * falls back to the detached replacement instead of exiting into nothing. Read only when a restart
108
+ * or a link-mode start needs it, never on the request path.
109
+ */
110
+ export function isDesktopSupervised(io: DesktopSupervisionIo = {}): boolean {
111
+ if (desktopParentPid === null) return false;
112
+ if ((io.parentPid ?? (() => process.ppid))() !== desktopParentPid) return false;
113
+ return (io.isAlive ?? processExists)(desktopParentPid);
114
+ }
115
+
116
+ /** Test seam: forget a marker a test took. */
117
+ export function resetDesktopSupervisionForTests(): void {
118
+ desktopParentPid = null;
119
+ }
120
+
15
121
  const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
16
122
 
17
123
  export type ExpectedSystemRestartPid =
@@ -847,6 +847,62 @@ export async function refetchAfterProtocolSafeReset(
847
847
  console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error");
848
848
  return null;
849
849
  }
850
- console.warn("[upstream-retry] pre-output Responses reset" + label + "; using one replacement stream");
850
+ console.warn("[upstream-retry] pre-output stream reset" + label + "; using one replacement stream");
851
851
  return replacement;
852
852
  }
853
+
854
+ /**
855
+ * Wrap a streamed body so a reset that arrives before the downstream reader has consumed a single
856
+ * byte swaps in ONE replacement body.
857
+ *
858
+ * The zero-byte gate is the whole reason this wrapper exists: the caller observed nothing, which is
859
+ * the stage where a replacement may even be considered. Every other question -- whether the operator
860
+ * granted one, whether the request is replayable, whether the replacement is a fresh unlocked body
861
+ * that matches the contract already promised to the client -- belongs to
862
+ * {@link refetchAfterProtocolSafeReset}. Delegating rather than re-deciding is what keeps the chat
863
+ * lane from drifting away from the one the Responses stream already uses.
864
+ *
865
+ * Partial output is never masked: once a byte has reached the caller, the original failure stands.
866
+ */
867
+ export function wrapWithZeroOutputRefetch(
868
+ body: ReadableStream<Uint8Array>,
869
+ doFetch: ProtocolSafeRefetch,
870
+ // `authorize` is optional on the shared options but required here: a zero-output replacement
871
+ // is always a post-header resend, so every caller must name the gate that weighs it.
872
+ opts: ProtocolSafeRefetchOptions & { authorize: () => boolean },
873
+ ): ReadableStream<Uint8Array> {
874
+ let reader = body.getReader();
875
+ let bytesRead = 0;
876
+ let retried = false;
877
+ return new ReadableStream<Uint8Array>({
878
+ async pull(controller) {
879
+ for (;;) {
880
+ try {
881
+ const { done, value } = await reader.read();
882
+ if (done) {
883
+ controller.close();
884
+ return;
885
+ }
886
+ bytesRead += value.byteLength;
887
+ controller.enqueue(value);
888
+ return;
889
+ } catch (err) {
890
+ if (!retried && bytesRead === 0 && !opts.abortSignal?.aborted) {
891
+ retried = true;
892
+ const replacement = await refetchAfterProtocolSafeReset(doFetch, err, { ...opts, authorize: opts.authorize });
893
+ if (replacement?.body) {
894
+ try { void reader.cancel().catch(() => {}); } catch { /* broken reader; the replacement won */ }
895
+ reader = replacement.body.getReader();
896
+ continue;
897
+ }
898
+ }
899
+ try { controller.error(err); } catch { /* already torn down */ }
900
+ return;
901
+ }
902
+ }
903
+ },
904
+ cancel(reason) {
905
+ try { void reader.cancel(reason).catch(() => {}); } catch { /* already torn down */ }
906
+ },
907
+ });
908
+ }
package/src/link/ports.ts CHANGED
@@ -7,6 +7,15 @@
7
7
  export const MIN_LINK_PORT = 1024;
8
8
  export const MAX_LINK_PORT = 65535;
9
9
 
10
+ /**
11
+ * A Child joining from its dashboard picks its tunnel port from this range. It sits below the
12
+ * common OS ephemeral ranges (macOS and Windows 49152-65535, Linux 32768-60999), so an outgoing
13
+ * connection that happens to hold the port rarely blocks the tunnel when it comes back after a
14
+ * reboot. The persisted port of an existing link is never rewritten.
15
+ */
16
+ export const JOIN_TUNNEL_PORT_MIN = 20_000;
17
+ export const JOIN_TUNNEL_PORT_MAX = 29_999;
18
+
10
19
  export function isLinkPort(value: unknown): value is number {
11
20
  return typeof value === "number" && Number.isInteger(value) && value >= MIN_LINK_PORT && value <= MAX_LINK_PORT;
12
21
  }
@@ -12,6 +12,8 @@
12
12
  * - Tunnel and exec commands use StrictHostKeyChecking=yes. Only the probe uses accept-new,
13
13
  * against an empty temporary file, so the key it records can be shown to the user first.
14
14
  * - Forwards always bind 127.0.0.1 on both ends.
15
+ * - Remote ocx runs through `remoteOcxArgv`: a non-interactive ssh session reads no interactive
16
+ * profile, so ~/.bun/bin and Homebrew are usually missing from the remote PATH.
15
17
  */
16
18
 
17
19
  import { isAbsolute } from "node:path";
@@ -129,6 +131,20 @@ export function buildExecArgv(options: ExecArgvOptions): string[] {
129
131
  ];
130
132
  }
131
133
 
134
+ /**
135
+ * The remote sh script for every ocx call. `quoteRemote` single-quotes it, so the login shell
136
+ * passes it through untouched and `$HOME`/`$PATH` expand in the remote `sh`; the arguments reach
137
+ * ocx as `"$@"` without another round of parsing. The fallback directories are appended, so an
138
+ * ocx the remote PATH already resolves keeps winning. A missing ocx exits 127.
139
+ */
140
+ export const REMOTE_OCX_SCRIPT = 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"';
141
+ /** The POSIX shell's exit status for a command it could not find. */
142
+ export const REMOTE_COMMAND_NOT_FOUND = 127;
143
+
144
+ export function remoteOcxArgv(args: readonly string[]): string[] {
145
+ return ["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", ...args];
146
+ }
147
+
132
148
  export interface ProbeArgvOptions {
133
149
  alias: string;
134
150
  /** An empty, private, temporary file. The probe records the offered host key here. */
@@ -1,4 +1,66 @@
1
+ import { homedir } from "node:os";
2
+
1
3
  const DEFAULT_OUTPUT_BYTES = 64 * 1024;
4
+ const HINT_MAX_CHARS = 160;
5
+ // Written as escapes on purpose: invisible and bidi controls must never sit literally in source.
6
+ const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g;
7
+ const HINT_CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
8
+ const HINT_SECRET = /\bocx_(data|admin|session|pair)_[A-Za-z0-9_-]+/g;
9
+ const HINT_URL_QUERY = /\b(https?:\/\/[^\s?#]*)[?#]\S*/g;
10
+
11
+ type SpawnEnv = Record<string, string | undefined>;
12
+
13
+ /**
14
+ * PATH for ssh and ssh-keygen children on POSIX. A desktop sidecar inherits launchd's minimal
15
+ * PATH, so a ProxyCommand helper installed by Homebrew or into ~/.bun/bin would not resolve.
16
+ * Inherited entries keep their order and precedence; the common helper directories are appended
17
+ * once. Windows keeps its inherited environment untouched and gets undefined.
18
+ */
19
+ export function linkSshPath(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): string | undefined {
20
+ if (platform === "win32") return undefined;
21
+ const home = env.HOME || homedir();
22
+ const extra = ["/opt/homebrew/bin", "/usr/local/bin", ...(home ? [`${home}/.bun/bin`, `${home}/.local/bin`] : [])];
23
+ return [...new Set([...(env.PATH ?? "").split(":").filter(Boolean), ...extra])].join(":");
24
+ }
25
+
26
+ /** The environment ssh runs with: the inherited one with `linkSshPath`, or undefined on Windows. */
27
+ export function linkSshSpawnEnv(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): SpawnEnv | undefined {
28
+ const path = linkSshPath(env, platform);
29
+ return path === undefined ? undefined : { ...env, PATH: path };
30
+ }
31
+
32
+ /**
33
+ * One hint line as the dashboard may see it: control and bidi characters removed, OpenCodex
34
+ * secrets and URL queries redacted, whitespace collapsed, capped at 160 code points. The cut is
35
+ * made between code points, so an astral character is never split into a lone surrogate.
36
+ */
37
+ export function boundHint(line: string): string | undefined {
38
+ const clean = line.replace(HINT_CONTROL, " ").replace(HINT_SECRET, "ocx_$1_[redacted]")
39
+ .replace(HINT_URL_QUERY, "$1").replace(/\s+/g, " ").trim();
40
+ if (!clean) return undefined;
41
+ const points = Array.from(clean);
42
+ return points.length > HINT_MAX_CHARS ? `${points.slice(0, HINT_MAX_CHARS - 1).join("")}\u2026` : clean;
43
+ }
44
+
45
+ /**
46
+ * A short reason for a failed ssh command: the last non-empty stderr line, with terminal escapes,
47
+ * control and bidi characters removed, OpenCodex secrets and URL queries redacted, capped at 160
48
+ * code points. It reads stderr only; stdout and stdin can carry keys. Callers return it to the
49
+ * dashboard and never log it.
50
+ */
51
+ export function sshFailureHint(stderr: string): string | undefined {
52
+ const lines = stderr.replace(ANSI_SEQUENCE, "").split(/\r\n|\r|\n/);
53
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
54
+ const hint = boundHint(lines[index]!);
55
+ if (hint) return hint;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** The runner's own failure (spawn, timeout, output limit) as a hint; other errors give none. */
61
+ export function sshRunnerErrorHint(error: unknown): string | undefined {
62
+ return error instanceof SshRunnerError ? boundHint(error.message) : undefined;
63
+ }
2
64
 
3
65
  export interface SshRunResult {
4
66
  code: number;
@@ -71,14 +133,19 @@ function defaultKill(process: Bun.Subprocess, signal?: NodeJS.Signals): void {
71
133
  process.kill(signal);
72
134
  }
73
135
 
74
- export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number } = {}): SshRunner {
136
+ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number; env?: () => SpawnEnv | undefined } = {}): SshRunner {
75
137
  const spawn = deps.spawn ?? ((argv, options) => Bun.spawn(argv, options));
76
138
  const defaultTimeoutMs = deps.timeoutMs ?? 30_000;
139
+ // Read per spawn so a PATH change reaches the next command; Windows passes no env key at all.
140
+ const spawnEnv = (): { env?: SpawnEnv } => {
141
+ const env = (deps.env ?? (() => linkSshSpawnEnv()))();
142
+ return env ? { env } : {};
143
+ };
77
144
 
78
145
  const spawnChild = (argv: readonly string[]): SshChild => {
79
146
  let child: Bun.Subprocess;
80
147
  try {
81
- child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
148
+ child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe", ...spawnEnv() });
82
149
  } catch (error) {
83
150
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
84
151
  }
@@ -108,6 +175,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
108
175
  stdin: options.stdin === undefined ? "ignore" : "pipe",
109
176
  stdout: "pipe",
110
177
  stderr: "pipe",
178
+ ...spawnEnv(),
111
179
  });
112
180
  } catch (error) {
113
181
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
@@ -5,8 +5,11 @@
5
5
  * - connected: the forward is up.
6
6
  * - reconnecting: a transient failure; requests through the link fail with 503 meanwhile, and a
7
7
  * new attempt is due at `retryAt`.
8
- * - failed: needs the user. Auth, host key and forward failures are not retried, and neither is a
9
- * link that stayed down for FAILED_AFTER_MS.
8
+ * - failed: auth, host key and forward failures, and a link that stayed down for FAILED_AFTER_MS.
9
+ * Without a retry policy (the Home's `-R` supervisor) it needs the user. With one (the Child's
10
+ * own `-L` tunnel) a reason that has a delay is tried again at `retryAt`, and the retry attempt
11
+ * runs with `inFlight` while the state still reads failed; a reason without a delay stays
12
+ * terminal.
10
13
  */
11
14
 
12
15
  export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
@@ -17,7 +20,7 @@ export type TunnelState =
17
20
  | { kind: "connecting"; since: number }
18
21
  | { kind: "connected"; since: number }
19
22
  | { kind: "reconnecting"; since: number; attempt: number; retryAt: number; inFlight: boolean }
20
- | { kind: "failed"; since: number; reason: TunnelFailure };
23
+ | { kind: "failed"; since: number; reason: TunnelFailure; retryAt?: number; inFlight?: boolean };
21
24
 
22
25
  export type TunnelEvent =
23
26
  | { type: "spawn"; now: number }
@@ -26,10 +29,25 @@ export type TunnelEvent =
26
29
  | { type: "tick"; now: number }
27
30
  | { type: "stop" };
28
31
 
32
+ /** Opt-in retry of a failed tunnel: the delay before each failure reason is tried again. */
33
+ export interface TunnelRetryPolicy {
34
+ retryFailedAfterMs: Partial<Record<TunnelFailure, number>>;
35
+ }
36
+
29
37
  export const FAILED_AFTER_MS = 5 * 60_000;
30
38
  export const BASE_DELAY_MS = 1_000;
31
39
  export const MAX_DELAY_MS = 30_000;
32
40
 
41
+ /**
42
+ * The client-owned tunnel's policy. Timeout and forward failures retry about once a minute. Auth
43
+ * retries every five minutes, and because each auth failure schedules the next attempt five
44
+ * minutes out, no more than 12 attempts reach the Home's sshd in an hour. A changed host key is a
45
+ * security signal and is never retried.
46
+ */
47
+ export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
48
+ retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
49
+ };
50
+
33
51
  export const IDLE: TunnelState = { kind: "idle" };
34
52
 
35
53
  /** Capped exponential backoff with ±20% jitter. `random` is injectable for tests. */
@@ -40,22 +58,52 @@ export function nextDelayMs(attempt: number, random: () => number = Math.random)
40
58
  return Math.round(Math.min(MAX_DELAY_MS, base * jitter));
41
59
  }
42
60
 
43
- export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: () => number): TunnelState {
61
+ /** A failed state; under a policy that retries `reason` it carries the next attempt's time. */
62
+ export function failedTunnel(
63
+ reason: TunnelFailure,
64
+ now: number,
65
+ policy?: TunnelRetryPolicy,
66
+ since: number = now,
67
+ ): TunnelState {
68
+ const delay = policy?.retryFailedAfterMs[reason];
69
+ return delay === undefined
70
+ ? { kind: "failed", since, reason }
71
+ : { kind: "failed", since, reason, retryAt: now + delay, inFlight: false };
72
+ }
73
+
74
+ function failureOf(cls: StderrClass): TunnelFailure | null {
75
+ return cls === "auth" || cls === "hostkey" || cls === "forward" ? cls : null;
76
+ }
77
+
78
+ export function reduceTunnel(
79
+ state: TunnelState,
80
+ event: TunnelEvent,
81
+ random?: () => number,
82
+ policy?: TunnelRetryPolicy,
83
+ ): TunnelState {
44
84
  if (event.type === "stop") return IDLE;
45
85
  switch (event.type) {
46
86
  case "spawn":
87
+ if (state.kind === "failed" && state.retryAt !== undefined) return { ...state, inFlight: true };
47
88
  if (state.kind === "idle" || state.kind === "failed") return { kind: "connecting", since: event.now };
48
89
  if (state.kind === "reconnecting") return { ...state, inFlight: true };
49
90
  return state;
50
91
  case "ready":
51
92
  if (state.kind === "connecting" || state.kind === "reconnecting") return { kind: "connected", since: event.now };
93
+ if (state.kind === "failed" && state.inFlight) return { kind: "connected", since: event.now };
52
94
  return state;
53
95
  case "exit": {
54
- if (state.kind === "idle" || state.kind === "failed") return state;
55
- const cls = event.stderrClass;
56
- if (cls === "auth" || cls === "hostkey" || cls === "forward") return { kind: "failed", since: event.now, reason: cls };
96
+ if (state.kind === "idle") return state;
97
+ if (state.kind === "failed") {
98
+ if (!state.inFlight) return state;
99
+ // A retry of a failed link failed again. It stays failed from the original moment; a
100
+ // transient exit keeps the slow cadence (timeout) instead of restarting fast backoff.
101
+ return failedTunnel(failureOf(event.stderrClass) ?? "timeout", event.now, policy, state.since);
102
+ }
103
+ const failure = failureOf(event.stderrClass);
104
+ if (failure) return failedTunnel(failure, event.now, policy);
57
105
  const since = state.kind === "reconnecting" || state.kind === "connecting" ? state.since : event.now;
58
- if (event.now - since >= FAILED_AFTER_MS) return { kind: "failed", since: event.now, reason: "timeout" };
106
+ if (event.now - since >= FAILED_AFTER_MS) return failedTunnel("timeout", event.now, policy);
59
107
  const attempt = state.kind === "reconnecting" ? state.attempt + 1 : 1;
60
108
  return { kind: "reconnecting", since, attempt, retryAt: event.now + nextDelayMs(attempt, random), inFlight: false };
61
109
  }
@@ -63,7 +111,7 @@ export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: ()
63
111
  // An attempt in flight does not pause the clock: a first attempt or a retry that hangs past
64
112
  // the limit still fails the link, and the supervisor kills the child on seeing `failed`.
65
113
  if ((state.kind === "reconnecting" || state.kind === "connecting") && event.now - state.since >= FAILED_AFTER_MS) {
66
- return { kind: "failed", since: event.now, reason: "timeout" };
114
+ return failedTunnel("timeout", event.now, policy);
67
115
  }
68
116
  return state;
69
117
  }
@@ -71,6 +119,7 @@ export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: ()
71
119
 
72
120
  /** Whether the supervisor should start a new ssh attempt now. */
73
121
  export function dueForSpawn(state: TunnelState, now: number): boolean {
122
+ if (state.kind === "failed") return state.retryAt !== undefined && !state.inFlight && now >= state.retryAt;
74
123
  return state.kind === "reconnecting" && !state.inFlight && now >= state.retryAt;
75
124
  }
76
125
 
@@ -11,7 +11,8 @@
11
11
  * serve the retry" — and within the healthy group a simple headroom sort is enough.
12
12
  */
13
13
  import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
14
- import { getKiroAccountExhaustion } from "../providers/kiro-usage";
14
+ import { kiroAccountEvidence } from "../providers/kiro-usage";
15
+ import type { ProviderAccount } from "./types";
15
16
 
16
17
  /** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */
17
18
  export type QuotaModelFamily = "gem" | "cla";
@@ -83,8 +84,11 @@ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
83
84
  * The minimum wins: an account at 5% of its five-hour window is unusable right now even if
84
85
  * its monthly allowance is barely touched.
85
86
  */
86
- function headroomOf(provider: string, accountId: string, requestedModelId?: string | null): number | null {
87
- const quota = getCachedProviderAccountQuota(provider, accountId);
87
+ function headroomOf(provider: string, accountId: string, requestedModelId?: string | null, account?: ProviderAccount): number | null {
88
+ const evidence = provider === "kiro" && account ? kiroAccountEvidence(account) : null;
89
+ const quota = provider === "kiro"
90
+ ? evidence?.quotaPercent === undefined ? null : { monthlyPercent: evidence.quotaPercent, updatedAt: Date.now() }
91
+ : getCachedProviderAccountQuota(provider, accountId);
88
92
  if (!quota) return null;
89
93
  // Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
90
94
  // the unranked ring rather than to a differently wrong answer.
@@ -119,8 +123,9 @@ export function accountHeadroomPercent(
119
123
  provider: string,
120
124
  accountId: string,
121
125
  requestedModelId?: string | null,
126
+ account?: ProviderAccount,
122
127
  ): number | null {
123
- return headroomOf(provider, accountId, requestedModelId);
128
+ return headroomOf(provider, accountId, requestedModelId, account);
124
129
  }
125
130
 
126
131
  /** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
@@ -128,10 +133,10 @@ export function isAccountQuotaExhausted(
128
133
  provider: string,
129
134
  accountId: string,
130
135
  requestedModelId?: string | null,
136
+ account?: ProviderAccount,
131
137
  ): boolean {
132
- const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
133
- if (exhaustion !== null) return exhaustion.exhausted;
134
- const headroom = headroomOf(provider, accountId, requestedModelId);
138
+ if (provider === "kiro") return account ? kiroAccountEvidence(account).exhausted === true : false;
139
+ const headroom = headroomOf(provider, accountId, requestedModelId, account);
135
140
  return headroom !== null && headroom <= 0;
136
141
  }
137
142
 
@@ -145,6 +150,7 @@ export function rankAccountsByHeadroom(
145
150
  provider: string,
146
151
  ring: readonly string[],
147
152
  requestedModelId?: string | null,
153
+ accounts?: ReadonlyMap<string, ProviderAccount>,
148
154
  ): string[] {
149
155
  if (ring.length < 2) return [...ring];
150
156
 
@@ -158,11 +164,14 @@ export function rankAccountsByHeadroom(
158
164
  const ranked: Ranked[] = ring.map((id, index) => {
159
165
  // A provider-declared exhaustion verdict outranks the percentage: an account may sit at
160
166
  // 100% and still be servable when overage is enabled, and the verdict knows that.
161
- const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${id}`) : null;
162
- const headroom = headroomOf(provider, id, requestedModelId);
163
- if (exhaustion !== null || headroom !== null) sawEvidence = true;
164
-
165
- if (isAccountQuotaExhausted(provider, id, requestedModelId)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
167
+ const account = accounts?.get(id);
168
+ const exhaustion = provider === "kiro" && account ? kiroAccountEvidence(account).exhausted : undefined;
169
+ const headroom = headroomOf(provider, id, requestedModelId, account);
170
+ if (exhaustion !== undefined || headroom !== null) sawEvidence = true;
171
+
172
+ if (isAccountQuotaExhausted(provider, id, requestedModelId, account)
173
+ || (provider === "kiro" && exhaustion === undefined && headroom !== null && headroom <= 0))
174
+ return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
166
175
  if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
167
176
  return { id, bucket: RANK_HEALTHY, headroom, index };
168
177
  });
@@ -186,6 +195,7 @@ export function hasHeadroomEvidence(
186
195
  provider: string,
187
196
  ids: readonly string[],
188
197
  requestedModelId?: string | null,
198
+ accounts?: ReadonlyMap<string, ProviderAccount>,
189
199
  ): boolean {
190
200
  // A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
191
201
  //
@@ -200,8 +210,9 @@ export function hasHeadroomEvidence(
200
210
  return ids.length > 0 && ids.every(id => headroomOf(provider, id, requestedModelId) !== null);
201
211
  }
202
212
  return ids.some(id =>
203
- headroomOf(provider, id, requestedModelId) !== null
204
- || (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
213
+ headroomOf(provider, id, requestedModelId, accounts?.get(id)) !== null
214
+ || (provider === "kiro" && accounts?.get(id) !== undefined
215
+ && kiroAccountEvidence(accounts.get(id)!).exhausted !== undefined));
205
216
  }
206
217
  /**
207
218
  * How long to cool an account that just 429'd, when we know its allowance is spent.
@@ -213,10 +224,10 @@ export function hasHeadroomEvidence(
213
224
  const MIN_EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
214
225
  const MAX_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
215
226
 
216
- export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now()): number | null {
227
+ export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now(), account?: ProviderAccount): number | null {
217
228
  if (provider !== "kiro") return null;
218
- const exhaustion = getKiroAccountExhaustion(`${provider}\u0000${accountId}`, now);
219
- if (!exhaustion?.exhausted) return null;
220
- const untilReset = exhaustion.nextResetAt === undefined ? MIN_EXHAUSTED_COOLDOWN_MS : exhaustion.nextResetAt - now;
229
+ const evidence = account ? kiroAccountEvidence(account, now) : {};
230
+ if (!evidence.exhausted) return null;
231
+ const untilReset = evidence.resetAt === undefined ? MIN_EXHAUSTED_COOLDOWN_MS : evidence.resetAt - now;
221
232
  return Math.min(Math.max(untilReset, MIN_EXHAUSTED_COOLDOWN_MS), MAX_EXHAUSTED_COOLDOWN_MS);
222
233
  }