@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -11,6 +11,7 @@ import {
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
13
  saveConfigPreservingClaudeCode,
14
+ subagentDefaultSyncEffective,
14
15
  } from "../../config";
15
16
  import {
16
17
  clearLoginState,
@@ -190,6 +191,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
190
191
  )));
191
192
  return jsonResponse({
192
193
  multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
194
+ syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
193
195
  model: config.injectionModel ?? null,
194
196
  effort: config.injectionEffort ?? null,
195
197
  prompt: config.injectionPrompt ?? null,
@@ -207,6 +209,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
207
209
  }
208
210
  const body = parsedBody as {
209
211
  multiAgentGuidanceEnabled?: unknown;
212
+ syncCodexSubagentDefaults?: unknown;
210
213
  model?: unknown;
211
214
  effort?: unknown;
212
215
  prompt?: unknown;
@@ -214,6 +217,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
214
217
  const { isCodexReasoningEffort } = await import("../../reasoning-effort");
215
218
 
216
219
  let nextEnabled = config.multiAgentGuidanceEnabled;
220
+ // Start from the effective state reported by GET. A stale hand-edited
221
+ // `true` without a model must not spring back on during a model-only PUT.
222
+ let nextSyncCodexSubagentDefaults = subagentDefaultSyncEffective(config);
217
223
  let nextModel = config.injectionModel;
218
224
  let nextEffort = config.injectionEffort;
219
225
  let nextPrompt = config.injectionPrompt;
@@ -224,10 +230,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
224
230
  }
225
231
  nextEnabled = body.multiAgentGuidanceEnabled;
226
232
  }
233
+ if ("syncCodexSubagentDefaults" in body) {
234
+ if (typeof body.syncCodexSubagentDefaults !== "boolean") {
235
+ return jsonResponse({ error: "syncCodexSubagentDefaults must be a boolean" }, 400);
236
+ }
237
+ nextSyncCodexSubagentDefaults = body.syncCodexSubagentDefaults;
238
+ }
227
239
  if ("model" in body) {
228
240
  if (body.model === null || body.model === "") nextModel = undefined;
229
- else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model;
230
- else return jsonResponse({ error: "model must be a non-empty string or null" }, 400);
241
+ else if (typeof body.model === "string" && body.model.trim().length > 0) nextModel = body.model;
242
+ else return jsonResponse({ error: "model must be a nonblank string or null" }, 400);
231
243
  }
232
244
  if ("effort" in body) {
233
245
  if (body.effort === null || body.effort === "") nextEffort = undefined;
@@ -242,10 +254,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
242
254
  else if (body.prompt === null || body.prompt === "") nextPrompt = undefined;
243
255
  else return jsonResponse({ error: "prompt must be a string or null" }, 400);
244
256
  }
245
- // Clearing the model always clears the effort (it is meaningless alone).
246
- if (!nextModel) nextEffort = undefined;
257
+ // Clearing the model always clears model-dependent settings before sync/effort gates.
258
+ if (!nextModel) {
259
+ nextEffort = undefined;
260
+ nextSyncCodexSubagentDefaults = false;
261
+ }
262
+ if (body.syncCodexSubagentDefaults === true && !nextModel?.trim()) {
263
+ return jsonResponse({ error: "syncCodexSubagentDefaults requires an injection model" }, 400);
264
+ }
265
+ if (nextSyncCodexSubagentDefaults && nextEffort !== undefined && !isCodexReasoningEffort(nextEffort)) {
266
+ return jsonResponse({ error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort" }, 400);
267
+ }
247
268
 
248
269
  config.multiAgentGuidanceEnabled = nextEnabled;
270
+ if (nextSyncCodexSubagentDefaults) config.syncCodexSubagentDefaults = true;
271
+ else delete config.syncCodexSubagentDefaults;
249
272
  if (nextModel) config.injectionModel = nextModel;
250
273
  else delete config.injectionModel;
251
274
  if (nextEffort) config.injectionEffort = nextEffort;
@@ -257,6 +280,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
257
280
  return jsonResponse({
258
281
  ok: true,
259
282
  multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
283
+ syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
260
284
  model: config.injectionModel ?? null,
261
285
  effort: config.injectionEffort ?? null,
262
286
  prompt: config.injectionPrompt ?? null,
@@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry";
29
29
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
30
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
31
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
+ import {
33
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
34
+ codexAccountNamespaceForModel,
35
+ } from "../../codex/account-namespace-match";
32
36
  import { clearThreadAccountMap } from "../../codex/routing";
33
37
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
38
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
@@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
123
127
  const previous = config.combos?.[sourceId];
124
128
  const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null;
125
129
  const newPublicModel = comboPublicModelId(id, normalized);
130
+ if (codexAccountNamespaceForModel(config.codexAccountNamespaces, newPublicModel)) {
131
+ return jsonResponse({ error: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR }, 409);
132
+ }
126
133
  const nextCombos = { ...(config.combos ?? {}) };
127
134
  if (renameFrom) delete nextCombos[renameFrom];
128
135
  nextCombos[id] = stored;
@@ -225,10 +225,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
225
225
 
226
226
  if (url.pathname === "/api/sync" && req.method === "POST") {
227
227
  const { syncModelsToCodex } = await import("../../codex/sync");
228
+ const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
228
229
  const result = await syncModelsToCodex(undefined, config, null);
230
+ // Hint only after a real catalog/cache write — never enumerate processes here
231
+ // (WMIC/PowerShell would block Bun's event loop on every dashboard sync).
229
232
  return jsonResponse({
230
- ...result,
231
- staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
233
+ ...attachStaleAppServerHint(result),
234
+ ...(result.ok ? {} : { error: result.message }),
232
235
  }, result.ok ? 200 : 500);
233
236
  }
234
237
 
@@ -34,7 +34,19 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
36
  import { scanStorage } from "../../storage/scanner";
37
- import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup";
37
+ import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
38
+ import { runArchivedCleanupJob } from "../../storage/cleanup-job";
39
+ import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
40
+ import {
41
+ normalizeStorageCleanupPolicy,
42
+ parseStorageCleanupPolicyInput,
43
+ writeStorageCleanupPolicyToConfig,
44
+ } from "../../storage/policy";
45
+ import {
46
+ getStorageCleanupPolicyJobState,
47
+ getStorageCleanupPolicyTestStreamResponse,
48
+ requestStorageCleanupPolicyRun,
49
+ } from "../../storage/policy-job";
38
50
  import {
39
51
  currentUsageLogRevision,
40
52
  readUsageSnapshotForManagement,
@@ -278,7 +290,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
278
290
  ? pickWireCleanupTestHooks(body._test)
279
291
  : undefined;
280
292
  try {
281
- const result = executeArchivedCleanup({
293
+ const result = await runArchivedCleanupJob({
282
294
  percent,
283
295
  mode: mode as CleanupMode,
284
296
  digest,
@@ -286,14 +298,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
286
298
  });
287
299
  if (!result.ok) {
288
300
  const status =
289
- result.error === "codex_busy" || result.error === "stale_preview" || result.error === "referenced_history"
301
+ result.error === "codex_busy"
302
+ || result.error === "stale_preview"
303
+ || result.error === "referenced_history"
304
+ || result.error === "storage_mutation_busy"
305
+ || result.error === "restore_pending_overlap"
290
306
  ? 409
291
307
  : result.error === "invalid_mode" || result.error === "invalid_digest"
292
308
  ? 400
293
309
  : 500;
294
310
  const messages: Record<string, string> = {
295
311
  codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
312
+ storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
296
313
  stale_preview: "Archived files changed since preview — run Preview again.",
314
+ restore_pending_overlap: "Selected archives overlap an incomplete trash restore — finish or retry restore first.",
297
315
  referenced_history: "Selected archives are still referenced by forked or paginated history.",
298
316
  invalid_digest: "Preview digest is missing or invalid.",
299
317
  invalid_mode: "mode must be quarantine or permanent.",
@@ -326,5 +344,151 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
326
344
  }
327
345
  }
328
346
 
347
+ if (url.pathname === "/api/storage/trash" && req.method === "GET") {
348
+ try {
349
+ const entries = listTrashEntries();
350
+ return jsonResponse({
351
+ entries: entries.map(({ id, epoch, fileCount, bytes, quarantinedAt, mode }) => ({
352
+ id,
353
+ epoch,
354
+ fileCount,
355
+ bytes,
356
+ ...(quarantinedAt !== undefined ? { quarantinedAt } : {}),
357
+ ...(mode ? { mode } : {}),
358
+ })),
359
+ });
360
+ } catch {
361
+ return jsonResponse({ error: "trash_list_failed", entries: [] }, 500);
362
+ }
363
+ }
364
+
365
+ if (url.pathname === "/api/storage/trash/restore/test-stream" && req.method === "GET") {
366
+ if (process.env.OPENCODEX_CLEANUP_TEST_HOOKS === "1") {
367
+ const stream = getRestoreTrashTestStreamResponse();
368
+ if (stream) return stream;
369
+ }
370
+ // Always answer this test-only path — never fall through to the GUI SPA (200 HTML).
371
+ return jsonResponse({ error: "not_available" }, 404);
372
+ }
373
+
374
+ if (url.pathname === "/api/storage/trash/restore" && req.method === "POST") {
375
+ let body: { id?: unknown };
376
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
377
+ const id = typeof body?.id === "string" ? body.id : "";
378
+ if (!id.trim()) {
379
+ return jsonResponse({ error: "invalid_trash", message: "Trash entry id is required." }, 400);
380
+ }
381
+ try {
382
+ const result = await runRestoreTrashEntryJob(id);
383
+ if (!result.ok) {
384
+ const status =
385
+ result.error === "codex_busy"
386
+ || result.error === "dest_exists"
387
+ || result.error === "storage_mutation_busy"
388
+ ? 409
389
+ : result.error === "missing_trash"
390
+ ? 404
391
+ : result.error === "invalid_trash"
392
+ ? 400
393
+ : 500;
394
+ const messages: Record<RestoreErrorCode, string> = {
395
+ invalid_trash: "Trash entry id is missing or invalid.",
396
+ missing_trash: "Trash entry was not found.",
397
+ codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
398
+ storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
399
+ dest_exists: "Restore destination already exists — remove or rename the archived file and retry.",
400
+ fs_failed: "Filesystem restore failed. Some files may already be restored — check archived_sessions and .trash.",
401
+ db_reconcile_failed: "Could not restore Codex state database rows.",
402
+ restore_failed: "Restore failed.",
403
+ restore_worker_timeout: "Restore took too long (over 10 minutes) and was stopped.",
404
+ restore_worker_aborted: "Restore was cancelled during shutdown.",
405
+ restore_worker_failed: "Restore worker crashed or failed unexpectedly.",
406
+ };
407
+ const errorCode = result.error ?? "restore_failed";
408
+ const baseMessage = messages[errorCode] ?? messages.restore_failed;
409
+ const message =
410
+ result.message && errorCode === "restore_worker_failed"
411
+ ? `${baseMessage} (${result.message})`
412
+ : baseMessage;
413
+ return jsonResponse({
414
+ ok: false,
415
+ error: errorCode,
416
+ message,
417
+ count: result.count,
418
+ bytes: result.bytes,
419
+ restoredPaths: result.restoredPaths,
420
+ ...(result.trashDir ? { trashDir: result.trashDir } : {}),
421
+ }, status);
422
+ }
423
+ return jsonResponse({
424
+ ok: true,
425
+ trashDir: result.trashDir,
426
+ count: result.count,
427
+ bytes: result.bytes,
428
+ restoredPaths: result.restoredPaths,
429
+ });
430
+ } catch {
431
+ return jsonResponse({
432
+ ok: false,
433
+ error: "restore_failed",
434
+ message: "Restore failed.",
435
+ }, 500);
436
+ }
437
+ }
438
+
439
+ if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") {
440
+ const stream = getStorageCleanupPolicyTestStreamResponse();
441
+ if (stream) return stream;
442
+ // Production: hook is off. Return an explicit JSON 404 — do not fall through to the GUI.
443
+ return jsonResponse({ error: "not_found" }, 404);
444
+ }
445
+
446
+ if (url.pathname === "/api/storage/cleanup-policy" && req.method === "GET") {
447
+ const policy = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
448
+ return jsonResponse({
449
+ ...policy,
450
+ job: getStorageCleanupPolicyJobState(),
451
+ });
452
+ }
453
+
454
+ if (url.pathname === "/api/storage/cleanup-policy" && req.method === "PUT") {
455
+ let raw: unknown;
456
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
457
+ const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
458
+ const parsed = parseStorageCleanupPolicyInput(raw, previous);
459
+ if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
460
+ // Never enable implicitly: if client omitted enabled, keep previous (default false).
461
+ const body = raw as Record<string, unknown>;
462
+ if (body.enabled === undefined) parsed.policy.enabled = previous.enabled;
463
+ const saved = writeStorageCleanupPolicyToConfig(parsed.policy);
464
+ config.storageCleanupPolicy = saved;
465
+ return jsonResponse({ ok: true, policy: saved, job: getStorageCleanupPolicyJobState() });
466
+ }
467
+
468
+ if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") {
469
+ try {
470
+ const accepted = requestStorageCleanupPolicyRun({ reason: "manual", force: true });
471
+ if (!accepted.accepted) {
472
+ return jsonResponse({
473
+ ok: false,
474
+ started: false,
475
+ error: "already_running",
476
+ message: "A cleanup policy run is already in progress.",
477
+ job: accepted.state,
478
+ policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
479
+ }, 409);
480
+ }
481
+ // Return promptly — clients poll GET for skip/defer/success/error outcomes.
482
+ return jsonResponse({
483
+ ok: true,
484
+ started: true,
485
+ job: accepted.state,
486
+ policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
487
+ });
488
+ } catch {
489
+ return jsonResponse({ ok: false, error: "cleanup_failed" }, 500);
490
+ }
491
+ }
492
+
329
493
  return null;
330
494
  }
@@ -10,6 +10,8 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
+ readConfigDiagnostics,
14
+ reconcileLiveConfigFromDisk,
13
15
  saveConfigPreservingClaudeCode,
14
16
  } from "../../config";
15
17
  import {
@@ -19,7 +21,6 @@ import {
19
21
  listOAuthProviders,
20
22
  startLoginFlow,
21
23
  submitManualLoginCode,
22
- upsertOAuthProvider,
23
24
  } from "../../oauth";
24
25
  import { removeCredential } from "../../oauth/store";
25
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
@@ -27,9 +28,15 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
27
28
  import { deriveProviderPresets } from "../../providers/derive";
28
29
  import { providerCodexAccountMode } from "../../providers/registry";
29
30
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
- import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
+ import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
31
32
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
33
  import { clearThreadAccountMap } from "../../codex/routing";
34
+ import {
35
+ normalizeAccountPoolStickyLimit,
36
+ normalizeAccountPoolStrategy,
37
+ parseAccountPoolStickyLimit,
38
+ parseAccountPoolStrategy,
39
+ } from "../../codex/pool-rotation";
33
40
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
41
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
42
  import { resolveCodexHomeDir } from "../../codex/home";
@@ -59,6 +66,7 @@ import { buildApiAccessEndpoints } from "./api-access";
59
66
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
60
67
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
61
68
  import type { ManagementContext } from "./context";
69
+ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
62
70
 
63
71
  export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
64
72
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -80,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
80
88
  const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
81
89
  const provider = (body.provider ?? "").trim().toLowerCase();
82
90
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
91
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
92
+ if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409);
83
93
  const accountId = body.accountId?.trim();
84
94
  const reauth = body.reauth === true || Boolean(accountId);
85
95
  try {
@@ -90,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
90
100
  return jsonResponse({ error: "Unknown account for reauth" }, 404);
91
101
  }
92
102
  }
103
+ // Use persisted state, not the live object, as the merge base: another management
104
+ // request may already have mutated live config and yielded before its save.
105
+ const persistedBaseline = readConfigDiagnostics().config;
93
106
  // addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
94
107
  const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
95
108
  forceLogin: body.addAccount === true || reauth,
96
109
  ...(accountId ? { reauthAccountId: accountId } : {}),
110
+ }, {
111
+ // startLoginFlow returns the authorization URL before background persistence completes.
112
+ // Three-way reconcile settled disk changes so a failed login cannot leave a provider
113
+ // live-only and an in-flight management mutation cannot be erased before it saves.
114
+ onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline),
97
115
  });
98
- upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
99
116
  if (authUrl && !deviceCode) {
100
117
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
101
118
  // window.open is popup-blocked because it runs after an await, not a direct click.
@@ -146,8 +163,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
146
163
  await removeCredential(provider);
147
164
  clearLoginState(provider);
148
165
  // Drop cached/last-good quota rows tied to the removed credential.
149
- const { clearProviderQuotaCache } = await import("../../providers/quota");
166
+ const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
150
167
  clearProviderQuotaCache();
168
+ clearAccountQuotaCache(provider);
151
169
  return jsonResponse({ success: true });
152
170
  }
153
171
 
@@ -163,18 +181,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
163
181
  projectOAuthAccountHealth,
164
182
  projectStoredOAuthAccountHealth,
165
183
  } = await import("../../oauth/health");
166
- const set = getAccountSet(provider);
167
- const accounts = (status.accounts ?? []).map(summary => {
168
- const full = set?.accounts.find(account => account.id === summary.id);
169
- const health = full
170
- ? projectStoredOAuthAccountHealth(provider, full)
171
- : projectOAuthAccountHealth({
172
- needsReauth: summary.needsReauth === true,
173
- reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
174
- });
175
- return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
184
+ const projectAccounts = () => {
185
+ const set = getAccountSet(provider);
186
+ const current = getLoginStatus(provider);
187
+ return {
188
+ activeAccountId: current.activeAccountId ?? null,
189
+ accounts: (current.accounts ?? []).map(summary => {
190
+ const full = set?.accounts.find(account => account.id === summary.id);
191
+ const health = full
192
+ ? projectStoredOAuthAccountHealth(provider, full)
193
+ : projectOAuthAccountHealth({
194
+ needsReauth: summary.needsReauth === true,
195
+ reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
196
+ });
197
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
198
+ }),
199
+ };
200
+ };
201
+ // Per-account rate limits: Anthropic reports usage per credential, so every logged-in
202
+ // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
203
+ // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
204
+ const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
205
+ if (!wantQuota) return jsonResponse(projectAccounts());
206
+ const forceRefresh = url.searchParams.get("refresh") === "1";
207
+ // Probing may refresh the active credential and mark needsReauth — project health
208
+ // from the post-probe store so the response is not stale.
209
+ const rows = await fetchProviderAccountQuotas(provider, forceRefresh);
210
+ const byId = new Map(rows.map(row => [row.accountId, row]));
211
+ const projected = projectAccounts();
212
+ return jsonResponse({
213
+ activeAccountId: projected.activeAccountId,
214
+ accounts: projected.accounts.map(account => {
215
+ const row = byId.get(account.id);
216
+ if (!row) return account;
217
+ return {
218
+ ...account,
219
+ quota: row.quota,
220
+ ...(row.unavailable ? { quotaUnavailable: true } : {}),
221
+ };
222
+ }),
176
223
  });
177
- return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts });
178
224
  }
179
225
  if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
180
226
  const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
@@ -183,10 +229,104 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
183
229
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
184
230
  const { setActiveAccount } = await import("../../oauth/store");
185
231
  if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
232
+ if (provider === "anthropic") {
233
+ const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
234
+ resetAnthropicRoutingForManualSelection(body.accountId);
235
+ }
186
236
  const { clearProviderQuotaCache } = await import("../../providers/quota");
187
237
  clearProviderQuotaCache();
188
238
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
189
239
  }
240
+
241
+ // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
242
+ if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
243
+ const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
244
+ if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
245
+ const pool = config.anthropicAccountPool ?? {};
246
+ return jsonResponse({
247
+ provider,
248
+ enabled: pool.enabled === true,
249
+ autoSwitchThreshold: typeof pool.autoSwitchThreshold === "number" ? pool.autoSwitchThreshold : 80,
250
+ strategy: normalizeAccountPoolStrategy(pool.strategy),
251
+ stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
252
+ experimental: true,
253
+ });
254
+ }
255
+ if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
256
+ const parsedBody = await req.json().catch(() => ({}));
257
+ if (!isPlainRecord(parsedBody)) {
258
+ return jsonResponse({ error: "body must be an object" }, 400);
259
+ }
260
+ const body = parsedBody as {
261
+ provider?: unknown;
262
+ enabled?: unknown;
263
+ autoSwitchThreshold?: unknown;
264
+ strategy?: unknown;
265
+ stickyLimit?: unknown;
266
+ };
267
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
268
+ if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400);
269
+ let enabled = config.anthropicAccountPool?.enabled === true;
270
+ if (body.enabled !== undefined) {
271
+ if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
272
+ enabled = body.enabled;
273
+ }
274
+ let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80;
275
+ if (body.autoSwitchThreshold !== undefined) {
276
+ if (
277
+ typeof body.autoSwitchThreshold !== "number"
278
+ || !Number.isInteger(body.autoSwitchThreshold)
279
+ || body.autoSwitchThreshold < 0
280
+ || body.autoSwitchThreshold > 100
281
+ ) {
282
+ return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
283
+ }
284
+ threshold = body.autoSwitchThreshold;
285
+ }
286
+ let strategy = config.anthropicAccountPool?.strategy;
287
+ if (body.strategy !== undefined) {
288
+ const parsed = parseAccountPoolStrategy(body.strategy);
289
+ if (parsed === null) {
290
+ return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
291
+ }
292
+ strategy = parsed;
293
+ }
294
+ let stickyLimit = config.anthropicAccountPool?.stickyLimit;
295
+ if (body.stickyLimit !== undefined) {
296
+ const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
297
+ if (parsed === null) {
298
+ return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
299
+ }
300
+ stickyLimit = parsed;
301
+ }
302
+ config.anthropicAccountPool = {
303
+ enabled,
304
+ autoSwitchThreshold: threshold,
305
+ ...(strategy !== undefined ? { strategy } : {}),
306
+ ...(stickyLimit !== undefined ? { stickyLimit } : {}),
307
+ };
308
+ saveConfigPreservingClaudeCode(config);
309
+ return jsonResponse({
310
+ ok: true,
311
+ provider,
312
+ enabled,
313
+ autoSwitchThreshold: threshold,
314
+ strategy: normalizeAccountPoolStrategy(strategy),
315
+ stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit),
316
+ experimental: true,
317
+ });
318
+ }
319
+ if (url.pathname === "/api/oauth/accounts/clear-cooldown" && req.method === "POST") {
320
+ const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown };
321
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
322
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
323
+ if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
324
+ if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
325
+ const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
326
+ const cleared = clearAnthropicAccountCooldown(accountId);
327
+ return jsonResponse({ ok: true, cleared });
328
+ }
329
+
190
330
  if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
191
331
  const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
192
332
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
@@ -208,9 +348,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
208
348
  if (!id) return jsonResponse({ error: "missing id" }, 400);
209
349
  const { removeAccount, getAccountSet } = await import("../../oauth/store");
210
350
  if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
351
+ if (provider === "anthropic") {
352
+ const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing");
353
+ clearAnthropicAccountCooldown(id);
354
+ clearAnthropicSessionAffinityForAccount(id);
355
+ }
211
356
  if (!getAccountSet(provider)) clearLoginState(provider);
212
- const { clearProviderQuotaCache } = await import("../../providers/quota");
357
+ const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
213
358
  clearProviderQuotaCache();
359
+ clearAccountQuotaCache(provider);
214
360
  return jsonResponse({ ok: true });
215
361
  }
216
362
 
@@ -308,7 +454,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
308
454
  const salt = crypto.randomUUID();
309
455
  const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
310
456
  const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
311
- const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
457
+ const key = "ocx_data_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
312
458
  const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
313
459
  config.apiKeys = [...(config.apiKeys ?? []), entry];
314
460
  saveConfigPreservingClaudeCode(config);