@bitkyc08/opencodex 2.7.40 โ†’ 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -0,0 +1,263 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ OcxClaudeDesktopAssignment,
4
+ OcxClaudeDesktopFamily,
5
+ OcxClaudeDesktopProfile,
6
+ } from "../types";
7
+
8
+ export const DESKTOP_FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const;
9
+ export type DesktopFamily = OcxClaudeDesktopFamily;
10
+ export type DesktopProfile = OcxClaudeDesktopProfile;
11
+
12
+ export interface DesktopProfileModel {
13
+ route: string;
14
+ label: string;
15
+ contextWindow?: number;
16
+ }
17
+
18
+ export interface RenderedDesktopModel extends DesktopProfileModel {
19
+ name: string;
20
+ family: DesktopFamily;
21
+ isFamilyDefault: boolean;
22
+ supports1m: boolean;
23
+ }
24
+
25
+ const DATE_ALIAS = /^claude-opus-4-8-(2026\d{4})$/;
26
+ const DAY_COUNT_2026 = 365;
27
+
28
+ export class DesktopProfileError extends Error {
29
+ constructor(message: string, readonly path = "profile") {
30
+ super(`${path}: ${message}`);
31
+ this.name = "DesktopProfileError";
32
+ }
33
+ }
34
+
35
+ export function emptyDesktopProfile(): DesktopProfile {
36
+ return {
37
+ version: 1,
38
+ assignments: {},
39
+ defaults: { opus: null, fable: null, sonnet: null, haiku: null },
40
+ };
41
+ }
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return !!value && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], path: string): void {
48
+ const allowed = new Set(keys);
49
+ for (const key of Object.keys(value)) {
50
+ if (!allowed.has(key)) throw new DesktopProfileError(`unknown field "${key}"`, path);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Applied-state markers survive every profile rebuild.
56
+ *
57
+ * `parseDesktopProfile`, `reconcileDesktopProfile` and `moveDesktopRoute` each construct a
58
+ * fresh `{ version, assignments, defaults }`, and the management routes persist whatever they
59
+ * return. Without this carry-through, saving an assignment โ€” or merely dragging a model to
60
+ * another family โ€” would erase the fingerprint the apply route wrote, and the GUI would report
61
+ * "not applied" for a config that is applied on disk.
62
+ */
63
+ function appliedMarkers(source: { appliedFingerprint?: unknown; appliedAt?: unknown }): {
64
+ appliedFingerprint?: string;
65
+ appliedAt?: string;
66
+ } {
67
+ return {
68
+ ...(typeof source.appliedFingerprint === "string" ? { appliedFingerprint: source.appliedFingerprint } : {}),
69
+ ...(typeof source.appliedAt === "string" ? { appliedAt: source.appliedAt } : {}),
70
+ };
71
+ }
72
+
73
+ function isFamily(value: unknown): value is DesktopFamily {
74
+ return typeof value === "string" && (DESKTOP_FAMILIES as readonly string[]).includes(value);
75
+ }
76
+
77
+ function routeModelId(route: string): string {
78
+ const slash = route.indexOf("/");
79
+ return slash >= 0 ? route.slice(slash + 1) : route;
80
+ }
81
+
82
+ function isRealAnthropicRoute(route: string): boolean {
83
+ return route.startsWith("anthropic/claude-");
84
+ }
85
+
86
+ function validDateAlias(alias: string): boolean {
87
+ const match = DATE_ALIAS.exec(alias);
88
+ if (!match) return false;
89
+ const year = Number(match[1]!.slice(0, 4));
90
+ const month = Number(match[1]!.slice(4, 6));
91
+ const day = Number(match[1]!.slice(6, 8));
92
+ const date = new Date(Date.UTC(year, month - 1, day));
93
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
94
+ }
95
+
96
+ export function parseDesktopProfile(value: unknown): DesktopProfile {
97
+ if (!isPlainObject(value)) throw new DesktopProfileError("must be an object");
98
+ // `appliedFingerprint`/`appliedAt` are written back by the apply route once a profile
99
+ // reaches Claude Desktop (see server/management/agent-settings-routes.ts). Rejecting them
100
+ // here made every reload after the first apply fail with `unknown field`.
101
+ assertExactKeys(value, ["version", "assignments", "defaults", "appliedFingerprint", "appliedAt"], "profile");
102
+ if (value.version !== 1) throw new DesktopProfileError("version must be 1", "profile.version");
103
+ if (!isPlainObject(value.assignments)) throw new DesktopProfileError("must be an object", "profile.assignments");
104
+ if (!isPlainObject(value.defaults)) throw new DesktopProfileError("must be an object", "profile.defaults");
105
+ assertExactKeys(value.defaults, DESKTOP_FAMILIES, "profile.defaults");
106
+ if (value.appliedFingerprint !== undefined && typeof value.appliedFingerprint !== "string") {
107
+ throw new DesktopProfileError("must be a string", "profile.appliedFingerprint");
108
+ }
109
+ if (value.appliedAt !== undefined && typeof value.appliedAt !== "string") {
110
+ throw new DesktopProfileError("must be a string", "profile.appliedAt");
111
+ }
112
+
113
+ const assignments: Record<string, OcxClaudeDesktopAssignment> = {};
114
+ const aliases = new Set<string>();
115
+ for (const [route, raw] of Object.entries(value.assignments)) {
116
+ if (!route.trim() || !route.includes("/")) throw new DesktopProfileError("route must be provider/model", `profile.assignments.${route || "<empty>"}`);
117
+ if (!isPlainObject(raw)) throw new DesktopProfileError("must be an object", `profile.assignments.${route}`);
118
+ assertExactKeys(raw, ["family", "alias"], `profile.assignments.${route}`);
119
+ if (!isFamily(raw.family)) throw new DesktopProfileError("unknown family", `profile.assignments.${route}.family`);
120
+ if (typeof raw.alias !== "string" || !raw.alias) throw new DesktopProfileError("must be a non-empty string", `profile.assignments.${route}.alias`);
121
+ if (isRealAnthropicRoute(route)) {
122
+ if (raw.alias !== routeModelId(route)) throw new DesktopProfileError("real Anthropic routes must keep their exact model id", `profile.assignments.${route}.alias`);
123
+ } else if (!validDateAlias(raw.alias)) {
124
+ throw new DesktopProfileError("must be a valid claude-opus-4-8-2026MMDD alias", `profile.assignments.${route}.alias`);
125
+ }
126
+ if (aliases.has(raw.alias)) throw new DesktopProfileError(`duplicate alias "${raw.alias}"`, `profile.assignments.${route}.alias`);
127
+ aliases.add(raw.alias);
128
+ assignments[route] = { family: raw.family, alias: raw.alias };
129
+ }
130
+
131
+ const defaults = {} as DesktopProfile["defaults"];
132
+ for (const family of DESKTOP_FAMILIES) {
133
+ const route = value.defaults[family];
134
+ if (route !== null && typeof route !== "string") throw new DesktopProfileError("must be a route or null", `profile.defaults.${family}`);
135
+ const members = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort();
136
+ if (members.length === 0) {
137
+ if (route !== null) throw new DesktopProfileError("must be null for an empty family", `profile.defaults.${family}`);
138
+ defaults[family] = null;
139
+ continue;
140
+ }
141
+ if (typeof route !== "string" || !assignments[route] || assignments[route]!.family !== family) {
142
+ throw new DesktopProfileError("must reference a member of this family", `profile.defaults.${family}`);
143
+ }
144
+ defaults[family] = route;
145
+ }
146
+ return { version: 1, assignments, defaults, ...appliedMarkers(value) };
147
+ }
148
+
149
+ function dayOfYearAlias(dayIndex: number): string {
150
+ const date = new Date(Date.UTC(2026, 0, dayIndex + 1));
151
+ const y = date.getUTCFullYear();
152
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
153
+ const d = String(date.getUTCDate()).padStart(2, "0");
154
+ return `claude-opus-4-8-${y}${m}${d}`;
155
+ }
156
+
157
+ function routeStartDay(route: string): number {
158
+ return createHash("sha256").update(route).digest().readUInt32BE(0) % DAY_COUNT_2026;
159
+ }
160
+
161
+ function allocateAlias(route: string, used: Set<string>): string {
162
+ if (isRealAnthropicRoute(route)) return routeModelId(route);
163
+ const start = routeStartDay(route);
164
+ for (let offset = 0; offset < DAY_COUNT_2026; offset += 1) {
165
+ const alias = dayOfYearAlias((start + offset) % DAY_COUNT_2026);
166
+ if (!used.has(alias)) return alias;
167
+ }
168
+ throw new DesktopProfileError("all 365 encoded date slots are occupied", `profile.assignments.${route}.alias`);
169
+ }
170
+
171
+ export function reconcileDesktopProfile(
172
+ stored: unknown,
173
+ models: readonly DesktopProfileModel[],
174
+ ): DesktopProfile {
175
+ const profile = stored === undefined || stored === null ? emptyDesktopProfile() : parseDesktopProfile(stored);
176
+ const assignments: DesktopProfile["assignments"] = Object.fromEntries(
177
+ Object.entries(profile.assignments).map(([route, assignment]) => [route, { ...assignment }]),
178
+ );
179
+ const used = new Set(Object.values(assignments).map(value => value.alias));
180
+ for (const model of [...models].sort((a, b) => a.route.localeCompare(b.route))) {
181
+ if (assignments[model.route]) continue;
182
+ const alias = allocateAlias(model.route, used);
183
+ used.add(alias);
184
+ assignments[model.route] = { family: "opus", alias };
185
+ }
186
+ const defaults = { ...profile.defaults };
187
+ for (const family of DESKTOP_FAMILIES) {
188
+ const members = Object.keys(assignments).filter(route => assignments[route]!.family === family).sort();
189
+ const current = defaults[family];
190
+ defaults[family] = current && assignments[current]?.family === family ? current : (members[0] ?? null);
191
+ }
192
+ return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(profile) });
193
+ }
194
+
195
+ export function moveDesktopRoute(
196
+ profile: DesktopProfile,
197
+ route: string,
198
+ family: DesktopFamily,
199
+ makeDefault = false,
200
+ ): DesktopProfile {
201
+ const parsed = parseDesktopProfile(profile);
202
+ const current = parsed.assignments[route];
203
+ if (!current) throw new DesktopProfileError("route is not assigned", `profile.assignments.${route}`);
204
+ const oldFamily = current.family;
205
+ if (oldFamily === family) {
206
+ return makeDefault ? setDesktopFamilyDefault(parsed, family, route) : parsed;
207
+ }
208
+ const assignments = { ...parsed.assignments, [route]: { ...current, family } };
209
+ const defaults = { ...parsed.defaults };
210
+ if (defaults[oldFamily] === route) {
211
+ defaults[oldFamily] = Object.keys(assignments).filter(key => key !== route && assignments[key]!.family === oldFamily).sort()[0] ?? null;
212
+ }
213
+ const destinationMembers = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort();
214
+ if (makeDefault || !defaults[family] || assignments[defaults[family]!]?.family !== family) defaults[family] = route;
215
+ if (!defaults[family] && destinationMembers.length > 0) defaults[family] = destinationMembers[0]!;
216
+ return parseDesktopProfile({ version: 1, assignments, defaults, ...appliedMarkers(parsed) });
217
+ }
218
+
219
+ export function setDesktopFamilyDefault(
220
+ profile: DesktopProfile,
221
+ family: DesktopFamily,
222
+ route: string | null,
223
+ ): DesktopProfile {
224
+ const parsed = parseDesktopProfile(profile);
225
+ const members = Object.keys(parsed.assignments).filter(key => parsed.assignments[key]!.family === family);
226
+ if (route === null && members.length > 0) throw new DesktopProfileError("cannot clear a non-empty family default", `profile.defaults.${family}`);
227
+ if (route !== null && parsed.assignments[route]?.family !== family) throw new DesktopProfileError("route is not a member of this family", `profile.defaults.${family}`);
228
+ return parseDesktopProfile({ ...parsed, defaults: { ...parsed.defaults, [family]: route } });
229
+ }
230
+
231
+ export function renderDesktopProfile(
232
+ profile: DesktopProfile,
233
+ models: readonly DesktopProfileModel[],
234
+ ): RenderedDesktopModel[] {
235
+ const parsed = parseDesktopProfile(profile);
236
+ const modelByRoute = new Map(models.map(model => [model.route, model]));
237
+ const activeByFamily = new Map<DesktopFamily, string[]>();
238
+ for (const family of DESKTOP_FAMILIES) activeByFamily.set(family, []);
239
+ for (const [route, assignment] of Object.entries(parsed.assignments)) {
240
+ if (modelByRoute.has(route)) activeByFamily.get(assignment.family)!.push(route);
241
+ }
242
+ for (const routes of activeByFamily.values()) routes.sort();
243
+ const effectiveDefaults = {} as Record<DesktopFamily, string | null>;
244
+ for (const family of DESKTOP_FAMILIES) {
245
+ const active = activeByFamily.get(family)!;
246
+ const stored = parsed.defaults[family];
247
+ effectiveDefaults[family] = stored && active.includes(stored) ? stored : (active[0] ?? null);
248
+ }
249
+ const defaultOrder = DESKTOP_FAMILIES.map(family => effectiveDefaults[family]).filter((route): route is string => !!route);
250
+ const defaultSet = new Set(defaultOrder);
251
+ const rest = Object.keys(parsed.assignments).filter(route => modelByRoute.has(route) && !defaultSet.has(route)).sort();
252
+ return [...defaultOrder, ...rest].map(route => {
253
+ const model = modelByRoute.get(route)!;
254
+ const assignment = parsed.assignments[route]!;
255
+ return {
256
+ ...model,
257
+ name: assignment.alias,
258
+ family: assignment.family,
259
+ isFamilyDefault: effectiveDefaults[assignment.family] === route,
260
+ supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000,
261
+ };
262
+ });
263
+ }
@@ -16,6 +16,8 @@ import { createHmac, randomBytes } from "node:crypto";
16
16
  import { isClaudeDebugEnabled } from "../lib/debug-settings";
17
17
 
18
18
  export interface ClaudeInboundDebugEntry {
19
+ /** Monotonic capture id โ€” unique even when several entries share Date.now(). */
20
+ id: number;
19
21
  at: number;
20
22
  endpoint: "messages" | "count_tokens";
21
23
  model: string;
@@ -39,6 +41,7 @@ const RING_LIMIT = 20;
39
41
  const ring: ClaudeInboundDebugEntry[] = [];
40
42
  const salt = randomBytes(16).toString("hex");
41
43
  let lastEnabled = false;
44
+ let nextCaptureId = 0;
42
45
 
43
46
  function tag(value: string): string {
44
47
  return createHmac("sha256", salt).update(value).digest("hex").slice(0, 8);
@@ -82,6 +85,7 @@ export function captureClaudeInbound(
82
85
  const userId = metadata && typeof metadata.user_id === "string" ? metadata.user_id : undefined;
83
86
  const system = systemText(body.system);
84
87
  const entry: ClaudeInboundDebugEntry = {
88
+ id: ++nextCaptureId,
85
89
  at: Date.now(),
86
90
  endpoint,
87
91
  model: typeof body.model === "string" ? body.model : "unknown",
@@ -102,7 +102,13 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i
102
102
  export type AnthropicIdStyle = "desktop3p" | "readable";
103
103
 
104
104
  /** Build the full anthropic-flavor discovery list (ids are Desktop 3P aliases). */
105
- export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedModels: readonly CatalogModel[], auto: AutoContextMode = AUTO_CONTEXT_OFF, idStyle: AnthropicIdStyle = "desktop3p"): AnthropicModelInfo[] {
105
+ export function buildAnthropicModelInfos(
106
+ nativeSlugs: readonly string[],
107
+ routedModels: readonly CatalogModel[],
108
+ auto: AutoContextMode = AUTO_CONTEXT_OFF,
109
+ idStyle: AnthropicIdStyle = "desktop3p",
110
+ aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
111
+ ): AnthropicModelInfo[] {
106
112
  const out: AnthropicModelInfo[] = [];
107
113
  const seen = new Set<string>();
108
114
  // [1m] picker variant (devlog 260712 B1): Claude Code accounts exactly 1M for ids
@@ -122,7 +128,7 @@ export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedM
122
128
  out.push({ ...base, id, display_name: `${base.display_name} ยท ${label}`, max_input_tokens: Math.min(window, ONE_MILLION) });
123
129
  };
124
130
  for (const slug of nativeSlugs) {
125
- const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : desktop3pAlias("native", slug);
131
+ const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug);
126
132
  if (seen.has(id)) continue;
127
133
  seen.add(id);
128
134
  const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true);
@@ -130,7 +136,7 @@ export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedM
130
136
  push1mVariant(info, nativeOpenAiContextWindow(slug));
131
137
  }
132
138
  for (const m of routedModels) {
133
- const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : desktop3pAlias(m.provider, m.id);
139
+ const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : aliasForRoute(m.provider, m.id);
134
140
  if (seen.has(id)) continue;
135
141
  seen.add(id);
136
142
  const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
@@ -19,6 +19,7 @@ const EXTENDED_USAGE = `Usage:
19
19
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
20
20
  ocx account alias <provider> <id|main> <display-name|-> [--json]
21
21
  ocx account remove <provider> <id|main> --yes [--json]
22
+ ocx account clear-cooldown <provider> <id|main> [--json]
22
23
  ocx account add-key <provider> [--label <label>] [--json]`;
23
24
  const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
24
25
  ocx account add-key <provider> <<< "$MY_KEY"
@@ -282,6 +283,39 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
282
283
  return 0;
283
284
  }
284
285
 
286
+ /**
287
+ * Lift a quota cooldown on a Codex account.
288
+ *
289
+ * This is the user-facing escape from the lockout described in
290
+ * `devlog/_plan/260726_cooldown_lockout_hardening`: injected routing makes the proxy the
291
+ * only model path for Codex Desktop, so a stuck cooldown reads as "the whole app is dead".
292
+ *
293
+ * Codex accounts only. API-key pools already reset their own 429 cooldowns through key
294
+ * management (`clearKeyCooldowns`), and OAuth providers have no equivalent state here.
295
+ */
296
+ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promise<number> {
297
+ const wantsJson = flag(args, "--json");
298
+ const name = args.shift();
299
+ const requestedId = args.shift();
300
+ if (!name || !requestedId || args.length) return usage();
301
+ const classified = configAndType(deps, name);
302
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
303
+ if (classified.type !== "codex") {
304
+ return usage(`Error: ${name} is not a Codex account pool; cooldown clearing applies to Codex accounts only`);
305
+ }
306
+ const id = requestedId === "main" ? MAIN_ID : requestedId;
307
+ const baseUrl = await resolveBaseUrl(deps);
308
+ if (!baseUrl) return proxyUnreachable();
309
+ const response = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/accounts/clear-cooldown", { id });
310
+ if (response.status === 0) return proxyUnreachable();
311
+ if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`);
312
+ const cleared = response.json?.cleared === true;
313
+ if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, id, cleared }, null, 2));
314
+ else if (cleared) console.log(`${name}: cooldown lifted for ${requestedId}`);
315
+ else console.log(`${name}: no active cooldown for ${requestedId}`);
316
+ return 0;
317
+ }
318
+
285
319
  export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
286
320
  const wantsJson = flag(args, "--json");
287
321
  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, cmdRefresh, cmdRemove } from "./account-extended";
5
+ import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, 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
 
@@ -23,6 +23,7 @@ const ACCOUNT_USAGE = `Usage:
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
25
  ocx account remove <provider> <account-or-key-id|main> --yes [--json]
26
+ ocx account clear-cooldown <provider> <account-id|main> [--json]
26
27
  ocx account add-key <provider> [--label <label>] [--json]
27
28
 
28
29
  List and switch provider accounts and API-key pools (masked output only).
@@ -257,6 +258,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
257
258
  if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps);
258
259
  if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps);
259
260
  if (sub === "remove") return await cmdRemove(rest, deps);
261
+ if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
260
262
  if (sub === "add-key") return await cmdAddKey(rest, deps);
261
263
  console.error(ACCOUNT_USAGE);
262
264
  return 1;
@@ -0,0 +1,152 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
4
+ import {
5
+ DESKTOP_FAMILIES,
6
+ moveDesktopRoute,
7
+ parseDesktopProfile,
8
+ setDesktopFamilyDefault,
9
+ type DesktopFamily,
10
+ type DesktopProfile,
11
+ } from "../claude/desktop-profile";
12
+ import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p";
13
+ import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
14
+ import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api";
15
+ import { findLiveProxy } from "../server/proxy-liveness";
16
+
17
+ function isFamily(value: string | undefined): value is DesktopFamily {
18
+ return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value);
19
+ }
20
+
21
+ function printDesktopHelp(): void {
22
+ console.log(`Usage:
23
+ ocx claude desktop [apply] [--static|--hybrid|--discovery-only]
24
+ ocx claude desktop show [--json]
25
+ ocx claude desktop move <provider/model> <opus|fable|sonnet|haiku> [--default]
26
+ ocx claude desktop default <family> <provider/model|none>
27
+ ocx claude desktop export <path|->
28
+ ocx claude desktop import <path> [--apply]`);
29
+ }
30
+
31
+ async function applyProfile(profile: DesktopProfile, mode: Desktop3pConfigMode): Promise<{ ok: boolean; path: string; reason?: string }> {
32
+ const config = loadConfig();
33
+ const state = await buildClaudeDesktopState(config, profile);
34
+ config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
35
+ saveConfigPreservingClaudeCode(config);
36
+ const live = await findLiveProxy();
37
+ const allModels = await fetchAllModels(config);
38
+ const routed = filterCatalogVisibleModels(allModels, config).map(model => ({
39
+ provider: model.provider,
40
+ id: model.id,
41
+ contextWindow: model.contextWindow,
42
+ }));
43
+ const result = writeDesktop3pConfig(
44
+ live?.port ?? config.port ?? 10100,
45
+ [...visibleNativeSlugs(config)],
46
+ routed,
47
+ config.apiKeys?.[0]?.key,
48
+ mode,
49
+ state.profile,
50
+ );
51
+ return { ok: result.written, path: result.path, reason: result.reason };
52
+ }
53
+
54
+ export async function handleClaudeDesktopCommand(argv: string[]): Promise<number> {
55
+ const command = argv[0];
56
+ if (command === "help" || command === "--help" || command === "-h") {
57
+ printDesktopHelp();
58
+ return 0;
59
+ }
60
+
61
+ // Legacy mode flags remain apply aliases and are parsed before subcommands.
62
+ const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg));
63
+ const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0;
64
+ if (applyInvocation) {
65
+ const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg));
66
+ if (nonMode.length > 0) {
67
+ console.error(`์•Œ ์ˆ˜ ์—†๋Š” ์ธ์ž: ${nonMode.join(" ")}`);
68
+ return 1;
69
+ }
70
+ const parsedMode = parseDesktop3pModeArgs(legacyFlags);
71
+ if ("error" in parsedMode) { console.error(parsedMode.error); return 1; }
72
+ try {
73
+ const config = loadConfig();
74
+ const state = await buildClaudeDesktopState(config);
75
+ const result = await applyProfile(state.profile, parsedMode.mode);
76
+ if (!result.ok) { console.error(`์„ค์ • ์ ์šฉ ์‹คํŒจ: ${result.reason ?? "unknown error"}`); return 1; }
77
+ console.log(`Claude Desktop ์„ค์ •์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค: ${result.path}`);
78
+ console.log("Claude Desktop์„ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ๋’ค ๋‹ค์‹œ ์—ด์–ด ์ฃผ์„ธ์š”.");
79
+ return 0;
80
+ } catch (error) {
81
+ console.error(error instanceof Error ? error.message : String(error));
82
+ return 1;
83
+ }
84
+ }
85
+
86
+ try {
87
+ const config = loadConfig();
88
+ const state = await buildClaudeDesktopState(config);
89
+ if (command === "show") {
90
+ if (argv.length > 2 || (argv[1] && argv[1] !== "--json")) throw new Error("Usage: ocx claude desktop show [--json]");
91
+ if (argv[1] === "--json") console.log(JSON.stringify(state));
92
+ else {
93
+ for (const family of DESKTOP_FAMILIES) {
94
+ console.log(`${family.toUpperCase()}${state.profile.defaults[family] ? ` (default: ${state.profile.defaults[family]})` : ""}`);
95
+ for (const model of state.models.filter(item => item.assignment.family === family)) {
96
+ console.log(` ${model.available ? "โ€ข" : "โ—‹"} ${model.route} -> ${model.assignment.alias}${model.available ? "" : " (unavailable)"}`);
97
+ }
98
+ }
99
+ }
100
+ return 0;
101
+ }
102
+ if (command === "move") {
103
+ const [, route, familyRaw, ...flags] = argv;
104
+ if (!route || !isFamily(familyRaw) || flags.some(flag => flag !== "--default")) throw new Error("Usage: ocx claude desktop move <route> <family> [--default]");
105
+ if (!state.models.some(model => model.route === route && model.available)) throw new Error(`ํ˜„์žฌ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค: ${route}`);
106
+ const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default"));
107
+ config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
108
+ saveConfigPreservingClaudeCode(config);
109
+ console.log(`${route} ๋ชจ๋ธ์„ ${familyRaw} ๊ทธ๋ฃน์œผ๋กœ ์˜ฎ๊ฒผ์Šต๋‹ˆ๋‹ค.`);
110
+ return 0;
111
+ }
112
+ if (command === "default") {
113
+ const [, familyRaw, routeRaw] = argv;
114
+ if (!isFamily(familyRaw) || !routeRaw || argv.length !== 3) throw new Error("Usage: ocx claude desktop default <family> <route|none>");
115
+ const route = routeRaw === "none" ? null : routeRaw;
116
+ if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`ํ˜„์žฌ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค: ${route}`);
117
+ const profile = setDesktopFamilyDefault(state.profile, familyRaw, route);
118
+ config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
119
+ saveConfigPreservingClaudeCode(config);
120
+ console.log(`${familyRaw} ๊ธฐ๋ณธ ๋ชจ๋ธ์„ ${route ?? "์—†์Œ"}์œผ๋กœ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.`);
121
+ return 0;
122
+ }
123
+ if (command === "export") {
124
+ const target = argv[1];
125
+ if (!target || argv.length !== 2) throw new Error("Usage: ocx claude desktop export <path|->");
126
+ const json = JSON.stringify(state.profile, null, 2) + "\n";
127
+ if (target === "-") process.stdout.write(json);
128
+ else writeFileSync(resolve(target), json, { encoding: "utf8", mode: 0o600 });
129
+ return 0;
130
+ }
131
+ if (command === "import") {
132
+ const source = argv[1];
133
+ const flags = argv.slice(2);
134
+ if (!source || flags.some(flag => flag !== "--apply")) throw new Error("Usage: ocx claude desktop import <path> [--apply]");
135
+ const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8")));
136
+ const reconciled = (await buildClaudeDesktopState(config, profile)).profile;
137
+ config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled };
138
+ saveConfigPreservingClaudeCode(config);
139
+ if (flags.includes("--apply")) {
140
+ const result = await applyProfile(reconciled, "static");
141
+ if (!result.ok) { console.error(`ํ”„๋กœํ•„์€ ์ €์žฅํ–ˆ์ง€๋งŒ Desktop ์ ์šฉ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ${result.reason ?? "unknown error"}`); return 1; }
142
+ }
143
+ console.log("Claude Desktop ํ”„๋กœํ•„์„ ๊ฐ€์ ธ์™”์Šต๋‹ˆ๋‹ค.");
144
+ return 0;
145
+ }
146
+ printDesktopHelp();
147
+ return 1;
148
+ } catch (error) {
149
+ console.error(error instanceof Error ? error.message : String(error));
150
+ return 1;
151
+ }
152
+ }
package/src/cli/claude.ts CHANGED
@@ -14,19 +14,39 @@ import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
14
14
  import { commandInvocation } from "../lib/win-exec";
15
15
  import { findLiveProxy } from "../server/proxy-liveness";
16
16
  import type { OcxConfig } from "../types";
17
+ import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
18
+ import { resolveClaudeAuthMode } from "../claude/auth-mode";
17
19
 
18
20
  export interface ClaudeLaunchEnv {
19
21
  [key: string]: string | undefined;
20
22
  }
21
23
 
24
+ /**
25
+ * Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
26
+ * launch base so detection and the spawned process can never disagree (audit R3-3).
27
+ */
28
+ export type ClaudeEnvDeps = { authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens"> };
29
+
22
30
  /**
23
31
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
24
32
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
25
33
  * overrides variables the user already exported, apart from stale loopback
26
34
  * ANTHROPIC_BASE_URL values owned by a previous opencodex launch.
27
35
  */
28
- export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaunchEnv, contextWindows: Record<string, number> = {}): ClaudeLaunchEnv {
36
+ export function buildClaudeEnv(
37
+ config: OcxConfig,
38
+ port: number,
39
+ base: ClaudeLaunchEnv,
40
+ contextWindows: Record<string, number> = {},
41
+ deps: ClaudeEnvDeps = {},
42
+ ): ClaudeLaunchEnv {
29
43
  const env: ClaudeLaunchEnv = { ...base };
44
+ // Step 1 โ€” strip OUR OWN dummy from the inherited environment before anything reads
45
+ // or writes the token slot. setDefault below preserves any non-empty value, so a
46
+ // stale marker left in place would suppress the admission key and then be removed,
47
+ // leaving the child with no token at all (audit R2-1). It is opencodex state, never
48
+ // user auth, so dropping it unconditionally is safe.
49
+ if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
30
50
  const setDefault = (name: string, value: string | undefined) => {
31
51
  if (value === undefined || value.length === 0) return;
32
52
  if (env[name] !== undefined && env[name] !== "") return; // user wins
@@ -55,8 +75,22 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
55
75
  if ((config.apiKeys?.length ?? 0) > 0) {
56
76
  setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
57
77
  }
58
- if (!env.ANTHROPIC_AUTH_TOKEN && config.claudeCode?.authMode === "proxy") {
59
- env.ANTHROPIC_AUTH_TOKEN = "opencodex-proxy";
78
+ // Detection reads the SAME base env this launch will use, so the resolver and the
79
+ // spawned process cannot disagree. Injected deps are spread FIRST and `env` bound
80
+ // LAST, and the injection type excludes `env`, so a test fake cannot break that.
81
+ // `ownTokens` is bound last for the same reason: it is config-derived, and a fake
82
+ // that replaced it could make our own admission key look like user auth.
83
+ const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
84
+ ...defaultAuthDetectDeps(base as NodeJS.ProcessEnv),
85
+ ...(deps.authDetect ?? {}),
86
+ env: () => base as NodeJS.ProcessEnv,
87
+ ownTokens: ownAdmissionTokens(config),
88
+ }));
89
+ if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
90
+ env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
91
+ }
92
+ if (resolved.origin === "auto-unknown") {
93
+ console.error("โš  Claude ์ธ์ฆ์„ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค โ€” ๊ตฌ๋… ๋ฐฉ์‹์œผ๋กœ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. GUI์—์„œ ์ธ์ฆ ๋ชจ๋“œ๋ฅผ ์ง์ ‘ ์ง€์ •ํ•˜๋ฉด ์ด ํŒ๋‹จ์„ ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.");
60
94
  }
61
95
  // NOTE: do NOT set _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL here. While it enables
62
96
  // Design/Remote Control, it DISABLES gateway model discovery (Claude Code's eligibility