@bitkyc08/opencodex 2.10.0 → 2.10.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.
- package/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
|
|
4
|
+
import { COMBO_NAMESPACE } from "../combos";
|
|
5
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
6
|
+
import { getAuthStorePath } from "../oauth/store";
|
|
7
|
+
import type { OcxConfig } from "../types";
|
|
8
|
+
import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
|
|
9
|
+
import {
|
|
10
|
+
type CatalogSourceForGather,
|
|
11
|
+
bundledCatalogCacheState,
|
|
12
|
+
resolveCatalogSourceForGather,
|
|
13
|
+
} from "./catalog/bundled";
|
|
14
|
+
import {
|
|
15
|
+
acceptCatalogGatherSourcePath,
|
|
16
|
+
captureAndSealCatalogHomeSelection,
|
|
17
|
+
captureCatalogGatherTargetIdentity,
|
|
18
|
+
createCatalogGatherEvidenceSession,
|
|
19
|
+
readCatalogGatherSource,
|
|
20
|
+
sealCatalogGatherEvidenceSession,
|
|
21
|
+
type CatalogFilesystemEvidenceSession,
|
|
22
|
+
} from "./catalog/filesystem-evidence";
|
|
23
|
+
import {
|
|
24
|
+
CatalogGatherBusyError,
|
|
25
|
+
createCatalogGatherAuthorityIdentity,
|
|
26
|
+
filterCatalogVisibleModels,
|
|
27
|
+
gatherRoutedModelsForCatalogGather,
|
|
28
|
+
type CatalogGatherProviderAuthOutcome,
|
|
29
|
+
} from "./catalog/provider-fetch";
|
|
30
|
+
import {
|
|
31
|
+
catalogBackupPathFor,
|
|
32
|
+
catalogHasRoutedEntries,
|
|
33
|
+
findNativeTemplate,
|
|
34
|
+
legacyCatalogBackupPath,
|
|
35
|
+
parseCatalogJson,
|
|
36
|
+
type RawCatalog,
|
|
37
|
+
} from "./catalog/parsing";
|
|
38
|
+
import {
|
|
39
|
+
buildCatalogEntries,
|
|
40
|
+
orderForSubagents,
|
|
41
|
+
} from "./catalog/sync";
|
|
42
|
+
import { exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
43
|
+
import { disabledNativeSlugs } from "./catalog/metadata";
|
|
44
|
+
import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
|
|
45
|
+
import { withCatalogWriteSerialization } from "./catalog-write-serialization";
|
|
46
|
+
import {
|
|
47
|
+
publishHashedCodexCatalogBackup,
|
|
48
|
+
publishLegacyCodexCatalogBackup,
|
|
49
|
+
replaceActiveCodexCatalog,
|
|
50
|
+
replaceCodexModelsCache,
|
|
51
|
+
type PreparedCatalogFileWrite,
|
|
52
|
+
} from "./internal/catalog-writer";
|
|
53
|
+
import type {
|
|
54
|
+
CatalogAdmissionSnapshot,
|
|
55
|
+
CatalogDisposition,
|
|
56
|
+
CatalogGatherAuthorityIdentity,
|
|
57
|
+
CatalogNotice,
|
|
58
|
+
CatalogProviderDiscoveryPolicySnapshot,
|
|
59
|
+
CatalogProcessLocalEvidence,
|
|
60
|
+
CatalogSourceEvidence,
|
|
61
|
+
CatalogSourceRole,
|
|
62
|
+
ConvergeRequest,
|
|
63
|
+
} from "./convergence-types";
|
|
64
|
+
|
|
65
|
+
export interface CatalogWriteReceipt {
|
|
66
|
+
readonly keyedBackup: "written" | "preserved" | "not-requested";
|
|
67
|
+
readonly legacyBackup: "written" | "preserved" | "not-requested";
|
|
68
|
+
readonly catalog: "written" | "not-written";
|
|
69
|
+
readonly cache: "written" | "not-written";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type CodexCatalogCommitResult =
|
|
73
|
+
| { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt }
|
|
74
|
+
| { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" }
|
|
75
|
+
| { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" }
|
|
76
|
+
| { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt };
|
|
77
|
+
|
|
78
|
+
declare const catalogCandidateBrand: unique symbol;
|
|
79
|
+
export interface CodexCatalogCandidate { readonly [catalogCandidateBrand]: true }
|
|
80
|
+
|
|
81
|
+
export type CodexCatalogGatherResult =
|
|
82
|
+
| { readonly kind: "candidate"; readonly candidate: CodexCatalogCandidate }
|
|
83
|
+
| { readonly kind: "disposition"; readonly disposition: CatalogDisposition };
|
|
84
|
+
|
|
85
|
+
type CommitAttempt = CodexCatalogCommitResult | { readonly kind: "busy" };
|
|
86
|
+
|
|
87
|
+
interface CandidateState {
|
|
88
|
+
consumed: boolean;
|
|
89
|
+
readonly generation: CatalogAdmissionSnapshot["generation"];
|
|
90
|
+
readonly authority: CatalogGatherAuthorityIdentity;
|
|
91
|
+
readonly sourceEvidence: CatalogSourceEvidence;
|
|
92
|
+
readonly processLocal: CatalogProcessLocalEvidence;
|
|
93
|
+
readonly home: string;
|
|
94
|
+
readonly targets: CatalogAdmissionSnapshot["targets"];
|
|
95
|
+
readonly catalog: PreparedCatalogFileWrite;
|
|
96
|
+
readonly cache: PreparedCatalogFileWrite;
|
|
97
|
+
readonly keyedBackup?: PreparedCatalogFileWrite;
|
|
98
|
+
readonly legacyBackup?: PreparedCatalogFileWrite;
|
|
99
|
+
readonly changed: boolean;
|
|
100
|
+
readonly notices: readonly CatalogNotice[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const candidateStates = new WeakMap<object, CandidateState>();
|
|
104
|
+
function same(left: unknown, right: unknown): boolean {
|
|
105
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function targetPath(identity: string): string {
|
|
109
|
+
const parsed = JSON.parse(identity) as { path?: unknown };
|
|
110
|
+
if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
|
|
111
|
+
return parsed.path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function catalogFrom(bytes: Uint8Array | null): RawCatalog | null {
|
|
115
|
+
return bytes === null ? null : parseCatalogJson(Buffer.from(bytes).toString("utf8"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function catalogBytes(catalog: RawCatalog): string {
|
|
119
|
+
return `${JSON.stringify(catalog, null, 2)}\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ReadonlyRawCatalogLike {
|
|
123
|
+
readonly models?: readonly Readonly<Record<string, unknown>>[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasRoutedEntries(catalog: ReadonlyRawCatalogLike): boolean {
|
|
127
|
+
return (catalog.models ?? []).some(entry => typeof entry.slug === "string" && entry.slug.includes("/"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvidence {
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
runtime: Object.freeze({ ...source.processLocal.runtime }),
|
|
133
|
+
bundledCatalog: Object.freeze({ ...source.processLocal.bundledCatalog }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bindGatherPaths(
|
|
138
|
+
session: CatalogFilesystemEvidenceSession,
|
|
139
|
+
snapshot: CatalogAdmissionSnapshot,
|
|
140
|
+
): Readonly<{ catalog: string; cache: string; keyedBackup: string; legacyBackup?: string }> {
|
|
141
|
+
const catalog = targetPath(snapshot.targets.catalog);
|
|
142
|
+
const cache = targetPath(snapshot.targets.cache);
|
|
143
|
+
const keyedBackup = targetPath(snapshot.targets.catalogBackups[0]!);
|
|
144
|
+
const legacyBackup = snapshot.targets.catalogBackups[1]
|
|
145
|
+
? targetPath(snapshot.targets.catalogBackups[1]) : undefined;
|
|
146
|
+
const configPath = snapshot.sourceEvidence.required["catalog-target-selection"].logicalPath;
|
|
147
|
+
|
|
148
|
+
acceptCatalogGatherSourcePath(session, "catalog-target-selection", configPath);
|
|
149
|
+
readCatalogGatherSource(session, "catalog-target-selection");
|
|
150
|
+
acceptCatalogGatherSourcePath(session, "active-catalog-merge", catalog);
|
|
151
|
+
acceptCatalogGatherSourcePath(session, "hashed-backup-fallback", keyedBackup);
|
|
152
|
+
acceptCatalogGatherSourcePath(session, "legacy-backup-fallback", legacyBackup ?? legacyCatalogBackupPath());
|
|
153
|
+
acceptCatalogGatherSourcePath(session, "models-cache-fallback", cache);
|
|
154
|
+
acceptCatalogGatherSourcePath(session, "runtime-selection", codexRuntimeStatePath(getConfigDir()));
|
|
155
|
+
acceptCatalogGatherSourcePath(session, "provider-auth-selection", getAuthStorePath());
|
|
156
|
+
acceptCatalogGatherSourcePath(session, "native-catalog-selection", catalog);
|
|
157
|
+
return { catalog, cache, keyedBackup, ...(legacyBackup ? { legacyBackup } : {}) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function prepareCatalog(
|
|
161
|
+
config: Readonly<OcxConfig>,
|
|
162
|
+
source: Extract<CatalogSourceForGather, { kind: "available" }>,
|
|
163
|
+
active: RawCatalog | null,
|
|
164
|
+
routedModels: Awaited<ReturnType<typeof gatherRoutedModelsForCatalogGather>>,
|
|
165
|
+
): RawCatalog {
|
|
166
|
+
const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
|
|
167
|
+
const template = findNativeTemplate(catalog);
|
|
168
|
+
const enabled = filterCatalogVisibleModels(routedModels, config);
|
|
169
|
+
const featured = config.subagentModels ?? [];
|
|
170
|
+
const ordered = orderForSubagents(enabled, featured);
|
|
171
|
+
const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
|
|
172
|
+
? config.multiAgentMode : "default";
|
|
173
|
+
const exactComboSlugs = exactComboCatalogSlugs(config);
|
|
174
|
+
const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
|
|
175
|
+
const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true);
|
|
176
|
+
const includeNativeOpenAi = enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => (
|
|
177
|
+
name === "openai" && isCanonicalOpenAiForwardProvider(provider)
|
|
178
|
+
));
|
|
179
|
+
const disabledNative = disabledNativeSlugs(config);
|
|
180
|
+
const nativeSlugs = includeNativeOpenAi
|
|
181
|
+
? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => (
|
|
182
|
+
typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug)
|
|
183
|
+
? [entry.slug] : []
|
|
184
|
+
)))]
|
|
185
|
+
: [];
|
|
186
|
+
const entries = buildCatalogEntries(
|
|
187
|
+
template ? JSON.parse(JSON.stringify(template)) : null,
|
|
188
|
+
nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs,
|
|
189
|
+
);
|
|
190
|
+
if (entries.length === nativeSlugs.length) {
|
|
191
|
+
const configuredProviders = new Set(enabledProviders.map(([name]) => name));
|
|
192
|
+
const preserved = (active?.models ?? []).filter(entry => {
|
|
193
|
+
if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false;
|
|
194
|
+
const provider = entry.slug.slice(0, entry.slug.indexOf("/"));
|
|
195
|
+
const description = typeof entry.description === "string" ? entry.description : "";
|
|
196
|
+
return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → ");
|
|
197
|
+
});
|
|
198
|
+
entries.push(...preserved);
|
|
199
|
+
}
|
|
200
|
+
if (!hasPhysicalComboProvider) {
|
|
201
|
+
const exact = exactComboSlugs;
|
|
202
|
+
catalog.models = entries.filter(entry => (
|
|
203
|
+
typeof entry.slug !== "string" || !exact.has(entry.slug)
|
|
204
|
+
|| (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0)
|
|
205
|
+
));
|
|
206
|
+
} else {
|
|
207
|
+
catalog.models = entries;
|
|
208
|
+
}
|
|
209
|
+
return catalog;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function gatherCodexCatalogCandidate(
|
|
213
|
+
snapshot: CatalogAdmissionSnapshot,
|
|
214
|
+
): Promise<CodexCatalogGatherResult> {
|
|
215
|
+
let providerGatherStarted = false;
|
|
216
|
+
try {
|
|
217
|
+
const session = createCatalogGatherEvidenceSession();
|
|
218
|
+
const home = captureAndSealCatalogHomeSelection(session);
|
|
219
|
+
if (!same(home, snapshot.sourceEvidence.homeSelection)) {
|
|
220
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
|
|
221
|
+
}
|
|
222
|
+
const paths = bindGatherPaths(session, snapshot);
|
|
223
|
+
const source = resolveCatalogSourceForGather(session);
|
|
224
|
+
if (source.kind === "catalog-unavailable") {
|
|
225
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "catalog-unavailable", retryable: false } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const activeBytes = readCatalogGatherSource(session, "active-catalog-merge");
|
|
229
|
+
const cacheBytes = readCatalogGatherSource(session, "models-cache-fallback");
|
|
230
|
+
const keyedBackupBytes = readCatalogGatherSource(session, "hashed-backup-fallback");
|
|
231
|
+
const legacyBackupBytes = paths.legacyBackup
|
|
232
|
+
? readCatalogGatherSource(session, "legacy-backup-fallback") : null;
|
|
233
|
+
if (Object.keys(snapshot.config.combos ?? {}).length > 0) {
|
|
234
|
+
readCatalogGatherSource(session, "native-catalog-selection");
|
|
235
|
+
}
|
|
236
|
+
const authOutcomes: CatalogGatherProviderAuthOutcome[] = [];
|
|
237
|
+
const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = [];
|
|
238
|
+
providerGatherStarted = true;
|
|
239
|
+
const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, {
|
|
240
|
+
providerAuthOutcomes: authOutcomes,
|
|
241
|
+
discoveryPolicySnapshots: discoveryPolicies,
|
|
242
|
+
});
|
|
243
|
+
const processLocal = processEvidence(source);
|
|
244
|
+
const sourceEvidence = sealCatalogGatherEvidenceSession(session);
|
|
245
|
+
if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) {
|
|
246
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const current = captureCatalogAdmissionSnapshot(snapshot.config);
|
|
250
|
+
if (!same(current.configIdentity, snapshot.configIdentity)
|
|
251
|
+
|| !same(current.targets, snapshot.targets)
|
|
252
|
+
|| !same(current.sourceEvidence.homeSelection, snapshot.sourceEvidence.homeSelection)
|
|
253
|
+
|| !same(current.sourceEvidence.required, snapshot.sourceEvidence.required)) {
|
|
254
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const active = catalogFrom(activeBytes);
|
|
258
|
+
const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels);
|
|
259
|
+
const preparedCatalogBytes = catalogBytes(preparedCatalog);
|
|
260
|
+
const preparedCacheBytes = `${JSON.stringify({
|
|
261
|
+
fetched_at: "2000-01-01T00:00:00Z",
|
|
262
|
+
client_version: "0.0.0",
|
|
263
|
+
models: preparedCatalog.models ?? [],
|
|
264
|
+
}, null, 2)}\n`;
|
|
265
|
+
const pristineBytes = active && !catalogHasRoutedEntries(active)
|
|
266
|
+
? Buffer.from(activeBytes!).toString("utf8")
|
|
267
|
+
: !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null;
|
|
268
|
+
const notices = new Set<CatalogNotice>();
|
|
269
|
+
if (source.source !== "bundled-catalog-template") notices.add("fallback");
|
|
270
|
+
if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth");
|
|
271
|
+
const candidate = {} as CodexCatalogCandidate;
|
|
272
|
+
candidateStates.set(candidate, {
|
|
273
|
+
consumed: false,
|
|
274
|
+
generation: snapshot.generation,
|
|
275
|
+
authority: createCatalogGatherAuthorityIdentity(
|
|
276
|
+
snapshot,
|
|
277
|
+
sourceEvidence,
|
|
278
|
+
processLocal,
|
|
279
|
+
discoveryPolicies,
|
|
280
|
+
),
|
|
281
|
+
sourceEvidence,
|
|
282
|
+
processLocal,
|
|
283
|
+
home: home.canonicalCodexHome,
|
|
284
|
+
targets: snapshot.targets,
|
|
285
|
+
catalog: { path: paths.catalog, content: preparedCatalogBytes },
|
|
286
|
+
cache: { path: paths.cache, content: preparedCacheBytes },
|
|
287
|
+
...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}),
|
|
288
|
+
...(pristineBytes && paths.legacyBackup
|
|
289
|
+
? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}),
|
|
290
|
+
changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes
|
|
291
|
+
|| Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
|
|
292
|
+
notices: Object.freeze([...notices]),
|
|
293
|
+
});
|
|
294
|
+
return { kind: "candidate", candidate };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error instanceof CatalogGatherBusyError) {
|
|
297
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "busy", retryable: true } };
|
|
298
|
+
}
|
|
299
|
+
if (!providerGatherStarted) {
|
|
300
|
+
return { kind: "disposition", disposition: { status: "skipped", reason: "refused", retryable: false } };
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
kind: "disposition",
|
|
304
|
+
disposition: { status: "failed", reason: "provider-network", phase: "gather", retryable: true, partialWrite: false },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
|
|
310
|
+
let session: CatalogFilesystemEvidenceSession;
|
|
311
|
+
let validatingTargets = false;
|
|
312
|
+
try {
|
|
313
|
+
session = createCatalogGatherEvidenceSession();
|
|
314
|
+
const home = captureAndSealCatalogHomeSelection(session);
|
|
315
|
+
if (!same(home, state.sourceEvidence.homeSelection)) return { kind: "stale", reason: "home-selection" };
|
|
316
|
+
validatingTargets = true;
|
|
317
|
+
const currentTargets = {
|
|
318
|
+
catalog: captureCatalogGatherTargetIdentity(session, state.catalog.path),
|
|
319
|
+
cache: captureCatalogGatherTargetIdentity(session, state.cache.path),
|
|
320
|
+
catalogBackups: state.targets.catalogBackups.map(identity => (
|
|
321
|
+
captureCatalogGatherTargetIdentity(session, targetPath(identity))
|
|
322
|
+
)),
|
|
323
|
+
};
|
|
324
|
+
if (!same(currentTargets, state.targets)) return { kind: "stale", reason: "target-identity" };
|
|
325
|
+
validatingTargets = false;
|
|
326
|
+
for (const observation of [state.sourceEvidence.required["catalog-target-selection"]]) {
|
|
327
|
+
acceptCatalogGatherSourcePath(session, observation.role, observation.logicalPath);
|
|
328
|
+
readCatalogGatherSource(session, observation.role);
|
|
329
|
+
}
|
|
330
|
+
for (const [role, observations] of Object.entries(state.sourceEvidence.conditional)) {
|
|
331
|
+
for (const observation of observations) {
|
|
332
|
+
acceptCatalogGatherSourcePath(session, role as CatalogSourceRole, observation.logicalPath);
|
|
333
|
+
readCatalogGatherSource(session, role as CatalogSourceRole);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!same(sealCatalogGatherEvidenceSession(session), state.sourceEvidence)) {
|
|
337
|
+
return { kind: "stale", reason: "source-observation" };
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
return { kind: "refused", reason: validatingTargets ? "target-unsafe" : "source-unreadable" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (state.processLocal.runtime.state === "used") {
|
|
344
|
+
const current = peekCodexRuntimeProcessCache();
|
|
345
|
+
if (current.kind !== "available" || current.epoch !== state.processLocal.runtime.epoch
|
|
346
|
+
|| current.valueIdentity !== state.processLocal.runtime.valueIdentity) {
|
|
347
|
+
return { kind: "stale", reason: "process-local" };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (state.processLocal.bundledCatalog.state === "used") {
|
|
351
|
+
const current = bundledCatalogCacheState();
|
|
352
|
+
if (current.epoch !== state.processLocal.bundledCatalog.epoch
|
|
353
|
+
|| current.valueIdentity !== state.processLocal.bundledCatalog.valueIdentity) {
|
|
354
|
+
return { kind: "stale", reason: "process-local" };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceActiveCodexCatalog>[0]): CodexCatalogCommitResult {
|
|
361
|
+
let writes: CatalogWriteReceipt = {
|
|
362
|
+
keyedBackup: "not-requested",
|
|
363
|
+
legacyBackup: "not-requested",
|
|
364
|
+
catalog: "not-written",
|
|
365
|
+
cache: "not-written",
|
|
366
|
+
};
|
|
367
|
+
try {
|
|
368
|
+
if (state.keyedBackup) {
|
|
369
|
+
writes = { ...writes, keyedBackup: publishHashedCodexCatalogBackup(permit, state.home, state.keyedBackup) };
|
|
370
|
+
}
|
|
371
|
+
if (state.legacyBackup) {
|
|
372
|
+
writes = { ...writes, legacyBackup: publishLegacyCodexCatalogBackup(permit, state.home, state.legacyBackup) };
|
|
373
|
+
}
|
|
374
|
+
replaceActiveCodexCatalog(permit, state.home, state.catalog);
|
|
375
|
+
writes = { ...writes, catalog: "written" };
|
|
376
|
+
replaceCodexModelsCache(permit, state.home, state.cache);
|
|
377
|
+
writes = { ...writes, cache: "written" };
|
|
378
|
+
return { kind: "committed", changed: state.changed, writes };
|
|
379
|
+
} catch {
|
|
380
|
+
return { kind: "failed", surface: "disk", writes };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function commitCodexCatalogCandidate(
|
|
385
|
+
candidate: CodexCatalogCandidate,
|
|
386
|
+
deadlineMs: number,
|
|
387
|
+
): Promise<CommitAttempt> {
|
|
388
|
+
const state = candidateStates.get(candidate as object);
|
|
389
|
+
if (!state) return { kind: "refused", reason: "source-ambiguous" };
|
|
390
|
+
if (state.consumed) return { kind: "stale", reason: "candidate-consumed" };
|
|
391
|
+
const deadline = Date.now() + Math.max(0, deadlineMs);
|
|
392
|
+
while (true) {
|
|
393
|
+
const acquired = withCatalogWriteSerialization(state.home, permit => {
|
|
394
|
+
state.consumed = true;
|
|
395
|
+
const guarded = withExpectedConfigGenerationSync(state.generation, () => {
|
|
396
|
+
const invalid = revalidateCandidate(state);
|
|
397
|
+
return invalid ?? fixedCommit(state, permit);
|
|
398
|
+
});
|
|
399
|
+
if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const;
|
|
400
|
+
if (guarded.kind === "unavailable") return { kind: "busy" } as const;
|
|
401
|
+
return guarded.value;
|
|
402
|
+
});
|
|
403
|
+
if (acquired.kind === "completed") return acquired.value;
|
|
404
|
+
if (acquired.reason !== "busy" || Date.now() >= deadline) return { kind: "busy" };
|
|
405
|
+
await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[]): CatalogDisposition {
|
|
410
|
+
if (result.kind === "busy") return { status: "skipped", reason: "busy", retryable: true };
|
|
411
|
+
if (result.kind === "committed") {
|
|
412
|
+
return { status: "committed", changed: result.changed, degraded: notices.length > 0, notices };
|
|
413
|
+
}
|
|
414
|
+
if (result.kind === "stale") return { status: "skipped", reason: "stale", retryable: true };
|
|
415
|
+
if (result.kind === "refused") return { status: "skipped", reason: "refused", retryable: false };
|
|
416
|
+
const partialWrite = result.writes.keyedBackup === "written"
|
|
417
|
+
|| result.writes.legacyBackup === "written" || result.writes.catalog === "written";
|
|
418
|
+
return { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function convergeCodexCatalog(
|
|
422
|
+
snapshot: CatalogAdmissionSnapshot,
|
|
423
|
+
request: ConvergeRequest,
|
|
424
|
+
lifecycle: Readonly<{ onCommitBegin?: () => void }> = {},
|
|
425
|
+
): Promise<Readonly<{ changed: boolean; catalogRefresh: CatalogDisposition }>> {
|
|
426
|
+
if (request.scope !== "catalog" || request.action !== "converge") {
|
|
427
|
+
return {
|
|
428
|
+
changed: false,
|
|
429
|
+
catalogRefresh: { status: "failed", reason: "disk", phase: "gather", retryable: false, partialWrite: false },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const gathered = await gatherCodexCatalogCandidate(snapshot);
|
|
433
|
+
if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition };
|
|
434
|
+
const state = candidateStates.get(gathered.candidate as object)!;
|
|
435
|
+
lifecycle.onCommitBegin?.();
|
|
436
|
+
const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
|
|
437
|
+
return {
|
|
438
|
+
changed: committed.kind === "committed" ? committed.changed : false,
|
|
439
|
+
catalogRefresh: projectCommit(committed, state.notices),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable desired state for the native Codex integration.
|
|
3
|
+
*
|
|
4
|
+
* The switch itself was never the hard part — `ocx restore` already returns Codex
|
|
5
|
+
* to its native path without stopping the proxy. What was missing is that the
|
|
6
|
+
* decision did not survive: `ocx start` force-synced unconditionally, so an OFF
|
|
7
|
+
* lasted exactly until the next start. That is the defect this module closes, and
|
|
8
|
+
* it is the same one Grok's shipped toggle still has.
|
|
9
|
+
*
|
|
10
|
+
* ABSENT MEANS ON. A user who never touched a switch, a config written by an
|
|
11
|
+
* older binary, and an explicit `true` are the same state, and none of them may
|
|
12
|
+
* be read as "the user turned this off". Only an explicit `false` is OFF.
|
|
13
|
+
*
|
|
14
|
+
* This module does NOT own linearization. The plan that predates
|
|
15
|
+
* `src/codex/user-identity.ts` proposed a second per-home lock at
|
|
16
|
+
* `tmpdir()/opencodex-native-locks/sha256(home).sqlite`; that keys on the home
|
|
17
|
+
* alone, carries no proof of effective user, and would collide or split
|
|
18
|
+
* depending on the temp root. Convergence takes the write lock; this module only
|
|
19
|
+
* records intent through the config coordinator.
|
|
20
|
+
*
|
|
21
|
+
* Design record: devlog/_plan/260803_codex_desktop_toggle/030_desired_state.md.
|
|
22
|
+
*/
|
|
23
|
+
import { loadConfig, mutatePersistedConfig } from "../config";
|
|
24
|
+
import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
|
|
25
|
+
|
|
26
|
+
/** Clients whose durable intent this module owns. */
|
|
27
|
+
export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
|
|
28
|
+
|
|
29
|
+
/** Injectable for tests; production passes the real sync. */
|
|
30
|
+
export type CodexStartupSync = (port: number) => Promise<unknown>;
|
|
31
|
+
|
|
32
|
+
export type CodexDesiredStateResult =
|
|
33
|
+
| { readonly ok: true; readonly status: "committed" | "unchanged"; readonly enabled: boolean }
|
|
34
|
+
| {
|
|
35
|
+
readonly ok: false;
|
|
36
|
+
readonly reason: "missing" | "invalid" | "conflict";
|
|
37
|
+
readonly retryable: boolean;
|
|
38
|
+
readonly message: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is native Codex integration wanted?
|
|
43
|
+
*
|
|
44
|
+
* Takes the config rather than reading it, so a caller that already holds an
|
|
45
|
+
* admitted snapshot cannot accidentally answer from a fresher one — the whole
|
|
46
|
+
* point of admission is that one decision uses one set of bytes.
|
|
47
|
+
*/
|
|
48
|
+
export function integrationEnabled(
|
|
49
|
+
config: Pick<OcxConfig, "clientIntegrations">,
|
|
50
|
+
client: DurableIntentClientId,
|
|
51
|
+
): boolean {
|
|
52
|
+
return config.clientIntegrations?.[client] !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function codexIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrations">): boolean {
|
|
56
|
+
return integrationEnabled(config, "codex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Grok's toggle SHIPPED without this, which is the bug: it strips the fence in
|
|
61
|
+
* `~/.grok/config.toml` and records nothing, so the next `ocx start` calls
|
|
62
|
+
* `syncGrokConfig` unconditionally and writes the fence straight back.
|
|
63
|
+
*/
|
|
64
|
+
export function grokIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrations">): boolean {
|
|
65
|
+
return integrationEnabled(config, "grok");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The same question when no snapshot is in hand. Reads the persisted config. */
|
|
69
|
+
export function codexIntegrationEnabledNow(): boolean {
|
|
70
|
+
return codexIntegrationEnabled(loadConfig());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Persist the desired state, touching only that one field.
|
|
75
|
+
*
|
|
76
|
+
* Field-scoped on purpose: the callback runs on a freshly rebased snapshot inside
|
|
77
|
+
* the config coordinator, so a concurrent provider or model edit is preserved
|
|
78
|
+
* instead of being clobbered by a whole-config write built from a stale read.
|
|
79
|
+
*/
|
|
80
|
+
export function setIntegrationEnabled(
|
|
81
|
+
client: DurableIntentClientId,
|
|
82
|
+
enabled: boolean,
|
|
83
|
+
): CodexDesiredStateResult {
|
|
84
|
+
const outcome = mutatePersistedConfig(config => {
|
|
85
|
+
const current = integrationEnabled(config, client);
|
|
86
|
+
if (current === enabled) return { changed: false, value: enabled };
|
|
87
|
+
const integrations = { ...(config.clientIntegrations ?? {}) };
|
|
88
|
+
if (enabled) {
|
|
89
|
+
// ON is the absence, not a stored `true`: writing `true` would make an
|
|
90
|
+
// untouched config and a re-enabled one differ in bytes for no reason, and
|
|
91
|
+
// every later reader has to treat them identically anyway.
|
|
92
|
+
delete integrations[client];
|
|
93
|
+
} else {
|
|
94
|
+
integrations[client] = false;
|
|
95
|
+
}
|
|
96
|
+
// Drop the key entirely once nothing is left in it, so enabling twice does
|
|
97
|
+
// not leave `"clientIntegrations": {}` behind in the user's file.
|
|
98
|
+
if (Object.keys(integrations).length === 0) delete config.clientIntegrations;
|
|
99
|
+
else config.clientIntegrations = integrations;
|
|
100
|
+
return { changed: true, value: enabled };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (outcome.status !== "unavailable") {
|
|
104
|
+
return { ok: true, status: outcome.status, enabled };
|
|
105
|
+
}
|
|
106
|
+
// `conflict` is the only retryable one: it means a competing writer won the
|
|
107
|
+
// rebase, so the same call can succeed. A missing or malformed config will fail
|
|
108
|
+
// identically forever, and telling a caller to retry that is how a UI ends up
|
|
109
|
+
// spinning on a problem only the user can fix.
|
|
110
|
+
const retryable = outcome.reason === "conflict";
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: outcome.reason,
|
|
114
|
+
retryable,
|
|
115
|
+
message: outcome.reason === "conflict"
|
|
116
|
+
? "Another process changed the config while this switch was being written."
|
|
117
|
+
: outcome.reason === "missing"
|
|
118
|
+
? "No config file exists to record the switch in."
|
|
119
|
+
: "The config file is malformed; refusing to overwrite it.",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function setCodexIntegrationEnabled(enabled: boolean): CodexDesiredStateResult {
|
|
124
|
+
return setIntegrationEnabled("codex", enabled);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateResult {
|
|
128
|
+
return setIntegrationEnabled("grok", enabled);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The startup gate, as a function rather than an `if` buried in `handleStart`.
|
|
133
|
+
*
|
|
134
|
+
* `ocx start` used to call `syncModelsToCodex(port).catch(() => {})`
|
|
135
|
+
* unconditionally, which is exactly why turning Codex off lasted until the next
|
|
136
|
+
* start: the restore worked, and then start put the routing straight back. It
|
|
137
|
+
* lived inline in a 600-line startup function that opens sockets and installs
|
|
138
|
+
* services, so nothing could test it — and an untestable gate is how the
|
|
139
|
+
* unconditional version survived this long.
|
|
140
|
+
*
|
|
141
|
+
* The `.catch` is deliberate and stays: a provider fetch failing at startup must
|
|
142
|
+
* not stop the proxy from coming up. Swallowing a failure to APPLY is tolerable.
|
|
143
|
+
* Swallowing the user's decision was not.
|
|
144
|
+
*
|
|
145
|
+
* Returns whether the sync ran, so a caller — or a test — can tell "skipped
|
|
146
|
+
* because the user turned it off" from "ran and quietly failed".
|
|
147
|
+
*/
|
|
148
|
+
export async function syncCodexOnStartIfEnabled(
|
|
149
|
+
port: number,
|
|
150
|
+
config: Pick<OcxConfig, "clientIntegrations">,
|
|
151
|
+
sync: CodexStartupSync = defaultStartupSync,
|
|
152
|
+
): Promise<boolean> {
|
|
153
|
+
if (!codexIntegrationEnabled(config)) return false;
|
|
154
|
+
await sync(port).catch(() => {});
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function defaultStartupSync(port: number): Promise<unknown> {
|
|
159
|
+
const { syncModelsToCodex } = await import("./sync");
|
|
160
|
+
return syncModelsToCodex(port);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The Grok startup gate.
|
|
165
|
+
*
|
|
166
|
+
* Grok's toggle shipped and then `ocx start` called `syncGrokConfig`
|
|
167
|
+
* unconditionally, so switching Grok off lasted exactly one restart — the fence
|
|
168
|
+
* came out of `~/.grok/config.toml` and the next start wrote it straight back.
|
|
169
|
+
* Same defect as Codex had, in a different file.
|
|
170
|
+
*
|
|
171
|
+
* The caller keeps its own try/catch, because a Grok failure must never block
|
|
172
|
+
* startup and its diagnostic is worth printing. This only answers whether to
|
|
173
|
+
* attempt the sync at all.
|
|
174
|
+
*/
|
|
175
|
+
export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
|
|
176
|
+
return grokIntegrationEnabled(config);
|
|
177
|
+
}
|