@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `ocx resolve` — the machine-readable runtime resolution surface for an embedding shell.
3
+ *
4
+ * D5 of devlog/_plan/260921_app_runtime_ownership/: the desktop shell must stop resolving
5
+ * the config home, the port and liveness itself. The tuned probe budgets in
6
+ * src/server/proxy-liveness.ts exist because a shell-side reimplementation answered
7
+ * "nobody listening" twice and started duplicate proxies; this verb exposes that module's
8
+ * verdict instead of copying it, alongside src/config/paths.ts (the home) and the CLI's
9
+ * own preferred-port selection (`config.port ?? 10100` — resolve takes no --port).
10
+ *
11
+ * Contract:
12
+ * - `--json` puts exactly ONE JSON document on stdout, versioned by `schema`; the
13
+ * default prints two human lines, the same opt-in split as `ocx ready --json`.
14
+ * - liveness has three answers, not two: "live", "absent-proven" (every recorded and
15
+ * configured endpoint definitively refused or answered non-opencodex), and unknown.
16
+ * Unknown NEVER reaches the wire as absent — a probe that timed out, a listener that
17
+ * withheld /healthz, or an identity mismatch exits 1 instead. Only "absent-proven"
18
+ * may authorise starting a new runtime.
19
+ * - exit 0 whenever a trustworthy verdict exists — live, or proven absent. A MISSING
20
+ * config.json is defaults, not an error.
21
+ * - exit 1 when the CLI cannot resolve: an invalid config.json must NOT be answered
22
+ * with `loadConfig`'s repair-to-defaults behaviour, because that hands the caller
23
+ * a guessed port; and unknown liveness must not be answered as absence.
24
+ * - exit 64 for any argument, pre-parsed in src/cli/root.ts before preflight side
25
+ * effects, mirroring `ocx ready`. The verb is read-only and listed in
26
+ * skipsCodexShimAutoRestore, so a lookup made to populate a consent surface never
27
+ * triggers a shim repair side effect.
28
+ *
29
+ * Discovery uses the START_OWNERSHIP_LIVENESS budget, not the 750ms single-shot default:
30
+ * the shell's launch decision keys on this verdict, and answering "nobody" for a slow
31
+ * live proxy is the duplicate-proxy decision the start path tunes against (#5004).
32
+ *
33
+ * Lives outside cli/index.ts (which dispatches argv at module top level) so tests can
34
+ * import it, the same split as ready.ts.
35
+ */
36
+ import { readConfigDiagnostics, type ConfigDiagnostics } from "../config";
37
+ import { getConfigDir } from "../config/paths";
38
+ import { readRuntimePort } from "../config/process-state";
39
+ import { packageVersion } from "../lib/package-version";
40
+ import {
41
+ findLiveProxy,
42
+ probeEndpointLiveness,
43
+ START_OWNERSHIP_LIVENESS,
44
+ type EndpointLiveness,
45
+ type LiveProxy,
46
+ } from "../server/proxy-liveness";
47
+ import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan";
48
+
49
+ /** Wire version of the resolve document. Bump only on an incompatible shape change. */
50
+ export const RESOLVE_SCHEMA = "ocx-resolve/1";
51
+
52
+ /** The port every preferred-port selection in the CLI falls back to. */
53
+ export const RESOLVE_DEFAULT_PORT = 10100;
54
+
55
+ export interface ResolveLivenessJson {
56
+ /**
57
+ * "live" when the identity-checked probe found our proxy; "absent-proven" when every
58
+ * recorded and configured endpoint is definitively dead. The third state — unknown —
59
+ * exits 1 before this document is printed, so it never appears on the wire as absence.
60
+ */
61
+ status: "live" | "absent-proven";
62
+ pid: number | null;
63
+ port: number | null;
64
+ /** Raw bind hostname that answered; compose probe URLs via probeHostname semantics. */
65
+ hostname?: string;
66
+ /** Where the verdict came from: the runtime record, or the configured listen port. */
67
+ source: LiveProxy["source"] | null;
68
+ /** Version the live proxy reported on /healthz, when it reported one. */
69
+ version?: string;
70
+ /** Listener role the live proxy reported, when it reported one ("client" = connected client). */
71
+ role?: string;
72
+ }
73
+
74
+ export interface ResolveJson {
75
+ schema: typeof RESOLVE_SCHEMA;
76
+ /** Version of this CLI binary, so a shell can compare its engine against the live proxy. */
77
+ cliVersion: string;
78
+ /** Resolved opencodex home (OPENCODEX_HOME or ~/.opencodex), from src/config/paths.ts. */
79
+ configHome: string;
80
+ port: {
81
+ /** The port a client should use: the live listener's port when one answers, else the configured one. */
82
+ effective: number;
83
+ /** The configured listen port (config.port ?? 10100); what a start would prefer. */
84
+ configured: number;
85
+ /** Whether `effective` came from a live proxy or from configuration. */
86
+ source: LiveProxy["source"];
87
+ };
88
+ liveness: ResolveLivenessJson;
89
+ }
90
+
91
+ export interface ResolveArgs {
92
+ json: boolean;
93
+ }
94
+
95
+ export type ResolveParseResult = { ok: true; args: ResolveArgs } | { ok: false; code: 64 };
96
+
97
+ /** Pure argument parser: the only flag is `--json`. */
98
+ export function parseResolveArgs(argv: string[]): ResolveParseResult {
99
+ for (const flag of argv) {
100
+ if (flag !== "--json") return { ok: false, code: 64 };
101
+ }
102
+ return { ok: true, args: { json: argv.includes("--json") } };
103
+ }
104
+
105
+ export interface ResolveIo {
106
+ configDir?: () => string;
107
+ readDiagnostics?: () => ConfigDiagnostics;
108
+ findLive?: () => Promise<LiveProxy | null>;
109
+ /** Runtime-port record reader; production default is readRuntimePort. */
110
+ readRuntime?: () => { port?: number; hostname?: string } | null;
111
+ /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */
112
+ probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise<EndpointLiveness>;
113
+ cliVersion?: () => string;
114
+ stdout?: { log: (s: string) => void };
115
+ stderr?: { error: (s: string) => void };
116
+ }
117
+
118
+ function livenessJson(live: LiveProxy | null): ResolveLivenessJson {
119
+ // Reaching here with null means absence was PROVEN by the caller (unknown exits 1
120
+ // before this document is built).
121
+ if (!live) return { status: "absent-proven", pid: null, port: null, source: null };
122
+ return {
123
+ status: "live",
124
+ pid: live.pid,
125
+ port: live.port,
126
+ source: live.source,
127
+ ...(live.hostname === undefined ? {} : { hostname: live.hostname }),
128
+ ...(live.version === undefined ? {} : { version: live.version }),
129
+ ...(live.role === undefined ? {} : { role: live.role }),
130
+ };
131
+ }
132
+
133
+ /** Pure shaper: one live verdict plus configuration becomes the wire document. */
134
+ export function buildResolveJson(
135
+ config: { port?: number },
136
+ live: LiveProxy | null,
137
+ configHome: string,
138
+ cliVersion: string,
139
+ ): ResolveJson {
140
+ const configured = config.port ?? RESOLVE_DEFAULT_PORT;
141
+ return {
142
+ schema: RESOLVE_SCHEMA,
143
+ cliVersion,
144
+ configHome,
145
+ port: {
146
+ effective: live ? live.port : configured,
147
+ configured,
148
+ source: live ? live.source : "config",
149
+ },
150
+ liveness: livenessJson(live),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Human form: two lines, no prose flourish — an operator skims it, a shell uses --json.
156
+ */
157
+ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): void {
158
+ stdout.log(`Config home: ${json.configHome}`);
159
+ const live = json.liveness;
160
+ if (live.status === "live") {
161
+ const pidText = live.pid === null ? "unknown" : String(live.pid);
162
+ const versionText = live.version ?? "unknown version";
163
+ stdout.log(`Proxy live on port ${json.port.effective} (PID ${pidText}, ${versionText}); effective port ${json.port.effective}.`);
164
+ } else {
165
+ stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Run `ocx resolve` over injected I/O. Returns the exit code. The production defaults
171
+ * read config through the diagnostics surface (which distinguishes missing, valid and
172
+ * invalid instead of repairing to defaults) and perform one identity-checked discovery
173
+ * at the ownership-safe budget — resolve adds no probing policy of its own.
174
+ */
175
+ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise<number> {
176
+ const stdout = io.stdout ?? console;
177
+ const stderr = io.stderr ?? console;
178
+ const configDir = io.configDir ?? getConfigDir;
179
+ const readDiagnostics = io.readDiagnostics ?? readConfigDiagnostics;
180
+ const findLive = io.findLive ?? (() => findLiveProxy(START_OWNERSHIP_LIVENESS));
181
+ const readRuntime = io.readRuntime ?? readRuntimePort;
182
+ const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness;
183
+ const cliVersion = io.cliVersion ?? packageVersion;
184
+ const configHome = configDir();
185
+ let diagnostics: ConfigDiagnostics;
186
+ try {
187
+ diagnostics = readDiagnostics();
188
+ } catch (error) {
189
+ // A resolution that could not run must not read as "no proxy": the caller has to
190
+ // refuse to guess (D5) rather than treat this as a proven-absent verdict.
191
+ stderr.error(`resolve failed: ${error instanceof Error ? error.message : String(error)}`);
192
+ return 1;
193
+ }
194
+ if (diagnostics.source === "fallback") {
195
+ // An invalid config must not resolve to defaults: the effective port would be a
196
+ // guess at 10100 while the operator's config.port is unread. The repair-to-defaults
197
+ // policy in loadConfig is for interactive recovery, not for a shell contract.
198
+ stderr.error(`resolve failed: the config in ${configHome} is invalid (${diagnostics.error ?? "unknown error"}); refusing to guess.`);
199
+ return 1;
200
+ }
201
+ let live: LiveProxy | null;
202
+ try {
203
+ live = await findLive();
204
+ } catch (error) {
205
+ stderr.error(`resolve failed: ${error instanceof Error ? error.message : String(error)}`);
206
+ return 1;
207
+ }
208
+ if (!live) {
209
+ // findLiveProxy collapses "definitely nothing" and "could not tell" into the same
210
+ // null. The launch decision keys on this verdict, so resolve owes the caller the
211
+ // tri-state answer the updater already enforces: only EVERY candidate definitively
212
+ // dead is absence. Anything else is unknown, and unknown exits 1 — it must never
213
+ // authorise starting a second runtime.
214
+ let provenDown = false;
215
+ try {
216
+ provenDown = await everyEndpointProvenDownAsync(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint);
217
+ } catch {
218
+ // A probe that cannot run is not evidence of absence.
219
+ provenDown = false;
220
+ }
221
+ if (!provenDown) {
222
+ stderr.error("resolve: liveness is unknown (a probe timed out or a listener withheld /healthz); refusing to treat unknown as absent.");
223
+ return 1;
224
+ }
225
+ }
226
+ const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion());
227
+ if (args.json) stdout.log(JSON.stringify(json));
228
+ else reportHuman(json, stdout);
229
+ return 0;
230
+ }
package/src/cli/root.ts CHANGED
@@ -10,16 +10,19 @@
10
10
  */
11
11
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
12
12
  import { parseReadyArgs, type ReadyArgs } from "./ready";
13
+ import { parseResolveArgs, type ResolveArgs } from "./resolve";
13
14
  import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
14
15
 
15
16
  export interface CliHead {
16
- kind: "version" | "help" | "ready" | "command";
17
+ kind: "version" | "help" | "ready" | "resolve" | "command";
17
18
  command: string | undefined;
18
19
  args: string[];
19
20
  /** For kind "help": the subcommand whose usage should print, if any. */
20
21
  helpTarget?: string;
21
22
  /** Present only for `ready`; undefined when the ready args failed to parse. */
22
23
  readyArgs?: ReadyArgs;
24
+ /** Present only for `resolve`; undefined when the resolve args failed to parse. */
25
+ resolveArgs?: ResolveArgs;
23
26
  }
24
27
 
25
28
  export function parseCliHead(argv: string[]): CliHead {
@@ -51,6 +54,13 @@ export function parseCliHead(argv: string[]): CliHead {
51
54
  if (!parsed.ok) return { kind: "ready", command, args, readyArgs: undefined };
52
55
  return { kind: "ready", command, args, readyArgs: parsed.args };
53
56
  }
57
+ // Same ordering contract as `ready`: `ocx resolve` rejects any argument with exit
58
+ // 64 BEFORE maybeAutoRestoreCodexShim (or any other preflight with side effects) runs.
59
+ if (command === "resolve") {
60
+ const parsed = parseResolveArgs(args.slice(1));
61
+ if (!parsed.ok) return { kind: "resolve", command, args, resolveArgs: undefined };
62
+ return { kind: "resolve", command, args, resolveArgs: parsed.args };
63
+ }
54
64
  return { kind: "command", command, args };
55
65
  }
56
66
 
@@ -79,6 +89,19 @@ export async function runCli(argv: string[]): Promise<CliHead> {
79
89
  maybeAutoRestoreCodexShim(head.command, head.args);
80
90
  return head;
81
91
  }
92
+ case "resolve": {
93
+ // Fail-closed impossible-state guard, mirroring ready: the pre-parse above already
94
+ // rejected invalid arguments before any preflight, so a missing resolveArgs means
95
+ // dispatch diverged. Refuse with code 64 and perform NO I/O.
96
+ if (!head.resolveArgs) {
97
+ console.error("Usage: ocx resolve [--json]");
98
+ console.error(" --json prints one JSON document: the config home, the effective port,");
99
+ console.error(" and the identity-checked liveness verdict.");
100
+ process.exit(64);
101
+ }
102
+ maybeAutoRestoreCodexShim(head.command, head.args);
103
+ return head;
104
+ }
82
105
  case "command":
83
106
  maybeAutoRestoreCodexShim(head.command, head.args);
84
107
  return head;
@@ -0,0 +1,56 @@
1
+ export interface StartOwnershipLease {
2
+ release(): void;
3
+ }
4
+
5
+ export class StartOwnershipRollbackUncertainError extends AggregateError {
6
+ constructor(errors: Iterable<unknown>) {
7
+ super(errors, "start listener rollback could not be proven complete");
8
+ this.name = "StartOwnershipRollbackUncertainError";
9
+ }
10
+ }
11
+
12
+ export interface StartOwnershipPublicationDeps<TBound> {
13
+ acquireLease(): StartOwnershipLease;
14
+ bind(): Promise<TBound>;
15
+ writePid(bound: TBound): void;
16
+ writeRuntime(bound: TBound): void;
17
+ stopBound(bound: TBound): void | Promise<void>;
18
+ removeRuntime(): void;
19
+ removePid(): void;
20
+ }
21
+
22
+ /** Bind and publish PID/runtime ownership as one lease-protected transaction. */
23
+ export async function bindAndPublishStartOwnership<TBound>(
24
+ deps: StartOwnershipPublicationDeps<TBound>,
25
+ ): Promise<TBound> {
26
+ const lease = deps.acquireLease();
27
+ let bound: TBound;
28
+ let releaseLease = true;
29
+ try {
30
+ try { bound = await deps.bind(); }
31
+ catch (error) {
32
+ if (error instanceof StartOwnershipRollbackUncertainError) releaseLease = false;
33
+ throw error;
34
+ }
35
+ try {
36
+ deps.writePid(bound);
37
+ deps.writeRuntime(bound);
38
+ } catch (error) {
39
+ const failures: unknown[] = [error];
40
+ let stopFailed = false;
41
+ try { await deps.stopBound(bound); }
42
+ catch (failure) { stopFailed = true; failures.push(failure); }
43
+ try { deps.removeRuntime(); } catch (failure) { failures.push(failure); }
44
+ try { deps.removePid(); } catch (failure) { failures.push(failure); }
45
+ if (stopFailed) {
46
+ releaseLease = false;
47
+ throw new StartOwnershipRollbackUncertainError(failures);
48
+ }
49
+ if (failures.length > 1) throw new AggregateError(failures, "start ownership publication rollback failed");
50
+ throw error;
51
+ }
52
+ return bound;
53
+ } finally {
54
+ if (releaseLease) lease.release();
55
+ }
56
+ }
@@ -1,5 +1,5 @@
1
1
  import { readPidFileValue, readRuntimePort } from "../config/process-state";
2
- import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
2
+ import { isConnectionRefused, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
3
3
  import { directLocalHttpFetch } from "../server/direct-local-http";
4
4
  import { isProcessAlive } from "../lib/process-control";
5
5
 
@@ -26,23 +26,7 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "
26
26
  : "unreachable";
27
27
  }
28
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
- }
29
+ export { isConnectionRefused } from "../server/proxy-liveness";
46
30
 
47
31
  /**
48
32
  * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
package/src/cli/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { durableBunRuntime } from "../lib/bun-runtime";
2
+ import { existsSync, readFileSync } from "node:fs";
2
3
  import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
3
4
  import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state";
4
5
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
@@ -7,6 +8,8 @@ import type { OcxConfig } from "../types";
7
8
  import { diagnoseService, serviceLogPath } from "../service";
8
9
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
9
10
  import { getCodexRoutingKind } from "../codex/inject";
11
+ import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
12
+ import { CODEX_CONFIG_PATH } from "../codex/paths";
10
13
  import { diagnoseCodexShim } from "../codex/shim";
11
14
  import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
12
15
  import { packageVersion } from "./help";
@@ -502,6 +505,65 @@ export function unusedProxyWarningLines(input: {
502
505
  ];
503
506
  }
504
507
 
508
+ /**
509
+ * The mirror case: routing is ours and nothing is answering it.
510
+ *
511
+ * #5261: this state does not merely fail model calls. The root `openai_base_url` we inject
512
+ * is the base URL of Codex's own built-in openai provider, so with the proxy down a user can
513
+ * be stopped at Codex sign-in with no mention of opencodex anywhere on the screen. The
514
+ * injection is on disk and survives reboot, so it does not clear itself.
515
+ *
516
+ * The rest of the not-running report offers only ways to bring the proxy BACK, which is the
517
+ * wrong half of the choice for someone who wants their editor working again now. `ocx restore`
518
+ * needs no proxy, no management API and no network, so name it here — this report is the
519
+ * surface such a user is most likely to reach before the config file itself.
520
+ *
521
+ * Restricted to routing opencodex owns. `custom-local` is somebody else's gateway, and
522
+ * `ocx restore` would not remove it.
523
+ */
524
+ export function deadProxyRoutingAdviceLines(input: {
525
+ proxyUp: boolean;
526
+ routingKind: StartupHealth["routingKind"];
527
+ }): string[] {
528
+ if (input.proxyUp || input.routingKind !== "opencodex-local") return [];
529
+ return [
530
+ "Codex is still pointed at this proxy, so sign-in and model requests both fail while it is down.",
531
+ "To hand Codex back to its own account and endpoints without starting anything: ocx restore",
532
+ ];
533
+ }
534
+
535
+ /**
536
+ * Read the live Codex config and report an opencodex catalog pointer whose file is gone.
537
+ *
538
+ * Unreadable or absent config is reported as no finding rather than as a problem: this is a
539
+ * diagnostic line, and inventing one from missing evidence is worse than staying quiet.
540
+ */
541
+ export function detectMissingCodexCatalogPath(): string | null {
542
+ try {
543
+ if (!existsSync(CODEX_CONFIG_PATH)) return null;
544
+ return missingOwnedCatalogPath(readFileSync(CODEX_CONFIG_PATH, "utf8"));
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * The one state in this report where Codex is broken independently of the proxy (#5261).
552
+ *
553
+ * A `model_catalog_json` naming a file that is gone stops Codex loading its configuration at
554
+ * all, so it presents as the same blank wall as dead routing while having a different cause and
555
+ * a different fix. Both are named, because restarting the proxy rewrites the catalog and
556
+ * restoring removes the pointer, and which one the user wants is their choice, not ours.
557
+ */
558
+ export function missingCodexCatalogLines(missingCatalogPath: string | null): string[] {
559
+ if (!missingCatalogPath) return [];
560
+ return [
561
+ "⚠️ Codex is pointed at a model catalog that is no longer on disk, so Codex cannot load its config:",
562
+ ` ${missingCatalogPath}`,
563
+ " Regenerate it with 'ocx start', or remove opencodex from Codex with 'ocx restore'.",
564
+ ];
565
+ }
566
+
505
567
  export async function collectStatus(): Promise<CliStatusView> {
506
568
  const configDiagnostics = readConfigDiagnostics();
507
569
  const config = configDiagnostics.config;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Structured summary for `ocx stop --json` (D4 of the app runtime ownership unit).
3
+ *
4
+ * The desktop shell drives the real `ocx stop` as a child process so the receipt-backed
5
+ * teardown, the drain, the Windows respawn verification and the client-config restore run
6
+ * exactly as they do from a terminal. This module makes that run's RESULT readable from
7
+ * outside: a StopRunRecord is threaded through the existing stop path in
8
+ * src/cli/index.ts (the same booleans that already decide the exit code), and
9
+ * summarizeStopRun turns it into one versioned JSON document.
10
+ *
11
+ * Nothing here re-decides anything. If a field of the record is wrong, the fix belongs
12
+ * in the stop path, not in the summarizer.
13
+ */
14
+ import { STOP_HISTORY_DEFERRED_EXIT_CODE } from "../update/stop-contract.mjs";
15
+
16
+ /** Wire version of the stop summary document. */
17
+ export const STOP_SUMMARY_SCHEMA = "ocx-stop/1";
18
+
19
+ export type StopServiceOutcome =
20
+ | "absent"
21
+ | "stopped"
22
+ | "stopped-respawnable"
23
+ | "failed"
24
+ | "state-unknown"
25
+ | "error";
26
+
27
+ export type StopProxyOutcome =
28
+ | "stopped"
29
+ | "stopped-orphan"
30
+ | "not-running"
31
+ | "stop-failed"
32
+ | "ownership-refused"
33
+ | "unresolvable-pid"
34
+ | "respawned"
35
+ | "unknown";
36
+
37
+ export type StopSharedTeardownOutcome =
38
+ | "restored"
39
+ | "performed-by-proxy"
40
+ | "refused"
41
+ | "failed"
42
+ | "skipped";
43
+
44
+ /** Facts recorded where the stop path already decides them. */
45
+ export interface StopRunRecord {
46
+ /** What stopServiceIfInstalledDetailed returned, or "error" when it threw. */
47
+ service: StopServiceOutcome;
48
+ /** Which proxy path ran and how it ended. */
49
+ proxy: StopProxyOutcome;
50
+ /** Who ended up restoring shared client config (native Codex + Grok). */
51
+ sharedTeardown: StopSharedTeardownOutcome;
52
+ /** An inherited pending-teardown receipt blocked the restore. */
53
+ inheritedTeardownBlocks: boolean;
54
+ /** A discharged receipt could not be removed from disk. */
55
+ receiptClearFailed: boolean;
56
+ }
57
+
58
+ /** The internal booleans that already pick the process exit code. */
59
+ export interface StopRunSignals {
60
+ failed: boolean;
61
+ historyOnly: boolean;
62
+ historyDeferred: boolean;
63
+ exitCode: number;
64
+ }
65
+
66
+ export interface StopSummaryJson {
67
+ schema: typeof STOP_SUMMARY_SCHEMA;
68
+ /** Strict exit-code view: true only for exit 0. */
69
+ ok: boolean;
70
+ outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed";
71
+ exitCode: number;
72
+ /** True when this stop left no proxy of this home running by its own paths. */
73
+ runtimeDown: boolean;
74
+ service: StopServiceOutcome;
75
+ proxy: StopProxyOutcome;
76
+ sharedTeardown: StopSharedTeardownOutcome;
77
+ /** One stable human-readable line for a caller's UI. */
78
+ message: string;
79
+ }
80
+
81
+ /** What handleStop returns: the pre-existing boolean plus the structured twin. */
82
+ export interface StopOutcome {
83
+ /**
84
+ * The exact boolean the stop path returned before summaries existed (!stopFailed).
85
+ * It is NOT the same as summary.ok: 79/80 stops return true here, because the
86
+ * downtime warning that keys on it applies whenever the runtime went down.
87
+ */
88
+ ok: boolean;
89
+ summary: StopSummaryJson;
90
+ }
91
+
92
+ function stopOutcome(record: StopRunRecord, signals: StopRunSignals): StopSummaryJson["outcome"] {
93
+ if (signals.failed) return "failed";
94
+ if (signals.historyOnly) return "history-incomplete";
95
+ if (signals.historyDeferred) {
96
+ // The deferred exit code is a proven claim; anything else means another obligation
97
+ // was sitting in the home, which the exit-code logic already reports as failure.
98
+ return signals.exitCode === STOP_HISTORY_DEFERRED_EXIT_CODE ? "history-deferred" : "failed";
99
+ }
100
+ if (record.proxy === "not-running") return "not-running";
101
+ if (record.proxy === "stopped" || record.proxy === "stopped-orphan") return "stopped";
102
+ return "failed";
103
+ }
104
+
105
+ function stopMessage(record: StopRunRecord, signals: StopRunSignals, outcome: StopSummaryJson["outcome"]): string {
106
+ if (record.proxy === "respawned") return "The proxy was respawned after the stop; it is still running.";
107
+ if (record.proxy === "ownership-refused") return "The proxy refused the stop; it belongs to a different opencodex home.";
108
+ if (record.proxy === "unresolvable-pid") return "A proxy is answering, but its process id could not be resolved, so it was not stopped.";
109
+ if (record.proxy === "stop-failed") return "The proxy process could not be stopped.";
110
+ if (record.service === "failed") return "The installed service manager did not stop and may respawn the proxy.";
111
+ if (record.service === "state-unknown") return "The service manager state could not be read.";
112
+ if (record.service === "error") return "Stopping the installed service failed.";
113
+ if (record.inheritedTeardownBlocks) return "An earlier stop left an outstanding shared teardown that could not be confirmed.";
114
+ if (record.sharedTeardown === "failed") return "The shared teardown failed; client configuration may still point at the stopped proxy.";
115
+ if (record.receiptClearFailed) return "The shared teardown finished, but its receipt could not be removed.";
116
+ if (record.sharedTeardown === "refused") return "The shared teardown was refused before it changed anything; it is still owed.";
117
+ if (signals.historyOnly) return "The proxy stopped; Codex history cleanup did not complete.";
118
+ if (outcome === "history-deferred") return "The proxy stopped; the shared teardown was deferred and is still owed.";
119
+ if (outcome === "not-running") return "No proxy was running.";
120
+ if (outcome === "stopped") return "The proxy stopped.";
121
+ return "The stop failed.";
122
+ }
123
+
124
+ /** Pure mapper from the recorded run to the wire document. */
125
+ export function summarizeStopRun(record: StopRunRecord, signals: StopRunSignals): StopSummaryJson {
126
+ const outcome = stopOutcome(record, signals);
127
+ return {
128
+ schema: STOP_SUMMARY_SCHEMA,
129
+ ok: signals.exitCode === 0,
130
+ outcome,
131
+ exitCode: signals.exitCode,
132
+ runtimeDown: record.proxy === "stopped" || record.proxy === "stopped-orphan" || record.proxy === "not-running",
133
+ service: record.service,
134
+ proxy: record.proxy,
135
+ sharedTeardown: record.sharedTeardown,
136
+ message: stopMessage(record, signals, outcome),
137
+ };
138
+ }
139
+
140
+ /** Emit the summary as exactly one JSON document on stdout. */
141
+ export function printStopSummary(summary: StopSummaryJson, stdout: { log: (s: string) => void } = console): void {
142
+ stdout.log(JSON.stringify(summary));
143
+ }
@@ -84,3 +84,12 @@ export function everyEndpointProvenDown(
84
84
  if (endpoints.length === 0) return false;
85
85
  return endpoints.every(e => probe(e) === "dead");
86
86
  }
87
+
88
+ export async function everyEndpointProvenDownAsync(
89
+ endpoints: readonly ProbeEndpoint[],
90
+ probe: (e: ProbeEndpoint) => Promise<"live" | "dead" | "unknown"> | "live" | "dead" | "unknown",
91
+ ): Promise<boolean> {
92
+ if (endpoints.length === 0) return false;
93
+ const results = await Promise.all(endpoints.map(e => probe(e)));
94
+ return results.every(result => result === "dead");
95
+ }
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import type { Server } from "bun";
3
2
  import { loadConfig } from "../config";
4
3
  import { browserSecurityHeaders } from "../server/auth-cors";
@@ -16,11 +15,9 @@ import { readClientConnectionState } from "./state";
16
15
  import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api";
17
16
  import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth";
18
17
  import { relayHubManagementRequest } from "./hub-relay";
18
+ import { packageVersion } from "../lib/package-version";
19
19
 
20
- const VERSION = (() => {
21
- try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; }
22
- catch { return "0.0.0"; }
23
- })();
20
+ const VERSION = packageVersion("0.0.0");
24
21
  const GUI_SPA_PATHS = new Set([
25
22
  "/dashboard", "/startup", "/providers", "/models", "/subagents",
26
23
  "/logs", "/usage", "/storage", "/codex-set", "/integrations",
@@ -201,6 +201,7 @@ export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, pr
201
201
  const selected = { ...profile };
202
202
  const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer }));
203
203
  const captured = boundary(selected, registered, false);
204
+ const lstatProbe = io.lstatKind;
204
205
  function check(path: string, directory: boolean, mutation: boolean): void {
205
206
  if (path !== (directory ? selected.detectDir : selected.configPath)) {
206
207
  refuse("IO attempted to access a different account path.");
@@ -214,6 +215,9 @@ export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, pr
214
215
  return {
215
216
  readText: path => { check(path, false, false); return io.readText(path); },
216
217
  statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); },
218
+ ...(lstatProbe
219
+ ? { lstatKind: (path: string) => { check(path, path === selected.detectDir, false); return lstatProbe(path); } }
220
+ : {}),
217
221
  writeText: (path, text) => { check(path, false, true); io.writeText(path, text); },
218
222
  removeFile: path => { check(path, false, true); io.removeFile(path); },
219
223
  mkdirp: path => { check(path, true, true); io.mkdirp(path); },