@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -107,15 +107,9 @@ export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY";
107
107
  /** Env reference shared by apiKey and the dedicated proxy admission header. */
108
108
  export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`;
109
109
 
110
- /** Env var Pi interpolates. Pi takes bare `$NAME`, not opencode's `{env:NAME}`. */
111
- export const PI_API_KEY_ENV = "OPENCODEX_API_KEY";
112
-
113
- /** Pi's reference form for the admission key. Never the value. */
114
- export const PI_API_KEY_ENV_REF = `$${PI_API_KEY_ENV}`;
115
-
116
110
  /**
117
111
  * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays
118
- * in the environment exactly as it does for OpenCode and Pi.
112
+ * in the environment exactly as it does for OpenCode.
119
113
  */
120
114
  export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY";
121
115
  export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`;
@@ -125,12 +119,12 @@ export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY";
125
119
  export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
126
120
 
127
121
  /**
128
- * Kimi Code reads credentials ONLY from its config file — it never falls back
129
- * to the shell environment. A loopback bind needs no real admission key, so we
130
- * emit the same placeholder the Grok managed block uses rather than a user
131
- * secret; a non-loopback bind is refused by the writer instead of papered over.
122
+ * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback
123
+ * bind needs no real admission key, so we emit the same placeholder the Grok
124
+ * managed block uses rather than a user secret. Pi resolves `apiKey` before
125
+ * building its model list and hides the provider when an env reference is unset.
132
126
  */
133
- export const KIMI_LOOPBACK_PLACEHOLDER = "opencodex-loopback";
127
+ export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback";
134
128
 
135
129
  /**
136
130
  * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
@@ -728,7 +722,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
728
722
  [OPENCODE_PROVIDER_ID]: {
729
723
  baseUrl: ctx.baseUrl,
730
724
  api: PI_API_DIALECT,
731
- apiKey: PI_API_KEY_ENV_REF,
725
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
732
726
  models,
733
727
  },
734
728
  },
@@ -808,7 +802,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
808
802
  [OPENCODE_PROVIDER_ID]: {
809
803
  type: "openai",
810
804
  base_url: ctx.baseUrl,
811
- api_key: KIMI_LOOPBACK_PLACEHOLDER,
805
+ api_key: LOOPBACK_API_KEY_PLACEHOLDER,
812
806
  },
813
807
  },
814
808
  models,
@@ -949,15 +943,14 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
949
943
  id: "pi",
950
944
  filename: "pi-models.json",
951
945
  destination: () => join(homedir(), ".pi", "agent", "models.json"),
952
- apiKeyEnv: PI_API_KEY_ENV,
953
- exportHint: `export ${PI_API_KEY_ENV}=<your key>`,
946
+ apiKeyEnv: "",
947
+ exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.",
954
948
  build: buildPiClientConfig,
955
949
  format: "json",
956
950
  summarize: summarizePi,
957
951
  buildContribution: buildPiContribution,
958
- // No header field in Pi's provider block (and the schema is unverified
959
- // against a real install), so there is nowhere to put the dedicated
960
- // admission header a remote bind requires.
952
+ // No header field in Pi's provider block, so there is nowhere to put the
953
+ // dedicated admission header a remote bind requires.
961
954
  loopbackOnly: true,
962
955
  },
963
956
  hermes: {
@@ -7,6 +7,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f
7
7
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
8
8
  import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache";
9
9
  import { forgetCodexAccountPause } from "./account-pause";
10
+ import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
10
11
  import type { OcxConfig } from "../types";
11
12
 
12
13
  let observedMainChatgptAccountId: string | undefined;
@@ -74,6 +75,8 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
74
75
  runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
75
76
  .filter(account => account.isMain || account.id !== accountId);
76
77
  forgetCodexAccountPause(runtimeConfig, accountId);
78
+ forgetCodexAccountPriority(runtimeConfig, accountId);
79
+ clearCodexAccountPin(runtimeConfig, accountId);
77
80
  if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
78
81
  purgeCodexAccountRuntimeState(accountId);
79
82
  invalidateCodexWebSocketsForAccount(accountId);
@@ -1,6 +1,10 @@
1
1
  import type { CodexAccount, OcxConfig } from "../types";
2
2
  import { COMBO_NAMESPACE } from "../combos/types";
3
3
  import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
4
+ import {
5
+ POLICY_NAMESPACE,
6
+ routingProfileAliasNamespacePrefixes,
7
+ } from "../routing/profile-namespace";
4
8
  import {
5
9
  CODEX_ACCOUNT_LOG_LABEL_RE,
6
10
  createCodexAccountLogLabel,
@@ -21,6 +25,7 @@ const RESERVED_NAMESPACE_KEYS = new Set([
21
25
  "constructor",
22
26
  COMBO_NAMESPACE,
23
27
  OPENAI_CODEX_PROVIDER_ID,
28
+ POLICY_NAMESPACE,
24
29
  ].map(codexProviderNamespaceKey));
25
30
  const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16;
26
31
 
@@ -68,17 +73,21 @@ function claimNamespace(requested: string, used: Set<string>): string {
68
73
  return namespace;
69
74
  }
70
75
 
71
- function occupiedNamespaces(config: Pick<OcxConfig, "combos" | "providers">): Set<string> {
76
+ /** Collect every public namespace that a generated account selector must not claim. */
77
+ function occupiedNamespaces(
78
+ config: Pick<OcxConfig, "combos" | "providers" | "routingProfiles">,
79
+ ): Set<string> {
72
80
  return new Set([
73
81
  ...Object.keys(config.providers).map(codexProviderNamespaceKey),
74
82
  ...comboAliasNamespaces(config),
83
+ ...routingProfileAliasNamespacePrefixes(config),
75
84
  ...RESERVED_NAMESPACE_KEYS,
76
85
  ]);
77
86
  }
78
87
 
79
88
  /** Build an initial account-selector map without deriving public selectors from aliases or ids. */
80
89
  export function defaultCodexAccountNamespaces(
81
- config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers">,
90
+ config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers" | "routingProfiles">,
82
91
  ): Record<string, string> {
83
92
  const namespaces: Record<string, string> = {};
84
93
  const used = occupiedNamespaces(config);
@@ -99,13 +108,39 @@ export function defaultCodexAccountNamespaces(
99
108
  return namespaces;
100
109
  }
101
110
 
111
+ /**
112
+ * Initialize generated selectors only for an explicit opt-in with no existing bindings.
113
+ * A true result means the map was replaced and the caller must persist the updated config.
114
+ */
115
+ export function initializeDefaultCodexAccountNamespaces(
116
+ config: Pick<
117
+ OcxConfig,
118
+ | "codexAccountPickerEnabled"
119
+ | "codexAccountNamespaces"
120
+ | "codexAccounts"
121
+ | "combos"
122
+ | "providers"
123
+ | "routingProfiles"
124
+ >,
125
+ ): boolean {
126
+ if (config.codexAccountPickerEnabled !== true
127
+ || Object.keys(config.codexAccountNamespaces ?? {}).length > 0) return false;
128
+
129
+ const namespaces = defaultCodexAccountNamespaces(config);
130
+ config.codexAccountNamespaces = namespaces;
131
+ return true;
132
+ }
133
+
102
134
  /**
103
135
  * Add one account to a generated map without renaming or replacing explicit existing entries.
104
136
  * The account-creation layer must reject a new id that already equals an existing selector key.
105
137
  * A true result means the map was mutated in place; callers must persist the updated config.
106
138
  */
107
139
  export function appendDefaultCodexAccountNamespace(
108
- config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers">,
140
+ config: Pick<
141
+ OcxConfig,
142
+ "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers" | "routingProfiles"
143
+ >,
109
144
  account: Pick<CodexAccount, "id" | "isMain" | "logLabel">,
110
145
  ): boolean {
111
146
  const namespaces = config.codexAccountNamespaces;
@@ -147,3 +182,14 @@ export function codexAccountNamespaceEntries(
147
182
  return Object.entries(config.codexAccountNamespaces ?? {})
148
183
  .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]);
149
184
  }
185
+
186
+ /**
187
+ * Whether generated account-qualified rows are enabled for catalog discovery.
188
+ * A non-empty hand-written map predating the explicit override remains enabled.
189
+ */
190
+ export function codexAccountPickerEnabled(
191
+ config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccountPickerEnabled">,
192
+ ): boolean {
193
+ return (config.codexAccountPickerEnabled === undefined || config.codexAccountPickerEnabled === true)
194
+ && Object.keys(config.codexAccountNamespaces ?? {}).length > 0;
195
+ }
@@ -0,0 +1,83 @@
1
+ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
2
+ import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
3
+ import type { OcxConfig } from "../types";
4
+
5
+ /**
6
+ * Which ids may carry a selection order: any pool account, plus the synthetic
7
+ * `__main__` Desktop login. Owned here rather than by the config schema because
8
+ * every writer needs it — the schema rejects a bad key on load, but the management
9
+ * API has to reject one before `setCodexAccountPriority` persists it as an own
10
+ * property that the schema would then reject, degrading the whole map.
11
+ */
12
+ export function isCodexAccountPriorityKey(key: unknown): key is string {
13
+ return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key);
14
+ }
15
+
16
+ /**
17
+ * Persisted selection order for one account: higher numbers are used earlier.
18
+ * Stored as a config sidecar rather than a `codexAccounts` row field because the
19
+ * Codex Desktop login (`__main__`) has no row and must be orderable too.
20
+ */
21
+ export function getCodexAccountPriority(config: OcxConfig, accountId: string): number {
22
+ return codexAccountPriorityLookup(config)(accountId);
23
+ }
24
+
25
+ /**
26
+ * Write one account's selection order. The default is stored as absence, so a
27
+ * pool that was never ordered keeps no map at all and routing takes its fast path.
28
+ * Entries are rebuilt through `Object.fromEntries` so a reserved key such as
29
+ * `__proto__` becomes an own data property instead of invoking a prototype setter.
30
+ */
31
+ export function setCodexAccountPriority(config: OcxConfig, accountId: string, priority: number): void {
32
+ const entries = new Map(Object.entries(config.codexAccountPriorities ?? {}));
33
+ if (priority === DEFAULT_ACCOUNT_PRIORITY) entries.delete(accountId);
34
+ else entries.set(accountId, priority);
35
+
36
+ if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries);
37
+ else delete config.codexAccountPriorities;
38
+ }
39
+
40
+ export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void {
41
+ setCodexAccountPriority(config, accountId, DEFAULT_ACCOUNT_PRIORITY);
42
+ }
43
+
44
+ /**
45
+ * Stable lookup for one selection pass. Routing calls this once per pick and
46
+ * hands the closure to `selectPriorityTier`, so a pool without stored order pays
47
+ * a single map read rather than one per candidate.
48
+ */
49
+ export function codexAccountPriorityLookup(config: OcxConfig): (accountId: string) => number {
50
+ const priorities = config.codexAccountPriorities;
51
+ if (!priorities) return () => DEFAULT_ACCOUNT_PRIORITY;
52
+ return accountId => (
53
+ Object.hasOwn(priorities, accountId)
54
+ ? normalizeAccountPriority(priorities[accountId])
55
+ : DEFAULT_ACCOUNT_PRIORITY
56
+ );
57
+ }
58
+
59
+ /**
60
+ * The account an operator most recently selected by hand. While pinned, priority
61
+ * never preempts upward: the pin is a tier *ceiling* for round-robin/fill-first --
62
+ * `selectPriorityTier` skips every tier above the pinned account's, so higher-ordered
63
+ * accounts are suppressed rather than the pinned one being guaranteed -- and a veto in
64
+ * the quota path, until the account crosses the auto-switch threshold.
65
+ */
66
+ export function pinnedCodexAccountId(config: OcxConfig): string | undefined {
67
+ return config.activeCodexAccountPinned;
68
+ }
69
+
70
+ export function isCodexAccountPinned(config: OcxConfig, accountId: string): boolean {
71
+ return config.activeCodexAccountPinned === accountId;
72
+ }
73
+
74
+ export function setCodexAccountPin(config: OcxConfig, accountId: string): void {
75
+ config.activeCodexAccountPinned = accountId;
76
+ }
77
+
78
+ /** Release the pin. With `accountId` given, only when it is the pinned account. */
79
+ export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void {
80
+ if (accountId === undefined || config.activeCodexAccountPinned === accountId) {
81
+ delete config.activeCodexAccountPinned;
82
+ }
83
+ }
@@ -15,20 +15,33 @@ import {
15
15
  } from "./account-store";
16
16
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
17
17
  import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
18
+ import {
19
+ clearCodexAccountPin,
20
+ getCodexAccountPriority,
21
+ isCodexAccountPriorityKey,
22
+ pinnedCodexAccountId,
23
+ setCodexAccountPin,
24
+ setCodexAccountPriority,
25
+ } from "./account-priority";
18
26
  import {
19
27
  claimDueCodexQuotaRecoveryProbes,
20
28
  clearCodexAccountCooldown,
21
29
  clearThreadAccountMapForAccount,
22
30
  getEffectiveActiveCodexAccountId,
31
+ isEffectiveCodexAccountPinned,
23
32
  reconcileCodexActiveAfterExclusion,
24
33
  resetCodexRoutingForManualSelection,
25
34
  settleCodexQuotaRecoveryProbe,
26
35
  } from "./routing";
27
36
  import {
37
+ DEFAULT_ACCOUNT_PRIORITY,
38
+ MAX_ACCOUNT_PRIORITY,
39
+ MIN_ACCOUNT_PRIORITY,
28
40
  normalizeAccountPoolStickyLimit,
29
41
  normalizeAccountPoolStrategy,
30
42
  parseAccountPoolStickyLimit,
31
43
  parseAccountPoolStrategy,
44
+ parseAccountPriority,
32
45
  } from "./pool-rotation";
33
46
  import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
34
47
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
@@ -191,6 +204,7 @@ function poolAccountDto(
191
204
  quotaResult: PoolQuotaResult,
192
205
  hasCredential: boolean,
193
206
  paused: boolean,
207
+ priority: number,
194
208
  ): CodexAuthAccountDto {
195
209
  const quota = quotaForPlan(quotaResult.quota, account.plan);
196
210
  const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
@@ -203,6 +217,7 @@ function poolAccountDto(
203
217
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
204
218
  isMain: false,
205
219
  paused,
220
+ priority,
206
221
  quota: quota ? { ...quota } : null,
207
222
  needsReauth,
208
223
  hasCredential,
@@ -644,6 +659,8 @@ export interface CodexAuthAccountDto {
644
659
  logLabel?: string;
645
660
  isMain: boolean;
646
661
  paused: boolean;
662
+ /** Selection order; higher is used earlier. Always present, 0 when unset. */
663
+ priority: number;
647
664
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
648
665
  needsReauth?: boolean;
649
666
  hasCredential: boolean;
@@ -1017,6 +1034,7 @@ export async function listCodexAuthAccountsSnapshot(
1017
1034
  { quota: null, needsReauth: true },
1018
1035
  false,
1019
1036
  isCodexAccountPaused(runtimeConfig, accountId),
1037
+ getCodexAccountPriority(runtimeConfig, accountId),
1020
1038
  )];
1021
1039
  }
1022
1040
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -1035,6 +1053,7 @@ export async function listCodexAuthAccountsSnapshot(
1035
1053
  effectiveQuotaResult,
1036
1054
  true,
1037
1055
  isCodexAccountPaused(runtimeConfig, accountId),
1056
+ getCodexAccountPriority(runtimeConfig, accountId),
1038
1057
  )];
1039
1058
  });
1040
1059
  const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
@@ -1055,6 +1074,7 @@ export async function listCodexAuthAccountsSnapshot(
1055
1074
  plan: mainInfo.plan,
1056
1075
  isMain: true,
1057
1076
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1077
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1058
1078
  hasCredential: hasMainCredential,
1059
1079
  needsReauth: mainNeedsReauth,
1060
1080
  quota: mainInfo.quota ? {
@@ -1297,6 +1317,53 @@ export async function handleCodexAuthAPI(
1297
1317
  });
1298
1318
  }
1299
1319
 
1320
+ // Deliberately a route of its own rather than a field on the alias PATCH: aliases
1321
+ // are display-only and reject __main__, while selection order is routing metadata
1322
+ // that the Desktop account must be able to carry. Re-ordering never kicks a live
1323
+ // thread, so there is no affinity clearing and no appliesImmediately here.
1324
+ if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
1325
+ let parsedBody: unknown;
1326
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1327
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
1328
+ return jsonResponse({ error: "body must be an object" }, 400);
1329
+ }
1330
+ const body = parsedBody as { id?: unknown; priority?: unknown };
1331
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1332
+ if (!isCodexAccountPriorityKey(id)) {
1333
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1334
+ }
1335
+
1336
+ let priority = DEFAULT_ACCOUNT_PRIORITY;
1337
+ if (body.priority !== null) {
1338
+ const parsed = parseAccountPriority(body.priority);
1339
+ if (parsed === null) {
1340
+ return jsonResponse({
1341
+ error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
1342
+ }, 400);
1343
+ }
1344
+ priority = parsed;
1345
+ }
1346
+
1347
+ const runtimeConfig = getRuntimeConfig(config);
1348
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
1349
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1350
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1351
+
1352
+ setCodexAccountPriority(runtimeConfig, id, priority);
1353
+ // Both a pin and an order are the operator saying which account to use, so the newer
1354
+ // statement wins. Without this a pin made before any order existed — an ordinary
1355
+ // account switch — would outrank the order forever: it blocks preemption and caps
1356
+ // every eligibility list at its own tier until that account drains or is paused.
1357
+ clearCodexAccountPin(runtimeConfig);
1358
+ saveRuntimeConfig(config, runtimeConfig);
1359
+ return jsonResponse({
1360
+ ok: true,
1361
+ id,
1362
+ priority,
1363
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1364
+ });
1365
+ }
1366
+
1300
1367
  if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
1301
1368
  const runtimeConfig = getRuntimeConfig(config);
1302
1369
  const result = await pauseExhaustedCodexAccounts(
@@ -1359,6 +1426,15 @@ export async function handleCodexAuthAPI(
1359
1426
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
1360
1427
  }
1361
1428
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
1429
+ // "Use this account now" outranks selection order until the account is spent:
1430
+ // persisted here rather than in resetCodexRoutingForManualSelection, which is
1431
+ // runtime state only. A null id clears the selection instead of making one, so it
1432
+ // must release the pin rather than record one: pinning the `targetAccountId`
1433
+ // fallback would leave a pin that no effective active account matches, which
1434
+ // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
1435
+ // honours it as a ceiling — invisibly capping the pool at the main account's tier.
1436
+ if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
1437
+ else setCodexAccountPin(runtimeConfig, targetAccountId);
1362
1438
  resetCodexRoutingForManualSelection(targetAccountId);
1363
1439
  saveRuntimeConfig(config, runtimeConfig);
1364
1440
  return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
@@ -1368,6 +1444,13 @@ export async function handleCodexAuthAPI(
1368
1444
  const runtimeConfig = getRuntimeConfig(config);
1369
1445
  return jsonResponse({
1370
1446
  activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1447
+ pinned: isEffectiveCodexAccountPinned(runtimeConfig),
1448
+ // Which account carries the pin, not just whether the active one does. Under
1449
+ // round-robin or fill-first the pin caps the tier ceiling at its own tier while the
1450
+ // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
1451
+ // sibling's turn even though the pin is still suppressing every higher tier. The id
1452
+ // lets a surface mark the account the operator actually chose.
1453
+ pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
1371
1454
  autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
1372
1455
  upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
1373
1456
  accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
@@ -112,15 +112,18 @@ export class CodexPoolAuthenticationError extends Error {
112
112
  }
113
113
  }
114
114
 
115
+ export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
116
+ "OpenCodex local native-main profile maintenance is active; retry this request";
117
+
115
118
  export class CodexMainProfileDrainingError extends Error {
116
119
  constructor() {
117
- super("Native Codex main profile is switching; retry this request");
120
+ super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
118
121
  this.name = "CodexMainProfileDrainingError";
119
122
  }
120
123
  }
121
124
 
122
125
  export function codexMainProfileDrainingResponse(): Response {
123
- const response = formatErrorResponse(503, "server_busy", "Native Codex main profile is switching; retry this request");
126
+ const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
124
127
  const headers = new Headers(response.headers);
125
128
  headers.set("Retry-After", "1");
126
129
  return new Response(response.body, { status: response.status, headers });
@@ -736,6 +736,17 @@ function modelInputModalities(
736
736
  value === "text" || value === "image" || value === "audio"
737
737
  ));
738
738
  if (explicit && explicit.length > 0) return explicit;
739
+ const architecture = plainRecord(item.architecture);
740
+ const architectureModality = typeof architecture?.modality === "string"
741
+ ? normalizedMetadataString(architecture.modality, 64)
742
+ : undefined;
743
+ if (architectureModality?.includes("->")) {
744
+ const [rawInput = ""] = architectureModality.split("->");
745
+ const inferred = rawInput
746
+ .split("+")
747
+ .filter(value => value === "text" || value === "image" || value === "audio");
748
+ if (inferred.length > 0) return [...new Set(inferred)];
749
+ }
739
750
  if (capabilityRecord?.vision === false) return ["text"];
740
751
  if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) {
741
752
  return ["text", "image"];
@@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { expandUserPath, readConfigDiagnostics, websocketsEnabled } from "../../config";
5
+ import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config";
6
+ import { shouldSyncCodexOnStart } from "../desired-state";
6
7
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
8
  import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
9
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
@@ -652,6 +653,8 @@ interface RetainedCatalogSyncResult {
652
653
  path: string;
653
654
  catalogWritten: boolean;
654
655
  comboOmissions: ComboCatalogOmission[];
656
+ /** `desired_disabled` observed under K after the provider await; nothing was written. */
657
+ skippedReason?: "desired_disabled";
655
658
  }
656
659
 
657
660
  interface RetainedCatalogSyncWrite {
@@ -965,6 +968,20 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
965
968
  };
966
969
  const goModels = await gatherRoutedModels(config, { comboOmissions });
967
970
  const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
971
+ // Desired state can flip OFF during the provider await above. The catalog
972
+ // evidence revalidation below cannot see that — intent lives in our config,
973
+ // not in the catalog files — so the policy is re-read here, under K, right
974
+ // before the only write. A lost race becomes the discriminated skip instead
975
+ // of a routed catalog/cache surviving a completed disable.
976
+ if (!shouldSyncCodexOnStart(loadConfig())) {
977
+ return {
978
+ added: 0,
979
+ path: prepared.catalogPath,
980
+ catalogWritten: false,
981
+ comboOmissions,
982
+ skippedReason: "desired_disabled" as const,
983
+ };
984
+ }
968
985
  const current = revalidateRetainedCatalogSync(config, prepared);
969
986
  if (current === null) return null;
970
987
  return writeRetainedCatalogSync({
@@ -1049,6 +1066,11 @@ export function invalidateCodexModelsCacheWithPermit(
1049
1066
  owningCodexHome: string,
1050
1067
  ): boolean {
1051
1068
  try {
1069
+ // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
1070
+ // K before this rewrite runs, so the commit-path desired-state check cannot
1071
+ // cover it. A disable landing in that gap must not be overwritten by a
1072
+ // routed cache write — re-read intent under this permit, same as the commit.
1073
+ if (!shouldSyncCodexOnStart(loadConfig())) return false;
1052
1074
  const catalogPath = readCodexCatalogPath();
1053
1075
  if (!existsSync(catalogPath)) return false;
1054
1076
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
@@ -66,7 +66,8 @@ export type CodexWriteLockRefusalReason =
66
66
 
67
67
  export type CodexWriteLockResult<T> =
68
68
  | { status: "acquired"; value: T; waitedMs: number; lockId: string }
69
- | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number }
69
+ | { status: "skipped"; reason: "desired_disabled" | "desired_enabled"; waitedMs: number }
70
+ | { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string }
70
71
  | {
71
72
  status: "refused";
72
73
  reason: CodexWriteLockRefusalReason;
@@ -124,6 +125,14 @@ export interface CodexWriteCommitContext {
124
125
  readonly coordinator: CodexCoordinatorTransaction;
125
126
  }
126
127
 
128
+ /** A synchronous under-lock policy re-read proved the requested apply stale. */
129
+ export class CodexWriteLockSkipped extends Error {
130
+ constructor(readonly reason: "desired_disabled" | "desired_enabled") {
131
+ super(reason);
132
+ this.name = "CodexWriteLockSkipped";
133
+ }
134
+ }
135
+
127
136
  /** Rejects an `async` callback at typecheck; a cast thenable is caught at runtime. */
128
137
  type Synchronous<T> = T extends PromiseLike<unknown> ? never : T;
129
138
 
@@ -291,7 +300,7 @@ export async function withCodexWriteLock<T>(
291
300
  const waited = (): number => Math.round(performance.now() - started);
292
301
 
293
302
  for (;;) {
294
- if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited() };
303
+ if (signal?.aborted) return { status: "busy", reason: "cancelled", retryable: true, waitedMs: waited(), lockId: target.lockId };
295
304
 
296
305
  let transaction: ReturnType<typeof openCodexCoordinatorTransaction> | undefined;
297
306
  try {
@@ -306,7 +315,7 @@ export async function withCodexWriteLock<T>(
306
315
  error instanceof Error ? error.message : "The Codex write lock could not be opened.");
307
316
  }
308
317
  if (performance.now() >= deadline) {
309
- return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() };
318
+ return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId };
310
319
  }
311
320
  await sleepJittered(deadline - performance.now(), signal);
312
321
  continue;
@@ -345,13 +354,16 @@ export async function withCodexWriteLock<T>(
345
354
  return { status: "acquired", value: value as T, waitedMs: waited(), lockId: target.lockId };
346
355
  } catch (error) {
347
356
  transaction.rollback();
357
+ if (error instanceof CodexWriteLockSkipped) {
358
+ return { status: "skipped", reason: error.reason, waitedMs: waited() };
359
+ }
348
360
  if (error instanceof CodexWriteLockStaleAdmission) {
349
361
  return refuse("authority_not_proven",
350
362
  "The admitted state changed before the commit could be made under the lock.");
351
363
  }
352
364
  if (isBusyError(error)) {
353
365
  if (performance.now() >= deadline) {
354
- return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited() };
366
+ return { status: "busy", reason: "deadline", retryable: true, waitedMs: waited(), lockId: target.lockId };
355
367
  }
356
368
  await sleepJittered(deadline - performance.now(), signal);
357
369
  continue;