@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
@@ -24,6 +24,14 @@ export interface AccountRow {
24
24
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
25
25
  priority?: number;
26
26
  quota?: CodexQuotaDto | null;
27
+ /**
28
+ * Whether the pool is holding this account out of rotation.
29
+ *
30
+ * The server has always sent it (auth-api.ts:286 for pool accounts, :1315 for main) and the
31
+ * CLI dropped it, so a paused account was indistinguishable from an available one in every
32
+ * human listing (#2703).
33
+ */
34
+ paused?: boolean;
27
35
  }
28
36
 
29
37
  export type ClassifyResult = { type: AccountType } | { error: string };
@@ -83,6 +91,12 @@ export interface ApiResult {
83
91
  /** 0 = network-level failure (proxy unreachable). */
84
92
  status: number;
85
93
  json: Record<string, unknown>;
94
+ /**
95
+ * Message from the thrown transport error when `status` is 0. Previously the
96
+ * error was swallowed by a catch block with an empty body, so an unreachable proxy, a DNS
97
+ * failure and a TLS error were indistinguishable (#2698).
98
+ */
99
+ transportError?: string;
86
100
  }
87
101
 
88
102
  export async function apiJson(
@@ -103,8 +117,14 @@ export async function apiJson(
103
117
  });
104
118
  const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
105
119
  return { status: res.status, json };
106
- } catch {
107
- return { status: 0, json: {} };
120
+ } catch (error) {
121
+ // status 0 stays the transport sentinel, but keep the cause: callers can now
122
+ // tell the operator why the request never reached the proxy (#2698).
123
+ return {
124
+ status: 0,
125
+ json: {},
126
+ transportError: error instanceof Error ? error.message : String(error),
127
+ };
108
128
  }
109
129
  }
110
130
 
@@ -115,18 +135,43 @@ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null>
115
135
  return `http://${probeHostname(live.hostname)}:${live.port}`;
116
136
  }
117
137
 
118
- export function proxyUnreachable(): number {
138
+ export function proxyUnreachable(transportError?: string): number {
119
139
  console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
140
+ // Naming the transport cause distinguishes "nothing is listening" from a refused
141
+ // or reset connection, which is what made #2696-class breakage undiagnosable.
142
+ if (transportError) console.error(`reason: ${transportError}`);
120
143
  return 1;
121
144
  }
122
145
 
123
- export function apiError(json: Record<string, unknown>, fallback: string): number {
124
- const message = typeof json.error === "string" ? json.error : fallback;
125
- console.error(`Error: ${message}`);
146
+ function accountStringField(json: Record<string, unknown>, key: string): string | undefined {
147
+ const value = json[key];
148
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
149
+ }
150
+
151
+ /**
152
+ * Report a failed management call from the account family.
153
+ *
154
+ * `reason` and `hint` are the actionable fields on a refusal — the management plane
155
+ * sets both on a 503, and several routes return `reason` with no `error` key at all,
156
+ * which used to print only the generic fallback (#2698).
157
+ *
158
+ * `status` selects the exit code so the account client speaks the same vocabulary as
159
+ * runtime-api.ts: 4 for not-found, 5 for conflict, 1 otherwise. Previously every
160
+ * failure exited 1, so a script could not distinguish a missing account from a
161
+ * concurrent mutation.
162
+ */
163
+ export function apiError(json: Record<string, unknown>, fallback: string, status: number): number {
164
+ const primary = accountStringField(json, "error") ?? fallback;
165
+ const lines = [`Error: ${primary}`];
166
+ const reason = accountStringField(json, "reason");
167
+ if (reason && reason !== primary) lines.push(`reason: ${reason}`);
168
+ const hint = accountStringField(json, "hint");
169
+ if (hint && hint !== primary) lines.push(`hint: ${hint}`);
170
+ for (const line of lines) console.error(line);
126
171
  if (json.cleanupRequired === true) {
127
172
  console.error("Warning: native-login staging cleanup is still required; run 'ocx account main doctor'.");
128
173
  }
129
- return 1;
174
+ return status === 404 ? 4 : status === 409 ? 5 : 1;
130
175
  }
131
176
 
132
177
  export interface FamilyRows {
@@ -134,10 +179,12 @@ export interface FamilyRows {
134
179
  activeId: string | null;
135
180
  autoSwitchThreshold?: number;
136
181
  /** HTTP status for a completed family read, including failures. */
137
- status?: number;
182
+ status: number;
138
183
  /** Set when the family endpoint returned an error. */
139
184
  errorJson?: Record<string, unknown>;
140
185
  networkDown?: boolean;
186
+ /** Transport cause when `networkDown` is set. Callers must forward this to `proxyUnreachable`. */
187
+ transportError?: string;
141
188
  }
142
189
 
143
190
  export interface CodexQuotaDto {
@@ -145,6 +192,14 @@ export interface CodexQuotaDto {
145
192
  monthlyPercent?: number;
146
193
  weeklyResetAt?: number;
147
194
  monthlyResetAt?: number;
195
+ /**
196
+ * Five-hour window, as the per-account provider probe reports it
197
+ * (`/api/oauth/accounts?quota=1`). Distinct from `shortPercent`, which is the Codex pool's
198
+ * self-declared burst window; the two surfaces name the same idea differently and both reach
199
+ * this DTO.
200
+ */
201
+ fiveHourPercent?: number;
202
+ fiveHourResetAt?: number;
148
203
  /** Sub-day burst window, when upstream declares one (#1791). */
149
204
  shortPercent?: number;
150
205
  shortResetAt?: number;
@@ -182,12 +237,19 @@ interface CodexAccountDto {
182
237
  needsReauth?: boolean;
183
238
  priority?: number;
184
239
  quota?: CodexQuotaDto | null;
240
+ paused?: boolean;
185
241
  }
186
242
 
187
243
  function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
188
244
  if (!quota) return null;
189
245
  const projected: CodexQuotaDto = {};
190
- for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) {
246
+ // `fiveHourPercent`/`fiveHourResetAt` were declared on the DTO and read by two renderers
247
+ // -- `quotaText`'s `quota.fiveHourPercent ?? quota.shortPercent` (account.ts:89) and
248
+ // `quotaParts` (account-extended.ts:275) -- but omitted from this whitelist, so the first
249
+ // operand was unreachable and a 5h-only account rendered as unknown (#2703). A projection
250
+ // that silently drops a field its own type declares is worse than one that never had it:
251
+ // the type checks, the renderer looks correct, and only the output is wrong.
252
+ for (const key of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) {
191
253
  if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
192
254
  }
193
255
  return projected;
@@ -197,6 +259,7 @@ export async function fetchCodexRows(
197
259
  deps: AccountDeps,
198
260
  baseUrl: string,
199
261
  forceRefresh = false,
262
+ includeQuota = forceRefresh,
200
263
  ): Promise<FamilyRows> {
201
264
  const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
202
265
  const [accountsRes, activeRes] = await Promise.all([
@@ -210,7 +273,13 @@ export async function fetchCodexRows(
210
273
  return { rows: [], activeId: null, status: activeRes.status, errorJson: activeRes.json };
211
274
  }
212
275
  if (accountsRes.status === 0 || activeRes.status === 0) {
213
- return { rows: [], activeId: null, status: 0, networkDown: true };
276
+ return {
277
+ rows: [],
278
+ activeId: null,
279
+ status: 0,
280
+ networkDown: true,
281
+ transportError: accountsRes.transportError ?? activeRes.transportError,
282
+ };
214
283
  }
215
284
  const activeId = typeof activeRes.json.activeCodexAccountId === "string"
216
285
  ? activeRes.json.activeCodexAccountId
@@ -229,7 +298,8 @@ export async function fetchCodexRows(
229
298
  active: a.id === activeId,
230
299
  needsReauth: a.needsReauth,
231
300
  priority: typeof a.priority === "number" ? a.priority : 0,
232
- ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
301
+ paused: a.paused === true,
302
+ ...(includeQuota ? { quota: projectQuota(a.quota) } : {}),
233
303
  }));
234
304
  return { rows, activeId, autoSwitchThreshold, status: 200 };
235
305
  }
@@ -240,11 +310,25 @@ interface OAuthAccountDto {
240
310
  email?: string;
241
311
  active?: boolean;
242
312
  needsReauth?: boolean;
313
+ quota?: CodexQuotaDto | null;
314
+ quotaUnavailable?: boolean;
243
315
  }
244
316
 
245
- async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
246
- const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`);
247
- if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
317
+ async function fetchOAuthRows(
318
+ deps: AccountDeps,
319
+ baseUrl: string,
320
+ name: string,
321
+ quota?: { refresh?: boolean },
322
+ ): Promise<FamilyRows> {
323
+ // Quota is opt-in: the server probes the upstream once per stored credential when `quota=1`
324
+ // is present, so the default listing must stay a cheap local read (#2566).
325
+ const query = quota
326
+ ? `?provider=${encodeURIComponent(name)}&quota=1${quota.refresh ? "&refresh=1" : ""}`
327
+ : `?provider=${encodeURIComponent(name)}`;
328
+ const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts${query}`);
329
+ if (res.status === 0) {
330
+ return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
331
+ }
248
332
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
249
333
  const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null;
250
334
  const accounts = Array.isArray(res.json.accounts) ? res.json.accounts as OAuthAccountDto[] : [];
@@ -256,6 +340,8 @@ async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string):
256
340
  email: a.email,
257
341
  active: a.active ?? a.id === activeId,
258
342
  needsReauth: a.needsReauth,
343
+ ...(a.quota !== undefined ? { quota: a.quota } : {}),
344
+ ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
259
345
  }));
260
346
  return { rows, activeId, status: 200 };
261
347
  }
@@ -269,7 +355,9 @@ interface ApiKeyDto {
269
355
 
270
356
  async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
271
357
  const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
272
- if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
358
+ if (res.status === 0) {
359
+ return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
360
+ }
273
361
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
274
362
  const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
275
363
  const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
@@ -284,9 +372,15 @@ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): P
284
372
  return { rows, activeId, status: 200 };
285
373
  }
286
374
 
287
- export function fetchRows(deps: AccountDeps, baseUrl: string, name: string, type: AccountType): Promise<FamilyRows> {
288
- if (type === "codex") return fetchCodexRows(deps, baseUrl);
289
- if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name);
375
+ export function fetchRows(
376
+ deps: AccountDeps,
377
+ baseUrl: string,
378
+ name: string,
379
+ type: AccountType,
380
+ quota?: { refresh?: boolean },
381
+ ): Promise<FamilyRows> {
382
+ if (type === "codex") return fetchCodexRows(deps, baseUrl, Boolean(quota?.refresh), quota !== undefined);
383
+ if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name, quota);
290
384
  return fetchKeyRows(deps, baseUrl, name);
291
385
  }
292
386
 
@@ -294,8 +388,11 @@ export async function fetchProviderQuotaReport(
294
388
  deps: AccountDeps,
295
389
  baseUrl: string,
296
390
  name: string,
297
- ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown> }> {
391
+ ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown>; transportError?: string }> {
298
392
  const res = await apiJson(deps, baseUrl, "GET", "/api/provider-quotas?refresh=1");
393
+ if (res.status === 0) {
394
+ return { status: 0, report: null, errorJson: res.json, transportError: res.transportError };
395
+ }
299
396
  if (res.status !== 200) return { status: res.status, report: null, errorJson: res.json };
300
397
  const reports = Array.isArray(res.json.reports) ? res.json.reports as ProviderQuotaReportDto[] : [];
301
398
  return { status: 200, report: reports.find(report => report?.provider === name) ?? null };
@@ -40,6 +40,11 @@ const EXTENDED_USAGE = `Usage:
40
40
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
41
41
  ocx account alias <provider> <id|main> <display-name|-> [--json]
42
42
  ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
43
+ ocx account pause <provider> <id|main> [--json]
44
+ ocx account resume <provider> <id|main> [--json]
45
+ ocx account pause-exhausted <provider> [--json]
46
+ ocx account strategy <provider> [<quota|round-robin|fill-first>] [--json]
47
+ ocx account sticky <provider> [<1-100>] [--json]
43
48
  ocx account remove <provider> <id|main> --yes [--json]
44
49
  ocx account clear-cooldown <provider> <id|main> [--json]
45
50
  ocx account add-key <provider> [--label <label>] [--json]
@@ -232,8 +237,8 @@ function configAndType(deps: AccountDeps, name: string) {
232
237
  }
233
238
 
234
239
  function familyFailure(result: FamilyRows, fallback: string): number | null {
235
- if (result.networkDown) return proxyUnreachable();
236
- if (result.errorJson) return apiError(result.errorJson, fallback);
240
+ if (result.networkDown) return proxyUnreachable(result.transportError);
241
+ if (result.errorJson) return apiError(result.errorJson, fallback, result.status);
237
242
  return null;
238
243
  }
239
244
 
@@ -249,17 +254,14 @@ function resetIso(value: number | undefined): string | null {
249
254
 
250
255
  function refreshLine(row: FamilyRows["rows"][number]): string {
251
256
  const parts = [row.id === MAIN_ID ? "main" : row.id, row.email, row.plan];
252
- const quota = row.quota;
253
- if (!quota || (quota.weeklyPercent === undefined && quota.monthlyPercent === undefined)) {
254
- parts.push("quota: unknown");
255
- } else {
256
- if (quota.weeklyPercent !== undefined) parts.push(`weekly ${quota.weeklyPercent}%`);
257
- const weeklyReset = resetIso(quota.weeklyResetAt);
258
- if (weeklyReset) parts.push(`resets ${weeklyReset}`);
259
- if (quota.monthlyPercent !== undefined) parts.push(`monthly ${quota.monthlyPercent}%`);
260
- const monthlyReset = resetIso(quota.monthlyResetAt);
261
- if (monthlyReset) parts.push(`resets ${monthlyReset}`);
262
- }
257
+ if (row.paused) parts.push("paused");
258
+ // Was a second quota dialect: it gated the whole block on weekly/monthly, so an account
259
+ // reporting only a 5h window printed `quota: unknown` while `quotaParts` five lines below
260
+ // rendered the same data correctly for the provider path (#2703). Two halves of one file
261
+ // disagreeing about how to read one DTO is the defect; delegating removes it rather than
262
+ // teaching the second dialect a third window.
263
+ const quotaText = row.quota ? quotaParts(row.quota).join(" ") : "";
264
+ parts.push(quotaText.length > 0 ? quotaText : "quota: unknown");
263
265
  if (row.needsReauth) parts.push("needs-reauth");
264
266
  return parts.filter(Boolean).join(" ");
265
267
  }
@@ -279,7 +281,7 @@ function quotaParts(quota: ProviderQuotaDto): string[] {
279
281
  return parts;
280
282
  }
281
283
 
282
- function providerQuotaLine(name: string, report: ProviderQuotaReportDto): string {
284
+ export function providerQuotaLine(name: string, report: ProviderQuotaReportDto): string {
283
285
  return [name, ...quotaParts(report.quota)].join(" ");
284
286
  }
285
287
 
@@ -325,8 +327,8 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
325
327
  if (!baseUrl) return proxyUnreachable();
326
328
  if (classified.type !== "codex") {
327
329
  const result = await fetchProviderQuotaReport(deps, baseUrl, name);
328
- if (result.status === 0) return proxyUnreachable();
329
- if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`);
330
+ if (result.status === 0) return proxyUnreachable(result.transportError);
331
+ if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`, result.status);
330
332
  if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
331
333
  else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
332
334
  return 0;
@@ -360,15 +362,15 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
360
362
  if (!baseUrl) return proxyUnreachable();
361
363
  if (action === "status") {
362
364
  const response = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
363
- if (response.status === 0) return proxyUnreachable();
365
+ if (response.status === 0) return proxyUnreachable(response.transportError);
364
366
  if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
365
- return apiError(response.json, "failed to read auto-switch status");
367
+ return apiError(response.json, "failed to read auto-switch status", response.status);
366
368
  }
367
369
  threshold = response.json.autoSwitchThreshold;
368
370
  } else {
369
371
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
370
- if (response.status === 0) return proxyUnreachable();
371
- if (response.status !== 200) return apiError(response.json, "failed to update auto-switch");
372
+ if (response.status === 0) return proxyUnreachable(response.transportError);
373
+ if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status);
372
374
  }
373
375
  const enabled = threshold! > 0;
374
376
  if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2));
@@ -459,8 +461,8 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
459
461
  const baseUrl = await resolveBaseUrl(deps);
460
462
  if (!baseUrl) return proxyUnreachable();
461
463
  const response = await apiJson(deps, baseUrl, "POST", "/api/providers/keys", { name, key, ...(label ? { label } : {}) });
462
- if (response.status === 0) return proxyUnreachable();
463
- if (response.status !== 201) return apiError(response.json, `failed to add a key for ${name}`);
464
+ if (response.status === 0) return proxyUnreachable(response.transportError);
465
+ if (response.status !== 201) return apiError(response.json, `failed to add a key for ${name}`, response.status);
464
466
  const id = typeof response.json.id === "string" ? response.json.id : null;
465
467
  // Redact the key inside the label BEFORE serialization — a key containing
466
468
  // JSON-escaped characters (" or \) would otherwise survive the whole-output
@@ -536,7 +538,7 @@ export async function cmdImport(args: string[], deps: AccountDeps): Promise<numb
536
538
  clearTimeout(timer);
537
539
  }
538
540
  if (response.status === 0) {
539
- if (!timedOut) return proxyUnreachable();
541
+ if (!timedOut) return proxyUnreachable(response.transportError);
540
542
  console.error(`Error: import_timeout after ${importTimeoutMs}ms`);
541
543
  return 1;
542
544
  }
@@ -589,8 +591,8 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
589
591
  const baseUrl = await resolveBaseUrl(deps);
590
592
  if (!baseUrl) return proxyUnreachable();
591
593
  const response = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/accounts/clear-cooldown", { id });
592
- if (response.status === 0) return proxyUnreachable();
593
- if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`);
594
+ if (response.status === 0) return proxyUnreachable(response.transportError);
595
+ if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`, response.status);
594
596
  const cleared = response.json?.cleared === true;
595
597
  if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, id, cleared }, null, 2));
596
598
  else if (cleared) console.log(`${name}: cooldown lifted for ${requestedId}`);
@@ -680,8 +682,8 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
680
682
  }
681
683
 
682
684
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/priority", { id, priority });
683
- if (response.status === 0) return proxyUnreachable();
684
- if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`);
685
+ if (response.status === 0) return proxyUnreachable(response.transportError);
686
+ if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`, response.status);
685
687
  const applied = typeof response.json.priority === "number" ? response.json.priority : (priority ?? 0);
686
688
  if (wantsJson) {
687
689
  console.log(JSON.stringify(
@@ -703,6 +705,237 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
703
705
  return 0;
704
706
  }
705
707
 
708
+ /**
709
+ * `ocx account pause|resume <provider> <id>` (#2702).
710
+ *
711
+ * The server routes have always existed; only the CLI caller was missing, so pausing an
712
+ * account was dashboard-only. The issue reports these as POST; the code is PUT
713
+ * (`auth-api.ts:1494`), and the route is shared by both directions with a `paused` boolean
714
+ * rather than being two endpoints.
715
+ */
716
+ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolean): Promise<number> {
717
+ const wantsJson = flag(args, "--json");
718
+ const name = args.shift();
719
+ const requestedId = args.shift();
720
+ const verb = paused ? "pause" : "resume";
721
+ if (!name || !requestedId || args.length) return usage();
722
+ const classified = configAndType(deps, name);
723
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
724
+ if (classified.type !== "codex") {
725
+ return usage(`Error: ${verb} applies to the openai Codex account pool`);
726
+ }
727
+ const id = requestedId === "main" ? MAIN_ID : requestedId;
728
+
729
+ const baseUrl = await resolveBaseUrl(deps);
730
+ if (!baseUrl) return proxyUnreachable();
731
+
732
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/pause", { id, paused });
733
+ // Transport sentinel first: status 0 means the proxy never answered, and comparing it to
734
+ // 200 would report an unreachable proxy as a management error.
735
+ if (response.status === 0) return proxyUnreachable(response.transportError);
736
+ if (response.status !== 200) return apiError(response.json, `failed to ${verb} ${requestedId}`, response.status);
737
+
738
+ if (wantsJson) {
739
+ console.log(JSON.stringify({ ok: true, provider: name, id, paused }, null, 2));
740
+ } else {
741
+ console.log(`${name}: ${requestedId} ${paused ? "paused" : "resumed"}`);
742
+ }
743
+ if (paused) {
744
+ // Both are server-side effects of this route (auth-api.ts:1508-1510), not consequences
745
+ // the operator would infer from the word "pause".
746
+ console.error("Threads bound to this account are unbound, and a fallback account is selected if this one was active.");
747
+ }
748
+ return 0;
749
+ }
750
+
751
+ /**
752
+ * `ocx account pause-exhausted [--off]` (#2702).
753
+ *
754
+ * Pauses every account whose quota is spent. The route refreshes quota for each account, so
755
+ * it can partially fail; the response distinguishes "checked none and some failed" from
756
+ * "checked some", and that distinction is reported rather than flattened into ok/not-ok.
757
+ */
758
+ export async function cmdPauseExhausted(args: string[], deps: AccountDeps): Promise<number> {
759
+ const wantsJson = flag(args, "--json");
760
+ const name = args.shift();
761
+ if (!name || args.length) return usage();
762
+ const classified = configAndType(deps, name);
763
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
764
+ if (classified.type !== "codex") {
765
+ return usage("Error: pause-exhausted applies to the openai Codex account pool");
766
+ }
767
+
768
+ const baseUrl = await resolveBaseUrl(deps);
769
+ if (!baseUrl) return proxyUnreachable();
770
+
771
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/pause-exhausted", {});
772
+ if (response.status === 0) return proxyUnreachable(response.transportError);
773
+ if (response.status !== 200) return apiError(response.json, "failed to pause exhausted accounts", response.status);
774
+
775
+ const pausedIds = Array.isArray(response.json.pausedAccountIds)
776
+ ? (response.json.pausedAccountIds as unknown[]).filter((value): value is string => typeof value === "string")
777
+ : [];
778
+ const checked = typeof response.json.checkedAccountCount === "number" ? response.json.checkedAccountCount : null;
779
+ const failed = typeof response.json.failedAccountCount === "number" ? response.json.failedAccountCount : null;
780
+
781
+ const complete = failed === null || failed === 0;
782
+ const ok = complete;
783
+ if (failed !== null && failed > 0) {
784
+ console.error(`Quota refresh failed for ${failed} account(s); those were not evaluated.`);
785
+ }
786
+ if (wantsJson) {
787
+ console.log(JSON.stringify({
788
+ ok,
789
+ complete,
790
+ provider: name,
791
+ pausedAccountIds: pausedIds,
792
+ checkedAccountCount: checked,
793
+ failedAccountCount: failed,
794
+ }, null, 2));
795
+ return ok ? 0 : 1;
796
+ }
797
+ console.log(pausedIds.length > 0
798
+ ? `${name}: paused ${pausedIds.length} exhausted account(s): ${pausedIds.join(", ")}`
799
+ : `${name}: no exhausted accounts to pause`);
800
+ return ok ? 0 : 1;
801
+ }
802
+
803
+ /**
804
+ * Two pools expose strategy and sticky, and they are NOT reached the same way:
805
+ *
806
+ * | | Codex pool | Anthropic pool |
807
+ * |---|---|---|
808
+ * | read | `GET /api/codex-auth/active` | `GET /api/oauth/accounts/pool?provider=` |
809
+ * | write | `PUT /api/codex-auth/pool-strategy` | `PUT /api/oauth/accounts/pool` |
810
+ * | keys | `accountPoolStrategy`/`accountPoolStickyLimit` | `strategy`/`stickyLimit` |
811
+ * | body | bare field | field **plus** a mandatory `provider` |
812
+ *
813
+ * Omitting `provider` from the Anthropic write body earns a 400
814
+ * (`oauth-account-routes.ts:344`), so the asymmetry has to be encoded somewhere. Encoding it
815
+ * here keeps ONE verb pair working on both pools. The alternative the plan left open -- a second
816
+ * `provider-strategy`/`provider-sticky` pair -- would double the surface an operator must learn
817
+ * to express one idea, and a CLI that can steer one pool and not the other is exactly the trap
818
+ * this unit exists to remove.
819
+ */
820
+ interface PoolTransport {
821
+ readPath: string;
822
+ writePath: string;
823
+ /** Response key carrying the applied strategy. */
824
+ strategyKey: string;
825
+ /** Response key carrying the applied sticky limit. */
826
+ stickyKey: string;
827
+ writeBody: (field: "strategy" | "stickyLimit", value: unknown) => Record<string, unknown>;
828
+ }
829
+
830
+ const CODEX_POOL_TRANSPORT: PoolTransport = {
831
+ readPath: "/api/codex-auth/active",
832
+ writePath: "/api/codex-auth/pool-strategy",
833
+ strategyKey: "accountPoolStrategy",
834
+ stickyKey: "accountPoolStickyLimit",
835
+ writeBody: (field, value) => ({ [field]: value }),
836
+ };
837
+
838
+ function anthropicPoolTransport(provider: string): PoolTransport {
839
+ return {
840
+ readPath: `/api/oauth/accounts/pool?provider=${encodeURIComponent(provider)}`,
841
+ writePath: "/api/oauth/accounts/pool",
842
+ strategyKey: "strategy",
843
+ stickyKey: "stickyLimit",
844
+ writeBody: (field, value) => ({ provider, [field]: value }),
845
+ };
846
+ }
847
+
848
+ /**
849
+ * The pool-config route supports `anthropic` only and says so with a 400. Any other OAuth
850
+ * provider is refused here with the same wording rather than spending a round-trip to learn it.
851
+ */
852
+ function poolTransportFor(
853
+ classified: { type: "codex" | "oauth" | "api-key" },
854
+ name: string,
855
+ ): PoolTransport | string {
856
+ if (classified.type === "codex") return CODEX_POOL_TRANSPORT;
857
+ if (classified.type === "oauth" && name === "anthropic") return anthropicPoolTransport(name);
858
+ return `pool settings apply to the openai Codex pool and the anthropic pool, not "${name}"`;
859
+ }
860
+
861
+ /**
862
+ * `ocx account strategy <provider> [<name>]` and `ocx account sticky <provider> [<n>]` (#2702).
863
+ *
864
+ * One implementation rather than two near-duplicates, because strategy and sticky are two
865
+ * fields of one setting on every pool that has them.
866
+ *
867
+ * Values are NOT re-validated here. The server owns both contracts -- three strategy names,
868
+ * a 1-100 sticky bound -- and a duplicated bound is a second thing to keep in sync. Its 400
869
+ * is actionable now that the CLI prints `reason`.
870
+ */
871
+ async function poolSetting(
872
+ args: string[],
873
+ deps: AccountDeps,
874
+ field: "strategy" | "stickyLimit",
875
+ ): Promise<number> {
876
+ const wantsJson = flag(args, "--json");
877
+ const name = args.shift();
878
+ const requested = args.shift();
879
+ const label = field === "strategy" ? "pool strategy" : "sticky limit";
880
+ if (!name || args.length) return usage();
881
+ const classified = configAndType(deps, name);
882
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
883
+ const transport = poolTransportFor(classified, name);
884
+ if (typeof transport === "string") return usage(`Error: ${transport}`);
885
+
886
+ const baseUrl = await resolveBaseUrl(deps);
887
+ if (!baseUrl) return proxyUnreachable();
888
+
889
+ // No value means show. A read must not rewrite what it is reporting.
890
+ if (requested === undefined) {
891
+ const response = await apiJson(deps, baseUrl, "GET", transport.readPath);
892
+ if (response.status === 0) return proxyUnreachable(response.transportError);
893
+ if (response.status !== 200) return apiError(response.json, `failed to read ${label}`, response.status);
894
+ const strategy = response.json[transport.strategyKey];
895
+ const sticky = response.json[transport.stickyKey];
896
+ if (wantsJson) {
897
+ // Pool-neutral key names: the two routes spell the same two settings differently, and a
898
+ // `--json` consumer should not have to branch on which pool answered.
899
+ console.log(JSON.stringify({ ok: true, provider: name, strategy, stickyLimit: sticky }, null, 2));
900
+ } else {
901
+ console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
902
+ }
903
+ return 0;
904
+ }
905
+
906
+ // Sent as a number when it parses as one so the server sees the type it validates;
907
+ // a non-numeric string still goes through and earns the server's own 400.
908
+ const value = field === "strategy"
909
+ ? requested
910
+ : (Number.isNaN(Number(requested)) ? requested : Number(requested));
911
+ const response = await apiJson(deps, baseUrl, "PUT", transport.writePath, transport.writeBody(field, value));
912
+ if (response.status === 0) return proxyUnreachable(response.transportError);
913
+ if (response.status !== 200) return apiError(response.json, `failed to set ${label}`, response.status);
914
+
915
+ if (wantsJson) {
916
+ console.log(JSON.stringify({
917
+ ok: true,
918
+ provider: name,
919
+ strategy: response.json[transport.strategyKey],
920
+ stickyLimit: response.json[transport.stickyKey],
921
+ }, null, 2));
922
+ } else {
923
+ // Echo the APPLIED value from the response, not the requested one: the server normalizes,
924
+ // and printing the request would hide a normalization the operator should see.
925
+ const applied = field === "strategy" ? response.json[transport.strategyKey] : response.json[transport.stickyKey];
926
+ console.log(`${name}: ${label} is now ${String(applied)}`);
927
+ }
928
+ return 0;
929
+ }
930
+
931
+ export function cmdStrategy(args: string[], deps: AccountDeps): Promise<number> {
932
+ return poolSetting(args, deps, "strategy");
933
+ }
934
+
935
+ export function cmdSticky(args: string[], deps: AccountDeps): Promise<number> {
936
+ return poolSetting(args, deps, "stickyLimit");
937
+ }
938
+
706
939
  export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
707
940
  const wantsJson = flag(args, "--json");
708
941
  const name = args.shift();
@@ -728,8 +961,8 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe
728
961
  ? { provider: name, accountId: id, alias }
729
962
  : { name, id, alias };
730
963
  const response = await apiJson(deps, baseUrl, "PUT", path, body);
731
- if (response.status === 0) return proxyUnreachable();
732
- if (response.status !== 200) return apiError(response.json, `failed to rename ${requestedId}`);
964
+ if (response.status === 0) return proxyUnreachable(response.transportError);
965
+ if (response.status !== 200) return apiError(response.json, `failed to rename ${requestedId}`, response.status);
733
966
  const result = { ok: true, provider: name, id, alias: alias || null };
734
967
  if (wantsJson) console.log(JSON.stringify(result, null, 2));
735
968
  else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`);