@iislee/opencodex 2.31.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -484,6 +484,29 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
484
484
  return join(primeAgentDir(env, home), "models.json");
485
485
  }
486
486
 
487
+ export function clineHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
488
+ const override = env.CLINE_DATA_DIR?.trim();
489
+ if (override) return absoluteClientPath(override, home, "CLINE_DATA_DIR");
490
+
491
+ const vscodeDir = process.platform === "darwin"
492
+ ? join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings")
493
+ : process.platform === "win32"
494
+ ? join(env.APPDATA || join(home, "AppData", "Roaming"), "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings")
495
+ : join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings");
496
+
497
+ if (existsSync(vscodeDir)) return vscodeDir;
498
+
499
+ const cliSettingsDir = join(home, ".cline", "data", "settings");
500
+ if (existsSync(cliSettingsDir) || existsSync(join(home, ".cline"))) return cliSettingsDir;
501
+
502
+ return vscodeDir;
503
+ }
504
+
505
+ /** Cline's canonical providers configuration file. */
506
+ export function clineConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
507
+ return join(clineHomeDir(env, home), "providers.json");
508
+ }
509
+
487
510
  /**
488
511
  * One proxy-routed model destined for a client config. Deliberately narrower than
489
512
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -526,7 +549,8 @@ export type ExportClientId =
526
549
  | "dsh"
527
550
  | "mcode"
528
551
  | "zcode"
529
- | "prime";
552
+ | "prime"
553
+ | "cline";
530
554
 
531
555
  export interface ExportClientSpec {
532
556
  id: ExportClientId;
@@ -725,10 +749,10 @@ export function buildOpencodeProviderBlockFromCatalog(
725
749
  * models produce identical bytes. Stability matters because the GUI shows a diffable
726
750
  * preview and agents may checksum the payload.
727
751
  */
728
- export function normalizeExportModels(models: readonly ExportModel[]): ExportModel[] {
752
+ export function normalizeExportModels(models: readonly ExportModel[] = []): ExportModel[] {
729
753
  const seen = new Set<string>();
730
754
  const unique: ExportModel[] = [];
731
- for (const model of models) {
755
+ for (const model of models ?? []) {
732
756
  if (seen.has(model.namespaced)) continue;
733
757
  seen.add(model.namespaced);
734
758
  unique.push(model);
@@ -1379,6 +1403,42 @@ function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig {
1379
1403
  };
1380
1404
  }
1381
1405
 
1406
+ export interface ClineProviderSettings {
1407
+ provider: string;
1408
+ baseUrl: string;
1409
+ apiKey?: string;
1410
+ model?: string;
1411
+ }
1412
+
1413
+ export interface ClineProviderBlock {
1414
+ settings: ClineProviderSettings;
1415
+ tokenSource?: string;
1416
+ }
1417
+
1418
+ export interface ClineGeneratedConfig {
1419
+ version: number;
1420
+ providers: Record<string, ClineProviderBlock>;
1421
+ }
1422
+
1423
+ function buildClineClientConfig(ctx: ExportContext): ClineGeneratedConfig {
1424
+ const models = normalizeExportModels(ctx.models);
1425
+ const defaultModel = models[0]?.namespaced ?? "opencodex";
1426
+ return {
1427
+ version: 1,
1428
+ providers: {
1429
+ [OPENCODE_PROVIDER_ID]: {
1430
+ settings: {
1431
+ provider: "openai-compatible",
1432
+ baseUrl: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1",
1433
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
1434
+ model: defaultModel,
1435
+ },
1436
+ tokenSource: "manual",
1437
+ },
1438
+ },
1439
+ };
1440
+ }
1441
+
1382
1442
  /**
1383
1443
  * Per-client model counts, read back off the SERIALIZED document rather than
1384
1444
  * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about
@@ -1390,6 +1450,11 @@ function summarizeOpencode(document: unknown): { modelCount: number; modelsWitho
1390
1450
  return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
1391
1451
  }
1392
1452
 
1453
+ function summarizeCline(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1454
+ const provider = (document as ClineGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID];
1455
+ return { modelCount: provider ? 1 : 0, modelsWithoutLimits: 0 };
1456
+ }
1457
+
1393
1458
  function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1394
1459
  const models = (document as PiGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
1395
1460
  return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
@@ -1522,6 +1587,11 @@ function buildPrimeContribution(ctx: ExportContext): ManagedContribution {
1522
1587
  return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1523
1588
  }
1524
1589
 
1590
+ function buildClineContribution(ctx: ExportContext): ManagedContribution {
1591
+ const doc = buildClineClientConfig(ctx);
1592
+ return singleFragment("cline", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1593
+ }
1594
+
1525
1595
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1526
1596
  opencode: {
1527
1597
  id: "opencode",
@@ -1675,6 +1745,18 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1675
1745
  // from this initial loopback-only integration — same stance as OMP's.
1676
1746
  loopbackOnly: true,
1677
1747
  },
1748
+ cline: {
1749
+ id: "cline",
1750
+ filename: "providers.json",
1751
+ destination: env => clineConfigPath(env),
1752
+ apiKeyEnv: "",
1753
+ exportHint: "Cline reads a non-secret placeholder from providers.json; loopback needs no key.",
1754
+ build: buildClineClientConfig,
1755
+ format: "json",
1756
+ summarize: summarizeCline,
1757
+ buildContribution: buildClineContribution,
1758
+ loopbackOnly: true,
1759
+ },
1678
1760
  };
1679
1761
 
1680
1762
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -18,10 +18,11 @@ import { probePi } from "./probes/pi";
18
18
  import { probeGrok } from "./probes/grok";
19
19
  import { probeOpencode } from "./probes/opencode";
20
20
  import { probeAgy } from "./probes/agy";
21
+ import { probeCline } from "./probes/cline";
21
22
  import { readCcSwitchCurrentProfiles, type CcSwitchProfile } from "./probes/cc-switch";
22
23
  import { readPaseoProviderCommands, type PaseoProviderCommand } from "./probes/paseo";
23
24
 
24
- export type ClientId = "claude" | "codex" | "pi" | "grok" | "opencode" | "agy";
25
+ export type ClientId = "claude" | "codex" | "pi" | "grok" | "opencode" | "agy" | "cline";
25
26
 
26
27
  export type ClientVerdict = "ocx" | "direct" | "mixed" | "missing" | "unknown";
27
28
 
@@ -70,6 +71,7 @@ export interface EffectiveStatusOpts {
70
71
  grok?: typeof probeGrok;
71
72
  opencode?: typeof probeOpencode;
72
73
  agy?: typeof probeAgy;
74
+ cline?: typeof probeCline;
73
75
  ccSwitch?: typeof readCcSwitchCurrentProfiles;
74
76
  paseo?: typeof readPaseoProviderCommands;
75
77
  };
@@ -82,6 +84,7 @@ const CLIENT_LABELS: Record<ClientId, string> = {
82
84
  grok: "Grok Build",
83
85
  opencode: "OpenCode",
84
86
  agy: "Antigravity (agy)",
87
+ cline: "Cline",
85
88
  };
86
89
 
87
90
  /** Map client id → CC Switch `app_type` column (when present). */
@@ -90,6 +93,7 @@ const CC_SWITCH_APP: Partial<Record<ClientId, string>> = {
90
93
  codex: "codex",
91
94
  grok: "grokbuild",
92
95
  opencode: "opencode",
96
+ cline: "cline",
93
97
  };
94
98
 
95
99
  /** Map client id → Paseo `agents.providers` key. */
@@ -99,6 +103,7 @@ const PASEO_PROVIDER: Partial<Record<ClientId, string>> = {
99
103
  agy: "antigravity-acp",
100
104
  claude: "claude",
101
105
  codex: "codex",
106
+ cline: "cline",
102
107
  };
103
108
 
104
109
  export interface ProxyTarget {
@@ -220,13 +225,14 @@ export async function readClientsEffectiveStatus(
220
225
  const grokFn = opts.probes?.grok ?? probeGrok;
221
226
  const opencodeFn = opts.probes?.opencode ?? probeOpencode;
222
227
  const agyFn = opts.probes?.agy ?? probeAgy;
228
+ const clineFn = opts.probes?.cline ?? probeCline;
223
229
  const ccSwitchFn = opts.probes?.ccSwitch ?? readCcSwitchCurrentProfiles;
224
230
  const paseoFn = opts.probes?.paseo ?? readPaseoProviderCommands;
225
231
 
226
232
  const emptyProfiles: CcSwitchProfile[] = [];
227
233
  const emptyPaseo: PaseoProviderCommand[] = [];
228
234
 
229
- const [ccProfiles, paseoCommands, claude, codex, pi, grok, opencode, agy] = await Promise.all([
235
+ const [ccProfiles, paseoCommands, claude, codex, pi, grok, opencode, agy, cline] = await Promise.all([
230
236
  safeProbeAsync("cc-switch", () => Promise.resolve(ccSwitchFn({ home })), emptyProfiles),
231
237
  safeProbeAsync("paseo", () => Promise.resolve(paseoFn({ home })), emptyPaseo),
232
238
  safeProbeAsync("claude", () => Promise.resolve(claudeFn({ home })), {
@@ -247,6 +253,9 @@ export async function readClientsEffectiveStatus(
247
253
  safeProbeAsync("agy", () => Promise.resolve(agyFn({ home })), {
248
254
  present: false, baseUrl: null, model: null, configPaths: [] as string[], notes: ["probe failed"], binary: null,
249
255
  }),
256
+ safeProbeAsync("cline", () => Promise.resolve(clineFn({ home })), {
257
+ present: false, baseUrl: null, model: null, configPaths: [] as string[], notes: ["probe failed"], binary: null,
258
+ }),
250
259
  ]);
251
260
 
252
261
  const clients: ClientEffectiveStatus[] = [];
@@ -372,6 +381,25 @@ export async function readClientsEffectiveStatus(
372
381
  });
373
382
  }
374
383
 
384
+ // Cline
385
+ {
386
+ const raw = cline;
387
+ const { verdict, viaOcx } = verdictFromBaseUrl(raw.baseUrl, proxy, { present: raw.present });
388
+ clients.push({
389
+ id: "cline",
390
+ label: CLIENT_LABELS.cline,
391
+ present: raw.present,
392
+ viaOcx,
393
+ verdict: raw.present ? (raw.baseUrl ? verdict : "unknown") : "missing",
394
+ baseUrl: raw.baseUrl,
395
+ model: raw.model,
396
+ configPaths: raw.configPaths,
397
+ launcher: paseoLauncher("cline", paseoCommands) ?? (raw.binary ? "cline" : null),
398
+ switcher: switcherFor("cline", ccProfiles),
399
+ notes: raw.notes,
400
+ });
401
+ }
402
+
375
403
  return {
376
404
  generatedAt: Date.now(),
377
405
  proxy: {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cline effective-status probe.
3
+ * Reads providers.json and checks for opencodex / openai-compatible configuration.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { clineConfigPath, clineHomeDir } from "../config-export";
7
+
8
+ export interface ClineProbeResult {
9
+ present: boolean;
10
+ baseUrl: string | null;
11
+ model: string | null;
12
+ configPaths: string[];
13
+ notes: string[];
14
+ binary: string | null;
15
+ }
16
+
17
+ function resolveBinary(name: string): string | null {
18
+ try {
19
+ const which = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which;
20
+ if (typeof which === "function") {
21
+ return which(name) ?? null;
22
+ }
23
+ } catch { /* ignore */ }
24
+ return null;
25
+ }
26
+
27
+ export function probeCline(opts: { home: string; env?: NodeJS.ProcessEnv } = { home: "" }): ClineProbeResult {
28
+ const notes: string[] = [];
29
+ const binary = resolveBinary("cline");
30
+ const configPath = clineConfigPath(opts.env, opts.home);
31
+ const homeDir = clineHomeDir(opts.env, opts.home);
32
+ const exists = existsSync(configPath);
33
+ const dirExists = existsSync(homeDir);
34
+ const present = exists || dirExists || Boolean(binary);
35
+
36
+ if (!exists) {
37
+ if (present) {
38
+ notes.push("Cline installation detected, but providers.json is not configured yet.");
39
+ }
40
+ return {
41
+ present,
42
+ baseUrl: null,
43
+ model: null,
44
+ configPaths: existsSync(homeDir) ? [homeDir] : [configPath],
45
+ notes,
46
+ binary,
47
+ };
48
+ }
49
+
50
+ let baseUrl: string | null = null;
51
+ let model: string | null = null;
52
+
53
+ try {
54
+ const raw = readFileSync(configPath, "utf8");
55
+ const parsed = JSON.parse(raw) as {
56
+ lastUsedProvider?: string;
57
+ providers?: Record<string, { settings?: { baseUrl?: string; model?: string; provider?: string } }>;
58
+ };
59
+ const providers = parsed?.providers ?? {};
60
+ const ocxProvider = providers.opencodex ?? providers["openai-compatible"];
61
+ if (ocxProvider?.settings?.baseUrl) {
62
+ baseUrl = ocxProvider.settings.baseUrl;
63
+ model = ocxProvider.settings.model ?? null;
64
+ } else if (parsed?.lastUsedProvider && providers[parsed.lastUsedProvider]?.settings?.baseUrl) {
65
+ const active = providers[parsed.lastUsedProvider]!;
66
+ baseUrl = active.settings?.baseUrl ?? null;
67
+ model = active.settings?.model ?? null;
68
+ }
69
+ } catch (error) {
70
+ notes.push(`Could not read providers.json: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
72
+
73
+ return {
74
+ present: true,
75
+ baseUrl,
76
+ model,
77
+ configPaths: [configPath],
78
+ notes,
79
+ binary,
80
+ };
81
+ }
@@ -5,6 +5,27 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
5
5
 
6
6
  export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
7
7
 
8
+ /**
9
+ * Account log labels come in two families (#2699):
10
+ *
11
+ * - `p<hex6>` (plus the literal `main`) — a Codex pool account.
12
+ * - `o<hex6>` — a non-Codex OAuth provider account (xai, cursor, and siblings).
13
+ *
14
+ * Both are sha256-derived digests, never an email and never a raw provider account id. That is
15
+ * a privacy requirement, not a formatting preference: these labels are written to the usage log
16
+ * and served over the management API.
17
+ *
18
+ * hex6 is 16.7M values, so two accounts CAN collide and merge into one reported row. That is a
19
+ * reporting inaccuracy at operator scale, not a correctness or privacy failure, and it is the
20
+ * accepted cost of keeping the existing `p` format byte-compatible.
21
+ */
22
+ export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/;
23
+ export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6})$/;
24
+
25
+ export function oauthAccountLogLabel(accountId: string, provider = ""): string {
26
+ return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`;
27
+ }
28
+
8
29
  export function createCodexAccountLogLabel(existingLabels: Iterable<string | undefined | null> = []): string {
9
30
  const used = new Set([...existingLabels].filter((value): value is string => !!value));
10
31
  for (let i = 0; i < 16; i++) {
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../types";
2
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
2
3
 
3
4
  /** Whether an account is administratively excluded from future pool selection. */
4
5
  export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean {
@@ -12,7 +13,7 @@ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paus
12
13
  else pausedIds.delete(accountId);
13
14
 
14
15
  if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds];
15
- else delete config.pausedCodexAccountIds;
16
+ else deleteConfigTopLevelKey(config, "pausedCodexAccountIds");
16
17
  }
17
18
 
18
19
  export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void {
@@ -1,6 +1,7 @@
1
1
  import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
2
2
  import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
3
3
  import type { OcxConfig } from "../types";
4
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
4
5
 
5
6
  /**
6
7
  * Which ids may carry a selection order: any pool account, plus the synthetic
@@ -34,7 +35,7 @@ export function setCodexAccountPriority(config: OcxConfig, accountId: string, pr
34
35
  else entries.set(accountId, priority);
35
36
 
36
37
  if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries);
37
- else delete config.codexAccountPriorities;
38
+ else deleteConfigTopLevelKey(config, "codexAccountPriorities");
38
39
  }
39
40
 
40
41
  export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void {
@@ -78,6 +79,6 @@ export function setCodexAccountPin(config: OcxConfig, accountId: string): void {
78
79
  /** Release the pin. With `accountId` given, only when it is the pinned account. */
79
80
  export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void {
80
81
  if (accountId === undefined || config.activeCodexAccountPinned === accountId) {
81
- delete config.activeCodexAccountPinned;
82
+ deleteConfigTopLevelKey(config, "activeCodexAccountPinned");
82
83
  }
83
84
  }
@@ -17,7 +17,8 @@ import {
17
17
  import { readCodexCatalogPath } from "./catalog/parsing";
18
18
 
19
19
  export const STALE_CODEX_APP_SERVER_HINT =
20
- "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex).";
20
+ "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). "
21
+ + "On Windows the desktop app itself may also need a full restart (ocx sync --restart-desktop-app).";
21
22
 
22
23
  /** Attach the shared dashboard hint only after a catalog or models_cache write. */
23
24
  export function attachStaleAppServerHint<T extends {
@@ -259,6 +260,23 @@ export function isCodexAppServerCommandLine(commandLine: string, executable?: st
259
260
  }
260
261
  if (tokens.length === 0) return false;
261
262
  if (isCodeModeHostProcess(tokens)) return true;
263
+ // An npm-installed Codex runs as a PAIR: `node /usr/local/bin/codex app-server` and the
264
+ // vendored native binary that wrapper spawns. Only the child used to match, so
265
+ // `--restart-codex` signalled the child while its supervisor kept holding the socket -
266
+ // which is what "PID(s) still running after SIGTERM" was reporting on Linux.
267
+ //
268
+ // The codex-shaped token must be IMMEDIATELY next. Skipping interpreter flags to reach
269
+ // it looks tempting and is wrong: interpreter options take values, so a generic skip
270
+ // reads the value of `node --require codex app-server worker.js` as the entrypoint.
271
+ // Supporting flags needs a real Node/Bun/Deno entrypoint parser, not a loop over
272
+ // hyphens; the observed wrappers put the path first, so this stays narrow on purpose.
273
+ //
274
+ // Dropping only the interpreter and re-running the ordinary scan is what preserves the
275
+ // subcommand discipline below: `node <codex> exec 'hi'` stays unmatched exactly like
276
+ // `codex exec 'hi'` does.
277
+ if (isInterpreterToken(tokens[0]!) && tokens.length > 1 && isCodexExecutableToken(tokens[1]!)) {
278
+ tokens = tokens.slice(1);
279
+ }
262
280
  if (!isCodexExecutableToken(tokens[0]!)) return false;
263
281
 
264
282
  let i = 1;
@@ -505,6 +523,7 @@ export function formatStaleCodexAppServerWarning(
505
523
  `WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
506
524
  + "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
507
525
  + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. "
526
+ + "On Windows the desktop app itself may also need a full restart (`ocx sync --restart-desktop-app`). "
508
527
  + "Active turns may be interrupted."
509
528
  );
510
529
  }
@@ -738,6 +757,28 @@ const CATALOG_STATE_TTL_MS = 5_000;
738
757
  */
739
758
  const CATALOG_STATE_UNKNOWN_TTL_MS = 250;
740
759
 
760
+ /**
761
+ * How long a real observation may still be SERVED after it expires, while a refresh
762
+ * runs behind it (#2499).
763
+ *
764
+ * The probe is advisory and, on Windows, slow: `Invoke-CimMethod GetOwner` costs
765
+ * ~0.4s per candidate process, so a cold probe routinely outlives the 5s TTL and
766
+ * every turn that misses the cache pays for it on the request path. Serving the
767
+ * previous reading immediately keeps that cost off the turn without pretending it is
768
+ * fresh -- the refresh it triggers is what makes the next reading current.
769
+ *
770
+ * Measured from expiry rather than from when the reading was taken, so a `fresh`
771
+ * entry stays servable for its own TTL plus this bound. Anchoring it to expiry keeps
772
+ * the stale window independent of the TTL -- a cap on total age would quietly turn
773
+ * this path off if the TTL were ever raised past it.
774
+ *
775
+ * Bounded rather than unlimited: if the refresh keeps failing, an observation this
776
+ * old stops being evidence about the machine and it is better to wait for a real one.
777
+ * `unknown` is never served this way -- it is a failure to observe, not an
778
+ * observation, and it already has its own short window for exactly that reason.
779
+ */
780
+ export const CATALOG_STATE_MAX_STALE_MS = 60_000;
781
+
741
782
  export function catalogStateTtlMs(state: CodexAppServerCatalogState): number {
742
783
  return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS;
743
784
  }
@@ -833,6 +874,14 @@ export function collectCodexAppServerCatalogState(
833
874
  * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests.
834
875
  * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract.
835
876
  * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe.
877
+ *
878
+ * [Decision Log · #2499]
879
+ * - 목적과 의도: a cold probe outlives its own 5s TTL on Windows (~435ms per candidate process for `Invoke-CimMethod GetOwner`), so the cache expires before it can serve and the miss lands on a turn.
880
+ * - 기존 구현 및 제약 조건: the reading is advisory, and only `fresh` authorizes positive guidance (`src/server/responses/collaboration.ts`); `unknown` is a failure to observe rather than an observation.
881
+ * - 검토한 주요 대안: drop the per-process GetOwner fan-out (issue suggestion 1), or widen the TTL past the probe duration (suggestion 3).
882
+ * - 선택한 방식: serve an expired reading immediately when its generation still matches, bounded by `CATALOG_STATE_MAX_STALE_MS`, never for `unknown`, and refresh behind it; a failed refresh no longer evicts the reading it was refreshing.
883
+ * - 다른 대안 대신 이 방식을 선택한 이유: the fan-out change alters what "could not verify the owner" means for the current-user scoping contract and needs its own ground-truth comparison; a wider TTL still pays the probe on every human-paced turn.
884
+ * - 장점, 단점 및 영향: after the first probe the request path never waits; a server that stopped between readings can be described as `fresh` for up to the stale bound; a catalog write still invalidates immediately through the generation, and the cold path is unchanged.
836
885
  */
837
886
  export async function collectCodexAppServerCatalogStateForRequest(
838
887
  io: CodexAppServerProcessIo = {},
@@ -851,16 +900,30 @@ export async function collectCodexAppServerCatalogStateForRequest(
851
900
  catalogMtimeMs: io.catalogMtimeMs,
852
901
  now: io.now,
853
902
  };
854
- if (requestCatalogStateCache
903
+ const cached = requestCatalogStateCache
855
904
  && requestCatalogStateCache.generation === generation
856
905
  && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity)
857
- && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) {
858
- return requestCatalogStateCache.status;
906
+ ? requestCatalogStateCache
907
+ : null;
908
+ if (cached && now - cached.atMs < catalogStateTtlMs(cached.status.state)) {
909
+ return cached.status;
859
910
  }
911
+ // An expired real reading is still worth handing back while the refresh runs. It
912
+ // cannot have been invalidated by an ocx catalog write: every such write calls
913
+ // `resetCodexAppServerCatalogStateCache`, which advances the generation and drops
914
+ // this entry, so a generation match means no write has landed since it was taken.
915
+ // What it can miss is an app-server that started or stopped meanwhile -- and a
916
+ // server started after the reading is newer than the catalog, which is the `fresh`
917
+ // this entry already says.
918
+ const servableStale = cached
919
+ && cached.status.state !== "unknown"
920
+ && now - cached.atMs < catalogStateTtlMs(cached.status.state) + CATALOG_STATE_MAX_STALE_MS
921
+ ? cached.status
922
+ : null;
860
923
  if (requestCatalogStateFlight
861
924
  && requestCatalogStateFlight.generation === generation
862
925
  && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) {
863
- return requestCatalogStateFlight.promise;
926
+ return servableStale ?? requestCatalogStateFlight.promise;
864
927
  }
865
928
 
866
929
  const refresh = async (): Promise<CodexAppServerCatalogStatus> => {
@@ -918,7 +981,18 @@ export async function collectCodexAppServerCatalogStateForRequest(
918
981
  if (requestCatalogStateGeneration !== generation) {
919
982
  return { state: "unknown" as const, processes: [], catalogMtimeMs: null };
920
983
  }
921
- if (requestCatalogStateFlight === flight) {
984
+ // A refresh that failed must not evict a real reading. Before this function
985
+ // served stale entries, caching `unknown` cost at most the 250ms that state
986
+ // is allowed to live. Now that an expired observation is what callers are
987
+ // handed, overwriting one with `unknown` would take the answer AWAY from
988
+ // them on a transient failure -- `unknown` is not servable, so the next
989
+ // caller waits for a probe instead of getting the reading it would have had.
990
+ // Keep the observation and let its own age retire it.
991
+ const wouldEvictAnObservation = status.state === "unknown"
992
+ && requestCatalogStateCache?.generation === generation
993
+ && requestCatalogStateCache.status.state !== "unknown"
994
+ && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity);
995
+ if (requestCatalogStateFlight === flight && !wouldEvictAnObservation) {
922
996
  requestCatalogStateCache = {
923
997
  generation,
924
998
  identity,
@@ -932,7 +1006,9 @@ export async function collectCodexAppServerCatalogStateForRequest(
932
1006
  });
933
1007
  flight = { generation, identity, promise };
934
1008
  requestCatalogStateFlight = flight;
935
- return flight.promise;
1009
+ // `promise` already absorbs its own failures, so leaving it unawaited here cannot
1010
+ // surface as an unhandled rejection; the next caller picks up whatever it stored.
1011
+ return servableStale ?? flight.promise;
936
1012
  }
937
1013
 
938
1014
  /**
@@ -209,21 +209,59 @@ function codexAccountPersistenceConflict(
209
209
  : undefined;
210
210
  }
211
211
 
212
+ /**
213
+ * The exact label `parseUsageQuota` emits for the Codex Spark window (quota.ts).
214
+ * Matching on the label rather than on "is a custom window" is load-bearing: the same array
215
+ * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
216
+ * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
217
+ */
218
+ const CODEX_SPARK_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly";
219
+
220
+ /**
221
+ * Drop the Spark window unless the operator asked for it (default hidden).
222
+ *
223
+ * Applied at the DTO boundary, never at parse or cache time: custom windows participate in
224
+ * quota-presence checks, snapshot reconciliation and capacity aggregation, so removing Spark
225
+ * upstream of this point would change routing state rather than display.
226
+ *
227
+ * Both GUI surfaces funnel through here — the Codex Auth rows directly, and /api/provider-quotas
228
+ * via listCodexAuthAccountsSnapshot — so one filter covers both. Filtering only one would leave
229
+ * the other still rendering the row the operator switched off.
230
+ */
231
+ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
232
+ quota: T,
233
+ ): T {
234
+ if (!quota?.customWindows?.length) return quota;
235
+ if (loadConfig().showCodexSparkQuota === true) return quota;
236
+ const kept = quota.customWindows.filter(window => window.label !== CODEX_SPARK_WINDOW_LABEL);
237
+ if (kept.length === quota.customWindows.length) return quota;
238
+ // An empty list is dropped rather than serialized: an absent field and an empty array should
239
+ // not be two different ways of saying "no custom windows" on the wire.
240
+ const next = { ...quota } as Record<string, unknown>;
241
+ if (kept.length > 0) next.customWindows = kept;
242
+ else delete next.customWindows;
243
+ return next as T;
244
+ }
245
+
246
+
212
247
  function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
213
248
  quota: T,
214
249
  plan: unknown,
215
250
  ): T {
216
- if (!quota || !isThirtyDayOnlyCodexPlan(plan)) return quota;
251
+ const visible = withSparkVisibility(quota);
252
+ if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
253
+ const quotaWindows = visible;
217
254
  return {
218
- ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
219
- ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
255
+ ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
256
+ ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
220
257
  // A 30-day plan can still carry a burst window, and it blocks the account on its own.
221
258
  // Dropping it here would show a healthy card for an account upstream is refusing (#1791).
222
- ...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}),
223
- ...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}),
224
- ...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}),
225
- ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
226
- ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
259
+ ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
260
+ ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
261
+ ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
262
+ ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
263
+ ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
264
+ ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
227
265
  } as T;
228
266
  }
229
267
 
@@ -1806,7 +1844,7 @@ export async function handleCodexAuthAPI(
1806
1844
  }
1807
1845
 
1808
1846
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
1809
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
1847
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
1810
1848
  const requestedAccountId = body.id?.trim();
1811
1849
  const reauth = body.reauth === true;
1812
1850
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -1840,7 +1878,9 @@ export async function handleCodexAuthAPI(
1840
1878
 
1841
1879
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
1842
1880
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
1843
- if (result.url) {
1881
+ // Both login routes share one resolver so this surface cannot drift from the other.
1882
+ const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
1883
+ if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
1844
1884
  const { openUrl } = await import("../lib/open-url");
1845
1885
  openUrl(result.url);
1846
1886
  }