@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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 (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -37,7 +37,7 @@ import { readUsageEntries } from "../../usage/log";
37
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
38
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
39
39
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
40
- import { getProviderRegistryEntry } from "../../providers/registry";
40
+ import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
41
41
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
42
42
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
43
43
  import {
@@ -201,7 +201,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokC
201
201
  }
202
202
 
203
203
  export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
204
- const entry = getProviderRegistryEntry(name);
204
+ const entry = providerMatchesRegistryTransport(name, provider) ? getProviderRegistryEntry(name) : undefined;
205
205
  if (!entry?.staticHeaders || !provider.headers) return provider;
206
206
  const headerEntries = Object.entries(provider.headers);
207
207
  const staticEntries = Object.entries(entry.staticHeaders);
@@ -214,14 +214,14 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
214
214
 
215
215
  /** Shared Desktop profile DTO builder for the management API and CLI. */
216
216
  export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
217
- const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog");
217
+ const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
218
218
  const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p");
219
219
  const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
220
220
  const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
221
221
  const profileModels: DesktopProfileModel[] = [
222
222
  // Native rows carry their real context window from the same accessor the Grok sync
223
223
  // uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop.
224
- ...visibleNativeSlugs(config).map(id => {
224
+ ...desktopVisibleNativeSlugs(config).map(id => {
225
225
  const contextWindow = nativeOpenAiContextWindow(id);
226
226
  return { route: `native/${id}`, label: `${id} (native)`,
227
227
  ...(contextWindow !== undefined ? { contextWindow } : {}) };
@@ -233,6 +233,19 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
233
233
  })),
234
234
  ];
235
235
  const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels);
236
+ if (config.claudeCode?.desktopNativeModels === false) {
237
+ for (const route of Object.keys(profile.assignments)) {
238
+ if (route.startsWith("native/")) delete profile.assignments[route];
239
+ }
240
+ for (const family of ["opus", "fable", "sonnet", "haiku"] as const) {
241
+ const current = profile.defaults[family];
242
+ if (current?.startsWith("native/")) {
243
+ profile.defaults[family] = Object.keys(profile.assignments)
244
+ .filter(route => profile.assignments[route]?.family === family)
245
+ .sort()[0] ?? null;
246
+ }
247
+ }
248
+ }
236
249
  const available = new Set(profileModels.map(model => model.route));
237
250
  const modelByRoute = new Map(profileModels.map(model => [model.route, model]));
238
251
  // Effort support: routed models with a non-empty reasoningEfforts ladder support effort;
@@ -241,7 +254,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
241
254
  for (const m of routed) {
242
255
  effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0);
243
256
  }
244
- for (const id of visibleNativeSlugs(config)) {
257
+ for (const id of desktopVisibleNativeSlugs(config)) {
245
258
  effortByRoute.set(`native/${id}`, true);
246
259
  }
247
260
  const models = Object.keys(profile.assignments).sort().map(route => ({
@@ -0,0 +1,39 @@
1
+ /**
2
+ * /api/github/star and /api/update/badge — the two cheap polls behind the
3
+ * sidebar's GitHub star and update controls.
4
+ *
5
+ * Both ride the standard management gate (auth + origin check happen before
6
+ * dispatch), and both are scalar-only: a star state enum, a repo slug, version
7
+ * strings, and a fixed error code. No GitHub token, account login, or raw `gh`/npm
8
+ * output is ever serialized here — starring runs through the user's own `gh` CLI and
9
+ * this surface only learns the yes/no answer. `gh` writes the authenticated account
10
+ * name to stderr, so that output is discarded at the source rather than forwarded.
11
+ */
12
+ import { jsonResponse } from "../auth-cors";
13
+ import type { ManagementContext } from "./context";
14
+
15
+ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
16
+ const { req, url } = ctx;
17
+
18
+ if (url.pathname === "/api/github/star" && req.method === "GET") {
19
+ const { getStarStatus } = await import("../../github/star-state");
20
+ return jsonResponse(await getStarStatus());
21
+ }
22
+
23
+ if (url.pathname === "/api/github/star" && req.method === "POST") {
24
+ const { starRepository } = await import("../../github/star-state");
25
+ const result = await starRepository();
26
+ return jsonResponse({
27
+ ...result.status,
28
+ ok: result.ok,
29
+ ...(result.code ? { code: result.code } : {}),
30
+ });
31
+ }
32
+
33
+ if (url.pathname === "/api/update/badge" && req.method === "GET") {
34
+ const { readUpdateBadge } = await import("../../update/badge");
35
+ return jsonResponse(readUpdateBadge());
36
+ }
37
+
38
+ return null;
39
+ }
@@ -65,6 +65,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
65
65
  import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
66
66
  import { handleComboRoutes } from "./management/combo-routes";
67
67
  import { handleSystemRoutes } from "./management/system-routes";
68
+ import { handleSidebarRoutes } from "./management/sidebar-routes";
68
69
  import type { ManagementContext } from "./management/context";
69
70
  export type { ManagementApiDeps } from "./management/context";
70
71
  import { fetchAllModels } from "./management/shared";
@@ -130,7 +131,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
130
131
  ?? (await handleAgentSettingsRoutes(ctx))
131
132
  ?? (await handleOauthAccountRoutes(ctx))
132
133
  ?? (await handleComboRoutes(ctx))
133
- ?? (await handleSystemRoutes(ctx));
134
+ ?? (await handleSystemRoutes(ctx))
135
+ ?? (await handleSidebarRoutes(ctx));
134
136
  if (routed) return routed;
135
137
 
136
138
  if (url.pathname === "/api/stop" && req.method === "POST") {
@@ -109,6 +109,13 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
109
109
  return verified === candidate ? verified : null;
110
110
  };
111
111
 
112
+ const verifiedReportedPid = (reported: number | null): number | null => {
113
+ if (reported === null) return null;
114
+ if (!Number.isSafeInteger(reported) || reported <= 0) return null;
115
+ const verified = verifyPidFn(reported);
116
+ return verified === reported ? verified : null;
117
+ };
118
+
112
119
  const pid = readPidFn();
113
120
  let probedPort: number | null = null;
114
121
  if (pid) {
@@ -136,7 +143,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
136
143
  // (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
137
144
  // would hand destructive callers (stopProxy → kill fallback) a reusable pid.
138
145
  if (identity) {
139
- return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname, source: "runtime" };
146
+ return { pid: verifiedReportedPid(identity.pid), port: record.port, hostname: record.hostname, source: "runtime" };
140
147
  }
141
148
  }
142
149
 
@@ -145,7 +152,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
145
152
  const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
146
153
  if (identity) {
147
154
  return {
148
- pid: identity.pid ?? killablePid(pid),
155
+ pid: verifiedReportedPid(identity.pid) ?? killablePid(pid),
149
156
  port,
150
157
  hostname: config.hostname,
151
158
  source: "config",
@@ -1,6 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
3
  import { formatPassthroughUpstreamError } from "./passthrough-error";
4
+ import { describeUpstreamConnectFailure } from "./upstream-error";
4
5
  import {
5
6
  getConfigPath,
6
7
  multiAgentGuidanceEnabled,
@@ -173,16 +174,9 @@ export function sidecarOutcomeRecorder(
173
174
 
174
175
 
175
176
 
176
- export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
177
+ import { isShadowSourceModel } from "../../lib/shadow-call";
177
178
 
178
- export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
179
- if (modelId.includes("/")) return false;
180
- const configuredStrings = Array.isArray(configured)
181
- ? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
182
- : [];
183
- const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
184
- return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
185
- }
179
+ export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
186
180
 
187
181
 
188
182
 
@@ -1143,10 +1137,6 @@ export async function handleResponses(
1143
1137
  }
1144
1138
  if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
1145
1139
 
1146
- if (isThreadSpawnRequest(req.headers)) {
1147
- await maybePrimeSubagentQuota(config);
1148
- }
1149
-
1150
1140
  let route: RouteResult;
1151
1141
  try {
1152
1142
  route = routeModel(config, parsed.modelId);
@@ -1157,6 +1147,17 @@ export async function handleResponses(
1157
1147
  return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
1158
1148
  }
1159
1149
 
1150
+ const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
1151
+ && parsed._previousResponseInputExpanded !== true;
1152
+ // A canonical replay miss must not poll quota upstream before the final fail-closed decision.
1153
+ // Cached fallback state can still select a provider with native continuation support below.
1154
+ if (
1155
+ isThreadSpawnRequest(req.headers)
1156
+ && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
1157
+ ) {
1158
+ await maybePrimeSubagentQuota(config);
1159
+ }
1160
+
1160
1161
  let authCtx: CodexAuthContext = { kind: "main", accountId: null };
1161
1162
  let selectedForwardHeaders = req.headers;
1162
1163
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -1205,6 +1206,20 @@ export async function handleResponses(
1205
1206
  return unreadableEncryptedAgentTaskResponse();
1206
1207
  }
1207
1208
 
1209
+ // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
1210
+ // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
1211
+ // I/O instead of stripping the id and silently forwarding a context-free delta (#702).
1212
+ if (
1213
+ hasUnexpandedPreviousResponse
1214
+ && isCanonicalOpenAiForwardProvider(route.provider)
1215
+ ) {
1216
+ return formatErrorResponse(
1217
+ 400,
1218
+ "invalid_request_error",
1219
+ "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
1220
+ );
1221
+ }
1222
+
1208
1223
  await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx });
1209
1224
 
1210
1225
  {
@@ -1443,7 +1458,7 @@ export async function handleResponses(
1443
1458
  }
1444
1459
  const msg = outcome === "timeout"
1445
1460
  ? `Provider connect timeout after ${connectMs}ms`
1446
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1461
+ : describeUpstreamConnectFailure(err, connectMs);
1447
1462
  return formatErrorResponse(502, "upstream_error", msg);
1448
1463
  };
1449
1464
  try {
@@ -2101,9 +2116,7 @@ export async function handleResponses(
2101
2116
  cleanupUpstreamAbort();
2102
2117
  upstream.abort();
2103
2118
  if (options.abortSignal?.aborted) return clientCancelledResponse();
2104
- const msg = err instanceof Error && err.name === "TimeoutError"
2105
- ? `Provider connect timeout after ${connectMs}ms`
2106
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2119
+ const msg = describeUpstreamConnectFailure(err, connectMs);
2107
2120
  return formatErrorResponse(502, "upstream_error", msg);
2108
2121
  }
2109
2122
 
@@ -2143,9 +2156,7 @@ export async function handleResponses(
2143
2156
  if (options.abortSignal?.aborted) {
2144
2157
  return { failed: clientCancelledResponse() };
2145
2158
  }
2146
- const msg = err instanceof Error && err.name === "TimeoutError"
2147
- ? `Provider connect timeout after ${connectMs}ms`
2148
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2159
+ const msg = describeUpstreamConnectFailure(err, connectMs);
2149
2160
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
2150
2161
  }
2151
2162
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Upstream connection failures share one message shape across the three catch sites in
3
+ * core.ts. A TLS certificate/hostname mismatch deserves its own wording: the generic
4
+ * "Provider unreachable" reads as if opencodex built a wrong endpoint, which sent issue
5
+ * #553 looking for an adapter URL bug that does not exist. Name the likely cause and the
6
+ * command that settles it.
7
+ */
8
+ export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string {
9
+ if (err instanceof Error && err.name === "TimeoutError") {
10
+ return `Provider connect timeout after ${connectMs}ms`;
11
+ }
12
+ const detail = err instanceof Error ? err.message : String(err);
13
+ const code = err instanceof Error ? (err as { code?: unknown }).code : undefined;
14
+ // `code` is the reliable signal. The message fallback is anchored to the head because Bun
15
+ // renders this rejection as `ERR_TLS_CERT_ALTNAME_INVALID fetching "<url>"`; matching the
16
+ // bare substring anywhere would also fire on text that merely quotes the code back at us.
17
+ // Only transport failures reach these call sites, so that is defensive rather than load-bearing.
18
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || detail.startsWith("ERR_TLS_CERT_ALTNAME_INVALID")) {
19
+ const host = extractHostname(detail);
20
+ const target = host ?? "the provider host";
21
+ const probe = host ?? "<host>";
22
+ return `Provider TLS certificate does not match ${target}: ${redactUrlUserinfo(detail)}. `
23
+ + "opencodex did not rewrite this hostname — a certificate that does not cover it normally "
24
+ + "means TLS interception (corporate proxy, VPN, or local MITM tooling) or a poisoned DNS "
25
+ + `answer. Check with: openssl s_client -connect ${probe}:443 -servername ${probe} `
26
+ + "</dev/null | openssl x509 -noout -subject -ext subjectAltName";
27
+ }
28
+ return `Provider unreachable: ${redactUrlUserinfo(detail)}`;
29
+ }
30
+
31
+ /**
32
+ * A provider base URL may carry credentials as userinfo (`https://user:token@host/`), and the
33
+ * runtime error echoes the URL it was fetching. Strip it before the message reaches a client
34
+ * or a log line.
35
+ */
36
+ function redactUrlUserinfo(detail: string): string {
37
+ return detail.replace(/(https?:\/\/)[^/\s"'@]*@/g, "$1<redacted>@");
38
+ }
39
+
40
+ function extractHostname(detail: string): string | null {
41
+ const match = detail.match(/https?:\/\/([^/\s"']+)/);
42
+ if (!match?.[1]) return null;
43
+ try {
44
+ return new URL(`https://${match[1]}`).hostname || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -106,10 +106,14 @@ export function resetStartupInstallStateForTests(): void {
106
106
  installState = { status: "idle" };
107
107
  }
108
108
 
109
- export function startupInstallArgv(action: StartupInstallAction): string[] {
110
- return action === "install-service"
111
- ? ["service", "install"]
112
- : ["codex-shim", "install"];
109
+ export function startupInstallArgv(
110
+ action: StartupInstallAction,
111
+ options?: { repair?: boolean },
112
+ ): string[] {
113
+ if (action === "install-service") {
114
+ return options?.repair ? ["service", "repair"] : ["service", "install"];
115
+ }
116
+ return ["codex-shim", "install"];
113
117
  }
114
118
 
115
119
  export interface CliInstallFailure {
@@ -152,10 +156,13 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
152
156
  return classifyCliInstallFailure(stdout, stderr, error).detail;
153
157
  }
154
158
 
155
- function runCliInstall(action: StartupInstallAction): Promise<{ stdout: string; stderr: string }> {
159
+ function runCliInstall(
160
+ action: StartupInstallAction,
161
+ options?: { repair?: boolean },
162
+ ): Promise<{ stdout: string; stderr: string }> {
156
163
  const bun = durableBunPath();
157
164
  const cli = join(import.meta.dir, "..", "cli", "index.ts");
158
- const argv = [cli, ...startupInstallArgv(action)];
165
+ const argv = [cli, ...startupInstallArgv(action, options)];
159
166
  return new Promise((resolve, reject) => {
160
167
  execFile(bun, argv, {
161
168
  encoding: "utf8",
@@ -219,29 +226,37 @@ function applyReconciliationOutcome(
219
226
  /**
220
227
  * Execute the existing fixed CLI installer outside the proxy event loop.
221
228
  *
229
+ * Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart
230
+ * without Task Scheduler re-registration, so it must not enter the UAC elevation path.
231
+ *
222
232
  * After an elevation request timeout the lock becomes `indeterminate` until the
223
233
  * original elevated transaction completes and is reconciled. A process restart
224
234
  * clears this in-memory lock — callers must then inspect Task Scheduler reality
225
235
  * (see evaluateSchedulerInstallRestartReconciliation) before installing again.
226
236
  */
227
- export function runStartupInstallAction(action: StartupInstallAction): Promise<{ message: string }> {
237
+ export function runStartupInstallAction(
238
+ action: StartupInstallAction,
239
+ options?: { repair?: boolean },
240
+ ): Promise<{ message: string }> {
228
241
  const busy = rejectIfBusy(action);
229
242
  if (busy) return Promise.reject(busy);
230
243
 
244
+ const repair = options?.repair === true;
231
245
  const attemptId = randomUUID();
232
246
  const startedAt = Date.now();
233
247
  installState = { status: "running", action, attemptId, startedAt };
234
248
 
235
249
  const operation = (async () => {
236
250
  try {
237
- await runCliInstall(action);
251
+ await runCliInstall(action, { repair });
238
252
  } catch (error) {
239
253
  const code = installFailureCode(error);
240
254
  const detail = error instanceof Error ? error.message : String(error);
241
- // Elevate only for a structured Task Scheduler /create access denial — never for
242
- // WinSW removal, asset writes, or generic permission errors.
255
+ // Elevate only for fresh install + structured Task Scheduler /create access denial —
256
+ // never for repair, WinSW removal, asset writes, or generic permission errors.
243
257
  if (
244
- action === "install-service"
258
+ !repair
259
+ && action === "install-service"
245
260
  && process.platform === "win32"
246
261
  && (code === WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER
247
262
  || isWindowsSchtasksCreateAccessDenied(detail))
@@ -276,10 +291,11 @@ export function runStartupInstallAction(action: StartupInstallAction): Promise<{
276
291
  throw error;
277
292
  }
278
293
  }
294
+ if (action === "install-service") {
295
+ return { message: repair ? "Background service repaired." : "Background service installed." };
296
+ }
279
297
  return {
280
- message: action === "install-service"
281
- ? "Background service installed."
282
- : "Codex launcher shim installed.",
298
+ message: repair ? "Codex launcher shim repaired." : "Codex launcher shim installed.",
283
299
  };
284
300
  })();
285
301