@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
package/src/cli/agent.ts CHANGED
@@ -13,6 +13,13 @@ import {
13
13
  type RuntimeApiDeps,
14
14
  } from "./runtime-api";
15
15
 
16
+ interface WebSearchModelOption {
17
+ value: string;
18
+ model: string;
19
+ backend: "openai" | "anthropic";
20
+ authSlot?: boolean;
21
+ }
22
+
16
23
  const USAGE = `Usage:
17
24
  ocx agent [status] [--json]
18
25
  ocx agent injection <status|set> [--model <id|->] [--effort <level|->]
@@ -20,7 +27,7 @@ const USAGE = `Usage:
20
27
  ocx agent effort <status|set> [--main <level|->] [--subagent <level|->] [--json]
21
28
  ocx agent subagents <status|set|clear> [model,model...] [--json]
22
29
  ocx agent fallback <status|set|clear> [model,model...] [--poll-ms <5000-600000>] [--json]
23
- ocx agent sidecar <status|web|vision> [--model <id|->] [--backend <openai|anthropic|->]
30
+ ocx agent sidecar <status|web|vision> [--list] [--model <id|->] [--backend <openai|anthropic|xai|gemini|exa|->]
24
31
  [--reasoning <level>] [--max-descriptions <n>] [--json]`;
25
32
 
26
33
  function clearable(value: string | undefined): string | null | undefined {
@@ -152,6 +159,29 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise<void> {
152
159
  return;
153
160
  }
154
161
  if (section !== "web" && section !== "vision") throw new CliUsageError("sidecar must be web, vision, or status", USAGE);
162
+ // --list must be consumed BEFORE rejectArgs sees it. It prints the server's
163
+ // candidate set — the exact list the GUI picker shows (#2188): the server
164
+ // computes it once and every surface consumes it, so the CLI cannot drift.
165
+ const wantsList = takeFlag(args, "--list");
166
+ if (wantsList) {
167
+ rejectArgs(args, USAGE);
168
+ const settings = await runtimeRequest("/api/sidecar-settings", {}, deps) as {
169
+ webSearchModels?: WebSearchModelOption[];
170
+ visionModels?: Array<{ value: string; backend?: string; baseline?: boolean }>;
171
+ };
172
+ if (section === "web") {
173
+ const options = settings.webSearchModels ?? [];
174
+ printData(options, wantsJson, options.length === 0
175
+ ? ["no runnable web-search sidecar models (log in to ChatGPT or Anthropic)"]
176
+ : options.map(option => `${option.value} [${option.backend}]${option.authSlot ? " (auth slot)" : ""}`));
177
+ } else {
178
+ const options = settings.visionModels ?? [];
179
+ printData(options, wantsJson, options.length === 0
180
+ ? ["no eligible vision describers"]
181
+ : options.map(option => `${option.value}${option.backend ? ` [${option.backend}]` : ""}${option.baseline ? " (baseline)" : ""}`));
182
+ }
183
+ return;
184
+ }
155
185
  const model = takeOption(args, "--model");
156
186
  const backend = takeOption(args, "--backend");
157
187
  const reasoning = takeOption(args, "--reasoning");
@@ -163,6 +193,19 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise<void> {
163
193
  if (reasoning !== undefined) settings.reasoning = reasoning;
164
194
  if (maxDescriptionsPerTurn !== undefined) settings.maxDescriptionsPerTurn = maxDescriptionsPerTurn;
165
195
  if (Object.keys(settings).length === 0) throw new CliUsageError("at least one sidecar option is required", USAGE);
196
+ if (section === "web" && model !== undefined && model !== "-") {
197
+ const offered = await runtimeRequest("/api/sidecar-settings", {}, deps) as {
198
+ webSearchModels?: WebSearchModelOption[];
199
+ };
200
+ const requestedBackend = backend === "-" ? "openai" : backend;
201
+ const option = offered.webSearchModels?.find(candidate =>
202
+ (candidate.value === model || candidate.model === model)
203
+ && (requestedBackend === undefined || candidate.backend === requestedBackend));
204
+ if (option) {
205
+ settings.model = option.model;
206
+ if (backend !== "-") settings.backend = option.backend;
207
+ }
208
+ }
166
209
  const body = section === "web" ? { webSearch: settings } : { vision: settings };
167
210
  const result = await runtimeRequest("/api/sidecar-settings", { method: "PUT", body: JSON.stringify(body) }, deps);
168
211
  printData(result, wantsJson, [`${section} sidecar settings updated.`]);
@@ -0,0 +1,73 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { injectClaudeAgentDefs } from "../claude/agents-inject";
3
+ import { fetchClaudeContextWindows } from "./claude";
4
+ import type { ReadinessGate } from "../server/readiness";
5
+
6
+ export interface ClaudeAgentStartupSyncDeps {
7
+ fetchContextWindows?: typeof fetchClaudeContextWindows;
8
+ injectAgentDefs?: typeof injectClaudeAgentDefs;
9
+ warn?: (message: string) => void;
10
+ }
11
+
12
+ /**
13
+ * Keep the public readiness gate pending until both startup reconciliations have settled.
14
+ *
15
+ * The Codex sync remains the authority for ready versus failed. Claude roster repair is
16
+ * deliberately best-effort (#2200), but readiness must not become observable between the
17
+ * Codex write and that repair: a service manager could otherwise launch Claude Code against
18
+ * stale `ocx-*.md` files. A small forwarding gate delays only the successful transition;
19
+ * terminal Codex failure is still published immediately.
20
+ */
21
+ export async function reconcileClientStartupBeforeReady<T>(
22
+ readinessGate: ReadinessGate,
23
+ syncCodex: (deferredGate: ReadinessGate) => Promise<T>,
24
+ syncClaudeRoster: () => Promise<unknown>,
25
+ ): Promise<T> {
26
+ let codexReady = false;
27
+ const deferredGate: ReadinessGate = {
28
+ getStatus: () => readinessGate.getStatus(),
29
+ markReady: () => { codexReady = true; },
30
+ markFailed: () => readinessGate.markFailed(),
31
+ };
32
+
33
+ const result = await syncCodex(deferredGate);
34
+ await syncClaudeRoster();
35
+ if (codexReady) readinessGate.markReady();
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * Reconcile the generated Claude Code roster after the proxy listener is live.
41
+ *
42
+ * This belongs to the owning CLI lifecycle rather than `startServer`: the latter is also a
43
+ * library/test primitive and must not mutate a developer's real `~/.claude` directory merely
44
+ * because an in-process test server was created. The live Management API supplies the same bounded
45
+ * context-window map used by `ocx claude`; failure keeps startup available and falls back to an
46
+ * unmarked roster. Disabled integrations skip discovery and prune verified-owned definitions.
47
+ */
48
+ export async function syncClaudeAgentDefsAtProxyStartup(
49
+ config: OcxConfig,
50
+ port: number,
51
+ deps: ClaudeAgentStartupSyncDeps = {},
52
+ ): Promise<string[] | null> {
53
+ const inject = deps.injectAgentDefs ?? injectClaudeAgentDefs;
54
+ const warn = deps.warn ?? (message => console.warn(message));
55
+
56
+ try {
57
+ if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
58
+ return inject(config, {});
59
+ }
60
+
61
+ let windows: Record<string, number> = {};
62
+ try {
63
+ windows = await (deps.fetchContextWindows ?? fetchClaudeContextWindows)(config, port);
64
+ } catch {
65
+ // Startup remains best-effort. The next management mutation or `ocx claude` launch can
66
+ // restore context markers after a transient catalog/Management API failure.
67
+ }
68
+ return inject(config, windows);
69
+ } catch (error) {
70
+ warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
71
+ return null;
72
+ }
73
+ }
@@ -204,8 +204,9 @@ const commandRunners: Record<string, CommandRunner> = {
204
204
  },
205
205
  sync: async deps => {
206
206
  const restartCodex = deps.args.slice(1).includes("--restart-codex");
207
+ const live = await deps.findLiveProxy();
207
208
  const synced = await syncModelsToCodex(
208
- (await deps.findLiveProxy())?.port,
209
+ live?.port,
209
210
  undefined,
210
211
  undefined,
211
212
  undefined,
@@ -229,6 +230,28 @@ const commandRunners: Record<string, CommandRunner> = {
229
230
  if (synced.catalogWritten || synced.cacheSynced) {
230
231
  afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
231
232
  }
233
+ // `ocx sync` is a direct CLI path; it does not call the management
234
+ // `/api/sync` route. Refresh the already-connected MCode block here too,
235
+ // after Codex has published the catalog that supplies its capabilities.
236
+ if (synced.status !== "refused" && live) {
237
+ try {
238
+ const config = deps.loadConfig();
239
+ const { refreshOwnedIntegration } = await import("../integrations/owned-refresh");
240
+ const result = await refreshOwnedIntegration({
241
+ clientId: "mcode",
242
+ models: async () => {
243
+ const { loadExportModels } = await import("../server/management/model-rows");
244
+ return loadExportModels(config);
245
+ },
246
+ config,
247
+ port: live.port,
248
+ });
249
+ if (result?.changed) console.log("MCode integration refreshed from the current catalog.");
250
+ else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`);
251
+ } catch (error) {
252
+ console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`);
253
+ }
254
+ }
232
255
  return code;
233
256
  },
234
257
  v2: async deps => {
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Align Grok and Claude Desktop files with the durable switches during `ocx ensure`.
3
+ *
4
+ * handleEnsure used to load config once, then health-probe / model-sync / spawn,
5
+ * and only afterwards mutate ~/.grok/config.toml and the Desktop library from
6
+ * that snapshot. An OFF→ON flip in that window stripped a freshly enabled fence
7
+ * or deleted a freshly applied Desktop profile; ON→OFF rewrote the files the
8
+ * user had just turned off. Re-read persisted desired state immediately before
9
+ * each external-file mutation, and use that current config for sync inputs.
10
+ */
11
+ import { loadConfig } from "../config";
12
+ import { stripGrokConfig, type GrokInjectResult } from "../grok/inject";
13
+ import { removeDesktop3pStandardPivot } from "../claude/desktop-3p";
14
+ import {
15
+ claudeDesktopIntegrationEnabled,
16
+ shouldSyncGrokOnStart,
17
+ } from "../codex/desired-state";
18
+ import type { OcxConfig } from "../types";
19
+
20
+ export function grokSyncFailureMessage(err: unknown): string {
21
+ const detail = err instanceof Error ? err.message : String(err);
22
+ return `Grok Build config sync failed: ${detail}. `
23
+ + "~/.grok/config.toml may still point at a previous proxy port — "
24
+ + "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it.";
25
+ }
26
+
27
+ export interface EnsureDesiredIntegrationsDeps {
28
+ loadConfig: () => OcxConfig;
29
+ stripGrokConfig: typeof stripGrokConfig;
30
+ syncGrokConfig: (
31
+ port: number,
32
+ config: OcxConfig,
33
+ opts?: { hostname?: string },
34
+ ) => Promise<GrokInjectResult>;
35
+ removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot;
36
+ log?: (message: string) => void;
37
+ error?: (message: string) => void;
38
+ }
39
+
40
+ async function defaultSyncGrokConfig(
41
+ port: number,
42
+ config: OcxConfig,
43
+ opts: { hostname?: string } = {},
44
+ ): Promise<GrokInjectResult> {
45
+ const { syncGrokConfig } = await import("../grok/sync");
46
+ return syncGrokConfig(port, config, opts);
47
+ }
48
+
49
+ const productionDeps: EnsureDesiredIntegrationsDeps = {
50
+ loadConfig,
51
+ stripGrokConfig,
52
+ syncGrokConfig: defaultSyncGrokConfig,
53
+ removeDesktop3pStandardPivot,
54
+ };
55
+
56
+ function io(deps: EnsureDesiredIntegrationsDeps): {
57
+ log: (message: string) => void;
58
+ error: (message: string) => void;
59
+ } {
60
+ return {
61
+ log: deps.log ?? (message => console.log(message)),
62
+ error: deps.error ?? (message => console.error(message)),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Keep ~/.grok/config.toml aligned with the durable Grok switch.
68
+ *
69
+ * `handleStart` already gates inject on `shouldSyncGrokOnStart`. `ocx ensure`
70
+ * used to call `syncGrokConfig` unconditionally, so a dashboard/update/restart
71
+ * path that lands in ensure rewrote the fence while the switch stayed OFF.
72
+ * When the switch is OFF, strip any leftover managed block instead of injecting.
73
+ */
74
+ export async function ensureGrokFenceMatchesDesired(
75
+ port: number,
76
+ opts: { hostname?: string } = {},
77
+ deps: EnsureDesiredIntegrationsDeps = productionDeps,
78
+ ): Promise<void> {
79
+ const config = deps.loadConfig();
80
+ const { log, error } = io(deps);
81
+ if (!shouldSyncGrokOnStart(config)) {
82
+ try {
83
+ const grok = deps.stripGrokConfig();
84
+ if (grok.changed) log(` ↩️ ${grok.message}`);
85
+ else if (!grok.ok) error(`⚠️ ${grok.message}`);
86
+ } catch (err) {
87
+ error(`⚠️ ${grokSyncFailureMessage(err)}`);
88
+ }
89
+ return;
90
+ }
91
+ try {
92
+ const hostname = opts.hostname ?? config.hostname;
93
+ const g = await deps.syncGrokConfig(
94
+ port,
95
+ config,
96
+ hostname !== undefined ? { hostname } : {},
97
+ );
98
+ if (g.changed) log(" + Grok Build config updated (~/.grok/config.toml)");
99
+ else if (!g.ok) error(`⚠️ ${g.message}`);
100
+ } catch (err) {
101
+ error(`⚠️ ${grokSyncFailureMessage(err)}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * When Claude Desktop is durably OFF, clear any leftover owned gateway profile.
107
+ * ensure/update used to leave Claude-3p residue in place after a failed disable
108
+ * (drifted fingerprint), so the Integrations card kept looking applied/stale.
109
+ */
110
+ export function ensureClaudeDesktopMatchesDesired(
111
+ deps: EnsureDesiredIntegrationsDeps = productionDeps,
112
+ ): void {
113
+ const config = deps.loadConfig();
114
+ const { log, error } = io(deps);
115
+ if (claudeDesktopIntegrationEnabled(config)) return;
116
+ try {
117
+ const removed = deps.removeDesktop3pStandardPivot({
118
+ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null,
119
+ });
120
+ if (removed.ok && removed.changed) {
121
+ log(" ↩️ Claude Desktop integration residue removed.");
122
+ } else if (!removed.ok) {
123
+ error(`⚠️ Claude Desktop cleanup skipped: ${removed.reason ?? removed.kind}.`);
124
+ }
125
+ } catch (err) {
126
+ const detail = err instanceof Error ? err.message : String(err);
127
+ error(`⚠️ Claude Desktop cleanup failed: ${detail}.`);
128
+ }
129
+ }
130
+
131
+ export type EnsureDesiredIntegrationsBranch =
132
+ | { kind: "live"; hostname?: string }
133
+ | { kind: "spawned" };
134
+
135
+ /**
136
+ * Reconcile the two external integration files after either ensure race window.
137
+ * Only the live proxy's observed bind host crosses this boundary; persisted
138
+ * config is deliberately loaded inside each mutation helper.
139
+ */
140
+ export async function reconcileEnsureDesiredIntegrations(
141
+ port: number,
142
+ branch: EnsureDesiredIntegrationsBranch,
143
+ deps: EnsureDesiredIntegrationsDeps = productionDeps,
144
+ ): Promise<void> {
145
+ const liveHost = branch.kind === "live" ? branch.hostname : undefined;
146
+ await ensureGrokFenceMatchesDesired(
147
+ port,
148
+ liveHost ? { hostname: liveHost } : {},
149
+ deps,
150
+ );
151
+ ensureClaudeDesktopMatchesDesired(deps);
152
+ }
package/src/cli/help.ts CHANGED
@@ -60,7 +60,7 @@ Usage:
60
60
  ocx memory [--json] Alias of ocx observe memory
61
61
  ocx api-key <sub> Alias of ocx access key
62
62
  ocx access <sub> External API keys and endpoint information
63
- ocx export --client <id> Print a client config wired to the running proxy (10 clients)
63
+ ocx export --client <id> Print a client config wired to the running proxy (11 clients)
64
64
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
65
65
  ocx grok <sub> Grok Build model selection and apply
66
66
  ocx system <sub> Runtime settings, startup, sync, and updates
package/src/cli/index.ts CHANGED
@@ -54,7 +54,18 @@ import { maybeShowStarPrompt } from "./star-prompt";
54
54
  import { scheduleCatalogPrewarm } from "./catalog-prewarm";
55
55
  import { maybeShowUpdatePrompt } from "../update/notify";
56
56
  import { syncModelsToCodex } from "../codex/sync";
57
- import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
57
+ import {
58
+ shouldSyncGrokOnStart,
59
+ syncCodexOnStartIfEnabled,
60
+ } from "../codex/desired-state";
61
+ import {
62
+ reconcileClientStartupBeforeReady,
63
+ syncClaudeAgentDefsAtProxyStartup,
64
+ } from "./claude-agent-startup-sync";
65
+ import {
66
+ grokSyncFailureMessage,
67
+ reconcileEnsureDesiredIntegrations,
68
+ } from "./ensure-desired-integrations";
58
69
 
59
70
  /**
60
71
  * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing
@@ -116,21 +127,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
116
127
  return null;
117
128
  }
118
129
 
119
- /**
120
- * A Grok fence sync that throws is best-effort by design — it must never block startup.
121
- * Reporting nothing, however, is what lets a STALE fence survive: `~/.grok/config.toml`
122
- * keeps naming whatever port the last successful sync wrote, and once that listener is
123
- * gone every grok turn retries against a refused connection while our own log stays
124
- * silent (2026-07-27 field report: 8 entries pinned to a dead 127.0.0.1:4179).
125
- * So say what failed and name the single command that repairs it.
126
- */
127
- function grokSyncFailureMessage(err: unknown): string {
128
- const detail = err instanceof Error ? err.message : String(err);
129
- return `Grok Build config sync failed: ${detail}. `
130
- + "~/.grok/config.toml may still point at a previous proxy port — "
131
- + "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it.";
132
- }
133
-
134
130
  /** Argv for detached `start`, optionally hard-pinning the listen port. */
135
131
  function startArgv(port?: number): string[] {
136
132
  const args = ["start"];
@@ -381,14 +377,18 @@ async function handleStart(options: { block?: boolean } = {}) {
381
377
  // The hook is useful only for an installed Claude Code CLI. Reconcile instead of
382
378
  // appending unconditionally so stale OpenCodex-owned hooks are removed as well.
383
379
  reportShellHookFailure(reconcileShellHook(systemEnv.injected));
384
-
385
380
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
386
- // Post-startup sync drives the readiness gate AND the #1046 stale app-server
387
- // warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle
388
- // (OFF no sync) and reports whether anything was written; the readiness gate
389
- // observes the real sync outcome (ok/warning) so /readyz never advertises a
390
- // half-synced proxy as ready while /healthz stays live.
391
- const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate);
381
+ // Codex sync owns the ready/failed verdict, but its successful transition is
382
+ // deferred until the best-effort Claude roster reconciliation settles. This
383
+ // keeps /readyz closed across both startup writes without making an optional
384
+ // Claude integration failure prevent the proxy from starting.
385
+ const startupSync = await reconcileClientStartupBeforeReady(
386
+ readinessGate,
387
+ gate => syncCodexOnStartIfEnabled(port, config, undefined, gate),
388
+ () => systemEnv.injected
389
+ ? Promise.resolve(null)
390
+ : syncClaudeAgentDefsAtProxyStartup(config, port),
391
+ );
392
392
  if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
393
393
  // #1046: one warning per startup, after BOTH writes. The server's cache
394
394
  // invalidation happens first and the catalog sync second, so the mtime is only
@@ -469,14 +469,15 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
469
469
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
470
470
  const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
471
471
  reportShellHookFailure(reconcileShellHook(systemEnv.injected));
472
+ if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, live.port);
472
473
  // Refresh the Grok Build fence too (same contract as start). live.hostname is the
473
474
  // hostname the running proxy actually bound — config.hostname may have drifted.
474
- try {
475
- const { syncGrokConfig } = await import("../grok/sync");
476
- const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {});
477
- if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
478
- else if (!g.ok) console.error(`⚠️ ${g.message}`);
479
- } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
475
+ // The reconciler re-reads immediately before each client-file mutation; only
476
+ // the live proxy's observed bind host is safe to carry across this boundary.
477
+ await reconcileEnsureDesiredIntegrations(
478
+ live.port,
479
+ { kind: "live", hostname: live.hostname },
480
+ );
480
481
  console.log(`✅ Proxy running on port ${live.port}`);
481
482
  return true;
482
483
  }
@@ -496,15 +497,12 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
496
497
  process.exitCode = 1;
497
498
  return false;
498
499
  }
499
- // Deterministic fence guarantee: the spawned child injects late in its own startup, but
500
- // this parent returns as soon as /healthz responds — inject here too (idempotent block
501
- // replace) so `ocx ensure` never returns without the Grok fence in place.
502
- try {
503
- const { syncGrokConfig } = await import("../grok/sync");
504
- const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});
505
- if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
506
- else if (!g.ok) console.error(`⚠️ ${g.message}`);
507
- } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
500
+ // Deterministic fence guarantee when the durable switch is ON: the spawned child
501
+ // injects late in its own startup, but this parent returns as soon as /healthz
502
+ // responds — align here too so `ocx ensure` never returns with a stale ON/OFF mismatch.
503
+ // Persisted state is loaded inside each mutation after waitForProxy, so a
504
+ // toggle while the child starts wins over the pre-spawn snapshot.
505
+ await reconcileEnsureDesiredIntegrations(port, { kind: "spawned" });
508
506
  // Always sync the LIVE port: after a fallback-port start, config.port still names the
509
507
  // busy preferred port — syncing that would point Codex at a dead listener.
510
508
  const synced = await syncModelsToCodex(port).catch(e => {
@@ -512,6 +510,10 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
512
510
  return null;
513
511
  });
514
512
  if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
513
+ // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent
514
+ // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are
515
+ // still observable. Always use the live port, including fallback-port starts.
516
+ await syncClaudeAgentDefsAtProxyStartup(config, port);
515
517
  console.log(`✅ Proxy running on port ${port}`);
516
518
  return true;
517
519
  }
@@ -18,7 +18,7 @@ const CLAUDE_USAGE = `Usage:
18
18
  [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>]
19
19
  [--compact-window <tokens|default>] [--inject-agents <on|off>]
20
20
  [--small-fast-model <id|->] [--model-map <from=to,from=to|->]
21
- [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|->]
21
+ [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->]
22
22
  [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]`;
23
23
 
24
24
  const GROK_USAGE = `Usage:
@@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
216
216
  { name: "api-key", usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
217
217
  {
218
218
  name: "export",
219
- usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode> [--json] [--out <path>] [--force]",
220
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode) wired to the running proxy.",
219
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime> [--json] [--out <path>] [--force]",
220
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent) wired to the running proxy.",
221
221
  details: [
222
222
  "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
223
223
  "--out <path> writes the native config there and refuses to replace an existing file without --force.",