@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -15,6 +15,7 @@ import {
15
15
  const USAGE = `Usage:
16
16
  ocx provider edit <name> [--adapter <id>] [--base-url <url>] [--default-model <id|->]
17
17
  [--auth-mode <key|forward|oauth|local|->] [--note <text|->]
18
+ [--api-key-transport <x-api-key|bearer|->]
18
19
  [--enabled <on|off>] [--live-models <on|off>] [--allow-private-network <on|off>] [--json]
19
20
  ocx provider test <name> [--json]
20
21
  ocx provider quota [--refresh] [--json]
@@ -37,6 +38,7 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
37
38
  const defaultModel = cleared(takeOption(args, "--default-model"));
38
39
  const authMode = cleared(takeOption(args, "--auth-mode"));
39
40
  const note = cleared(takeOption(args, "--note"));
41
+ const apiKeyTransport = cleared(takeOption(args, "--api-key-transport"));
40
42
  const enabled = takeBooleanOption(args, "--enabled");
41
43
  const liveModels = takeBooleanOption(args, "--live-models");
42
44
  const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network");
@@ -46,6 +48,7 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
46
48
  if (defaultModel !== undefined) patch.defaultModel = defaultModel;
47
49
  if (authMode !== undefined) patch.authMode = authMode;
48
50
  if (note !== undefined) patch.note = note;
51
+ if (apiKeyTransport !== undefined) patch.apiKeyTransport = apiKeyTransport;
49
52
  if (enabled !== undefined) patch.disabled = !enabled;
50
53
  if (liveModels !== undefined) patch.liveModels = liveModels;
51
54
  if (allowPrivateNetwork !== undefined) patch.allowPrivateNetwork = allowPrivateNetwork;
@@ -8,13 +8,14 @@
8
8
  * show <name> Show provider config details (secrets masked)
9
9
  * set-default <name> Change the default provider
10
10
  */
11
- import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
11
+ import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
12
12
  import { hasHelpFlag } from "./help";
13
13
  import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
14
14
  import { providerConfigSeed } from "../providers/derive";
15
15
  import type { OcxProviderConfig } from "../types";
16
16
  import { findLiveProxy } from "../server/proxy-liveness";
17
17
  import { syncModelsToCodex } from "../codex/sync";
18
+ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Arg helpers
@@ -124,7 +125,7 @@ function handleList(args: string[]): void {
124
125
  // provider add
125
126
  // ---------------------------------------------------------------------------
126
127
 
127
- const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--default-model <model>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
128
+ const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
128
129
 
129
130
  async function handleAdd(args: string[]): Promise<void> {
130
131
  const name = args[0];
@@ -138,13 +139,14 @@ async function handleAdd(args: string[]): Promise<void> {
138
139
  process.exit(1);
139
140
  }
140
141
 
141
- const restArgs = args.slice(1);
142
- const force = consumeFlag(restArgs, "--force");
143
- const setDefault = consumeFlag(restArgs, "--set-default");
144
- const wantsJson = consumeFlag(restArgs, "--json");
145
- const wantsSync = consumeFlag(restArgs, "--sync");
142
+ const restArgs = args.slice(1);
143
+ const force = consumeFlag(restArgs, "--force");
144
+ const setDefault = consumeFlag(restArgs, "--set-default");
145
+ const wantsJson = consumeFlag(restArgs, "--json");
146
+ const wantsSync = consumeFlag(restArgs, "--sync");
146
147
  const allowPrivateNetwork = consumeFlag(restArgs, "--allow-private-network");
147
- const apiKey = consumeFlagValue(restArgs, "--api-key");
148
+ const apiKey = consumeFlagValue(restArgs, "--api-key");
149
+ const apiKeyTransport = consumeFlagValue(restArgs, "--api-key-transport");
148
150
  const adapter = consumeFlagValue(restArgs, "--adapter");
149
151
  const baseUrl = consumeFlagValue(restArgs, "--base-url");
150
152
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
@@ -152,6 +154,12 @@ async function handleAdd(args: string[]): Promise<void> {
152
154
 
153
155
  const config = loadConfig();
154
156
 
157
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
158
+ if (namespaceCollision) {
159
+ console.error(`Error: ${namespaceCollision}.`);
160
+ process.exit(1);
161
+ }
162
+
155
163
  if (hasOwnProvider(config.providers, name) && !force) {
156
164
  console.error(`Provider "${name}" already exists. Use --force to overwrite.`);
157
165
  process.exit(1);
@@ -188,9 +196,22 @@ async function handleAdd(args: string[]): Promise<void> {
188
196
  };
189
197
  }
190
198
 
191
- config.providers[name] = provConfig;
199
+ if (apiKeyTransport !== undefined) {
200
+ if (apiKeyTransport !== "x-api-key" && apiKeyTransport !== "bearer") {
201
+ console.error('Error: --api-key-transport must be "x-api-key" or "bearer".');
202
+ process.exit(1);
203
+ }
204
+ const transportError = apiKeyTransportConfigError({ ...provConfig, apiKeyTransport });
205
+ if (transportError) {
206
+ console.error(`Error: ${transportError}.`);
207
+ process.exit(1);
208
+ }
209
+ provConfig.apiKeyTransport = apiKeyTransport;
210
+ }
211
+
212
+ config.providers[name] = provConfig;
192
213
  if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
193
- if (setDefault) config.defaultProvider = name;
214
+ if (setDefault) config.defaultProvider = name;
194
215
 
195
216
  validateAndSave(config);
196
217
 
@@ -1,8 +1,11 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
- import { createInterface } from "node:readline/promises";
5
4
  import { getConfigDir } from "../config";
5
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
+ import { commandInvocation } from "../lib/win-exec";
7
+ import { isAgentDriven } from "./agent-driven";
8
+ import { interactiveConfirm } from "./interactive-confirm";
6
9
 
7
10
  const REPO = "lidge-jun/opencodex";
8
11
  /** Fires exactly once from the first interactive `ocx start`. */
@@ -21,24 +24,74 @@ export function hasStarPromptRun(): boolean {
21
24
  }
22
25
  }
23
26
 
27
+ /**
28
+ * Whether `gh` is both installed and logged in. Starring goes through the
29
+ * user's own `gh` auth, so an unauthenticated CLI cannot fulfil a "Yes" — in
30
+ * that case the prompt stays silent instead of asking for something it would
31
+ * then fail to do.
32
+ */
33
+ /**
34
+ * On Windows `gh` is a `.cmd` shim; a shell-less spawn of the bare name skips
35
+ * PATHEXT and refuses `.cmd` targets, so it stalls until the timeout instead of
36
+ * failing fast. Route every call through the launcher the rest of the CLI uses.
37
+ */
38
+ /** Resolve `gh` once; callers keep their own spawnSync overload. */
39
+ function ghInvocation(args: string[]) {
40
+ const invocation = commandInvocation("gh", args);
41
+ return {
42
+ file: invocation.file,
43
+ args: invocation.args,
44
+ verbatim: invocation.options.windowsVerbatimArguments === true,
45
+ };
46
+ }
47
+
24
48
  function ghAvailable(): boolean {
25
- const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
26
- return !r.error && r.status === 0;
49
+ const v = ghInvocation(["--version"]);
50
+ const version = spawnSync(v.file, v.args,
51
+ { stdio: "ignore", timeout: 3000, windowsHide: true, windowsVerbatimArguments: v.verbatim });
52
+ if (version.error || version.status !== 0) return false;
53
+ const a = ghInvocation(["auth", "status"]);
54
+ const auth = spawnSync(a.file, a.args,
55
+ { stdio: "ignore", timeout: 5000, windowsHide: true, windowsVerbatimArguments: a.verbatim });
56
+ return !auth.error && auth.status === 0;
27
57
  }
28
58
 
29
59
  function starRepo(): { ok: boolean; error?: string } {
30
- const r = spawnSync("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`],
31
- { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true });
60
+ const star = ghInvocation(["api", "-X", "PUT", `/user/starred/${REPO}`]);
61
+ const r = spawnSync(star.file, star.args,
62
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true,
63
+ windowsVerbatimArguments: star.verbatim });
32
64
  if (r.error) return { ok: false, error: r.error.message };
33
65
  if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
34
66
  return { ok: true };
35
67
  }
36
68
 
37
69
  /**
38
- * First interactive `ocx start`: a one-time `[y/N]` "star on GitHub?" prompt.
39
- * On yes, stars the repo via the user's `gh` auth. No-op under the background
40
- * service, for non-TTY/piped runs, when already prompted, or when `gh` is
41
- * unavailable. Never throws.
70
+ * Shown instead of the prompt when an agent is driving `ocx start`. The agent
71
+ * reads this as its instruction: it must not answer, it must ask the person.
72
+ * Deliberately addressed to the agent rather than silently skipped, so the
73
+ * request reaches the user through the agent instead of disappearing.
74
+ */
75
+ function printAgentDeferral(): void {
76
+ console.log(
77
+ `\n \x1b[38;5;141m⭐ opencodex isn't starred on GitHub yet.\x1b[0m\n` +
78
+ ` \x1b[2mAgent: do not answer this yourself — it uses the user's GitHub identity.\n` +
79
+ ` Ask the user whether to star ${REPO}, and only run\n` +
80
+ ` \`gh api -X PUT /user/starred/${REPO}\` if they say yes.\x1b[0m\n`,
81
+ );
82
+ }
83
+
84
+ /**
85
+ * First interactive `ocx start`: a one-time "star on GitHub?" question with an
86
+ * explicit Yes/No selector (arrow keys, `y`/`n`, Enter), starring through the
87
+ * user's own `gh` login.
88
+ *
89
+ * The selector is only rendered when the account owner is there to answer it:
90
+ * it is skipped under the background service, for non-TTY/piped runs, and when
91
+ * `gh` is missing or logged out. When an agent is driving the process the
92
+ * question is not auto-answered — the agent is told to ask the user instead,
93
+ * and the one-time marker stays unwritten so a later hand-typed run can still
94
+ * show the real prompt. Never throws.
42
95
  */
43
96
  export async function maybeShowStarPrompt(): Promise<void> {
44
97
  try {
@@ -46,17 +99,25 @@ export async function maybeShowStarPrompt(): Promise<void> {
46
99
  const dir = getConfigDir();
47
100
  const marker = join(dir, MARKER);
48
101
  if (existsSync(marker)) return;
49
- if (!ghAvailable()) return; // can't star without gh — stay silent and re-check on a later start
50
- try { mkdirSync(dir, { recursive: true }); writeFileSync(marker, new Date().toISOString()); } catch { /* best-effort */ }
102
+ if (!ghAvailable()) return; // can't star without an authenticated gh — stay silent and re-check on a later start
51
103
 
52
- const rl = createInterface({ input: process.stdin, output: process.stdout });
53
- let yes = false;
54
- try {
55
- const ans = (await rl.question("\n \x1b[38;5;141m⭐ Enjoying opencodex? Star it on GitHub?\x1b[0m [y/N] ")).trim().toLowerCase();
56
- yes = ans === "y" || ans === "yes";
57
- } finally {
58
- rl.close();
104
+ // An agent would answer this on the user's behalf, using the user's GitHub
105
+ // identity. Hand the question to the agent to relay, and leave the marker
106
+ // unwritten so the user still gets the real prompt on their own run.
107
+ if (isAgentDriven()) {
108
+ printAgentDeferral();
109
+ return;
59
110
  }
111
+ try {
112
+ recordOwnedConfigPath(dir, marker);
113
+ mkdirSync(dir, { recursive: true });
114
+ writeFileSync(marker, new Date().toISOString());
115
+ } catch { /* best-effort */ }
116
+
117
+ const yes = await interactiveConfirm({
118
+ question: "\n \x1b[38;5;141m⭐ Enjoying opencodex? Star it on GitHub (via gh)?\x1b[0m",
119
+ defaultYes: true,
120
+ });
60
121
  if (!yes) return;
61
122
  const r = starRepo();
62
123
  console.log(r.ok ? " Thanks for the star! ⭐\n" : ` Couldn't star automatically (${r.error}) — ${REPO}\n`);
package/src/cli/status.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { durableBunRuntime } from "../lib/bun-runtime";
2
2
  import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config";
3
3
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
4
- import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
4
+ import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { diagnoseService } from "../service";
7
7
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
@@ -102,6 +102,14 @@ export function selectListenTarget(
102
102
  };
103
103
  }
104
104
 
105
+ /** Prefer live result (including authoritative null pid) over the on-disk pid file. */
106
+ export function resolveStatusPid(
107
+ live: { pid: number | null } | null,
108
+ pidFile: number | null,
109
+ ): number | null {
110
+ return live ? live.pid : pidFile;
111
+ }
112
+
105
113
  async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
106
114
  const url = target.healthUrl;
107
115
  const controller = new AbortController();
@@ -132,9 +140,33 @@ async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
132
140
  export async function collectStatus(): Promise<CliStatusView> {
133
141
  const configDiagnostics = readConfigDiagnostics();
134
142
  const config = configDiagnostics.config;
135
- const pid = readPid();
136
- const listen = selectListenTarget(config, pid, pid ? readRuntimePort(pid) : null);
137
- const health = await checkProxyHealth(listen);
143
+ // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618).
144
+ // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and
145
+ // warn on malformed config.json (status --json must stay stderr-clean).
146
+ const live = await findLiveProxy({
147
+ configFn: () => ({ port: config.port, hostname: config.hostname }),
148
+ });
149
+ const pidFile = readPid();
150
+ // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile.
151
+ const pid = resolveStatusPid(live, pidFile);
152
+ const listen = live
153
+ ? {
154
+ port: live.port,
155
+ hostname: live.hostname,
156
+ source: live.source,
157
+ healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`,
158
+ dashboardUrl: `http://localhost:${live.port}/`,
159
+ }
160
+ : selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null);
161
+ // findLiveProxy already identity-probed /healthz; avoid a second fetch that can race.
162
+ const health = live
163
+ ? {
164
+ ok: true,
165
+ url: listen.healthUrl,
166
+ message: `ok (pid ${live.pid ?? "unknown"})`,
167
+ label: `${listen.healthUrl} ok (live)`,
168
+ }
169
+ : await checkProxyHealth(listen);
138
170
  const bunRuntime = durableBunRuntime();
139
171
  const service = diagnoseService();
140
172
  const serviceSummary = service.summary;
@@ -228,13 +260,15 @@ export async function collectStatus(): Promise<CliStatusView> {
228
260
  runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
229
261
  },
230
262
  };
231
- const proxyLabel = pid && health.ok
232
- ? `running (PID ${pid})`
233
- : pid
234
- ? `PID file points to PID ${pid}, but health check failed`
235
- : health.ok
236
- ? "reachable, but PID file is missing or stale"
237
- : "not running";
263
+ const proxyLabel = live
264
+ ? `running (PID ${live.pid ?? pid ?? "unknown"})`
265
+ : pid && health.ok
266
+ ? `running (PID ${pid})`
267
+ : pid
268
+ ? `PID file points to PID ${pid}, but health check failed`
269
+ : health.ok
270
+ ? "reachable, but PID file is missing or stale"
271
+ : "not running";
238
272
 
239
273
  return {
240
274
  proxyLabel,
@@ -242,8 +276,8 @@ export async function collectStatus(): Promise<CliStatusView> {
242
276
  json: {
243
277
  schemaVersion: 1,
244
278
  proxy: {
245
- running: Boolean(pid && health.ok),
246
- pid,
279
+ running: Boolean(live) || Boolean(pid && health.ok),
280
+ pid: live?.pid ?? pid,
247
281
  health: {
248
282
  ok: health.ok,
249
283
  url: health.url,
package/src/cli/v2.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * - nothing in the catalog build path calls this module; no auto-flip exists.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
14
- import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
14
+ import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
15
15
 
16
16
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
17
17
  import { loadConfig, saveConfig } from "../config";
@@ -85,6 +85,15 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
85
85
  log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
86
86
  const threads = getLogicalMaxThreads();
87
87
  log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
88
+ const v2Active = isEnabled();
89
+ const agentsEnabled = getAgentsEnabled();
90
+ log.log(`agents.enabled: ${agentsEnabled === null ? "(unset — upstream default true)" : agentsEnabled}`);
91
+ const maxDepth = getAgentsMaxDepth();
92
+ // max_depth is V1-only upstream; say so whenever V2 is active so the number
93
+ // cannot be misread as an effective V2 limit.
94
+ log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored while multi_agent_v2 is enabled)" : ""}`);
95
+ const instructions = getSubagentDeveloperInstructions();
96
+ log.log(`subagent_developer_instructions: ${instructions === null ? "(unset — children inherit)" : instructions === "" ? '"" (clears inherited instructions)' : JSON.stringify(instructions)}`);
88
97
  if (isEnabled() && hasMaxThreads()) {
89
98
  log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
90
99
  }
@@ -0,0 +1,34 @@
1
+ /** Canonical persisted Codex pool-account id format. */
2
+ export const CODEX_ACCOUNT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
3
+
4
+ // Account credentials are persisted in JSON object maps. These names have
5
+ // special meaning on ordinary JavaScript objects and must never be admitted as
6
+ // user-controlled keys.
7
+ const RESERVED_CODEX_ACCOUNT_IDS = new Set(["__proto__", "prototype", "constructor"]);
8
+
9
+ /**
10
+ * Stable internal id under which the Codex Desktop login participates in account rotation.
11
+ * It is reserved and must never be used as a public namespace target or pool-account id.
12
+ */
13
+ export const MAIN_CODEX_ACCOUNT_ID = "__main__";
14
+
15
+ export function isValidCodexAccountId(accountId: unknown): accountId is string {
16
+ return typeof accountId === "string"
17
+ && accountId !== MAIN_CODEX_ACCOUNT_ID
18
+ && CODEX_ACCOUNT_ID_RE.test(accountId)
19
+ && !RESERVED_CODEX_ACCOUNT_IDS.has(accountId.toLowerCase());
20
+ }
21
+
22
+ type CodexAccountIdentityRow = { id: string; isMain: boolean };
23
+
24
+ /** Legacy invalid rows remain loadable for cleanup, but never participate in routing. */
25
+ export function isSelectableCodexPoolAccount(account: CodexAccountIdentityRow): boolean {
26
+ return !account.isMain && isValidCodexAccountId(account.id);
27
+ }
28
+
29
+ /** A pre-validation pool row that collides with the internal Desktop-account sentinel. */
30
+ export function hasLegacyMainCodexPoolAccount(
31
+ accounts: readonly CodexAccountIdentityRow[] | undefined,
32
+ ): boolean {
33
+ return accounts?.some(account => !account.isMain && account.id === MAIN_CODEX_ACCOUNT_ID) ?? false;
34
+ }
@@ -6,6 +6,7 @@ import { clearAccountQuota } from "./quota";
6
6
  import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
7
7
  import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
8
8
  import { clearMainAccountInfoCache } from "./main-account-cache";
9
+ import { forgetCodexAccountPause } from "./account-pause";
9
10
  import type { OcxConfig } from "../types";
10
11
 
11
12
  let observedMainChatgptAccountId: string | undefined;
@@ -45,7 +46,9 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void {
45
46
 
46
47
  export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
47
48
  removeCodexAccountCredential(accountId);
48
- runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId);
49
+ runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
50
+ .filter(account => account.isMain || account.id !== accountId);
51
+ forgetCodexAccountPause(runtimeConfig, accountId);
49
52
  if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
50
53
  purgeCodexAccountRuntimeState(accountId);
51
54
  invalidateCodexWebSocketsForAccount(accountId);
@@ -0,0 +1,63 @@
1
+ import { isValidCodexAccountId } from "./account-id";
2
+
3
+ /** Config-only sentinel for the Codex Desktop account; outside the pool-account id grammar. */
4
+ export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "@main";
5
+
6
+ export const CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR =
7
+ "combo alias must not use a configured Codex account namespace";
8
+
9
+ const CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR =
10
+ "account id must not collide with a configured Codex account namespace";
11
+
12
+ /** Provider ids are compared case-insensitively at namespace admission boundaries. */
13
+ export function codexProviderNamespaceKey(value: string): string {
14
+ return value.toLowerCase();
15
+ }
16
+
17
+ export function hasCodexAccountNamespace(
18
+ namespaces: unknown,
19
+ namespace: string,
20
+ ): boolean {
21
+ return !!namespaces
22
+ && typeof namespaces === "object"
23
+ && !Array.isArray(namespaces)
24
+ && Object.hasOwn(namespaces, namespace);
25
+ }
26
+
27
+ export function codexAccountNamespaceProviderCollisionError(
28
+ namespaces: unknown,
29
+ providerName: string,
30
+ ): string | undefined {
31
+ const normalizedProvider = codexProviderNamespaceKey(providerName);
32
+ const collides = !!namespaces
33
+ && typeof namespaces === "object"
34
+ && !Array.isArray(namespaces)
35
+ && Object.keys(namespaces)
36
+ .some(namespace => codexProviderNamespaceKey(namespace) === normalizedProvider);
37
+ return collides
38
+ ? "provider name must not collide with a configured Codex account namespace"
39
+ : undefined;
40
+ }
41
+
42
+ export function codexAccountIdNamespaceCollisionError(
43
+ namespaces: unknown,
44
+ accountId: string,
45
+ ): string | undefined {
46
+ return hasCodexAccountNamespace(namespaces, accountId)
47
+ ? CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR
48
+ : undefined;
49
+ }
50
+
51
+ export function codexAccountNamespaceForModel(
52
+ namespaces: unknown,
53
+ modelId: string,
54
+ ): string | undefined {
55
+ const slash = modelId.indexOf("/");
56
+ if (slash <= 0) return undefined;
57
+ const namespace = modelId.slice(0, slash);
58
+ return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined;
59
+ }
60
+
61
+ export function isValidCodexAccountNamespaceTarget(accountId: unknown): accountId is string {
62
+ return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || isValidCodexAccountId(accountId);
63
+ }
@@ -0,0 +1,149 @@
1
+ import type { CodexAccount, OcxConfig } from "../types";
2
+ import { COMBO_NAMESPACE } from "../combos/types";
3
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
4
+ import {
5
+ CODEX_ACCOUNT_LOG_LABEL_RE,
6
+ createCodexAccountLogLabel,
7
+ fallbackCodexAccountLogLabel,
8
+ } from "./account-label";
9
+ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
10
+ import {
11
+ codexAccountIdNamespaceCollisionError,
12
+ codexProviderNamespaceKey,
13
+ MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
14
+ } from "./account-namespace-match";
15
+
16
+ export { isValidCodexAccountNamespaceTarget } from "./account-namespace-match";
17
+
18
+ const RESERVED_NAMESPACE_KEYS = new Set([
19
+ "__proto__",
20
+ "prototype",
21
+ "constructor",
22
+ COMBO_NAMESPACE,
23
+ OPENAI_CODEX_PROVIDER_ID,
24
+ ].map(codexProviderNamespaceKey));
25
+ const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16;
26
+
27
+ function comboAliasNamespaces(config: Pick<OcxConfig, "combos">): string[] {
28
+ return Object.values(config.combos ?? {}).flatMap((combo) => {
29
+ const alias = typeof combo?.alias === "string" ? combo.alias.trim() : "";
30
+ const slash = alias.indexOf("/");
31
+ return slash > 0 ? [alias.slice(0, slash)] : [];
32
+ });
33
+ }
34
+
35
+ function privateAccountSelectorCandidates(accountIds: Iterable<string>): Set<string> {
36
+ const candidates = new Set<string>();
37
+ for (const accountId of accountIds) {
38
+ candidates.add(accountId);
39
+ candidates.add(fallbackCodexAccountLogLabel(accountId));
40
+ }
41
+ return candidates;
42
+ }
43
+
44
+ function defaultPublicAccountSelector(
45
+ account: Pick<CodexAccount, "id" | "logLabel">,
46
+ allPrivateCandidates: ReadonlySet<string>,
47
+ ): string {
48
+ const privateCandidates = new Set(allPrivateCandidates);
49
+ privateCandidates.add(account.id);
50
+ privateCandidates.add(fallbackCodexAccountLogLabel(account.id));
51
+ if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "")
52
+ && !privateCandidates.has(account.logLabel!)) return account.logLabel!;
53
+
54
+ // Legacy or hand-edited rows may predate persisted random log labels. Allocate a fresh public
55
+ // selector rather than exposing the stable, id-derived fallback used only to redact old logs.
56
+ for (let attempt = 0; attempt < PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS; attempt += 1) {
57
+ const selector = createCodexAccountLogLabel(privateCandidates);
58
+ if (!privateCandidates.has(selector)) return selector;
59
+ }
60
+ throw new Error("Unable to allocate a unique Codex account selector");
61
+ }
62
+
63
+ function claimNamespace(requested: string, used: Set<string>): string {
64
+ let namespace = requested;
65
+ let suffix = 2;
66
+ while (used.has(namespace)) namespace = `${requested}-${suffix++}`;
67
+ used.add(namespace);
68
+ return namespace;
69
+ }
70
+
71
+ function occupiedNamespaces(config: Pick<OcxConfig, "combos" | "providers">): Set<string> {
72
+ return new Set([
73
+ ...Object.keys(config.providers).map(codexProviderNamespaceKey),
74
+ ...comboAliasNamespaces(config),
75
+ ...RESERVED_NAMESPACE_KEYS,
76
+ ]);
77
+ }
78
+
79
+ /** Build an initial account-selector map without deriving public selectors from aliases or ids. */
80
+ export function defaultCodexAccountNamespaces(
81
+ config: Pick<OcxConfig, "codexAccounts" | "combos" | "providers">,
82
+ ): Record<string, string> {
83
+ const namespaces: Record<string, string> = {};
84
+ const used = occupiedNamespaces(config);
85
+ const accounts = config.codexAccounts ?? [];
86
+ const privateCandidates = privateAccountSelectorCandidates(
87
+ accounts
88
+ .filter(account => account.id !== MAIN_CODEX_ACCOUNT_ID)
89
+ .map(account => account.id),
90
+ );
91
+ for (const candidate of privateCandidates) used.add(candidate);
92
+
93
+ namespaces[claimNamespace("main", used)] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET;
94
+ for (const account of accounts) {
95
+ if (account.isMain || !isValidCodexAccountId(account.id)) continue;
96
+ const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used);
97
+ namespaces[namespace] = account.id;
98
+ }
99
+ return namespaces;
100
+ }
101
+
102
+ /**
103
+ * Add one account to a generated map without renaming or replacing explicit existing entries.
104
+ * The account-creation layer must reject a new id that already equals an existing selector key.
105
+ * A true result means the map was mutated in place; callers must persist the updated config.
106
+ */
107
+ export function appendDefaultCodexAccountNamespace(
108
+ config: Pick<OcxConfig, "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers">,
109
+ account: Pick<CodexAccount, "id" | "isMain" | "logLabel">,
110
+ ): boolean {
111
+ const namespaces = config.codexAccountNamespaces;
112
+ if (account.isMain
113
+ || !isValidCodexAccountId(account.id)
114
+ || !namespaces
115
+ || Object.keys(namespaces).length === 0
116
+ || codexAccountIdNamespaceCollisionError(namespaces, account.id)
117
+ || Object.values(namespaces).includes(account.id)) return false;
118
+
119
+ const used = occupiedNamespaces(config);
120
+ for (const namespace of Object.keys(namespaces)) used.add(namespace);
121
+ const privateCandidates = privateAccountSelectorCandidates([
122
+ ...(config.codexAccounts ?? [])
123
+ .filter(existing => existing.id !== MAIN_CODEX_ACCOUNT_ID)
124
+ .map(existing => existing.id),
125
+ ...Object.values(namespaces),
126
+ account.id,
127
+ ]);
128
+ for (const candidate of privateCandidates) used.add(candidate);
129
+ const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used);
130
+ namespaces[namespace] = account.id;
131
+ return true;
132
+ }
133
+
134
+ export function isMainCodexAccountTarget(accountId: string): boolean {
135
+ return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID;
136
+ }
137
+
138
+ function normalizeCodexAccountNamespaceTarget(accountId: string): string {
139
+ return isMainCodexAccountTarget(accountId)
140
+ ? MAIN_CODEX_ACCOUNT_ID
141
+ : accountId;
142
+ }
143
+
144
+ export function codexAccountNamespaceEntries(
145
+ config: Pick<OcxConfig, "codexAccountNamespaces">,
146
+ ): Array<[string, string]> {
147
+ return Object.entries(config.codexAccountNamespaces ?? {})
148
+ .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]);
149
+ }
@@ -0,0 +1,20 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ /** Whether an account is administratively excluded from future pool selection. */
4
+ export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean {
5
+ return config.pausedCodexAccountIds?.includes(accountId) ?? false;
6
+ }
7
+
8
+ /** Persist the account's pool eligibility without changing credentials or runtime health. */
9
+ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void {
10
+ const pausedIds = new Set(config.pausedCodexAccountIds ?? []);
11
+ if (paused) pausedIds.add(accountId);
12
+ else pausedIds.delete(accountId);
13
+
14
+ if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds];
15
+ else delete config.pausedCodexAccountIds;
16
+ }
17
+
18
+ export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void {
19
+ setCodexAccountPaused(config, accountId, false);
20
+ }