@bitkyc08/opencodex 2.10.2 → 2.11.0-preview.20260808

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-B1P60C4o.js +70 -0
  4. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { atomicWriteFile } from "../config";
@@ -103,6 +103,45 @@ interface Desktop3pMetadata {
103
103
  [key: string]: unknown;
104
104
  }
105
105
 
106
+ export type Desktop3pLibraryKind =
107
+ | "not_installed"
108
+ | "standard"
109
+ | "gateway_ours"
110
+ | "gateway_drifted"
111
+ | "foreign"
112
+ | "no_owned_state"
113
+ | "broken"
114
+ | "unsafe";
115
+
116
+ export interface Desktop3pLibraryInspection {
117
+ kind: Desktop3pLibraryKind;
118
+ libraryPath: string;
119
+ selectedProfilePath: string | null;
120
+ appliedId: string | null;
121
+ /** Paths of opencodex-owned rows that are not selected by Desktop. */
122
+ residualPaths: string[];
123
+ /** Bounded reason code; never includes metadata or profile contents. */
124
+ reason?: "metadata_unreadable" | "unsafe_applied_id" | "invalid_owned_profile";
125
+ fingerprint?: string;
126
+ /**
127
+ * Whether Desktop's applied selection is our owned entry, by ID match alone.
128
+ * `null` = undeterminable (no metadata, unreadable metadata, or no appliedId);
129
+ * a readable appliedId with no owned entry is a KNOWN false, not unknown.
130
+ * Deliberately independent of profile-file health: the status contract
131
+ * predates this inspector and callers render tri-state.
132
+ */
133
+ ownedProfileActive: boolean | null;
134
+ }
135
+
136
+ export interface Desktop3pRemovalResult {
137
+ ok: boolean;
138
+ changed: boolean;
139
+ kind: "removed" | "noop" | "cleanup_incomplete" | "unsafe" | "write_failed";
140
+ libraryPath: string;
141
+ residualPaths?: string[];
142
+ reason?: string;
143
+ }
144
+
106
145
  let desktop3pRegistry = new Map<string, string>();
107
146
  let desktop3pAliasesByRoute = new Map<string, string>();
108
147
 
@@ -327,6 +366,186 @@ function parseMetadata(path: string): Desktop3pMetadata {
327
366
  return { ...parsed, entries: parsed.entries };
328
367
  }
329
368
 
369
+ const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
370
+
371
+ function isRecord(value: unknown): value is Record<string, unknown> {
372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
373
+ }
374
+
375
+ function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
376
+ return entry?.name === "opencodex" || entry?.name === "opencodex-standard";
377
+ }
378
+
379
+ /** A gateway row is removable; the selected standard row must always remain. */
380
+ function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
381
+ return entry?.name === "opencodex";
382
+ }
383
+
384
+ function profilePath(libraryPath: string, id: string): string {
385
+ return join(libraryPath, `${id}.json`);
386
+ }
387
+
388
+ /**
389
+ * Read Desktop's selected config without changing its library.
390
+ *
391
+ * This is intentionally separate from the eager writer below: status probes must
392
+ * never manufacture a config-library directory on a machine without Desktop.
393
+ */
394
+ export function inspectDesktop3pConfigLibrary(
395
+ options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null } = {},
396
+ ): Desktop3pLibraryInspection {
397
+ const libraryPath = resolveDesktop3pConfigLibraryPath(options);
398
+ if (!existsSync(libraryPath)) {
399
+ return { kind: "not_installed", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
400
+ }
401
+
402
+ const metadataPath = join(libraryPath, "_meta.json");
403
+ if (!existsSync(metadataPath)) {
404
+ return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
405
+ }
406
+
407
+ let metadata: Desktop3pMetadata;
408
+ try {
409
+ metadata = parseMetadata(metadataPath);
410
+ } catch {
411
+ return {
412
+ kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], reason: "metadata_unreadable", ownedProfileActive: null,
413
+ };
414
+ }
415
+ const appliedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null;
416
+ if (appliedId === null) {
417
+ return { kind: "no_owned_state", libraryPath, selectedProfilePath: null, appliedId: null, residualPaths: [], ownedProfileActive: null };
418
+ }
419
+ const selected = metadata.entries.find(entry => entry?.id === appliedId);
420
+ // A readable appliedId with no owned entry is a KNOWN false, not unknown.
421
+ const ownedProfileActive = isOwnedDesktopEntry(selected);
422
+ if (!SAFE_DESKTOP_PROFILE_ID.test(appliedId)) {
423
+ return {
424
+ kind: "unsafe", libraryPath, selectedProfilePath: null, appliedId, residualPaths: [], reason: "unsafe_applied_id", ownedProfileActive,
425
+ };
426
+ }
427
+
428
+ const selectedProfilePath = profilePath(libraryPath, appliedId);
429
+ const residualPaths = metadata.entries
430
+ .filter(entry => isOwnedDesktopGatewayEntry(entry) && entry.id !== appliedId && SAFE_DESKTOP_PROFILE_ID.test(entry.id))
431
+ .flatMap(entry => [profilePath(libraryPath, entry.id), `${profilePath(libraryPath, entry.id)}.bak`])
432
+ .filter(existsSync);
433
+ if (!existsSync(selectedProfilePath)) {
434
+ return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
435
+ }
436
+
437
+ let profile: Record<string, unknown>;
438
+ let fingerprint: string;
439
+ try {
440
+ const source = readFileSync(selectedProfilePath, "utf8");
441
+ const parsed = JSON.parse(source) as unknown;
442
+ if (!isRecord(parsed)) return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
443
+ profile = parsed;
444
+ fingerprint = createHash("sha256").update(source).digest("hex").slice(0, 16);
445
+ } catch {
446
+ return { kind: "broken", libraryPath, selectedProfilePath, appliedId, residualPaths, ownedProfileActive };
447
+ }
448
+ if (!isOwnedDesktopEntry(selected)) {
449
+ return { kind: "foreign", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive };
450
+ }
451
+ if (profile.inferenceProvider === undefined) {
452
+ return { kind: "standard", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive };
453
+ }
454
+ const validGateway = profile.inferenceProvider === "gateway"
455
+ && profile.inferenceCredentialKind === "static"
456
+ && typeof profile.inferenceGatewayBaseUrl === "string"
457
+ && typeof profile.inferenceGatewayApiKey === "string";
458
+ if (!validGateway) {
459
+ return {
460
+ kind: "unsafe", libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, reason: "invalid_owned_profile", ownedProfileActive,
461
+ };
462
+ }
463
+ return {
464
+ kind: options.appliedFingerprint && options.appliedFingerprint === fingerprint ? "gateway_ours" : "gateway_drifted",
465
+ libraryPath, selectedProfilePath, appliedId, residualPaths, fingerprint, ownedProfileActive,
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Select a credential-free standard profile before deleting an owned gateway.
471
+ * The old metadata row remains as a retry locator only until both its profile
472
+ * and backup are absent; successful cleanup removes it in the same operation.
473
+ */
474
+ export function removeDesktop3pStandardPivot(
475
+ options: Desktop3pConfigLibraryOptions & {
476
+ appliedFingerprint?: string | null;
477
+ unlink?: (path: string) => void;
478
+ } = {},
479
+ ): Desktop3pRemovalResult {
480
+ const inspected = inspectDesktop3pConfigLibrary(options);
481
+ if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") {
482
+ return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
483
+ }
484
+ if (inspected.kind === "broken" || inspected.kind === "unsafe" || inspected.kind === "gateway_drifted") {
485
+ return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason };
486
+ }
487
+ if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) {
488
+ return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: "unsafe_applied_id" };
489
+ }
490
+
491
+ const metadataPath = join(inspected.libraryPath, "_meta.json");
492
+ try {
493
+ const metadata = parseMetadata(metadataPath);
494
+ const selectedId = inspected.appliedId;
495
+ // When Desktop is actively using our gateway, pivot only that selected row
496
+ // first. Any second owned row is residue for a later standard-mode retry;
497
+ // this preserves the selected-row preference after an interrupted cleanup.
498
+ const targetIds = inspected.kind === "gateway_ours"
499
+ ? [selectedId]
500
+ : metadata.entries
501
+ .filter(isOwnedDesktopGatewayEntry)
502
+ .map(entry => entry.id)
503
+ .filter(id => SAFE_DESKTOP_PROFILE_ID.test(id));
504
+ if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
505
+
506
+ let metadataAfterPivot = metadata;
507
+ if (inspected.kind === "gateway_ours") {
508
+ const standardId = randomUUID();
509
+ const standardPath = profilePath(inspected.libraryPath, standardId);
510
+ atomicWriteFile(standardPath, "{}\n");
511
+ const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
512
+ metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
513
+ atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n");
514
+ }
515
+
516
+ const residualPaths: string[] = [];
517
+ for (const id of targetIds) {
518
+ for (const candidate of [profilePath(inspected.libraryPath, id), `${profilePath(inspected.libraryPath, id)}.bak`]) {
519
+ try {
520
+ if (existsSync(candidate)) (options.unlink ?? unlinkSync)(candidate);
521
+ } catch {
522
+ // Only the path is allowed to leave this credential-bearing cleanup boundary.
523
+ }
524
+ if (existsSync(candidate)) residualPaths.push(candidate);
525
+ }
526
+ }
527
+ const ownedResiduePaths = metadataAfterPivot.entries
528
+ .filter(entry => isOwnedDesktopGatewayEntry(entry) && !targetIds.includes(entry.id) && SAFE_DESKTOP_PROFILE_ID.test(entry.id))
529
+ .flatMap(entry => [profilePath(inspected.libraryPath, entry.id), `${profilePath(inspected.libraryPath, entry.id)}.bak`])
530
+ .filter(existsSync);
531
+ if (residualPaths.length > 0 || ownedResiduePaths.length > 0) {
532
+ return {
533
+ ok: false, changed: true, kind: "cleanup_incomplete", libraryPath: inspected.libraryPath,
534
+ residualPaths: [...new Set([...residualPaths, ...ownedResiduePaths])],
535
+ };
536
+ }
537
+ // Do not leave a metadata row pointing at a deleted profile. For a foreign
538
+ // selection this only removes proven opencodex residues; appliedId is kept.
539
+ atomicWriteFile(
540
+ metadataPath,
541
+ JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
542
+ );
543
+ return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
544
+ } catch {
545
+ return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath };
546
+ }
547
+ }
548
+
330
549
  /** Write and apply the opencodex config in Claude Desktop 3P's config library. */
331
550
  export function writeDesktop3pConfig(
332
551
  port: number,
@@ -343,7 +562,8 @@ export function writeDesktop3pConfig(
343
562
  try {
344
563
  mkdirSync(libraryPath, { recursive: true, mode: 0o700 });
345
564
  const metadata = parseMetadata(metadataPath);
346
- const existing = metadata.entries.find(entry => entry?.name === "opencodex" && typeof entry.id === "string");
565
+ const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry));
566
+ const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string");
347
567
  const id = existing?.id ?? randomUUID();
348
568
  configPath = join(libraryPath, `${id}.json`);
349
569
  const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" };
@@ -16,6 +16,7 @@ import {
16
16
  TranslatorBudgetExceededError,
17
17
  type TranslatorBudget,
18
18
  } from "../lib/translator-budget";
19
+ import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder";
19
20
 
20
21
  type Rec = Record<string, unknown>;
21
22
 
@@ -588,10 +589,16 @@ export function responsesSseToAnthropicSse(
588
589
  while (lineStart <= rawFrame.length) {
589
590
  const newline = rawFrame.indexOf("\n", lineStart);
590
591
  const lineEnd = newline === -1 ? rawFrame.length : newline;
591
- if (rawFrame.startsWith("event: ", lineStart)) {
592
- eventName = rawFrame.slice(lineStart + 7, lineEnd).trim();
593
- } else if (rawFrame.startsWith("data: ", lineStart)) {
594
- const fragmentStart = lineStart + 6;
592
+ // The space after the colon is optional in text/event-stream (#1170);
593
+ // compute the value offset the same way sseFieldValue does, without
594
+ // slicing the line first the byte accounting below is keyed to
595
+ // offsets into rawFrame.
596
+ const eventOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "event");
597
+ const dataOffset = sseFieldOffset(rawFrame, lineStart, lineEnd, "data");
598
+ if (eventOffset !== -1) {
599
+ eventName = rawFrame.slice(eventOffset, lineEnd).trim();
600
+ } else if (dataOffset !== -1) {
601
+ const fragmentStart = dataOffset;
595
602
  const fragmentBytes = utf8SliceBytes(rawFrame, fragmentStart, lineEnd);
596
603
  const fragmentReservation = translatorBudget.reserveTransient(fragmentBytes, { kind: "live_transient" });
597
604
  let fragmentCommitted = false;
@@ -861,8 +868,10 @@ export async function collectAnthropicMessage(
861
868
  let eventName = "";
862
869
  let dataLine = "";
863
870
  for (const line of rawFrame.split("\n")) {
864
- if (line.startsWith("event: ")) eventName = line.slice(7).trim();
865
- else if (line.startsWith("data: ")) dataLine += line.slice(6);
871
+ const eventValue = sseFieldValue(line, "event");
872
+ if (eventValue !== null) { eventName = eventValue.trim(); continue; }
873
+ const dataValue = sseFieldValue(line, "data");
874
+ if (dataValue !== null) dataLine += dataValue;
866
875
  }
867
876
  if (!eventName || !dataLine) continue;
868
877
  let data: unknown;
@@ -21,6 +21,8 @@ export interface AccountRow {
21
21
  masked?: string;
22
22
  active: boolean;
23
23
  needsReauth?: boolean;
24
+ /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
25
+ priority?: number;
24
26
  quota?: CodexQuotaDto | null;
25
27
  }
26
28
 
@@ -172,6 +174,7 @@ interface CodexAccountDto {
172
174
  plan?: string;
173
175
  isMain?: boolean;
174
176
  needsReauth?: boolean;
177
+ priority?: number;
175
178
  quota?: CodexQuotaDto | null;
176
179
  }
177
180
 
@@ -219,6 +222,7 @@ export async function fetchCodexRows(
219
222
  plan: a.plan,
220
223
  active: a.id === activeId,
221
224
  needsReauth: a.needsReauth,
225
+ priority: typeof a.priority === "number" ? a.priority : 0,
222
226
  ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
223
227
  }));
224
228
  return { rows, activeId, autoSwitchThreshold, status: 200 };
@@ -1,4 +1,10 @@
1
1
  import { loadConfig } from "../config";
2
+ import {
3
+ MAX_ACCOUNT_PRIORITY,
4
+ MIN_ACCOUNT_PRIORITY,
5
+ normalizeAccountPriority,
6
+ parseAccountPriority,
7
+ } from "../codex/pool-rotation";
2
8
  import {
3
9
  apiError,
4
10
  apiJson,
@@ -18,6 +24,7 @@ const EXTENDED_USAGE = `Usage:
18
24
  ocx account refresh <provider> [--json]
19
25
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
20
26
  ocx account alias <provider> <id|main> <display-name|-> [--json]
27
+ ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
21
28
  ocx account remove <provider> <id|main> --yes [--json]
22
29
  ocx account clear-cooldown <provider> <id|main> [--json]
23
30
  ocx account add-key <provider> [--label <label>] [--json]`;
@@ -316,6 +323,111 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
316
323
  return 0;
317
324
  }
318
325
 
326
+ /**
327
+ * Named selection orders. The words convey sequence rather than rank because the
328
+ * pool moves down the list only when everything above it is drained — "high
329
+ * priority" would suggest the account gets more traffic, which is not what
330
+ * ordering does.
331
+ */
332
+ const PRIORITY_PRESETS: Record<string, number> = {
333
+ first: 2,
334
+ earlier: 1,
335
+ normal: 0,
336
+ later: -1,
337
+ last: -2,
338
+ };
339
+
340
+ function priorityPresetName(priority: number): string | null {
341
+ return Object.entries(PRIORITY_PRESETS).find(([, value]) => value === priority)?.[0] ?? null;
342
+ }
343
+
344
+ function formatPriority(priority: number): string {
345
+ const preset = priorityPresetName(priority);
346
+ const signed = priority > 0 ? `+${priority}` : String(priority);
347
+ return preset ? `${signed} (${preset})` : signed;
348
+ }
349
+
350
+ /** `null` = reset to the default; `undefined` = unparseable. */
351
+ function parsePriorityArgument(raw: string): number | null | undefined {
352
+ const word = raw.trim().toLowerCase();
353
+ if (word === "reset") return null;
354
+ // Own keys only: `in` also matches "constructor", "__proto__", and friends.
355
+ if (Object.hasOwn(PRIORITY_PRESETS, word)) return PRIORITY_PRESETS[word];
356
+ // The regex only rules out shapes Number() would coerce ("1e2", " 1 ", ""); the range
357
+ // itself comes from the core parser so the CLI cannot drift from what the API accepts.
358
+ if (!/^[+-]?\d+$/.test(word)) return undefined;
359
+ return parseAccountPriority(Number(word)) ?? undefined;
360
+ }
361
+
362
+ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<number> {
363
+ const wantsJson = flag(args, "--json");
364
+ const name = args.shift();
365
+ const requestedId = args.shift();
366
+ const requestedPriority = args.shift();
367
+ if (!name || !requestedId || args.length) return usage();
368
+ const classified = configAndType(deps, name);
369
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
370
+ if (classified.type !== "codex") {
371
+ return usage("Error: selection order only applies to the openai Codex account pool");
372
+ }
373
+ const id = requestedId === "main" ? MAIN_ID : requestedId;
374
+
375
+ // Validate before touching the network so a typo never reaches the proxy.
376
+ let priority: number | null | undefined;
377
+ if (requestedPriority !== undefined) {
378
+ priority = parsePriorityArgument(requestedPriority);
379
+ if (priority === undefined) {
380
+ return usage(`Error: selection order must be an integer ${MIN_ACCOUNT_PRIORITY}..${MAX_ACCOUNT_PRIORITY}, one of ${Object.keys(PRIORITY_PRESETS).join("/")}, or reset`);
381
+ }
382
+ }
383
+
384
+ const baseUrl = await resolveBaseUrl(deps);
385
+ if (!baseUrl) return proxyUnreachable();
386
+
387
+ // No value means "show" — a read must not rewrite what it is reporting.
388
+ if (priority === undefined) {
389
+ const result = await fetchCodexRows(deps, baseUrl);
390
+ const failed = familyFailure(result, `failed to read ${name} accounts`);
391
+ if (failed !== null) return failed;
392
+ const row = result.rows.find(candidate => candidate.id === id);
393
+ if (!row) return usage(`Error: no ${name} account ${requestedId}`);
394
+ const current = normalizeAccountPriority(row.priority);
395
+ if (wantsJson) {
396
+ console.log(JSON.stringify(
397
+ { ok: true, provider: name, id, priority: current, preset: priorityPresetName(current) },
398
+ null,
399
+ 2,
400
+ ));
401
+ } else {
402
+ console.log(`${name}: ${requestedId} selection order is ${formatPriority(current)}`);
403
+ }
404
+ return 0;
405
+ }
406
+
407
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/priority", { id, priority });
408
+ if (response.status === 0) return proxyUnreachable();
409
+ if (response.status !== 200) return apiError(response.json, `failed to set selection order for ${requestedId}`);
410
+ const applied = typeof response.json.priority === "number" ? response.json.priority : (priority ?? 0);
411
+ if (wantsJson) {
412
+ console.log(JSON.stringify(
413
+ { ok: true, provider: name, id, priority: applied, preset: priorityPresetName(applied) },
414
+ null,
415
+ 2,
416
+ ));
417
+ } else {
418
+ console.log(`${name}: ${requestedId} selection order is now ${formatPriority(applied)}`);
419
+ }
420
+ // Not cmdUse's note: re-ordering takes effect on the next unbound request rather than
421
+ // only on new sessions, because preemption moves those requests up immediately.
422
+ console.error("Takes effect from the next unbound request; running threads keep their current account until drained.");
423
+ // The release is not optional and not conditional on the value changing, so it has to be
424
+ // stated: this route is the only way to clear a pin without immediately setting another,
425
+ // which means a write storing the order an account already had still releases it. Without
426
+ // this line that is a silent side effect of a command that looks purely declarative.
427
+ console.error('Also releases any manual "use this account now" pin, on any account.');
428
+ return 0;
429
+ }
430
+
319
431
  export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
320
432
  const wantsJson = flag(args, "--json");
321
433
  const name = args.shift();
@@ -2,7 +2,7 @@
2
2
  import { loadConfig } from "../config";
3
3
  import { providerCodexAccountMode } from "../providers/registry";
4
4
  import type { OcxConfig } from "../types";
5
- import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdRefresh, cmdRemove } from "./account-extended";
5
+ import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdPriority, cmdRefresh, cmdRemove } from "./account-extended";
6
6
  import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
7
7
  from "./account-api";
8
8
 
@@ -22,6 +22,7 @@ const ACCOUNT_USAGE = `Usage:
22
22
  ocx account refresh <provider> [--json]
23
23
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
24
24
  ocx account alias <provider> <account-or-key-id> <display-name|-> [--json]
25
+ ocx account priority <provider> <account-id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
25
26
  ocx account remove <provider> <account-or-key-id|main> --yes [--json]
26
27
  ocx account clear-cooldown <provider> <account-id|main> [--json]
27
28
  ocx account add-key <provider> [--label <label>] [--json]
@@ -67,11 +68,24 @@ function statusText(row: AccountRow): string {
67
68
  return parts.join(" ");
68
69
  }
69
70
 
71
+ /** Signed so the sort direction reads off the column; "-" where ordering does not apply. */
72
+ function priorityText(row: AccountRow): string {
73
+ if (row.priority === undefined) return "-";
74
+ return row.priority > 0 ? `+${row.priority}` : String(row.priority);
75
+ }
76
+
70
77
  export function formatAccountTable(rows: AccountRow[]): string {
71
- const header = ["PROVIDER", "TYPE", "ID", "PLAN/LABEL", "STATUS"];
78
+ const header = ["PROVIDER", "TYPE", "ID", "PLAN/LABEL", "PRIORITY", "STATUS"];
72
79
  const data = rows.map(r => {
73
80
  const keyLabel = r.masked && r.label !== r.masked ? `${r.masked} (${r.label})` : r.masked;
74
- return [r.provider, r.type, displayId(r.id), r.type === "api-key" ? keyLabel ?? "-" : r.label ?? "-", statusText(r)];
81
+ return [
82
+ r.provider,
83
+ r.type,
84
+ displayId(r.id),
85
+ r.type === "api-key" ? keyLabel ?? "-" : r.label ?? "-",
86
+ priorityText(r),
87
+ statusText(r),
88
+ ];
75
89
  });
76
90
  const widths = header.map((h, i) => Math.max(h.length, ...data.map(d => d[i]!.length)));
77
91
  const line = (cols: string[]) => cols.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd();
@@ -244,9 +258,11 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
244
258
  if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, type: c.type, activeId }, null, 2));
245
259
  else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
246
260
  if (c.type === "codex") {
247
- console.error("Applies to the next request after clearing existing pool affinity; in-flight requests keep their captured account.");
248
- console.error("Note: pool strategy, quota/cooldown/reauthentication state, and failure recovery may later select another eligible account.");
249
- console.error("Conversation context is replayed after account changes, but the provider-side prompt cache may be cold.");
261
+ console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured.");
262
+ const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
263
+ if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) {
264
+ console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`);
265
+ }
250
266
  }
251
267
  return 0;
252
268
  }
@@ -260,6 +276,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
260
276
  if (sub === "refresh") return await cmdRefresh(rest, deps);
261
277
  if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps);
262
278
  if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps);
279
+ if (sub === "priority") return await cmdPriority(rest, deps);
263
280
  if (sub === "remove") return await cmdRemove(rest, deps);
264
281
  if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
265
282
  if (sub === "add-key") return await cmdAddKey(rest, deps);
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
4
+ import { setIntegrationEnabled } from "../codex/desired-state";
4
5
  import {
5
6
  DESKTOP_FAMILIES,
6
7
  moveDesktopRoute,
@@ -41,7 +42,11 @@ export async function applyProfile(
41
42
  profile: DesktopProfile,
42
43
  mode: Desktop3pConfigMode,
43
44
  deps: ApplyProfileDeps = {},
44
- ): Promise<{ ok: boolean; path: string; reason?: string }> {
45
+ ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> {
46
+ // Explicit apply is an enable action. Persist intent before any Desktop write
47
+ // so a process crash cannot leave a gateway profile that startup immediately removes.
48
+ const desired = setIntegrationEnabled("claude-desktop", true);
49
+ if (!desired.ok) return { ok: false, path: "", reason: desired.message };
45
50
  const config = loadConfig();
46
51
  const state = await buildClaudeDesktopState(config, profile);
47
52
  config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
@@ -52,7 +57,7 @@ export async function applyProfile(
52
57
  // serving process installs the map there; a local-only write leaves the
53
58
  // daemon unable to decode aliases, and the provider rejects them (400).
54
59
  const post = deps.postApplyImpl ?? (async (m: Desktop3pConfigMode, p: DesktopProfile) =>
55
- runtimeRequest<{ ok?: boolean; path?: string; error?: string }>(
60
+ runtimeRequest<{ ok?: boolean; path?: string; error?: string; saved?: boolean; warning?: string }>(
56
61
  "/api/claude-desktop/apply",
57
62
  // The daemon's config may be older than what we just saved, so the
58
63
  // profile travels with the request instead of being re-read there.
@@ -61,12 +66,26 @@ export async function applyProfile(
61
66
  try {
62
67
  const applied = await post(mode, state.profile);
63
68
  if (applied.ok === false) return { ok: false, path: applied.path ?? "", reason: applied.error ?? "daemon apply failed" };
64
- return { ok: true, path: applied.path ?? "" };
69
+ // Partial success: Desktop was written but the applied marker was not
70
+ // persisted. Pass the degradation up instead of reporting a clean apply.
71
+ const partial = (applied as { saved?: boolean; warning?: string }).saved === false;
72
+ return {
73
+ ok: true,
74
+ path: applied.path ?? "",
75
+ ...(partial ? { warning: (applied as { warning?: string }).warning ?? "applied marker was not saved" } : {}),
76
+ };
65
77
  } catch (error) {
66
78
  return { ok: false, path: "", reason: error instanceof Error ? error.message : String(error) };
67
79
  }
68
80
  }
69
81
  const allModels = await fetchAllModels(config);
82
+ // The toggle can persist OFF while fetchAllModels was awaiting (same race the
83
+ // management writers fence). Re-read persisted intent immediately before the
84
+ // writer; a lost race is a discriminated skip, not a write.
85
+ const { claudeDesktopIntegrationEnabledNow } = await import("../codex/desired-state");
86
+ if (!claudeDesktopIntegrationEnabledNow()) {
87
+ return { ok: false, path: "", reason: "desired_state_changed" };
88
+ }
70
89
  const routed = filterCatalogVisibleModels(allModels, config).map(model => ({
71
90
  provider: model.provider,
72
91
  id: model.id,
@@ -111,6 +130,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
111
130
  return 1;
112
131
  }
113
132
  console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`);
133
+ // The write landed; only the bookkeeping marker did not. Saying nothing
134
+ // would leave the saved-vs-applied display wrong with no explanation.
135
+ if (result.warning) console.warn(`⚠️ ${result.warning}`);
114
136
  console.log("Claude Desktop을 완전히 종료한 뒤 다시 열어 주세요.");
115
137
  return 0;
116
138
  } catch (error) {
@@ -175,6 +197,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
175
197
  if (flags.includes("--apply")) {
176
198
  const result = await applyProfile(reconciled, "static", deps);
177
199
  if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; }
200
+ if (result.warning) console.warn(`⚠️ ${result.warning}`);
178
201
  }
179
202
  console.log("Claude Desktop 프로필을 가져왔습니다.");
180
203
  return 0;
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
+ import { clearCodexAccountPin } from "../codex/account-priority";
2
3
  import { getConfigPath, readConfigDiagnostics, saveConfig, validateConfigCandidate } from "../config";
3
4
  import type { OcxConfig } from "../types";
4
5
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
@@ -102,6 +103,14 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
102
103
  setPath(candidate, path, raw === undefined ? undefined : parseValue(raw), action === "unset");
103
104
  const config = validate(candidate);
104
105
  const savedValue = action === "unset" ? null : getPath(config, path);
106
+ // Setting the order here is the operator restating it, exactly as through
107
+ // `ocx account priority` or the management route, so it releases the manual pin
108
+ // for the same reason those do: a pin made before any order existed would
109
+ // otherwise outrank every order set afterwards, capping the pool at the pinned
110
+ // account's tier with nothing on any surface explaining why. `import` is
111
+ // deliberately not covered — that file supplies its own pin, so there is no
112
+ // stale one to release.
113
+ if (pathSegments(path)[0] === "codexAccountPriorities") clearCodexAccountPin(config);
105
114
  saveConfig(config);
106
115
  printData({ ok: true, path, value: redact(savedValue, path.split(".").at(-1)) }, wantsJson,
107
116
  [`${action === "unset" ? "Unset" : "Set"} ${path}.`]);