@bitkyc08/opencodex 2.84.0 → 2.86.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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -7,6 +7,9 @@ import type { ResponsesEffects } from "./response-effects";
7
7
  import type { ResponsesSendBudget } from "./request-send-budget";
8
8
  import { formatErrorResponse } from "../../bridge";
9
9
  import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search";
10
+ import { createRegisteredAdvisorPlan } from "./advisor-plan-slot";
11
+ import { ADVISOR_TOOL_NAME } from "./advisor-slot";
12
+ import { buildToolBridgeMaps } from "./collaboration";
10
13
  import {
11
14
  planImageBridge,
12
15
  planVideoBridge,
@@ -30,6 +33,8 @@ import {
30
33
  } from "../../oauth/generic-account-failover";
31
34
  import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
32
35
  import { isNonReplayableResponse } from "../../lib/upstream-retry";
36
+ import { unwrapUpstreamRetryEvidenceError } from "../../codex/auth-context";
37
+ import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope";
33
38
  import { noteKiroMonthlyRefusal, noteKiroServedSuccess } from "../../providers/kiro-usage";
34
39
  import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
35
40
  import { anthropicPoolCooldownResponse, readDisplaySafeErrorText } from "./core-errors";
@@ -60,6 +65,7 @@ export async function executeResponsesSidecars(
60
65
  | "inboundWire"
61
66
  | "selectedForwardHeaders"
62
67
  | "translatorBudget"
68
+ | "toolBridgeMaps"
63
69
  | "rememberKiroDeliveredFinalAnswer"
64
70
  | "responseStateOptions"
65
71
  >,
@@ -169,6 +175,25 @@ export async function executeResponsesSidecars(
169
175
  }
170
176
  }
171
177
 
178
+ // Advisor sidecar plan (optional subsystem; null when disabled, unconfigured, or when this
179
+ // request IS an advisor loopback consultation — the recursion fence). The plan carries
180
+ // request-scoped state only; preflight keeps fixed policy separate from user-role advice
181
+ // before the worker is dispatched. Registration seam: the core path sees only
182
+ // `parsed._advisorGuard`; only an activated factory can load the optional implementation.
183
+ const advisorPlan = options.advisorInternal === true
184
+ ? null
185
+ : await createRegisteredAdvisorPlan({
186
+ config,
187
+ workerIdentity: `${route.modelId} (provider ${route.providerName})`,
188
+ workerModelId: route.modelId,
189
+ abortSignal: options.abortSignal,
190
+ });
191
+ // Preflight only on real worker turns: a routed-compaction turn is a summarization request,
192
+ // and injecting advice into it would pollute the summary Codex replaces its history with.
193
+ if (advisorPlan && !routedCompaction) {
194
+ await advisorPlan.preflightInject(parsed);
195
+ }
196
+
172
197
  // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
173
198
  // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
174
199
  // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
@@ -198,6 +223,20 @@ export async function executeResponsesSidecars(
198
223
  // A single request may rotate once for a verification refusal. Keep this separate from the
199
224
  // broader OAuth failover count: a sidecar turn can also spend that count on rate-limit recovery.
200
225
  let antigravityValidationRotationAttempted = false;
226
+ // A replacement credential whose Copilot Auto negotiation leaves the key's scope ends the turn.
227
+ // The loops only see "no rotation" and fail with the original, replayable 429, so the refusal
228
+ // is held here and answers in place of that failure.
229
+ let sidecarScopeDenial: AdmissionModelDeniedError | undefined;
230
+ const noteSidecarScopeDenial = (error: unknown): void => {
231
+ const refusal = unwrapUpstreamRetryEvidenceError(error);
232
+ if (refusal instanceof AdmissionModelDeniedError) sidecarScopeDenial ??= refusal;
233
+ };
234
+ const sidecarScopeRefusal = (response: Response): Response | undefined => {
235
+ if (!sidecarScopeDenial || response.ok) return undefined;
236
+ void response.body?.cancel().catch(() => {});
237
+ releaseSearchProbeLease();
238
+ return admissionModelDeniedResponse(sidecarScopeDenial);
239
+ };
201
240
  /** Admit a replacement credential, or leave the loop's original refusal authoritative. */
202
241
  const rotateSidecarProviderOn429 = async (
203
242
  retryAfter: string | null,
@@ -264,7 +303,8 @@ export async function executeResponsesSidecars(
264
303
  return null;
265
304
  }
266
305
  transportState.genericFailovers += 1;
267
- } catch {
306
+ } catch (error) {
307
+ noteSidecarScopeDenial(error);
268
308
  hop.permit?.release();
269
309
  return null;
270
310
  }
@@ -284,7 +324,10 @@ export async function executeResponsesSidecars(
284
324
  } else if (rotated) {
285
325
  route.provider = rotated;
286
326
  try { await resolveCopilotSelection(retryParsed ?? parsed); }
287
- catch { return null; } // Keep the sidecar's original refusal when replacement negotiation fails.
327
+ catch (error) { // Keep the sidecar's original refusal when replacement negotiation fails.
328
+ noteSidecarScopeDenial(error);
329
+ return null;
330
+ }
288
331
  } else if (
289
332
  // A POSITIVE gate, not an early return. An early `return null` here made every later arm
290
333
  // unreachable: Anthropic never has a genericFailoverAccountId (isGenericFailoverProvider
@@ -326,7 +369,8 @@ export async function executeResponsesSidecars(
326
369
  return null;
327
370
  }
328
371
  if (route.providerName === "kiro") transportState.genericFailovers += 1;
329
- } catch {
372
+ } catch (error) {
373
+ noteSidecarScopeDenial(error);
330
374
  hop.permit?.release();
331
375
  return null;
332
376
  }
@@ -527,6 +571,8 @@ export async function executeResponsesSidecars(
527
571
  notifyResponseComplete(response);
528
572
  },
529
573
  });
574
+ const imgScopeRefusal = sidecarScopeRefusal(imgResponse);
575
+ if (imgScopeRefusal) return imgScopeRefusal;
530
576
  if (imgResponse.body) {
531
577
  const imgTurnAc = new AbortController();
532
578
  imgTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
@@ -622,6 +668,8 @@ export async function executeResponsesSidecars(
622
668
  notifyResponseComplete(response);
623
669
  },
624
670
  });
671
+ const wsScopeRefusal = sidecarScopeRefusal(loopResponse);
672
+ if (wsScopeRefusal) return wsScopeRefusal;
625
673
  // A routed Anthropic model whose last pool account refused on spent quota fails the loop's
626
674
  // first iteration with a generic 429 before any search runs. Answer the usage limit instead,
627
675
  // as the main path does (#6991), so Codex stops rather than retrying the whole turn.
@@ -645,5 +693,32 @@ export async function executeResponsesSidecars(
645
693
  return wsResponse;
646
694
  }
647
695
 
696
+ // Advisor synthetic tool + stream guard: attach ONLY when this function is about to hand the
697
+ // request back to the normal translated exchange (no sidecar loop claimed the turn, not a
698
+ // run-turn adapter, not compaction). The web-search/image loops own their own event handling;
699
+ // run-turn adapters execute their own loop; both would leak the synthetic tool call they
700
+ // cannot intercept. Preflight (above) still applies to those paths — only the tool does not.
701
+ if (advisorPlan && !routedCompaction && !transportState.adapter.runTurn && !wsPlan && !imgPlan && !vidPlan) {
702
+ // A client-declared tool named "advisor" would collide with the synthetic injection (one
703
+ // wire name, two schemas); the synthetic runtime owns the name for this turn.
704
+ parsed.context.tools = [
705
+ ...(parsed.context.tools ?? []).filter(t => !t.advisor && t.name !== ADVISOR_TOOL_NAME),
706
+ advisorPlan.tool,
707
+ ];
708
+ // The advisor tool joined AFTER prepare computed the bridge maps; recompute so the tool is
709
+ // declared (undeclared-tool guard, tool_choice mapping, schema repair) on this turn.
710
+ //
711
+ // Rebuild WITHOUT the budget: prepare already charged every tool in the catalog, and
712
+ // buildToolBridgeMaps charges each name it registers, so re-passing the budget would bill the
713
+ // whole catalog a second time and can trip `translation_buffer_limit` for a large MCP catalog.
714
+ // Charge only the one entry that is genuinely new — the synthetic advisor tool is a bare
715
+ // function tool, so it retains its wire name and its bare name (collaboration.ts:187, :233).
716
+ requestState.toolBridgeMaps = buildToolBridgeMaps(parsed);
717
+ translatorBudget.chargeRetained(new TextEncoder().encode(ADVISOR_TOOL_NAME).byteLength * 2, {
718
+ kind: "retained_collectors",
719
+ });
720
+ advisorPlan.attachGuard(parsed);
721
+ }
722
+
648
723
  return undefined;
649
724
  }
@@ -170,7 +170,8 @@ export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean {
170
170
  return event.type === "heartbeat" || event.type === "tool_call_delta";
171
171
  }
172
172
 
173
- function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
173
+ /** Merge two reported usages (canonical Responses convention). Shared with the advisor guard. */
174
+ export function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
174
175
  if (!first) return second;
175
176
  if (!second) return first;
176
177
  const sumOptional = (key: keyof OcxUsage): number | undefined => {
@@ -1,3 +1,4 @@
1
+ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors";
1
2
  import {
2
3
  collectAmbiguousDottedAliases,
3
4
  dottedAliasIsUnambiguous,
@@ -78,7 +79,7 @@ const NAMELESS_TOOL_SPEC_TYPES = new Set([
78
79
  /** An upstream-supplied name reaches the error message; keep it bounded. */
79
80
  const MAX_REPORTED_NAME_CHARS = 100;
80
81
 
81
- export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call";
82
+ export { UNDECLARED_TOOL_CALL_ERROR_CODE };
82
83
 
83
84
  function isPlainObject(value: unknown): value is Record<string, unknown> {
84
85
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -370,6 +371,24 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean {
370
371
  );
371
372
  }
372
373
 
374
+ /**
375
+ * Whether the caller declared an explicitly EMPTY catalog — `tools: []` or an
376
+ * `additional_tools` item with `tools: []`. Unlike an absent or partial catalog this is an
377
+ * unambiguous deny-all: no deferred registry can exist under it, so even deferred wires
378
+ * enforce it. A hosted-only catalog is NOT empty — it still defers like an absent one.
379
+ */
380
+ export function hasExplicitEmptyToolCatalog(body: unknown): boolean {
381
+ if (!isPlainObject(body)) return false;
382
+ if (Array.isArray(body.tools) && body.tools.length === 0) return true;
383
+ if (!Array.isArray(body.input)) return false;
384
+ return body.input.some(item =>
385
+ isPlainObject(item)
386
+ && item.type === "additional_tools"
387
+ && Array.isArray(item.tools)
388
+ && item.tools.length === 0
389
+ );
390
+ }
391
+
373
392
  /**
374
393
  * Evaluates whether an individual output item represents an undeclared tool call.
375
394
  *
@@ -703,7 +722,6 @@ function failedBlocks(name: string, newline: string): readonly string[] {
703
722
  * @param providerExecutedCallTypes - Call types executed by the provider.
704
723
  * @param declaredBare - Explicitly declared bare tool names without namespace provenance.
705
724
  * @param declaredCustom - Current bare custom declarations eligible for code-mode recovery.
706
- * @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false.
707
725
  * @returns An SSE block rewrite function.
708
726
  */
709
727
  export function createUndeclaredToolCallGuardBlockRewrite(
@@ -712,8 +730,11 @@ export function createUndeclaredToolCallGuardBlockRewrite(
712
730
  providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
713
731
  declaredBare?: ReadonlySet<string>,
714
732
  declaredCustom?: ReadonlySet<string>,
715
- enforceDeclaredToolNames = true,
733
+ options?: { refuseUndeclared?: boolean },
716
734
  ): SseBlockRewrite {
735
+ // Deferred-catalog wires (Chat/Anthropic) keep the default-namespace normalization — the
736
+ // client can only resolve the bare name it declared — while the refusal itself stays off.
737
+ const refuse = options?.refuseUndeclared !== false;
717
738
  let tripped = false;
718
739
  return (block: string) => {
719
740
  if (tripped) return [];
@@ -725,12 +746,12 @@ export function createUndeclaredToolCallGuardBlockRewrite(
725
746
  } catch {
726
747
  return [block];
727
748
  }
728
- const name = enforceDeclaredToolNames
729
- ? undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom)
730
- : undefined;
731
- if (name !== undefined) {
732
- tripped = true;
733
- return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
749
+ if (refuse) {
750
+ const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom);
751
+ if (name !== undefined) {
752
+ tripped = true;
753
+ return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
754
+ }
734
755
  }
735
756
  const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare);
736
757
  if (normalized.changed) {
@@ -3,6 +3,8 @@ import { execFile } from "node:child_process";
3
3
  import { join } from "node:path";
4
4
  import { durableBunPath } from "../lib/bun-runtime";
5
5
  import { selfLaunchArgv } from "../lib/self-launch-argv";
6
+ import { isDesktopSupervised } from "../lib/system-restart-contract";
7
+ import { withLoginShellPath } from "./login-shell-path";
6
8
  import {
7
9
  WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER,
8
10
  isWindowsSchtasksCreateAccessDenied,
@@ -66,6 +68,8 @@ interface StartupInstallLaunch {
66
68
  execFile?: typeof execFile;
67
69
  /** Overrides Bun's own standalone detection, which a source-mode test cannot flip. */
68
70
  isStandaloneExecutable?: boolean;
71
+ isDesktopSupervised?: () => boolean;
72
+ withLoginShellPath?: (env: NodeJS.ProcessEnv) => Promise<NodeJS.ProcessEnv>;
69
73
  }
70
74
  let installLaunch: StartupInstallLaunch = {};
71
75
 
@@ -169,7 +173,16 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
169
173
  return classifyCliInstallFailure(stdout, stderr, error).detail;
170
174
  }
171
175
 
172
- function runCliInstall(
176
+ /**
177
+ * Under OpenCodex Desktop the proxy has the GUI launch PATH, where no user-installed Codex is
178
+ * visible. The shim installer gets the user's login-shell PATH instead (#6971).
179
+ */
180
+ async function installChildEnv(action: StartupInstallAction): Promise<NodeJS.ProcessEnv> {
181
+ if (action !== "install-shim" || !(installLaunch.isDesktopSupervised ?? isDesktopSupervised)()) return process.env;
182
+ return (installLaunch.withLoginShellPath ?? withLoginShellPath)(process.env);
183
+ }
184
+
185
+ async function runCliInstall(
173
186
  action: StartupInstallAction,
174
187
  options?: { repair?: boolean },
175
188
  ): Promise<{ stdout: string; stderr: string }> {
@@ -183,13 +196,14 @@ function runCliInstall(
183
196
  isStandaloneExecutable: installLaunch.isStandaloneExecutable,
184
197
  });
185
198
  const run = installLaunch.execFile ?? execFile;
199
+ const env = await installChildEnv(action);
186
200
  return new Promise((resolve, reject) => {
187
201
  const timeout = process.platform === "win32" && action === "install-service"
188
202
  ? 0
189
203
  : 60_000;
190
204
  run(bun, argv, {
191
205
  encoding: "utf8",
192
- env: process.env,
206
+ env,
193
207
  // A fresh Windows scheduler install now owns its UAC prompt inside this CLI
194
208
  // transaction. Killing only the CLI at 60s can orphan its elevated schtasks child,
195
209
  // which may register the task after the Dashboard has reported failure. Keep the
@@ -117,6 +117,7 @@ function conservativeFallback(config: Pick<OcxConfig, "codexAutoStart">): Startu
117
117
  serviceSupported: process.platform === "win32" || process.platform === "darwin" || process.platform === "linux",
118
118
  shimInstalled: shim.installed,
119
119
  shimHealthy: shim.healthy,
120
+ ...(shim.activationPending ? { shimActivationPending: true } : {}),
120
121
  platform: process.platform,
121
122
  diagnosticStale: true,
122
123
  });
@@ -122,11 +122,25 @@ function macLoginRegistration(deps: DesktopStartupDeps) {
122
122
  }
123
123
  const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
124
124
  const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
125
+ // macOS 13+ Background Task Management can take over the user LaunchAgent and resubmit it
126
+ // through smd; launchctl then names the submitter instead of the plist (#7019). The loaded
127
+ // job no longer points at the registration, so credit it like an unloaded job: same program,
128
+ // and the entire registration re-read unchanged.
129
+ if (program !== undefined && loadedPath !== undefined && SMD_SUBMITTED.test(loadedPath)
130
+ && /^\s*managed_by = (.+)$/m.exec(loaded)?.[1] === SERVICE_MANAGEMENT && realpathSync(program) === app) {
131
+ const final = capture();
132
+ if (!final || JSON.stringify(first) !== JSON.stringify(final)) return { app, proxy, loginEnabled: false };
133
+ return { app, proxy, loginEnabled: true, loginRegistration: "loaded" as const };
134
+ }
125
135
  const loginEnabled = program !== undefined && realpathSync(program) === app
126
136
  && loadedPath !== undefined && realpathSync(loadedPath) === first.registrationPath;
127
137
  return { app, proxy, loginEnabled, ...(loginEnabled ? { loginRegistration: "loaded" as const } : {}) };
128
138
  }
129
139
 
140
+ /** launchctl's `path` for a job Background Task Management resubmitted through smd. */
141
+ const SMD_SUBMITTED = /^\(submitted by smd\.\d+\)$/;
142
+ const SERVICE_MANAGEMENT = "com.apple.xpc.ServiceManagement";
143
+
130
144
  type DesktopLoginFacts = Pick<DesktopStartupDiagnostic, "loginEnabled" | "loginRegistration">;
131
145
 
132
146
  /**
@@ -38,7 +38,7 @@ import {
38
38
  type HealthProbeOutcome,
39
39
  type HealthSentinelTiming,
40
40
  } from "./health-sentinel-policy";
41
- import { probeHealth, type HealthProbeTarget } from "./health-sentinel-probe";
41
+ import { probeHealth, probeUrlHost, type HealthProbeTarget } from "./health-sentinel-probe";
42
42
 
43
43
  export const HEALTH_SENTINEL_ENV = {
44
44
  targetPid: "OCX_HEALTH_SENTINEL_TARGET_PID",
@@ -143,6 +143,11 @@ type Attempt =
143
143
  | { kind: "gone" }
144
144
  | { kind: "intent"; entry: RecoveryEntry };
145
145
 
146
+ function matchesProbeTarget(runtime: SentinelRuntimeRecord | null, pid: number, target: HealthProbeTarget): boolean {
147
+ return runtime !== null && runtime.pid === pid && !runtime.sibling
148
+ && runtime.port === target.port && probeUrlHost(runtime.hostname) === target.hostname;
149
+ }
150
+
146
151
  export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
147
152
  const timing: HealthSentinelTiming = { ...DEFAULT_HEALTH_SENTINEL_TIMING, ...deps.timing };
148
153
  const standDown = (reason: string): HealthSentinelResult => {
@@ -162,12 +167,18 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
162
167
  recoveryHistory: history,
163
168
  timing,
164
169
  });
170
+ let pinnedTarget: HealthProbeTarget | null = null;
165
171
  for (;;) {
166
172
  if (deps.isTargetGone()) return { outcome: "target_exited" };
167
173
  const runtime = deps.readRuntime();
168
174
  if (!runtime || runtime.pid !== deps.targetPid) return standDown("the runtime record no longer names the watched process");
169
175
  if (runtime.sibling) return standDown("the watched process is a sibling proxy");
170
- const outcome = await raceGone(deps.probe({ hostname: runtime.hostname ?? "", port: runtime.port }, timing.probeTimeoutMs), exitSignal);
176
+ const hostname = probeUrlHost(runtime.hostname);
177
+ if (hostname === null) return standDown("the recorded bind address is not numeric; DNS names cannot authorize recovery");
178
+ const target: HealthProbeTarget = pinnedTarget ?? { hostname, port: runtime.port };
179
+ if (!matchesProbeTarget(runtime, deps.targetPid, target)) return standDown("the recorded probe endpoint changed");
180
+ pinnedTarget = target;
181
+ const outcome = await raceGone(deps.probe(target, timing.probeTimeoutMs), exitSignal);
171
182
  if (outcome === GONE) return { outcome: "target_exited" };
172
183
  const wasArmed = policy.state().armed;
173
184
  const decision = policy.observe(outcome);
@@ -178,7 +189,7 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
178
189
  // Everything that could have changed during the probe is re-read under the lease.
179
190
  if (deps.isTargetGone()) return { kind: "gone" };
180
191
  const current = deps.readRuntime();
181
- if (!current || current.pid !== deps.targetPid || current.port !== runtime.port || current.sibling) {
192
+ if (!matchesProbeTarget(current, deps.targetPid, target)) {
182
193
  return { kind: "stand_down", reason: "the runtime record changed while the target was unresponsive" };
183
194
  }
184
195
  // Persist first: an attempted recovery must count toward the cap, and on Windows this
@@ -202,7 +213,10 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
202
213
  const { entry } = attempt;
203
214
  // The lease, history and log work above takes time. Re-check that the evidence is still
204
215
  // fresh right before the irreversible step: a proxy that recovered meanwhile must not be
205
- // killed on old observations. A clock that ran backwards makes the evidence stale too.
216
+ // killed on old observations. A clock that ran backwards makes the evidence stale too. The
217
+ // final runtime-record read is included before the freshness check so time spent on it
218
+ // cannot authorize termination on stale observations.
219
+ const finalRuntime = deps.readRuntime();
206
220
  const evidenceAge = deps.now() - outcome.endedAt;
207
221
  if (evidenceAge < 0 || evidenceAge > timing.maxEvidenceAgeMs || deps.isTargetGone()) {
208
222
  settle({ ...entry, state: "skipped" });
@@ -211,6 +225,10 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
211
225
  if (await raceGone(deps.sleep(policy.nextDelayMs()), exitSignal) === GONE) return { outcome: "target_exited" };
212
226
  continue;
213
227
  }
228
+ if (!matchesProbeTarget(finalRuntime, deps.targetPid, target)) {
229
+ settle({ ...entry, state: "skipped" });
230
+ return standDown("the recorded probe endpoint changed before termination");
231
+ }
214
232
  const result = deps.terminateTarget();
215
233
  if (result === "not_target") {
216
234
  settle({ ...entry, state: "skipped" });
@@ -329,6 +347,7 @@ export function readSentinelRuntime(path: string): SentinelRuntimeRecord | null
329
347
  try {
330
348
  const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
331
349
  const { pid, port, hostname } = parsed;
350
+ if (hostname !== undefined && typeof hostname !== "string") return null;
332
351
  if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
333
352
  if (typeof port !== "number" || !Number.isSafeInteger(port) || port <= 0 || port > 65_535) return null;
334
353
  return { pid, port, ...(typeof hostname === "string" ? { hostname } : {}), sibling: parsed.siblingOfPort !== undefined };
@@ -7,6 +7,9 @@
7
7
  * response framing and an absolute deadline. Only the timing decides liveness; the body's `pid`
8
8
  * lets the policy tell the target from another process that took the port.
9
9
  */
10
+ import { isIP } from "node:net";
11
+
12
+ import { isWildcardHostname } from "../codex/loopback-target";
10
13
  import { directLocalHttpFetch } from "../server/direct-local-http";
11
14
  import type { HealthProbeOutcome } from "./health-sentinel-policy";
12
15
 
@@ -17,12 +20,24 @@ export type HealthProbeIo = {
17
20
  fetch?: (url: string, timeoutMs: number) => Promise<Response>;
18
21
  };
19
22
 
20
- /** The URL host to dial for a recorded listen hostname: wildcard binds answer on loopback. */
21
- export function probeUrlHost(hostname: string | undefined): string {
22
- const value = (hostname ?? "").trim().replace(/^\[|\]$/g, "");
23
- if (!value || value === "0.0.0.0" || value === "localhost") return "127.0.0.1";
24
- if (value === "::") return "[::1]";
25
- return value.includes(":") ? `[${value}]` : value;
23
+ /**
24
+ * Numeric probe endpoint only; unknown DNS destinations cannot authorize recovery. Wildcard
25
+ * spellings use the server's own predicate (`*`, `0`, `00.0.0.000`, `::0`, ...), so every bind
26
+ * the server accepts as a wildcard is probed on loopback instead of standing the sentinel down.
27
+ */
28
+ export function probeUrlHost(hostname: string | undefined): string | null {
29
+ let value = (hostname ?? "").trim();
30
+ if (isWildcardHostname(value)) return value.includes(":") ? "[::1]" : "127.0.0.1";
31
+ if (value.startsWith("[") || value.endsWith("]")) {
32
+ if (!value.startsWith("[") || !value.endsWith("]")) return null;
33
+ value = value.slice(1, -1);
34
+ if (isIP(value) !== 6) return null;
35
+ }
36
+ if (!value || value === "0.0.0.0" || /^localhost\.?$/i.test(value)) return "127.0.0.1";
37
+ if (isIP(value) === 4) return value;
38
+ if (isIP(value) !== 6 || value.includes("%")) return null;
39
+ const host = new URL(`http://[${value}]/`).hostname;
40
+ return host === "[::]" ? "[::1]" : host;
26
41
  }
27
42
 
28
43
  /** `pid` from a `/healthz` JSON body, or null when the body does not carry one. */
@@ -39,8 +54,10 @@ export async function probeHealth(target: HealthProbeTarget, timeoutMs: number,
39
54
  const now = io.now ?? Date.now;
40
55
  const fetchFn = io.fetch ?? ((url: string, deadlineMs: number) => directLocalHttpFetch(url, { headers: { accept: "application/json" } }, { timeoutMs: deadlineMs }));
41
56
  const startedAt = now();
57
+ const hostname = probeUrlHost(target.hostname);
58
+ if (hostname === null) return { kind: "error", startedAt, endedAt: now() };
42
59
  try {
43
- const response = await fetchFn(`http://${probeUrlHost(target.hostname)}:${target.port}/healthz`, timeoutMs);
60
+ const response = await fetchFn(`http://${hostname}:${target.port}/healthz`, timeoutMs);
44
61
  // The framed body is already in memory; reading it cannot wait on the target.
45
62
  const body = await response.text().catch(() => "");
46
63
  return { kind: "answered", pid: healthzPid(body), startedAt, endedAt: now() };
@@ -0,0 +1,72 @@
1
+ import { closeSync, fstatSync, ftruncateSync, openSync, readSync, renameSync, writeSync } from "node:fs";
2
+ import { formatWithOptions } from "node:util";
3
+ import { dirname } from "node:path";
4
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
5
+
6
+ export const SERVICE_LOG_MAX_BYTES = 20 * 1024 * 1024;
7
+ export const SERVICE_LOG_CHECK_INTERVAL_MS = 60_000;
8
+
9
+ /** Preserve the manager's open append descriptor; retain at most two bounded archives. */
10
+ export function rotateServiceLog(path: string, maxBytes = SERVICE_LOG_MAX_BYTES): boolean {
11
+ let source: number | undefined;
12
+ let archive: number | undefined;
13
+ try {
14
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return false;
15
+ source = openSync(path, "r+");
16
+ const size = fstatSync(source).size;
17
+ if (size <= maxBytes) return false;
18
+ try { renameSync(`${path}.1`, `${path}.2`); } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
20
+ }
21
+ archive = openSync(`${path}.1`, "w", 0o600);
22
+ // A legacy unbounded file may be huge. Copy only its newest cap-sized tail,
23
+ // using fixed memory, before truncating the original inode.
24
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes));
25
+ let position = size - maxBytes;
26
+ while (position < size) {
27
+ const read = readSync(source, buffer, 0, Math.min(buffer.length, size - position), position);
28
+ if (read === 0) return false;
29
+ let written = 0;
30
+ while (written < read) {
31
+ const count = writeSync(archive, buffer, written, read - written);
32
+ if (count === 0) return false;
33
+ written += count;
34
+ }
35
+ position += read;
36
+ }
37
+ ftruncateSync(source, 0);
38
+ return true;
39
+ } catch {
40
+ return false; // Missing files, permissions and disk failures must never stop the service.
41
+ } finally {
42
+ for (const fd of [archive, source]) {
43
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best-effort */ }
44
+ }
45
+ }
46
+ }
47
+
48
+ /** One sink wrapper covers startup, provider diagnostics and multiline errors. */
49
+ export function startServiceLogging(
50
+ path: string,
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ intervalMs = SERVICE_LOG_CHECK_INTERVAL_MS,
53
+ ): () => void {
54
+ if (!env.OCX_SERVICE) return () => {};
55
+ const restore: (() => void)[] = [];
56
+ for (const method of ["log", "info", "debug", "warn", "error"] as const) {
57
+ const original = console[method];
58
+ console[method] = (...args: unknown[]) => {
59
+ const at = new Date().toISOString();
60
+ const line = formatWithOptions({ colors: false }, ...args);
61
+ original.call(console, line.split("\n").map(part => `${at} ${part}`).join("\n"));
62
+ };
63
+ restore.push(() => { console[method] = original; });
64
+ }
65
+ try {
66
+ for (const suffix of [".1", ".2"]) recordOwnedConfigPath(dirname(path), `${path}${suffix}`);
67
+ } catch { /* ownership bookkeeping must not break logging */ }
68
+ rotateServiceLog(path);
69
+ const timer = setInterval(() => { rotateServiceLog(path); }, intervalMs);
70
+ timer.unref();
71
+ return () => { clearInterval(timer); for (const reset of restore) reset(); };
72
+ }
@@ -201,14 +201,10 @@ function Start-StartupHealthProbe {
201
201
  if ($null -ne $script:startupProbeProcess) {
202
202
  # Process.Start succeeded but the async pipe setup failed: Dispose alone
203
203
  # would leave the Bun child running, so terminate it before cleanup.
204
- try {
205
- $script:startupProbeProcess.Kill()
206
- [void]$script:startupProbeProcess.WaitForExit(3000)
207
- } catch {
208
- Write-ActionLog "startup-health probe launch cleanup failed: $($_.Exception.GetType().Name)"
209
- }
204
+ Stop-StartupHealthProbe
205
+ } else {
206
+ Complete-StartupHealthProbe
210
207
  }
211
- Complete-StartupHealthProbe
212
208
  }
213
209
  }
214
210
 
@@ -217,8 +213,11 @@ function Complete-StartupHealthProbe {
217
213
  $script:startupProbeProcess = $null
218
214
  $script:startupProbeOutputTask = $null
219
215
  $script:startupProbeErrorTask = $null
216
+ $script:startupProbeTerminating = $false
220
217
  if ($null -ne $process) {
221
218
  try {
219
+ $process.StandardOutput.Dispose()
220
+ $process.StandardError.Dispose()
222
221
  $process.Dispose()
223
222
  } catch {
224
223
  Write-ActionLog "startup-health probe dispose failed: $($_.Exception.GetType().Name)"
@@ -226,6 +225,31 @@ function Complete-StartupHealthProbe {
226
225
  }
227
226
  }
228
227
 
228
+ function Stop-StartupHealthProbe([switch]$Shutdown) {
229
+ if ($null -eq $script:startupProbeProcess) { return }
230
+ $script:startupProbeTerminating = $true
231
+ try {
232
+ if (-not $script:startupProbeProcess.HasExited) { $script:startupProbeProcess.Kill() }
233
+ } catch { Write-ActionLog "startup-health probe kill failed: $($_.Exception.GetType().Name)" }
234
+ if ($Shutdown) {
235
+ # Shutdown alone may wait. Timeout ticks keep the child tracked until actual exit.
236
+ try { [void]$script:startupProbeProcess.WaitForExit(500) } catch { $null = $_ }
237
+ $exited = $false
238
+ try { $exited = $script:startupProbeProcess.HasExited } catch { $null = $_ }
239
+ if ($exited) {
240
+ # Exit does not mean both pipe readers finished; give them the same bounded wait.
241
+ $readers = @($script:startupProbeOutputTask, $script:startupProbeErrorTask) | Where-Object { $null -ne $_ }
242
+ if ($readers.Count -gt 0) {
243
+ try { [void][System.Threading.Tasks.Task]::WaitAll([System.Threading.Tasks.Task[]]$readers, 500) } catch { $null = $_ }
244
+ }
245
+ Complete-StartupHealthProbe
246
+ } else {
247
+ # Releasing the only reference would orphan a live child; keep it tracked.
248
+ Write-ActionLog "startup-health probe still running at shutdown; leaving it tracked"
249
+ }
250
+ }
251
+ }
252
+
229
253
  function Initialize-UpdateBadgeReader {
230
254
  if (([System.Management.Automation.PSTypeName]"TrayUpdateBadgeReader").Type) { return }
231
255
  Add-Type -TypeDefinition @'
@@ -433,6 +457,7 @@ $script:startupRefreshMs = 20000L
433
457
  $script:startupProbeProcess = $null
434
458
  $script:startupProbeOutputTask = $null
435
459
  $script:startupProbeErrorTask = $null
460
+ $script:startupProbeTerminating = $false
436
461
  $script:startupProbeStarted = 0L
437
462
  $script:startupProbeTimeoutMs = 30000L
438
463
  $script:updateAvailable = $false
@@ -520,10 +545,16 @@ function Update-TrayState {
520
545
  try {
521
546
  $probeExited = $script:startupProbeProcess.HasExited
522
547
  } catch {
523
- $probeExited = $true
548
+ # Failed inspection is not proof of exit; keep ownership and request termination.
549
+ Stop-StartupHealthProbe
524
550
  }
525
551
  $probeTimedOut = ($now - $script:startupProbeStarted) -gt $script:startupProbeTimeoutMs
526
- if ($probeExited -and $null -ne $script:startupProbeOutputTask -and $script:startupProbeOutputTask.IsCompleted) {
552
+ $probeDrained = ($null -eq $script:startupProbeOutputTask -or $script:startupProbeOutputTask.IsCompleted) -and
553
+ ($null -eq $script:startupProbeErrorTask -or $script:startupProbeErrorTask.IsCompleted)
554
+ if ($script:startupProbeTerminating) {
555
+ if ($probeExited -and $probeDrained) { Complete-StartupHealthProbe }
556
+ elseif (-not $probeExited) { Stop-StartupHealthProbe }
557
+ } elseif ($probeExited -and $probeDrained -and $null -ne $script:startupProbeOutputTask) {
527
558
  $probeStartup = $null
528
559
  try {
529
560
  $probeStartup = Parse-StartupHealthText $script:startupProbeOutputTask.Result
@@ -537,14 +568,8 @@ function Update-TrayState {
537
568
  # A hung diagnostic must not survive its refresh slot: terminate it so the
538
569
  # next refresh cannot stack another orphaned Bun process behind it.
539
570
  Write-ActionLog "startup-health probe timed out; terminating it"
540
- try {
541
- $script:startupProbeProcess.Kill()
542
- [void]$script:startupProbeProcess.WaitForExit(3000)
543
- } catch {
544
- Write-ActionLog "startup-health probe kill failed: $($_.Exception.GetType().Name)"
545
- }
571
+ Stop-StartupHealthProbe
546
572
  $script:startupHealth = $null
547
- Complete-StartupHealthProbe
548
573
  $script:startupHealthCheckedAt = $now
549
574
  }
550
575
  }
@@ -710,14 +735,8 @@ try {
710
735
  # A probe still running when the tray shuts down would outlive it: Dispose()
711
736
  # releases the handle but does not terminate the child, so kill the active
712
737
  # probe, wait briefly for exit, then release the probe state.
713
- try {
714
- Write-ActionLog "terminating active startup-health probe on tray shutdown"
715
- $script:startupProbeProcess.Kill()
716
- [void]$script:startupProbeProcess.WaitForExit(3000)
717
- } catch {
718
- Write-ActionLog "startup-health probe shutdown kill failed: $($_.Exception.GetType().Name)"
719
- }
720
- Complete-StartupHealthProbe
738
+ Write-ActionLog "terminating active startup-health probe on tray shutdown"
739
+ Stop-StartupHealthProbe -Shutdown
721
740
  }
722
741
  Stop-UpdateBadgeProbe -Shutdown
723
742
  $notify.Dispose()