@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -274,6 +274,9 @@ export function createResponsesSendBudget(
274
274
  noteAdapterRecoveryWithheld,
275
275
  sendBudgetExhausted,
276
276
  claimAmbiguousResend,
277
+ get ambiguousResendSpent(): boolean {
278
+ return isRequestExecutionBudget(sendBudget) && sendBudget.ambiguousResendSpent === true;
279
+ },
277
280
  get pendingHopPermit(): SingleUseDispatchPermit | undefined {
278
281
  return pendingHopPermit;
279
282
  },
@@ -313,6 +316,7 @@ function adapterDispatchBudgetView(
313
316
  get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
314
317
  remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
315
318
  claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true,
319
+ get ambiguousResendSpent(): boolean { return budget.ambiguousResendSpent === true; },
316
320
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
317
321
  // A dispatch whose upstream state is unknown is refused on its own merits. A hop that
318
322
  // already paid does not make an unsafe replay safe, so that check stays with the budget.
@@ -18,9 +18,9 @@ import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
18
18
  import type { AttemptRecoveryKind } from "../../usage/log";
19
19
  import { providerFetch } from "./fetch-helpers";
20
20
  import { normalizeLogConversationId } from "../request-log-conversation";
21
- import type { AdapterEvent, OcxProviderContinuationState } from "../../types";
21
+ import { normalizeDeclaredToolName, type AdapterEvent, type OcxProviderContinuationState } from "../../types";
22
22
  import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
23
- import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
23
+ import { SendBudgetExhaustedError, markResponseNonReplayable } from "../../lib/upstream-retry";
24
24
  import {
25
25
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
26
26
  hasEligibleGenericOAuthFailoverTarget,
@@ -39,6 +39,7 @@ import {
39
39
  import { rememberResponseState } from "../../responses/state";
40
40
  import { trackStreamLifetime } from "../lifecycle";
41
41
  import { awaitThoughtSignatureDurability } from "../../responses/thought-signature-replay";
42
+ import { undeclaredToolCallMessage } from "../responses-undeclared-tool-guard";
42
43
 
43
44
  /** One responsibility of the Responses request pipeline; state owners are explicit. */
44
45
  export async function executeResponsesRunTurn(
@@ -366,6 +367,20 @@ export async function executeResponsesRunTurn(
366
367
  };
367
368
 
368
369
  const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
370
+ const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic";
371
+ const classifyUndeclaredFirstTool = (
372
+ event: AdapterEvent,
373
+ ): Extract<AdapterEvent, { type: "error" }> | undefined => {
374
+ if (!enforceDeclaredToolNames || event.type !== "tool_call_start") return undefined;
375
+ const effectiveName = normalizeDeclaredToolName(event.name, declaredToolNames);
376
+ if (declaredToolNames.has(effectiveName)) return undefined;
377
+ return {
378
+ type: "error",
379
+ status: 502,
380
+ errorType: "upstream_error",
381
+ message: undeclaredToolCallMessage(effectiveName),
382
+ };
383
+ };
369
384
  if (parsed.stream) {
370
385
  void runTurn();
371
386
  let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
@@ -375,12 +390,16 @@ export async function executeResponsesRunTurn(
375
390
  eventSource = await preflightRunTurnFailover(eventSource);
376
391
  }
377
392
  if (options.comboAttempt) {
378
- const preflight = await preflightAdapterEvents(eventSource);
393
+ const preflight = await preflightAdapterEvents(eventSource, classifyUndeclaredFirstTool);
379
394
  if (preflight.error || preflight.empty) {
380
395
  runTurnAbort.abort();
381
396
  queue.close();
382
397
  const message = preflight.error?.message ?? "Adapter ended before producing a response";
383
- return formatErrorResponse(502, "upstream_error", redactSecretString(message));
398
+ const failure = formatErrorResponse(502, "upstream_error", redactSecretString(message));
399
+ // A replay-unsafe heartbeat means the adapter already ran a local side effect, so the
400
+ // combo must not send this turn to another target: the failure stays with this child.
401
+ if (preflight.replayUnsafe) markResponseNonReplayable(failure);
402
+ return failure;
384
403
  }
385
404
  eventSource = preflight.stream;
386
405
  }
@@ -412,7 +431,7 @@ export async function executeResponsesRunTurn(
412
431
  stallTimeoutSec: config.stallTimeoutSec,
413
432
  hideThinkingSummary: parsed.options.hideThinkingSummary,
414
433
  declaredToolNames,
415
- enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
434
+ enforceDeclaredToolNames,
416
435
  toolParameterSchemas,
417
436
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
418
437
  ...(routedCompaction ? { compaction: true } : {}),
@@ -468,12 +487,24 @@ export async function executeResponsesRunTurn(
468
487
  events = runTurnEvents;
469
488
  }
470
489
  if (options.comboAttempt) {
471
- const firstMeaningful = events.find(event => event.type !== "heartbeat");
472
- if (!firstMeaningful || firstMeaningful.type === "error") {
473
- const message = firstMeaningful?.type === "error"
490
+ const firstMeaningfulIndex = events.findIndex(event => event.type !== "heartbeat");
491
+ const firstMeaningful = firstMeaningfulIndex === -1 ? undefined : events[firstMeaningfulIndex];
492
+ // Same boundary as the streaming preflight: a replay-unsafe heartbeat means the adapter
493
+ // already ran a local side effect, so an undeclared tool call after it keeps the bridge's
494
+ // fail-closed refusal instead of becoming a hop that sends the turn to another target.
495
+ const replayUnsafe = events
496
+ .slice(0, firstMeaningfulIndex === -1 ? events.length : firstMeaningfulIndex)
497
+ .some(event => event.type === "heartbeat" && event.replayUnsafe === true);
498
+ const classifiedError = firstMeaningful && !replayUnsafe
499
+ ? classifyUndeclaredFirstTool(firstMeaningful)
500
+ : undefined;
501
+ if (!firstMeaningful || firstMeaningful.type === "error" || classifiedError) {
502
+ const message = classifiedError?.message ?? (firstMeaningful?.type === "error"
474
503
  ? firstMeaningful.message
475
- : "Adapter ended before producing a response";
476
- return formatErrorResponse(502, "upstream_error", redactSecretString(message));
504
+ : "Adapter ended before producing a response");
505
+ const failure = formatErrorResponse(502, "upstream_error", redactSecretString(message));
506
+ if (replayUnsafe) markResponseNonReplayable(failure);
507
+ return failure;
477
508
  }
478
509
  }
479
510
  let providerState: OcxProviderContinuationState | undefined;
@@ -483,7 +514,7 @@ export async function executeResponsesRunTurn(
483
514
  hideThinkingSummary: parsed.options.hideThinkingSummary,
484
515
  toolNsMap,
485
516
  declaredToolNames,
486
- enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
517
+ enforceDeclaredToolNames,
487
518
  toolParameterSchemas,
488
519
  freeformToolNames,
489
520
  toolSearchToolNames,
@@ -85,10 +85,11 @@ export function shouldUseCodexWsUpstream(
85
85
  url: string,
86
86
  init?: RequestInit,
87
87
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
88
- upstreamWebsocketConfigured = false,
88
+ upstreamWebsocketConfigured?: boolean,
89
89
  ): boolean {
90
90
  if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
91
91
  if (socks5ProxyFromEnv()) return false;
92
+ if (url === CODEX_RESPONSES_HTTP_URL && upstreamWebsocketConfigured === false) return false;
92
93
  // Bun's client WebSocket API delivers only fully assembled messages and has
93
94
  // no enforceable inbound payload limit. Keep arbitrary provider endpoints on
94
95
  // bounded HTTP/SSE until the client can reject fragmented text and binary
@@ -120,7 +120,6 @@ export function writeShellEnvFile(
120
120
  const maxCtx = config.claudeCode?.maxContextTokens;
121
121
  if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
122
122
  lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx))));
123
- lines.push(conditional("DISABLE_COMPACT", "1"));
124
123
  }
125
124
  // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl.
126
125
  const autoShell = auto ?? resolveAutoContext(config.claudeCode);
@@ -282,9 +282,12 @@ export async function injectSystemEnv(
282
282
  }
283
283
  // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
284
284
  // launchd domain, and track ONLY the keys we actually injected so revert cannot
285
- // delete a pre-existing user value (audit 139 #3).
285
+ // delete a pre-existing user value (audit 139 #3). A key we already track is ours
286
+ // (revertSystemEnv unsets it regardless of value), so it is refreshed, not skipped.
287
+ const producedLevers = new Set<string>();
286
288
  const injectLever = (name: string, value: string) => {
287
- if (launchctlGetenv(name) !== undefined) return;
289
+ producedLevers.add(name);
290
+ if (launchctlGetenv(name) !== undefined && !injectedKeys.includes(name)) return;
288
291
  inject(name, value);
289
292
  };
290
293
  // Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
@@ -293,7 +296,9 @@ export async function injectSystemEnv(
293
296
  // Auto-context: a user-owned launchd value drives the marking predicate so the
294
297
  // marker and threshold never separate (audit 021 #2); injectLever's user-wins
295
298
  // check below keeps that value untouched.
296
- const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
299
+ const userAutoCompact = injectedKeys.includes("CLAUDE_CODE_AUTO_COMPACT_WINDOW")
300
+ ? undefined
301
+ : launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
297
302
  const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
298
303
  const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
299
304
  for (const [name, value] of Object.entries(modelEnv)) {
@@ -303,7 +308,6 @@ export async function injectSystemEnv(
303
308
  const maxCtx = config.claudeCode?.maxContextTokens;
304
309
  if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
305
310
  injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
306
- injectLever("DISABLE_COMPACT", "1");
307
311
  }
308
312
  // Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
309
313
  if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
@@ -315,6 +319,23 @@ export async function injectSystemEnv(
315
319
  // instead of only terminal sessions. injectLever keeps a user-owned launchd value.
316
320
  const toolSearch = claudeToolSearchEnv(config.claudeCode?.toolSearch);
317
321
  if (toolSearch !== undefined) injectLever("ENABLE_TOOL_SEARCH", toolSearch);
322
+ // A lever injected on an earlier run that this config no longer produces (a cleared
323
+ // smallFastModel, a removed tier slot, or the DISABLE_COMPACT older releases paired with
324
+ // maxContextTokens) would otherwise stay in launchd until the proxy stops, and a
325
+ // same-port restart keeps the tracking record. Only tracked keys are touched, so a user-owned value is never removed.
326
+ for (const name of [...injectedKeys]) {
327
+ if ((SYSTEM_ENV_NAMES as readonly string[]).includes(name) || producedLevers.has(name)) continue;
328
+ // Older releases only ever injected DISABLE_COMPACT=1. Any other value was set by hand,
329
+ // so ownership is released without deleting it.
330
+ if (name === "DISABLE_COMPACT" && launchctlGetenv(name) !== "1") {
331
+ injectedKeys.splice(injectedKeys.indexOf(name), 1);
332
+ writeTracking(port, injectedKeys, tracked);
333
+ continue;
334
+ }
335
+ unsetLaunchctlEnv(name);
336
+ injectedKeys.splice(injectedKeys.indexOf(name), 1);
337
+ writeTracking(port, injectedKeys, tracked);
338
+ }
318
339
 
319
340
  // Shell-hook env file: works for new shells in already-running Terminal.app.
320
341
  writeShellEnvFile(port, config, modelEnv, auto, deps);
@@ -1,9 +1,12 @@
1
- import { existsSync, unlinkSync } from "node:fs";
1
+ import { existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { restoreNativeCodexAsync } from "../codex/inject";
4
4
  import { describeRetainedCodexProviderTable } from "../codex/inject/restore";
5
5
  import { stripGrokConfig } from "../grok/inject";
6
- import { serviceApiTokenFilePath } from "../lib/service-secrets";
6
+ import { withConfigMutationLockSync } from "../config/mutation-lock";
7
+ import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
8
+ import { pendingClientConnectMayOwnToken, readClientConnectionState } from "../client/state";
9
+ import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
7
10
  import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
8
11
  import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock";
9
12
  import { maybeShowStarPrompt } from "../cli/star-prompt";
@@ -182,6 +185,32 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
182
185
  return { sub: normalizeServiceSubcommand(sub), backend, invalid };
183
186
  }
184
187
 
188
+ /** Remove the service credential only when no client connection can own it. */
189
+ export function removeServiceTokenAfterUninstall(
190
+ lockDeps: ClientLifecycleLockDeps = {},
191
+ ): "removed" | "absent" | "retained" | "unverified" {
192
+ try {
193
+ return withClientLifecycleSync(() => withConfigMutationLockSync(() => {
194
+ const path = serviceApiTokenFilePath();
195
+ try { lstatSync(path); }
196
+ catch (error) {
197
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent";
198
+ throw error;
199
+ }
200
+ if (readClientConnectionState().kind !== "disconnected") return "retained";
201
+ const token = readServiceApiTokenState();
202
+ if (token.kind !== "present") return token.kind === "absent" ? "absent" : "unverified";
203
+ if (pendingClientConnectMayOwnToken(token.fingerprint)) return "retained";
204
+ unlinkSync(path);
205
+ return "removed";
206
+ }), lockDeps);
207
+ } catch {
208
+ // Lock, state-read and unlink failures all leave cleanup unverified, not successful.
209
+ return "unverified";
210
+ }
211
+ }
212
+
213
+ /** Execute a service verb while preserving client-owned credentials during uninstall. */
185
214
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
186
215
  const filteredArgs = args.filter((a): a is string => Boolean(a));
187
216
  const execute = async (): Promise<void> => {
@@ -424,7 +453,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
424
453
  }
425
454
  }
426
455
  removeServiceInstallState();
427
- try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
456
+ const tokenCleanup = removeServiceTokenAfterUninstall();
457
+ if (tokenCleanup === "retained") console.warn("⚠️ Service token kept because client state may own it.");
458
+ else if (tokenCleanup === "unverified") console.warn("⚠️ Service token cleanup could not be verified; inspect client state before deleting it.");
428
459
  console.log("✅ service uninstalled.");
429
460
  break;
430
461
  default:
@@ -41,6 +41,9 @@ function Load-TrayIcon([string]$Name, [System.Drawing.Icon]$Fallback) {
41
41
  $onlineIcon = Load-TrayIcon "opencodex-tray-online.ico" ([System.Drawing.SystemIcons]::Information)
42
42
  $warningIcon = Load-TrayIcon "opencodex-tray-warning.ico" ([System.Drawing.SystemIcons]::Warning)
43
43
  $offlineIcon = Load-TrayIcon "opencodex-tray-offline.ico" ([System.Drawing.SystemIcons]::Error)
44
+ $onlineUpdateIcon = Load-TrayIcon "opencodex-tray-online-update.ico" $onlineIcon
45
+ $warningUpdateIcon = Load-TrayIcon "opencodex-tray-warning-update.ico" $warningIcon
46
+ $offlineUpdateIcon = Load-TrayIcon "opencodex-tray-offline-update.ico" $offlineIcon
44
47
 
45
48
  function Get-StableHash([string]$Value) {
46
49
  $sha = [System.Security.Cryptography.SHA256]::Create()
@@ -86,6 +89,20 @@ function ConvertTo-NativeArgument([string]$Value) {
86
89
  return '"' + $Value + '"'
87
90
  }
88
91
 
92
+ # A fresh profile may not have created the default %USERPROFILE%.codex yet, and the
93
+ # CLI rejects a CODEX_HOME that does not exist. Only that default home is dropped so
94
+ # children resolve it themselves; any other home is passed through unchanged.
95
+ function Set-OcxChildEnvironment([System.Diagnostics.ProcessStartInfo]$StartInfo) {
96
+ $defaultHome = Normalize-HomePath (Join-Path $env:USERPROFILE ".codex")
97
+ $isDefaultHome = [string]::Equals($CodexHome, $defaultHome, [System.StringComparison]::OrdinalIgnoreCase)
98
+ if ($isDefaultHome -and -not [System.IO.Directory]::Exists($CodexHome)) {
99
+ $StartInfo.EnvironmentVariables.Remove("CODEX_HOME")
100
+ } else {
101
+ $StartInfo.EnvironmentVariables["CODEX_HOME"] = $CodexHome
102
+ }
103
+ $StartInfo.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome
104
+ }
105
+
89
106
  function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) {
90
107
  try {
91
108
  $allArgs = @($CliPath) + $CommandArgs
@@ -95,8 +112,7 @@ function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) {
95
112
  $psi.UseShellExecute = $false
96
113
  $psi.CreateNoWindow = $true
97
114
  $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
98
- $psi.EnvironmentVariables["CODEX_HOME"] = $CodexHome
99
- $psi.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome
115
+ Set-OcxChildEnvironment $psi
100
116
  if ($BunRuntimeSource) {
101
117
  $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource
102
118
  # Paired with the source so a later relaunch can tell the marker still describes
@@ -155,8 +171,7 @@ function Start-StartupHealthProbe {
155
171
  $psi.RedirectStandardError = $true
156
172
  $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
157
173
  $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
158
- $psi.EnvironmentVariables["CODEX_HOME"] = $CodexHome
159
- $psi.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome
174
+ Set-OcxChildEnvironment $psi
160
175
  if ($BunRuntimeSource) {
161
176
  $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource
162
177
  $psi.EnvironmentVariables["OCX_BUN_RUNTIME_PATH"] = $BunPath
@@ -198,6 +213,147 @@ function Complete-StartupHealthProbe {
198
213
  }
199
214
  }
200
215
 
216
+ function Initialize-UpdateBadgeReader {
217
+ if (([System.Management.Automation.PSTypeName]"TrayUpdateBadgeReader").Type) { return }
218
+ Add-Type -TypeDefinition @'
219
+ using System;
220
+ using System.IO;
221
+ using System.Text;
222
+ using System.Threading.Tasks;
223
+ public static class TrayUpdateBadgeReader {
224
+ public static async Task<string> ReadAsync(Stream input, int maxBytes) {
225
+ byte[] chunk = new byte[4096];
226
+ using (var content = new MemoryStream()) {
227
+ while (true) {
228
+ int count = await input.ReadAsync(chunk, 0, chunk.Length).ConfigureAwait(false);
229
+ if (count == 0) break;
230
+ if (content.Length + count > maxBytes) throw new InvalidDataException("badge pipe byte cap exceeded");
231
+ content.Write(chunk, 0, count);
232
+ }
233
+ return new UTF8Encoding(false, true).GetString(content.ToArray());
234
+ }
235
+ }
236
+ }
237
+ '@
238
+ }
239
+
240
+ function Parse-UpdateBadgeText([string]$Text) {
241
+ $lines = @($Text -split "\r?\n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
242
+ if ($lines.Count -ne 1) { return $null }
243
+ try {
244
+ $badge = $lines[0] | ConvertFrom-Json
245
+ if (($badge.updateAvailable -isnot [bool]) -or ($badge.unknown -isnot [bool]) -or
246
+ ($badge.canUpdate -isnot [bool])) { return $null }
247
+ return [bool]($badge.updateAvailable -and -not $badge.unknown -and $badge.canUpdate)
248
+ } catch { return $null }
249
+ }
250
+
251
+ function Complete-UpdateBadgeProbe {
252
+ $process = $script:updateBadgeProcess
253
+ $script:updateBadgeProcess = $null
254
+ $script:updateBadgeOutputTask = $null
255
+ $script:updateBadgeErrorTask = $null
256
+ $script:updateBadgeTerminating = $false
257
+ if ($null -ne $process) {
258
+ try {
259
+ $process.StandardOutput.Dispose()
260
+ $process.StandardError.Dispose()
261
+ $process.Dispose()
262
+ } catch { Write-ActionLog "update badge probe dispose failed: $($_.Exception.GetType().Name)" }
263
+ }
264
+ }
265
+
266
+ function Stop-UpdateBadgeProbe([switch]$Shutdown) {
267
+ if ($null -eq $script:updateBadgeProcess) { return }
268
+ $script:updateBadgeTerminating = $true
269
+ try {
270
+ if (-not $script:updateBadgeProcess.HasExited) { $script:updateBadgeProcess.Kill() }
271
+ } catch { Write-ActionLog "update badge probe termination failed: $($_.Exception.GetType().Name)" }
272
+ if ($Shutdown) {
273
+ # Only shutdown may wait, and it is bounded; the UI tick never calls this branch.
274
+ try { [void]$script:updateBadgeProcess.WaitForExit(500) } catch { $null = $_ }
275
+ Complete-UpdateBadgeProbe
276
+ }
277
+ }
278
+
279
+ function Start-UpdateBadgeProbe {
280
+ try {
281
+ Initialize-UpdateBadgeReader
282
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
283
+ $psi.FileName = $BunPath
284
+ $psi.Arguments = ((@($CliPath, "__update-badge") | ForEach-Object { ConvertTo-NativeArgument $_ }) -join " ")
285
+ $psi.UseShellExecute = $false
286
+ $psi.CreateNoWindow = $true
287
+ $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
288
+ $psi.RedirectStandardOutput = $true
289
+ $psi.RedirectStandardError = $true
290
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
291
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
292
+ Set-OcxChildEnvironment $psi
293
+ if ($BunRuntimeSource) {
294
+ $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource
295
+ $psi.EnvironmentVariables["OCX_BUN_RUNTIME_PATH"] = $BunPath
296
+ }
297
+ $script:updateBadgeProcess = [System.Diagnostics.Process]::Start($psi)
298
+ if ($null -eq $script:updateBadgeProcess) { throw "Process did not start" }
299
+ $script:updateBadgeOutputTask = [TrayUpdateBadgeReader]::ReadAsync($script:updateBadgeProcess.StandardOutput.BaseStream, $script:updateBadgeMaxBytesPerStream)
300
+ $script:updateBadgeErrorTask = [TrayUpdateBadgeReader]::ReadAsync($script:updateBadgeProcess.StandardError.BaseStream, $script:updateBadgeMaxBytesPerStream)
301
+ $script:updateBadgeStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
302
+ } catch {
303
+ Write-ActionLog "update badge probe launch failed: $($_.Exception.GetType().Name)"
304
+ if ($null -ne $script:updateBadgeProcess) { Stop-UpdateBadgeProbe }
305
+ }
306
+ }
307
+
308
+ function Maintain-UpdateBadgeProbe([long]$Now) {
309
+ if ($null -ne $script:updateBadgeProcess) {
310
+ $terminatingAtStart = $script:updateBadgeTerminating
311
+ $expired = ($Now - $script:updateBadgeStarted) -gt $script:updateBadgeTimeoutMs
312
+ $exited = $false
313
+ try { $exited = $script:updateBadgeProcess.HasExited } catch { Stop-UpdateBadgeProbe }
314
+ $drained = ($null -eq $script:updateBadgeOutputTask -or $script:updateBadgeOutputTask.IsCompleted) -and
315
+ ($null -eq $script:updateBadgeErrorTask -or $script:updateBadgeErrorTask.IsCompleted)
316
+ $failedRead = ($null -ne $script:updateBadgeOutputTask -and
317
+ ($script:updateBadgeOutputTask.IsFaulted -or $script:updateBadgeOutputTask.IsCanceled)) -or
318
+ ($null -ne $script:updateBadgeErrorTask -and
319
+ ($script:updateBadgeErrorTask.IsFaulted -or $script:updateBadgeErrorTask.IsCanceled))
320
+ if ($failedRead -and -not $script:updateBadgeTerminating) {
321
+ Write-ActionLog "update badge probe pipe read failed or exceeded byte cap"
322
+ Stop-UpdateBadgeProbe
323
+ }
324
+ if ($script:updateBadgeTerminating) {
325
+ # Reap on a later tick after both bounded readers settle; no replacement starts meanwhile.
326
+ if ($terminatingAtStart -and -not $exited) { Stop-UpdateBadgeProbe }
327
+ if ($terminatingAtStart -and $exited -and $drained) { Complete-UpdateBadgeProbe }
328
+ } elseif ($exited -and $drained -and $null -ne $script:updateBadgeOutputTask -and
329
+ $null -ne $script:updateBadgeErrorTask) {
330
+ $answer = $null
331
+ try {
332
+ if ($script:updateBadgeProcess.ExitCode -eq 0) {
333
+ $answer = Parse-UpdateBadgeText $script:updateBadgeOutputTask.Result
334
+ }
335
+ } catch { Write-ActionLog "update badge probe read failed: $($_.Exception.GetType().Name)" }
336
+ if ($null -ne $answer) {
337
+ $script:updateAvailable = [bool]$answer
338
+ $script:updateBadgeObservedAt = $Now
339
+ }
340
+ Complete-UpdateBadgeProbe
341
+ } elseif ($expired) {
342
+ Write-ActionLog "update badge probe timed out"
343
+ Stop-UpdateBadgeProbe
344
+ }
345
+ }
346
+ if ($script:updateBadgeObservedAt -eq 0 -or
347
+ ($Now - $script:updateBadgeObservedAt) -gt $script:updateBadgeExpiryMs) {
348
+ $script:updateAvailable = $false
349
+ }
350
+ if ($null -eq $script:updateBadgeProcess -and -not $script:updateBadgeTerminating -and
351
+ ($script:updateBadgeAttemptAt -eq 0 -or ($Now - $script:updateBadgeAttemptAt) -ge $script:updateBadgeRefreshMs)) {
352
+ $script:updateBadgeAttemptAt = $Now
353
+ Start-UpdateBadgeProbe
354
+ }
355
+ }
356
+
201
357
  function Read-ListenTarget {
202
358
  foreach ($path in @((Join-Path $OpenCodexHome "runtime-port.json"), (Join-Path $OpenCodexHome "config.json"))) {
203
359
  try {
@@ -241,6 +397,9 @@ $statusItem.Enabled = $false
241
397
  $safetyItem = New-Object System.Windows.Forms.ToolStripMenuItem
242
398
  $safetyItem.Enabled = $false
243
399
  $openItem = $menu.Items.Add("Open Dashboard")
400
+ $updateItem = $menu.Items.Add("Update available")
401
+ $updateItem.Visible = $false
402
+ $updateItem.Enabled = $false
244
403
  $startItem = $menu.Items.Add("Start Proxy")
245
404
  $stopItem = $menu.Items.Add("Stop Proxy and Restore Native Routing")
246
405
  $restartItem = $menu.Items.Add("Restart Proxy")
@@ -263,6 +422,18 @@ $script:startupProbeOutputTask = $null
263
422
  $script:startupProbeErrorTask = $null
264
423
  $script:startupProbeStarted = 0L
265
424
  $script:startupProbeTimeoutMs = 30000L
425
+ $script:updateAvailable = $false
426
+ $script:updateBadgeObservedAt = 0L
427
+ $script:updateBadgeAttemptAt = 0L
428
+ $script:updateBadgeRefreshMs = 60000L
429
+ $script:updateBadgeExpiryMs = 180000L
430
+ $script:updateBadgeTimeoutMs = 12000L
431
+ $script:updateBadgeMaxBytesPerStream = 16384
432
+ $script:updateBadgeProcess = $null
433
+ $script:updateBadgeOutputTask = $null
434
+ $script:updateBadgeErrorTask = $null
435
+ $script:updateBadgeStarted = 0L
436
+ $script:updateBadgeTerminating = $false
266
437
  $script:pendingAction = $null
267
438
  $script:pendingStarted = 0L
268
439
  $script:pendingDeadline = 0L
@@ -322,6 +493,7 @@ function Update-TrayState {
322
493
  $cameOnline = $script:online -and -not $script:wasOnline
323
494
  $script:wasOnline = $script:online
324
495
  $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
496
+ Maintain-UpdateBadgeProbe $now
325
497
  if ($null -ne $script:startupProbeProcess) {
326
498
  $probeExited = $false
327
499
  try {
@@ -377,20 +549,26 @@ function Update-TrayState {
377
549
  if ($null -ne $startup) {
378
550
  $label = if ($startup.status -eq "at-risk") { "At risk" } elseif ($startup.status -eq "protected") { "Protected" } else { "Native routing" }
379
551
  $safetyItem.Text = "Restart safety: $label"
380
- $notify.Icon = if ($startup.status -eq "at-risk") { $warningIcon } else { $onlineIcon }
552
+ $notify.Icon = if ($startup.status -eq "at-risk") {
553
+ if ($script:updateAvailable) { $warningUpdateIcon } else { $warningIcon }
554
+ } else {
555
+ if ($script:updateAvailable) { $onlineUpdateIcon } else { $onlineIcon }
556
+ }
381
557
  } else {
382
558
  $safetyItem.Text = "Restart safety: unavailable"
383
- $notify.Icon = $warningIcon
559
+ $notify.Icon = if ($script:updateAvailable) { $warningUpdateIcon } else { $warningIcon }
384
560
  }
385
561
  } else {
386
562
  $statusItem.Text = "Proxy: Offline"
387
563
  $safetyItem.Text = "Restart safety: start the proxy to inspect"
388
564
  $notify.Text = "opencodex: Offline"
389
- $notify.Icon = $offlineIcon
565
+ $notify.Icon = if ($script:updateAvailable) { $offlineUpdateIcon } else { $offlineIcon }
390
566
  $startItem.Enabled = $true
391
567
  $stopItem.Enabled = $false
392
568
  $restartItem.Enabled = $false
393
569
  }
570
+ $updateItem.Visible = $script:updateAvailable
571
+ $updateItem.Enabled = $script:updateAvailable
394
572
  if ($null -ne $script:pendingAction) {
395
573
  $startItem.Enabled = $false
396
574
  $stopItem.Enabled = $false
@@ -426,6 +604,7 @@ function Update-TrayState {
426
604
  }
427
605
 
428
606
  $openItem.add_Click({ Start-OcxCommand @("gui") })
607
+ $updateItem.add_Click({ Start-OcxCommand @("gui") })
429
608
  $startItem.add_Click({
430
609
  if (-not (Set-PendingAction "Start Proxy" 75)) { return }
431
610
  $statusItem.Text = "Proxy: Starting..."
@@ -489,6 +668,7 @@ $notify.Visible = $true
489
668
  $notify.Text = "opencodex: Checking..."
490
669
 
491
670
  try {
671
+ Initialize-UpdateBadgeReader
492
672
  Update-TrayState
493
673
  $timer.Start()
494
674
  [System.Windows.Forms.Application]::Run()
@@ -512,6 +692,7 @@ try {
512
692
  }
513
693
  Complete-StartupHealthProbe
514
694
  }
695
+ Stop-UpdateBadgeProbe -Shutdown
515
696
  $notify.Dispose()
516
697
  foreach ($icon in $script:ownedIcons) { $icon.Dispose() }
517
698
  $menu.Dispose()
@@ -19,6 +19,9 @@ const TRAY_ICON_FILES = [
19
19
  "opencodex-tray-online.ico",
20
20
  "opencodex-tray-warning.ico",
21
21
  "opencodex-tray-offline.ico",
22
+ "opencodex-tray-online-update.ico",
23
+ "opencodex-tray-warning-update.ico",
24
+ "opencodex-tray-offline-update.ico",
22
25
  ] as const;
23
26
 
24
27
  export interface WindowsTrayEntry {
@@ -68,6 +71,25 @@ function installedTrayIconPaths(): string[] {
68
71
  return TRAY_ICON_FILES.map(name => join(getConfigDir(), name));
69
72
  }
70
73
 
74
+ /**
75
+ * Files an installed tray must still have for its registration to count as ours.
76
+ *
77
+ * The dotted update icons arrived after the tray shipped, so an install made by an older
78
+ * release has only the three base icons. Requiring all six here classified that install as
79
+ * stale, and the updater then stopped the tray without reinstalling it (trayWasInstalled was
80
+ * false). The dotted icons stay in the install, rollback and uninstall lists; the tray script
81
+ * falls back to the base icon when one is missing, and the next `tray install` adds them.
82
+ */
83
+ export function windowsTrayRequiredFilesPresent(
84
+ state: Pick<WindowsTrayEntry, "bun" | "cli" | "script"> & { launcherPath?: string },
85
+ iconPaths: readonly string[],
86
+ exists: (path: string) => boolean = existsSync,
87
+ ): boolean {
88
+ const baseIcons = iconPaths.filter(path => !/-update\.ico$/i.test(path));
89
+ return [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...baseIcons]
90
+ .every(path => exists(path));
91
+ }
92
+
71
93
  export function windowsTrayStatePathsOwned(
72
94
  state: Pick<WindowsTrayEntry, "script" | "opencodexHome"> & { launcherPath?: string },
73
95
  configDir = getConfigDir(),
@@ -469,8 +491,7 @@ function trayStatusFrom(registered: string | null): WindowsTrayStatus {
469
491
  const running = heartbeatProcessAlive(heartbeat);
470
492
  const registrationOwned = state !== null
471
493
  && registered === state.runCommand
472
- && [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...installedTrayIconPaths()]
473
- .every(path => existsSync(path));
494
+ && windowsTrayRequiredFilesPresent(state, installedTrayIconPaths());
474
495
  const stale = windowsTrayRegistrationIsStale({
475
496
  registered: registered !== null,
476
497
  registrationOwned,