@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -3,6 +3,7 @@
3
3
  * A failed or inapplicable transform leaves the upstream bytes untouched.
4
4
  */
5
5
  import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
6
+ import { PICKER_MAX_OUTPUT_BYTES, PickerRewriteBudget, pickerRowBytes } from "./picker-budget";
6
7
 
7
8
  /** One opencodex model offered in Desktop's Code-tab picker. */
8
9
  export interface PickerModelEntry {
@@ -16,7 +17,7 @@ export interface PickerModelEntry {
16
17
  }
17
18
 
18
19
  export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024;
19
- export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024;
20
+ export const BOOTSTRAP_MAX_DECODED_BYTES = PICKER_MAX_OUTPUT_BYTES;
20
21
  const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/;
21
22
  const REWRITE_REMOVED_HEADERS = new Set([
22
23
  "content-encoding", "content-length", "etag", "digest", "content-md5", "transfer-encoding",
@@ -56,11 +57,14 @@ export interface PickerInjectionOptions {
56
57
  extraStrippedKeys?: readonly string[];
57
58
  }
58
59
 
59
- function injectIntoSurface(
60
+ interface SurfaceInjection { entries: unknown[]; additions: Record<string, unknown>[] }
61
+
62
+ function planSurfaceInjection(
60
63
  surface: Record<string, unknown>,
61
64
  models: readonly PickerModelEntry[],
62
65
  stripped: readonly string[],
63
- ): number | string {
66
+ budget: PickerRewriteBudget,
67
+ ): SurfaceInjection | string {
64
68
  if (!Array.isArray(surface.models)) return "no_models";
65
69
  const entries = surface.models as unknown[];
66
70
  const template = entries.map(record).find(entry =>
@@ -68,24 +72,28 @@ function injectIntoSurface(
68
72
  && !entry.disabled && !entry.disabled_reason && entry.section !== "deprecated");
69
73
  if (!template) return `no_template(models=${entries.length})`;
70
74
  const existing = new Set(entries.map(record).map(entry => entry?.id));
71
- let added = 0;
75
+ // Strip by shallow projection before measuring or cloning retained metadata. Native rows stay intact.
76
+ const retained = { ...template };
77
+ for (const key of Object.keys(retained)) {
78
+ if (stripped.includes(key) || /version/i.test(key)
79
+ || ["id", "name", "section", "context_window"].includes(key)) delete retained[key];
80
+ }
81
+ pickerRowBytes(retained);
82
+ const additions: Record<string, unknown>[] = [];
72
83
  for (const model of models) {
73
84
  if (existing.has(model.id)) continue;
74
- const copy = structuredClone(template);
85
+ const copy = { ...retained };
75
86
  copy.id = model.id;
76
87
  copy.name = model.name;
77
88
  copy.section = "main";
78
89
  if (model.contextWindow === undefined) delete copy.context_window;
79
90
  else copy.context_window = model.contextWindow;
80
- for (const key of Object.keys(copy)) {
81
- if (stripped.includes(key) || /version/i.test(key)) delete copy[key];
82
- }
83
91
  if (model.description !== undefined) copy.description = model.description;
84
- entries.push(copy);
92
+ budget.reserveRow(copy);
93
+ additions.push(copy);
85
94
  existing.add(model.id);
86
- added++;
87
95
  }
88
- return added;
96
+ return { entries, additions };
89
97
  }
90
98
 
91
99
  export function injectPickerModels(
@@ -110,10 +118,21 @@ export function injectPickerModels(
110
118
  }
111
119
  let added = 0;
112
120
  const skipped: string[] = [];
113
- for (const surface of targets) {
114
- const result = injectIntoSurface(surface, models, stripped);
115
- if (typeof result === "number") added += result;
116
- else skipped.push(`${String(surface.id)}:${result}`);
121
+ try {
122
+ const budget = new PickerRewriteBudget(bootstrap);
123
+ const plans: SurfaceInjection[] = [];
124
+ for (const surface of targets) {
125
+ const result = planSurfaceInjection(surface, models, stripped, budget);
126
+ if (typeof result === "string") skipped.push(`${String(surface.id)}:${result}`);
127
+ else { plans.push(result); added += result.additions.length; }
128
+ }
129
+ // All surfaces must fit before any deep clone or mutation; a refusal cannot publish a partial list.
130
+ const copies = plans.map(plan => plan.additions.map(row => structuredClone(row)));
131
+ for (let i = 0; i < plans.length; i++) {
132
+ for (const row of copies[i]!) plans[i]!.entries.push(row);
133
+ }
134
+ } catch {
135
+ return unchanged("rewrite_limit");
117
136
  }
118
137
  if (added === 0) return unchanged(skipped.length > 0 ? skipped.join(";") : models.length === 0 ? "no_routes" : "all_present");
119
138
  explain?.({ kind: "rewritten", added });
@@ -142,7 +161,9 @@ export function rewriteBootstrapBody(
142
161
  if (decoded.length > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("decoded_cap");
143
162
  const parsed: unknown = JSON.parse(decoded.toString("utf8"));
144
163
  if (injectPickerModels(parsed, models, explain) === 0) return null;
145
- return Buffer.from(JSON.stringify(parsed), "utf8");
164
+ const text = JSON.stringify(parsed);
165
+ if (Buffer.byteLength(text) > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("rewrite_limit");
166
+ return Buffer.from(text, "utf8");
146
167
  } catch {
147
168
  return unchanged("decode_or_parse_failed");
148
169
  }
@@ -0,0 +1,41 @@
1
+ import { jsonUtf8Bytes } from "../../lib/json-byte-size";
2
+
3
+ export const PICKER_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
4
+ const MAX_FIELD_BYTES = 64 * 1024;
5
+ const MAX_ROW_BYTES = 256 * 1024;
6
+ const MAX_ADDED_BYTES = 2 * 1024 * 1024;
7
+ const MAX_ADDED_ROWS = 4096;
8
+
9
+ /** Inspect references only: no deep copy or serialized string is allocated by preflight. */
10
+ export function pickerRowBytes(row: Record<string, unknown>): number {
11
+ for (const key of Object.keys(row)) {
12
+ jsonUtf8Bytes(key, MAX_FIELD_BYTES);
13
+ if (row[key] !== undefined) jsonUtf8Bytes(row[key], MAX_FIELD_BYTES);
14
+ }
15
+ return jsonUtf8Bytes(row, MAX_ROW_BYTES);
16
+ }
17
+
18
+ /** One budget for the entire response, including duplicate surfaces and the CLI fallback. */
19
+ export class PickerRewriteBudget {
20
+ private outputBytes: number;
21
+ private addedBytes = 0;
22
+ private addedRows = 0;
23
+
24
+ constructor(body: unknown) {
25
+ this.outputBytes = jsonUtf8Bytes(body, PICKER_MAX_OUTPUT_BYTES);
26
+ }
27
+
28
+ reserveBytes(bytes: number): void {
29
+ if (bytes > MAX_ADDED_BYTES - this.addedBytes || bytes > PICKER_MAX_OUTPUT_BYTES - this.outputBytes) {
30
+ throw new RangeError("Picker rewrite byte limit");
31
+ }
32
+ this.addedBytes += bytes;
33
+ this.outputBytes += bytes;
34
+ }
35
+
36
+ reserveRow(row: Record<string, unknown>): void {
37
+ if (++this.addedRows > MAX_ADDED_ROWS) throw new RangeError("Picker rewrite row limit");
38
+ // Include a separator even for an empty list, conservatively reserving one extra byte.
39
+ this.reserveBytes(pickerRowBytes(row) + 1);
40
+ }
41
+ }
@@ -1,11 +1,24 @@
1
1
  /**
2
- * HTTP/1.1 TLS terminator for claude.ai. Only the bounded bootstrap response is
3
- * held; all other HTTP bodies and upgraded sockets relay as streams.
2
+ * TLS terminator for claude.ai.
3
+ *
4
+ * The listener port is a plain TCP front that reads each connection's TLS ClientHello and splices
5
+ * it, unterminated, to one of two loopback servers. A client offering `h2` (Chromium's ordinary
6
+ * requests) reaches an HTTP/2 server, so Desktop multiplexes every claude.ai request over one
7
+ * connection as it does against Anthropic's own edge. When this listener spoke only HTTP/1.1,
8
+ * Desktop's long-lived SSE subscriptions took all six of Chromium's per-origin connections and every
9
+ * later claude.ai request queued in the client until it timed out (#6511). Everything else (no ALPN,
10
+ * HTTP/1.1 only, WebSocket connections, a ClientHello the front cannot read) reaches the native
11
+ * HTTP/1.1 server, unchanged. Upstream is one HTTP/1.1 request per client request on both paths.
12
+ * Only the bounded bootstrap response is held; other bodies and upgraded sockets relay as streams.
4
13
  */
14
+ import { createSecureServer } from "node:http2";
15
+ import type { Http2ServerRequest, Http2ServerResponse, ServerHttp2Session } from "node:http2";
5
16
  import { createServer, request as httpsRequest } from "node:https";
6
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
+ import { createServer as createTcpServer, connect as tcpConnect, type Server as TcpServer, type Socket } from "node:net";
7
19
  import { connect as tlsConnect } from "node:tls";
8
20
  import type { Duplex } from "node:stream";
21
+ import { clientHelloOffersH2 } from "./client-hello";
9
22
  import type { PemKeyPair } from "./local-ca";
10
23
  import {
11
24
  BOOTSTRAP_MAX_ENCODED_BYTES, isPickerBootstrapRequest, narrowBootstrapAcceptEncoding,
@@ -19,15 +32,45 @@ export interface PickerListenerOptions {
19
32
  upstream?: { host: string; port: number; servername: string; ca?: string };
20
33
  /** Test seam: encoded bootstrap cap; production uses BOOTSTRAP_MAX_ENCODED_BYTES. */
21
34
  maxEncodedBytes?: number;
35
+ /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
36
+ maxActiveUpstreams?: number;
22
37
  log?: (line: string) => void;
23
38
  }
24
39
  export interface PickerListenerHandle { port: number; close(): Promise<void> }
25
40
 
41
+ // Browser session cookies can exceed the HTTP compatibility layer's 16 KiB default.
42
+ // Keep both sides bounded, while allowing ordinary desktop session headers through.
43
+ export const PICKER_MAX_HEADER_BYTES = 64 * 1024;
44
+ /**
45
+ * Upstream requests in flight across the listener. HTTP/2 lets one connection open many streams,
46
+ * and each relays as its own upstream connection, so the budget caps that fan-out well above what
47
+ * Desktop uses (a handful of SSE subscriptions plus bursts of ordinary calls).
48
+ */
49
+ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
50
+ /** Advertised per HTTP/2 connection; Chromium queues further requests rather than failing them. */
51
+ export const PICKER_MAX_CONCURRENT_STREAMS = 100;
52
+ /** A connection that has not sent a complete ClientHello by then is dropped. */
53
+ const CLIENT_HELLO_TIMEOUT_MS = 10_000;
54
+
26
55
  const HOP_HEADERS = new Set([
27
56
  "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
28
57
  "te", "trailer", "transfer-encoding", "upgrade",
29
58
  ]);
30
59
 
60
+ type RelayRequest = IncomingMessage | Http2ServerRequest;
61
+ type RelayResponse = ServerResponse | Http2ServerResponse;
62
+
63
+ const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
64
+ const ORIGIN_FORM_TARGET = /^\/[\x21-\x7e]*$/;
65
+
66
+ /**
67
+ * HTTP/2 carries method and path as header values, which Bun accepts more loosely than an
68
+ * HTTP/1.1 request line can express. Only a method token and an origin-form target relay.
69
+ */
70
+ export function isRelayableH2Target(method: string | undefined, path: string | undefined): boolean {
71
+ return method !== undefined && path !== undefined && METHOD_TOKEN.test(method) && ORIGIN_FORM_TARGET.test(path);
72
+ }
73
+
31
74
  function filteredHeaders(raw: readonly string[], omit: ReadonlySet<string> = new Set()): string[] {
32
75
  const named = new Set<string>();
33
76
  for (let i = 0; i + 1 < raw.length; i += 2) {
@@ -61,15 +104,98 @@ function contentEncoding(raw: readonly string[]): string | undefined {
61
104
  return undefined;
62
105
  }
63
106
 
107
+ /**
108
+ * Request headers for the HTTP/1.1 upstream. An HTTP/2 request has pseudo-headers instead of a
109
+ * request line, names its origin in `:authority` (which replaces any Host, RFC 9113 8.3.1) and
110
+ * may split Cookie into crumbs (8.2.3); HTTP/1.1 needs one Host and one Cookie header.
111
+ */
112
+ function upstreamRequestHeaders(req: RelayRequest, omit: ReadonlySet<string>): string[] {
113
+ if (req.httpVersionMajor !== 2) return filteredHeaders(req.rawHeaders, omit);
114
+ const rest: string[] = [];
115
+ const cookies: string[] = [];
116
+ let authority: string | undefined;
117
+ let host: string | undefined;
118
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
119
+ const name = req.rawHeaders[i]!.toLowerCase();
120
+ const value = req.rawHeaders[i + 1]!;
121
+ if (name === ":authority") authority = value;
122
+ else if (name === "host") host = value;
123
+ else if (name === "cookie") cookies.push(value);
124
+ else if (!name.startsWith(":")) rest.push(req.rawHeaders[i]!, value);
125
+ }
126
+ const headers = filteredHeaders(rest, omit);
127
+ headers.unshift("Host", authority ?? host ?? "claude.ai");
128
+ if (cookies.length > 0) headers.push("Cookie", cookies.join("; "));
129
+ return headers;
130
+ }
131
+
132
+ function listenLoopback(server: TcpServer): Promise<number> {
133
+ return new Promise((resolve, reject) => {
134
+ server.once("error", reject);
135
+ server.listen(0, "127.0.0.1", () => {
136
+ server.off("error", reject);
137
+ const address = server.address();
138
+ if (!address || typeof address === "string") reject(new Error("picker listener has no port"));
139
+ else resolve(address.port);
140
+ });
141
+ });
142
+ }
143
+
144
+ function closeServer(server: TcpServer): Promise<void> {
145
+ return new Promise(resolve => {
146
+ if (!server.listening) { resolve(); return; }
147
+ server.close(() => resolve());
148
+ });
149
+ }
150
+
64
151
  export async function startPickerListener(options: PickerListenerOptions): Promise<PickerListenerHandle> {
65
152
  const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
66
153
  const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
154
+ const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
155
+ let activeUpstreams = 0;
67
156
  const upgrades = new Set<Duplex>();
68
- const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] });
157
+ const spliced = new Set<Socket>();
158
+ const h2Sockets = new Set<Duplex>();
159
+ const sessions = new Set<ServerHttp2Session>();
160
+ const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"], maxHeaderSize: PICKER_MAX_HEADER_BYTES });
161
+ // ALPN h2 only; no enableConnectProtocol, so Chromium opens WebSockets as HTTP/1.1 connections,
162
+ // which the front sends to `server`. An over-budget header block is refused natively per stream
163
+ // (Bun counts name + value + 32 bytes per field) before the request handler runs.
164
+ const h2Server = createSecureServer({
165
+ cert: options.leaf.certPem, key: options.leaf.keyPem,
166
+ settings: { maxHeaderListSize: PICKER_MAX_HEADER_BYTES, maxConcurrentStreams: PICKER_MAX_CONCURRENT_STREAMS },
167
+ });
168
+ // Track sockets from accept (a handshake that never completes too) and after TLS, so a forced
169
+ // shutdown reaches every one of them.
170
+ for (const event of ["connection", "secureConnection"] as const) {
171
+ h2Server.on(event, (socket: Duplex) => {
172
+ h2Sockets.add(socket);
173
+ socket.once("close", () => h2Sockets.delete(socket));
174
+ });
175
+ }
176
+ h2Server.on("session", (session: ServerHttp2Session) => {
177
+ sessions.add(session);
178
+ session.once("close", () => sessions.delete(session));
179
+ options.log?.("picker session h2");
180
+ });
69
181
 
70
- server.on("request", (req: IncomingMessage, res: ServerResponse) => {
182
+ const relay = (req: RelayRequest, relayRes: RelayResponse) => {
183
+ // The HTTP/2 compat response has every ServerResponse member used here; only writeHead's
184
+ // reason phrase differs, and sendHead branches on it.
185
+ const res = relayRes as ServerResponse;
186
+ const h2 = req.httpVersionMajor === 2;
187
+ // Refusals answer with an empty response and a fixed log line that carries no request data.
188
+ const refuse = (status: 400 | 503) => {
189
+ res.writeHead(status, { "Content-Length": "0" });
190
+ res.end();
191
+ options.log?.(`picker request refused ${status}`);
192
+ };
193
+ if (h2 && !isRelayableH2Target(req.method, req.url)) { refuse(400); return; }
194
+ if (activeUpstreams >= maxActiveUpstreams) { refuse(503); return; }
71
195
  const method = req.method ?? "GET";
72
- const pathname = new URL(req.url ?? "/", "https://claude.ai").pathname;
196
+ let pathname: string;
197
+ // A target like "//[" reads as an authority and throws; refuse it like any unrelayable target.
198
+ try { pathname = new URL(req.url ?? "/", "https://claude.ai").pathname; } catch { refuse(400); return; }
73
199
  const bootstrap = isPickerBootstrapRequest(method, pathname);
74
200
  const category = bootstrap ? "bootstrap" : "other";
75
201
  let logged = false;
@@ -77,66 +203,100 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
77
203
  if (!logged) options.log?.(`picker ${method} ${category} ${status}`);
78
204
  logged = true;
79
205
  };
206
+ // Set once the client side closes before the response finished: the upstream error that
207
+ // tearing it down raises is then expected, not a relay failure to answer or log.
208
+ let clientGone = false;
80
209
  const fail = () => {
210
+ if (clientGone) return;
81
211
  if (res.headersSent) res.destroy();
82
212
  else { res.writeHead(502, { "Content-Length": "0" }); res.end(); log(502); }
83
213
  };
84
214
  const omit = bootstrap ? new Set(["accept-encoding"]) : new Set<string>();
85
- const headers = filteredHeaders(req.rawHeaders, omit);
215
+ const headers = upstreamRequestHeaders(req, omit);
86
216
  if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding());
87
- const upReq = httpsRequest({
88
- host: upstream.host, port: upstream.port, servername: upstream.servername,
89
- ca: upstream.ca, rejectUnauthorized: true, agent: false,
90
- method, path: req.url, headers,
91
- }, upRes => {
92
- const status = upRes.statusCode ?? 502;
93
- const originalHeaders = filteredHeaders(upRes.rawHeaders);
94
- const sendHead = (raw: string[]) => {
95
- if (res.headersSent) return;
96
- res.writeHead(status, upRes.statusMessage, raw);
97
- log(status);
98
- };
99
- upRes.on("error", fail);
100
- if (!bootstrap || status !== 200 || !hasJsonContentType(upRes.rawHeaders)) {
101
- sendHead(originalHeaders);
102
- upRes.pipe(res);
103
- return;
104
- }
105
- const held: Buffer[] = [];
106
- let size = 0;
107
- let handedOff = false;
108
- const onData = (chunk: Buffer) => {
109
- if (size + chunk.length > cap) {
110
- upRes.pause();
111
- upRes.off("data", onData);
112
- handedOff = true;
217
+ let upReq: ReturnType<typeof httpsRequest>;
218
+ try {
219
+ // The HTTP/1.1 client validates the method, path and header values synchronously.
220
+ upReq = httpsRequest({
221
+ host: upstream.host, port: upstream.port, servername: upstream.servername,
222
+ ca: upstream.ca, rejectUnauthorized: true, agent: false,
223
+ method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES,
224
+ }, upRes => {
225
+ const status = upRes.statusCode ?? 502;
226
+ const originalHeaders = filteredHeaders(upRes.rawHeaders);
227
+ const sendHead = (raw: string[]) => {
228
+ if (res.headersSent) return;
229
+ // HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
230
+ if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
231
+ else res.writeHead(status, upRes.statusMessage, raw);
232
+ log(status);
233
+ };
234
+ upRes.on("error", fail);
235
+ if (!bootstrap || status !== 200 || !hasJsonContentType(upRes.rawHeaders)) {
113
236
  sendHead(originalHeaders);
114
- for (const part of held) res.write(part);
115
- res.write(chunk);
116
237
  upRes.pipe(res);
117
238
  return;
118
239
  }
119
- held.push(chunk);
120
- size += chunk.length;
121
- };
122
- upRes.on("data", onData);
123
- upRes.once("end", () => {
124
- if (handedOff) return;
125
- const original = Buffer.concat(held, size);
126
- let outcome = "unchanged";
127
- const rewritten = rewriteBootstrapBody(original, contentEncoding(upRes.rawHeaders), options.models(), result => {
128
- outcome = result.kind === "rewritten" ? `rewritten(+${result.added})` : `unchanged:${result.reason}`;
240
+ const held: Buffer[] = [];
241
+ let size = 0;
242
+ let handedOff = false;
243
+ const onData = (chunk: Buffer) => {
244
+ if (size + chunk.length > cap) {
245
+ upRes.pause();
246
+ upRes.off("data", onData);
247
+ handedOff = true;
248
+ sendHead(originalHeaders);
249
+ for (const part of held) res.write(part);
250
+ res.write(chunk);
251
+ upRes.pipe(res);
252
+ return;
253
+ }
254
+ held.push(chunk);
255
+ size += chunk.length;
256
+ };
257
+ upRes.on("data", onData);
258
+ upRes.once("end", () => {
259
+ if (handedOff) return;
260
+ const original = Buffer.concat(held, size);
261
+ let outcome = "unchanged";
262
+ const rewritten = rewriteBootstrapBody(original, contentEncoding(upRes.rawHeaders), options.models(), result => {
263
+ outcome = result.kind === "rewritten" ? `rewritten(+${result.added})` : `unchanged:${result.reason}`;
264
+ });
265
+ sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length));
266
+ options.log?.(`picker ${method} bootstrap ${outcome}`);
267
+ res.end(rewritten ?? original);
129
268
  });
130
- sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length));
131
- options.log?.(`picker ${method} bootstrap ${outcome}`);
132
- res.end(rewritten ?? original);
133
269
  });
270
+ } catch {
271
+ refuse(400);
272
+ return;
273
+ }
274
+ // One budget slot per upstream request, released once by whichever end finishes first.
275
+ activeUpstreams++;
276
+ let released = false;
277
+ const release = () => { if (!released) { released = true; activeUpstreams--; } };
278
+ upReq.once("close", release);
279
+ res.once("close", release);
280
+ if (h2) (req as Http2ServerRequest).stream.once("close", release);
281
+ upReq.on("error", error => {
282
+ if ((error as NodeJS.ErrnoException).code === "HPE_HEADER_OVERFLOW") {
283
+ options.log?.(`picker ${method} ${category} upstream:headers-too-large`);
284
+ }
285
+ fail();
134
286
  });
135
- upReq.on("error", fail);
136
287
  req.on("error", () => upReq.destroy());
137
- res.on("close", () => { if (!res.writableEnded) upReq.destroy(); });
288
+ const onClientClose = () => {
289
+ if (!res.writableEnded) clientGone = true;
290
+ upReq.destroy();
291
+ };
292
+ res.on("close", () => { if (!res.writableEnded) onClientClose(); });
293
+ // Bun's compat response skips its close event for a HEAD reset before end(); the stream's own
294
+ // close always fires. Destroying an upstream request that already completed is a no-op.
295
+ if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose);
138
296
  req.pipe(upReq);
139
- });
297
+ };
298
+ server.on("request", relay);
299
+ h2Server.on("request", relay);
140
300
 
141
301
  server.on("upgrade", (req, client, head) => {
142
302
  const method = req.method ?? "GET";
@@ -190,24 +350,77 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
190
350
  client.once("close", () => { upgrades.delete(client); target.destroy(); });
191
351
  });
192
352
 
353
+ let http1Port = 0;
354
+ let h2Port = 0;
355
+ // The front never terminates TLS: it reads the ClientHello, then splices the untouched bytes
356
+ // to the chosen server through a loopback socket, so both directions keep stream backpressure.
357
+ // Ownership: `spliced` holds every front and bridge socket from accept until close; close()
358
+ // destroys them, the h2 sessions and their TLS sockets, and the HTTP/1.1 server's connections.
359
+ let closing = false;
360
+ const front = createTcpServer(client => {
361
+ if (closing) { client.destroy(); return; }
362
+ spliced.add(client);
363
+ client.once("close", () => spliced.delete(client));
364
+ client.on("error", () => client.destroy());
365
+ // An absolute deadline: trickled bytes cannot hold an unfinished ClientHello open.
366
+ const deadline = setTimeout(() => client.destroy(), CLIENT_HELLO_TIMEOUT_MS);
367
+ client.once("close", () => clearTimeout(deadline));
368
+ let head: Buffer = Buffer.alloc(0);
369
+ const onData = (chunk: Buffer) => {
370
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
371
+ const offersH2 = clientHelloOffersH2(head);
372
+ if (offersH2 === null) return;
373
+ client.off("data", onData);
374
+ // Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
375
+ client.pause();
376
+ clearTimeout(deadline);
377
+ const inner = tcpConnect({ host: "127.0.0.1", port: offersH2 ? h2Port : http1Port });
378
+ spliced.add(inner);
379
+ const teardown = () => { client.destroy(); inner.destroy(); };
380
+ inner.on("error", teardown);
381
+ client.on("error", teardown);
382
+ // pipe() forwards each FIN after the queued bytes; a side that closes without having ended
383
+ // its peer (reset, dial failure) tears the peer down instead of leaving it open.
384
+ inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
385
+ client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
386
+ inner.once("connect", () => {
387
+ inner.setNoDelay(true);
388
+ client.setNoDelay(true);
389
+ // Every byte read during the peek (ClientHello and anything coalesced after it), once.
390
+ inner.write(head);
391
+ client.pipe(inner);
392
+ inner.pipe(client);
393
+ client.resume();
394
+ });
395
+ };
396
+ client.on("data", onData);
397
+ });
398
+
399
+ const servers: TcpServer[] = [server, h2Server, front];
400
+ // One forced shutdown for close() and for a failed start, shared by concurrent callers: destroy
401
+ // every tracked socket and session (a graceful HTTP/2 close would wait on open SSE streams
402
+ // forever), force the HTTP/1.1 server's connections closed while its native handle still exists
403
+ // (Bun's close() drops it), then stop accepting and wait for the servers to report closed.
404
+ let stopped: Promise<void> | null = null;
405
+ const shutdown = () => {
406
+ stopped ??= (async () => {
407
+ closing = true;
408
+ for (const socket of upgrades) socket.destroy();
409
+ for (const socket of spliced) socket.destroy();
410
+ for (const session of sessions) session.destroy();
411
+ for (const socket of h2Sockets) socket.destroy();
412
+ server.closeAllConnections();
413
+ await Promise.all(servers.map(closeServer));
414
+ })();
415
+ return stopped;
416
+ };
193
417
  try {
194
- await new Promise<void>((resolve, reject) => {
195
- server.once("error", reject);
196
- server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
197
- });
418
+ http1Port = await listenLoopback(server);
419
+ h2Port = await listenLoopback(h2Server);
420
+ const port = await listenLoopback(front);
421
+ return { port, close: shutdown };
198
422
  } catch (error) {
199
- server.close();
423
+ await shutdown();
200
424
  throw error;
201
425
  }
202
- const address = server.address();
203
- if (!address || typeof address === "string") throw new Error("picker listener has no port");
204
- return {
205
- port: address.port,
206
- async close() {
207
- for (const socket of upgrades) socket.destroy();
208
- const closed = new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
209
- server.closeAllConnections();
210
- await closed;
211
- },
212
- };
213
426
  }
@@ -248,6 +248,7 @@ export function anthropicIncompleteOutcome(
248
248
  */
249
249
  export function anthropicFailedStatus(error: Rec, message: string): number {
250
250
  const code = typeof error.code === "string" ? error.code : undefined;
251
+ if (code === "context_length_exceeded") return 400;
251
252
  return code === "translation_buffer_limit"
252
253
  ? 413
253
254
  : typeof error.status === "number"
@@ -726,6 +727,7 @@ export function responsesSseToAnthropicSse(
726
727
  status,
727
728
  message,
728
729
  true,
730
+ code === "context_length_exceeded" ? code : undefined,
729
731
  );
730
732
  break;
731
733
  }
@@ -0,0 +1,53 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { claudeConfigDir } from "./gateway-cache";
5
+ import { commandInvocation } from "../lib/win-exec";
6
+
7
+ export interface SubagentForceStatus {
8
+ targetValid: boolean;
9
+ version: string | null;
10
+ support: "supported" | "unsupported" | "unknown";
11
+ settingsOverride: boolean;
12
+ settingsReadable: boolean;
13
+ }
14
+
15
+ export function subagentForceSupport(version: string | null): SubagentForceStatus["support"] {
16
+ const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
17
+ if (!match) return "unknown";
18
+ const [major, minor, patch] = match.slice(1).map(Number);
19
+ return major! > 2 || (major === 2 && (minor! > 1 || (minor === 1 && patch! >= 257))) ? "supported" : "unsupported";
20
+ }
21
+
22
+ function installedVersion(): Promise<string | null> {
23
+ const invocation = commandInvocation("claude", ["--version"]);
24
+ return new Promise(resolve => {
25
+ execFile(invocation.file, invocation.args, { ...invocation.options, timeout: 1500, maxBuffer: 4096 }, (error, stdout) => {
26
+ resolve(error ? null : /^\s*(\d+\.\d+\.\d+)(?:\s|$)/.exec(String(stdout))?.[1] ?? null);
27
+ });
28
+ });
29
+ }
30
+
31
+ /** Server-local observation only: never returns settings values or modifies settings. */
32
+ export async function inspectSubagentForceStatus(
33
+ targetValid: boolean,
34
+ configDir = claudeConfigDir(),
35
+ versionProbe: () => Promise<string | null> = installedVersion,
36
+ ): Promise<SubagentForceStatus> {
37
+ const version = await versionProbe().catch(() => null);
38
+ let settingsOverride = false;
39
+ let settingsReadable = true;
40
+ try {
41
+ const path = join(configDir, "settings.json");
42
+ const metadata = await stat(path);
43
+ if (!metadata.isFile() || metadata.size > 1_048_576) throw new Error("settings unavailable");
44
+ const parsed = JSON.parse(await readFile(path, "utf8"));
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid settings");
46
+ const env = parsed.env;
47
+ settingsOverride = !!env && typeof env === "object" && !Array.isArray(env)
48
+ && (Object.hasOwn(env, "CLAUDE_CODE_SUBAGENT_MODEL") || Object.hasOwn(env, "CLAUDE_CODE_SUBAGENT_MODEL_FORCE"));
49
+ } catch (error) {
50
+ settingsReadable = (error as NodeJS.ErrnoException).code === "ENOENT";
51
+ }
52
+ return { targetValid, version, support: subagentForceSupport(version), settingsOverride, settingsReadable };
53
+ }