@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,53 @@
1
+ import { formatErrorResponse } from "../../bridge";
2
+ import { parseRetryAfterMs } from "../../combos";
3
+
4
+ function sanitizedRetryAfter(value: string | null | undefined, now: number): string | undefined {
5
+ const trimmed = value?.trim();
6
+ if (!trimmed || trimmed.length > 128) return undefined;
7
+ return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
8
+ }
9
+
10
+ /**
11
+ * Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
12
+ * Codex maps an *empty* body to the literal client string "Unknown error"
13
+ * (UnexpectedResponseError) — issue #452. Only empty bodies need wrapping.
14
+ *
15
+ * Non-empty bodies (including ChatGPT `{detail: ...}` account-model 400s and
16
+ * HTML/text errors) must keep their original bytes and headers so pool-retry
17
+ * activation and client diagnostics stay honest.
18
+ *
19
+ * Normalized (empty-body) responses force `Content-Type: application/json` and
20
+ * preserve a validated `Retry-After` so Responses clients and the chat-completions
21
+ * / Claude bridges that copy that header keep correct backoff.
22
+ */
23
+ export function formatPassthroughUpstreamError(
24
+ status: number,
25
+ bodyText: string,
26
+ options?: {
27
+ statusText?: string;
28
+ headers?: Headers;
29
+ now?: number;
30
+ },
31
+ ): Response {
32
+ const trimmed = bodyText.trim();
33
+ const now = options?.now ?? Date.now();
34
+ const retryAfter = sanitizedRetryAfter(options?.headers?.get("retry-after"), now);
35
+
36
+ if (trimmed) {
37
+ return new Response(bodyText, {
38
+ status,
39
+ ...(options?.statusText ? { statusText: options.statusText } : {}),
40
+ ...(options?.headers ? { headers: options.headers } : { headers: { "Content-Type": "application/json" } }),
41
+ });
42
+ }
43
+
44
+ const response = formatErrorResponse(
45
+ status,
46
+ "upstream_error",
47
+ `Provider error ${status}: (empty body)`,
48
+ );
49
+ const headers = new Headers(response.headers);
50
+ headers.set("Content-Type", "application/json");
51
+ if (retryAfter !== undefined) headers.set("Retry-After", retryAfter);
52
+ return new Response(response.body, { status: response.status, headers });
53
+ }
@@ -11,6 +11,7 @@
11
11
  import { formatErrorResponse } from "../bridge";
12
12
  import {
13
13
  CodexAccountCooldownError,
14
+ cooldownErrorResponse,
14
15
  CodexAuthContextError,
15
16
  CodexPoolAuthenticationError,
16
17
  CodexThreadAffinityExpiredError,
@@ -77,7 +78,7 @@ export async function handleSearch(
77
78
  logCtx.provider = formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
78
79
  } catch (err) {
79
80
  if (err instanceof CodexAccountCooldownError) {
80
- return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
81
+ return cooldownErrorResponse(err);
81
82
  }
82
83
  if (err instanceof CodexThreadAffinityExpiredError) {
83
84
  return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
@@ -3,8 +3,25 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
5
  import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
+ import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
7
+ import { resolveClaudeAuthMode } from "../claude/auth-mode";
6
8
  import type { OcxConfig } from "../types";
7
9
 
10
+ /**
11
+ * Does the opencodex dummy marker belong in the system environment?
12
+ *
13
+ * Keyed on the SAME resolver `ocx claude` uses, so an auto config with no Claude auth
14
+ * also reaches plain `claude` launches — before this, auto-absent users got nothing
15
+ * from auto-connect and the feature looked broken for exactly the people it helps
16
+ * (devlog 260726_claude_auth_auto/035).
17
+ *
18
+ * NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx
19
+ * ensure`, or a settings save). `ocx claude` re-resolves live on every launch.
20
+ */
21
+ function systemEnvMarkerMode(config: OcxConfig): "proxy" | "subscription" {
22
+ return resolveClaudeAuthMode(config, detectClaudeAuth(defaultAuthDetectDeps(process.env, ownAdmissionTokens(config)))).markerMode;
23
+ }
24
+
8
25
  // ---------------------------------------------------------------------------
9
26
  // Shell-hook env file: written on inject, sourced by the shell hook in .zshrc.
10
27
  // This works for ALL new shells immediately, unlike launchctl setenv which only
@@ -31,8 +48,8 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
31
48
  `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
32
49
  if (config.apiKeys?.length) {
33
50
  lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
34
- } else if (config.claudeCode?.authMode === "proxy") {
35
- lines.push(conditional("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy"));
51
+ } else if (systemEnvMarkerMode(config) === "proxy") {
52
+ lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
36
53
  }
37
54
  // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
38
55
  if (modelEnv.ANTHROPIC_MODEL) {
@@ -240,11 +257,11 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
240
257
  inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
241
258
  if (config.apiKeys?.length) {
242
259
  inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
243
- } else if (config.claudeCode?.authMode === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
244
- inject("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy");
245
- } else if (config.claudeCode?.authMode !== "proxy"
260
+ } else if (systemEnvMarkerMode(config) === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
261
+ inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
262
+ } else if (systemEnvMarkerMode(config) !== "proxy"
246
263
  && injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")
247
- && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === "opencodex-proxy") {
264
+ && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === PROXY_MARKER) {
248
265
  // Subscription switch-back (devlog 260720_claude_authmode_persist): remove ONLY
249
266
  // the opencodex-owned dummy token so a launchd-started Claude regains its own
250
267
  // claude.ai OAuth. User-set tokens (not tracked in injectedKeys, or carrying a
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Soft SIGTERM-before-/F is NOT available on Windows (process.kill is TerminateProcess).
3
+ * Graceful drain is stopProxyGracefully(); this module only resets leftover TCBs after hard kill.
4
+ */
5
+ import { dlopen, ptr, type Pointer } from "bun:ffi";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ export type TcpQuad = {
9
+ localAddr: string;
10
+ localPort: number;
11
+ remoteAddr: string;
12
+ remotePort: number;
13
+ state: string;
14
+ };
15
+
16
+ /** Parse netstat -ano rows whose local address uses `port`. Exported for tests. */
17
+ export function parseTcpQuadsForLocalPort(output: string, port: number): TcpQuad[] {
18
+ const rows: TcpQuad[] = [];
19
+ const portSuffix = `:${port}`;
20
+ for (const rawLine of output.split(/\r?\n/)) {
21
+ const line = rawLine.trim();
22
+ if (!/^TCP\b/i.test(line)) continue;
23
+ const parts = line.split(/\s+/);
24
+ if (parts.length < 4) continue;
25
+ const local = parts[1]!;
26
+ const remote = parts[2]!;
27
+ const state = parts[3]!;
28
+ if (!local.endsWith(portSuffix) && !local.endsWith(`]:${port}`)) continue;
29
+ const localAddr = stripPort(local, port);
30
+ const remoteParsed = splitHostPort(remote);
31
+ if (!localAddr || !remoteParsed) continue;
32
+ rows.push({
33
+ localAddr,
34
+ localPort: port,
35
+ remoteAddr: remoteParsed.host,
36
+ remotePort: remoteParsed.port,
37
+ state,
38
+ });
39
+ }
40
+ return rows;
41
+ }
42
+
43
+ function stripPort(addr: string, port: number): string | null {
44
+ const suffix = `:${port}`;
45
+ if (addr.endsWith(suffix)) return addr.slice(0, -suffix.length).replace(/^\[|\]$/g, "") || "0.0.0.0";
46
+ return null;
47
+ }
48
+
49
+ function splitHostPort(addr: string): { host: string; port: number } | null {
50
+ if (addr === "0.0.0.0:0" || addr === "*:*" || addr === "[::]:0") {
51
+ return { host: "0.0.0.0", port: 0 };
52
+ }
53
+ const m = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(addr);
54
+ if (!m) return null;
55
+ return { host: (m[1] ?? m[2] ?? "0.0.0.0").replace(/^::ffff:/i, ""), port: Number(m[3]) };
56
+ }
57
+
58
+ function ipv4ToWinUint32(addr: string): number | null {
59
+ const host = addr.replace(/^::ffff:/i, "");
60
+ // Refuse bare IPv6 — SetTcpEntry is IPv4-only; coercing "::"/"::1" to 0 would
61
+ // miss the real TCB and can hit an unrelated IPv4 wildcard row.
62
+ if (isBareIpv6Address(host)) return null;
63
+ if (host === "0.0.0.0" || host === "*") return 0;
64
+ const parts = host.split(".").map(Number);
65
+ if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
66
+ return (parts[0]! | (parts[1]! << 8) | (parts[2]! << 16) | (parts[3]! << 24)) >>> 0;
67
+ }
68
+
69
+ /** True for bare IPv6 (including :: / ::1), false for dotted IPv4 and IPv4-mapped. */
70
+ export function isBareIpv6Address(addr: string): boolean {
71
+ const host = String(addr || "").replace(/^\[|\]$/g, "").replace(/^::ffff:/i, "");
72
+ if (!host) return false;
73
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return false;
74
+ return host.includes(":");
75
+ }
76
+
77
+ export type WindowsTcpDropResult = {
78
+ /** Successful SetTcpEntry(DELETE_TCB) calls for IPv4 rows. */
79
+ dropped: number;
80
+ /** IPv6 (or unparseable) rows skipped — never coerced into IPv4 wildcards. */
81
+ skippedIpv6: number;
82
+ };
83
+
84
+ function htons(port: number): number {
85
+ return (((port & 0xff) << 8) | ((port >> 8) & 0xff)) >>> 0;
86
+ }
87
+
88
+ type SetTcpEntryFn = (row: Pointer) => number;
89
+ let setTcpEntryFn: SetTcpEntryFn | null | undefined;
90
+
91
+ function loadSetTcpEntry(): SetTcpEntryFn | null {
92
+ if (setTcpEntryFn !== undefined) return setTcpEntryFn;
93
+ if (process.platform !== "win32") {
94
+ setTcpEntryFn = null;
95
+ return null;
96
+ }
97
+ try {
98
+ const lib = dlopen("iphlpapi.dll", {
99
+ SetTcpEntry: { args: ["ptr"], returns: "u32" },
100
+ });
101
+ setTcpEntryFn = (row: Pointer) => lib.symbols.SetTcpEntry(row) as number;
102
+ } catch {
103
+ setTcpEntryFn = null;
104
+ }
105
+ return setTcpEntryFn;
106
+ }
107
+
108
+ function readNetstatAno(): string {
109
+ const netstat = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\netstat.exe`;
110
+ const cmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
111
+ try {
112
+ return execFileSync(cmd, ["/d", "/c", `chcp 437>nul & "${netstat}" -ano -p tcp`], {
113
+ encoding: "utf-8",
114
+ stdio: ["ignore", "pipe", "ignore"],
115
+ timeout: 5000,
116
+ windowsHide: true,
117
+ });
118
+ } catch {
119
+ return execFileSync(netstat, ["-ano", "-p", "tcp"], {
120
+ encoding: "utf-8",
121
+ stdio: ["ignore", "pipe", "ignore"],
122
+ timeout: 4000,
123
+ windowsHide: true,
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Force-delete IPv4 TCP rows bound to `localPort` via SetTcpEntry(DELETE_TCB).
130
+ * Does not kill foreign processes — only resets sockets so the listen port can bind again.
131
+ * Bare IPv6 rows (`::1`, `::`, etc.) are skipped (not coerced into IPv4 wildcards);
132
+ * IPv6 TCB reclamation is unsupported on this path.
133
+ */
134
+ export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResult {
135
+ if (process.platform !== "win32" || !Number.isFinite(port) || port <= 0) {
136
+ return { dropped: 0, skippedIpv6: 0 };
137
+ }
138
+ const setTcpEntry = loadSetTcpEntry();
139
+ if (!setTcpEntry) return { dropped: 0, skippedIpv6: 0 };
140
+
141
+ let output = "";
142
+ try {
143
+ output = readNetstatAno();
144
+ } catch {
145
+ return { dropped: 0, skippedIpv6: 0 };
146
+ }
147
+
148
+ const rows = parseTcpQuadsForLocalPort(output, Math.trunc(port));
149
+ let dropped = 0;
150
+ let skippedIpv6 = 0;
151
+ for (const row of rows) {
152
+ const localDw = ipv4ToWinUint32(row.localAddr);
153
+ const remoteDw = ipv4ToWinUint32(row.remoteAddr);
154
+ if (localDw === null || remoteDw === null) {
155
+ if (isBareIpv6Address(row.localAddr) || isBareIpv6Address(row.remoteAddr)) {
156
+ skippedIpv6 += 1;
157
+ }
158
+ continue;
159
+ }
160
+ const buf = new ArrayBuffer(20);
161
+ const view = new DataView(buf);
162
+ view.setUint32(0, 12, true); // MIB_TCP_STATE_DELETE_TCB
163
+ view.setUint32(4, localDw, true);
164
+ view.setUint32(8, htons(row.localPort), true);
165
+ view.setUint32(12, remoteDw, true);
166
+ view.setUint32(16, htons(row.remotePort), true);
167
+ try {
168
+ if (setTcpEntry(ptr(buf)) === 0) dropped += 1;
169
+ } catch {
170
+ /* keep going */
171
+ }
172
+ }
173
+ return { dropped, skippedIpv6 };
174
+ }
package/src/service.ts CHANGED
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path";
12
12
  import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13
13
  import { loadConfig } from "./config";
14
14
  import { restoreNativeCodex } from "./codex/inject";
15
+ import { stripGrokConfig } from "./grok/inject";
15
16
  import { isWslRuntime } from "./codex/home";
16
17
  import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
17
18
  import { isProcessAlive, stopProxy } from "./lib/process-control";
@@ -160,13 +161,42 @@ export function serviceReinstallArgs(): string[] {
160
161
  return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
161
162
  }
162
163
 
164
+ /**
165
+ * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
166
+ * touch it. Distinct from "stop failed": the manager was never even contacted, which means the
167
+ * installed service is still live and shared state (native Codex config, the Grok fence) must be
168
+ * left alone — tearing it down would strip config out from under a running service.
169
+ */
170
+ export class ServiceOwnershipError extends Error {
171
+ readonly code = "service-ownership-mismatch" as const;
172
+ }
173
+
174
+ export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
175
+ return err instanceof ServiceOwnershipError;
176
+ }
177
+
178
+ /**
179
+ * True when no installed service exists, or the installed one belongs to THIS
180
+ * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
181
+ * (native Codex config, the Grok fence) that a foreign service would still be relying on.
182
+ */
183
+ export function serviceEnvironmentOwnedHere(): boolean {
184
+ try {
185
+ assertServiceEnvironmentMatchesInstall();
186
+ return true;
187
+ } catch (err) {
188
+ if (isServiceOwnershipError(err)) return false;
189
+ return true; // unrelated failure: fall back to the previous behavior rather than wedging
190
+ }
191
+ }
192
+
163
193
  export function assertServiceEnvironmentMatchesInstall(): void {
164
194
  const state = readServiceInstallState();
165
195
  if (!state) return;
166
196
  const expected = normalizePathForCompare(state.codexHome);
167
197
  const actual = normalizePathForCompare(currentCodexHome());
168
198
  if (expected !== actual) {
169
- throw new Error(
199
+ throw new ServiceOwnershipError(
170
200
  `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
171
201
  "Run the service command from the same Codex home so native Codex restore updates the correct config.",
172
202
  );
@@ -174,7 +204,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
174
204
  const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
175
205
  const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
176
206
  if (expectedOpenCodexHome !== actualOpenCodexHome) {
177
- throw new Error(
207
+ throw new ServiceOwnershipError(
178
208
  `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
179
209
  "Run the service command from the same OpenCodex home so service state and secrets match.",
180
210
  );
@@ -1148,12 +1178,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1148
1178
  break;
1149
1179
  case "stop":
1150
1180
  assertServiceEnvironmentMatchesInstall();
1151
- ops.stop();
1181
+ // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
1182
+ // (and its Windows/Linux twins) even with nothing installed.
1183
+ if (ops.status() !== null || isServiceInstalled()) ops.stop();
1152
1184
  await stopTrackedProxyForServiceCommand();
1153
1185
  {
1154
1186
  const restore = restoreNativeCodex();
1155
1187
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
1156
1188
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
1189
+ // The Grok fence is the other managed config this command owns. Leaving it behind
1190
+ // pointed grok at a dead endpoint while native Codex was already restored.
1191
+ const grok = stripGrokConfig();
1192
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
1193
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
1157
1194
  }
1158
1195
  break;
1159
1196
  case "status": {
@@ -1181,6 +1218,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1181
1218
  if (!restore.success) {
1182
1219
  console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
1183
1220
  }
1221
+ const grok = stripGrokConfig();
1222
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
1223
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
1184
1224
  }
1185
1225
  removeServiceInstallState();
1186
1226
  try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
package/src/types.ts CHANGED
@@ -308,6 +308,12 @@ export interface OcxUrlCitation {
308
308
  export interface OcxUsage {
309
309
  inputTokens: number;
310
310
  outputTokens: number;
311
+ /**
312
+ * Absolute active-context size after the response. Stateful providers can expose this separately
313
+ * from their per-attempt usage. Responses serialization derives the input side from
314
+ * `contextTotalTokens - outputTokens` so output is never added to an absolute checkpoint twice.
315
+ */
316
+ contextTotalTokens?: number;
311
317
  totalTokens?: number;
312
318
  cachedInputTokens?: number;
313
319
  cacheReadInputTokens?: number;
@@ -356,11 +362,26 @@ export interface OcxClaudeCodeConfig {
356
362
  */
357
363
  systemEnv?: boolean;
358
364
  /**
359
- * Auth mode for Claude Code inbound requests. "proxy" injects a dummy
360
- * ANTHROPIC_AUTH_TOKEN so Claude Code routes through the proxy without a
361
- * real Anthropic key. Default: undefined (no token injection).
365
+ * Auth mode for Claude Code inbound requests a THREE-state intent.
366
+ *
367
+ * "proxy": inject the dummy ANTHROPIC_AUTH_TOKEN so Claude Code routes through the
368
+ * proxy without a real Anthropic key. "subscription": never inject it. UNSET means
369
+ * AUTO: the mode is resolved from detected Claude auth on every launch and every
370
+ * status read (src/claude/auth-mode.ts), so registering a Claude login switches the
371
+ * behaviour with no migration and no stored state.
372
+ *
373
+ * An explicit value always wins over detection and is never rewritten by the auto
374
+ * logic — that is what makes a manual choice stick (devlog 260726_claude_auth_auto).
375
+ */
376
+ authMode?: "proxy" | "subscription";
377
+ /**
378
+ * ISO timestamp of the one-time authMode migration. Before auto existed, choosing
379
+ * "Subscription" DELETED the key, so a pre-upgrade config cannot distinguish an
380
+ * explicit subscription choice from "never chose". Its ABSENCE identifies a
381
+ * pre-upgrade block; the migration writes it once and never re-runs, so a user who
382
+ * later picks Auto (which deletes authMode) is not silently converted back.
362
383
  */
363
- authMode?: "proxy";
384
+ authModeMigratedAt?: string;
364
385
  /**
365
386
  * Context-window override for Claude Code/Desktop clients (devlog 136 B6):
366
387
  * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
@@ -415,6 +436,27 @@ export interface OcxClaudeCodeConfig {
415
436
  webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
416
437
  /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
417
438
  visionSidecar?: { backend?: "openai" | "anthropic"; model?: string };
439
+ /** Persisted Claude Desktop four-family routing profile. */
440
+ desktopProfile?: OcxClaudeDesktopProfile;
441
+ /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */
442
+ desktopAutoApply?: boolean;
443
+ }
444
+
445
+ export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku";
446
+
447
+ export interface OcxClaudeDesktopAssignment {
448
+ family: OcxClaudeDesktopFamily;
449
+ alias: string;
450
+ }
451
+
452
+ export interface OcxClaudeDesktopProfile {
453
+ version: 1;
454
+ assignments: Record<string, OcxClaudeDesktopAssignment>;
455
+ defaults: Record<OcxClaudeDesktopFamily, string | null>;
456
+ /** SHA-256 fingerprint of the last successfully applied 3P config content. */
457
+ appliedFingerprint?: string;
458
+ /** ISO timestamp of the last successful apply. */
459
+ appliedAt?: string;
418
460
  }
419
461
 
420
462
  /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */
@@ -448,6 +490,16 @@ export interface OcxConfig {
448
490
  * Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear.
449
491
  */
450
492
  subagentModels?: string[];
493
+ /**
494
+ * Priority-ordered fallback models for spawned sub-agents. When the requested
495
+ * model is quota-exhausted or recently failed, opencodex rewrites the child
496
+ * turn to the next available entry before routing.
497
+ */
498
+ subagentModelFallback?: string[];
499
+ /**
500
+ * TTL (ms) for cached sub-agent model availability probes. Default 60_000.
501
+ */
502
+ subagentModelFallbackPollMs?: number;
451
503
  injectionModel?: string;
452
504
  /**
453
505
  * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls
@@ -455,6 +507,16 @@ export interface OcxConfig {
455
507
  * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
456
508
  */
457
509
  injectionEffort?: string;
510
+ /**
511
+ * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty
512
+ * means "everything visible", which is the historical behaviour — so an existing
513
+ * config keeps the fence it already had.
514
+ *
515
+ * Exclusion list rather than an inclusion list on purpose: a newly added provider
516
+ * model should appear in Grok by default, exactly as it does today. An inclusion list
517
+ * would silently hide every future model behind a switch nobody knew to flip.
518
+ */
519
+ grokExcludedModels?: string[];
458
520
  /**
459
521
  * When true, OpenAI-routed requests include `service_tier: "priority"` (fast inference).
460
522
  * When false, service_tier is stripped so requests use default speed.
@@ -475,7 +537,8 @@ export interface OcxConfig {
475
537
  * <multi_agent_mode> tags). When set, it replaces the built-in prompt on whichever
476
538
  * collab surface would have fired; firing gates are unchanged. Placeholders:
477
539
  * `{{model}}` -> injectionModel, `{{effort}}` -> injectionEffort, `{{roster}}` ->
478
- * the resolved sub-agent roster block ("" when nothing resolves).
540
+ * the resolved sub-agent roster block ("" when nothing resolves), `{{fallback}}` ->
541
+ * the configured subagent model fallback guidance block ("" when unset).
479
542
  */
480
543
  injectionPrompt?: string;
481
544
  /**
@@ -875,7 +938,12 @@ export interface OcxProviderConfig {
875
938
  /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
876
939
  preserveReasoningContentModels?: string[];
877
940
  /**
878
- * Model ids whose reasoning is a vendor `thinking: {type: enabled|disabled}` toggle on the
941
+ * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns
942
+ * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content.
943
+ */
944
+ reasoningSplitModels?: string[];
945
+ /**
946
+ * Model ids whose reasoning is a vendor `thinking: {type}` toggle on the
879
947
  * chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder.
880
948
  * The openai-chat adapter translates the mapped effort into the thinking toggle for these.
881
949
  */
@@ -204,6 +204,7 @@ export async function runUpdate(): Promise<void> {
204
204
  const capturedListen = {
205
205
  port: runtimeTrusted ? preUpdateRt.port : configPort,
206
206
  hostname: (runtimeTrusted ? preUpdateRt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
207
+ ...(runtimeTrusted && livePid ? { oldPid: livePid } : {}),
207
208
  };
208
209
 
209
210
  // Never replace package files under a live proxy: the running server dynamic-imports
@@ -281,12 +282,17 @@ export async function runUpdate(): Promise<void> {
281
282
  if (serviceWasInstalled) {
282
283
  console.log("🔁 Reinstalling the background service with the updated files...");
283
284
  const { serviceReinstallArgs } = await import("../service");
284
- const { waitForPortAvailable } = await import("../server/ports");
285
- const freed = await waitForPortAvailable(capturedListen.port, capturedListen.hostname, {
286
- timeoutMs: 5_000,
287
- intervalMs: 25,
285
+ const { reclaimListenPort } = await import("../server/port-reclaim");
286
+ const freed = await reclaimListenPort(capturedListen.port, capturedListen.hostname, {
287
+ timeoutMs: 30_000,
288
+ intervalMs: 100,
289
+ scanIntervalMs: 500,
290
+ killOcxHolders: capturedListen.oldPid != null,
291
+ onlyKillPids: capturedListen.oldPid != null ? [capturedListen.oldPid] : [],
288
292
  });
289
- if (!freed) console.warn(`⚠️ Port ${capturedListen.port} still busy; reinstalling with pinned --port anyway.`);
293
+ if (!freed) {
294
+ console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; reinstalling service with pinned --port ${capturedListen.port} anyway (refusing to hop).`);
295
+ }
290
296
  const prevBake = process.env.OCX_BAKE_PORT;
291
297
  process.env.OCX_BAKE_PORT = String(capturedListen.port);
292
298
  try {
@@ -301,26 +307,31 @@ export async function runUpdate(): Promise<void> {
301
307
  // On Windows, schtasks /create requires elevation. The CLI inherits the
302
308
  // user's (non-admin) token, so the service reinstall can fail with access
303
309
  // denied. Fall back to a direct detached proxy start so the update never
304
- // leaves the user without a running proxy.
305
- console.warn("⚠️ Service refresh failed — starting the proxy directly instead.");
306
- console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
307
- const env = { ...process.env };
308
- delete env.OCX_SERVICE;
309
- const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
310
- detached: true,
311
- stdio: "ignore",
312
- windowsHide: true,
313
- env,
314
- });
315
- child.unref();
316
- console.log(`✅ Proxy starting on port ${capturedListen.port}.`);
310
+ // leaves the user without a running proxy — but only when the port is free.
311
+ if (!freed) {
312
+ console.warn("⚠️ Service refresh failed and the captured port is still busy; not starting on another port.");
313
+ console.warn(` Run 'ocx service install' as administrator, then 'ocx start --port ${capturedListen.port}'.`);
314
+ } else {
315
+ console.warn("⚠️ Service refresh failed starting the proxy directly instead.");
316
+ console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
317
+ const env = { ...process.env };
318
+ delete env.OCX_SERVICE;
319
+ const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
320
+ detached: true,
321
+ stdio: "ignore",
322
+ windowsHide: true,
323
+ env,
324
+ });
325
+ child.unref();
326
+ console.log(`✅ Proxy starting on port ${capturedListen.port}.`);
327
+ }
317
328
  }
318
329
  } finally {
319
330
  if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
320
331
  else process.env.OCX_BAKE_PORT = prevBake;
321
332
  }
322
333
  } else {
323
- console.log("Restart the proxy: ocx start");
334
+ console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`);
324
335
  }
325
336
  } else {
326
337
  if (trayWasRunning) {