@bitkyc08/opencodex 2.58.0 → 2.60.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.
- package/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
package/src/codex/quota-types.ts
CHANGED
|
@@ -1,4 +1,69 @@
|
|
|
1
1
|
/** Quota wire/storage shapes. This leaf must not import credential or config owners. */
|
|
2
|
+
export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How recently a 100% burst reading must have been observed to exclude an account when it
|
|
6
|
+
* carries no reset timestamp (#3425). This is deliberately far tighter than the disk-hydration
|
|
7
|
+
* horizon so a persisted reading cannot strand a recovered account. Routing and UI share this
|
|
8
|
+
* value because both must answer whether the same short-window observation is still current.
|
|
9
|
+
*/
|
|
10
|
+
export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A window reading at or above this is a measured refusal, not a position on a scale.
|
|
14
|
+
*
|
|
15
|
+
* Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
|
|
16
|
+
* "we have not observed this account", 100 is "we observed it and it is full". It lives on
|
|
17
|
+
* this leaf, next to the freshness window, because the dashboard has to answer the same
|
|
18
|
+
* question and cannot import the routing or disk-cache owners to do it.
|
|
19
|
+
*/
|
|
20
|
+
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Above this a value is already milliseconds; at or below it, it is Unix seconds.
|
|
24
|
+
*
|
|
25
|
+
* Both reach storage, so the split has to live somewhere every reader can see. It lives on this
|
|
26
|
+
* leaf rather than beside the merge that uses it because the dashboard reads the same stored
|
|
27
|
+
* value and cannot import the disk-cache owner.
|
|
28
|
+
*/
|
|
29
|
+
const RESET_AT_SECONDS_MAX = 10_000_000_000;
|
|
30
|
+
|
|
31
|
+
/** Normalize a stored reset instant to milliseconds. */
|
|
32
|
+
export function resetAtToMs(resetAt: number): number {
|
|
33
|
+
return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A short-only reading that proves the account is blocked NOW.
|
|
38
|
+
*
|
|
39
|
+
* Routing and the dashboard's account-switch warning must answer this identically for the same
|
|
40
|
+
* snapshot, or the warning tells an operator an account is usable while the router refuses it.
|
|
41
|
+
* They did not: the dashboard compared a stored reset against `Date.now()` without normalizing
|
|
42
|
+
* units, so a seconds-form future reset looked expired there and live here, and it treated a
|
|
43
|
+
* fresh observation as sufficient even when an ELAPSED reset was present, where routing treats
|
|
44
|
+
* the reset as authoritative once it exists (#5045).
|
|
45
|
+
*
|
|
46
|
+
* Freshness is not optional in the reset-less branch. `getAccountQuota` performs no expiry
|
|
47
|
+
* check, partial updates carry a still-open short tuple forward, and disk hydration accepts a
|
|
48
|
+
* persisted reading for hours, so scoring exhausted from `shortPercent` alone would keep
|
|
49
|
+
* excluding an account whose burst window has since reset.
|
|
50
|
+
*/
|
|
51
|
+
export function isTerminalShortWindow(
|
|
52
|
+
quota: Pick<StoredAccountQuota, "shortPercent" | "shortResetAt" | "shortObservedAt">,
|
|
53
|
+
now: number,
|
|
54
|
+
): boolean {
|
|
55
|
+
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
56
|
+
if (!finite(quota.shortPercent) || quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
|
|
57
|
+
const resetAt = quota.shortResetAt;
|
|
58
|
+
if (!finite(resetAt) || resetAt <= 0) {
|
|
59
|
+
const observedAt = quota.shortObservedAt;
|
|
60
|
+
if (!finite(observedAt)) return false;
|
|
61
|
+
const age = now - observedAt;
|
|
62
|
+
return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
|
|
63
|
+
}
|
|
64
|
+
return resetAtToMs(resetAt) > now;
|
|
65
|
+
}
|
|
66
|
+
|
|
2
67
|
export type StoredAccountQuota = {
|
|
3
68
|
weeklyPercent?: number;
|
|
4
69
|
monthlyPercent?: number;
|
package/src/codex/quota.ts
CHANGED
|
@@ -8,8 +8,11 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
|
|
|
8
8
|
|
|
9
9
|
import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
|
|
10
10
|
import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
|
|
11
|
+
import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_PERCENT, resetAtToMs } from "./quota-types";
|
|
11
12
|
import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
|
|
13
|
+
|
|
12
14
|
export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
|
|
15
|
+
export { CODEX_EXHAUSTED_USAGE_PERCENT } from "./quota-types";
|
|
13
16
|
|
|
14
17
|
/** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */
|
|
15
18
|
const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
|
|
@@ -54,14 +57,13 @@ const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60;
|
|
|
54
57
|
* Both units reach storage — `normalizeResetAt` does not scale, and the GUI disambiguates by
|
|
55
58
|
* magnitude at read time — so a comparison written against one assumption is off by 1000x
|
|
56
59
|
* against the other. In the seconds-read-as-milliseconds direction every reading looks like it
|
|
57
|
-
* elapsed in 1970, which is a check that passes its own test and does nothing.
|
|
58
|
-
*
|
|
60
|
+
* elapsed in 1970, which is a check that passes its own test and does nothing.
|
|
61
|
+
*
|
|
62
|
+
* The split now lives on `./quota-types`, the leaf the dashboard can also import, because the
|
|
63
|
+
* dashboard was the reader that did not have it (#5045). Re-exported here so the existing
|
|
64
|
+
* callers of this module keep their import path.
|
|
59
65
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
export function resetAtToMs(resetAt: number): number {
|
|
63
|
-
return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
|
|
64
|
-
}
|
|
66
|
+
export { resetAtToMs };
|
|
65
67
|
|
|
66
68
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
67
69
|
const quotaHistory = new CodexQuotaHistory();
|
|
@@ -75,14 +77,6 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
|
|
|
75
77
|
// Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
|
|
76
78
|
// actually exhausted account is still eligible for threshold rotation.
|
|
77
79
|
export const CODEX_UNKNOWN_USAGE_SCORE = 101;
|
|
78
|
-
/**
|
|
79
|
-
* A window reading at or above this is a measured refusal, not a position on a scale.
|
|
80
|
-
*
|
|
81
|
-
* Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
|
|
82
|
-
* "we have not observed this account", 100 is "we observed it and it is full".
|
|
83
|
-
*/
|
|
84
|
-
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
85
|
-
|
|
86
80
|
export function isCodexQuotaExhausted(
|
|
87
81
|
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
|
|
88
82
|
plan?: unknown,
|
|
@@ -146,7 +140,7 @@ export function isCompleteCodexQuotaRecoverySnapshot(
|
|
|
146
140
|
return quota.monthlyIsPrimaryWindow === true && finitePercent(quota.monthlyPercent);
|
|
147
141
|
}
|
|
148
142
|
|
|
149
|
-
function finitePercent(value: number | undefined):
|
|
143
|
+
function finitePercent(value: number | undefined): value is number {
|
|
150
144
|
return typeof value === "number" && Number.isFinite(value);
|
|
151
145
|
}
|
|
152
146
|
|
|
@@ -245,14 +239,20 @@ function shortResetHasElapsed(resetAt: number | undefined, now: number): boolean
|
|
|
245
239
|
return resetAtToMs(resetAt) <= now;
|
|
246
240
|
}
|
|
247
241
|
|
|
248
|
-
/** Display/rotation carry expires; a reset clock cannot retract hard-lock evidence. */
|
|
242
|
+
/** Display/rotation carry expires; a reset clock cannot retract blocking hard-lock evidence. */
|
|
249
243
|
function assignCarriedShort(
|
|
250
244
|
next: StoredAccountQuota,
|
|
251
245
|
existing: StoredAccountQuota | undefined,
|
|
252
246
|
now: number,
|
|
253
247
|
policyEvidence = false,
|
|
254
248
|
): void {
|
|
255
|
-
if (!existing
|
|
249
|
+
if (!existing) return;
|
|
250
|
+
const existingShortPercent = existing.shortPercent;
|
|
251
|
+
const preserveBlockingEvidence = policyEvidence
|
|
252
|
+
&& finitePercent(existingShortPercent)
|
|
253
|
+
&& existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
|
|
254
|
+
&& existingShortPercent <= 100;
|
|
255
|
+
if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return;
|
|
256
256
|
if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
|
|
257
257
|
if (existing.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
|
|
258
258
|
if (existing.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
|
|
@@ -363,7 +363,12 @@ function mergeAccountQuota(
|
|
|
363
363
|
if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
const
|
|
366
|
+
const existingShortPercent = existing?.shortPercent;
|
|
367
|
+
const preserveKnownShort = policyEvidence
|
|
368
|
+
&& quota.shortPercent === undefined
|
|
369
|
+
&& finitePercent(existingShortPercent)
|
|
370
|
+
&& existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
|
|
371
|
+
&& existingShortPercent <= 100;
|
|
367
372
|
if (snapshotHasShort(quota) && !preserveKnownShort) {
|
|
368
373
|
if (quota.shortPercent !== undefined) {
|
|
369
374
|
next.shortPercent = quota.shortPercent;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
3
3
|
CODEX_UNKNOWN_USAGE_SCORE,
|
|
4
|
-
resetAtToMs,
|
|
5
4
|
} from "../quota";
|
|
6
5
|
import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
6
|
+
import { isTerminalShortWindow } from "../quota-types";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
8
|
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
9
|
|
|
10
|
+
export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
|
|
11
|
+
|
|
10
12
|
export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
11
13
|
export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
12
14
|
/**
|
|
@@ -25,14 +27,6 @@ export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
|
|
|
25
27
|
/** Minimum gap between probe leases for one cooled-down account. */
|
|
26
28
|
export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
|
|
27
29
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
28
|
-
/**
|
|
29
|
-
* How recently a 100% burst reading must have been OBSERVED to exclude an account when it
|
|
30
|
-
* carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
|
|
31
|
-
* horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
|
|
32
|
-
* reading can never strand a recovered account, and long enough that a snapshot taken at
|
|
33
|
-
* admission is still fresh when selection reads it.
|
|
34
|
-
*/
|
|
35
|
-
export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
|
|
36
30
|
/** How long a transient failure keeps the account out of pool selection. */
|
|
37
31
|
export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
|
|
38
32
|
export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
|
|
@@ -138,44 +132,11 @@ export function computeCodexUsageScore(quota: {
|
|
|
138
132
|
return Math.max(...values);
|
|
139
133
|
}
|
|
140
134
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
|
|
147
|
-
* five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
|
|
148
|
-
* explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
|
|
149
|
-
* That is #3029 pointed the other way: the issue is that
|
|
150
|
-
* an exhausted account stays selected, and "a recovered account stays excluded" trades one
|
|
151
|
-
* unusable pool for another.
|
|
152
|
-
*
|
|
153
|
-
* A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
|
|
154
|
-
* direction here is the one that keeps an account selectable: a wrongly-selected account
|
|
155
|
-
* fails one request, while a wrongly-excluded one is invisible until someone reads the pool
|
|
156
|
-
* by hand.
|
|
157
|
-
*
|
|
158
|
-
* A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
|
|
159
|
-
* sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
|
|
160
|
-
* Old disk snapshots without short-window provenance remain unknown.
|
|
161
|
-
*/
|
|
162
|
-
function isTerminalShortWindow(
|
|
163
|
-
quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
|
|
164
|
-
now: number,
|
|
165
|
-
): boolean {
|
|
166
|
-
if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
|
|
167
|
-
if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
|
|
168
|
-
const resetAt = quota.shortResetAt;
|
|
169
|
-
if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
|
|
170
|
-
const observedAt = quota.shortObservedAt;
|
|
171
|
-
if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
|
|
172
|
-
const age = now - observedAt;
|
|
173
|
-
return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
|
|
174
|
-
}
|
|
175
|
-
// Seconds and milliseconds both reach storage, so the split lives in one place next to the
|
|
176
|
-
// merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
|
|
177
|
-
return resetAtToMs(resetAt) > now;
|
|
178
|
-
}
|
|
135
|
+
// `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing
|
|
136
|
+
// and the account-switch warning have to answer this identically for the same snapshot, and
|
|
137
|
+
// they did not: see the note on the shared function (#5045). Its #3029 and #3425 reasoning —
|
|
138
|
+
// why freshness is not optional, and why a reading with no reset and no observation stays
|
|
139
|
+
// unknown rather than exhausted — moved with it.
|
|
179
140
|
|
|
180
141
|
export function classifyCodexUpstreamOutcome(
|
|
181
142
|
outcome: CodexUpstreamOutcome,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import { isCodexAccountPaused } from "../account-pause";
|
|
3
|
+
import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
4
|
+
import type { CodexAccountUsabilityOptions } from "../account-usability";
|
|
5
|
+
import { isCodexAccountUsable } from "../account-usability";
|
|
6
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
7
|
+
import { hasCodexQuotaHeadroom } from "./selection";
|
|
8
|
+
|
|
9
|
+
/** Why routing would drop a manual pin, or undefined when the pin survives. */
|
|
10
|
+
export type CodexPinDrainReason = "needs_reauth" | "paused" | "unusable" | "quota_threshold";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Would routing release a pin on this account the next time it resolves?
|
|
14
|
+
*
|
|
15
|
+
* This lives beside selection rather than inside `routing.ts` for the same reason
|
|
16
|
+
* {@link ./cache-affinity} does: it is a policy question asked by two callers that must answer
|
|
17
|
+
* identically. One is `releaseDrainedCodexAccountPin`, which acts on it. The other is
|
|
18
|
+
* `PUT /api/codex-auth/active`, which reports it.
|
|
19
|
+
*
|
|
20
|
+
* The two-step contradiction in #4521 is what happens when only the first exists. That route
|
|
21
|
+
* validates existence, pause and pending validation, answers 200, and says nothing about quota;
|
|
22
|
+
* the very next resolve runs this rule and drops the pin. The operator sees a setting accepted
|
|
23
|
+
* and then ignored. Reporting it from the same predicate, rather than from a second copy on the
|
|
24
|
+
* accepting surface, is what keeps the answer and the action from drifting -- a client-side
|
|
25
|
+
* re-derivation of the usage score has to track {@link ./cooldown-math} exactly, including the
|
|
26
|
+
* Free/Go plan windows and short-window freshness.
|
|
27
|
+
*
|
|
28
|
+
* Ordering is load-bearing. Cached reauth and configured pause are classified FIRST, because
|
|
29
|
+
* they hold for the main account even while its fenced native profile is unreadable; a
|
|
30
|
+
* selection-only caller then makes every later classification answer "no drain", so reading
|
|
31
|
+
* reauth after that guard would make a pin on a signed-out main look durable.
|
|
32
|
+
*
|
|
33
|
+
* This answers only whether the pin survives. It is not an admission check: the caller that
|
|
34
|
+
* acts on it releases a preference, and every involuntary release -- quota refusal, failover
|
|
35
|
+
* streak, cooldown, lost generation, affinity expiry -- is decided elsewhere and earlier.
|
|
36
|
+
*/
|
|
37
|
+
export function codexAccountPinDrainReason(
|
|
38
|
+
config: OcxConfig,
|
|
39
|
+
accountId: string,
|
|
40
|
+
selectionOptions?: Pick<
|
|
41
|
+
CodexAccountUsabilityOptions,
|
|
42
|
+
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
|
|
43
|
+
>,
|
|
44
|
+
now: number = Date.now(),
|
|
45
|
+
): CodexPinDrainReason | undefined {
|
|
46
|
+
if (isAccountNeedsReauth(accountId)) return "needs_reauth";
|
|
47
|
+
if (isCodexAccountPaused(config, accountId)) return "paused";
|
|
48
|
+
// Temporary drain deliberately forbids every native-main read. A pin on main cannot be
|
|
49
|
+
// classified by credential liveness or quota until the fenced profile is readable. Cached
|
|
50
|
+
// reauth and configured pause state were handled above.
|
|
51
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
|
|
55
|
+
if (!hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)) return "quota_threshold";
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
package/src/codex/routing.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { saveConfigPreservingClaudeCode } from "../config";
|
|
2
|
-
import { isCodexAccountGenerationLive } from "./account-store";
|
|
2
|
+
import { isCodexAccountGenerationLive, registerCodexRefreshGenerationHandoff } from "./account-store";
|
|
3
|
+
import { handOffThreadAffinityGeneration } from "./routing/thread-affinity";
|
|
3
4
|
import { codexAccountLogLabel } from "./account-label";
|
|
4
5
|
import { isCodexAccountPaused } from "./account-pause";
|
|
5
6
|
import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
|
|
6
7
|
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
|
|
7
|
-
import {
|
|
8
|
+
import { markAccountNeedsReauth } from "./account-runtime-state";
|
|
9
|
+
import { codexAccountPinDrainReason } from "./routing/pin-drain";
|
|
8
10
|
import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation";
|
|
9
11
|
import { getAccountQuota, isRetiredCodexSparkModel } from "./quota";
|
|
10
12
|
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
@@ -202,6 +204,13 @@ export {
|
|
|
202
204
|
getEffectiveActiveCodexAccountId,
|
|
203
205
|
isEffectiveCodexAccountPinned,
|
|
204
206
|
} from "./routing/active-account";
|
|
207
|
+
export { codexAccountPinDrainReason } from "./routing/pin-drain";
|
|
208
|
+
export type { CodexPinDrainReason } from "./routing/pin-drain";
|
|
209
|
+
|
|
210
|
+
// A shared refresh can outlive the request that opened it. Register the affinity
|
|
211
|
+
// handoff with the flight so a detached G -> G+1 commit cannot strand bindings at G.
|
|
212
|
+
registerCodexRefreshGenerationHandoff(handOffThreadAffinityGeneration);
|
|
213
|
+
|
|
205
214
|
function hasConfiguredPoolAccount(
|
|
206
215
|
config: OcxConfig,
|
|
207
216
|
accountId: string,
|
|
@@ -392,7 +401,14 @@ function pickLineageServingAccount(
|
|
|
392
401
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
393
402
|
modelId?: string,
|
|
394
403
|
): { accountId: string; reason: CodexAffinityReason } | null {
|
|
395
|
-
|
|
404
|
+
// Cohort keying (#4780) makes a tree share one key, so for a same-session family the parent's
|
|
405
|
+
// key IS this request's and the lookup below would re-ask a question the caller already
|
|
406
|
+
// answered by finding no binding entry. What remains is the case cohort keying cannot unify:
|
|
407
|
+
// a session-less chain whose parent this scope has not recorded, where the keys differ.
|
|
408
|
+
if (
|
|
409
|
+
lineage.parentConversationKey !== undefined
|
|
410
|
+
&& lineage.parentConversationKey !== lineage.conversationKey
|
|
411
|
+
) {
|
|
396
412
|
const parent = lineageServingAccountId(
|
|
397
413
|
lineage.parentConversationKey, config, now, quotaScope, selectionOptions, modelId,
|
|
398
414
|
);
|
|
@@ -455,19 +471,7 @@ function releaseDrainedCodexAccountPin(
|
|
|
455
471
|
): void {
|
|
456
472
|
const pinned = pinnedCodexAccountId(config);
|
|
457
473
|
if (pinned === undefined) return;
|
|
458
|
-
|
|
459
|
-
if (knownUnavailable) {
|
|
460
|
-
clearCodexAccountPin(config);
|
|
461
|
-
saveConfigPreservingClaudeCode(config);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
// Temporary drain deliberately forbids every native-main read. A pin on main
|
|
465
|
-
// cannot be classified by credential liveness or quota until the fenced profile
|
|
466
|
-
// is readable. Cached reauth and configured pause state were handled above.
|
|
467
|
-
if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
|
|
468
|
-
const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
|
|
469
|
-
|| !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now);
|
|
470
|
-
if (!drained) return;
|
|
474
|
+
if (codexAccountPinDrainReason(config, pinned, selectionOptions, now) === undefined) return;
|
|
471
475
|
clearCodexAccountPin(config);
|
|
472
476
|
saveConfigPreservingClaudeCode(config);
|
|
473
477
|
}
|
package/src/codex/shim.ts
CHANGED
|
@@ -241,7 +241,7 @@ function backupPathFor(path: string): string {
|
|
|
241
241
|
*/
|
|
242
242
|
function destroyedShimMessage(file: ShimFileState): string {
|
|
243
243
|
const wrapper = existsSync(file.wrapperPath)
|
|
244
|
-
?
|
|
244
|
+
? stableShimPathProbe(file.wrapperPath)?.prefix.includes(SHIM_MARKER) ? "present but unusable" : "present but not an opencodex shim"
|
|
245
245
|
: "missing";
|
|
246
246
|
const backup = existsSync(file.backupPath) ? "present" : "missing";
|
|
247
247
|
const base = `Codex autostart shim not restored: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
|
|
@@ -905,8 +905,120 @@ export function hasCodexAgentModelFallbackField(role: string, codexHome = CODEX_
|
|
|
905
905
|
}
|
|
906
906
|
|
|
907
907
|
/** Roles whose TOML still carries `model_fallback`, including empty arrays. */
|
|
908
|
-
export function scanCodexAgentRolesWithTomlModelFallback(
|
|
909
|
-
|
|
908
|
+
export function scanCodexAgentRolesWithTomlModelFallback(
|
|
909
|
+
codexHome = CODEX_HOME,
|
|
910
|
+
onListError?: (cause: unknown) => void,
|
|
911
|
+
): string[] {
|
|
912
|
+
try {
|
|
913
|
+
return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
|
|
914
|
+
} catch (cause) {
|
|
915
|
+
onListError?.(cause);
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/;
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* TOML-aware read of the root `model` pin, or null when there is none.
|
|
924
|
+
*
|
|
925
|
+
* Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted
|
|
926
|
+
* line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong
|
|
927
|
+
* question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`,
|
|
928
|
+
* an indented key, or a trailing comment are all valid TOML that Codex honours and that a
|
|
929
|
+
* stricter matcher would report as unpinned.
|
|
930
|
+
*
|
|
931
|
+
* It shares the scanner used for `model_fallback` for the reason that matters here: an imported
|
|
932
|
+
* role file keeps its instructions in a multiline string, and that string contains the very words
|
|
933
|
+
* this scan looks for. A line matcher would read a key out of prose.
|
|
934
|
+
*
|
|
935
|
+
* Table context is not tracked, matching the `model_fallback` parse. A `model` key under a later
|
|
936
|
+
* table header would be read as the root pin; Codex role files are flat in practice, and for a
|
|
937
|
+
* warning the conservative direction is to stay quiet.
|
|
938
|
+
*/
|
|
939
|
+
function parseTomlModelPin(content: string): string | null {
|
|
940
|
+
const lines = content.split(/\r?\n/);
|
|
941
|
+
const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 };
|
|
942
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
943
|
+
const line = lines[i]!;
|
|
944
|
+
if (state.inMultilineString) {
|
|
945
|
+
const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!);
|
|
946
|
+
if (end === -1) continue;
|
|
947
|
+
state.inMultilineString = null;
|
|
948
|
+
scanTomlLine(line.slice(end + 3), state);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
if (state.arrayDepth === 0) {
|
|
952
|
+
const key = line.match(TOML_MODEL_KEY);
|
|
953
|
+
if (key) {
|
|
954
|
+
const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`;
|
|
955
|
+
let at = 0;
|
|
956
|
+
while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1;
|
|
957
|
+
if (rest[at] !== '"' && rest[at] !== "'") return null;
|
|
958
|
+
const value = parseTomlStringAt(rest, at)?.value.trim() ?? "";
|
|
959
|
+
return value === "" ? null : value;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
scanTomlLine(line, state);
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Filename prefix opencodex gives the Claude agents it generates. */
|
|
968
|
+
const OPENCODEX_DERIVED_ROLE_PREFIX = "ocx-";
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Body markers that survive the Codex desktop external-agent import.
|
|
972
|
+
*
|
|
973
|
+
* The import carries the generated Claude agent's instructions across, so both the provenance
|
|
974
|
+
* marker and the routing directive end up inside the role TOML. `ocx-route:` is matched without
|
|
975
|
+
* its `<!--` comment prefix on purpose: the same import text-replaces "Claude Code" with "Codex"
|
|
976
|
+
* inside that body, so anything around the directive should be assumed rewritten.
|
|
977
|
+
*/
|
|
978
|
+
const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"] as const;
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Roles that look opencodex-derived but pin no model, so Codex runs them on the parent model.
|
|
982
|
+
*
|
|
983
|
+
* opencodex does not write Codex role TOMLs. These arrive when the Codex desktop external-agent
|
|
984
|
+
* import converts `~/.claude/agents/ocx-*.md` into `$CODEX_HOME/agents/ocx-*.toml`, dropping the
|
|
985
|
+
* `model:` frontmatter because a `claude-ocx-native--` id is not a Codex model and keeping only the
|
|
986
|
+
* instructions. The surviving `ocx-route` directive cannot make up the difference: it is honoured
|
|
987
|
+
* only on the Claude `/v1/messages` path and is inert on `/v1/responses`. So the role file names
|
|
988
|
+
* one model while every spawn runs on another, which is invisible until someone diffs
|
|
989
|
+
* `session_meta.agent_role` against `turn_context.model` (#4790).
|
|
990
|
+
*
|
|
991
|
+
* Detection is a heuristic for a warning, deliberately not an ownership claim. Nothing here
|
|
992
|
+
* authorizes writing to, repairing, or removing these files, and the marker-based ownership rules
|
|
993
|
+
* that govern the files opencodex does write are unchanged.
|
|
994
|
+
*/
|
|
995
|
+
export function scanOpencodexDerivedCodexAgentRolesWithoutModelPin(
|
|
996
|
+
codexHome = CODEX_HOME,
|
|
997
|
+
onListError?: (cause: unknown) => void,
|
|
998
|
+
): string[] {
|
|
999
|
+
const findings: string[] = [];
|
|
1000
|
+
let roles: string[];
|
|
1001
|
+
try {
|
|
1002
|
+
roles = listCodexAgentRoles(codexHome);
|
|
1003
|
+
} catch (cause) {
|
|
1004
|
+
onListError?.(cause);
|
|
1005
|
+
return [];
|
|
1006
|
+
}
|
|
1007
|
+
for (const role of roles) {
|
|
1008
|
+
let content: string;
|
|
1009
|
+
try {
|
|
1010
|
+
content = readFileSync(join(codexHome, "agents", `${role}.toml`), "utf8");
|
|
1011
|
+
} catch {
|
|
1012
|
+
// An unreadable file is not evidence of a missing pin.
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const derived = role.startsWith(OPENCODEX_DERIVED_ROLE_PREFIX)
|
|
1016
|
+
|| OPENCODEX_DERIVED_ROLE_MARKERS.some(marker => content.includes(marker));
|
|
1017
|
+
if (!derived) continue;
|
|
1018
|
+
if (parseTomlModelPin(content) !== null) continue;
|
|
1019
|
+
findings.push(role);
|
|
1020
|
+
}
|
|
1021
|
+
return findings.sort();
|
|
910
1022
|
}
|
|
911
1023
|
|
|
912
1024
|
export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
|