@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1

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 (94) hide show
  1. package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
  2. package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
  5. package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
  8. package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
  9. package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
  11. package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
  13. package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
  14. package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
  15. package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
  16. package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
  20. package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
  21. package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/dispatch.ts +195 -27
  56. package/src/cli/doctor.ts +100 -1
  57. package/src/cli/help.ts +11 -2
  58. package/src/cli/index.ts +19 -3
  59. package/src/cli/inspect.ts +230 -0
  60. package/src/cli/observe.ts +11 -3
  61. package/src/cli/registry.ts +34 -2
  62. package/src/cli/runtime-api.ts +51 -7
  63. package/src/cli/status.ts +16 -0
  64. package/src/cli/storage.ts +234 -0
  65. package/src/cli/system-command.ts +16 -0
  66. package/src/cli/usage-report.ts +52 -2
  67. package/src/cli/version-skew.ts +46 -0
  68. package/src/codex/account-label.ts +21 -0
  69. package/src/codex/catalog/provider-fetch.ts +4 -0
  70. package/src/codex/transition-state.ts +12 -3
  71. package/src/compatibility/openai-responses.ts +9 -1
  72. package/src/generated/compatibility-version.json +94 -58
  73. package/src/integrations/ownership-policy.ts +24 -5
  74. package/src/integrations/ownership.ts +36 -2
  75. package/src/integrations/state.ts +40 -7
  76. package/src/integrations/writer.ts +21 -3
  77. package/src/lib/admin-secrets.ts +24 -0
  78. package/src/lib/errors.ts +25 -1
  79. package/src/lib/service-secrets.ts +15 -0
  80. package/src/oauth/store.ts +16 -6
  81. package/src/providers/label.ts +34 -1
  82. package/src/responses/turn-termination.ts +107 -0
  83. package/src/server/management/logs-usage-routes.ts +5 -27
  84. package/src/server/management/route-registry.ts +317 -0
  85. package/src/server/proxy-liveness.ts +27 -4
  86. package/src/server/request-log.ts +37 -17
  87. package/src/server/responses/core.ts +80 -4
  88. package/src/server/responses/policy-fallback.ts +1 -1
  89. package/src/service.ts +34 -0
  90. package/src/storage/policy-job.ts +14 -4
  91. package/src/storage/policy.ts +79 -33
  92. package/src/usage/log.ts +32 -6
  93. package/src/usage/summary.ts +22 -34
  94. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -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,19 +179,27 @@ 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 {
144
- fiveHourPercent?: number;
145
- fiveHourResetAt?: number;
146
191
  weeklyPercent?: number;
147
192
  monthlyPercent?: number;
148
193
  weeklyResetAt?: number;
149
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;
150
203
  /** Sub-day burst window, when upstream declares one (#1791). */
151
204
  shortPercent?: number;
152
205
  shortResetAt?: number;
@@ -160,6 +213,8 @@ export interface ProviderQuotaWindowDto {
160
213
  }
161
214
 
162
215
  export interface ProviderQuotaDto extends CodexQuotaDto {
216
+ fiveHourPercent?: number;
217
+ fiveHourResetAt?: number;
163
218
  customWindows?: ProviderQuotaWindowDto[];
164
219
  updatedAt?: number;
165
220
  }
@@ -182,11 +237,18 @@ 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 = {};
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.
190
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
  }
@@ -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
  }
@@ -256,7 +326,9 @@ async function fetchOAuthRows(
256
326
  ? `?provider=${encodeURIComponent(name)}&quota=1${quota.refresh ? "&refresh=1" : ""}`
257
327
  : `?provider=${encodeURIComponent(name)}`;
258
328
  const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts${query}`);
259
- if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
329
+ if (res.status === 0) {
330
+ return { rows: [], activeId: null, status: 0, networkDown: true, transportError: res.transportError };
331
+ }
260
332
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
261
333
  const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null;
262
334
  const accounts = Array.isArray(res.json.accounts) ? res.json.accounts as OAuthAccountDto[] : [];
@@ -283,7 +355,9 @@ interface ApiKeyDto {
283
355
 
284
356
  async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
285
357
  const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
286
- 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
+ }
287
361
  if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
288
362
  const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
289
363
  const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
@@ -305,7 +379,7 @@ export function fetchRows(
305
379
  type: AccountType,
306
380
  quota?: { refresh?: boolean },
307
381
  ): Promise<FamilyRows> {
308
- if (type === "codex") return fetchCodexRows(deps, baseUrl);
382
+ if (type === "codex") return fetchCodexRows(deps, baseUrl, Boolean(quota?.refresh), quota !== undefined);
309
383
  if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name, quota);
310
384
  return fetchKeyRows(deps, baseUrl, name);
311
385
  }
@@ -314,8 +388,11 @@ export async function fetchProviderQuotaReport(
314
388
  deps: AccountDeps,
315
389
  baseUrl: string,
316
390
  name: string,
317
- ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown> }> {
391
+ ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown>; transportError?: string }> {
318
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
+ }
319
396
  if (res.status !== 200) return { status: res.status, report: null, errorJson: res.json };
320
397
  const reports = Array.isArray(res.json.reports) ? res.json.reports as ProviderQuotaReportDto[] : [];
321
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,24 +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
- const fiveHourPercent = quota?.fiveHourPercent ?? quota?.shortPercent;
254
- const fiveHourResetAt = quota?.fiveHourResetAt ?? quota?.shortResetAt;
255
- if (!quota || (quota.weeklyPercent === undefined && quota.monthlyPercent === undefined && fiveHourPercent === undefined)) {
256
- parts.push("quota: unknown");
257
- } else {
258
- if (fiveHourPercent !== undefined) {
259
- parts.push(`5h ${fiveHourPercent}%`);
260
- const fiveHourReset = resetIso(fiveHourResetAt);
261
- if (fiveHourReset) parts.push(`resets ${fiveHourReset}`);
262
- }
263
- if (quota.weeklyPercent !== undefined) parts.push(`weekly ${quota.weeklyPercent}%`);
264
- const weeklyReset = resetIso(quota.weeklyResetAt);
265
- if (weeklyReset) parts.push(`resets ${weeklyReset}`);
266
- if (quota.monthlyPercent !== undefined) parts.push(`monthly ${quota.monthlyPercent}%`);
267
- const monthlyReset = resetIso(quota.monthlyResetAt);
268
- if (monthlyReset) parts.push(`resets ${monthlyReset}`);
269
- }
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");
270
265
  if (row.needsReauth) parts.push("needs-reauth");
271
266
  return parts.filter(Boolean).join(" ");
272
267
  }
@@ -279,7 +274,7 @@ function quotaParts(quota: ProviderQuotaDto): string[] {
279
274
  const reset = resetIso(resetAt);
280
275
  if (reset) parts.push(`resets ${reset}`);
281
276
  };
282
- add("5h", quota.fiveHourPercent ?? quota.shortPercent, quota.fiveHourResetAt ?? quota.shortResetAt);
277
+ add("5h", quota.fiveHourPercent, quota.fiveHourResetAt);
283
278
  add("weekly", quota.weeklyPercent, quota.weeklyResetAt);
284
279
  add("monthly", quota.monthlyPercent, quota.monthlyResetAt);
285
280
  for (const window of quota.customWindows ?? []) add(window.label, window.percent, window.resetAt);
@@ -332,8 +327,8 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
332
327
  if (!baseUrl) return proxyUnreachable();
333
328
  if (classified.type !== "codex") {
334
329
  const result = await fetchProviderQuotaReport(deps, baseUrl, name);
335
- if (result.status === 0) return proxyUnreachable();
336
- 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);
337
332
  if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
338
333
  else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
339
334
  return 0;
@@ -367,15 +362,15 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
367
362
  if (!baseUrl) return proxyUnreachable();
368
363
  if (action === "status") {
369
364
  const response = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
370
- if (response.status === 0) return proxyUnreachable();
365
+ if (response.status === 0) return proxyUnreachable(response.transportError);
371
366
  if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
372
- return apiError(response.json, "failed to read auto-switch status");
367
+ return apiError(response.json, "failed to read auto-switch status", response.status);
373
368
  }
374
369
  threshold = response.json.autoSwitchThreshold;
375
370
  } else {
376
371
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
377
- if (response.status === 0) return proxyUnreachable();
378
- 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);
379
374
  }
380
375
  const enabled = threshold! > 0;
381
376
  if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2));
@@ -466,8 +461,8 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
466
461
  const baseUrl = await resolveBaseUrl(deps);
467
462
  if (!baseUrl) return proxyUnreachable();
468
463
  const response = await apiJson(deps, baseUrl, "POST", "/api/providers/keys", { name, key, ...(label ? { label } : {}) });
469
- if (response.status === 0) return proxyUnreachable();
470
- 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);
471
466
  const id = typeof response.json.id === "string" ? response.json.id : null;
472
467
  // Redact the key inside the label BEFORE serialization — a key containing
473
468
  // JSON-escaped characters (" or \) would otherwise survive the whole-output
@@ -543,7 +538,7 @@ export async function cmdImport(args: string[], deps: AccountDeps): Promise<numb
543
538
  clearTimeout(timer);
544
539
  }
545
540
  if (response.status === 0) {
546
- if (!timedOut) return proxyUnreachable();
541
+ if (!timedOut) return proxyUnreachable(response.transportError);
547
542
  console.error(`Error: import_timeout after ${importTimeoutMs}ms`);
548
543
  return 1;
549
544
  }
@@ -596,8 +591,8 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
596
591
  const baseUrl = await resolveBaseUrl(deps);
597
592
  if (!baseUrl) return proxyUnreachable();
598
593
  const response = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/accounts/clear-cooldown", { id });
599
- if (response.status === 0) return proxyUnreachable();
600
- 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);
601
596
  const cleared = response.json?.cleared === true;
602
597
  if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, id, cleared }, null, 2));
603
598
  else if (cleared) console.log(`${name}: cooldown lifted for ${requestedId}`);
@@ -687,8 +682,8 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
687
682
  }
688
683
 
689
684
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/priority", { id, priority });
690
- if (response.status === 0) return proxyUnreachable();
691
- 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);
692
687
  const applied = typeof response.json.priority === "number" ? response.json.priority : (priority ?? 0);
693
688
  if (wantsJson) {
694
689
  console.log(JSON.stringify(
@@ -710,6 +705,237 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
710
705
  return 0;
711
706
  }
712
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
+
713
939
  export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
714
940
  const wantsJson = flag(args, "--json");
715
941
  const name = args.shift();
@@ -735,8 +961,8 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe
735
961
  ? { provider: name, accountId: id, alias }
736
962
  : { name, id, alias };
737
963
  const response = await apiJson(deps, baseUrl, "PUT", path, body);
738
- if (response.status === 0) return proxyUnreachable();
739
- 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);
740
966
  const result = { ok: true, provider: name, id, alias: alias || null };
741
967
  if (wantsJson) console.log(JSON.stringify(result, null, 2));
742
968
  else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`);