@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -48,6 +48,7 @@ import { normalizeLogConversationId } from "../request-log-conversation";
48
48
  import { rememberResponseState } from "../../responses/state";
49
49
  import { trackStreamLifetime } from "../lifecycle";
50
50
  import { isAntigravityRawValidationRefusal } from "./antigravity-validation-refusal";
51
+ import { createSidecarSendBudget } from "./sidecar-send-budget";
51
52
 
52
53
  /** One responsibility of the Responses request pipeline; state owners are explicit. */
53
54
  export async function executeResponsesSidecars(
@@ -78,6 +79,7 @@ export async function executeResponsesSidecars(
78
79
  | "anthropicSessionKey"
79
80
  | "commitResolvedOAuthSelection"
80
81
  | "resolveSelectionAdapter"
82
+ | "resolveCopilotSelection"
81
83
  | "oauthDispatch"
82
84
  | "noteRoutedAttemptSend"
83
85
  | "bindKeyUsageFromBridge"
@@ -90,7 +92,7 @@ export async function executeResponsesSidecars(
90
92
  | "notifyResponseComplete"
91
93
  | "cancelResponseCompletion"
92
94
  >,
93
- sendBudgetState: Pick<ResponsesSendBudget, "reserveCredentialHop" | "refundableAdapterDispatchBudget" | "pendingHopPermit" | "noteAdapterPhysicalSend">,
95
+ sendBudgetState: Pick<ResponsesSendBudget, "reserveCredentialHop" | "refundableAdapterDispatchBudget" | "adapterDispatchBudget" | "noteInitialDispatch" | "noteAdapterPhysicalSend" | "noteAdapterRecoveryWithheld" | "pendingHopPermit">,
94
96
  ) {
95
97
  const { config, options, logCtx } = requestContext;
96
98
  const antigravityPoolActivated = requestState.route.providerName === "google-antigravity"
@@ -102,6 +104,7 @@ export async function executeResponsesSidecars(
102
104
  anthropicSessionKey,
103
105
  commitResolvedOAuthSelection,
104
106
  resolveSelectionAdapter,
107
+ resolveCopilotSelection,
105
108
  oauthDispatch,
106
109
  } = transportState;
107
110
  const {
@@ -114,6 +117,7 @@ export async function executeResponsesSidecars(
114
117
  } = requestState;
115
118
  const { routedCompaction, openAiSidecar } = sidecarState;
116
119
  const { reserveCredentialHop } = sendBudgetState;
120
+ const sidecarBudget = createSidecarSendBudget(options, sendBudgetState, () => logCtx.usageLogInputTokens);
117
121
  const {
118
122
  commitReasoningReplayServingRoute,
119
123
  continuationStateForResponse,
@@ -194,6 +198,7 @@ export async function executeResponsesSidecars(
194
198
  // A single request may rotate once for a verification refusal. Keep this separate from the
195
199
  // broader OAuth failover count: a sidecar turn can also spend that count on rate-limit recovery.
196
200
  let antigravityValidationRotationAttempted = false;
201
+ /** Admit a replacement credential, or leave the loop's original refusal authoritative. */
197
202
  const rotateSidecarProviderOn429 = async (
198
203
  retryAfter: string | null,
199
204
  responseHeaders?: Headers,
@@ -202,7 +207,7 @@ export async function executeResponsesSidecars(
202
207
  ): Promise<{ adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind } | null> => {
203
208
  const antigravityValidationResponse = canRunWebSearch && route.providerName === "google-antigravity"
204
209
  && route.provider.authMode === "oauth" && originalResponse?.status === 403;
205
- if (route.providerName !== "kiro" && !(anthropicInstance && originalResponse?.status === 403)
210
+ if (route.providerName !== "kiro" && !(anthropicInstance && (originalResponse?.status === 403 || originalResponse?.status === 401))
206
211
  && !antigravityValidationResponse && originalResponse && originalResponse.status !== 429) return null;
207
212
  let antigravityVerification = false;
208
213
  if (antigravityValidationResponse) {
@@ -264,7 +269,8 @@ export async function executeResponsesSidecars(
264
269
  return null;
265
270
  }
266
271
  recoveryKind = "oauth-account-403";
267
- sendBudgetState.pendingHopPermit = hop.permit;
272
+ if (!options.comboInitialSend) sendBudgetState.pendingHopPermit = hop.permit;
273
+ else sidecarBudget.ownCredentialHop(hop.permit);
268
274
  }
269
275
  const rotated = !originalResponse || originalResponse.status === 429
270
276
  ? rotateProviderTransportOn429(config, route.providerName, route.provider, {
@@ -277,6 +283,8 @@ export async function executeResponsesSidecars(
277
283
  // `applyFailoverSnapshot` has already rebound the route to the sibling identity/project.
278
284
  } else if (rotated) {
279
285
  route.provider = rotated;
286
+ try { await resolveCopilotSelection(retryParsed ?? parsed); }
287
+ catch { return null; } // Keep the sidecar's original refusal when replacement negotiation fails.
280
288
  } else if (
281
289
  // A POSITIVE gate, not an early return. An early `return null` here made every later arm
282
290
  // unreachable: Anthropic never has a genericFailoverAccountId (isGenericFailoverProvider
@@ -323,8 +331,9 @@ export async function executeResponsesSidecars(
323
331
  return null;
324
332
  }
325
333
  recoveryKind = "oauth-account-429";
326
- if (canRunWebSearch && route.providerName === "google-antigravity") sendBudgetState.pendingHopPermit = hop.permit;
327
- else hop.permit?.use();
334
+ if (!options.comboInitialSend && canRunWebSearch && route.providerName === "google-antigravity")
335
+ sendBudgetState.pendingHopPermit = hop.permit;
336
+ else sidecarBudget.ownCredentialHop(hop.permit);
328
337
  } else if (
329
338
  // Anthropic's pool is excluded from generic failover, so without this arm a 429 inside a
330
339
  // web-search or image-bridge turn was terminal even with the pool fully enabled -- while
@@ -364,8 +373,8 @@ export async function executeResponsesSidecars(
364
373
  hop.permit?.release();
365
374
  return null;
366
375
  }
367
- recoveryKind = "anthropic-oauth-429";
368
- hop.permit?.use();
376
+ recoveryKind = originalResponse?.status === 401 ? "oauth-401" : "anthropic-oauth-429";
377
+ sidecarBudget.ownCredentialHop(hop.permit);
369
378
  } else {
370
379
  // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement
371
380
  // credential. The 429 is terminal for this sidecar turn.
@@ -451,11 +460,14 @@ export async function executeResponsesSidecars(
451
460
  const imageProviderFetch = providerFetch(
452
461
  route.provider,
453
462
  options.codexWsRuntimeIdentity,
454
- { providerName: route.providerName, modelId: route.modelId },
463
+ { providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(transportState.adapter) },
455
464
  );
456
465
  const imgResponse = await runWithImageBridge({
457
466
  parsed, adapter: transportState.adapter,
458
- incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget },
467
+ incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget, ...(options.comboInitialSend ? { providerFetch: imageProviderFetch } : {}), ...sidecarBudget.incomingMeta },
468
+ onProducerStart: sidecarBudget.takeProducerOwnership,
469
+ onProducerEnd: sidecarBudget.release,
470
+ onIterationEnd: sidecarBudget.releaseUnsentHop,
459
471
  ...(imgPlan ? { plan: imgPlan } : {}),
460
472
  ...(vidPlan ? { videoPlan: vidPlan } : {}),
461
473
  forwardHeaders: requestState.selectedForwardHeaders,
@@ -471,10 +483,10 @@ export async function executeResponsesSidecars(
471
483
  stallTimeoutSec: config.stallTimeoutSec,
472
484
  waitForRequestSlot: imageProviderFetch.waitForPacing,
473
485
  fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch,
474
- fetchForRequest: (request, iterParsed) => {
486
+ fetchForRequest: (request, iterParsed, adapter) => {
475
487
  const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, {
476
488
  dispatchOverride: oauthDispatch(request, iterParsed),
477
- providerName: route.providerName, modelId: route.modelId,
489
+ providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter),
478
490
  });
479
491
  return fetch.unpacedFetch ?? fetch;
480
492
  },
@@ -541,12 +553,14 @@ export async function executeResponsesSidecars(
541
553
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
542
554
  providerName: route.providerName,
543
555
  modelId: route.modelId,
556
+ ...sidecarBudget.fetchOptions(transportState.adapter),
544
557
  })(input, init)) as typeof globalThis.fetch;
545
558
  const wsResponse = await runWithWebSearch({
546
559
  parsed, adapter: transportState.adapter,
547
- fetchForRequest: (request, iterParsed) => providerFetch(route.provider, options.codexWsRuntimeIdentity, {
560
+ onIterationEnd: sidecarBudget.releaseUnsentHop,
561
+ fetchForRequest: (request, iterParsed, adapter) => providerFetch(route.provider, options.codexWsRuntimeIdentity, {
548
562
  dispatchOverride: oauthDispatch(request, iterParsed),
549
- providerName: route.providerName, modelId: route.modelId,
563
+ providerName: route.providerName, modelId: route.modelId, ...sidecarBudget.fetchOptions(adapter),
550
564
  }),
551
565
  incomingMeta: {
552
566
  headers: requestState.selectedForwardHeaders,
@@ -554,11 +568,11 @@ export async function executeResponsesSidecars(
554
568
  abortSignal: options.abortSignal,
555
569
  translatorBudget,
556
570
  providerFetch: routedProviderFetch,
557
- ...(route.providerName === "google-antigravity" ? {
571
+ ...(!options.comboInitialSend && route.providerName === "google-antigravity" ? {
558
572
  sendBudget: sendBudgetState.refundableAdapterDispatchBudget,
559
573
  onPhysicalSend: (send: { ordinal: number; recovery?: AttemptRecoveryKind }) =>
560
574
  sendBudgetState.noteAdapterPhysicalSend(logCtx.usageLogInputTokens, send),
561
- } : {}),
575
+ } : sidecarBudget.incomingMeta),
562
576
  },
563
577
  backend: wsPlan.backend,
564
578
  forwardProvider: wsPlan.forwardSidecar?.provider,
@@ -591,7 +605,7 @@ export async function executeResponsesSidecars(
591
605
  stallTimeoutSec: wsPlan.stallTimeoutSec,
592
606
  streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
593
607
  on429: rotateSidecarProviderOn429,
594
- ...(route.providerName === "google-antigravity" ? { onIterationEnd: () => {
608
+ ...(!options.comboInitialSend && route.providerName === "google-antigravity" ? { onIterationEnd: () => {
595
609
  // The next Antigravity adapter dispatch claims this permit. If request construction,
596
610
  // admission or cancellation stopped it first, return the unused send allowance.
597
611
  const permit = sendBudgetState.pendingHopPermit;
@@ -0,0 +1,58 @@
1
+ import type { IncomingMeta, ProviderAdapter } from "../../adapters/base";
2
+ import type { HandleResponsesOptions } from "./core-options";
3
+ import type { ResponsesSendBudget } from "./request-send-budget";
4
+ import type { ProviderFetchOptions } from "./fetch-helpers";
5
+ import type { SingleUseDispatchPermit } from "../../lib/request-execution-budget";
6
+
7
+ /** Hosted inference consumes the same child-owned booking as ordinary dispatch. */
8
+ export function createSidecarSendBudget(
9
+ options: HandleResponsesOptions,
10
+ budget: Pick<ResponsesSendBudget, "adapterDispatchBudget" | "noteInitialDispatch" | "noteAdapterPhysicalSend" | "noteAdapterRecoveryWithheld" | "pendingHopPermit">,
11
+ inputTokens: () => number | undefined,
12
+ ) {
13
+ const initial = options.comboInitialSend;
14
+ let hopPermit: SingleUseDispatchPermit | undefined;
15
+ let producerActive = false;
16
+ /** Detach this owner’s hop before refunding its still-unused reservation. */
17
+ const releaseHop = (): void => {
18
+ const permit = hopPermit;
19
+ hopPermit = undefined;
20
+ if (budget.pendingHopPermit === permit) budget.pendingHopPermit = undefined;
21
+ permit?.release();
22
+ };
23
+ return {
24
+ /** Retain a Combo hop until dispatch; direct requests keep eager settlement. */
25
+ ownCredentialHop(permit?: SingleUseDispatchPermit): void {
26
+ if (!initial) { permit?.use(); return; } // Preserve direct callers' reporting contract.
27
+ releaseHop();
28
+ hopPermit = permit;
29
+ budget.pendingHopPermit = permit;
30
+ },
31
+ // Adapter-owned transports settle through the live dispatch view, not an HTTP receipt too.
32
+ incomingMeta: initial ? {
33
+ comboAttempt: options.comboAttempt === true,
34
+ sendBudget: budget.adapterDispatchBudget,
35
+ onPhysicalSend: send => budget.noteAdapterPhysicalSend(inputTokens(), send),
36
+ onRecoveryWithheld: budget.noteAdapterRecoveryWithheld,
37
+ } satisfies Partial<IncomingMeta> : {},
38
+ /** Attach HTTP receipts only when the adapter does not own physical dispatch. */
39
+ fetchOptions(adapter: ProviderAdapter): Pick<ProviderFetchOptions, "onPhysicalDispatch"> {
40
+ return initial && !adapter.fetchResponse && !adapter.runTurn
41
+ ? { onPhysicalDispatch: () => {
42
+ const prepaid = budget.pendingHopPermit;
43
+ budget.pendingHopPermit = undefined;
44
+ budget.noteInitialDispatch(prepaid);
45
+ } } : {};
46
+ },
47
+ /** Transfer cleanup to the asynchronous producer before it can outlive the response. */
48
+ takeProducerOwnership(): void {
49
+ if (initial) { initial.producerOwned = true; producerActive = true; }
50
+ },
51
+ /** Release unused bookings only after the owning producer has settled. */
52
+ release(): void {
53
+ initial?.permit.release(); producerActive = false; releaseHop();
54
+ },
55
+ /** An iteration may finish before runTurn; defer its refund while that producer is active. */
56
+ releaseUnsentHop(): void { if (!producerActive) releaseHop(); },
57
+ };
58
+ }
@@ -0,0 +1,33 @@
1
+ import { formatErrorResponse } from "../../bridge";
2
+ import { SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE } from "../../lib/errors";
3
+ import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
4
+ import { SpendLedgerFileRefusedError } from "../../lib/spend-reservation-ledger";
5
+
6
+ type StorageRefusalLog = Pick<RequestLogContext, "localTerminalReason" | "terminalSource" | "errorCode">;
7
+
8
+ /** Local storage refusal, including retry wrappers: never provider-health evidence. */
9
+ export function mapSpendLedgerStorageError(error: unknown, logCtx?: StorageRefusalLog) {
10
+ const seen = new Set<unknown>();
11
+ while (error instanceof Error && !seen.has(error)) {
12
+ if (error instanceof SpendLedgerFileRefusedError) {
13
+ if (logCtx) {
14
+ markLocalRequestLogRefusal(logCtx, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE);
15
+ logCtx.errorCode = SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE;
16
+ }
17
+ return {
18
+ status: 503,
19
+ errorType: "server_error",
20
+ code: SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE,
21
+ message: error.message,
22
+ } as const;
23
+ }
24
+ seen.add(error);
25
+ error = error.cause;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function spendLedgerStorageErrorResponse(error: unknown, logCtx?: StorageRefusalLog): Response | undefined {
31
+ const refusal = mapSpendLedgerStorageError(error, logCtx);
32
+ return refusal && formatErrorResponse(refusal.status, refusal.code, refusal.message);
33
+ }
@@ -15,6 +15,7 @@ import type { NativeResponseControl } from "./native-response-control";
15
15
  // returned event frames as an SSE byte stream, so every downstream consumer
16
16
  // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
17
17
 
18
+ import { codexWsReuseAcrossTurnsEnabled } from "../../config/codex-ws-reuse-setting";
18
19
  import { compareBunVersions } from "../../lib/bun-stream-caps";
19
20
  import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env";
20
21
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
@@ -26,7 +27,7 @@ import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire";
26
27
  import { isLoopbackUrl, rewriteWebSocketDial } from "../../plugins/upstream-hooks";
27
28
  export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES,
28
29
  MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit,
29
- isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire";
30
+ isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./codex-ws-wire";
30
31
  export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
31
32
 
32
33
  /**
@@ -99,8 +100,10 @@ export function planCodexWsDial(
99
100
  // and is authoritative; a pooled socket's upgrade copy is intentionally ignored. A rewriter
100
101
  // therefore cannot change them here, and the upgrade keeps the values the frame carries.
101
102
  const dialHeaders = { ...rewritten.headers };
103
+ const reuseAcrossTurns = codexWsReuseAcrossTurnsEnabled();
102
104
  for (const name of CODEX_WS_FRAME_HEADERS) {
103
- if (Object.hasOwn(headers, name)) dialHeaders[name] = headers[name]!;
105
+ if (reuseAcrossTurns) delete dialHeaders[name];
106
+ else if (Object.hasOwn(headers, name)) dialHeaders[name] = headers[name]!;
104
107
  else delete dialHeaders[name];
105
108
  }
106
109
  const dial = { ...rewritten, headers: dialHeaders };
@@ -148,7 +151,8 @@ export function shouldUseCodexWsUpstream(
148
151
  }
149
152
  }
150
153
 
151
- export function codexWsUpstreamFetch(
154
+ /** Select the bounded WS lane or HTTP fallback, forwarding receipts at physical dispatch. */
155
+ export async function codexWsUpstreamFetch(
152
156
  url: string,
153
157
  init: RequestInit,
154
158
  sseFallback: typeof globalThis.fetch,
@@ -157,6 +161,7 @@ export function codexWsUpstreamFetch(
157
161
  beforeDispatch?: (headers: Headers) => void,
158
162
  nativeControl?: NativeResponseControl,
159
163
  beforeContinuation?: () => Promise<void>,
164
+ onPhysicalDispatch?: () => void,
160
165
  ): Promise<Response> {
161
166
  const prepared = prepareCodexWsRequest(url, init);
162
167
  if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
@@ -217,8 +222,16 @@ export function codexWsUpstreamFetch(
217
222
  const dial = planCodexWsDial(wsUrl, headers, proxy);
218
223
  if (!dial) return sseFallback(url, init);
219
224
  const identity = control ? null : codexWsReuseIdentity(url, dial.headers, frameText, dial.proxy, dial.url);
220
- session = (identity ? codexWsPool.acquire(identity, dial.url, dial.headers, dial.proxy) : null)
221
- ?? new CodexWsSession(dial.url, dial.headers, false, undefined, dial.proxy);
225
+ const reuseAcrossTurns = identity !== null && codexWsReuseAcrossTurnsEnabled();
226
+ const pooled = identity
227
+ ? reuseAcrossTurns
228
+ ? await codexWsPool.acquireWaiting(identity, dial.url, dial.headers, dial.proxy, signal)
229
+ : codexWsPool.acquire(identity, dial.url, dial.headers, dial.proxy)
230
+ : null;
231
+ if (reuseAcrossTurns && signal?.aborted) {
232
+ return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
233
+ }
234
+ session = pooled ?? new CodexWsSession(dial.url, dial.headers, false, undefined, dial.proxy);
222
235
  if (!session.busy && !session.reserve()) {
223
236
  session.dispose();
224
237
  return sseFallback(url, init);
@@ -229,7 +242,7 @@ export function codexWsUpstreamFetch(
229
242
  return codexWsExchange({
230
243
  session, url, init, prepared, sseFallback, onQuota, beforeDispatch,
231
244
  nativeControl: control,
232
- beforeContinuation,
245
+ beforeContinuation, onPhysicalDispatch,
233
246
  bunVersion: typeof runtime === "string" ? runtime : runtime.version,
234
247
  });
235
248
  }
@@ -88,7 +88,8 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH
88
88
  // Mirror deriveStartupHealth's choice: an already-registered service is refreshed in
89
89
  // place. Hardcoding installService here silently undid that for every stale-cache
90
90
  // read, which is the path the dashboard hits while a probe is revalidating.
91
- recommendedCommand: value.routingKind === "opencodex-local" && value.desktop?.owned
91
+ recommendedAction: value.desktop ? "Reopen OpenCodex and check Start at Login." : value.recommendedAction,
92
+ recommendedCommand: value.routingKind === "opencodex-local" && value.desktop !== undefined
92
93
  ? null
93
94
  : value.routingKind === "custom-local" || value.routingKind === "unknown"
94
95
  ? value.commands.restoreNative
@@ -41,6 +41,8 @@ export interface WorkflowRefusalLog {
41
41
  readonly requestId: string;
42
42
  readonly start: number;
43
43
  readonly logCtx: RequestLogContext;
44
+ /** Receiving HTTP route's notification after this refusal's final-log call. */
45
+ readonly onLogged?: () => void;
44
46
  }
45
47
 
46
48
  /**
@@ -84,6 +86,7 @@ export function workflowRefusalResponse(
84
86
  addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, 429, {
85
87
  closeReason: "terminal",
86
88
  });
89
+ refusalLog.onLogged?.();
87
90
  }
88
91
  const refusal = formatErrorResponse(
89
92
  429,
@@ -26,6 +26,7 @@ import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
26
26
  import { win32 } from "node:path";
27
27
  import { serviceDiagnosticsSummary } from "./diagnostics";
28
28
  import { runServiceClaim } from "./claim";
29
+ import { createSupervisionLatch, desktopServiceCommandRefusal } from "./desktop-command-guard";
29
30
 
30
31
  /**
31
32
  * `restart` is NO LONGER folded into `repair`.
@@ -214,6 +215,7 @@ export function removeServiceTokenAfterUninstall(
214
215
  /** Execute a service verb while preserving client-owned credentials during uninstall. */
215
216
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
216
217
  const filteredArgs = args.filter((a): a is string => Boolean(a));
218
+ const supervisionLatch = createSupervisionLatch();
217
219
  const execute = async (): Promise<void> => {
218
220
  // `claim` is not an install verb: it is deliberately outside planServiceCommand (whose
219
221
  // backend/installation checks do not apply to an ownership write) and outside
@@ -231,6 +233,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
231
233
  process.exit(1);
232
234
  }
233
235
  const { parsed, command } = plan;
236
+ const refusal = desktopServiceCommandRefusal(command, undefined, supervisionLatch);
237
+ if (refusal) {
238
+ console.error(refusal);
239
+ process.exitCode = 1;
240
+ return;
241
+ }
234
242
  if (command === "repair" || command === "restart") {
235
243
  const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
236
244
  assertServiceEnvironmentMatchesInstall();
@@ -249,7 +257,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
249
257
  // existing registration had been restarted, not repaired (#4914).
250
258
  let repairError: unknown;
251
259
  try {
252
- await repairService({ verb });
260
+ await repairService({ verb, supervisionLatch });
253
261
  } catch (error) {
254
262
  repairError = error;
255
263
  process.exitCode = 1;
@@ -295,12 +303,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
295
303
  throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
296
304
  }
297
305
  if (scheduler.status === "absent") {
298
- await installFreshWindowsSchedulerSafely();
306
+ await installFreshWindowsSchedulerSafely({ supervisionLatch });
299
307
  } else {
300
- await installServiceSafely(backend, ops.install);
308
+ await installServiceSafely(backend, ops.install, { supervisionLatch });
301
309
  }
302
310
  } else {
303
- await installServiceSafely(backend, ops.install);
311
+ await installServiceSafely(backend, ops.install, { supervisionLatch });
304
312
  }
305
313
  } catch (error) {
306
314
  console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -474,6 +482,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
474
482
  };
475
483
 
476
484
  const preliminary = parseServiceArgs(filteredArgs);
485
+ if (preliminary.invalid.length === 0) {
486
+ const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
487
+ if (refusal) {
488
+ console.error(refusal);
489
+ process.exitCode = 1;
490
+ return;
491
+ }
492
+ }
477
493
  const windowsMutation = process.platform === "win32"
478
494
  && preliminary.invalid.length === 0
479
495
  && preliminary.sub !== "status";
@@ -0,0 +1,32 @@
1
+ import { createSupervisionLatch, inspectDesktopSupervision } from "./desktop-supervision.mjs";
2
+
3
+ export { createSupervisionLatch };
4
+ export type DesktopSupervision = ReturnType<typeof inspectDesktopSupervision>;
5
+ export type SupervisionInspector = (deps?: Parameters<typeof inspectDesktopSupervision>[0]) => DesktopSupervision;
6
+ export type SupervisionLatch = ReturnType<typeof createSupervisionLatch>;
7
+
8
+ export function desktopServiceRefusal(evidence: DesktopSupervision, blocked: boolean): string | null {
9
+ if (!blocked) return null;
10
+ const runtime = evidence.kind === "desktop" ? ` (pid ${evidence.runtimePid}, ${evidence.app})` : "";
11
+ return `OpenCodex Desktop supervises the running proxy${runtime}. `
12
+ + "Quit OpenCodex, then rerun 'ocx service install' to move startup management to this CLI.";
13
+ }
14
+
15
+ export function assertNoDesktopSupervision(
16
+ inspect: SupervisionInspector = inspectDesktopSupervision,
17
+ latch: SupervisionLatch = createSupervisionLatch(),
18
+ ): void {
19
+ const evidence = inspect();
20
+ const refusal = desktopServiceRefusal(evidence, latch.observe(evidence));
21
+ if (refusal) throw new Error(refusal);
22
+ }
23
+
24
+ export function desktopServiceCommandRefusal(
25
+ command: string,
26
+ inspect: SupervisionInspector = inspectDesktopSupervision,
27
+ latch: SupervisionLatch = createSupervisionLatch(),
28
+ ): string | null {
29
+ if (!["install", "repair", "start", "restart"].includes(command)) return null;
30
+ const evidence = inspect();
31
+ return desktopServiceRefusal(evidence, latch.observe(evidence));
32
+ }
@@ -1,9 +1,10 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
2
+ import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { readPid } from "../config/process-state";
6
6
  import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state";
7
+ import { inspectDesktopSupervision, processIdentity, procfs, type ProcReader } from "./desktop-supervision.mjs";
7
8
 
8
9
  export interface DesktopStartupDiagnostic {
9
10
  /** Durable desktop claim; failed supervision does not release ownership. */
@@ -11,11 +12,13 @@ export interface DesktopStartupDiagnostic {
11
12
  loginEnabled: boolean;
12
13
  running: boolean;
13
14
  viable: boolean;
15
+ /** Live parentage when no durable claim exists; never converts supervision into ownership. */
16
+ supervisor?: { supervisorPid: number; runtimePid: number; app: string };
14
17
  }
15
18
 
16
- /** A login item alone is insufficient: the same app must own and supervise this proxy. */
19
+ /** A login item alone is insufficient: the same app must supervise this proxy. */
17
20
  export function deriveDesktopStartup(facts: Omit<DesktopStartupDiagnostic, "viable">): DesktopStartupDiagnostic {
18
- return { ...facts, viable: facts.owned && facts.loginEnabled && facts.running };
21
+ return { ...facts, viable: (facts.owned || facts.supervisor !== undefined) && facts.loginEnabled && facts.running };
19
22
  }
20
23
 
21
24
  function run(command: string, args: string[]): string {
@@ -28,25 +31,12 @@ interface DesktopStartupDeps {
28
31
  uid?: number;
29
32
  ownership?: () => ServiceOwnershipResolution;
30
33
  readPid?: () => number | null;
34
+ readRuntimePortPid?: () => number | null;
31
35
  run?: typeof run;
32
36
  env?: NodeJS.ProcessEnv;
33
37
  proc?: ProcReader;
34
38
  }
35
39
 
36
- /** Linux process identity from procfs: executable target and parent pid. */
37
- interface ProcReader {
38
- exe(pid: number): string;
39
- parent(pid: number): number;
40
- }
41
- const procfs: ProcReader = {
42
- exe: pid => readlinkSync(`/proc/${pid}/exe`),
43
- // comm may contain spaces or parentheses, so fields are counted after the last ')'.
44
- parent: pid => {
45
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
46
- return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
47
- },
48
- };
49
-
50
40
  /** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */
51
41
  export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
52
42
  const platform = deps.platform ?? process.platform;
@@ -56,9 +46,47 @@ export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopS
56
46
  ? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined;
57
47
  }
58
48
 
59
- function processIdentity(pid: number, execute: typeof run): { parent: number; executable: string } | null {
60
- const row = /^(\d+)\s+(.+)$/.exec(execute("/bin/ps", ["-p", String(pid), "-o", "ppid=,comm="]));
61
- return row ? { parent: Number(row[1]), executable: realpathSync(row[2]!) } : null;
49
+ function macLoginRegistration(deps: DesktopStartupDeps) {
50
+ const execute = deps.run ?? run;
51
+ const path = join(deps.home ?? homedir(), "Library", "LaunchAgents", "OpenCodex.plist");
52
+ const plist = JSON.parse(execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path]));
53
+ const args = plist.ProgramArguments;
54
+ if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args)
55
+ || args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
56
+ || !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
57
+ || (plist.Program !== undefined && plist.Program !== args[0])) return null;
58
+ const app = realpathSync(args[0]);
59
+ const proxy = realpathSync(join(dirname(app), "ocx"));
60
+ accessSync(app, constants.X_OK);
61
+ accessSync(proxy, constants.X_OK);
62
+ const domain = `gui/${deps.uid ?? process.getuid!()}`;
63
+ const disabled = execute("/bin/launchctl", ["print-disabled", domain]);
64
+ const loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]);
65
+ const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
66
+ const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
67
+ const loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled)
68
+ && !/"OpenCodex"\s*=>\s*(?:disabled|true)/.test(disabled)
69
+ && program !== undefined && realpathSync(program) === app
70
+ && loadedPath !== undefined && realpathSync(loadedPath) === realpathSync(path);
71
+ return { app, proxy, loginEnabled };
72
+ }
73
+
74
+ function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: number, loginFor: (app: string) => boolean): DesktopStartupDiagnostic | undefined {
75
+ const evidence = inspectDesktopSupervision(deps);
76
+ if (evidence.kind !== "desktop") return undefined;
77
+ let loginEnabled = false;
78
+ try { loginEnabled = loginFor(evidence.app); }
79
+ catch { /* Login failure revokes restart viability, not the observed live supervisor. */ }
80
+ if ((deps.ownership ?? resolveServiceOwnership)().kind !== "none") return undefined;
81
+ // Login probes can outlive the runtime or its parent; only the same live chain gets credit.
82
+ const final = inspectDesktopSupervision({ ...deps, targetPid: evidence.runtimePid });
83
+ if (final.kind !== "desktop" || final.runtimePid !== evidence.runtimePid
84
+ || final.supervisorPid !== evidence.supervisorPid || final.app !== evidence.app) return undefined;
85
+ // A new claim or revision during the last process probe invalidates the unowned projection.
86
+ const finalOwner = (deps.ownership ?? resolveServiceOwnership)();
87
+ if (finalOwner.kind !== "none" || finalOwner.revision !== ownerRevision) return undefined;
88
+ const { supervisorPid, runtimePid, app } = evidence;
89
+ return deriveDesktopStartup({ owned: false, loginEnabled, running: true, supervisor: { supervisorPid, runtimePid, app } });
62
90
  }
63
91
 
64
92
  /** Read-only macOS desktop ownership, login registration and live parent/child checks. */
@@ -66,6 +94,10 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
66
94
  if ((deps.platform ?? process.platform) !== "darwin") return undefined;
67
95
  const ownership = deps.ownership ?? resolveServiceOwnership;
68
96
  const owner = ownership();
97
+ if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => {
98
+ const login = macLoginRegistration(deps);
99
+ return login !== null && login.app === app && login.loginEnabled;
100
+ });
69
101
  if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
70
102
  const facts = { owned: true, loginEnabled: false, running: false };
71
103
  const execute = deps.run ?? run;
@@ -74,26 +106,10 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
74
106
  const home = deps.home ?? homedir();
75
107
  const id = readFileSync(join(home, "Library", "Application Support", "com.opencodex.desktop", "install-id"), "utf8").trim();
76
108
  if (id !== owner.ownership.installId) return deriveDesktopStartup(facts);
77
- const path = join(home, "Library", "LaunchAgents", "OpenCodex.plist");
78
- const plist = JSON.parse(execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path]));
79
- const args = plist.ProgramArguments;
80
- if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args)
81
- || args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
82
- || !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
83
- || (plist.Program !== undefined && plist.Program !== args[0])) return deriveDesktopStartup(facts);
84
- const app = realpathSync(args[0]);
85
- const proxy = realpathSync(join(dirname(app), "ocx"));
86
- accessSync(app, constants.X_OK);
87
- accessSync(proxy, constants.X_OK);
88
- const domain = `gui/${deps.uid ?? process.getuid!()}`;
89
- const disabled = execute("/bin/launchctl", ["print-disabled", domain]);
90
- const loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]);
91
- const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
92
- const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
93
- facts.loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled)
94
- && !/"OpenCodex"\s*=>\s*(?:disabled|true)/.test(disabled)
95
- && program !== undefined && realpathSync(program) === app
96
- && loadedPath !== undefined && realpathSync(loadedPath) === realpathSync(path);
109
+ const login = macLoginRegistration(deps);
110
+ if (!login) return deriveDesktopStartup(facts);
111
+ const { app, proxy } = login;
112
+ facts.loginEnabled = login.loginEnabled;
97
113
  const pid = pidReader();
98
114
  if (pid !== null) {
99
115
  const child = processIdentity(pid, execute);
@@ -135,6 +151,16 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk
135
151
  if ((deps.platform ?? process.platform) !== "linux") return undefined;
136
152
  const ownership = deps.ownership ?? resolveServiceOwnership;
137
153
  const owner = ownership();
154
+ if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => {
155
+ const home = deps.home ?? homedir();
156
+ const configured = (deps.env ?? process.env).XDG_CONFIG_HOME;
157
+ const config = configured && isAbsolute(configured) ? configured : join(home, ".config");
158
+ // auto-launch's HOME entry must remain inside the session's XDG autostart search path.
159
+ if (resolve(config) !== resolve(home, ".config")) return false;
160
+ const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8");
161
+ const login = linuxLoginApp(entry);
162
+ return login !== null && realpathSync(login) === app;
163
+ });
138
164
  if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
139
165
  const facts = { owned: true, loginEnabled: false, running: false };
140
166
  const proc = deps.proc ?? procfs;
@@ -0,0 +1,26 @@
1
+ export type SupervisionEvidence =
2
+ | { kind: "desktop"; runtimePid: number; supervisorPid: number; app: string; proxy: string }
3
+ | { kind: "none" }
4
+ | { kind: "unknown"; reason: string; desktopSeen: boolean }
5
+ | { kind: "unsupported" };
6
+
7
+ export interface ProcReader {
8
+ exe(pid: number): string;
9
+ parent(pid: number): number;
10
+ }
11
+
12
+ export interface DesktopSupervisionDeps {
13
+ targetPid?: number;
14
+ platform?: NodeJS.Platform;
15
+ run?: (command: string, args: string[]) => string;
16
+ proc?: ProcReader;
17
+ readPid?: () => number | null;
18
+ readRuntimePortPid?: () => number | null;
19
+ }
20
+
21
+ export function desktopSupervisionPaths(): { pid: string; runtimePort: string };
22
+ export const procfs: ProcReader;
23
+ export function processIdentity(pid: number, execute?: DesktopSupervisionDeps["run"], onExecutable?: (path: string) => void):
24
+ { parent: number; executable: string } | null;
25
+ export function inspectDesktopSupervision(deps?: DesktopSupervisionDeps): SupervisionEvidence;
26
+ export function createSupervisionLatch(): { observe(evidence: SupervisionEvidence): boolean };