@bitkyc08/opencodex 2.10.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -30,7 +30,7 @@ import {
30
30
  type TranslatorBudget,
31
31
  } from "../lib/translator-budget";
32
32
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
33
- import { mapReasoningEffort } from "../reasoning-effort";
33
+ import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
34
34
 
35
35
  // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
36
36
  // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally
@@ -340,12 +340,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
340
340
  if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature;
341
341
  if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP;
342
342
  if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences;
343
- const directFlashThinking = provider.googleMode !== "vertex"
344
- && provider.googleMode !== "cloud-code-assist"
345
- && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")
343
+ // Effort thinkingLevel follows the configured ladder: any model advertising reasoning
344
+ // efforts (registry preset or user config) sends the mapped level, so a picker-selected
345
+ // effort actually reaches the wire (gemini-3.1-pro-preview ships a ladder). The original
346
+ // gemini-3.5/3.6-flash direct-mode slice stays hardcoded so unladdered configs keep their
347
+ // current behavior; Vertex participates only through an explicitly configured ladder (the
348
+ // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would
349
+ // suppress the responseModalities fallback below. CCA maps effort on its envelope path.
350
+ const thinkingEligible = provider.googleMode !== "cloud-code-assist"
351
+ && !isImageCapableModel(parsed.modelId)
352
+ && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined
353
+ || (provider.googleMode !== "vertex"
354
+ && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash")));
355
+ const thinkingLevel = thinkingEligible
346
356
  ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)
347
357
  : undefined;
348
- if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking };
358
+ if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel };
349
359
  if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
350
360
  generationConfig.responseModalities = ["TEXT", "IMAGE"];
351
361
  }
@@ -3,6 +3,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx
3
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
5
5
  import { debugProviderDiagnostic } from "../lib/debug";
6
+ import { sseFieldValue } from "../lib/sse-decoder";
6
7
  import { isDebugEnabled } from "../lib/debug-settings";
7
8
  import { isCyberPolicyCode } from "../lib/errors";
8
9
  import { redactSecretString } from "../lib/redact";
@@ -818,6 +819,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
818
819
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
819
820
  body.prompt_cache_key = parsed.options.promptCacheKey;
820
821
  }
822
+ // Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
823
+ // re-nests the flattened Responses fields under `json_schema` — the exact inverse of
824
+ // responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
825
+ // response_format is a first-class Chat Completions field, it is only present when the
826
+ // caller explicitly asked for structured output, and a backend that rejects it should
827
+ // fail loud rather than silently return prose the caller will try to JSON.parse.
828
+ const textFormat = parsed.options.textFormat;
829
+ if (textFormat?.type === "json_object") {
830
+ body.response_format = { type: "json_object" };
831
+ } else if (textFormat?.type === "json_schema") {
832
+ body.response_format = {
833
+ type: "json_schema",
834
+ json_schema: {
835
+ name: textFormat.name ?? "response",
836
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
837
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
838
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
839
+ },
840
+ };
841
+ }
821
842
 
822
843
  if (tools) {
823
844
  // Default-ON for chat-completions providers (user decision 260709): the buffered
@@ -927,8 +948,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
927
948
  // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
928
949
  // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
929
950
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
930
- if (!line.startsWith("data: ")) return "continue";
931
- const payload = line.slice(6).trim();
951
+ const rawPayload = sseFieldValue(line, "data");
952
+ if (rawPayload === null) return "continue";
953
+ const payload = rawPayload.trim();
932
954
  if (payload === "[DONE]") {
933
955
  yield* flushToolCalls();
934
956
  const stopReason = stopReasonFor(finishReason);
@@ -1048,7 +1048,8 @@ function stripInputImagesDeep(value: unknown): unknown {
1048
1048
  */
1049
1049
  function buildRoutedCompactionBody(body: unknown): unknown {
1050
1050
  if (!isPlainObject(body)) return body;
1051
- const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
1051
+ // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON.
1052
+ const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body;
1052
1053
  const input = Array.isArray(body.input) ? body.input : [];
1053
1054
  const kept = input.filter(item => !isPlainObject(item)
1054
1055
  // `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
package/src/bridge.ts CHANGED
@@ -827,8 +827,10 @@ export function bridgeToResponsesSSE(
827
827
  if (currentReasoning) closeCurrentReasoning();
828
828
  if (currentRawReasoning) closeCurrentRawReasoning();
829
829
  flushHiddenRawReasoning();
830
- // Reasoning consumed by a text turn, not a tool call: no cache target.
831
- rawReasoningForNextToolCall = "";
830
+ // Reasoning consumed by a REAL text turn, not a tool call: no cache target.
831
+ // Empty text deltas must not wipe reasoning that precedes a tool call
832
+ // (chat-completions providers emit empty content deltas mid-tool-turn).
833
+ if (event.text.length > 0) rawReasoningForNextToolCall = "";
832
834
  if (currentToolCall) closeCurrentToolCall();
833
835
  // Only flush on an explicit phase change. A later delta that omits `phase` must
834
836
  // keep appending to the current message rather than wiping the earlier phase.
@@ -880,7 +882,7 @@ export function bridgeToResponsesSSE(
880
882
  if (currentMsg) closeCurrentMessage("commentary");
881
883
  if (currentRawReasoning) closeCurrentRawReasoning();
882
884
  flushHiddenRawReasoning();
883
- rawReasoningForNextToolCall = "";
885
+ if (event.thinking.length > 0) rawReasoningForNextToolCall = "";
884
886
  if (currentToolCall) closeCurrentToolCall();
885
887
  if (!currentReasoning) {
886
888
  const itemId = `rs_${uuid()}`;
@@ -1561,7 +1563,9 @@ function buildResponseJSONWithBudget(
1561
1563
  if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
1562
1564
  if (currentSummaryReasoning) flushSummaryReasoning();
1563
1565
  if (currentRawReasoning) flushRawReasoning();
1564
- rawReasoningForNextToolCall = "";
1566
+ // Empty text deltas (batch chat responses always carry content, often "") must
1567
+ // not wipe reasoning that precedes a tool call (#950 non-streaming path).
1568
+ if (e.text.length > 0) rawReasoningForNextToolCall = "";
1565
1569
  if (currentToolCallId) flushToolCall();
1566
1570
  // Compaction turns keep the summary out of normal message output (replay dedup — see
1567
1571
  // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
@@ -1580,7 +1584,7 @@ function buildResponseJSONWithBudget(
1580
1584
  case "thinking_delta":
1581
1585
  if (currentText) flushText("commentary");
1582
1586
  if (currentRawReasoning) flushRawReasoning();
1583
- rawReasoningForNextToolCall = "";
1587
+ if (e.thinking.length > 0) rawReasoningForNextToolCall = "";
1584
1588
  if (currentToolCallId) flushToolCall();
1585
1589
  {
1586
1590
  ({ value: currentSummaryReasoning, bytes: currentSummaryReasoningBytes } = appendBatchString(
@@ -7,7 +7,7 @@
7
7
  */
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
- import { decodeServerSentEvents } from "../lib/sse-decoder";
10
+ import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
11
11
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
12
12
  import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
13
13
 
@@ -671,8 +671,9 @@ export async function collectChatCompletion(
671
671
  const rawFrame = buffer.slice(0, sep);
672
672
  buffer = replaceRetained(buffer, buffer.slice(sep + 2), "live_transient");
673
673
  for (const line of rawFrame.split("\n")) {
674
- if (!line.startsWith("data: ")) continue;
675
- const data = line.slice(6).trim();
674
+ const rawData = sseFieldValue(line, "data");
675
+ if (rawData === null) continue;
676
+ const data = rawData.trim();
676
677
  if (!data || data === "[DONE]") continue;
677
678
  let parsed: unknown;
678
679
  try { parsed = JSON.parse(data); } catch { continue; }
@@ -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();