@bitkyc08/opencodex 2.69.0 → 2.70.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.
Files changed (147) hide show
  1. package/gui/dist/assets/App-_MrbTrBe.js +51 -0
  2. package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
  3. package/gui/dist/assets/index-DKrO2eF3.js +86 -0
  4. package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-model-contract.ts +119 -0
  8. package/src/adapters/anthropic.ts +14 -83
  9. package/src/adapters/base.ts +2 -0
  10. package/src/adapters/command-code-project-context.ts +542 -0
  11. package/src/adapters/command-code-tool-text.ts +99 -67
  12. package/src/adapters/command-code.ts +5 -1
  13. package/src/adapters/cursor/catalog.ts +11 -0
  14. package/src/adapters/cursor/effort-map.ts +3 -0
  15. package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
  16. package/src/adapters/devin/cloud-direct/chat.ts +78 -48
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
  18. package/src/adapters/devin/context-overflow.ts +75 -0
  19. package/src/adapters/devin/live-models.ts +174 -13
  20. package/src/adapters/devin/reasoning-signature.ts +100 -0
  21. package/src/adapters/devin.ts +310 -117
  22. package/src/adapters/kiro/payload.ts +10 -10
  23. package/src/adapters/kiro-images.ts +58 -7
  24. package/src/adapters/openai-chat/passthrough.ts +3 -1
  25. package/src/adapters/openai-chat.ts +2 -1
  26. package/src/adapters/openai-responses/passthrough.ts +2 -1
  27. package/src/bridge/response-json.ts +15 -1
  28. package/src/bridge/sse.ts +3 -1
  29. package/src/claude/intercept/model-bindings.ts +1 -0
  30. package/src/cli/account-extended.ts +31 -1
  31. package/src/cli/account.ts +3 -0
  32. package/src/cli/capabilities.ts +13 -0
  33. package/src/cli/client-start-fence.ts +36 -0
  34. package/src/cli/dispatch.ts +3 -2
  35. package/src/cli/index.ts +98 -117
  36. package/src/cli/provider-runtime.ts +23 -2
  37. package/src/cli/provider.ts +3 -0
  38. package/src/cli/resolve.ts +18 -0
  39. package/src/cli/start-owner-fence.ts +30 -0
  40. package/src/cli/stop-approval.ts +13 -1
  41. package/src/cli/stop-restore.ts +73 -0
  42. package/src/cli/tray-proxy.ts +221 -18
  43. package/src/cli/version-skew.ts +25 -23
  44. package/src/client/runtime.ts +8 -4
  45. package/src/codex/auth-api/account-list.ts +11 -5
  46. package/src/codex/auth-api/login-flow.ts +5 -3
  47. package/src/codex/auth-api/main-account-probe.ts +186 -110
  48. package/src/codex/auth-api/pool-mode-gate.ts +61 -7
  49. package/src/codex/auth-api/pool-quota-probe.ts +142 -90
  50. package/src/codex/auth-api/reset-credit-service.ts +1 -1
  51. package/src/codex/catalog/gather-capture.ts +1 -0
  52. package/src/codex/catalog/model-hints.ts +3 -3
  53. package/src/codex/catalog/provider-models.ts +2 -0
  54. package/src/codex/low-quota-events.ts +28 -0
  55. package/src/codex/low-quota-observer.ts +23 -0
  56. package/src/codex/low-quota-protection.ts +272 -0
  57. package/src/codex/quota-query-backoff.ts +148 -0
  58. package/src/codex/quota.ts +7 -2
  59. package/src/config/diagnostics.ts +10 -1
  60. package/src/config/load-degrade.ts +17 -0
  61. package/src/config/mutation-lock.ts +3 -4
  62. package/src/config/process-state.ts +1 -1
  63. package/src/config/provider-validation.ts +22 -0
  64. package/src/config/schema/blocked-model-redirects.ts +17 -0
  65. package/src/config/schema/config-schema.ts +4 -1
  66. package/src/config/schema/leaf-validators.ts +33 -2
  67. package/src/config/serving-runtimes.ts +412 -0
  68. package/src/generated/compatibility-version.json +211 -115
  69. package/src/generated/model-metadata.ts +3 -3
  70. package/src/images/loop.ts +3 -0
  71. package/src/lib/errors.ts +2 -1
  72. package/src/lib/handled-signal-exit.ts +23 -0
  73. package/src/lib/request-failure-model.ts +1 -0
  74. package/src/lib/self-launch-argv.ts +9 -0
  75. package/src/lib/shadow-call.ts +5 -2
  76. package/src/lib/strict-semver.ts +26 -0
  77. package/src/lib/transient-service-path.ts +16 -0
  78. package/src/lib/windows-service-wrappers.ts +10 -6
  79. package/src/lib/winsw.ts +3 -1
  80. package/src/oauth/anthropic-model-routes.ts +105 -0
  81. package/src/oauth/anthropic-routing.ts +85 -48
  82. package/src/oauth/devin.ts +135 -5
  83. package/src/oauth/index.ts +20 -3
  84. package/src/oauth/kiro-terminal-failover.ts +23 -9
  85. package/src/oauth/pool-settings-capability.ts +10 -2
  86. package/src/oauth/store.ts +1 -1
  87. package/src/protocols/encoders/adapter-events.ts +3 -1
  88. package/src/providers/github-copilot-context.ts +27 -0
  89. package/src/providers/kiro-models.ts +4 -0
  90. package/src/providers/model-rename-fields.ts +3 -0
  91. package/src/providers/quota/account-cache.ts +8 -3
  92. package/src/providers/quota/devin.ts +286 -0
  93. package/src/providers/quota/vendor-probes-oauth.ts +6 -2
  94. package/src/providers/quota.ts +1 -1
  95. package/src/providers/registry/entries-core.ts +1 -1
  96. package/src/providers/registry/model-seeds.ts +5 -2
  97. package/src/responses/compaction-images.ts +27 -0
  98. package/src/router.ts +104 -16
  99. package/src/routing/analytics.ts +1 -0
  100. package/src/server/auth-cors.ts +9 -1
  101. package/src/server/background-lifecycle.ts +21 -10
  102. package/src/server/chat-completions.ts +2 -1
  103. package/src/server/chat-native.ts +1 -0
  104. package/src/server/index.ts +4 -8
  105. package/src/server/inference/client-encoder-delivery.ts +3 -0
  106. package/src/server/management/context.ts +3 -0
  107. package/src/server/management/low-quota-routes.ts +14 -0
  108. package/src/server/management/oauth-account-routes.ts +21 -1
  109. package/src/server/management/provider-overwrite-carry.ts +79 -6
  110. package/src/server/management/provider-routes.ts +41 -28
  111. package/src/server/management/route-registry.ts +1 -0
  112. package/src/server/management-api.ts +7 -0
  113. package/src/server/responses/adapter-continuation.ts +3 -0
  114. package/src/server/responses/adapter-delivery.ts +3 -0
  115. package/src/server/responses/adapter-dispatch.ts +20 -4
  116. package/src/server/responses/antigravity-validation-refusal.ts +20 -0
  117. package/src/server/responses/core-auth.ts +1 -0
  118. package/src/server/responses/core-codex-account.ts +1 -0
  119. package/src/server/responses/core-normalize.ts +4 -0
  120. package/src/server/responses/passthrough-dispatch.ts +8 -1
  121. package/src/server/responses/request-sidecar-auth.ts +2 -0
  122. package/src/server/responses/request-transport.ts +27 -10
  123. package/src/server/responses/response-effects.ts +4 -1
  124. package/src/server/responses/run-turn-execution.ts +126 -42
  125. package/src/server/responses/sidecar-execution.ts +4 -1
  126. package/src/service/diagnostics.ts +3 -1
  127. package/src/service/guarded-manager-target.ts +39 -12
  128. package/src/service/launchd.ts +14 -6
  129. package/src/service/repair.ts +6 -1
  130. package/src/service/service-child-ownership.ts +168 -0
  131. package/src/service/state.ts +22 -4
  132. package/src/service/systemd.ts +2 -2
  133. package/src/service/windows-guarded-manager.ts +530 -0
  134. package/src/service/windows-taskxml.ts +2 -2
  135. package/src/service.ts +2 -0
  136. package/src/types/config.ts +26 -0
  137. package/src/types/provider.ts +12 -0
  138. package/src/types/request.ts +2 -0
  139. package/src/types.ts +1 -0
  140. package/src/usage/expected-prices.ts +13 -0
  141. package/src/usage/telemetry-contract.ts +1 -0
  142. package/src/vision/anthropic-describe.ts +2 -1
  143. package/src/web-search/anthropic-executor.ts +2 -1
  144. package/src/web-search/loop.ts +1 -0
  145. package/gui/dist/assets/App-DDbgOTh6.js +0 -51
  146. package/gui/dist/assets/index-CuaBJkG4.js +0 -86
  147. package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
@@ -40,6 +40,25 @@ export type ProxyRestartDiscovery =
40
40
  | { status: "absent" }
41
41
  | { status: "uncertain"; error?: unknown };
42
42
 
43
+ export type ProxyRestartStartOutcome =
44
+ | { status: "started" }
45
+ | { status: "skipped" }
46
+ | { status: "failed"; launch: "never" | "exited" | "unknown"; error?: unknown };
47
+
48
+ /** Only the launched child's observed exit (or no launch) permits another attempt. */
49
+ export function restartStartOutcome(
50
+ started: boolean,
51
+ child: { exitCode: number | null; signalCode: string | null } | null,
52
+ error?: unknown,
53
+ ): ProxyRestartStartOutcome {
54
+ if (started) return { status: "started" };
55
+ return {
56
+ status: "failed",
57
+ launch: child === null ? "never" : child.exitCode !== null || child.signalCode !== null ? "exited" : "unknown",
58
+ error,
59
+ };
60
+ }
61
+
43
62
  export interface ProxyRestartDiscoveryIo {
44
63
  findLive: () => Promise<ProxyRestartLive | null>;
45
64
  waitBetweenChecks?: () => Promise<void>;
@@ -48,11 +67,81 @@ export interface ProxyRestartDiscoveryIo {
48
67
 
49
68
  export interface ProxyRestartIo {
50
69
  findLive: () => Promise<ProxyRestartDiscovery>;
51
- startWhenStopped: () => boolean | "skipped" | Promise<boolean | "skipped">;
70
+ startWhenStopped: (recoveringLiveRestart: boolean) => ProxyRestartStartOutcome | Promise<ProxyRestartStartOutcome>;
52
71
  requestInPlaceRestart: (
53
72
  previous: ProxyRestartLive,
54
73
  ) => ProxyRestartRequestOutcome | Promise<ProxyRestartRequestOutcome>;
55
74
  waitForReplacement: (previous: ProxyRestartLive) => Promise<ProxyRestartLive | null>;
75
+ /** Pause between start attempts; defaults to a short sleep. Tests pass a recorder. */
76
+ waitBetweenAttempts?: () => Promise<void>;
77
+ /**
78
+ * Strong re-observation after a missed replacement. Defaults to `findLive`, but the
79
+ * production wiring passes a fresh bounded window: by the time the replacement wait
80
+ * expires, the shared observe deadline has expired too, so reusing it would answer
81
+ * `uncertain` forever and the crash-recovery path could never run.
82
+ */
83
+ reobserveAfterReplacement?: (previous: ProxyRestartLive) => Promise<ProxyRestartDiscovery>;
84
+ /** A failed recovery start must recheck under a fresh window after the shared deadline. */
85
+ recheckAfterFailedStart?: () => Promise<ProxyRestartDiscovery>;
86
+ }
87
+
88
+ /**
89
+ * Poll for a departed predecessor inside a bounded confirmation window. Each round is
90
+ * already a strong observation (the caller supplies it); this only decides whether
91
+ * another round still fits: absent and valid-replacement verdicts return immediately,
92
+ * while the still-live old PID (or another uncertain round) keeps polling until the
93
+ * budget check refuses. A replacement that lands late in the window is still attested
94
+ * instead of being cut off by the wait that reserved the window.
95
+ */
96
+ export async function pollReplacementDeparture(
97
+ observe: () => Promise<ProxyRestartDiscovery>,
98
+ previous: ProxyRestartLive,
99
+ shouldContinue: () => boolean,
100
+ wait: () => Promise<void>,
101
+ ): Promise<ProxyRestartDiscovery> {
102
+ for (;;) {
103
+ const round = await observe();
104
+ if (round.status === "absent") return round;
105
+ if (round.status === "live" && isProxyReplacement(previous, round.live)) return round;
106
+ if (!shouldContinue()) return round;
107
+ await wait();
108
+ }
109
+ }
110
+
111
+ export async function waitForProxyReplacement(
112
+ previous: ProxyRestartLive,
113
+ deadlineAt: number,
114
+ observe: (deadlineAt: number) => Promise<ProxyRestartLive | null>,
115
+ ): Promise<ProxyRestartLive | null> {
116
+ while (Date.now() < deadlineAt) {
117
+ const live = await observe(deadlineAt);
118
+ if (Date.now() >= deadlineAt) return null;
119
+ if (isProxyReplacement(previous, live)) return live;
120
+ const remainingMs = deadlineAt - Date.now();
121
+ if (remainingMs > 0) await Bun.sleep(Math.min(250, remainingMs));
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export function reobserveRestartReplacement(
127
+ previous: ProxyRestartLive,
128
+ deadlineAt: number,
129
+ observe: (end: number) => Promise<ProxyRestartDiscovery>,
130
+ ): Promise<ProxyRestartDiscovery> {
131
+ const windowMs = Math.min(5_000, deadlineAt - Date.now());
132
+ if (windowMs < 1_500) return Promise.resolve({
133
+ status: "uncertain", error: new Error("restart_reobserve_window_exhausted"),
134
+ });
135
+ const end = Date.now() + windowMs;
136
+ return pollReplacementDeparture(() => observe(end), previous, () => Date.now() + 750 < end, () => Bun.sleep(250));
137
+ }
138
+
139
+ /** Production recovery recheck: the original restart deadline may already be spent. */
140
+ export function recheckRestartFailedStart(
141
+ observe: (end: number) => Promise<ProxyRestartDiscovery>,
142
+ ): Promise<ProxyRestartDiscovery> {
143
+ const end = Date.now() + 5_000;
144
+ return observe(end);
56
145
  }
57
146
 
58
147
  /**
@@ -145,13 +234,110 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean>
145
234
  * child and lets ordinary /api/stop restore native routing between the two halves.
146
235
  * When no proxy is live there is nothing to recycle, so restart degrades to the
147
236
  * caller's normal start path.
237
+ *
238
+ * An uncertain discovery round is re-observed. A failed start is retried only after
239
+ * proof that it never launched or its child exited; a health miss is insufficient.
240
+ * A missed replacement gets a bounded re-observation. A live target is never stopped
241
+ * to make room, and absence is confirmed twice before any fresh start.
242
+ */
243
+ /**
244
+ * A proven pre-launch refusal or observed child exit can be retried within this bound.
245
+ * A deliberate operator refusal (`"skipped"`) never retries.
148
246
  */
247
+ const RESTART_START_ATTEMPTS = 3;
248
+
249
+ /** Discovery rounds per restart transaction; an uncertain round is a transient race. */
250
+ const RESTART_DISCOVERY_ATTEMPTS = 3;
251
+
252
+ /**
253
+ * Start leg with phase evidence. A bare boolean cannot tell a pre-launch refusal from
254
+ * a child that was launched but is not healthy yet, or from post-health steps that
255
+ * threw on an already-live proxy — and blindly respawning on any of those risks a
256
+ * second proxy racing the first for the port. So every failed attempt is followed by a
257
+ * beat and a strong re-observation: a live proxy attests success (no second start),
258
+ * and only confirmed absence plus launch proof earns another attempt.
259
+ */
260
+ async function startRestartedProxy(
261
+ io: ProxyRestartIo,
262
+ waitBetweenAttempts: () => Promise<void>,
263
+ recoveringLiveRestart: boolean,
264
+ /** The restarted proxy's identity; recovery success must be a different process. */
265
+ previous?: ProxyRestartLive,
266
+ ): Promise<ProxyRestartResult> {
267
+ let originalError: unknown;
268
+ for (let attempt = 0; attempt < RESTART_START_ATTEMPTS; attempt++) {
269
+ let outcome: ProxyRestartStartOutcome;
270
+ try {
271
+ outcome = await io.startWhenStopped(recoveringLiveRestart);
272
+ } catch (error) {
273
+ // An unstructured exception can have happened after launch.
274
+ outcome = { status: "failed", launch: "unknown", error };
275
+ }
276
+ if (outcome.status === "skipped") return recoveringLiveRestart
277
+ ? { ok: false, phase: "replacement" } : { ok: true, mode: "skipped" };
278
+ if (outcome.status === "started") {
279
+ if (!previous) return { ok: true, mode: "started" };
280
+ // Recovery after a live restart: the start path counts any healthy proxy it finds as
281
+ // started, and the ORIGINAL process reappearing would satisfy it. Success needs an
282
+ // identity-verified replacement — a different runtime PID on the original port —
283
+ // observed in a fresh window; anything else fails closed as a missed replacement.
284
+ let confirm: ProxyRestartDiscovery;
285
+ try {
286
+ confirm = await (io.recheckAfterFailedStart?.() ?? io.findLive());
287
+ } catch (error) {
288
+ return { ok: false, phase: "replacement", error };
289
+ }
290
+ return confirm.status === "live" && isProxyReplacement(previous, confirm.live)
291
+ ? { ok: true, mode: "started" }
292
+ : { ok: false, phase: "replacement" };
293
+ }
294
+ if (attempt === 0) originalError = outcome.error;
295
+ // Beat first: a child that was just launched may still be binding, and post-health
296
+ // steps may have thrown on an already-live proxy. Re-observe before deciding: live
297
+ // attests success (never a second start), only confirmed absence earns a retry.
298
+ await waitBetweenAttempts();
299
+ let recheck: ProxyRestartDiscovery;
300
+ try {
301
+ recheck = await (io.recheckAfterFailedStart?.() ?? io.findLive());
302
+ } catch {
303
+ return { ok: false, phase: "start", error: originalError };
304
+ }
305
+ // Runtime-attested only: a config-sourced observation is not proof a proxy serves,
306
+ // so it keeps the start failure instead of reporting a success nobody earned.
307
+ // A throw on an attested-live proxy is different from a clean refusal: post-health
308
+ // work failed on a serving process, so the error propagates instead of converting
309
+ // to success — still without spawning again.
310
+ const attested = recheck.status === "live"
311
+ && recheck.live.pid !== null
312
+ && recheck.live.source === "runtime";
313
+ // Recovery after a live restart: the old PID coming back is not a replacement this
314
+ // command started. It is serving, so never start another over it, and never call it
315
+ // success either.
316
+ if (attested && previous && recheck.status === "live" && !isProxyReplacement(previous, recheck.live)) {
317
+ return { ok: false, phase: "replacement" };
318
+ }
319
+ if (attested && outcome.error !== undefined) return { ok: false, phase: "start", error: originalError };
320
+ if (attested) return { ok: true, mode: "started" };
321
+ if (outcome.launch === "unknown" || recheck.status !== "absent") {
322
+ return { ok: false, phase: "start", error: originalError };
323
+ }
324
+ }
325
+ return { ok: false, phase: "start", error: originalError };
326
+ }
327
+
149
328
  export async function runProxyRestart(io: ProxyRestartIo): Promise<ProxyRestartResult> {
150
- let discovery: ProxyRestartDiscovery;
151
- try {
152
- discovery = await io.findLive();
153
- } catch (error) {
154
- return { ok: false, phase: "request", error };
329
+ const waitBetweenAttempts = io.waitBetweenAttempts ?? (() => Bun.sleep(500));
330
+ // An uncertain round is a transient appear/vanish race, not a verdict: re-observe a
331
+ // few times before failing, so one invocation survives a supervisor mid-handoff.
332
+ let discovery: ProxyRestartDiscovery = { status: "uncertain", error: new Error("restart_discovery_no_attempt") };
333
+ for (let attempt = 0; attempt < RESTART_DISCOVERY_ATTEMPTS; attempt++) {
334
+ if (attempt > 0) await waitBetweenAttempts();
335
+ try {
336
+ discovery = await io.findLive();
337
+ } catch (error) {
338
+ discovery = { status: "uncertain", error };
339
+ }
340
+ if (discovery.status !== "uncertain") break;
155
341
  }
156
342
 
157
343
  if (discovery.status === "uncertain") {
@@ -159,13 +345,7 @@ export async function runProxyRestart(io: ProxyRestartIo): Promise<ProxyRestartR
159
345
  }
160
346
 
161
347
  if (discovery.status === "absent") {
162
- try {
163
- const started = await io.startWhenStopped();
164
- if (started === "skipped") return { ok: true, mode: "skipped" };
165
- return started ? { ok: true, mode: "started" } : { ok: false, phase: "start" };
166
- } catch (error) {
167
- return { ok: false, phase: "start", error };
168
- }
348
+ return startRestartedProxy(io, waitBetweenAttempts, false);
169
349
  }
170
350
 
171
351
  const previous = discovery.live;
@@ -187,13 +367,36 @@ export async function runProxyRestart(io: ProxyRestartIo): Promise<ProxyRestartR
187
367
  return { ok: false, phase: "request", error: request.error };
188
368
  }
189
369
 
370
+ let replacement: ProxyRestartLive | null;
190
371
  try {
191
- const replacement = await io.waitForReplacement(previous);
192
- if (replacement) return { ok: true, mode: "restarted", live: replacement };
193
- return request.accepted
194
- ? { ok: false, phase: "replacement" }
195
- : { ok: false, phase: "request", error: request.error };
372
+ replacement = await io.waitForReplacement(previous);
196
373
  } catch (error) {
197
374
  return { ok: false, phase: "replacement", error };
198
375
  }
376
+ if (replacement) return { ok: true, mode: "restarted", live: replacement };
377
+ // The replacement never arrived: re-observe once instead of failing blind. A proxy
378
+ // that crashed mid-restart reads absent (safe to start fresh — nothing live can race
379
+ // the bind); a replacement that landed just past the deadline still proves success;
380
+ // the same PID or another uncertain round fails closed exactly as before. A live
381
+ // target is never stopped to make room: the no-stop/start-fallback invariant holds.
382
+ const reobserveHook = io.reobserveAfterReplacement;
383
+ const reobserve = reobserveHook ? () => reobserveHook(previous) : io.findLive;
384
+ let again: ProxyRestartDiscovery;
385
+ try {
386
+ again = await reobserve();
387
+ } catch (error) {
388
+ return { ok: false, phase: "replacement", error };
389
+ }
390
+ if (again.status === "absent") {
391
+ // Only an ACCEPTED restart earns a recovery start. After an uncertain request the old
392
+ // proxy may merely be mid-restart and reappear; starting one here could race it.
393
+ if (!request.accepted) return { ok: false, phase: "request", error: request.error };
394
+ return startRestartedProxy(io, waitBetweenAttempts, true, previous);
395
+ }
396
+ if (again.status === "live" && isProxyReplacement(previous, again.live)) {
397
+ return { ok: true, mode: "restarted", live: again.live };
398
+ }
399
+ return request.accepted
400
+ ? { ok: false, phase: "replacement" }
401
+ : { ok: false, phase: "request", error: request.error };
199
402
  }
@@ -9,16 +9,31 @@
9
9
  * comparison instead of reimplementing it -- two diagnostics disagreeing about whether an
10
10
  * install is stale would be worse than neither reporting it.
11
11
  */
12
- import { parseStrictSemver, type StrictSemver } from "../lib/strict-semver";
12
+ import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
13
13
 
14
14
  /** Placeholder versions that mean "unknown", not "different". */
15
15
  const PLACEHOLDERS = new Set(["unknown", "0.0.0"]);
16
16
 
17
+ /**
18
+ * Which side of the comparison runs newer, so a consumer does not reparse the warning to
19
+ * find the direction. `"match"` is a real, non-placeholder equality; placeholders and a
20
+ * missing proxy version are `"unknown"`, and `"incomparable"` covers versions that
21
+ * differ but do not order under strict semver. The desktop shell reads this field to
22
+ * keep a takeover from silently downgrading a newer runtime.
23
+ */
24
+ export type VersionRelation =
25
+ | "match"
26
+ | "cli-newer"
27
+ | "proxy-newer"
28
+ | "incomparable"
29
+ | "unknown";
30
+
17
31
  export interface VersionSkew {
18
32
  readonly cliVersion: string;
19
33
  /** Version the live proxy reported, or null when nothing is live or it reported none. */
20
34
  readonly proxyVersion: string | null;
21
35
  readonly skewed: boolean;
36
+ readonly relation: VersionRelation;
22
37
  /** Operator-facing explanation; null when there is nothing to report. */
23
38
  readonly warning: string | null;
24
39
  }
@@ -28,25 +43,6 @@ export function isConfirmedVersionMatch(skew: VersionSkew): boolean {
28
43
  return skew.proxyVersion === skew.cliVersion && !PLACEHOLDERS.has(skew.cliVersion);
29
44
  }
30
45
 
31
- /** SemVer precedence ignores build metadata; raw equality is handled separately. */
32
- function compareVersions(cli: StrictSemver, proxy: StrictSemver): number {
33
- for (let i = 0; i < cli.core.length; i++) {
34
- if (cli.core[i]! !== proxy.core[i]!) return cli.core[i]! > proxy.core[i]! ? 1 : -1;
35
- }
36
- if (cli.prerelease.length === 0) return proxy.prerelease.length === 0 ? 0 : 1;
37
- if (proxy.prerelease.length === 0) return -1;
38
- for (let i = 0; i < Math.max(cli.prerelease.length, proxy.prerelease.length); i++) {
39
- const left = cli.prerelease[i];
40
- const right = proxy.prerelease[i];
41
- if (left === right) continue;
42
- if (left === undefined) return -1;
43
- if (right === undefined) return 1;
44
- if (typeof left !== typeof right) return typeof left === "bigint" ? -1 : 1;
45
- return left > right ? 1 : -1;
46
- }
47
- return 0;
48
- }
49
-
50
46
  /**
51
47
  * Compare the running CLI against the live proxy.
52
48
  *
@@ -58,12 +54,17 @@ function compareVersions(cli: StrictSemver, proxy: StrictSemver): number {
58
54
  */
59
55
  export function computeVersionSkew(cliVersion: string, proxyVersion: string | undefined): VersionSkew {
60
56
  const proxy = proxyVersion ?? null;
61
- if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) {
62
- return { cliVersion, proxyVersion: proxy, skewed: false, warning: null };
57
+ if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion)) {
58
+ return { cliVersion, proxyVersion: proxy, skewed: false, relation: "unknown", warning: null };
59
+ }
60
+ if (proxy === cliVersion) {
61
+ return { cliVersion, proxyVersion: proxy, skewed: false, relation: "match", warning: null };
63
62
  }
64
63
  const cliSemver = parseStrictSemver(cliVersion);
65
64
  const proxySemver = parseStrictSemver(proxy);
66
- const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0;
65
+ const order = cliSemver && proxySemver ? compareStrictSemver(cliSemver, proxySemver) : 0;
66
+ const relation: VersionRelation =
67
+ order > 0 ? "cli-newer" : order < 0 ? "proxy-newer" : "incomparable";
67
68
  const advice = order > 0
68
69
  ? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. "
69
70
  // `restart`, not `repair`: a version skew leaves the service DEFINITION unchanged, and
@@ -77,6 +78,7 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un
77
78
  cliVersion,
78
79
  proxyVersion: proxy,
79
80
  skewed: true,
81
+ relation,
80
82
  warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — ${advice}`,
81
83
  };
82
84
  }
@@ -7,6 +7,7 @@ import { removePid, removeRuntimePort, writePid, writeRuntimePort, type RuntimeP
7
7
  import { installCrashGuards } from "../lib/crash-guard";
8
8
  import { createLocalAttestationSecret } from "../lib/local-management-attestation";
9
9
  import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
10
+ import { handledSignalExitCode } from "../lib/handled-signal-exit";
10
11
  import {
11
12
  DESKTOP_RESTART_EXIT_CODE,
12
13
  DESKTOP_SUPERVISED_ENV,
@@ -169,7 +170,8 @@ export async function recycleStandalone(
169
170
  // operator noticed. Exit 1 is what those configs are watching for, and it is the same
170
171
  // policy the dashboard recycle already uses (src/server/management/system-restart.ts).
171
172
  //
172
- // launchd's KeepAlive restarts on any exit, so it is correct under both branches.
173
+ // launchd's KeepAlive is failure-only too (`SuccessfulExit` false), so exit 1 is what
174
+ // relaunches it as well; an exit 0 would leave the job stopped.
173
175
  if (process.env.OCX_SERVICE === "1") {
174
176
  exit(1);
175
177
  return;
@@ -266,7 +268,7 @@ export async function bindClientListener(
266
268
  }
267
269
 
268
270
  export async function startClientRuntime(
269
- options: { port?: number; block?: boolean } = {},
271
+ options: { port?: number; block?: boolean; afterPublish?: () => void } = {},
270
272
  io: ClientRuntimeIo = {},
271
273
  ): Promise<void> {
272
274
  const state = readClientConnectionState();
@@ -310,9 +312,10 @@ export async function startClientRuntime(
310
312
  installCrashGuards();
311
313
  writePid(process.pid);
312
314
  writeRuntimePort(clientRuntimeRecord(process.pid, boundPort));
315
+ options.afterPublish?.();
313
316
 
314
317
  let shuttingDown = false;
315
- const shutdown = () => {
318
+ const shutdown = (signal?: NodeJS.Signals) => {
316
319
  if (shuttingDown) return;
317
320
  shuttingDown = true;
318
321
  void (async () => {
@@ -328,7 +331,8 @@ export async function startClientRuntime(
328
331
  console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
329
332
  } finally {
330
333
  cleanup();
331
- process.exit(0);
334
+ // launchd relaunches only unsuccessful exits; an external signal must still count.
335
+ process.exit(handledSignalExitCode(signal));
332
336
  }
333
337
  })();
334
338
  };
@@ -12,7 +12,7 @@ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
12
12
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
13
13
  import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
14
14
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
15
- import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, isMainAccountIdentityGenerationLive } from "../main-account-cache";
15
+ import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive } from "../main-account-cache";
16
16
  import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
17
17
  import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
18
18
  import type { MainAccountHardLockStatus } from "../main-account-hard-lock";
@@ -256,7 +256,8 @@ export async function listCodexAuthAccountsSnapshot(
256
256
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
257
257
  // One redaction decision for the whole snapshot, read once from the operator's config (#3859).
258
258
  const maskEmails = emailMaskingEnabled(runtimeConfig);
259
- const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
259
+ const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false,
260
+ forceRefresh, false, runtimeConfig);
260
261
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
261
262
  const cred = getCodexAccountCredential(account.id);
262
263
  let quotaResult: PoolQuotaResult;
@@ -327,7 +328,11 @@ export async function listCodexAuthAccountsSnapshot(
327
328
  });
328
329
  const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
329
330
  const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
330
- const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
331
+ // An ordinary same-account return can be parsed after its credential was replaced.
332
+ // The card and hard-lock status must describe the same published quota snapshot.
333
+ const mainInfo = mainSnapshotLive
334
+ ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
335
+ : EMPTY_MAIN_ACCOUNT_INFO;
331
336
  const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
332
337
  ? mainResult.hasCredential
333
338
  : getMainAccountCredentialPresence() ?? false;
@@ -395,7 +400,7 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
395
400
  return;
396
401
  }
397
402
  if (isAccountNeedsReauth(accountId)) return;
398
- await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
403
+ await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config);
399
404
  } finally {
400
405
  lease.release();
401
406
  }
@@ -442,7 +447,8 @@ export async function pauseExhaustedCodexAccounts(
442
447
  failedAccountCount: number;
443
448
  }> => {
444
449
  if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
445
- const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
450
+ const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true,
451
+ true, false, config);
446
452
  if (!mainResult.credentialChecked || !mainResult.hasCredential) {
447
453
  return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
448
454
  }
@@ -1,6 +1,6 @@
1
1
  import { withCodexAccountLogLabel } from "../account-label";
2
2
  import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store";
3
- import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
3
+ import { clearAccountQuota, isCodexQuotaExhausted, isValidWhamHistoryObservation, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
4
4
  import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
5
5
  import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
6
6
  import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
@@ -265,6 +265,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
265
265
  let email = cred.email || accountId;
266
266
  let plan: string | undefined;
267
267
  let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
268
+ let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
268
269
  try {
269
270
  const tokens = { access_token: cred.access, account_id: oauthAccountId };
270
271
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
@@ -276,6 +277,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
276
277
  email = data.email ?? email;
277
278
  plan = nonEmptyPlan(data.plan_type) ?? undefined;
278
279
  quota = parseUsageQuota(data);
280
+ policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
279
281
  }
280
282
  } catch { /* wham fetch is non-blocking */ }
281
283
  // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
@@ -390,7 +392,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
390
392
  clearCodexPoolRefreshFailure(accountId);
391
393
  if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
392
394
  clearAccountNeedsReauth(accountId);
393
- if (quota) setAccountQuotaFromParsed(accountId, quota);
395
+ if (quota) setAccountQuotaFromParsed(accountId, quota, undefined, undefined, policyQuota);
394
396
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
395
397
  accounts[existingIdx] = withCodexAccountLogLabel({
396
398
  ...accounts[existingIdx],
@@ -420,7 +422,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
420
422
  // A new quota row is generation-gated by live account ownership. Reconcile the
421
423
  // durable config owner first so a partial prior sweep cannot reject this write.
422
424
  if (newAccountPersistence?.status === "committed" && quota) {
423
- setAccountQuotaFromParsed(accountId, quota);
425
+ setAccountQuotaFromParsed(accountId, quota, undefined, undefined, policyQuota);
424
426
  }
425
427
  const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
426
428
  latestConfig,