@bitkyc08/opencodex 2.48.0 → 2.50.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -74,13 +74,20 @@ export function providerFetch(
74
74
  const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
75
75
  base.preconnect?.(...args);
76
76
  };
77
+ // Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends.
78
+ // Return the original 3xx so the response owner retains its retry/health/relay contract.
79
+ const dispatch = Object.assign(
80
+ (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
81
+ base(input, { ...init, redirect: "manual" }),
82
+ { preconnect },
83
+ ) as typeof globalThis.fetch;
77
84
  const httpFetch = Object.assign(
78
85
  async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
79
86
  options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
80
87
  const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 };
81
88
  return options.dispatchOverride
82
- ? options.dispatchOverride(input, dispatchInit, base)
83
- : base(input, dispatchInit);
89
+ ? options.dispatchOverride(input, dispatchInit, dispatch)
90
+ : dispatch(input, dispatchInit);
84
91
  },
85
92
  { preconnect },
86
93
  ) as typeof globalThis.fetch;
@@ -163,6 +170,10 @@ export function storedPoolReplayDispatchNotifier(
163
170
  }) as ProviderFetch;
164
171
  }
165
172
 
173
+ /**
174
+ * Fetch through the header deadline with redirects always manual.
175
+ * @param _manualRedirect Ignored; retained for call compatibility. Even false uses manual.
176
+ */
166
177
  export async function fetchWithHeaderTimeout(
167
178
  url: string,
168
179
  init: Omit<RequestInit, "signal">,
@@ -170,7 +181,8 @@ export async function fetchWithHeaderTimeout(
170
181
  timeoutMs: number,
171
182
  preferIdentityEncoding = false,
172
183
  executor: typeof globalThis.fetch = globalThis.fetch,
173
- manualRedirect = false,
184
+ // Retained for existing callers; credential-bearing transport no longer opts out.
185
+ _manualRedirect = false,
174
186
  ): Promise<Response> {
175
187
  const pacing = executor as ProviderFetch;
176
188
  await pacing.waitForPacing?.(abortSignal);
@@ -189,10 +201,9 @@ export async function fetchWithHeaderTimeout(
189
201
  return await fetchExecutor(url, {
190
202
  ...init,
191
203
  headers,
192
- // Credential-bearing sends opt into manual redirects so a 3xx is relayed
193
- // as a Response instead of being followed into a rejection that is
194
- // indistinguishable from a pre-connection failure (#914).
195
- ...(manualRedirect ? { redirect: "manual" as const } : {}),
204
+ // Never replay provider credentials or request bodies to a redirect destination.
205
+ // Preserve the 3xx for the owner's existing response/health policy (#914, #1471).
206
+ redirect: "manual",
196
207
  signal: AbortSignal.any([abortSignal, timeout.signal]),
197
208
  timeout: 0,
198
209
  });
@@ -1,11 +1,13 @@
1
1
  import { comboFailureDecision } from "../../combos/failover";
2
2
  import { readBoundedResponseBody } from "../../lib/bounded-body";
3
- import { readJsonRequestBody } from "../request-decompress";
3
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
4
4
  import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
5
  import type { OcxConfig } from "../../types";
6
6
  import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
7
  import { handleResponses as handleResponsesCore } from "./core";
8
8
  import { requestPacingOverloadResponse } from "./pacing-overload";
9
+ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
10
+ import { captureCallerDirectAuth } from "../../providers/caller-authorization";
9
11
 
10
12
  type CoreHandler = typeof handleResponsesCore;
11
13
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -47,6 +49,10 @@ function requestWithCandidate(
47
49
  candidate: Pick<RouteCandidateTrace, "provider" | "model">,
48
50
  ): Request {
49
51
  const headers = new Headers(req.headers);
52
+ // The next candidate owns a different physical credential domain. Typed
53
+ // admission and any claimed Claude snapshot stay in caller-owned CoreOptions.
54
+ headers.delete("authorization");
55
+ headers.delete("chatgpt-account-id");
50
56
  headers.delete("content-encoding");
51
57
  headers.delete("content-length");
52
58
  headers.set("content-type", "application/json");
@@ -119,6 +125,12 @@ export async function handleResponsesWithPolicyFallback(
119
125
  let storedPool401ReplayDispatched = false;
120
126
  const coreOptions: CoreOptions = {
121
127
  ...options,
128
+ openAiSidecarAuth: options.openAiSidecarAuth === undefined
129
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
130
+ nativeCallerAuth: options.nativeCallerAuth === undefined
131
+ ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth,
132
+ callerDirectAuth: options.callerDirectAuth === undefined
133
+ ? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth,
122
134
  ...(options.onRequestBodyRead ? {
123
135
  onRequestBodyRead: () => {
124
136
  if (requestBodyReadNotified) return;
@@ -133,7 +145,11 @@ export async function handleResponsesWithPolicyFallback(
133
145
  };
134
146
  let rawBody: Record<string, unknown> | null = null;
135
147
  try {
136
- const parsed = await readJsonRequestBody(req.clone());
148
+ const parsed = await readJsonRequestBody(
149
+ req.clone(),
150
+ undefined,
151
+ resolveInboundBodyLimitBytes(config.maxInboundBodyBytes),
152
+ );
137
153
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) rawBody = parsed as Record<string, unknown>;
138
154
  } catch {
139
155
  // Core owns the client-facing parse/decompression error.
@@ -33,7 +33,7 @@ import {
33
33
  type ExactOpenAiSidecarAccount,
34
34
  } from "../providers/openai-sidecar";
35
35
  import { routeModel } from "../router";
36
- import { readJsonRequestBody } from "./request-decompress";
36
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
37
37
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
38
38
  import type { RequestLogContext } from "./request-log";
39
39
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
@@ -64,7 +64,7 @@ export async function handleSearch(
64
64
  }
65
65
  let body: unknown;
66
66
  try {
67
- body = await readJsonRequestBody(req);
67
+ body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
68
68
  } catch (err) {
69
69
  return decodeRequestErrorResponse(err, "search");
70
70
  }
package/src/service.ts CHANGED
@@ -16,6 +16,13 @@ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
16
16
  import { stripGrokConfig } from "./grok/inject";
17
17
  import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
18
18
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
19
+
20
+ /**
21
+ * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
22
+ * for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its
23
+ * routing-preservation meaning, so a proxy carrying it is not necessarily the managed job.
24
+ */
25
+ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
19
26
  import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
20
27
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
28
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
@@ -508,6 +515,11 @@ export function buildPlist(
508
515
  const opencodexHome = process.env.OPENCODEX_HOME?.trim();
509
516
  const envLines = [
510
517
  ` <key>OCX_SERVICE</key><string>1</string>`,
518
+ // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
519
+ // also set it on the proxies they spawn, to borrow its routing-preservation meaning
520
+ // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
521
+ // the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
522
+ ` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`,
511
523
  ...(launcher ? [] : [
512
524
  ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
513
525
  ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
@@ -853,9 +865,51 @@ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name:
853
865
  }
854
866
 
855
867
  function sh(cmd: string): string {
868
+ assertLiveServiceManagerAllowed(cmd);
856
869
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
857
870
  }
858
871
 
872
+ /**
873
+ * Service-manager invocations that only observe. Everything else changes a job that
874
+ * launchd or the systemd user manager is running right now.
875
+ */
876
+ const READ_ONLY_SERVICE_MANAGER = new RegExp(
877
+ "^(?:launchctl\\s+(?:list|print|print-disabled|blame|managerpid|manageruid)\\b"
878
+ + "|systemctl\\s+(?:--user\\s+)?(?:show|show-environment|status|is-active|is-enabled|is-failed|cat|list-units|list-unit-files|--version)\\b)",
879
+ );
880
+
881
+ const SERVICE_MANAGER_COMMAND = /^(?:launchctl|systemctl)\b/;
882
+
883
+ /**
884
+ * Refuse to mutate a live service manager from an armed test process.
885
+ *
886
+ * The test preload isolates HOME, OPENCODEX_HOME and CODEX_HOME, and that is enough for
887
+ * anything addressed by path. It is not enough here. `systemctl --user stop
888
+ * opencodex-proxy.service` is addressed by job NAME and talks to the user manager that is
889
+ * already running, so it stops the proxy the developer is actually using no matter what
890
+ * HOME says. `launchctl bootout gui/<uid>/com.opencodex.proxy` has the same shape.
891
+ *
892
+ * Windows already had this guard: `querySchtasks` refuses every non-query call while the
893
+ * test-home guard is armed, after a partially-faked test replaced a real scheduled task
894
+ * with a launcher inside a temporary test home. macOS and Linux were left without the
895
+ * equivalent, which means the person most likely to run this suite - someone running
896
+ * opencodex on the machine they are developing it on - is the person it can disrupt.
897
+ *
898
+ * Read-only verbs stay allowed: probing what the manager reports is the whole point of
899
+ * the diagnostics, and observation cannot take a service down.
900
+ */
901
+ export function assertLiveServiceManagerAllowed(command: string): void {
902
+ if (!isTestHomeGuardArmed()) return;
903
+ const trimmed = command.trim();
904
+ if (!SERVICE_MANAGER_COMMAND.test(trimmed)) return;
905
+ if (READ_ONLY_SERVICE_MANAGER.test(trimmed)) return;
906
+ throw new Error(
907
+ `refusing to run \`${trimmed}\` from an armed test process: launchd and the systemd user `
908
+ + "manager address a job by name, not by HOME, so this reaches the service the developer is "
909
+ + "actually running. Inject the service operation instead of calling the live manager.",
910
+ );
911
+ }
912
+
859
913
  /**
860
914
  * Run `launchctl` and report BOTH streams regardless of exit status.
861
915
  *
@@ -875,6 +929,9 @@ export function runLaunchctl(
875
929
  deps: { run?: typeof spawnSync } = {},
876
930
  ): { ok: boolean; stdout: string; stderr: string; status: number | null } {
877
931
  const run = deps.run ?? spawnSync;
932
+ // Only the real runner is guarded. Tests that inject a spawnSync stand-in are
933
+ // exercising the parsing, not reaching launchd, and must keep working.
934
+ if (run === spawnSync) assertLiveServiceManagerAllowed(`launchctl ${args.join(" ")}`);
878
935
  const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
879
936
  // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
880
937
  // null for a signalled child; neither may be reported as success.
@@ -2281,7 +2338,23 @@ export function readWindowsSchedulerXmlState(
2281
2338
  }
2282
2339
 
2283
2340
  // ── macOS (launchd) ──
2284
- function installLaunchd(): void {
2341
+ /**
2342
+ * Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
2343
+ * {@link runLaunchctl} result, not a spawnSync result. It is optional so this stays
2344
+ * assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
2345
+ * (`() => void`), and so `platformOps` wires the same function the tests exercise.
2346
+ *
2347
+ * The seam is what makes the eviction below testable at all. The live-service-manager
2348
+ * guard refuses every mutating verb from an armed test process and `bootout` is not on
2349
+ * its read-only list, so a test reaching the real runner would fail closed on the guard
2350
+ * instead of exercising the sequence.
2351
+ *
2352
+ * No `matches` dep: unlike `startLaunchd`, this function never consults
2353
+ * {@link launchdJobMatchesPlist}. It has just rewritten the plist, so a live job is stale
2354
+ * by construction and there is nothing to compare against.
2355
+ */
2356
+ export function installLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
2357
+ const run = deps.launchctl ?? runLaunchctl;
2285
2358
  const dir = join(homedir(), "Library", "LaunchAgents");
2286
2359
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2287
2360
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
@@ -2295,17 +2368,41 @@ function installLaunchd(): void {
2295
2368
  // so the staleness diagnostic judges exactly what launchd runs.
2296
2369
  const launcher = stableLauncherEntry();
2297
2370
  writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8");
2298
- // Best-effort: an absent job is fine here, and a failed unload is caught by the
2299
- // load verification below with a better message than a raw unload error.
2300
- runLaunchctl(["unload", p]);
2301
- const loaded = runLaunchctl(["load", "-w", p]);
2371
+ // `unload` is the legacy verb and it does not evict a job bootstrapped into the GUI
2372
+ // domain — which is precisely the state that could not repair itself. Modern launchd
2373
+ // answers `load -w` for an already-bootstrapped job with "Load failed: 5:
2374
+ // Input/output error" AND exits 0, so `ocx update` replaced the binary, ran repair,
2375
+ // and left launchd running the PREVIOUS job while the fresh plist sat unused (#4141).
2376
+ //
2377
+ // This EVICTS the running job. That is the repair being asked for, and it is why it
2378
+ // lives here and nowhere else: `installLaunchd` has already rewritten the plist, so
2379
+ // whatever is loaded is stale by construction. `ocx service start` must never do
2380
+ // this, and `startLaunchd` accordingly still refuses to.
2381
+ //
2382
+ // Absence is fine: booting out a job that is not there is a no-op, and a real failure
2383
+ // is reported by the load verification below with a better message than a raw
2384
+ // eviction error would carry.
2385
+ const bootoutTarget = `${launchdGuiDomain()}/${LABEL}`;
2386
+ run(["bootout", bootoutTarget]);
2387
+ let loaded = run(["load", "-w", p]);
2388
+ if (launchctlLoadFailed(loaded.stderr)) {
2389
+ // Still bootstrapped after an eviction: the job re-registered between the two calls,
2390
+ // or the first `bootout` raced a job that had not finished exiting. Evict and load
2391
+ // once more — ONCE. A bounded retry recovers the race; a loop would turn a genuinely
2392
+ // wedged domain into a hang instead of the diagnosable throw below.
2393
+ run(["bootout", bootoutTarget]);
2394
+ loaded = run(["load", "-w", p]);
2395
+ }
2302
2396
  if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
2303
2397
  // Do NOT write install state for a load that did not take: state describing an
2304
2398
  // unused plist is what made this failure invisible.
2305
2399
  throw new Error(
2306
2400
  `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
2307
- + "A previous job may still be bootstrapped. Try:\n"
2308
- + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
2401
+ // The hint used to tell the operator to run `bootout` by hand. It now runs twice
2402
+ // above, so naming it as an untried remedy would send someone to repeat what just
2403
+ // failed. Report what was attempted instead.
2404
+ + `A previous job is still bootstrapped after two attempts to boot it out of ${launchdGuiDomain()}.\n`
2405
+ + `Inspect it with:\n launchctl print ${bootoutTarget}\n`
2309
2406
  // macOS `service repair` delegates straight to installLaunchd, so this fires for
2310
2407
  // an already-installed service too; repair reloads it without re-registering.
2311
2408
  + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
@@ -3328,6 +3425,7 @@ export function buildUnit(
3328
3425
  const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
3329
3426
  const envLines = [
3330
3427
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
3428
+ systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"),
3331
3429
  ...(launcher ? [] : [
3332
3430
  systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
3333
3431
  systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
@@ -3860,10 +3958,31 @@ export async function installFreshWindowsSchedulerSafely(
3860
3958
  export function installedServiceRespawnRisk(
3861
3959
  probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
3862
3960
  platform: NodeJS.Platform = process.platform,
3863
- ): "none" | "respawnable" | "unknown" {
3961
+ io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {},
3962
+ ): "none" | "respawnable" | "unknown" | "self-unload" {
3864
3963
  // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
3865
3964
  // wrapper survives its task ending (#764).
3866
- if (platform !== "win32") return "none";
3965
+ //
3966
+ // "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD
3967
+ // one (#4023). When the proxy is itself the managed job, `launchctl unload` /
3968
+ // `systemctl stop` terminate this very process, so the manager stop can kill the request
3969
+ // handler before the shared teardown restores the native Codex config keys — leaving
3970
+ // `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json`
3971
+ // pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not
3972
+ // available here: the #3008 contract requires the manager to be proven stopped first.
3973
+ // So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops
3974
+ // the proxy from the outside and owns the teardown through its receipt.
3975
+ if (platform !== "win32") {
3976
+ const env = io.env ?? process.env;
3977
+ // Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and
3978
+ // `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting),
3979
+ // and refusing their dashboard stop would break a proxy that no manager supervises.
3980
+ if (env[SERVICE_MANAGED_ENV] !== "1") return "none";
3981
+ const exists = io.exists ?? existsSync;
3982
+ if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
3983
+ if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
3984
+ return "none";
3985
+ }
3867
3986
  try {
3868
3987
  // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
3869
3988
  // fail — it does not throw — so testing for "present" let an unanswerable probe
@@ -96,6 +96,7 @@ export interface CleanupResult {
96
96
  trashDir?: string;
97
97
  error?: CleanupErrorCode;
98
98
  removedPaths: string[];
99
+ skippedReferencedPaths?: string[];
99
100
  }
100
101
 
101
102
  const STATE_DB_FILE = /^state_(\d+)\.sqlite$/;
@@ -684,52 +685,59 @@ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], code
684
685
  }
685
686
 
686
687
  /**
687
- * True when any matched thread is still linked to a thread outside the delete set
688
- * (spawn edges) or uses paginated history that other live threads may depend on via fork.
689
- * Throws real DB errors (busy/corruption) so callers can refuse cleanup.
688
+ * Partition matched threads into deletable and referenced snapshots. Linked spawn/fork
689
+ * history and paginated histories stay in the skipped set. Throws real DB errors.
690
690
  */
691
- function findReferencedHistory(
691
+ function filterReferencedHistory(
692
692
  db: Database,
693
693
  threads: ThreadSnapshot[],
694
- ): boolean {
695
- if (threads.length === 0) return false;
696
- const ids = threads.map(t => t.id);
697
- const idSet = new Set(ids);
698
-
699
- // Paginated history keeps durable projections tied to the rollout — refuse cleanup.
700
- if (threads.some(t => (t.history_mode ?? "").toLowerCase() === "paginated")) {
701
- return true;
702
- }
703
-
704
- // Spawn edges that cross the delete boundary keep history reachable.
705
- if (tableExists(db, "thread_spawn_edges")) {
706
- for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK)) {
694
+ ): { safe: ThreadSnapshot[]; skipped: ThreadSnapshot[] } {
695
+ let safe = threads.filter(t => (t.history_mode ?? "").toLowerCase() !== "paginated");
696
+ const skipped = new Map(threads
697
+ .filter(t => (t.history_mode ?? "").toLowerCase() === "paginated")
698
+ .map(t => [t.id, t]));
699
+
700
+ while (safe.length > 0) {
701
+ const idSet = new Set(safe.map(t => t.id));
702
+ const unsafeIds = new Set<string>();
703
+
704
+ // Spawn edges that cross the delete boundary keep history reachable.
705
+ if (tableExists(db, "thread_spawn_edges")) {
706
+ for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK)) {
707
707
  const placeholders = chunk.map(() => "?").join(",");
708
708
  const edges = db.query<{ parent_thread_id: string; child_thread_id: string }, string[]>(
709
709
  `SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges
710
710
  WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
711
711
  ).all(...chunk, ...chunk);
712
712
  for (const edge of edges) {
713
- if (!idSet.has(edge.parent_thread_id) || !idSet.has(edge.child_thread_id)) {
714
- return true;
713
+ if (!idSet.has(edge.parent_thread_id)) unsafeIds.add(edge.child_thread_id);
714
+ if (!idSet.has(edge.child_thread_id)) unsafeIds.add(edge.parent_thread_id);
715
+ }
716
+ }
717
+ }
718
+
719
+ // Other threads that list one of ours as forked_from / parent (when columns exist).
720
+ for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) {
721
+ if (!columnExists(db, "threads", column)) continue;
722
+ for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK * 2)) {
723
+ const placeholders = chunk.map(() => "?").join(",");
724
+ const rows = db.query<{ id: string; ref: string }, string[]>(
725
+ `SELECT id, ${column} AS ref FROM threads WHERE ${column} IN (${placeholders})`,
726
+ ).all(...chunk);
727
+ for (const row of rows) {
728
+ if (!idSet.has(row.id)) unsafeIds.add(row.ref);
715
729
  }
716
730
  }
717
731
  }
718
- }
719
732
 
720
- // Other threads that list one of ours as forked_from / parent (when columns exist).
721
- for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) {
722
- if (!columnExists(db, "threads", column)) continue;
723
- for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK * 2)) {
724
- const placeholders = chunk.map(() => "?").join(",");
725
- const rows = db.query<{ id: string }, string[]>(
726
- `SELECT id FROM threads WHERE ${column} IN (${placeholders})`,
727
- ).all(...chunk);
728
- if (rows.some(r => !idSet.has(r.id))) return true;
733
+ if (unsafeIds.size === 0) break;
734
+ for (const thread of safe) {
735
+ if (unsafeIds.has(thread.id)) skipped.set(thread.id, thread);
729
736
  }
737
+ safe = safe.filter(thread => !unsafeIds.has(thread.id));
730
738
  }
731
739
 
732
- return false;
740
+ return { safe, skipped: [...skipped.values()] };
733
741
  }
734
742
 
735
743
  function tableExists(db: Database, name: string): boolean {
@@ -778,6 +786,7 @@ function deleteThreadsAndDependents(db: Database, threadIds: string[]): void {
778
786
  interface ReconcileOk {
779
787
  ok: true;
780
788
  threads: ThreadSnapshot[];
789
+ skipped: ThreadSnapshot[];
781
790
  }
782
791
  interface ReconcileErr {
783
792
  ok: false;
@@ -1463,14 +1472,14 @@ function withWritableDb(
1463
1472
  }
1464
1473
  }
1465
1474
 
1466
- /** Load matching archived threads and refuse referenced history — no deletes yet. */
1475
+ /** Load matching archived threads and retain referenced history — no deletes yet. */
1467
1476
  function loadThreadsForCleanup(
1468
1477
  stateDbPath: string,
1469
1478
  candidates: ArchivedCandidate[],
1470
1479
  codexHome: string,
1471
1480
  busyTimeoutMs: number,
1472
1481
  ): ReconcileOk | ReconcileErr {
1473
- if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [] };
1482
+ if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [], skipped: [] };
1474
1483
  let db: Database | undefined;
1475
1484
  try {
1476
1485
  db = openDbWritable(stateDbPath, busyTimeoutMs);
@@ -1478,10 +1487,8 @@ function loadThreadsForCleanup(
1478
1487
  if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
1479
1488
  return { ok: false, error: "pinned_thread" };
1480
1489
  }
1481
- if (findReferencedHistory(db, threads)) {
1482
- return { ok: false, error: "referenced_history" };
1483
- }
1484
- return { ok: true, threads };
1490
+ const filtered = filterReferencedHistory(db, threads);
1491
+ return { ok: true, threads: filtered.safe, skipped: filtered.skipped };
1485
1492
  } catch (error) {
1486
1493
  return { ok: false, error: mapDbError(error) };
1487
1494
  } finally {
@@ -1504,7 +1511,7 @@ function reconcileDeletedThreads(
1504
1511
  stageDir: string,
1505
1512
  hooks?: ReconcileTestHooks,
1506
1513
  ): ReconcileOk | ReconcileErr {
1507
- if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] };
1514
+ if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [], skipped: [] };
1508
1515
 
1509
1516
  if (hooks?.beforeReconcileLock) hooks.beforeReconcileLock();
1510
1517
 
@@ -1546,7 +1553,7 @@ function reconcileDeletedThreads(
1546
1553
  stateDb.exec("ROLLBACK");
1547
1554
  return { ok: false, error: "pinned_thread" };
1548
1555
  }
1549
- if (findReferencedHistory(stateDb, threads)) {
1556
+ if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) {
1550
1557
  stateDb.exec("ROLLBACK");
1551
1558
  return { ok: false, error: "referenced_history" };
1552
1559
  }
@@ -1582,7 +1589,7 @@ function reconcileDeletedThreads(
1582
1589
  if (hooks?.afterSatelliteMutations) hooks.afterSatelliteMutations();
1583
1590
 
1584
1591
  // Re-check under the same lock before committing state deletes.
1585
- if (findReferencedHistory(stateDb, threads)) {
1592
+ if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) {
1586
1593
  stateDb.exec("ROLLBACK");
1587
1594
  return failWithRestore("referenced_history");
1588
1595
  }
@@ -1590,7 +1597,7 @@ function reconcileDeletedThreads(
1590
1597
  if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
1591
1598
  stateDb.exec("COMMIT");
1592
1599
  // Keep satellite-backup.json for quarantine restore; permanent purge removes the stage.
1593
- return { ok: true, threads };
1600
+ return { ok: true, threads, skipped: [] };
1594
1601
  } catch (error) {
1595
1602
  if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
1596
1603
  throw error;
@@ -1895,7 +1902,30 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1895
1902
  const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome);
1896
1903
  if (normalized) threadByRelPath.set(normalized, thread);
1897
1904
  }
1898
- const manifestEntries: CleanupManifestEntry[] = preview.candidates.map(candidate => {
1905
+ const skippedReferencedPaths = loaded.skipped
1906
+ .map(thread => normalizeArchivedRolloutPath(thread.rollout_path, codexHome))
1907
+ .filter((path): path is string => path !== null);
1908
+ const matchedPaths = new Set([
1909
+ ...threadByRelPath.keys(),
1910
+ ...skippedReferencedPaths,
1911
+ ]);
1912
+ const candidates = preview.candidates.filter(candidate => {
1913
+ return !matchedPaths.has(candidate.relPath) || threadByRelPath.has(candidate.relPath);
1914
+ });
1915
+ if (candidates.length === 0) {
1916
+ removeStageIfEmpty(stageDir, []);
1917
+ removeEmptyTrashRoot(codexHome);
1918
+ return {
1919
+ ok: true,
1920
+ mode,
1921
+ percent,
1922
+ count: 0,
1923
+ bytes: 0,
1924
+ removedPaths: [],
1925
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
1926
+ };
1927
+ }
1928
+ const manifestEntries: CleanupManifestEntry[] = candidates.map(candidate => {
1899
1929
  const thread = threadByRelPath.get(candidate.relPath);
1900
1930
  return {
1901
1931
  relPath: candidate.relPath,
@@ -1936,7 +1966,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1936
1966
  return fail(mode, percent, "fs_failed");
1937
1967
  }
1938
1968
 
1939
- const stageResult = stageCandidates(codexHome, preview.candidates, stageDir, {
1969
+ const stageResult = stageCandidates(codexHome, candidates, stageDir, {
1940
1970
  blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined,
1941
1971
  });
1942
1972
  if (!stageResult.ok) {
@@ -1956,7 +1986,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1956
1986
 
1957
1987
  const deleted = reconcileDeletedThreads(
1958
1988
  paths,
1959
- preview.candidates,
1989
+ candidates,
1960
1990
  codexHome,
1961
1991
  busyTimeoutMs,
1962
1992
  stageDir,
@@ -1973,8 +2003,8 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1973
2003
  return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined);
1974
2004
  }
1975
2005
 
1976
- const removedPaths = preview.candidates.map(c => c.relPath);
1977
- const bytes = preview.candidates.reduce((sum, c) => sum + c.bytes, 0);
2006
+ const removedPaths = candidates.map(c => c.relPath);
2007
+ const bytes = candidates.reduce((sum, c) => sum + c.bytes, 0);
1978
2008
 
1979
2009
  if (mode === "quarantine") {
1980
2010
  return {
@@ -1985,6 +2015,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1985
2015
  bytes,
1986
2016
  trashDir,
1987
2017
  removedPaths,
2018
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
1988
2019
  };
1989
2020
  }
1990
2021
 
@@ -2038,6 +2069,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
2038
2069
  count: removedPaths.length,
2039
2070
  bytes,
2040
2071
  removedPaths,
2072
+ ...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
2041
2073
  };
2042
2074
  }
2043
2075
 
@@ -34,4 +34,22 @@ export interface CodexAccountCredentialRecord {
34
34
  lastCodexValidatedAt?: number;
35
35
  lastCodexValidationStatus?: "ok" | "failed";
36
36
  lastCodexValidationError?: string;
37
+ /** OAuth succeeded while quota was exhausted; never route until deferred validation succeeds. */
38
+ codexValidationPending?: boolean;
39
+ /**
40
+ * Set when the recorded failure is TERMINAL: the OAuth grant itself was revoked or has
41
+ * expired, so no retry can recover it and only a re-login will. It distinguishes a dead
42
+ * credential from a transient warmup or probe failure that may clear on its own.
43
+ *
44
+ * Deliberately a separate optional key rather than a third value in
45
+ * `lastCodexValidationStatus`: `isCredentialRecord` admits only `"ok" | "failed"`, so a
46
+ * record carrying an unrecognized status fails validation and is DROPPED from the store
47
+ * on load. An unknown extra key is carried through untouched instead, which keeps a
48
+ * downgrade from deleting the account entry and its credential.
49
+ *
50
+ * Cleared by `markCodexAccountValidated` and — because it is absent from
51
+ * `preservedValidationMetadata` — by every credential write. A refresh that succeeds
52
+ * disproves "the grant was revoked", so the verdict must not outlive it.
53
+ */
54
+ lastCodexValidationTerminal?: boolean;
37
55
  }