@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
package/src/cli/index.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env bun
2
- import { serviceStayOutExitCode } from "../service/windows-wrapper-exit";
2
+ import { serviceStayOutExitCode, WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit";
3
+ import { isSupervisedServiceChild, serviceChildOwnershipDecisionForClassifiedChild } from "../service/service-child-ownership";
4
+ import { SERVICE_MANAGED_ENV } from "../service/state";
3
5
  import { spawn } from "node:child_process";
4
6
  import { homedir } from "node:os";
5
7
  import { join } from "node:path";
8
+ import { currentServingCommand, deferServiceChildToNewerRuntime, markDelegatedServiceReady, recordServingRuntime } from "../config/serving-runtimes";
9
+ import { packageVersion } from "../lib/package-version";
6
10
  import { findGuiDist } from "../server/gui-static";
7
11
  import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness";
8
12
 
@@ -74,11 +78,14 @@ import { parseStartOptions, StartArgsError } from "./start-args";
74
78
 
75
79
  import {
76
80
  discoverStableProxyForRestart,
77
- isProxyReplacement,
81
+ recheckRestartFailedStart, reobserveRestartReplacement,
82
+ restartStartOutcome,
83
+ waitForProxyReplacement,
78
84
  runProxyRestart,
79
85
  runTrayProxyStart,
80
86
  type ProxyRestartLive,
81
87
  type ProxyRestartResult,
88
+ type ProxyRestartStartOutcome,
82
89
  } from "./tray-proxy";
83
90
  import { requestBoundSystemRestart } from "./system-restart-client";
84
91
  import { installCrashGuards } from "../lib/crash-guard";
@@ -146,8 +153,12 @@ import { loadExportModels } from "../server/management/model-rows";
146
153
 
147
154
  import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state";
148
155
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
149
- import { selfLaunchArgv } from "../lib/self-launch-argv";
156
+ import { startArgv } from "../lib/self-launch-argv";
150
157
  import { initializeNodeLauncherContext } from "./launcher-context";
158
+ import { restoreSharedClientStateAfterStop } from "./stop-restore";
159
+ import { startClientRuntimeUnderOwnershipLease } from "./client-start-fence";
160
+ import { recoverStartStateUnderOwnershipLease } from "./start-owner-fence";
161
+ import { handledSignalExitCode } from "../lib/handled-signal-exit";
151
162
  import { createLocalAttestationSecret } from "../lib/local-management-attestation";
152
163
  import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
153
164
 
@@ -231,15 +242,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
231
242
  return null;
232
243
  }
233
244
 
234
- /** Argv for detached `start`, optionally hard-pinning the listen port. */
235
- function startArgv(port?: number): string[] {
236
- const args = ["start"];
237
- if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
238
- args.push("--port", String(Math.trunc(port)));
239
- }
240
- return selfLaunchArgv(args);
241
- }
242
-
243
245
  class StartCommandExit extends Error {
244
246
  constructor(readonly exitCode: number) {
245
247
  super(`start command exited with code ${exitCode}`);
@@ -350,24 +352,37 @@ async function chooseListenPort(
350
352
  }
351
353
 
352
354
  async function findProxyOwnerBeforeJournalRecovery(
353
- options: { probeConfiguredPort?: boolean } = {},
355
+ options: { probeConfiguredPort?: boolean; deferPidCleanup?: boolean } = {},
354
356
  ): Promise<{ live: LiveProxy | null; pidSnapshot: number | null }> {
355
357
  const pidSnapshot = readPidFileValue();
356
358
  const hasRuntimeOwner = readRuntimePort() !== null;
357
359
  const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
358
360
  // A negative answer lets the caller walk past a proxy it was supposed to find and
359
- // deletes this home's stale pid record. Journal recovery follows cross-home discovery.
361
+ // deletes this home's stale pid record. Supervised starts defer that write to the
362
+ // lease-held owner recheck; journal recovery follows cross-home discovery.
360
363
  // One 750ms probe is not enough evidence for that (#5004) — a transport
361
364
  // failure is indistinguishable from an empty port, and the reported Windows duplicate
362
365
  // came from exactly that answer on a proxy the previous command had just found healthy.
363
366
  const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null;
364
367
  if (live) return { live, pidSnapshot };
365
368
 
366
- removePidIfValueIs(pidSnapshot);
369
+ if (!options.deferPidCleanup) removePidIfValueIs(pidSnapshot);
367
370
  return { live: null, pidSnapshot };
368
371
  }
369
372
 
370
373
  async function handleStart(options: { block?: boolean } = {}) {
374
+ // A supervised service child defers to a foreign recorded owner before doing
375
+ // anything else. 'ocx service start' refuses this activation path already, but
376
+ // the process managers below it — the Windows boot wrapper's restart loop and
377
+ // the launchd/systemd units — spawn 'start' directly, which let an npm service
378
+ // resurrect beside a desktop-owned runtime. The stay-out exit is the wrapper's
379
+ // intentional-stop protocol, so a refusal does not read as a crash to respawn.
380
+ const supervisedServiceChild = isSupervisedServiceChild(process.env);
381
+ const childOwnership = serviceChildOwnershipDecisionForClassifiedChild(supervisedServiceChild);
382
+ if (childOwnership.kind === "stay-out") {
383
+ console.error("❌ " + childOwnership.refusal);
384
+ process.exit(serviceStayOutExitCode());
385
+ }
371
386
  // Native (WinSW) service mode has no batch wrapper to read the service token file into
372
387
  // the environment, and a FOREGROUND `ocx start` has no wrapper at all — so the app loads
373
388
  // the token here, before the server binds, with the same precedence the launchd plist and
@@ -409,7 +424,7 @@ async function handleStart(options: { block?: boolean } = {}) {
409
424
  let siblingStart = honorSiblingMarker(process.env, consumeSiblingHandoff) !== null;
410
425
  // A restart replacement waits out its draining parent instead of refusing it, and bounds its handoff log (restart-handoff.ts).
411
426
  const restartParent = { restartParentPid: takeRestartHandoffMarkers(process.env), requestedPort, ocxService: process.env.OCX_SERVICE };
412
- const owner = await probeOwnerPastRestartParent(() => findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true }), restartParent);
427
+ const owner = await probeOwnerPastRestartParent(() => findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true, deferPidCleanup: supervisedServiceChild }), restartParent);
413
428
  if (owner.live) {
414
429
  // Rationale and the full decision table live on `decideStartWithLiveOwner`.
415
430
  const decision = decideStartWithLiveOwner({
@@ -438,8 +453,18 @@ async function handleStart(options: { block?: boolean } = {}) {
438
453
  + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`,
439
454
  );
440
455
  }
441
- if (!owner.live && !siblingStart) siblingStart = await markCrossHomeSibling();
442
- if (!siblingStart) reconcileStartupJournal();
456
+ siblingStart = await recoverStartStateUnderOwnershipLease({
457
+ supervised: supervisedServiceChild,
458
+ acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()),
459
+ decide: () => serviceChildOwnershipDecisionForClassifiedChild(supervisedServiceChild),
460
+ stayOut: refusal => { console.error("❌ " + refusal); process.exit(serviceStayOutExitCode()); },
461
+ recover: async () => {
462
+ if (!owner.live && supervisedServiceChild) removePidIfValueIs(owner.pidSnapshot);
463
+ if (!owner.live && !siblingStart) siblingStart = await markCrossHomeSibling();
464
+ if (!siblingStart) reconcileStartupJournal();
465
+ return siblingStart;
466
+ },
467
+ });
443
468
 
444
469
  const clientState = readClientConnectionState();
445
470
  if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
@@ -451,10 +476,20 @@ async function handleStart(options: { block?: boolean } = {}) {
451
476
  throw new Error(`client startup refused: ${rotationGate.reason}`);
452
477
  }
453
478
  const { startClientRuntime } = await import("../client/runtime");
454
- await startClientRuntime({ port: requestedPort, block: options.block });
479
+ // Same lease-held owner recheck as the server path below: this branch binds and publishes too.
480
+ await startClientRuntimeUnderOwnershipLease({
481
+ acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()),
482
+ decide: () => serviceChildOwnershipDecisionForClassifiedChild(supervisedServiceChild),
483
+ stayOut: refusal => { console.error("❌ " + refusal); process.exit(serviceStayOutExitCode()); },
484
+ start: afterPublish => startClientRuntime({ port: requestedPort, block: options.block, afterPublish }),
485
+ });
455
486
  return;
456
487
  }
457
488
 
489
+ // Managed service children may defer before binding (serving-runtimes.ts).
490
+ const deferredExit = await deferServiceChildToNewerRuntime({ sibling: siblingStart, env: process.env, selfVersion: packageVersion(), selfCommand: currentServingCommand(), port: requestedPort });
491
+ if (deferredExit !== null) process.exit(deferredExit);
492
+
458
493
  // Interactive-only update prompt. Must run BEFORE we bind a port / write a
459
494
  // PID: choosing "Update now" installs globally and exits, so we never want a
460
495
  // live daemon holding resources while it overwrites its own binary. Never from a
@@ -475,6 +510,11 @@ async function handleStart(options: { block?: boolean } = {}) {
475
510
  boundStart = await bindAndPublishStartOwnership({
476
511
  acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()),
477
512
  bind: async () => {
513
+ const leasedChildOwnership = serviceChildOwnershipDecisionForClassifiedChild(supervisedServiceChild);
514
+ if (leasedChildOwnership.kind === "stay-out") {
515
+ console.error("❌ " + leasedChildOwnership.refusal);
516
+ throw new StartCommandExit(serviceStayOutExitCode());
517
+ }
478
518
  // The earlier probe owned journal cleanup. This one owns the bind decision: an
479
519
  // updater may have stopped the old runtime and acquired this lease for replacement.
480
520
  const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS);
@@ -556,6 +596,9 @@ async function handleStart(options: { block?: boolean } = {}) {
556
596
 
557
597
  const { server, serverModule, port, readinessGate, config } = boundStart;
558
598
  const { drainAndShutdown, isRecyclingForExit, noteExplicitShutdownRequested } = serverModule;
599
+ markDelegatedServiceReady();
600
+ // Register the relaunch command the service-child deferral consults on a later takeover.
601
+ recordServingRuntime({ command: currentServingCommand(), version: packageVersion(), servedAt: new Date().toISOString() });
559
602
  // Records are visible now; background work may observe this runtime without a gap.
560
603
  scheduleCatalogPrewarm();
561
604
  installCrashGuards();
@@ -617,7 +660,7 @@ async function handleStart(options: { block?: boolean } = {}) {
617
660
  // this window as the same Ctrl-C (one graceful drain); a deliberate later press
618
661
  // escalates to an immediate force-exit ("gradual kill").
619
662
  const FORCE_AFTER_MS = 500;
620
- const shutdown = () => {
663
+ const shutdown = (signal?: NodeJS.Signals) => {
621
664
  const now = Date.now();
622
665
  if (shuttingDown) {
623
666
  if (now - shutdownStartedAt < FORCE_AFTER_MS) return; // near-simultaneous duplicate — ignore
@@ -635,7 +678,7 @@ async function handleStart(options: { block?: boolean } = {}) {
635
678
  shutdownSucceeded = await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
636
679
  } finally {
637
680
  const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
638
- process.exit(restored && shutdownSucceeded ? 0 : 1);
681
+ process.exit(restored && shutdownSucceeded ? handledSignalExitCode(signal) : 1);
639
682
  }
640
683
  })();
641
684
  };
@@ -737,17 +780,22 @@ function detachedStartEnvironment(): NodeJS.ProcessEnv {
737
780
  const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);
738
781
  // Only a real service wrapper may claim supervision. A detached ensure/tray child is an
739
782
  // ordinary owner, never a sibling: while live it maintains routing, and on exit restores it.
783
+ // The supervisor markers ride along whenever this runs inside a service child's
784
+ // environment, so they must leave with OCX_SERVICE — otherwise the child would
785
+ // answer the ownership gate as a managed job it is not.
740
786
  delete env.OCX_SERVICE;
787
+ delete env[SERVICE_MANAGED_ENV];
788
+ delete env[WINDOWS_WRAPPER_PROTOCOL_ENV];
741
789
  return withProcessRuntimeProvenance(env);
742
790
  }
743
791
 
744
- async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise<boolean> {
792
+ async function handleEnsure(options: { existingIsSuccess?: boolean; forceStart?: boolean; onSpawn?: (child: ReturnType<typeof spawn>) => void } = {}): Promise<boolean> {
745
793
  const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
746
794
  if (!owner.live && !(await markCrossHomeSibling())) reconcileStartupJournal();
747
795
  // A later ensure can find this home's sibling alive; the other port still owns shared clients.
748
796
  if (owner.live) await markLiveHomeSibling(owner.live);
749
797
  const config = loadConfig();
750
- if (!codexAutoStartEnabled(config)) {
798
+ if (!options.forceStart && !codexAutoStartEnabled(config)) {
751
799
  console.log("Codex autostart is disabled.");
752
800
  return false;
753
801
  }
@@ -790,6 +838,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
790
838
  windowsHide: true,
791
839
  env: detachedStartEnvironment(),
792
840
  });
841
+ options.onSpawn?.(child);
793
842
  child.unref();
794
843
 
795
844
  const port = (await waitForProxy())?.port;
@@ -861,24 +910,8 @@ async function handleTrayProxyStart(existingIsSuccess = true): Promise<boolean>
861
910
 
862
911
  const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000;
863
912
 
864
- async function waitForProxyReplacement(
865
- previous: ProxyRestartLive,
866
- deadlineAt: number,
867
- ): Promise<ProxyRestartLive | null> {
868
- while (Date.now() < deadlineAt) {
869
- const live = await findLiveProxy({ deadlineAt });
870
- if (Date.now() >= deadlineAt) return null;
871
- // Modern /healthz publishes a PID. Require a different, identity-verified process;
872
- // merely seeing the old port online again is not proof that restart completed.
873
- if (isProxyReplacement(previous, live)) {
874
- return live;
875
- }
876
- const remainingMs = deadlineAt - Date.now();
877
- if (remainingMs > 0) await Bun.sleep(Math.min(250, remainingMs));
878
- }
879
- return null;
880
- }
881
-
913
+ /** Reserve confirmation time within the shared restart deadline. */
914
+ const RESTART_REOBSERVE_RESERVE_MS = 10_000;
882
915
  function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>): void {
883
916
  if (result.phase === "identity") {
884
917
  console.error("❌ Refusing to restart because the running proxy identity could not be attested.");
@@ -901,9 +934,8 @@ function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>
901
934
  console.error("❌ Proxy was not running and the fallback start did not become healthy.");
902
935
  }
903
936
  }
904
-
905
937
  async function handleProxyRestart(
906
- startWhenStopped: () => Promise<boolean | "skipped">,
938
+ startWhenStopped: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>,
907
939
  ): Promise<boolean> {
908
940
  const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS;
909
941
  const result = await runProxyRestart({
@@ -913,93 +945,42 @@ async function handleProxyRestart(
913
945
  }),
914
946
  startWhenStopped,
915
947
  requestInPlaceRestart: previous => requestBoundSystemRestart(previous, deadlineAt),
916
- waitForReplacement: previous => waitForProxyReplacement(previous, deadlineAt),
948
+ waitForReplacement: previous => waitForProxyReplacement(previous, deadlineAt - RESTART_REOBSERVE_RESERVE_MS, end => findLiveProxy({ deadlineAt: end })),
949
+ reobserveAfterReplacement: previous => reobserveRestartReplacement(previous, deadlineAt, end =>
950
+ discoverStableProxyForRestart({
951
+ findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }),
952
+ waitBetweenChecks: () => Bun.sleep(250),
953
+ expired: () => Date.now() >= end,
954
+ })),
955
+ recheckAfterFailedStart: () => recheckRestartFailedStart(end => discoverStableProxyForRestart({
956
+ findLive: () => findLiveProxy({ deadlineAt: end, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= end,
957
+ })),
917
958
  });
918
959
  if (!result.ok) reportRestartFailure(result);
919
960
  process.exitCode = result.ok ? 0 : 1;
920
961
  return result.ok;
921
962
  }
922
-
923
963
  async function handleTrayProxyRestart(): Promise<void> {
924
- await handleProxyRestart(() => handleTrayProxyStart(false));
964
+ // A service start has no child handle here; its failed result is ambiguous.
965
+ await handleProxyRestart(async () => (await handleTrayProxyStart(false))
966
+ ? { status: "started" }
967
+ : { status: "failed", launch: "unknown" });
925
968
  }
926
-
927
- async function handleRestartStartWhenStopped(): Promise<boolean | "skipped"> {
928
- if (!codexAutoStartEnabled(loadConfig())) {
969
+ async function handleRestartStartWhenStopped(recoveringLiveRestart = false): Promise<ProxyRestartStartOutcome> {
970
+ if (!recoveringLiveRestart && !codexAutoStartEnabled(loadConfig())) {
929
971
  console.log("Codex autostart is disabled; no proxy was started.");
930
- return "skipped";
972
+ return { status: "skipped" };
931
973
  }
932
- return handleEnsure({ existingIsSuccess: false });
933
- }
934
-
935
- /**
936
- * Restore shared client state after a stop.
937
- *
938
- * Returns the two failure kinds separately. `historyOnly` means teardown succeeded and
939
- * only Codex history metadata could not be finalized: the proxy is down, the service is
940
- * stopped, and a manifest is waiting for review. `other` means something that actually
941
- * removes state a client depends on.
942
- *
943
- * The distinction exists because `ocx update` must proceed for the first and abort for the
944
- * second, and it can only see an exit code (#3008).
945
- *
946
- * `historyDeferred` is the third kind (#4718). The Codex history preflight refuses BEFORE
947
- * the config half runs, so nothing was restored at all: config, catalog, history and
948
- * provenance are untouched and the client is still routed at the proxy that just stopped.
949
- * Like `historyOnly` the proxy is genuinely down, so an update may replace package files.
950
- * Unlike `historyOnly` the obligation was not performed, so the receipt must survive.
951
- */
952
- async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> {
953
- let historyOnly = false;
954
- let historyDeferred = false;
955
- let other = false;
974
+ let child: ReturnType<typeof spawn> | null = null;
956
975
  try {
957
- const result = await restoreNativeCodexAsync();
958
- if (result.success) {
959
- console.log(`↩️ ${result.message}`);
960
- if (result.retainedCodexProviderTable) {
961
- reportRetainedCodexProviderTable(result.retainedCodexProviderTable);
962
- }
963
- }
964
- else {
965
- // Codex history is the one restore whose failure leaves the runtime consistent: the
966
- // manifest is retained and the routed metadata is untouched. Config and catalog are
967
- // not — a client reads those, so their failure is a real teardown failure.
968
- const artifacts = result.artifacts;
969
- const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed";
970
- // A preflight refusal reports every artifact as `skipped` because none of them were
971
- // attempted. Reading the states alone cannot tell that apart from an ownership
972
- // refusal, so the structured reason carries it and the states are still required to
973
- // agree — a refusal that somehow reports a failed artifact is not this case.
974
- // A degraded restore has no refusal reason and reports config as partial, so it cannot
975
- // enter this branch: its config obligation was discharged and the stop receipt must be
976
- // released rather than preserved.
977
- const preflightRefused = result.historyPreflightRefusal !== undefined
978
- && artifacts.config.state === "skipped"
979
- && artifacts.catalog.state === "skipped"
980
- && artifacts.history.state === "skipped";
981
- if (preflightRefused) historyDeferred = true;
982
- else if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true;
983
- else other = true;
984
- console.error(`⚠️ ${result.message}`);
985
- }
976
+ const started = await handleEnsure({ existingIsSuccess: false, forceStart: recoveringLiveRestart, onSpawn: spawned => { child = spawned; } });
977
+ return restartStartOutcome(started, child);
986
978
  } catch (error) {
987
- other = true;
988
- console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
979
+ return restartStartOutcome(false, child, error);
989
980
  }
990
-
991
- // A refused or thrown Grok strip is actionable because it would point Grok at a dead proxy.
992
- try {
993
- const grok = stripGrokConfig();
994
- if (grok.changed) console.log(`↩️ ${grok.message}`);
995
- else if (!grok.ok) { other = true; console.error(`⚠️ ${grok.message}`); }
996
- } catch (error) {
997
- other = true;
998
- console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
999
- }
1000
- return { historyOnly, historyDeferred, other };
1001
981
  }
1002
982
 
983
+
1003
984
  async function handleStop(approval?: StopApproval) {
1004
985
  const lease = acquireOwnershipMutationLease(serviceStatePaths());
1005
986
  try {
@@ -1442,7 +1423,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1442
1423
  // not answering. That is the whole point of leaving the receipt behind.
1443
1424
  console.log("↩️ Finishing a shared teardown left unfinished by an earlier stop.");
1444
1425
  }
1445
- const restore = await restoreSharedClientStateAfterStop();
1426
+ const restore = await restoreSharedClientStateAfterStop(reportRetainedCodexProviderTable);
1446
1427
  record.sharedTeardown = restore.historyDeferred ? "refused" : restore.other ? "failed" : "restored";
1447
1428
  if (restore.other) stopFailed = true;
1448
1429
  else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces;
@@ -1,4 +1,4 @@
1
- import { modelCapabilitiesConfigError } from "../config/provider-validation";
1
+ import { contextTierRecordConfigError, modelCapabilitiesConfigError } from "../config/provider-validation";
2
2
  import {
3
3
  CliUsageError,
4
4
  csv,
@@ -42,7 +42,7 @@ const USAGE = `Usage:
42
42
  [--headers <json>] [--enabled <on|off>] [--live-models <on|off>]
43
43
  [--retain-models <id,id|->] [--model <id> --text-only]
44
44
  [--xai-chat <on|off>]
45
- [--allow-private-network <on|off>] [--json]
45
+ [--allow-private-network <on|off>] [--model-context-tier <model=default|long_context>] [--json]
46
46
  ocx provider test <name> [--json]
47
47
  ocx provider quota [--refresh] [--json]
48
48
  ocx provider resets [--limit <n>] [--json]
@@ -73,6 +73,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
73
73
  const liveModels = takeBooleanOption(args, "--live-models");
74
74
  const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network");
75
75
  const xaiChat = takeBooleanOption(args, "--xai-chat");
76
+ const contextTierValues: string[] = [];
77
+ for (;;) {
78
+ const value = takeOption(args, "--model-context-tier");
79
+ if (value === undefined) break;
80
+ contextTierValues.push(value);
81
+ }
76
82
  const textOnly = takeFlag(args, "--text-only");
77
83
  const capabilityModel = takeOption(args, "--model");
78
84
  rejectArgs(args, USAGE);
@@ -83,6 +89,21 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
83
89
  if (error) throw new CliUsageError(error, USAGE);
84
90
  patch.modelCapabilities = declaration;
85
91
  }
92
+ if (contextTierValues.length) {
93
+ if (name !== "github-copilot") throw new CliUsageError("--model-context-tier is valid only for provider github-copilot", USAGE);
94
+ const tiers: Record<string, "default" | "long_context"> = Object.create(null);
95
+ for (const value of contextTierValues) {
96
+ const separator = value.indexOf("=");
97
+ const model = value.slice(0, separator);
98
+ const tier = value.slice(separator + 1);
99
+ if (separator < 1 || (tier !== "default" && tier !== "long_context"))
100
+ throw new CliUsageError("--model-context-tier must use model=default or model=long_context", USAGE);
101
+ tiers[model] = tier;
102
+ }
103
+ const error = contextTierRecordConfigError(tiers);
104
+ if (error) throw new CliUsageError(error, USAGE);
105
+ patch.modelContextTiers = tiers;
106
+ }
86
107
  if (xaiChat !== undefined) {
87
108
  if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE);
88
109
  patch.xaiResponsesOptIn = !xaiChat;
@@ -245,6 +245,9 @@ async function handleAdd(args: string[]): Promise<void> {
245
245
  if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) {
246
246
  provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities);
247
247
  }
248
+ if (existingProvider?.modelContextTiers !== undefined && provConfig.modelContextTiers === undefined) {
249
+ provConfig.modelContextTiers = structuredClone(existingProvider.modelContextTiers);
250
+ }
248
251
  if (textOnly) {
249
252
  const modelId = capabilityModel ?? defaultModel ?? provConfig.defaultModel;
250
253
  if (!modelId) {
@@ -59,6 +59,7 @@ import {
59
59
  } from "../service/ownership-compatibility";
60
60
  import { observeManagingClis } from "../service/managing-cli";
61
61
  import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs";
62
+ import { computeVersionSkew, type VersionSkew } from "./version-skew";
62
63
 
63
64
  /** Wire version of the resolve document. Bump only on an incompatible shape change. */
64
65
  export const RESOLVE_SCHEMA = "ocx-resolve/1";
@@ -114,6 +115,14 @@ export interface ResolveJson {
114
115
  * member of `ServiceTakeoverCompatibility`'s union.
115
116
  */
116
117
  takeover: ResolveTakeover;
118
+ /**
119
+ * The bundled CLI's version compared against the live proxy's, present only when
120
+ * something is live. A shell reads `relation` to decide whether attaching or a
121
+ * takeover moves versions: `"proxy-newer"` means this CLI's bundled runtime would
122
+ * DOWNGRADE the listener, which the desktop uses to stay a guest instead of offering
123
+ * takeover. Additive within ocx-resolve/1; older shells ignore it and read as unknown.
124
+ */
125
+ versionSkew?: VersionSkew;
117
126
  }
118
127
 
119
128
  export type ResolveTakeover =
@@ -192,6 +201,9 @@ export function buildResolveJson(
192
201
  liveness: livenessJson(live),
193
202
  ownership,
194
203
  takeover,
204
+ // A proven absence has nothing to skew against; the field is omitted there rather
205
+ // than emitted as an "unknown" row, matching the liveness identity fields.
206
+ ...(live ? { versionSkew: computeVersionSkew(cliVersion, live.version) } : {}),
195
207
  };
196
208
  }
197
209
 
@@ -221,6 +233,12 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v
221
233
  ? "Takeover: supported"
222
234
  : `Takeover: blocked (${json.takeover.reason}: ${json.takeover.detail})`,
223
235
  );
236
+ // The skew warning is the same sentence `ocx status` and `doctor` surface; an
237
+ // operator comparing a live proxy against this CLI gets it here too instead of
238
+ // discovering the mismatch only inside the desktop shell.
239
+ if (json.versionSkew?.warning) {
240
+ stdout.log(`Version skew: ${json.versionSkew.warning}`);
241
+ }
224
242
  }
225
243
 
226
244
  /**
@@ -0,0 +1,30 @@
1
+ import type { ServiceChildOwnershipDecision } from "../service/service-child-ownership";
2
+
3
+ /** Fence shared client-state recovery against a claim committed after the early owner probe. */
4
+ export async function recoverStartStateUnderOwnershipLease(deps: {
5
+ supervised: boolean;
6
+ acquireLease: () => { release(): void };
7
+ decide: () => ServiceChildOwnershipDecision;
8
+ stayOut: (refusal: string) => never;
9
+ recover: () => Promise<boolean>;
10
+ }): Promise<boolean> {
11
+ if (!deps.supervised) return deps.recover();
12
+ const lease = deps.acquireLease();
13
+ let released = false;
14
+ const release = () => {
15
+ if (released) return;
16
+ released = true;
17
+ lease.release();
18
+ };
19
+ try {
20
+ const decision = deps.decide();
21
+ if (decision.kind === "stay-out") {
22
+ // stayOut exits the process, so a finally block would never release the lease.
23
+ release();
24
+ deps.stayOut(decision.refusal);
25
+ }
26
+ return await deps.recover();
27
+ } finally {
28
+ release();
29
+ }
30
+ }
@@ -158,8 +158,20 @@ export async function runGuardedManagerStep(
158
158
  return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
159
159
  }
160
160
  const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
161
+ if (snapshot.manager.kind === "bound"
162
+ && service !== "stopped" && service !== "stopped-respawnable") {
163
+ return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy: false };
164
+ }
161
165
  let handledByProxy = false;
162
- if (snapshot.manager.kind === "absent") {
166
+ // A bound manager whose stop cascades to the child needs no signal: launchd and
167
+ // systemd end the job's whole process tree. A bound Task Scheduler or WinSW
168
+ // manager does not — schtasks /end can leave both the wrapper and the approved
169
+ // PID alive (#764) — so those get the same graceful signal the absent path uses,
170
+ // run AFTER the manager stop so the proxy's respawn-refusal sees the manager
171
+ // already ended (the deferral receipt still bypasses it as before).
172
+ const needsApprovedSignal = snapshot.manager.kind === "absent"
173
+ || snapshot.manager.childNeedsSeparateStop;
174
+ if (needsApprovedSignal) {
163
175
  try { handledByProxy = await io.signalApproved(); }
164
176
  catch { return { service, effect: "failed", proxy: "unknown", handledByProxy }; }
165
177
  }
@@ -0,0 +1,73 @@
1
+ import { restoreNativeCodexAsync } from "../codex/inject";
2
+ import type { RetainedCodexProviderTable } from "../codex/inject/restore";
3
+ import { stripGrokConfig } from "../grok/inject";
4
+
5
+ /**
6
+ * Restore shared client state after a stop.
7
+ *
8
+ * Returns the two failure kinds separately. `historyOnly` means teardown succeeded and
9
+ * only Codex history metadata could not be finalized: the proxy is down, the service is
10
+ * stopped, and a manifest is waiting for review. `other` means something that actually
11
+ * removes state a client depends on.
12
+ *
13
+ * The distinction exists because `ocx update` must proceed for the first and abort for the
14
+ * second, and it can only see an exit code (#3008).
15
+ *
16
+ * `historyDeferred` is the third kind (#4718). The Codex history preflight refuses BEFORE
17
+ * the config half runs, so nothing was restored at all: config, catalog, history and
18
+ * provenance are untouched and the client is still routed at the proxy that just stopped.
19
+ * Like `historyOnly` the proxy is genuinely down, so an update may replace package files.
20
+ * Unlike `historyOnly` the obligation was not performed, so the receipt must survive.
21
+ */
22
+ export async function restoreSharedClientStateAfterStop(
23
+ reportRetainedCodexProviderTable: (retained: RetainedCodexProviderTable) => void,
24
+ ): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> {
25
+ let historyOnly = false;
26
+ let historyDeferred = false;
27
+ let other = false;
28
+ try {
29
+ const result = await restoreNativeCodexAsync();
30
+ if (result.success) {
31
+ console.log(`↩️ ${result.message}`);
32
+ if (result.retainedCodexProviderTable) {
33
+ reportRetainedCodexProviderTable(result.retainedCodexProviderTable);
34
+ }
35
+ }
36
+ else {
37
+ // Codex history is the one restore whose failure leaves the runtime consistent: the
38
+ // manifest is retained and the routed metadata is untouched. Config and catalog are
39
+ // not — a client reads those, so their failure is a real teardown failure.
40
+ const artifacts = result.artifacts;
41
+ const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed";
42
+ // A preflight refusal reports every artifact as `skipped` because none of them were
43
+ // attempted. Reading the states alone cannot tell that apart from an ownership
44
+ // refusal, so the structured reason carries it and the states are still required to
45
+ // agree — a refusal that somehow reports a failed artifact is not this case.
46
+ // A degraded restore has no refusal reason and reports config as partial, so it cannot
47
+ // enter this branch: its config obligation was discharged and the stop receipt must be
48
+ // released rather than preserved.
49
+ const preflightRefused = result.historyPreflightRefusal !== undefined
50
+ && artifacts.config.state === "skipped"
51
+ && artifacts.catalog.state === "skipped"
52
+ && artifacts.history.state === "skipped";
53
+ if (preflightRefused) historyDeferred = true;
54
+ else if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true;
55
+ else other = true;
56
+ console.error(`⚠️ ${result.message}`);
57
+ }
58
+ } catch (error) {
59
+ other = true;
60
+ console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+
63
+ // A refused or thrown Grok strip is actionable because it would point Grok at a dead proxy.
64
+ try {
65
+ const grok = stripGrokConfig();
66
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
67
+ else if (!grok.ok) { other = true; console.error(`⚠️ ${grok.message}`); }
68
+ } catch (error) {
69
+ other = true;
70
+ console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
72
+ return { historyOnly, historyDeferred, other };
73
+ }