@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
|
@@ -19,15 +19,32 @@ interface ProviderQuotasDto {
|
|
|
19
19
|
reports?: ProviderQuotaReportDto[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface QuotaResetEventDto {
|
|
23
|
+
kind?: string;
|
|
24
|
+
scope?: string;
|
|
25
|
+
window?: string;
|
|
26
|
+
percentBefore?: number;
|
|
27
|
+
percentAfter?: number;
|
|
28
|
+
resetAt?: number;
|
|
29
|
+
detectedAt?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface QuotaResetsDto {
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
events?: QuotaResetEventDto[];
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
const USAGE = `Usage:
|
|
23
38
|
ocx provider edit <name> [--adapter <id>] [--base-url <url>] [--default-model <id|->]
|
|
24
39
|
[--auth-mode <key|forward|oauth|local|->] [--note <text|->]
|
|
25
40
|
[--api-key-transport <x-api-key|bearer|->]
|
|
26
41
|
[--headers <json>] [--enabled <on|off>] [--live-models <on|off>]
|
|
27
42
|
[--retain-models <id,id|->]
|
|
43
|
+
[--xai-chat <on|off>]
|
|
28
44
|
[--allow-private-network <on|off>] [--json]
|
|
29
45
|
ocx provider test <name> [--json]
|
|
30
46
|
ocx provider quota [--refresh] [--json]
|
|
47
|
+
ocx provider resets [--limit <n>] [--json]
|
|
31
48
|
ocx provider presets [--json]
|
|
32
49
|
ocx provider account-mode <pool|direct> [--json]
|
|
33
50
|
ocx provider selected <name> [--set <model,model...>] [--clear] [--json]
|
|
@@ -54,7 +71,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
54
71
|
const enabled = takeBooleanOption(args, "--enabled");
|
|
55
72
|
const liveModels = takeBooleanOption(args, "--live-models");
|
|
56
73
|
const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network");
|
|
74
|
+
const xaiChat = takeBooleanOption(args, "--xai-chat");
|
|
57
75
|
rejectArgs(args, USAGE);
|
|
76
|
+
if (xaiChat !== undefined) {
|
|
77
|
+
if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE);
|
|
78
|
+
patch.xaiResponsesOptIn = !xaiChat;
|
|
79
|
+
}
|
|
58
80
|
if (adapter !== undefined) patch.adapter = adapter;
|
|
59
81
|
if (baseUrl !== undefined) patch.baseUrl = baseUrl;
|
|
60
82
|
if (defaultModel !== undefined) patch.defaultModel = defaultModel;
|
|
@@ -132,6 +154,48 @@ async function quota(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
132
154
|
printData(result, wantsJson, lines);
|
|
133
155
|
}
|
|
134
156
|
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Recently detected quota resets, newest first.
|
|
160
|
+
*
|
|
161
|
+
* `accountTag` is deliberately NOT rendered: it is a salted hash that means nothing to a human
|
|
162
|
+
* reading a terminal, and printing it invites treating an opaque tag as an account identifier.
|
|
163
|
+
*/
|
|
164
|
+
function quotaResetLine(event: QuotaResetEventDto): string {
|
|
165
|
+
const when = typeof event.detectedAt === "number"
|
|
166
|
+
? new Date(event.detectedAt).toISOString()
|
|
167
|
+
: "unknown time";
|
|
168
|
+
const movement = typeof event.percentBefore === "number" && typeof event.percentAfter === "number"
|
|
169
|
+
? `${event.percentBefore}% -> ${event.percentAfter}%`
|
|
170
|
+
: "usage unknown";
|
|
171
|
+
const next = typeof event.resetAt === "number"
|
|
172
|
+
? `, next ${new Date(event.resetAt).toISOString()}`
|
|
173
|
+
: "";
|
|
174
|
+
return `${when} ${event.scope ?? "?"} ${event.window ?? "?"} ${event.kind ?? "?"}: ${movement}${next}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function resets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
178
|
+
const args = [...argv];
|
|
179
|
+
const wantsJson = takeFlag(args, "--json");
|
|
180
|
+
const limitRaw = takeOption(args, "--limit");
|
|
181
|
+
rejectArgs(args, USAGE);
|
|
182
|
+
if (limitRaw !== undefined && !/^\d+$/.test(limitRaw)) {
|
|
183
|
+
throw new CliUsageError("--limit must be a non-negative integer", USAGE);
|
|
184
|
+
}
|
|
185
|
+
const query = limitRaw === undefined ? "" : `?limit=${limitRaw}`;
|
|
186
|
+
const result = await runtimeRequest<QuotaResetsDto>(`/api/quota-resets${query}`, {}, deps);
|
|
187
|
+
const events = Array.isArray(result?.events) ? result.events : [];
|
|
188
|
+
// One line per event, NOT summaryLines: that helper is a depth-1 flattener and renders a
|
|
189
|
+
// non-scalar array as "N item(s)", which is what made `ocx provider quota` useless in #2565.
|
|
190
|
+
const lines = events.length > 0
|
|
191
|
+
? events.map(quotaResetLine)
|
|
192
|
+
// An empty list is ambiguous, so say which kind of empty it is. Without this an operator
|
|
193
|
+
// cannot tell "nothing has reset yet" from "I never turned this on".
|
|
194
|
+
: [result?.enabled === true
|
|
195
|
+
? "no resets detected yet"
|
|
196
|
+
: "quota-reset notifications are disabled (set quotaResetNotify.enabled)"];
|
|
197
|
+
printData(result, wantsJson, lines);
|
|
198
|
+
}
|
|
135
199
|
async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
136
200
|
const args = [...argv];
|
|
137
201
|
const wantsJson = takeFlag(args, "--json");
|
|
@@ -210,6 +274,7 @@ export async function handleProviderRuntimeCommand(sub: string, argv: string[],
|
|
|
210
274
|
update: edit,
|
|
211
275
|
test: testProvider,
|
|
212
276
|
quota,
|
|
277
|
+
resets,
|
|
213
278
|
presets,
|
|
214
279
|
"account-mode": accountMode,
|
|
215
280
|
selected,
|
package/src/cli/provider.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
18
18
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
19
19
|
import { syncModelsToCodex } from "../codex/sync";
|
|
20
20
|
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
21
|
+
import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance";
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Arg helpers
|
|
@@ -212,6 +213,8 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
const existingProvider = config.providers[name];
|
|
216
|
+
const { initializeProviderModelSelection } = await import("../providers/initial-model-selection");
|
|
217
|
+
initializeProviderModelSelection(name, provConfig, existingProvider, config);
|
|
215
218
|
config.providers[name] = provConfig;
|
|
216
219
|
// A --force overwrite rotates the key/endpoint but must not drop a
|
|
217
220
|
// user-configured price overlay (same rule as the /api/providers path and
|
|
@@ -227,6 +230,7 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
227
230
|
if (wantsJson) {
|
|
228
231
|
console.log(JSON.stringify({
|
|
229
232
|
action: "added",
|
|
233
|
+
modelSelection: modelSelectionNextSteps(name),
|
|
230
234
|
provider: name,
|
|
231
235
|
adapter: provConfig.adapter,
|
|
232
236
|
baseUrl: provConfig.baseUrl,
|
|
@@ -255,6 +259,7 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
255
259
|
|
|
256
260
|
const registryLabel = registryEntry ? ` (${registryEntry.label})` : "";
|
|
257
261
|
console.log(`✅ Provider "${name}"${registryLabel} added.`);
|
|
262
|
+
for (const line of modelSelectionGuidance(name)) console.log(line);
|
|
258
263
|
if (setDefault) console.log(` Set as default provider.`);
|
|
259
264
|
if (registryEntry?.authKind === "oauth") {
|
|
260
265
|
console.log(` Authenticate with: ocx login ${name}`);
|
|
@@ -433,6 +438,7 @@ Subcommands:
|
|
|
433
438
|
set-default <name> Change the default provider
|
|
434
439
|
selected <name> Show or set the provider model allowlist
|
|
435
440
|
quota Show provider quota reports
|
|
441
|
+
resets Show recently detected quota resets
|
|
436
442
|
presets List GUI provider presets
|
|
437
443
|
account-mode <mode> Set OpenAI Codex pool/direct mode
|
|
438
444
|
|
|
@@ -442,6 +448,8 @@ Examples:
|
|
|
442
448
|
ocx provider add my-ollama --adapter openai-chat --base-url http://localhost:11434/v1
|
|
443
449
|
ocx provider show anthropic --json
|
|
444
450
|
ocx provider set-default anthropic
|
|
451
|
+
ocx provider edit xai --xai-chat on # opt Grok 4.5/4.6 into Chat Completions
|
|
452
|
+
ocx provider edit xai --xai-chat off # use Responses again
|
|
445
453
|
ocx provider remove my-ollama`;
|
|
446
454
|
|
|
447
455
|
export async function handleProviderCommand(args: string[]): Promise<void> {
|
package/src/cli/registry.ts
CHANGED
|
@@ -228,6 +228,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
228
228
|
usage: "ocx route combo <list|show|set|remove> ...",
|
|
229
229
|
summary: "Manage routing features; combo is currently the supported routing resource.",
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: "effort",
|
|
233
|
+
usage: "ocx effort [status|<level>|set|clear|model] [--main <level|->] [--subagent <level|->] [--injection <level|->] [--json]",
|
|
234
|
+
summary: "Inspect and configure reasoning effort caps and defaults.",
|
|
235
|
+
details: [
|
|
236
|
+
"With no arguments or `status`, displays effective effort caps, injection effort, and supported rungs.",
|
|
237
|
+
"`ocx effort <level>` (or `set --main <level>`) sets the global/main-agent reasoning ceiling.",
|
|
238
|
+
"`--subagent <level>` sets the hard ceiling for delegated sub-agent turns.",
|
|
239
|
+
"`ocx effort clear` (or `set --main - --subagent -`) removes main and subagent caps but preserves injection effort; use `ocx effort set --injection -` to clear it.",
|
|
240
|
+
"`ocx effort model <provider/model|model>` inspects a model's configured ladder, disabled status, and wire mappings.",
|
|
241
|
+
"Works both online (via live proxy API) and offline (modifies persisted config safely with atomic writes).",
|
|
242
|
+
],
|
|
243
|
+
},
|
|
231
244
|
{
|
|
232
245
|
name: "agent",
|
|
233
246
|
usage: "ocx agent <status|injection|effort|subagents|fallback|sidecar> ...",
|
|
@@ -315,13 +328,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
315
328
|
{
|
|
316
329
|
name: "claude",
|
|
317
330
|
usage: "ocx claude [claude args...]",
|
|
318
|
-
summary: "Launch Claude Code
|
|
331
|
+
summary: "Launch Claude Code through the proxy, with native fallback when Claude routing is disabled.",
|
|
319
332
|
details: [
|
|
320
333
|
"Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,",
|
|
321
334
|
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.",
|
|
335
|
+
"When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.",
|
|
322
336
|
"Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).",
|
|
323
337
|
"Older versions: pick models via ANTHROPIC_MODEL or /model <id> directly (any string passes through).",
|
|
324
|
-
"User-exported ANTHROPIC_* variables
|
|
338
|
+
"User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.",
|
|
325
339
|
"",
|
|
326
340
|
"Claude Desktop profile:",
|
|
327
341
|
" ocx claude desktop [apply] Save and apply the four-family profile",
|
package/src/cli/runtime-api.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - 다른 대안 대신 이 방식을 선택한 이유: GUI/CLI의 검증 규칙이 갈라지지 않고 fallback port도 안전하게 찾는다.
|
|
10
10
|
* - 장점, 단점 및 영향: 동작 일관성이 높아지는 대신 live 관리 명령은 실행 중인 proxy가 필요하다.
|
|
11
11
|
*/
|
|
12
|
-
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
12
|
+
import { findLiveProxy, probeHostname, type LivenessIo, type LiveProxy } from "../server/proxy-liveness";
|
|
13
13
|
import { runningProxyUpdateHeaders } from "../oauth/login-cli";
|
|
14
14
|
|
|
15
15
|
export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean };
|
|
@@ -20,6 +20,8 @@ export interface RuntimeApiDeps {
|
|
|
20
20
|
/** Test injection for commands that read a secret from stdin instead of argv. */
|
|
21
21
|
stdinImpl?: CliStdin;
|
|
22
22
|
stdinTimeoutMs?: number;
|
|
23
|
+
/** Optional proxy liveness probe injection for commands that check or fall back around live runtime state. */
|
|
24
|
+
findLiveProxy?: (io?: LivenessIo) => Promise<LiveProxy | null>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export class CliUsageError extends Error {
|
package/src/cli/star-prompt.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { isatty } from "node:tty";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { getConfigDir } from "../config";
|
|
5
6
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
@@ -86,11 +87,20 @@ function ghAvailable(): boolean {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/** Test seam: replace gh/interactiveConfirm so the full prompt flow is
|
|
89
|
-
* drivable without a real gh login or a TTY conversation.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
* drivable without a real gh login or a TTY conversation.
|
|
91
|
+
*
|
|
92
|
+
* `isTty` is part of the seam because the guard reads the file descriptors directly through
|
|
93
|
+
* `isatty` rather than `process.stdin.isTTY`: touching the stream properties would make Bun
|
|
94
|
+
* construct the stream, which dereferences the working directory and throws when that directory
|
|
95
|
+
* has been unlinked (#3400). A test therefore cannot fake a TTY by redefining those properties,
|
|
96
|
+
* so it overrides the decision here instead. */
|
|
97
|
+
type StarPromptTestDeps = {
|
|
98
|
+
ghAvailable?: () => boolean;
|
|
99
|
+
interactiveConfirm?: typeof interactiveConfirm;
|
|
100
|
+
isTty?: () => boolean;
|
|
101
|
+
};
|
|
102
|
+
let depsForTests: StarPromptTestDeps | null = null;
|
|
103
|
+
export function setStarPromptDepsForTests(deps: StarPromptTestDeps | null): void {
|
|
94
104
|
depsForTests = deps;
|
|
95
105
|
}
|
|
96
106
|
|
|
@@ -167,7 +177,13 @@ function printAgentDeferral(): void {
|
|
|
167
177
|
*/
|
|
168
178
|
export async function maybeShowStarPrompt(): Promise<void> {
|
|
169
179
|
try {
|
|
170
|
-
|
|
180
|
+
let isTty = false;
|
|
181
|
+
try {
|
|
182
|
+
isTty = depsForTests?.isTty ? depsForTests.isTty() : isatty(0) && isatty(1);
|
|
183
|
+
} catch {
|
|
184
|
+
/* best-effort */
|
|
185
|
+
}
|
|
186
|
+
if (process.env.OCX_SERVICE || !isTty) return;
|
|
171
187
|
const dir = getConfigDir();
|
|
172
188
|
const marker = join(dir, MARKER);
|
|
173
189
|
if (existsSync(marker)) return;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readPidFileValue, readRuntimePort } from "../config/process-state";
|
|
2
|
+
import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
|
|
3
|
+
import { directLocalHttpFetch } from "../server/direct-local-http";
|
|
4
|
+
import { isProcessAlive } from "../lib/process-control";
|
|
5
|
+
|
|
6
|
+
type HealthCheck = {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
url: string;
|
|
9
|
+
message: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** True only for a connect-phase refusal: proof that nothing holds the port. */
|
|
12
|
+
refused?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ListenTarget = {
|
|
16
|
+
port: number;
|
|
17
|
+
hostname?: string;
|
|
18
|
+
source: "runtime" | "config";
|
|
19
|
+
healthUrl: string;
|
|
20
|
+
dashboardUrl: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "timed out" | "unreachable" {
|
|
24
|
+
return signal.aborted || (error instanceof Error && error.name === "AbortError")
|
|
25
|
+
? "timed out"
|
|
26
|
+
: "unreachable";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every
|
|
31
|
+
* non-abort failure, including a socket that was ACCEPTED and then reset — which is what
|
|
32
|
+
* an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is
|
|
33
|
+
* free, so this reads the underlying errno instead of the display string.
|
|
34
|
+
*/
|
|
35
|
+
export function isConnectionRefused(error: unknown): boolean {
|
|
36
|
+
for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) {
|
|
37
|
+
const code = (current as { code?: unknown }).code;
|
|
38
|
+
if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true;
|
|
39
|
+
// Bun surfaces the refusal as a plain message on some platforms; the errno name is
|
|
40
|
+
// still the discriminator, not a substring of arbitrary prose.
|
|
41
|
+
if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true;
|
|
42
|
+
current = (current as { cause?: unknown }).cause;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
|
|
49
|
+
* `ocx.pid` and `runtime-port.json` (only SIGINT/SIGTERM/SIGHUP and normal exit are
|
|
50
|
+
* wired to it), so both records outlive it. That makes "crashed" and "never started"
|
|
51
|
+
* distinguishable — and #1419 is what it costs when we discard the distinction: the
|
|
52
|
+
* reporter's unsupervised `ocx gui` proxy died and every later command said only
|
|
53
|
+
* "not running", never that a previous process had exited or that a service would
|
|
54
|
+
* have restarted it.
|
|
55
|
+
*
|
|
56
|
+
* Two races have to stay closed, because a false "it crashed" is worse than a missing
|
|
57
|
+
* hint. `handleStart` binds the port BEFORE it publishes either record, so:
|
|
58
|
+
*
|
|
59
|
+
* - a start that publishes between two reads is caught by comparing the raw records
|
|
60
|
+
* observed before and after the probes (the same snapshot discipline
|
|
61
|
+
* `removePidIfValueIs` uses for deletion);
|
|
62
|
+
* - a start that has bound but not yet published leaves both snapshots identical, so
|
|
63
|
+
* records alone cannot see it. That one is excluded on the port instead: the probe
|
|
64
|
+
* must have been REFUSED at connect, which is the only outcome proving nothing holds
|
|
65
|
+
* the port. A socket that is accepted and then reset — an in-flight bind — is not a
|
|
66
|
+
* refusal, so review caught `unreachable` being too broad for this job.
|
|
67
|
+
*
|
|
68
|
+
* What this can and cannot prove: the records outliving their process establish that the
|
|
69
|
+
* previous run did not complete its cleanup. It does not establish a cause, and it cannot
|
|
70
|
+
* fully exclude a clean exit whose `unlinkSync` failed, because cleanup ignores that
|
|
71
|
+
* error (`src/cli/index.ts:324-325`) and the records carry no session provenance. The
|
|
72
|
+
* wording therefore says the records remain and the run MAY have exited unexpectedly.
|
|
73
|
+
*/
|
|
74
|
+
export function isUncleanExitEvidence(input: {
|
|
75
|
+
live: boolean;
|
|
76
|
+
healthOk: boolean;
|
|
77
|
+
healthRefused: boolean;
|
|
78
|
+
ownerPidAlive: boolean;
|
|
79
|
+
pidRecordBefore: number | null;
|
|
80
|
+
pidRecordAfter: number | null;
|
|
81
|
+
runtimePidBefore: number | null;
|
|
82
|
+
runtimePidAfter: number | null;
|
|
83
|
+
}): boolean {
|
|
84
|
+
if (input.live || input.healthOk) return false;
|
|
85
|
+
if (!input.healthRefused) return false;
|
|
86
|
+
if (input.ownerPidAlive) return false;
|
|
87
|
+
if (input.pidRecordBefore !== input.pidRecordAfter) return false;
|
|
88
|
+
if (input.runtimePidBefore !== input.runtimePidAfter) return false;
|
|
89
|
+
return input.pidRecordAfter !== null || input.runtimePidAfter !== null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
|
|
93
|
+
const url = target.healthUrl;
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => controller.abort(), 800);
|
|
96
|
+
try {
|
|
97
|
+
const response = await directLocalHttpFetch(url, { signal: controller.signal });
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const message = `returned HTTP ${response.status}`;
|
|
100
|
+
return { ok: false, url, message, label: `${url} ${message}` };
|
|
101
|
+
}
|
|
102
|
+
const body = await response.json().catch(() => null) as { service?: unknown; status?: unknown; version?: unknown; uptime?: unknown } | null;
|
|
103
|
+
if (!isOpencodexHealthz(body)) {
|
|
104
|
+
const message = "responded, but not an opencodex proxy";
|
|
105
|
+
return { ok: false, url, message, label: `${url} ${message}` };
|
|
106
|
+
}
|
|
107
|
+
const version = typeof body?.version === "string" ? ` v${body.version}` : "";
|
|
108
|
+
const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
|
|
109
|
+
const message = `ok${version}${uptime}`;
|
|
110
|
+
return { ok: true, url, message, label: `${url} ${message}` };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const reason = proxyHealthFailureReason(error, controller.signal);
|
|
113
|
+
return { ok: false, url, message: reason, label: `${url} ${reason}`, refused: isConnectionRefused(error) };
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The ONE evidence gatherer for stale-process state, shared by `ocx status` and
|
|
121
|
+
* `ocx doctor`.
|
|
122
|
+
*
|
|
123
|
+
* It deliberately probes the port named by the STALE RECORD, not the configured display
|
|
124
|
+
* port. Review found the two commands disagreeing precisely here: a proxy that hopped to
|
|
125
|
+
* a fallback port, or a config whose port changed after the crash, left status probing
|
|
126
|
+
* the configured port while doctor probed the recorded one, so one reported a crash and
|
|
127
|
+
* the other did not. The question being asked is "is the process that wrote this record
|
|
128
|
+
* gone?", and only that record's own port can answer it.
|
|
129
|
+
*
|
|
130
|
+
* `live` short-circuits before the probe so a healthy install pays nothing.
|
|
131
|
+
*/
|
|
132
|
+
export async function probeUncleanExitState(input: {
|
|
133
|
+
live: boolean;
|
|
134
|
+
port?: number;
|
|
135
|
+
hostname?: string | null;
|
|
136
|
+
}): Promise<boolean> {
|
|
137
|
+
if (input.live) return false;
|
|
138
|
+
const pidRecordBefore = readPidFileValue();
|
|
139
|
+
const runtimeBefore = readRuntimePort();
|
|
140
|
+
const runtimePidBefore = runtimeBefore?.pid ?? null;
|
|
141
|
+
if (pidRecordBefore === null && runtimePidBefore === null) return false;
|
|
142
|
+
const ownerPid = pidRecordBefore ?? runtimePidBefore;
|
|
143
|
+
if (ownerPid !== null && isProcessAlive(ownerPid)) return false;
|
|
144
|
+
// The recorded port is the evidence target. Fall back to the configured port only when
|
|
145
|
+
// no runtime record exists, which is the pid-file-only case.
|
|
146
|
+
const port = runtimeBefore?.port ?? input.port ?? 10100;
|
|
147
|
+
const hostname = runtimeBefore?.hostname ?? input.hostname ?? undefined;
|
|
148
|
+
const health = await checkProxyHealth({
|
|
149
|
+
port,
|
|
150
|
+
hostname,
|
|
151
|
+
source: runtimeBefore ? "runtime" : "config",
|
|
152
|
+
healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
|
|
153
|
+
dashboardUrl: `http://localhost:${port}/`,
|
|
154
|
+
});
|
|
155
|
+
const pidRecordAfter = readPidFileValue();
|
|
156
|
+
const runtimePidAfter = readRuntimePort()?.pid ?? null;
|
|
157
|
+
const ownerPidAfter = pidRecordAfter ?? runtimePidAfter;
|
|
158
|
+
return isUncleanExitEvidence({
|
|
159
|
+
live: false,
|
|
160
|
+
healthOk: health.ok,
|
|
161
|
+
healthRefused: health.refused === true,
|
|
162
|
+
ownerPidAlive: ownerPidAfter !== null && isProcessAlive(ownerPidAfter),
|
|
163
|
+
pidRecordBefore,
|
|
164
|
+
pidRecordAfter,
|
|
165
|
+
runtimePidBefore,
|
|
166
|
+
runtimePidAfter,
|
|
167
|
+
});
|
|
168
|
+
}
|
package/src/cli/status.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { durableBunRuntime } from "../lib/bun-runtime";
|
|
2
2
|
import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
|
|
3
|
-
import { getPidPath, readPid,
|
|
3
|
+
import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state";
|
|
4
4
|
import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
|
|
5
|
-
import { findLiveProxy,
|
|
6
|
-
import { directLocalHttpFetch } from "../server/direct-local-http";
|
|
5
|
+
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
7
6
|
import type { OcxConfig } from "../types";
|
|
8
7
|
import { diagnoseService, serviceLogPath } from "../service";
|
|
9
8
|
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
|
|
10
|
-
import { isProcessAlive } from "../lib/process-control";
|
|
11
9
|
import { getCodexRoutingKind } from "../codex/inject";
|
|
12
10
|
import { diagnoseCodexShim } from "../codex/shim";
|
|
13
11
|
import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
|
|
@@ -19,15 +17,9 @@ import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
|
|
|
19
17
|
import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
|
|
20
18
|
import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
|
|
21
19
|
import { collectClientConnectionStatus } from "./connect";
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
url: string;
|
|
26
|
-
message: string;
|
|
27
|
-
label: string;
|
|
28
|
-
/** True only for a connect-phase refusal: proof that nothing holds the port. */
|
|
29
|
-
refused?: boolean;
|
|
30
|
-
};
|
|
20
|
+
export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes";
|
|
21
|
+
export type { ListenTarget } from "./status-probes";
|
|
22
|
+
import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
|
|
31
23
|
|
|
32
24
|
export type CliStatusJson = {
|
|
33
25
|
schemaVersion: 1;
|
|
@@ -112,14 +104,6 @@ export type CliStatusView = {
|
|
|
112
104
|
};
|
|
113
105
|
|
|
114
106
|
|
|
115
|
-
export type ListenTarget = {
|
|
116
|
-
port: number;
|
|
117
|
-
hostname?: string;
|
|
118
|
-
source: "runtime" | "config";
|
|
119
|
-
healthUrl: string;
|
|
120
|
-
dashboardUrl: string;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
107
|
type StatusListenConfig = Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">;
|
|
124
108
|
|
|
125
109
|
function statusDashboardUrl(config: StatusListenConfig, hostname: string | undefined, port: number): string {
|
|
@@ -160,75 +144,6 @@ export function resolveStatusPid(
|
|
|
160
144
|
return live ? live.pid : pidFile;
|
|
161
145
|
}
|
|
162
146
|
|
|
163
|
-
export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "timed out" | "unreachable" {
|
|
164
|
-
return signal.aborted || (error instanceof Error && error.name === "AbortError")
|
|
165
|
-
? "timed out"
|
|
166
|
-
: "unreachable";
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every
|
|
171
|
-
* non-abort failure, including a socket that was ACCEPTED and then reset — which is what
|
|
172
|
-
* an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is
|
|
173
|
-
* free, so this reads the underlying errno instead of the display string.
|
|
174
|
-
*/
|
|
175
|
-
export function isConnectionRefused(error: unknown): boolean {
|
|
176
|
-
for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) {
|
|
177
|
-
const code = (current as { code?: unknown }).code;
|
|
178
|
-
if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true;
|
|
179
|
-
// Bun surfaces the refusal as a plain message on some platforms; the errno name is
|
|
180
|
-
// still the discriminator, not a substring of arbitrary prose.
|
|
181
|
-
if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true;
|
|
182
|
-
current = (current as { cause?: unknown }).cause;
|
|
183
|
-
}
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
|
|
189
|
-
* `ocx.pid` and `runtime-port.json` (only SIGINT/SIGTERM/SIGHUP and normal exit are
|
|
190
|
-
* wired to it), so both records outlive it. That makes "crashed" and "never started"
|
|
191
|
-
* distinguishable — and #1419 is what it costs when we discard the distinction: the
|
|
192
|
-
* reporter's unsupervised `ocx gui` proxy died and every later command said only
|
|
193
|
-
* "not running", never that a previous process had exited or that a service would
|
|
194
|
-
* have restarted it.
|
|
195
|
-
*
|
|
196
|
-
* Two races have to stay closed, because a false "it crashed" is worse than a missing
|
|
197
|
-
* hint. `handleStart` binds the port BEFORE it publishes either record, so:
|
|
198
|
-
*
|
|
199
|
-
* - a start that publishes between two reads is caught by comparing the raw records
|
|
200
|
-
* observed before and after the probes (the same snapshot discipline
|
|
201
|
-
* `removePidIfValueIs` uses for deletion);
|
|
202
|
-
* - a start that has bound but not yet published leaves both snapshots identical, so
|
|
203
|
-
* records alone cannot see it. That one is excluded on the port instead: the probe
|
|
204
|
-
* must have been REFUSED at connect, which is the only outcome proving nothing holds
|
|
205
|
-
* the port. A socket that is accepted and then reset — an in-flight bind — is not a
|
|
206
|
-
* refusal, so review caught `unreachable` being too broad for this job.
|
|
207
|
-
*
|
|
208
|
-
* What this can and cannot prove: the records outliving their process establish that the
|
|
209
|
-
* previous run did not complete its cleanup. It does not establish a cause, and it cannot
|
|
210
|
-
* fully exclude a clean exit whose `unlinkSync` failed, because cleanup ignores that
|
|
211
|
-
* error (`src/cli/index.ts:324-325`) and the records carry no session provenance. The
|
|
212
|
-
* wording therefore says the records remain and the run MAY have exited unexpectedly.
|
|
213
|
-
*/
|
|
214
|
-
export function isUncleanExitEvidence(input: {
|
|
215
|
-
live: boolean;
|
|
216
|
-
healthOk: boolean;
|
|
217
|
-
healthRefused: boolean;
|
|
218
|
-
ownerPidAlive: boolean;
|
|
219
|
-
pidRecordBefore: number | null;
|
|
220
|
-
pidRecordAfter: number | null;
|
|
221
|
-
runtimePidBefore: number | null;
|
|
222
|
-
runtimePidAfter: number | null;
|
|
223
|
-
}): boolean {
|
|
224
|
-
if (input.live || input.healthOk) return false;
|
|
225
|
-
if (!input.healthRefused) return false;
|
|
226
|
-
if (input.ownerPidAlive) return false;
|
|
227
|
-
if (input.pidRecordBefore !== input.pidRecordAfter) return false;
|
|
228
|
-
if (input.runtimePidBefore !== input.runtimePidAfter) return false;
|
|
229
|
-
return input.pidRecordAfter !== null || input.runtimePidAfter !== null;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
147
|
/**
|
|
233
148
|
* `ocx status` greens on process liveness alone, so a proxy that answers
|
|
234
149
|
* /healthz reads healthy even when Codex is not pointed at it and every routed
|
|
@@ -252,84 +167,6 @@ export function unusedProxyWarningLines(input: {
|
|
|
252
167
|
];
|
|
253
168
|
}
|
|
254
169
|
|
|
255
|
-
async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
|
|
256
|
-
const url = target.healthUrl;
|
|
257
|
-
const controller = new AbortController();
|
|
258
|
-
const timer = setTimeout(() => controller.abort(), 800);
|
|
259
|
-
try {
|
|
260
|
-
const response = await directLocalHttpFetch(url, { signal: controller.signal });
|
|
261
|
-
if (!response.ok) {
|
|
262
|
-
const message = `returned HTTP ${response.status}`;
|
|
263
|
-
return { ok: false, url, message, label: `${url} ${message}` };
|
|
264
|
-
}
|
|
265
|
-
const body = await response.json().catch(() => null) as { service?: unknown; status?: unknown; version?: unknown; uptime?: unknown } | null;
|
|
266
|
-
if (!isOpencodexHealthz(body)) {
|
|
267
|
-
const message = "responded, but not an opencodex proxy";
|
|
268
|
-
return { ok: false, url, message, label: `${url} ${message}` };
|
|
269
|
-
}
|
|
270
|
-
const version = typeof body?.version === "string" ? ` v${body.version}` : "";
|
|
271
|
-
const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
|
|
272
|
-
const message = `ok${version}${uptime}`;
|
|
273
|
-
return { ok: true, url, message, label: `${url} ${message}` };
|
|
274
|
-
} catch (error) {
|
|
275
|
-
const reason = proxyHealthFailureReason(error, controller.signal);
|
|
276
|
-
return { ok: false, url, message: reason, label: `${url} ${reason}`, refused: isConnectionRefused(error) };
|
|
277
|
-
} finally {
|
|
278
|
-
clearTimeout(timer);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* The ONE evidence gatherer for stale-process state, shared by `ocx status` and
|
|
284
|
-
* `ocx doctor`.
|
|
285
|
-
*
|
|
286
|
-
* It deliberately probes the port named by the STALE RECORD, not the configured display
|
|
287
|
-
* port. Review found the two commands disagreeing precisely here: a proxy that hopped to
|
|
288
|
-
* a fallback port, or a config whose port changed after the crash, left status probing
|
|
289
|
-
* the configured port while doctor probed the recorded one, so one reported a crash and
|
|
290
|
-
* the other did not. The question being asked is "is the process that wrote this record
|
|
291
|
-
* gone?", and only that record's own port can answer it.
|
|
292
|
-
*
|
|
293
|
-
* `live` short-circuits before the probe so a healthy install pays nothing.
|
|
294
|
-
*/
|
|
295
|
-
export async function probeUncleanExitState(input: {
|
|
296
|
-
live: boolean;
|
|
297
|
-
port?: number;
|
|
298
|
-
hostname?: string | null;
|
|
299
|
-
}): Promise<boolean> {
|
|
300
|
-
if (input.live) return false;
|
|
301
|
-
const pidRecordBefore = readPidFileValue();
|
|
302
|
-
const runtimeBefore = readRuntimePort();
|
|
303
|
-
const runtimePidBefore = runtimeBefore?.pid ?? null;
|
|
304
|
-
if (pidRecordBefore === null && runtimePidBefore === null) return false;
|
|
305
|
-
const ownerPid = pidRecordBefore ?? runtimePidBefore;
|
|
306
|
-
if (ownerPid !== null && isProcessAlive(ownerPid)) return false;
|
|
307
|
-
// The recorded port is the evidence target. Fall back to the configured port only when
|
|
308
|
-
// no runtime record exists, which is the pid-file-only case.
|
|
309
|
-
const port = runtimeBefore?.port ?? input.port ?? 10100;
|
|
310
|
-
const hostname = runtimeBefore?.hostname ?? input.hostname ?? undefined;
|
|
311
|
-
const health = await checkProxyHealth({
|
|
312
|
-
port,
|
|
313
|
-
hostname,
|
|
314
|
-
source: runtimeBefore ? "runtime" : "config",
|
|
315
|
-
healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
|
|
316
|
-
dashboardUrl: `http://localhost:${port}/`,
|
|
317
|
-
});
|
|
318
|
-
const pidRecordAfter = readPidFileValue();
|
|
319
|
-
const runtimePidAfter = readRuntimePort()?.pid ?? null;
|
|
320
|
-
const ownerPidAfter = pidRecordAfter ?? runtimePidAfter;
|
|
321
|
-
return isUncleanExitEvidence({
|
|
322
|
-
live: false,
|
|
323
|
-
healthOk: health.ok,
|
|
324
|
-
healthRefused: health.refused === true,
|
|
325
|
-
ownerPidAlive: ownerPidAfter !== null && isProcessAlive(ownerPidAfter),
|
|
326
|
-
pidRecordBefore,
|
|
327
|
-
pidRecordAfter,
|
|
328
|
-
runtimePidBefore,
|
|
329
|
-
runtimePidAfter,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
170
|
export async function collectStatus(): Promise<CliStatusView> {
|
|
334
171
|
const configDiagnostics = readConfigDiagnostics();
|
|
335
172
|
const config = configDiagnostics.config;
|