@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -11,20 +11,30 @@
11
11
  import { formatErrorResponse } from "../bridge";
12
12
  import {
13
13
  CodexAccountCooldownError,
14
+ codexMainProfileDrainingResponse,
14
15
  cooldownErrorResponse,
15
16
  CodexAuthContextError,
17
+ CodexMainProfileDrainingError,
16
18
  CodexPoolAuthenticationError,
17
19
  CodexThreadAffinityExpiredError,
18
20
  } from "../codex/auth-context";
21
+ import { codexAccountNamespaceForModel } from "../codex/account-namespace-match";
19
22
  import { formatCodexProviderForLog } from "../codex/routing";
20
23
  import { signalWithTimeout } from "../lib/abort";
21
24
  import { sidecarEnter } from "../lib/sidecar-tracker";
22
25
  import type { OcxConfig } from "../types";
23
- import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../providers/openai-sidecar";
26
+ import {
27
+ listOpenAiForwardSidecarCandidates,
28
+ resolveFirstUsableOpenAiSidecar,
29
+ type ExactOpenAiSidecarAccount,
30
+ } from "../providers/openai-sidecar";
31
+ import { routeModel } from "../router";
24
32
  import { readJsonRequestBody } from "./request-decompress";
25
33
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
26
34
  import type { RequestLogContext } from "./request-log";
27
35
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
36
+ import type { AdmissionLease } from "../lib/admission";
37
+ import { codexAccountSelectionForTurn } from "./lifecycle";
28
38
 
29
39
  /**
30
40
  * Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response
@@ -40,6 +50,7 @@ export async function handleSearch(
40
50
  req: Request,
41
51
  config: OcxConfig,
42
52
  logCtx: RequestLogContext,
53
+ turnAdmissionLease?: AdmissionLease,
43
54
  ): Promise<Response> {
44
55
  try { validateForwardAdmissionCredential(req.headers, config); }
45
56
  catch (err) {
@@ -55,6 +66,32 @@ export async function handleSearch(
55
66
  const model = (body as { model?: unknown } | null)?.model;
56
67
  if (typeof model === "string" && model) logCtx.model = model;
57
68
 
69
+ let exactAccount: ExactOpenAiSidecarAccount | undefined;
70
+ let relayBody = body;
71
+ const accountNamespace = typeof model === "string"
72
+ ? codexAccountNamespaceForModel(config.codexAccountNamespaces, model)
73
+ : undefined;
74
+ if (accountNamespace && typeof model === "string") {
75
+ try {
76
+ const route = routeModel(config, model);
77
+ if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) {
78
+ return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model");
79
+ }
80
+ exactAccount = { accountId: route.codexAccountId, modelId: route.modelId };
81
+ logCtx.provider = `${route.providerName}-${accountNamespace}`;
82
+ logCtx.routeDecision = route.routeDecision;
83
+ // The ChatGPT search endpoint only understands the native model slug. The
84
+ // account namespace is proxy routing syntax and must not cross the wire.
85
+ relayBody = { ...(body as Record<string, unknown>), model: route.modelId };
86
+ } catch (err) {
87
+ return formatErrorResponse(
88
+ 400,
89
+ "invalid_request_error",
90
+ err instanceof Error ? err.message : "Invalid Codex account-qualified search model",
91
+ );
92
+ }
93
+ }
94
+
58
95
  const candidates = listOpenAiForwardSidecarCandidates(config);
59
96
  if (candidates.length === 0) {
60
97
  return formatErrorResponse(
@@ -67,7 +104,10 @@ export async function handleSearch(
67
104
 
68
105
  let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
69
106
  try {
70
- upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config);
107
+ upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config, {
108
+ exactAccount,
109
+ beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
110
+ });
71
111
  if (!upstream) {
72
112
  return formatErrorResponse(
73
113
  401,
@@ -75,16 +115,21 @@ export async function handleSearch(
75
115
  "web search relay needs ChatGPT auth (Authorization header)",
76
116
  );
77
117
  }
78
- logCtx.provider = formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
118
+ logCtx.provider = accountNamespace
119
+ ? `${upstream.providerName}-${accountNamespace}`
120
+ : formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
79
121
  } catch (err) {
80
122
  if (err instanceof CodexAccountCooldownError) {
81
- return cooldownErrorResponse(err);
123
+ return cooldownErrorResponse(err, Date.now(), accountNamespace);
82
124
  }
125
+ if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse();
83
126
  if (err instanceof CodexThreadAffinityExpiredError) {
84
127
  return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
85
128
  }
86
129
  if (err instanceof CodexAuthContextError) {
87
- const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config);
130
+ const safeAccountLabel = accountNamespace
131
+ ? `openai-${accountNamespace}`
132
+ : formatCodexProviderForLog("openai", err.accountId, config);
88
133
  console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
89
134
  return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
90
135
  }
@@ -103,7 +148,7 @@ export async function handleSearch(
103
148
  const upstreamResponse = await fetch(url, {
104
149
  method: "POST",
105
150
  headers,
106
- body: JSON.stringify(body),
151
+ body: JSON.stringify(relayBody),
107
152
  signal: linkedSignal.signal,
108
153
  });
109
154
  const payload = await upstreamResponse.arrayBuffer();
@@ -9,6 +9,56 @@ import type { TranslatorBudget } from "../lib/translator-budget";
9
9
 
10
10
  export type SsePayloadRewrite = (payload: string) => string;
11
11
 
12
+ /**
13
+ * Block-level SSE rewrite: maps one complete SSE event block (without its
14
+ * blank-line delimiter) to zero or more replacement blocks. This is the
15
+ * contract lifecycle repair needs: injecting missing canonical events
16
+ * (#893) is impossible in the one-payload-in/one-payload-out model.
17
+ *
18
+ * `dispose` releases any retained state (budget-charged collectors) when the
19
+ * relay tears down — terminal, EOF, cancel, or error. Relays call it exactly
20
+ * once per teardown path.
21
+ */
22
+ export type SseBlockRewrite = ((block: string) => readonly string[]) & {
23
+ dispose?: () => void;
24
+ };
25
+
26
+ /** Adapt a payload rewrite to the block contract (replace only on change). */
27
+ export function payloadRewriteAsBlockRewrite(rewrite: SsePayloadRewrite): SseBlockRewrite {
28
+ return (block) => {
29
+ const payload = sseDataPayload(block);
30
+ if (payload === null) return [block];
31
+ const rewritten = rewrite(payload);
32
+ return rewritten !== payload ? [replaceSseDataPayload(block, rewritten)] : [block];
33
+ };
34
+ }
35
+
36
+ /** Chain block rewrites: every block stage N emits feeds stage N+1. */
37
+ export function composeSseBlockRewrites(...rewrites: SseBlockRewrite[]): SseBlockRewrite {
38
+ const active = rewrites.filter(Boolean);
39
+ if (active.length === 0) return Object.assign((block: string) => [block], {});
40
+ const composed: SseBlockRewrite = (block: string) => {
41
+ let blocks: readonly string[] = [block];
42
+ for (const rewrite of active) {
43
+ const next: string[] = [];
44
+ for (const current of blocks) next.push(...rewrite(current));
45
+ blocks = next;
46
+ }
47
+ return blocks;
48
+ };
49
+ // Child disposal is part of the contract: one idempotent disposer for the
50
+ // whole chain, so relay teardown never leaks a nested collector.
51
+ let disposed = false;
52
+ composed.dispose = () => {
53
+ if (disposed) return;
54
+ disposed = true;
55
+ for (const rewrite of active) {
56
+ try { rewrite.dispose?.(); } catch { /* teardown must not throw */ }
57
+ }
58
+ };
59
+ return composed;
60
+ }
61
+
12
62
  /** Split one complete SSE event block while retaining its original blank-line delimiter. */
13
63
  export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
14
64
  const match = buffer.match(/\r?\n\r?\n/);
@@ -69,12 +119,34 @@ export function relaySseWithPayloadRewrite(
69
119
  body: ReadableStream<Uint8Array>,
70
120
  rewrite: SsePayloadRewrite,
71
121
  translatorBudget: TranslatorBudget,
122
+ ): ReadableStream<Uint8Array> {
123
+ return relaySseWithBlockRewrite(body, payloadRewriteAsBlockRewrite(rewrite), translatorBudget);
124
+ }
125
+
126
+ /**
127
+ * Relay an SSE body through a single JS pull wrapper, applying a block-level
128
+ * rewrite that may emit zero or more blocks per upstream event (lifecycle
129
+ * event injection, #893). The original stream's delimiter style is preserved
130
+ * for every emitted block.
131
+ */
132
+ export function relaySseWithBlockRewrite(
133
+ body: ReadableStream<Uint8Array>,
134
+ rewrite: SseBlockRewrite,
135
+ translatorBudget: TranslatorBudget,
72
136
  ): ReadableStream<Uint8Array> {
73
137
  const reader = body.getReader();
74
138
  const decoder = new TextDecoder();
75
139
  const encoder = new TextEncoder();
76
140
  let buffer = "";
77
141
  let bufferBytes = 0;
142
+ // Relays have several independent teardown paths; disposal is exactly once.
143
+ let disposed = false;
144
+ let cancelled = false;
145
+ const disposeRewrite = (): void => {
146
+ if (disposed) return;
147
+ disposed = true;
148
+ try { rewrite.dispose?.(); } catch { /* teardown must not throw */ }
149
+ };
78
150
 
79
151
  const appendBuffer = (fragment: string): void => {
80
152
  if (!fragment) return;
@@ -130,20 +202,18 @@ export function relaySseWithPayloadRewrite(
130
202
  let next: { block: string; delimiter: string; rest: string } | null;
131
203
  while ((next = nextSseBlock(buffer))) {
132
204
  replaceBuffer(next.rest);
133
- const payload = sseDataPayload(next.block);
134
- const rewrittenPayload = payload ? rewrite(payload) : undefined;
135
- const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
136
- ? replaceSseDataPayload(next.block, rewrittenPayload)
137
- : next.block;
138
- enqueueText(controller, block + next.delimiter);
205
+ for (const outBlock of rewrite(next.block)) {
206
+ enqueueText(controller, outBlock + next.delimiter);
207
+ }
139
208
  }
140
209
  if (flushFinal && buffer.length > 0) {
141
- const payload = sseDataPayload(buffer);
142
- const rewrittenPayload = payload ? rewrite(payload) : undefined;
143
- const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
144
- ? replaceSseDataPayload(buffer, rewrittenPayload)
145
- : buffer;
146
- enqueueText(controller, block);
210
+ const tailBlocks = rewrite(buffer);
211
+ // A trailing fragment has no delimiter of its own; multiple emitted
212
+ // blocks must still be framed as separate events (#893 review).
213
+ const tailDelimiter = buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n";
214
+ for (let i = 0; i < tailBlocks.length; i++) {
215
+ enqueueText(controller, tailBlocks[i]! + (i < tailBlocks.length - 1 ? tailDelimiter : ""));
216
+ }
147
217
  releaseBuffer();
148
218
  }
149
219
  };
@@ -152,10 +222,14 @@ export function relaySseWithPayloadRewrite(
152
222
  async pull(controller) {
153
223
  try {
154
224
  const { done, value } = await reader.read();
225
+ // A cancel raced this pending read: never feed the rewriter again
226
+ // after its disposal (#893 review).
227
+ if (cancelled) return;
155
228
  if (done) {
156
229
  appendBuffer(decoder.decode());
157
230
  emitProcessedBlocks(controller, true);
158
231
  releaseBuffer();
232
+ disposeRewrite();
159
233
  controller.close();
160
234
  return;
161
235
  }
@@ -163,12 +237,15 @@ export function relaySseWithPayloadRewrite(
163
237
  emitProcessedBlocks(controller);
164
238
  } catch (error) {
165
239
  releaseBuffer();
240
+ disposeRewrite();
166
241
  try { await reader.cancel(error); } catch { /* already closed */ }
167
242
  controller.error(error);
168
243
  }
169
244
  },
170
245
  cancel(reason) {
246
+ cancelled = true;
171
247
  releaseBuffer();
248
+ disposeRewrite();
172
249
  reader.cancel(reason).catch(() => {});
173
250
  },
174
251
  });
@@ -23,9 +23,14 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH
23
23
  rebootSafe: false,
24
24
  protection: "none",
25
25
  diagnosticStale: true,
26
+ // Mirror deriveStartupHealth's choice: an already-registered service is refreshed in
27
+ // place. Hardcoding installService here silently undid that for every stale-cache
28
+ // read, which is the path the dashboard hits while a probe is revalidating.
26
29
  recommendedCommand: value.routingKind === "custom-local" || value.routingKind === "unknown"
27
30
  ? value.commands.restoreNative
28
- : value.commands.installService,
31
+ : value.serviceInstalled && !value.serviceConflict
32
+ ? value.commands.repairService
33
+ : value.commands.installService,
29
34
  };
30
35
  }
31
36
 
@@ -72,6 +77,7 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
72
77
  : truncateRetainedUtf8(parsed.recommendedCommand, MAX_DIAGNOSTIC_VALUE_BYTES),
73
78
  commands: {
74
79
  installService: truncateRetainedUtf8(parsed.commands.installService, MAX_DIAGNOSTIC_VALUE_BYTES),
80
+ repairService: truncateRetainedUtf8(parsed.commands.repairService, MAX_DIAGNOSTIC_VALUE_BYTES),
75
81
  installShim: truncateRetainedUtf8(parsed.commands.installShim, MAX_DIAGNOSTIC_VALUE_BYTES),
76
82
  restoreNative: truncateRetainedUtf8(parsed.commands.restoreNative, MAX_DIAGNOSTIC_VALUE_BYTES),
77
83
  },
@@ -1,10 +1,11 @@
1
1
  import type { ServerWebSocket } from "bun";
2
+ import { responsesJsonEventSequence } from "./responses-json-events";
2
3
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
4
  import type { CodexAuthContext } from "../codex/auth-context";
4
5
  import { headersForCodexAuthContext } from "../codex/auth-context";
5
6
  import type { ResponsesTerminalStatus } from "../bridge";
6
7
  import type { DataPlaneAdmission } from "./auth-cors";
7
- import type { AdmissionReservation } from "../lib/admission";
8
+ import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
8
9
 
9
10
  const OPEN = 1;
10
11
  type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
@@ -39,6 +40,12 @@ export interface WsData {
39
40
  liveUpstreamHeaders?: Record<string, string>;
40
41
  livePending?: Array<string | Buffer>;
41
42
  liveOpened?: boolean;
43
+ /** Once teardown starts, ignore new client frames until the upstream closes. */
44
+ liveClosing?: boolean;
45
+ /** Schedules one bounded close retry without surrendering native-main ownership. */
46
+ liveCloseFallback?: ReturnType<typeof setTimeout>;
47
+ /** Turn/account ownership retained for the complete sideband socket lifetime. */
48
+ liveTurnAdmissionLease?: AdmissionLease;
42
49
  admissionLease?: AdmissionReservation<ServerWebSocket<WsData>>;
43
50
  }
44
51
 
@@ -301,25 +308,12 @@ export function sendResponsesJsonAsEvents(
301
308
  }
302
309
  sendTextFrame(ws, text);
303
310
  };
304
- const output = Array.isArray(response.output) ? response.output : [];
305
- sendObservedFrame({
306
- type: "response.created",
307
- response: { ...response, status: "in_progress", output: [] },
308
- });
309
- output.forEach((item, outputIndex) => {
310
- sendObservedFrame({
311
- type: "response.output_item.done",
312
- output_index: outputIndex,
313
- item,
314
- });
315
- });
316
311
  const finalStatus = response.status === "failed" || response.status === "incomplete"
317
312
  ? response.status
318
313
  : "completed";
319
- sendObservedFrame({
320
- type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
321
- response: { ...response, status: finalStatus },
322
- });
314
+ for (const frame of responsesJsonEventSequence(response)) {
315
+ sendObservedFrame(frame);
316
+ }
323
317
  onTerminal?.(finalStatus);
324
318
  }
325
319
 
@@ -0,0 +1,297 @@
1
+ /**
2
+ * What the service manager says about this machine, read-only and fail-closed.
3
+ *
4
+ * The question this answers is NOT "is a job loaded". Installation writes the
5
+ * definition BEFORE the state file (`service.ts` install paths) and embeds
6
+ * `CODEX_HOME`/`OPENCODEX_HOME` inside it, so an interrupted reinstall leaves a
7
+ * valid state file for one home beside an installed definition for another. A
8
+ * probe that only asked about registration would call that owned. On macOS it is
9
+ * worse: a logged-out user has the plist on disk with no GUI domain at all, so
10
+ * registration reports nothing while a foreign definition sits right there.
11
+ *
12
+ * So the probe reads the DEFINITION, parses the homes out of it, and reports
13
+ * what it saw. Comparing those homes is the caller's job — a probe that returned
14
+ * a verdict would be deciding ownership from half the evidence.
15
+ *
16
+ * Every command here is read-only and bounded. The user's proxy runs under a
17
+ * service manager while this executes; a probe that could start, stop or reload
18
+ * anything is not a probe. An unbounded one is not much better, since a wedged
19
+ * manager would hold the event loop.
20
+ */
21
+ import { spawnSync } from "node:child_process";
22
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ /** Short: this runs inside admission, and a slow answer is the same as none. */
27
+ export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
28
+
29
+ export type ServiceManagerBackend = "launchd" | "systemd" | "scheduler" | "winsw";
30
+
31
+ export interface ServiceManagerClaim {
32
+ readonly backend: ServiceManagerBackend;
33
+ readonly definitionPath: string;
34
+ /**
35
+ * The homes the definition names. `null` means the definition deliberately
36
+ * omits that key, which is different from naming a different one: an install
37
+ * that ran without `CODEX_HOME` set writes no such key at all.
38
+ */
39
+ readonly homes: {
40
+ readonly codexHome: string | null;
41
+ readonly opencodexHome: string | null;
42
+ };
43
+ readonly registration: "present" | "absent";
44
+ }
45
+
46
+ export type ServiceManagerInstallation =
47
+ | { readonly kind: "absent" }
48
+ | { readonly kind: "present"; readonly claims: readonly ServiceManagerClaim[] }
49
+ | { readonly kind: "conflict"; readonly claims: readonly ServiceManagerClaim[] }
50
+ | { readonly kind: "unknown"; readonly reason: string };
51
+
52
+ /** Injected so a test can observe the EXACT argv production emits. */
53
+ export interface ProbeRunner {
54
+ (file: string, args: readonly string[]): {
55
+ status: number | null;
56
+ stdout: string;
57
+ stderr: string;
58
+ timedOut: boolean;
59
+ spawnFailed: boolean;
60
+ };
61
+ }
62
+
63
+ export const defaultProbeRunner: ProbeRunner = (file, args) => {
64
+ const result = spawnSync(file, [...args], {
65
+ encoding: "utf8",
66
+ windowsHide: true,
67
+ timeout: SERVICE_PROBE_TIMEOUT_MS,
68
+ });
69
+ return {
70
+ status: result.status,
71
+ stdout: String(result.stdout ?? ""),
72
+ stderr: String(result.stderr ?? ""),
73
+ // `signal` is SIGTERM when the timeout fired; a spawn failure sets `error`.
74
+ timedOut: result.signal !== null && result.error === undefined,
75
+ spawnFailed: result.error !== undefined,
76
+ };
77
+ };
78
+
79
+ export interface ProbeDeps {
80
+ readonly run?: ProbeRunner;
81
+ readonly platform?: NodeJS.Platform;
82
+ readonly uid?: number;
83
+ readonly home?: string;
84
+ }
85
+
86
+ const LABEL = "com.opencodex.proxy";
87
+ const TASK = "opencodex-proxy";
88
+
89
+ /**
90
+ * `launchctl print` exits 113 for a service that is not there and 112 when the
91
+ * domain itself cannot be reached — measured against nonexistent targets rather
92
+ * than assumed. Only 113 is an answer; everything else is a failure to ask.
93
+ */
94
+ const LAUNCHCTL_NO_SUCH_SERVICE = 113;
95
+ /**
96
+ * 112 is an answer about the DOMAIN, not the label.
97
+ *
98
+ * Measured on macOS 27.0: querying a domain with no service name at all still
99
+ * returns it, and a domain that does not exist cannot be running one of our
100
+ * jobs. Treating it as "could not ask" would refuse every write on a fresh
101
+ * headless Mac — no GUI domain, and no installation either.
102
+ */
103
+ const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
104
+
105
+ /**
106
+ * Residue on disk, distinguished from a path that could not be read.
107
+ *
108
+ * `existsSync` answers "no" for a dangling symlink and for a path whose parent
109
+ * denies traversal. Both are residue, not absence — only ENOENT is absence.
110
+ */
111
+ function artifactPresence(path: string): "present" | "absent" | "unreadable" {
112
+ try {
113
+ lstatSync(path);
114
+ return "present";
115
+ } catch (error) {
116
+ return (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable";
117
+ }
118
+ }
119
+
120
+ function unknown(reason: string): ServiceManagerInstallation {
121
+ return { kind: "unknown", reason };
122
+ }
123
+
124
+ /** Pull `<key>NAME</key><string>VALUE</string>` out of a plist body. */
125
+ function plistEnvValue(body: string, key: string): string | null {
126
+ const match = body.match(
127
+ new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([^<]*)</string>`),
128
+ );
129
+ return match ? match[1] : null;
130
+ }
131
+
132
+ /** Pull `Environment="NAME=VALUE"` (quoted or bare) out of a systemd unit. */
133
+ function unitEnvValue(body: string, key: string): string | null {
134
+ for (const line of body.split("\n")) {
135
+ const match = line.match(new RegExp(`^\\s*Environment=\\s*"?${key}=([^"\\n]*)"?\\s*$`));
136
+ if (match) return match[1];
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
142
+ const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);
143
+
144
+ /*
145
+ * BOTH domains, because they are independent and hold separate service sets.
146
+ * Measured on macOS 27.0: the shipped agent answers 0 under `gui/<uid>` and
147
+ * 113 under `user/<uid>`. Asking only one leaves the other free to hold a job
148
+ * this probe would then call absent.
149
+ */
150
+ let registration: "present" | "absent" = "absent";
151
+ let unreachableDomains = 0;
152
+ for (const domain of [`gui/${deps.uid}`, `user/${deps.uid}`]) {
153
+ const printed = deps.run("/bin/launchctl", ["print", `${domain}/${LABEL}`]);
154
+ if (printed.spawnFailed || printed.timedOut) {
155
+ return unknown(`launchctl could not be asked: ${printed.timedOut ? "timed out" : printed.stderr.trim()}`);
156
+ }
157
+ if (printed.status === 0) { registration = "present"; break; }
158
+ if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
159
+ if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) { unreachableDomains += 1; continue; }
160
+ return unknown(`launchctl print exited ${String(printed.status)}: ${printed.stderr.trim()}`);
161
+ }
162
+
163
+ const definition = artifactPresence(definitionPath);
164
+ if (definition === "absent") {
165
+ // No file. A registration without one means launchd holds a definition whose
166
+ // file is gone — real, and not something to resolve unattended.
167
+ if (registration === "present") {
168
+ return unknown("launchd has a job loaded but its plist is missing");
169
+ }
170
+ /*
171
+ * Nothing staged, and every domain either answered "no such service" or does
172
+ * not exist. 112 is an answer ABOUT THE DOMAIN and is label-independent —
173
+ * querying a domain with no service name at all returns it — so an
174
+ * unreachable domain cannot be hiding a job of ours. Calling this `unknown`
175
+ * instead would refuse every write on a fresh headless Mac, which has no
176
+ * GUI domain and no installation either.
177
+ */
178
+ void unreachableDomains;
179
+ return { kind: "absent" };
180
+ }
181
+
182
+ let body: string;
183
+ try {
184
+ body = readFileSync(definitionPath, "utf-8");
185
+ } catch (error) {
186
+ // Present-but-unreadable cannot supply homes, and `present` without homes
187
+ // would compare equal to nothing and read as agreement.
188
+ return unknown(`the launchd plist exists but could not be read: ${String(error)}`);
189
+ }
190
+
191
+ return {
192
+ kind: "present",
193
+ claims: [{
194
+ backend: "launchd",
195
+ definitionPath,
196
+ homes: {
197
+ codexHome: plistEnvValue(body, "CODEX_HOME"),
198
+ opencodexHome: plistEnvValue(body, "OPENCODEX_HOME"),
199
+ },
200
+ registration,
201
+ }],
202
+ };
203
+ }
204
+
205
+ function systemdProperty(out: string, key: string): string | null {
206
+ for (const line of out.split("\n")) {
207
+ const match = line.match(new RegExp(`^${key}=(.*)$`));
208
+ if (match) return match[1].trim();
209
+ }
210
+ return null;
211
+ }
212
+
213
+ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): ServiceManagerInstallation {
214
+ const definitionPath = join(deps.home, ".config", "systemd", "user", `${TASK}.service`);
215
+
216
+ /*
217
+ * All four properties in one call. LoadState alone is not enough — it is
218
+ * orthogonal to ActiveState — and neither says whether the LOADED bytes match
219
+ * the file. NeedDaemonReload is that signal, and this repository already
220
+ * documents it as the systemd analogue of launchd's stale plist.
221
+ */
222
+ const shown = deps.run("systemctl", [
223
+ "--user", "show", TASK,
224
+ "-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
225
+ ]);
226
+ if (shown.spawnFailed || shown.timedOut) {
227
+ return unknown(`systemctl could not be asked: ${shown.timedOut ? "timed out" : shown.stderr.trim()}`);
228
+ }
229
+ if (shown.status !== 0) {
230
+ // A missing unit still exits ZERO and says not-found; a non-zero status means
231
+ // the question never reached the bus.
232
+ return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`);
233
+ }
234
+
235
+ const loadState = systemdProperty(shown.stdout, "LoadState");
236
+ const activeState = systemdProperty(shown.stdout, "ActiveState");
237
+ const fragmentPath = systemdProperty(shown.stdout, "FragmentPath");
238
+ const needReload = systemdProperty(shown.stdout, "NeedDaemonReload");
239
+ if (loadState === null || activeState === null || needReload === null) {
240
+ return unknown("systemctl show did not report the properties it was asked for");
241
+ }
242
+ if (needReload === "yes") {
243
+ return unknown("systemd has a stale definition loaded; it needs daemon-reload");
244
+ }
245
+
246
+ const registration: "present" | "absent" =
247
+ loadState === "not-found" && activeState === "inactive" && !fragmentPath ? "absent" : "present";
248
+
249
+ if (artifactPresence(definitionPath) === "absent") {
250
+ return registration === "absent"
251
+ ? { kind: "absent" }
252
+ : unknown("systemd knows this unit but its file is missing");
253
+ }
254
+
255
+ let body: string;
256
+ try {
257
+ body = readFileSync(definitionPath, "utf-8");
258
+ } catch (error) {
259
+ return unknown(`the systemd unit exists but could not be read: ${String(error)}`);
260
+ }
261
+
262
+ return {
263
+ kind: "present",
264
+ claims: [{
265
+ backend: "systemd",
266
+ definitionPath,
267
+ homes: {
268
+ codexHome: unitEnvValue(body, "CODEX_HOME"),
269
+ opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
270
+ },
271
+ registration,
272
+ }],
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Windows is deferred to its own phase and reports `unknown` until then.
278
+ *
279
+ * Not an oversight: the definition there is a chain, not a file. The task XML
280
+ * names only the launcher, and the homes live in the batch wrapper it eventually
281
+ * runs — a probe that parsed the XML and stopped would find no homes and read
282
+ * that as agreement. Reporting `unknown` refuses unattended convergence on
283
+ * Windows, which is the safe direction while the chain walk is unwritten.
284
+ */
285
+ function inspectWindows(): ServiceManagerInstallation {
286
+ return unknown("the Windows definition chain is not inspected yet");
287
+ }
288
+
289
+ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation {
290
+ const platform = deps.platform ?? process.platform;
291
+ const run = deps.run ?? defaultProbeRunner;
292
+ const home = deps.home ?? homedir();
293
+ if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home });
294
+ if (platform === "linux") return inspectSystemd({ run, home });
295
+ if (platform === "win32") return inspectWindows();
296
+ return unknown(`no service manager probe for platform ${platform}`);
297
+ }