@bitkyc08/opencodex 2.75.0 → 2.76.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 +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
package/src/cli/runtime-api.ts
CHANGED
|
@@ -200,6 +200,7 @@ export function takeOption(args: string[], flag: string): string | undefined {
|
|
|
200
200
|
return value;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** Consume one boolean option using the supported on/off synonyms; reject other values. */
|
|
203
204
|
export function takeBooleanOption(args: string[], flag: string): boolean | undefined {
|
|
204
205
|
const raw = takeOption(args, flag);
|
|
205
206
|
if (raw === undefined) return undefined;
|
|
@@ -208,11 +209,13 @@ export function takeBooleanOption(args: string[], flag: string): boolean | undef
|
|
|
208
209
|
throw new CliUsageError(`${flag} must be on or off`);
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/** Parse a decimal safe integer, allowing separators only between digits. */
|
|
211
213
|
export function takeIntegerOption(args: string[], flag: string, options: { min?: number } = {}): number | undefined {
|
|
212
214
|
const raw = takeOption(args, flag);
|
|
213
215
|
if (raw === undefined) return undefined;
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
+
const decimal = raw.trim();
|
|
217
|
+
const value = /^[+-]?\d+(?:[_,]\d+)*$/.test(decimal) ? Number(decimal.replace(/[_,]/g, "")) : NaN;
|
|
218
|
+
if (!Number.isSafeInteger(value) || value < (options.min ?? Number.MIN_SAFE_INTEGER)) {
|
|
216
219
|
throw new CliUsageError(`${flag} must be an integer${options.min !== undefined ? ` >= ${options.min}` : ""}`);
|
|
217
220
|
}
|
|
218
221
|
return value;
|
package/src/cli/status-oauth.ts
CHANGED
|
@@ -72,6 +72,11 @@ export function formatOAuthHealthForStatus(
|
|
|
72
72
|
parts.push(CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE);
|
|
73
73
|
break;
|
|
74
74
|
}
|
|
75
|
+
const policy = report.mainAccountHardLock;
|
|
76
|
+
if (policy) {
|
|
77
|
+
const warning = policy.externalUsage ? "; possible usage outside opencodex — the lock may not prevent exhaustion" : "";
|
|
78
|
+
parts.push(`Codex main-account protection: ${policy.state} (5h ≥ ${policy.thresholds.short}%, weekly/monthly ≥ ${policy.thresholds.long}%)${warning}`);
|
|
79
|
+
}
|
|
75
80
|
const oauthBlock = formatEntryBlock(report.entries);
|
|
76
81
|
if (oauthBlock) parts.push(oauthBlock);
|
|
77
82
|
return parts.join("\n");
|
package/src/cli/status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CodexMainAccountPolicyHealth } from "../oauth/health";
|
|
1
2
|
import { durableBunRuntime } from "../lib/bun-runtime";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
|
|
@@ -104,6 +105,7 @@ export type CliRemoteHubStatus = {
|
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
export type CliStatusJson = {
|
|
108
|
+
mainAccountHardLock?: CodexMainAccountPolicyHealth;
|
|
107
109
|
schemaVersion: 1;
|
|
108
110
|
/**
|
|
109
111
|
* This machine's topology role, named rather than inferred (#4236).
|
|
@@ -676,7 +678,8 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str
|
|
|
676
678
|
];
|
|
677
679
|
}
|
|
678
680
|
|
|
679
|
-
|
|
681
|
+
/** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */
|
|
682
|
+
export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise<CliStatusView> {
|
|
680
683
|
const configDiagnostics = readConfigDiagnostics();
|
|
681
684
|
const config = configDiagnostics.config;
|
|
682
685
|
const claudeDesktop = {
|
|
@@ -714,6 +717,9 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
714
717
|
const live = await findLiveProxy({
|
|
715
718
|
configFn: () => ({ port: config.port, hostname: config.hostname }),
|
|
716
719
|
});
|
|
720
|
+
const mainAccountHardLock = options.mainAccountPolicy && live
|
|
721
|
+
? (await (await import("../oauth/health")).fetchCodexHealthFromLiveProxy(undefined, async () => live)).mainAccountHardLock
|
|
722
|
+
: undefined;
|
|
717
723
|
const pidFile = readPid();
|
|
718
724
|
// Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile.
|
|
719
725
|
const pid = resolveStatusPid(live, pidFile);
|
|
@@ -865,6 +871,7 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
865
871
|
proxyLabel,
|
|
866
872
|
healthLabel: health.label,
|
|
867
873
|
json: {
|
|
874
|
+
...(mainAccountHardLock ? { mainAccountHardLock } : {}),
|
|
868
875
|
schemaVersion: 1,
|
|
869
876
|
runtimeRole: config.runtimeRole ?? "standalone",
|
|
870
877
|
proxy: {
|
package/src/cli/stop-approval.ts
CHANGED
|
@@ -64,19 +64,41 @@ export function parseStopApproval(argv: string[]): StopApprovalParse {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean {
|
|
67
|
-
return
|
|
68
|
-
&& now.liveness.pid === expected.pid && now.liveness.port === expected.port
|
|
69
|
-
&& now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname
|
|
70
|
-
&& now.liveness.role !== "client" && now.configHome === expected.configHome
|
|
71
|
-
&& now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown"
|
|
72
|
-
&& now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken;
|
|
67
|
+
return stopApprovalMismatch(expected, now) === null;
|
|
73
68
|
}
|
|
74
69
|
|
|
75
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The single guard fact that no longer holds, or null when everything still matches.
|
|
72
|
+
* The guarded stop collapses every re-verification into one `approval-changed` outcome;
|
|
73
|
+
* without the specific check, a desktop log cannot tell a genuine ownership or token
|
|
74
|
+
* drift from a transient probe failure on an unchanged runtime.
|
|
75
|
+
*/
|
|
76
|
+
export function stopApprovalMismatch(expected: StopApproval, now: ResolveJson): string | null {
|
|
77
|
+
if (now.schema !== "ocx-resolve/1") return "the resolve contract changed";
|
|
78
|
+
if (now.liveness.status !== "live") return `the approved runtime is no longer live (${now.liveness.status})`;
|
|
79
|
+
if (now.liveness.pid !== expected.pid) {
|
|
80
|
+
return `the live PID is now ${now.liveness.pid ?? "unverified"} (approved ${expected.pid})`;
|
|
81
|
+
}
|
|
82
|
+
if (now.liveness.port !== expected.port) return `the live port is now ${now.liveness.port} (approved ${expected.port})`;
|
|
83
|
+
if (now.port.effective !== expected.port) return `the effective port is now ${now.port.effective} (approved ${expected.port})`;
|
|
84
|
+
if ((now.liveness.hostname ?? "") !== expected.hostname) return "the bind hostname changed";
|
|
85
|
+
if (now.liveness.role === "client") return "the runtime now routes as a client";
|
|
86
|
+
if (now.configHome !== expected.configHome) return "the config home changed";
|
|
87
|
+
if (now.cliVersion !== expected.cliVersion) return `the resolving CLI is now ${now.cliVersion} (approved ${expected.cliVersion})`;
|
|
88
|
+
if (now.ownership.kind === "unknown") return "the ownership claim could not be re-verified";
|
|
89
|
+
if (now.takeover.kind !== "supported") {
|
|
90
|
+
return `takeover compatibility is now blocked (${now.takeover.reason})`;
|
|
91
|
+
}
|
|
92
|
+
if (now.takeover.token !== expected.compatibilityToken) return "the compatibility fingerprint changed";
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function approvalChanged(detail?: string): StopOutcome {
|
|
76
97
|
return { ok: false, summary: {
|
|
77
98
|
schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1,
|
|
78
99
|
runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped",
|
|
79
|
-
|
|
100
|
+
...(detail ? { detail } : {}),
|
|
101
|
+
message: `The approved runtime or managing CLI changed${detail ? `: ${detail}` : ""}; no stop was attempted.`,
|
|
80
102
|
} };
|
|
81
103
|
}
|
|
82
104
|
|
|
@@ -104,10 +126,20 @@ export async function runApprovedStop(
|
|
|
104
126
|
const now = await read();
|
|
105
127
|
const target = tracked();
|
|
106
128
|
const manager = managerTarget();
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
if (now === null) return approvalChanged("the runtime could not be re-resolved");
|
|
130
|
+
const mismatch = stopApprovalMismatch(expected, now);
|
|
131
|
+
if (mismatch !== null) return approvalChanged(mismatch);
|
|
132
|
+
if (target === null) return approvalChanged("the tracked runtime record could not be verified");
|
|
133
|
+
if (target.pid !== expected.pid || target.port !== expected.port
|
|
134
|
+
|| target.hostname !== expected.hostname) {
|
|
135
|
+
return approvalChanged("the tracked runtime record changed");
|
|
136
|
+
}
|
|
137
|
+
if (manager.kind === "unknown") {
|
|
138
|
+
return approvalChanged(`the service manager could not be re-verified (${manager.reason})`);
|
|
139
|
+
}
|
|
140
|
+
if (manager.kind === "bound" && manager.pid !== expected.pid) {
|
|
141
|
+
return approvalChanged("a different manager owns the approved runtime");
|
|
142
|
+
}
|
|
111
143
|
snapshot = { approval: expected, manager };
|
|
112
144
|
} catch { return approvalChanged(); }
|
|
113
145
|
return snapshot ? stop(snapshot) : approvalChanged();
|
|
@@ -149,13 +181,20 @@ export async function runGuardedManagerStep(
|
|
|
149
181
|
managerState: () => Promise<"inactive" | "active" | "unknown">;
|
|
150
182
|
},
|
|
151
183
|
): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed";
|
|
152
|
-
proxy: "stopped" | "unknown"; handledByProxy: boolean }> {
|
|
184
|
+
proxy: "stopped" | "unknown"; handledByProxy: boolean; detail?: string }> {
|
|
153
185
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
186
|
+
const current = io.revalidateManager();
|
|
187
|
+
if (!sameGuardedManager(snapshot.manager, current)) {
|
|
188
|
+
// An unprovable manager is not a proven change: name the observation failure,
|
|
189
|
+
// not a swap, or the stop log loses why the revalidation could not answer.
|
|
190
|
+
const detail = current.kind === "unknown"
|
|
191
|
+
? `the service manager could not be re-verified (${current.reason})`
|
|
192
|
+
: "the service manager changed between approval and stop";
|
|
193
|
+
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false, detail };
|
|
156
194
|
}
|
|
157
195
|
} catch {
|
|
158
|
-
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false
|
|
196
|
+
return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false,
|
|
197
|
+
detail: "the service manager could not be re-validated" };
|
|
159
198
|
}
|
|
160
199
|
const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
|
|
161
200
|
if (snapshot.manager.kind === "bound"
|
package/src/cli/stop-report.ts
CHANGED
|
@@ -76,6 +76,11 @@ export interface StopSummaryJson {
|
|
|
76
76
|
sharedTeardown: StopSharedTeardownOutcome;
|
|
77
77
|
/** One stable human-readable line for a caller's UI. */
|
|
78
78
|
message: string;
|
|
79
|
+
/**
|
|
80
|
+
* The single guard fact that failed on a `approval-changed` refusal, when the
|
|
81
|
+
* run can name one. Additive on `ocx-stop/1`; absent on every other outcome.
|
|
82
|
+
*/
|
|
83
|
+
detail?: string;
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
/** What handleStop returns: the pre-existing boolean plus the structured twin. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection of Codex-based omo (LazyCodex), the variant that reads Codex role files and
|
|
3
|
+
* `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
4
|
+
*
|
|
5
|
+
* "omo" names three products. Pi-based omo (senpi) owns `~/.omo/agent` and is the omo file
|
|
6
|
+
* integration in `src/integrations/registry.ts`; OpenCode-based omo (oh-my-opencode) keeps its
|
|
7
|
+
* own config under OpenCode. Neither proves LazyCodex is installed, and `~/.omo` exists for all
|
|
8
|
+
* of them, so this reads only LazyCodex's footprint inside CODEX_HOME: the Codex plugin
|
|
9
|
+
* `omo@sisyphuslabs` enabled in config.toml (the key LazyCodex's own doctor checks) and an
|
|
10
|
+
* installed copy of it carrying the `lazycodex-install.json` receipt its installer writes.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const LAZYCODEX_PLUGIN_ID = "omo@sisyphuslabs";
|
|
16
|
+
|
|
17
|
+
export interface LazyCodexDetection {
|
|
18
|
+
readonly detected: boolean;
|
|
19
|
+
readonly pluginEnabled: boolean;
|
|
20
|
+
readonly pluginInstalled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pluginEnabled(codexHome: string): boolean {
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = Bun.TOML.parse(readFileSync(join(codexHome, "config.toml"), "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const plugins = (parsed as { plugins?: Record<string, { enabled?: unknown } | undefined> } | null)?.plugins;
|
|
31
|
+
return plugins?.[LAZYCODEX_PLUGIN_ID]?.enabled === true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pluginInstalled(codexHome: string): boolean {
|
|
35
|
+
const root = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo");
|
|
36
|
+
try {
|
|
37
|
+
return readdirSync(root, { withFileTypes: true })
|
|
38
|
+
.some(entry => entry.isDirectory() && existsSync(join(root, entry.name, "lazycodex-install.json")));
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function detectLazyCodex(codexHome: string): LazyCodexDetection {
|
|
45
|
+
const enabled = pluginEnabled(codexHome);
|
|
46
|
+
const installed = pluginInstalled(codexHome);
|
|
47
|
+
return { detected: enabled && installed, pluginEnabled: enabled, pluginInstalled: installed };
|
|
48
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omo (Codex / LazyCodex)'s per-role model setting, `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
3
|
+
*
|
|
4
|
+
* LazyCodex 5.1.1 and later reads it, and callers reach this only after `detectLazyCodex`
|
|
5
|
+
* says LazyCodex is installed. The file is omo's, so this writes only on an explicit
|
|
6
|
+
* dashboard or CLI pick, never creates the file, and refuses a file with comments: the write
|
|
7
|
+
* re-serializes JSON, and a comment the user wrote would be lost without a word.
|
|
8
|
+
*/
|
|
9
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
|
|
13
|
+
import { assertIntegrationWriteOwnership } from "../integrations/config-io";
|
|
14
|
+
import { hasJsoncComments, parseJsonc } from "../lib/jsonc";
|
|
15
|
+
|
|
16
|
+
export type OmoRoleModelsState =
|
|
17
|
+
| { readonly state: "absent" }
|
|
18
|
+
| { readonly state: "comments" }
|
|
19
|
+
| { readonly state: "invalid" }
|
|
20
|
+
| { readonly state: "unreadable" }
|
|
21
|
+
| { readonly state: "present"; readonly models: Readonly<Record<string, string>> };
|
|
22
|
+
|
|
23
|
+
export type OmoRoleModelWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid";
|
|
24
|
+
|
|
25
|
+
/** omo resolves its home from HOME, then USERPROFILE, then the OS home; this follows it. */
|
|
26
|
+
export function omoJsoncPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
27
|
+
const base = env.HOME?.trim() || env.USERPROFILE?.trim() || home;
|
|
28
|
+
return join(base, ".omo", "omo.jsonc");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type JsonObject = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
function isObject(value: unknown): value is JsonObject {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Loaded =
|
|
38
|
+
| { kind: "absent" | "comments" | "invalid" }
|
|
39
|
+
| { kind: "document"; text: string; doc: JsonObject };
|
|
40
|
+
|
|
41
|
+
function load(path: string): Loaded {
|
|
42
|
+
let text: string;
|
|
43
|
+
try {
|
|
44
|
+
if (!lstatSync(path).isFile()) return { kind: "invalid" };
|
|
45
|
+
text = readFileSync(path, "utf8");
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (hasJsoncComments(text)) return { kind: "comments" };
|
|
51
|
+
try {
|
|
52
|
+
const doc = parseJsonc(text.replace(/^\ufeff/, ""));
|
|
53
|
+
return isObject(doc) ? { kind: "document", text, doc } : { kind: "invalid" };
|
|
54
|
+
} catch {
|
|
55
|
+
return { kind: "invalid" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function agentsOf(doc: JsonObject): JsonObject | null | undefined {
|
|
60
|
+
const codex = doc.codex;
|
|
61
|
+
if (codex === undefined) return undefined;
|
|
62
|
+
if (!isObject(codex)) return null;
|
|
63
|
+
const agents = codex.agents;
|
|
64
|
+
if (agents === undefined) return undefined;
|
|
65
|
+
return isObject(agents) ? agents : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readOmoRoleModels(path: string = omoJsoncPath()): OmoRoleModelsState {
|
|
69
|
+
let loaded: Loaded;
|
|
70
|
+
try {
|
|
71
|
+
loaded = load(path);
|
|
72
|
+
} catch {
|
|
73
|
+
return { state: "unreadable" };
|
|
74
|
+
}
|
|
75
|
+
if (loaded.kind !== "document") return { state: loaded.kind };
|
|
76
|
+
const agents = agentsOf(loaded.doc);
|
|
77
|
+
if (agents === null) return { state: "invalid" };
|
|
78
|
+
const models: Record<string, string> = {};
|
|
79
|
+
for (const [role, entry] of Object.entries(agents ?? {})) {
|
|
80
|
+
if (isObject(entry) && typeof entry.model === "string") models[role] = entry.model;
|
|
81
|
+
}
|
|
82
|
+
return { state: "present", models };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function serialize(text: string, doc: JsonObject): string {
|
|
86
|
+
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
|
|
87
|
+
let out = JSON.stringify(doc, null, indent);
|
|
88
|
+
if (/\r?\n$/.test(text)) out += "\n";
|
|
89
|
+
if (text.includes("\r\n")) out = out.replace(/\n/g, "\r\n");
|
|
90
|
+
return text.startsWith("\ufeff") ? `\ufeff${out}` : out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeOmoRoleModel(role: string, model: string, path: string = omoJsoncPath()): OmoRoleModelWriteStatus {
|
|
94
|
+
const loaded = load(path);
|
|
95
|
+
if (loaded.kind !== "document") return loaded.kind === "comments" ? "skipped_comments" : loaded.kind;
|
|
96
|
+
const { doc, text } = loaded;
|
|
97
|
+
// Only an absent key is created; an explicit null is a value the reader calls invalid.
|
|
98
|
+
const codex = doc.codex === undefined ? {} : doc.codex;
|
|
99
|
+
if (!isObject(codex)) return "invalid";
|
|
100
|
+
const agents = codex.agents === undefined ? {} : codex.agents;
|
|
101
|
+
if (!isObject(agents)) return "invalid";
|
|
102
|
+
const entry = agents[role] === undefined ? {} : agents[role];
|
|
103
|
+
if (!isObject(entry)) return "invalid";
|
|
104
|
+
if (entry.model === model) return "unchanged";
|
|
105
|
+
agents[role] = { ...entry, model };
|
|
106
|
+
codex.agents = agents;
|
|
107
|
+
doc.codex = codex;
|
|
108
|
+
assertIntegrationWriteOwnership(path);
|
|
109
|
+
atomicWriteFileNoFollowUnclaimed(path, serialize(text, doc));
|
|
110
|
+
return "written";
|
|
111
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
|
|
3
|
+
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
4
|
+
import {
|
|
5
|
+
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
6
|
+
isTerminalShortWindow,
|
|
7
|
+
resetAtToMs,
|
|
8
|
+
type StoredAccountQuota,
|
|
9
|
+
} from "./quota-types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether an account may keep serving once one of its usage windows is full (#6334).
|
|
13
|
+
*
|
|
14
|
+
* Upstream does not refuse an account that holds ChatGPT credits at 100%: it serves the request
|
|
15
|
+
* and draws the balance. Selection only takes an account off on quota after a refusal, so an
|
|
16
|
+
* account with credits was never moved off. Spending is opt-in: only the ids in
|
|
17
|
+
* `creditCodexAccountIds` may keep serving from credits, and every other account leaves rotation
|
|
18
|
+
* at 100% and comes back after its reset. Pool accounts and the `__main__` login both carry the
|
|
19
|
+
* switch; the main login is also checked where the main-account hard lock is.
|
|
20
|
+
*/
|
|
21
|
+
export function codexAccountUsesCreditsAfterLimit(
|
|
22
|
+
config: Pick<OcxConfig, "creditCodexAccountIds">,
|
|
23
|
+
accountId: string,
|
|
24
|
+
): boolean {
|
|
25
|
+
return config.creditCodexAccountIds?.includes(accountId) ?? false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Persist the switch for one account. Only the accounts allowed to spend credits are stored. */
|
|
29
|
+
export function setCodexAccountCreditsAfterLimit(config: OcxConfig, accountId: string, enabled: boolean): void {
|
|
30
|
+
const ids = new Set(config.creditCodexAccountIds ?? []);
|
|
31
|
+
if (enabled) ids.add(accountId);
|
|
32
|
+
else ids.delete(accountId);
|
|
33
|
+
writeCreditAccountIds(config, ids);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The dashboard's global switch. On allows every account the caller passes, which is every
|
|
38
|
+
* current account; off clears the list, so a later account still starts with spending off.
|
|
39
|
+
*/
|
|
40
|
+
export function setAllCodexAccountsCreditsAfterLimit(config: OcxConfig, accountIds: readonly string[], enabled: boolean): void {
|
|
41
|
+
writeCreditAccountIds(config, new Set(enabled ? accountIds : []));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeCreditAccountIds(config: OcxConfig, ids: ReadonlySet<string>): void {
|
|
45
|
+
if (ids.size > 0) config.creditCodexAccountIds = [...ids];
|
|
46
|
+
else deleteConfigTopLevelKey(config, "creditCodexAccountIds");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function forgetCodexAccountCreditUse(config: OcxConfig, accountId: string): void {
|
|
50
|
+
setCodexAccountCreditsAfterLimit(config, accountId, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A usage window that is full right now.
|
|
55
|
+
*
|
|
56
|
+
* Stricter than the selection score about time, on purpose. The score keeps a 100% reading until
|
|
57
|
+
* a new observation replaces it, which is safe there because an account at 100% still receives
|
|
58
|
+
* traffic and that traffic brings the next observation. An account held by this switch receives
|
|
59
|
+
* none, so the reading itself has to say when it ends: a long window counts only while its reset
|
|
60
|
+
* is still ahead, and one without a reset is not trusted. The burst window uses the rule routing
|
|
61
|
+
* and the dashboard already share.
|
|
62
|
+
*/
|
|
63
|
+
export function isCodexUsageLimitReached(quota: StoredAccountQuota | null, plan: unknown, now: number): boolean {
|
|
64
|
+
return codexUsageLimitResetAt(quota, plan, now) !== undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* When the full window ends, in milliseconds, or undefined when no window is full. With several
|
|
69
|
+
* full windows the latest reset is the earliest moment the account is usable again.
|
|
70
|
+
*/
|
|
71
|
+
export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: unknown, now: number): number | undefined {
|
|
72
|
+
if (!quota) return undefined;
|
|
73
|
+
const full: number[] = [];
|
|
74
|
+
if (isTerminalShortWindow(quota, now)) {
|
|
75
|
+
// A reset-less burst reading counts while it is fresh, so there is no instant to report.
|
|
76
|
+
full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0 ? resetAtToMs(quota.shortResetAt) : now);
|
|
77
|
+
}
|
|
78
|
+
const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
|
|
79
|
+
? [[quota.monthlyPercent, quota.monthlyResetAt]]
|
|
80
|
+
: [[quota.weeklyPercent, quota.weeklyResetAt], [quota.monthlyPercent, quota.monthlyResetAt]];
|
|
81
|
+
for (const [percent, resetAt] of longWindows) {
|
|
82
|
+
if (typeof percent !== "number" || percent < CODEX_EXHAUSTED_USAGE_PERCENT) continue;
|
|
83
|
+
if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) continue;
|
|
84
|
+
const resetMs = resetAtToMs(resetAt);
|
|
85
|
+
if (resetMs > now) full.push(resetMs);
|
|
86
|
+
}
|
|
87
|
+
return full.length > 0 ? Math.max(...full) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether automatic selection must skip this account so that it keeps its credits. */
|
|
91
|
+
export function isCodexAccountHeldForCredits(
|
|
92
|
+
config: Pick<OcxConfig, "creditCodexAccountIds">,
|
|
93
|
+
accountId: string,
|
|
94
|
+
quota: StoredAccountQuota | null,
|
|
95
|
+
plan: unknown,
|
|
96
|
+
now: number,
|
|
97
|
+
): boolean {
|
|
98
|
+
return !codexAccountUsesCreditsAfterLimit(config, accountId) && isCodexUsageLimitReached(quota, plan, now);
|
|
99
|
+
}
|
|
@@ -18,6 +18,7 @@ import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
|
|
|
18
18
|
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
|
|
19
19
|
import { extractAccountIdClaims } from "../oauth/chatgpt";
|
|
20
20
|
import { forgetCodexAccountPause } from "./account-pause";
|
|
21
|
+
import { forgetCodexAccountCreditUse } from "./account-credit-use";
|
|
21
22
|
import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
|
|
22
23
|
import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
|
|
23
24
|
import { forgetCodexAccountAutoSwitchThreshold } from "./account-auto-switch";
|
|
@@ -179,6 +180,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
|
|
|
179
180
|
runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
|
|
180
181
|
.filter(account => account.isMain || account.id !== accountId);
|
|
181
182
|
forgetCodexAccountPause(runtimeConfig, accountId);
|
|
183
|
+
forgetCodexAccountCreditUse(runtimeConfig, accountId);
|
|
182
184
|
forgetCodexAccountPriority(runtimeConfig, accountId);
|
|
183
185
|
forgetCodexAccountAutoSwitchThreshold(runtimeConfig, accountId);
|
|
184
186
|
if (runtimeConfig.codexQuotaAutoRefresh?.[accountId]) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's write of one Codex agent role's model, and optionally its reasoning effort.
|
|
3
|
+
*
|
|
4
|
+
* Codex overrides the spawn-time model with the root `model` pin in
|
|
5
|
+
* `$CODEX_HOME/agents/<role>.toml`, so that pin is what decides a child's model. This module
|
|
6
|
+
* edits exactly that value, plus the root `model_reasoning_effort` when a caller passes one, and
|
|
7
|
+
* nothing else: every other byte of the file, including the instructions multiline string that
|
|
8
|
+
* usually mentions `model =` in prose, is left as it was.
|
|
9
|
+
* It runs only on an explicit user action; no sync or startup path calls it.
|
|
10
|
+
*/
|
|
11
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
|
|
14
|
+
import { assertIntegrationWriteOwnership } from "../integrations/config-io";
|
|
15
|
+
import { encodeBasicString, findInvalidCharacter } from "./prompt-layers/encoding";
|
|
16
|
+
import { dominantEol } from "./prompt-layers/toml-edit";
|
|
17
|
+
import { isDeclaredReasoningEffort } from "../reasoning-effort";
|
|
18
|
+
import {
|
|
19
|
+
listCodexAgentRoles,
|
|
20
|
+
locateTomlModelKey,
|
|
21
|
+
locateTomlReasoningEffortKey,
|
|
22
|
+
type TomlModelKeyLocation,
|
|
23
|
+
} from "./subagent-model-fallback";
|
|
24
|
+
|
|
25
|
+
export type AgentRoleModelErrorCode =
|
|
26
|
+
| "unknown_role"
|
|
27
|
+
| "invalid_model"
|
|
28
|
+
| "invalid_effort"
|
|
29
|
+
| "unsupported_model_value"
|
|
30
|
+
| "unsafe_target"
|
|
31
|
+
| "invalid_role_file";
|
|
32
|
+
|
|
33
|
+
export class AgentRoleModelError extends Error {
|
|
34
|
+
constructor(readonly code: AgentRoleModelErrorCode, message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CodexAgentRoleModel {
|
|
40
|
+
readonly role: string;
|
|
41
|
+
readonly model: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateAgentRoleModel(model: unknown): string {
|
|
45
|
+
if (typeof model !== "string") throw new AgentRoleModelError("invalid_model", "model must be a string");
|
|
46
|
+
const trimmed = model.trim();
|
|
47
|
+
if (trimmed === "") throw new AgentRoleModelError("invalid_model", "model must not be empty");
|
|
48
|
+
if (trimmed.includes("\n") || findInvalidCharacter(trimmed) !== null) {
|
|
49
|
+
throw new AgentRoleModelError("invalid_model", "model must not contain control characters");
|
|
50
|
+
}
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateAgentRoleEffort(effort: unknown): string {
|
|
55
|
+
if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) {
|
|
56
|
+
throw new AgentRoleModelError("invalid_effort", "effort must be a reasoning effort level such as low, medium or high");
|
|
57
|
+
}
|
|
58
|
+
return effort;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set a root string key. An existing value is replaced inside its quotes and keeps its
|
|
63
|
+
* quote style when the new value allows it; a missing key is inserted after the leading
|
|
64
|
+
* comment block with the file's own line ending.
|
|
65
|
+
*/
|
|
66
|
+
function setTomlRootString(content: string, location: TomlModelKeyLocation | null, key: string, value: string): string {
|
|
67
|
+
if (location?.inRootTable) {
|
|
68
|
+
if (!location.span) {
|
|
69
|
+
throw new AgentRoleModelError("unsupported_model_value", `the existing ${key} value is not a one-line string`);
|
|
70
|
+
}
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
const line = lines[location.line]!;
|
|
73
|
+
const literal = line[location.span.start] === "'" && !value.includes("'")
|
|
74
|
+
? `'${value}'`
|
|
75
|
+
: encodeBasicString(value);
|
|
76
|
+
lines[location.line] = line.slice(0, location.span.start) + literal + line.slice(location.span.end);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
const eol = dominantEol(content);
|
|
80
|
+
const bom = content.startsWith("\ufeff") ? 1 : 0;
|
|
81
|
+
const lines = content.slice(bom).split("\n");
|
|
82
|
+
let offset = bom;
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (!/^\s*#/.test(line)) break;
|
|
85
|
+
offset += line.length + 1;
|
|
86
|
+
}
|
|
87
|
+
const assignment = `${key} = ${encodeBasicString(value)}`;
|
|
88
|
+
if (offset > content.length) return `${content}${eol}${assignment}${eol}`;
|
|
89
|
+
return `${content.slice(0, offset)}${assignment}${eol}${content.slice(offset)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function setTomlRootModel(content: string, model: string): string {
|
|
93
|
+
return setTomlRootString(content, locateTomlModelKey(content), "model", model);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setTomlRootReasoningEffort(content: string, effort: string): string {
|
|
97
|
+
return setTomlRootString(content, locateTomlReasoningEffortKey(content), "model_reasoning_effort", effort);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readCodexAgentRoleEffort(role: string, codexHome: string): string | null {
|
|
101
|
+
try {
|
|
102
|
+
const location = locateTomlReasoningEffortKey(readFileSync(roleFile(role, codexHome), "utf8"));
|
|
103
|
+
return location?.inRootTable ? location.value : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function roleFile(role: string, codexHome: string): string {
|
|
110
|
+
return join(codexHome, "agents", `${role}.toml`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requireKnownRole(role: string, codexHome: string): void {
|
|
114
|
+
// Membership in the directory listing is the whole path check: a name with a separator or
|
|
115
|
+
// `..` can never equal a listed file stem.
|
|
116
|
+
if (!listCodexAgentRoles(codexHome).includes(role)) {
|
|
117
|
+
throw new AgentRoleModelError("unknown_role", `no Codex agent role named ${JSON.stringify(role)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function listCodexAgentRoleModels(codexHome: string): CodexAgentRoleModel[] {
|
|
122
|
+
return listCodexAgentRoles(codexHome).sort().map(role => {
|
|
123
|
+
let model: string | null = null;
|
|
124
|
+
try {
|
|
125
|
+
const location = locateTomlModelKey(readFileSync(roleFile(role, codexHome), "utf8"));
|
|
126
|
+
model = location?.inRootTable ? location.value : null;
|
|
127
|
+
} catch { /* an unreadable role reports no pin */ }
|
|
128
|
+
return { role, model };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Codex refuses a role file that is not valid TOML. Editing one anyway could turn a role that
|
|
134
|
+
* Codex rejects into one it loads, so both the original and the edited document must parse.
|
|
135
|
+
*/
|
|
136
|
+
function assertValidRoleToml(role: string, text: string, stage: "before" | "after"): void {
|
|
137
|
+
try {
|
|
138
|
+
Bun.TOML.parse(text);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new AgentRoleModelError(
|
|
141
|
+
"invalid_role_file",
|
|
142
|
+
stage === "before"
|
|
143
|
+
? `${role}.toml is not valid TOML; fix it before setting a model`
|
|
144
|
+
: `setting the model would leave ${role}.toml invalid; the file was not changed`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function writeCodexAgentRoleModel(
|
|
150
|
+
role: string,
|
|
151
|
+
model: string,
|
|
152
|
+
codexHome: string,
|
|
153
|
+
effort?: string,
|
|
154
|
+
): { status: "written" | "unchanged" } {
|
|
155
|
+
requireKnownRole(role, codexHome);
|
|
156
|
+
const path = roleFile(role, codexHome);
|
|
157
|
+
if (!lstatSync(path).isFile()) {
|
|
158
|
+
throw new AgentRoleModelError("unsafe_target", `${role}.toml is not a regular file; edit it where it points`);
|
|
159
|
+
}
|
|
160
|
+
const before = readFileSync(path, "utf8");
|
|
161
|
+
assertValidRoleToml(role, before, "before");
|
|
162
|
+
const withModel = setTomlRootModel(before, validateAgentRoleModel(model));
|
|
163
|
+
const after = effort === undefined
|
|
164
|
+
? withModel
|
|
165
|
+
: setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort));
|
|
166
|
+
if (after === before) return { status: "unchanged" };
|
|
167
|
+
assertValidRoleToml(role, after, "after");
|
|
168
|
+
assertIntegrationWriteOwnership(path);
|
|
169
|
+
atomicWriteFileNoFollowUnclaimed(path, after);
|
|
170
|
+
return { status: "written" };
|
|
171
|
+
}
|