@bitkyc08/opencodex 2.11.0 → 2.11.1

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 (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `ocx export --client <id>` — print a client config for the live proxy.
3
3
  *
4
- * Six clients, four formats: opencode and Pi are JSON, Hermes and Gajae YAML,
5
- * OpenClaw JSON5, Kimi TOML.
4
+ * Seven clients, four formats: opencode and Pi are JSON, OMP, Hermes and Gajae
5
+ * are YAML, OpenClaw JSON5, Kimi TOML.
6
6
  *
7
7
  * Two consumers, one payload (devlog 260731_client_config_export/020):
8
8
  *
@@ -179,13 +179,13 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
179
179
 
180
180
  // Every serializer already ends with exactly one newline.
181
181
  if (out !== undefined) writeExport(out, text, force);
182
- // stderr, so `--json` stdout stays byte-exact for a redirect.
182
+ // stderr, so `--json` stdout stays a standalone JSON document.
183
183
  if (out !== undefined && wantsJson) console.error(`Wrote ${out}`);
184
184
 
185
185
  const degraded = models.filter(model => !hasContextLimit(model)).length;
186
- // `--json` keeps emitting the DOCUMENT at the top level: a script that
187
- // pipes it into a config file must not have to unwrap an envelope we added
188
- // for our own convenience. Format metadata rides in the human lines below.
186
+ // `--json` keeps emitting the DOCUMENT at the top level as JSON for scripts;
187
+ // `--out` is the path that writes the selected client's native format.
188
+ // Format metadata rides in the human lines below.
189
189
  printData(clientConfig, wantsJson, [
190
190
  text.trimEnd(),
191
191
  "",
package/src/cli/help.ts CHANGED
@@ -173,12 +173,12 @@ const helpEntries: Record<string, HelpEntry> = {
173
173
  },
174
174
  "api-key": { usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
175
175
  export: {
176
- usage: "ocx export --client <opencode|pi|hermes|openclaw|kimi|gajae> [--json] [--out <path>] [--force]",
177
- summary: "Print a client config (opencode, Pi, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.",
176
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae> [--json] [--out <path>] [--force]",
177
+ summary: "Print a client config (opencode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.",
178
178
  details: [
179
- "--json prints only the config JSON on stdout, so it is safe to redirect to a file.",
180
- "--out <path> writes the config there and refuses to replace an existing file without --force.",
181
- "The config never contains a key; it references the client's env var, which you export before launching. Kimi cannot hold an env reference, so it carries a loopback placeholder instead.",
179
+ "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
180
+ "--out <path> writes the native config there and refuses to replace an existing file without --force.",
181
+ "The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
182
182
  "The destination path is printed for merging by hand — ocx never writes your real client config.",
183
183
  ],
184
184
  },
@@ -313,7 +313,7 @@ Usage:
313
313
  ocx agent <sub> Subagents, injection, effort caps, and sidecars
314
314
  ocx observe <sub> Logs, usage, storage, memory, and debug data
315
315
  ocx access <sub> External API keys and endpoint information
316
- ocx export --client <id> Print a client config wired to the running proxy (6 clients)
316
+ ocx export --client <id> Print a client config wired to the running proxy (7 clients)
317
317
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
318
318
  ocx grok <sub> Grok Build model selection and apply
319
319
  ocx system <sub> Runtime settings, startup, sync, and updates
package/src/cli/index.ts CHANGED
@@ -2,7 +2,11 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject";
4
4
  import { stripGrokConfig } from "../grok/inject";
5
- import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "../codex/history-job";
5
+ import {
6
+ describeHistoryJobFailure,
7
+ resolveCodexHistoryJobTarget,
8
+ runCodexHistoryJob,
9
+ } from "../codex/history-job";
6
10
  import { reconcileJournal } from "../codex/journal";
7
11
  import {
8
12
  codexAutoStartEnabled,
@@ -36,7 +40,7 @@ import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSele
36
40
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
37
41
  import { createReadinessGate } from "../server/readiness";
38
42
  import { parseReadyArgs, runReady, type ReadyArgs } from "./ready";
39
- import { stopProxy } from "../lib/process-control";
43
+ import { ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
40
44
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
41
45
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
42
46
  import { startupHealthSummary } from "../codex/autostart-health";
@@ -675,6 +679,10 @@ async function handleStop() {
675
679
  // exact teardown the refusal exists to prevent.
676
680
  const detail = err instanceof Error ? err.message : String(err);
677
681
  if (detail) console.error(` ${detail}`);
682
+ if (err instanceof ProxyOwnershipRefusedError) {
683
+ ownershipBlocked = true;
684
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
685
+ }
678
686
  }
679
687
  } else {
680
688
  // Snapshot the stale on-disk state BEFORE the async probe: a concurrent `ocx start`
@@ -693,6 +701,10 @@ async function handleStop() {
693
701
  console.error(`❌ Failed to stop proxy (PID ${live.pid}).`);
694
702
  const detail = err instanceof Error ? err.message : String(err);
695
703
  if (detail) console.error(` ${detail}`);
704
+ if (err instanceof ProxyOwnershipRefusedError) {
705
+ ownershipBlocked = true;
706
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
707
+ }
696
708
  }
697
709
  } else if (!stoppedService) {
698
710
  console.log("No running proxy found.");
@@ -899,7 +911,7 @@ async function handleRecoverHistory() {
899
911
  : { rows: 0, files: 0, failed: true as const };
900
912
  if (r.failed) {
901
913
  console.error(
902
- "⚠️ Recovery SKIPPED: the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command.",
914
+ `⚠️ Recovery SKIPPED: ${describeHistoryJobFailure(outcome, "recover-legacy")}`,
903
915
  );
904
916
  process.exit(1);
905
917
  }
@@ -1148,11 +1160,16 @@ switch (command) {
1148
1160
  break;
1149
1161
  }
1150
1162
  case "codex-shim": {
1151
- const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("../codex/shim");
1163
+ const { codexShimStatus, diagnoseCodexShim, installCodexShim, uninstallCodexShim } = await import("../codex/shim");
1152
1164
  switch (args[1]) {
1153
1165
  case "install": {
1154
1166
  const r = installCodexShim();
1155
- console.log(r.installed ? `✅ ${r.message}` : `⚠️ ${r.message}`);
1167
+ const { collectCodexShimReadinessWarnings } = await import("./codex-shim-readiness");
1168
+ const warnings = diagnoseCodexShim().healthy
1169
+ ? collectCodexShimReadinessWarnings()
1170
+ : [];
1171
+ console.log(`${r.installed && warnings.length === 0 ? "✅ " : "⚠️ "}${r.message}`);
1172
+ for (const warning of warnings) console.warn(` ${warning}`);
1156
1173
  break;
1157
1174
  }
1158
1175
  case "status":
@@ -20,7 +20,7 @@ const USAGE = `Usage:
20
20
  ocx models <enable|disable> <provider/model|native-model> [--native] [--json]
21
21
  ocx models provider <name> <on|off> [--json]
22
22
  ocx models selected <provider> [--set <id,id...>|--clear] [--json]
23
- ocx models context <status|value <tokens>|provider <name> <on|off>|all <on|off>> [--json]
23
+ ocx models context <status|value <tokens> [--set-all]|provider <name> on [--value <tokens>]|provider <name> off|all <on|off>> [--json]
24
24
  ocx models shadow <status|set> [model|-] [--enabled <on|off>] [--json]`;
25
25
 
26
26
  type ModelRow = {
@@ -158,11 +158,23 @@ async function context(argv: string[], deps: RuntimeApiDeps): Promise<void> {
158
158
  const value = Number(raw.replace(/[_,]/g, ""));
159
159
  if (!Number.isInteger(value) || value <= 0) throw new CliUsageError("context value must be a positive integer", USAGE);
160
160
  body = { value };
161
+ // Explicit apply-to-all switch for headless use: re-points every routed provider to
162
+ // the new value, mirroring the dashboard's "apply to every routed provider" toggle.
163
+ // Without it the value only becomes the default for future toggles.
164
+ if (takeFlag(args, "--set-all")) body.setAll = true;
161
165
  } else if (action === "provider") {
162
166
  const provider = args.shift()?.trim();
163
167
  const state = args.shift()?.toLowerCase();
164
168
  if (!provider || (state !== "on" && state !== "off")) throw new CliUsageError("provider and on|off are required", USAGE);
165
169
  body = { provider, enabled: state === "on" };
170
+ // Optional explicit cap value for this provider only (`ocx models context provider
171
+ // openai on --value 128000`). Mirrors the dashboard's per-provider cap picker; the
172
+ // value never leaks to other providers.
173
+ const value = takeIntegerOption(args, "--value", { min: 1 });
174
+ if (value !== undefined && state !== "on") {
175
+ throw new CliUsageError("--value can only be used with on", USAGE);
176
+ }
177
+ if (value !== undefined) body.value = value;
166
178
  } else if (action === "all") {
167
179
  const state = args.shift()?.toLowerCase();
168
180
  if (state !== "on" && state !== "off") throw new CliUsageError("all requires on|off", USAGE);
@@ -12,6 +12,7 @@ import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadCo
12
12
  import { hasHelpFlag } from "./help";
13
13
  import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
14
14
  import { providerConfigSeed } from "../providers/derive";
15
+ import { dropProviderCustomModels } from "../providers/provider-id-rewrite";
15
16
  import type { OcxProviderConfig } from "../types";
16
17
  import { findLiveProxy } from "../server/proxy-liveness";
17
18
  import { syncModelsToCodex } from "../codex/sync";
@@ -302,6 +303,7 @@ function handleRemove(args: string[]): void {
302
303
  }
303
304
 
304
305
  delete config.providers[name];
306
+ const droppedCustomModels = dropProviderCustomModels(config, name);
305
307
  validateAndSave(config);
306
308
 
307
309
 
@@ -312,11 +314,16 @@ function handleRemove(args: string[]): void {
312
314
  remainingProviders: Object.keys(config.providers),
313
315
  defaultProvider: config.defaultProvider,
314
316
  needsSync: true,
317
+ ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
315
318
  }, null, 2));
316
319
  return;
317
320
  }
318
321
 
319
322
  console.log(`✅ Provider "${name}" removed.`);
323
+ if (droppedCustomModels > 0) {
324
+ const plural = droppedCustomModels === 1 ? "model" : "models";
325
+ console.log(` Also removed ${droppedCustomModels} custom ${plural} that belonged to it.`);
326
+ }
320
327
  }
321
328
 
322
329
  // ---------------------------------------------------------------------------
@@ -170,6 +170,49 @@ export function opencodeGlobalConfigPath(
170
170
  return join(xdg, "opencode", "opencode.json");
171
171
  }
172
172
 
173
+ const OMP_PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
174
+ const OMP_WINDOWS_RESERVED_PROFILE_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
175
+
176
+ function ompProfileName(env: OpencodeLaunchEnv): string | undefined {
177
+ // OMP_PROFILE wins by presence, even when explicitly empty; PI_PROFILE is
178
+ // only the legacy fallback when the canonical variable is undefined.
179
+ const raw = env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE;
180
+ const profile = raw?.trim();
181
+ if (!profile || profile === "default") return undefined;
182
+ if (
183
+ profile === "."
184
+ || profile === ".."
185
+ || profile.endsWith(".")
186
+ || !OMP_PROFILE_NAME_RE.test(profile)
187
+ || OMP_WINDOWS_RESERVED_PROFILE_RE.test(profile)
188
+ ) {
189
+ throw new ClientPathError(`Invalid OMP profile "${raw}"`);
190
+ }
191
+ return profile;
192
+ }
193
+
194
+ /** Resolve the global Oh My Pi agent directory using OMP's own env precedence. */
195
+ export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
196
+ const profile = ompProfileName(env);
197
+ if (!profile) {
198
+ const override = env.PI_CODING_AGENT_DIR?.trim();
199
+ if (override) return absoluteClientPath(override, home, "PI_CODING_AGENT_DIR");
200
+ }
201
+ // OMP treats PI_CONFIG_DIR as a directory name relative to the user's home,
202
+ // even when the value starts with `/` or `~`. Mirror that path.join contract
203
+ // exactly instead of assigning those values different filesystem semantics.
204
+ const root = join(home, env.PI_CONFIG_DIR || ".omp");
205
+ return profile ? join(root, "profiles", profile, "agent") : join(root, "agent");
206
+ }
207
+
208
+ /** OMP's canonical custom-provider catalog. */
209
+ export function ompModelsConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
210
+ const agentDir = ompAgentDir(env, home);
211
+ const yamlFallback = join(agentDir, "models.yaml");
212
+ const canonical = join(agentDir, "models.yml");
213
+ return !existsSync(canonical) && existsSync(yamlFallback) ? yamlFallback : canonical;
214
+ }
215
+
173
216
  /** Compose the OpenAI-compatible proxy base URL from a live probe result. */
174
217
  export function opencodeProxyBaseUrl(port: number, hostname?: string): string {
175
218
  return `http://${probeHostname(hostname)}:${port}/v1`;
@@ -364,6 +407,7 @@ export interface ExportContext {
364
407
  export type ExportClientId =
365
408
  | "opencode"
366
409
  | "pi"
410
+ | "omp"
367
411
  | "hermes"
368
412
  | "openclaw"
369
413
  | "kimi"
@@ -399,14 +443,13 @@ export interface ExportClientSpec {
399
443
  */
400
444
  buildContribution: BuildContribution;
401
445
  /**
402
- * True when this client can only reach a loopback bind.
446
+ * True when the generated integration deliberately supports loopback only.
403
447
  *
404
448
  * `/v1/chat/completions` rejects bearer credentials and requires the
405
449
  * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in
406
- * src/server/auth-cors.ts). A client whose schema has no place to put that
407
- * header therefore cannot authenticate against a remote bind at all so we
408
- * say so rather than exporting a config that 401s. Same reasoning as the
409
- * Grok managed block's non-loopback refusal.
450
+ * src/server/auth-cors.ts). If this exporter cannot safely emit that header,
451
+ * it refuses a remote bind rather than generating a config that 401s. Same
452
+ * reasoning as the Grok managed block's non-loopback refusal.
410
453
  */
411
454
  loopbackOnly: boolean;
412
455
  }
@@ -894,6 +937,11 @@ function buildPiContribution(ctx: ExportContext): ManagedContribution {
894
937
  return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
895
938
  }
896
939
 
940
+ function buildOmpContribution(ctx: ExportContext): ManagedContribution {
941
+ const doc = buildPiClientConfig(ctx);
942
+ return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
943
+ }
944
+
897
945
  function buildHermesContribution(ctx: ExportContext): ManagedContribution {
898
946
  const doc = buildHermesClientConfig(ctx);
899
947
  return singleFragment("hermes", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
@@ -953,6 +1001,20 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
953
1001
  // dedicated admission header a remote bind requires.
954
1002
  loopbackOnly: true,
955
1003
  },
1004
+ omp: {
1005
+ id: "omp",
1006
+ filename: "omp-models.yaml",
1007
+ destination: env => ompModelsConfigPath(env),
1008
+ apiKeyEnv: "",
1009
+ exportHint: "OMP reads a non-secret placeholder from models.yml; loopback needs no key.",
1010
+ build: buildPiClientConfig,
1011
+ format: "yaml",
1012
+ summarize: summarizePi,
1013
+ buildContribution: buildOmpContribution,
1014
+ // OMP supports provider-level headers, but remote credential wiring is
1015
+ // intentionally deferred from this initial loopback-only integration.
1016
+ loopbackOnly: true,
1017
+ },
956
1018
  hermes: {
957
1019
  id: "hermes",
958
1020
  filename: "hermes-config.yaml",
@@ -1,3 +1,10 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import {
3
+ atomicWriteFile,
4
+ getConfigPath,
5
+ saveConfigPreservingClaudeCode,
6
+ withConfigMutationLockSync,
7
+ } from "../config";
1
8
  import { removeCodexAccountCredential } from "./account-store";
2
9
  import { clearAccountNeedsReauth } from "./account-runtime-state";
3
10
  import { getMainChatgptAccountId } from "./auth-collision";
@@ -8,10 +15,25 @@ import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
8
15
  import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache";
9
16
  import { forgetCodexAccountPause } from "./account-pause";
10
17
  import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
18
+ import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces";
11
19
  import type { OcxConfig } from "../types";
12
20
 
13
21
  let observedMainChatgptAccountId: string | undefined;
14
22
 
23
+ export class CodexAccountDeleteCleanupError extends Error {
24
+ constructor() {
25
+ super("Account deletion was saved, but local credential cleanup did not complete. Retry removal.");
26
+ this.name = "CodexAccountDeleteCleanupError";
27
+ }
28
+ }
29
+
30
+ export class CodexAccountDeleteRollbackError extends Error {
31
+ constructor() {
32
+ super("Account deletion failed and the previous config could not be restored. Restart before retrying.");
33
+ this.name = "CodexAccountDeleteRollbackError";
34
+ }
35
+ }
36
+
15
37
  export function purgeCodexAccountRuntimeState(accountId: string): void {
16
38
  clearAccountNeedsReauth(accountId);
17
39
  clearAccountQuota(accountId);
@@ -70,14 +92,81 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void {
70
92
  clearMainAccountCredentialPresence();
71
93
  }
72
94
 
73
- export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
74
- removeCodexAccountCredential(accountId);
75
- runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
76
- .filter(account => account.isMain || account.id !== accountId);
77
- forgetCodexAccountPause(runtimeConfig, accountId);
78
- forgetCodexAccountPriority(runtimeConfig, accountId);
79
- clearCodexAccountPin(runtimeConfig, accountId);
80
- if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
81
- purgeCodexAccountRuntimeState(accountId);
82
- invalidateCodexWebSocketsForAccount(accountId);
95
+ function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void {
96
+ for (const key of Object.keys(target) as Array<keyof OcxConfig>) delete target[key];
97
+ Object.assign(target, snapshot);
98
+ }
99
+
100
+ function restorePersistedConfig(configPath: string, previousBytes: string): void {
101
+ try {
102
+ if (readFileSync(configPath, "utf8") === previousBytes) return;
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
105
+ }
106
+ atomicWriteFile(configPath, previousBytes);
107
+ }
108
+
109
+ /**
110
+ * Delete a stored account while retaining its selector binding.
111
+ *
112
+ * When the runtime config is backed by an existing config.json, commit the config deletion before
113
+ * credentials or runtime state are destroyed. Transient callers intentionally skip durable config
114
+ * persistence because they have no durable account row to protect. The whole sequence shares the
115
+ * config mutation coordinator so a cooperating writer cannot re-add a persisted account between
116
+ * the durable config commit and credential cleanup.
117
+ *
118
+ * Returns true when a picker-visible row disappeared and the catalog must converge.
119
+ */
120
+ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean {
121
+ let cleanupFailed = false;
122
+ const pickerVisibilityChanged = withConfigMutationLockSync(() => {
123
+ const previousConfig = structuredClone(runtimeConfig);
124
+ const configPath = getConfigPath();
125
+ const hasPersistedConfig = existsSync(configPath);
126
+ const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath, "utf8") : undefined;
127
+ const hadStoredAccount = (runtimeConfig.codexAccounts ?? [])
128
+ .some(account => !account.isMain && account.id === accountId);
129
+ const hadVisiblePickerBinding = hadStoredAccount
130
+ && codexAccountPickerEnabled(runtimeConfig)
131
+ && codexAccountNamespaceEntries(runtimeConfig)
132
+ .some(([, boundAccountId]) => boundAccountId === accountId);
133
+
134
+ runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
135
+ .filter(account => account.isMain || account.id !== accountId);
136
+ forgetCodexAccountPause(runtimeConfig, accountId);
137
+ forgetCodexAccountPriority(runtimeConfig, accountId);
138
+ clearCodexAccountPin(runtimeConfig, accountId);
139
+ if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined;
140
+
141
+ if (previousPersistedConfig !== undefined) {
142
+ try {
143
+ // Persist first for durable configs. Destructive cleanup below must never run for a
144
+ // deletion that failed to commit. Transient configs intentionally skip this write.
145
+ saveConfigPreservingClaudeCode(runtimeConfig);
146
+ } catch (error) {
147
+ restoreRuntimeConfig(runtimeConfig, previousConfig);
148
+ try {
149
+ restorePersistedConfig(configPath, previousPersistedConfig);
150
+ } catch {
151
+ throw new CodexAccountDeleteRollbackError();
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ try {
158
+ removeCodexAccountCredential(accountId);
159
+ purgeCodexAccountRuntimeState(accountId);
160
+ invalidateCodexWebSocketsForAccount(accountId);
161
+ } catch {
162
+ // Do not throw through the mutation coordinator after config.json committed: that would roll
163
+ // back only the SQLite generation transaction, not the already-atomic file replacement.
164
+ cleanupFailed = true;
165
+ }
166
+
167
+ return hadVisiblePickerBinding;
168
+ });
169
+
170
+ if (cleanupFailed) throw new CodexAccountDeleteCleanupError();
171
+ return pickerVisibilityChanged;
83
172
  }