@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
@@ -14,6 +14,7 @@ import { projectLinkStatus, type LinkStatusDto } from "../../link/status-project
14
14
  import { isLinkPort } from "../../link/ports";
15
15
  import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
16
16
  import { clientLinkTunnelStatus } from "../../client/link-tunnel";
17
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
17
18
  import type { ManagementContext } from "./context";
18
19
  import { readManagementJsonBodyOr } from "./body";
19
20
  import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
@@ -228,10 +229,12 @@ async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState,
228
229
  if (!revoked) {
229
230
  try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
230
231
  try {
231
- const current = readStoreFor(ctx);
232
- if (!current.links.some(link => link.id === record.id)) {
233
- writeStoreFor(ctx, { ...current, links: [...current.links, record] });
234
- }
232
+ withConfigMutationLockSync(() => {
233
+ const current = readStoreFor(ctx);
234
+ if (!current.links.some(link => link.id === record.id)) {
235
+ writeStoreFor(ctx, { ...current, links: [...current.links, record] });
236
+ }
237
+ });
235
238
  } catch {
236
239
  // The in-memory marker still makes the residual visible when persistence is unavailable.
237
240
  }
@@ -239,11 +242,16 @@ async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState,
239
242
  }
240
243
  try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
241
244
  try {
242
- const current = readStoreFor(ctx);
243
- const next = { ...current, links: current.links.filter(link => link.id !== record.id) };
244
- if (next.links.length !== current.links.length) {
245
- writeStoreFor(ctx, next);
246
- }
245
+ // Read→filter→write stays one section of the shared mutation lock: a sibling
246
+ // route's create must not land between the read and the rename and vanish.
247
+ const next = withConfigMutationLockSync(() => {
248
+ const current = readStoreFor(ctx);
249
+ const filtered = { ...current, links: current.links.filter(link => link.id !== record.id) };
250
+ if (filtered.links.length !== current.links.length) {
251
+ writeStoreFor(ctx, filtered);
252
+ }
253
+ return filtered;
254
+ });
247
255
  clearCompensationFailed(record.id, compensationPath());
248
256
  state.compensationFailures?.delete(record.id);
249
257
  if (next.links.length === 0) await state.listener.close();
@@ -362,14 +370,20 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
362
370
  const record = { id, alias: body.alias, direction: "hub-initiated" as const, hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
363
371
  let store: LinkStore;
364
372
  try {
365
- const current = readStoreFor(ctx);
366
- if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) {
373
+ // Duplicate-check and append under one mutation-lock section: two writers
374
+ // racing this check could otherwise both pass it and append, losing one link.
375
+ const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
376
+ const current = readStoreFor(ctx);
377
+ if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) return "dup";
378
+ store = { ...current, links: [...current.links, record] };
379
+ writeStoreFor(ctx, store);
380
+ return "written";
381
+ });
382
+ if (writeOutcome === "dup") {
367
383
  const compensation = await compensateNewLink(ctx, state, record);
368
384
  if (compensation) return compensation;
369
385
  return fail("link_exists", "A link for this alias already exists.", 409);
370
386
  }
371
- store = { ...current, links: [...current.links, record] };
372
- writeStoreFor(ctx, store);
373
387
  await state.listener.ensureStarted();
374
388
  if (state.listener.status().state !== "listening") {
375
389
  const compensation = await compensateNewLink(ctx, state, record);
@@ -430,13 +444,18 @@ async function issue(ctx: ManagementContext): Promise<Response> {
430
444
  const record = { id, alias: body.alias, direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: body.tunnelPort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
431
445
  let failureCode = "link_issue_failed";
432
446
  try {
433
- const current = readStoreFor(ctx);
434
- if (current.links.some(link => link.id === id)) throw new Error("link id collision");
435
- if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) {
447
+ // Same atomic duplicate-check+append as the hub-initiated branch.
448
+ const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
449
+ const current = readStoreFor(ctx);
450
+ if (current.links.some(link => link.id === id)) throw new Error("link id collision");
451
+ if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) return "dup";
452
+ writeStoreFor(ctx, { ...current, links: [...current.links, record] });
453
+ return "written";
454
+ });
455
+ if (writeOutcome === "dup") {
436
456
  const compensation = await compensateNewLink(ctx, state, record);
437
457
  return compensation ?? fail("link_exists", "A link for this alias already exists.", 409);
438
458
  }
439
- writeStoreFor(ctx, { ...current, links: [...current.links, record] });
440
459
  await state.listener.ensureStarted();
441
460
  if (state.listener.status().state !== "listening") {
442
461
  failureCode = "listener_unavailable";
@@ -538,9 +557,12 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
538
557
  if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
539
558
  let next: LinkStore;
540
559
  try {
541
- const latest = readStoreFor(ctx);
542
- next = { ...latest, links: latest.links.filter(link => link.id !== id) };
543
- if (next.links.length !== latest.links.length) writeStoreFor(ctx, next);
560
+ next = withConfigMutationLockSync(() => {
561
+ const latest = readStoreFor(ctx);
562
+ const filtered = { ...latest, links: latest.links.filter(link => link.id !== id) };
563
+ if (filtered.links.length !== latest.links.length) writeStoreFor(ctx, filtered);
564
+ return filtered;
565
+ });
544
566
  clearCompensationFailed(id, compensationPath());
545
567
  } catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
546
568
  state.compensationFailures?.delete(id);
@@ -361,8 +361,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
361
361
  { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false },
362
362
  { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false },
363
363
  { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true },
364
- { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
365
- { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
364
+ { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
365
+ { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md" } },
366
366
  // server/management/request-history-routes
367
367
  { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
368
368
  // server/management/routing-analytics-routes
@@ -364,7 +364,7 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
364
364
  /** Shared Desktop profile DTO builder for the management API and CLI. */
365
365
  export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
366
366
  const { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
367
- const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p");
367
+ const { routeSupportsOneMillion } = await import("../../claude/long-context");
368
368
  const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
369
369
  const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
370
370
  const profileModels: DesktopProfileModel[] = [
@@ -413,8 +413,8 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
413
413
  ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}),
414
414
  effortSupported: effortByRoute.get(route) ?? false,
415
415
  // Read-only view of the 1M capability the written Desktop config already emits,
416
- // derived from the SAME threshold so the dashboard chip can never disagree.
417
- supports1m: (modelByRoute.get(route)?.contextWindow ?? 0) >= DESKTOP_SUPPORTS_1M_THRESHOLD,
416
+ // derived from the SAME predicate so the dashboard chip can never disagree.
417
+ supports1m: routeSupportsOneMillion(route, modelByRoute.get(route)?.contextWindow),
418
418
  assignment: profile.assignments[route]!,
419
419
  }));
420
420
  return {
@@ -139,19 +139,20 @@ export async function resolveNativeOAuthBindingForInstance(
139
139
  const routeDecision = currentRoute(instance, config, model);
140
140
  const manualSelectionGeneration = routing.captureAnthropicManualSelectionGeneration();
141
141
  const selection = captureOAuthAccountSelection(instance);
142
- // Always ask admission first so pause/login/cooldown retains its typed local refusal.
143
- const admittedId = await routing.resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model);
144
- assertOwner();
145
- if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
146
- const proposed = routing.resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
147
- // A lost selection commit discards a recovery proposal: a newer manual/policy choice wins.
142
+ // A recovery candidate may replace a terminal active credential, including pool-off.
143
+ // A newer committed selection or route discards the proposal and uses normal admission.
148
144
  const recoverySelectionMatches = !!options.expectedRecoverySelection
149
145
  && selection?.accountId === options.expectedRecoverySelection.accountId
150
146
  && selection?.revision === options.expectedRecoverySelection.revision;
151
147
  const recoveryRouteMatches = options.expectedRecoveryRouteDecision === undefined
152
148
  || JSON.stringify(options.expectedRecoveryRouteDecision) === JSON.stringify(routeDecision);
153
- const accountId = attempt === 0 && options.candidateAccountId && recoverySelectionMatches && recoveryRouteMatches
154
- ? options.candidateAccountId : admittedId;
149
+ const recoveryAccountId = attempt === 0 && options.candidateAccountId && recoverySelectionMatches && recoveryRouteMatches
150
+ ? options.candidateAccountId : undefined;
151
+ const accountId = recoveryAccountId
152
+ ?? await routing.resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model);
153
+ assertOwner();
154
+ if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
155
+ const proposed = routing.resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
155
156
  if (!selection) continue;
156
157
  if (!routeCandidates(routing.getEligibleAnthropicAccounts(Date.now(), model), routeDecision).includes(accountId)) {
157
158
  throw new NativeOAuthSelectionChangedError();
@@ -36,11 +36,12 @@ import {
36
36
  buildAnthropicMessagesPassthroughRequest,
37
37
  type AnthropicMessagesPassthroughRequest,
38
38
  } from "../adapters/anthropic/passthrough";
39
- import { resolveInboundModel } from "../claude/inbound";
40
- import { anthropicErrorBody, anthropicErrorResponse, collectAnthropicMessage } from "../claude/outbound";
39
+ import { effortFromOutputConfig, resolveInboundModel } from "../claude/inbound";
40
+ import { anthropicErrorBody, anthropicErrorResponse, claudeOverflowSsePayload, claudePromptTooLongMessage, collectAnthropicMessage, isContextOverflowText, isThroughputLimitText } from "../claude/outbound";
41
41
  import type { AdmissionLease } from "../lib/admission";
42
42
  import { readBoundedResponseBody } from "../lib/bounded-body";
43
43
  import { classifyError } from "../lib/errors";
44
+ import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
44
45
  import { redactSecretString } from "../lib/redact";
45
46
  import { resolveClientRetryAfter } from "../lib/retry-after";
46
47
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
@@ -107,6 +108,7 @@ import {
107
108
  import {
108
109
  noteProviderAttemptSend,
109
110
  recordAttemptCredentialSource,
111
+ recordAttemptRequestedEffort,
110
112
  recordFirstOutput,
111
113
  recordKeyAttemptFailure,
112
114
  recordKeyWireAttemptUsage,
@@ -119,6 +121,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
119
121
  import { sseFieldValue } from "../lib/sse-decoder";
120
122
  import { admissionModelDeniedResponse, type AdmissionModelScope } from "./admission-model-scope";
121
123
  import { nativeMessagesToolScopeDenial } from "./messages-native-scope";
124
+ import { retainUpstreamMessagesRequestId } from "./messages-response-headers";
122
125
 
123
126
  export {
124
127
  isNativeMessagesRouteEligible,
@@ -317,6 +320,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
317
320
  logCtx.requestedModel = requestedModel;
318
321
  if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel;
319
322
  logCtx.requestedServiceTier = typeof body.service_tier === "string" ? body.service_tier : undefined;
323
+ if (logCtx.requestedEffort === undefined) {
324
+ const effort = effortFromOutputConfig(body.output_config);
325
+ const thinking = isRec(body.thinking) ? body.thinking : undefined;
326
+ const budget = thinking?.budget_tokens;
327
+ if (effort !== undefined) logCtx.requestedEffort = effort;
328
+ else if (thinking?.type === "disabled") logCtx.requestedEffort = "none";
329
+ else if (thinking?.type === "enabled" && typeof budget === "number"
330
+ && Number.isSafeInteger(budget) && budget > 0) logCtx.requestedEffort = `budget:${budget}`;
331
+ }
320
332
  // Reserve spend the way native Chat does: an input estimate that never enters usage, and the
321
333
  // caller's own output ceiling.
322
334
  if (logCtx.usageLogInputTokens === undefined) {
@@ -332,6 +344,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
332
344
  adapter: "anthropic",
333
345
  });
334
346
  attemptHandle.seal(logCtx.accountLogLabel);
347
+ recordAttemptRequestedEffort(logCtx);
335
348
  const { attempt } = attemptHandle;
336
349
  const finalLog = createFinalRequestLog(logIds, logCtx);
337
350
  const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => {
@@ -500,7 +513,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
500
513
  && nativeMessagesDeclineReason({ ...route, provider: routed }, body, config, selector) === undefined;
501
514
  };
502
515
 
503
- const send = async (recovery?: "rate-limit-429" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
516
+ const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
504
517
  const remaining = remainingTransientSends();
505
518
  if (requestTransientPolicy && remaining <= 0) {
506
519
  throw new Error("native Messages transient send budget exhausted before recovery dispatch");
@@ -672,7 +685,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
672
685
  const oauthRetryKey = {};
673
686
  const triedAccountIds = new Set<string>();
674
687
  let oauthFailovers = 0;
675
- while (oauthBinding && (response.status === 429 || response.status === 403)) {
688
+ while (oauthBinding && (response.status === 429 || response.status === 403 || response.status === 401)) {
676
689
  const sendingBinding = oauthBinding;
677
690
  const expectedRecoverySelection = sendingBinding.selection;
678
691
  triedAccountIds.add(sendingBinding.snapshot.accountId);
@@ -690,7 +703,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
690
703
  canRetry: transientSendAvailable() && oauthFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
691
704
  });
692
705
  if (!nextAccountId) break;
693
- const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429";
706
+ const recovery = response.status === 401 ? "oauth-401" : response.status === 403 ? "oauth-account-403" : "rate-limit-429";
694
707
  discard(response);
695
708
  oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget,
696
709
  sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId,
@@ -739,7 +752,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
739
752
  upstream.abort();
740
753
  return fail(499, "Client cancelled request", "api_error");
741
754
  }
742
- return nativeMessagesErrorResponse(response, bodyText, finishLog);
755
+ return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers);
743
756
  }
744
757
 
745
758
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
@@ -764,10 +777,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
764
777
  releaseStreamTurn();
765
778
  }
766
779
  }, bodyGuard);
767
- return new Response(relayed, {
780
+ // A configured Messages provider words an oversized-input refusal its own way; Claude Code
781
+ // compacts only on Anthropic's wording (devlog/_plan/261009_claude_1m_default/010). The
782
+ // rewrite buffers whole frames, so it sits after the tap: stall detection keeps timing raw
783
+ // upstream bytes. Real Anthropic pools refuse pre-stream in that wording and keep one relay.
784
+ const clientStream = nativeInstance ? relayed : relaySseWithPayloadRewrite(relayed, claudeOverflowSsePayload, translatorBudget);
785
+ return retainUpstreamMessagesRequestId(new Response(clientStream, {
768
786
  status: 200,
769
787
  headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" },
770
- });
788
+ }), response.headers);
771
789
  }
772
790
  // A non-streaming caller whose upstream streamed anyway: fold the stream into one message.
773
791
  const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {};
@@ -789,10 +807,11 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
789
807
  // as the streaming lane's row; a plain upstream error event keeps the non_stream row.
790
808
  const tapMeta = tapState.meta;
791
809
  if (tapMeta && (tapMeta.terminalStatus || tapMeta.closeReason !== "terminal")) finishLog(502, text, tapMeta);
810
+ if (isNativeOverflowError(error, text)) return fail(400, claudePromptTooLongMessage(text), "invalid_request_error");
792
811
  return fail(502, text, "api_error");
793
812
  }
794
813
  finishLog(200);
795
- return Response.json(message);
814
+ return retainUpstreamMessagesRequestId(Response.json(message), response.headers);
796
815
  } catch (error) {
797
816
  cleanupAbort();
798
817
  upstream.abort();
@@ -850,12 +869,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
850
869
  const serialized = JSON.stringify(message);
851
870
  finishLog(200);
852
871
  if (requestedStream) {
853
- return new Response(messageAsSse(message), {
872
+ return retainUpstreamMessagesRequestId(new Response(messageAsSse(message), {
854
873
  status: 200,
855
874
  headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
856
- });
875
+ }), response.headers);
857
876
  }
858
- return new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } });
877
+ return retainUpstreamMessagesRequestId(new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }), response.headers);
878
+ }
879
+
880
+ /** A folded stream error that refuses an oversized input (same gate as the SSE rewrite). */
881
+ function isNativeOverflowError(error: Rec, message: string): boolean {
882
+ const sized = error.type === "invalid_request_error" || error.type === "request_too_large";
883
+ return sized && !isThroughputLimitText(message)
884
+ && (error.code === "context_length_exceeded" || isContextOverflowText(message));
859
885
  }
860
886
 
861
887
  /**
@@ -865,11 +891,13 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
865
891
  */
866
892
  function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response {
867
893
  let upstreamType: string | undefined;
894
+ let upstreamCode: string | undefined;
868
895
  let upstreamMessage: string | undefined;
869
896
  try {
870
897
  const parsed = JSON.parse(bodyText) as Rec;
871
898
  const details = isRec(parsed.error) ? parsed.error : parsed;
872
899
  if (typeof details.type === "string") upstreamType = details.type;
900
+ if (typeof details.code === "string") upstreamCode = details.code;
873
901
  if (typeof details.message === "string" && details.message.trim()) {
874
902
  upstreamMessage = redactSecretString(details.message.trim());
875
903
  }
@@ -898,9 +926,17 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis
898
926
  if (retryAfter) headers.set("Retry-After", retryAfter);
899
927
  else if (transient) headers.set("Retry-After", "2");
900
928
  if (replayRefusal) applyReplayRefusalClientHeaders(headers);
929
+ // The native envelope keeps its shape (no added code); only an overflow's wording changes, so
930
+ // Claude Code recognizes it and compacts (010).
931
+ // Only a 400/413 refusal: the shared classifier also files a 429 "too many tokens per minute"
932
+ // under context_length_exceeded, and that one must stay a rate limit.
933
+ const overflow = !replayRefusal && (response.status === 400 || response.status === 413)
934
+ && !isThroughputLimitText(safeMessage)
935
+ && (classified.code === "context_length_exceeded" || upstreamCode === "context_length_exceeded"
936
+ || isContextOverflowText(safeMessage));
901
937
  const out = new Response(JSON.stringify(anthropicErrorBody(
902
938
  status,
903
- safeMessage,
939
+ overflow ? claudePromptTooLongMessage(safeMessage) : safeMessage,
904
940
  transient ? "overloaded_error" : upstreamType,
905
941
  replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
906
942
  )), { status, headers });
@@ -0,0 +1,14 @@
1
+ /** Only an opaque Anthropic request id is carried; arbitrary upstream headers stay private. */
2
+ export function upstreamMessagesRequestIdHeaders(upstream: Headers): Record<string, string> {
3
+ const requestId = upstream.get("request-id");
4
+ return requestId && /^req_[A-Za-z0-9_-]{1,128}$/.test(requestId)
5
+ ? { "request-id": requestId }
6
+ : {};
7
+ }
8
+
9
+ /** The response is newly constructed by the Messages handler; preserve its body and markers. */
10
+ export function retainUpstreamMessagesRequestId(response: Response, upstream: Headers): Response {
11
+ const requestId = upstreamMessagesRequestIdHeaders(upstream)["request-id"];
12
+ if (requestId) response.headers.set("request-id", requestId);
13
+ return response;
14
+ }
@@ -338,6 +338,8 @@ export function relaySseEagerBounded(
338
338
  upstreamError,
339
339
  terminalBoundary.upstreamRefusalCode(),
340
340
  opts?.maskCredential,
341
+ terminalBoundary.upstreamErrorType(),
342
+ terminalBoundary.upstreamErrorCode(),
341
343
  );
342
344
  queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength;
343
345
  try {
@@ -6,13 +6,14 @@ import {
6
6
  isCyberPolicyCode,
7
7
  isCyberPolicyMessage,
8
8
  isTerminalRefusalCode,
9
+ recognizedUpstreamError,
9
10
  safetyRefusalCodeFromMessage,
10
11
  terminalRefusalFallbackMessage,
11
12
  upstreamErrorMessageFromPayload,
12
13
  } from "../lib/errors";
13
14
  import { redactSecretString } from "../lib/redact";
14
15
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
15
- import { carryReplayRefusal } from "../lib/upstream-retry";
16
+ import { carryReplayRefusal, isReplayRefusalResponse } from "../lib/upstream-retry";
16
17
  import { isUsageDebugEnabled } from "../usage/debug";
17
18
  import {
18
19
  addRequestLog,
@@ -168,25 +169,28 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown, maskCredenti
168
169
  * classifies this terminal by `error.code` alone and retries everything outside
169
170
  * its fatal set (codex-rs/codex-api/src/sse/responses.rs:423-450), so stamping
170
171
  * `upstream_server_error` on a refusal delivered it as a retryable disconnect
171
- * and drove the reconnect loop in #5176. Without a refusal code the terminal is
172
- * unchanged: a genuine transport failure stays retryable, which is what it is.
172
+ * and drove the reconnect loop in #5176. Without a refusal code the terminal
173
+ * carries only recognized rate-limit/overload classes; unknown codes stay generic.
173
174
  */
174
175
  export function upstreamErrorTailFrame(
175
176
  encoder: TextEncoder,
176
177
  message: string,
177
178
  refusalCode?: string,
178
179
  maskCredential?: (text: string) => string,
180
+ errorType?: string,
181
+ errorCode?: string,
179
182
  ): Uint8Array {
180
183
  return encoder.encode(
181
- `event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode, maskCredential)}\n\n`,
184
+ `event: response.failed\ndata: ${upstreamErrorFailedPayload(message, refusalCode, maskCredential, errorType, errorCode)}\n\n`,
182
185
  );
183
186
  }
184
187
 
185
- function upstreamErrorFailedPayload(message: string, refusalCode?: string, maskCredential?: (text: string) => string): string {
188
+ function upstreamErrorFailedPayload(message: string, refusalCode?: string, maskCredential?: (text: string) => string, errorType?: string, errorCode?: string): string {
186
189
  const diagnostic = redactSecretString(message);
190
+ const recognized = recognizedUpstreamError({ type: errorType, code: errorCode });
187
191
  const error = {
188
- type: refusalCode === undefined ? "upstream_error" : "invalid_request_error",
189
- code: refusalCode === undefined ? "upstream_server_error" : (maskCredential?.(refusalCode) ?? refusalCode),
192
+ type: refusalCode === undefined ? (recognized?.type ?? "upstream_error") : "invalid_request_error",
193
+ code: refusalCode === undefined ? (recognized?.code ?? "upstream_server_error") : (maskCredential?.(refusalCode) ?? refusalCode),
190
194
  message: (maskCredential?.(diagnostic) ?? diagnostic).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS),
191
195
  };
192
196
  return JSON.stringify({
@@ -549,6 +553,8 @@ export function relaySseWithFailedTail(
549
553
  upstreamError,
550
554
  terminalBoundary.upstreamRefusalCode(),
551
555
  opts?.maskCredential,
556
+ terminalBoundary.upstreamErrorType(),
557
+ terminalBoundary.upstreamErrorCode(),
552
558
  ));
553
559
  controller.enqueue(doneFrame(encoder));
554
560
  }
@@ -898,6 +904,11 @@ export function responseWithDeferredRequestLog(
898
904
  logCtx: RequestLogContext,
899
905
  addLog: (entry: RequestLogEntry) => void = addRequestLog,
900
906
  ): Response {
907
+ // A synthetic 429 is a transport verdict, not evidence that the provider declined the turn.
908
+ if (isReplayRefusalResponse(response)) {
909
+ logCtx.causeHint = "transport-ambiguous";
910
+ logCtx.terminalSource = "synthetic";
911
+ }
901
912
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
902
913
  if (isUsageDebugEnabled() && !logCtx.usageDebugContentType && contentType) {
903
914
  logCtx.usageDebugContentType = contentType;
@@ -37,6 +37,47 @@ export function normalizeLogConversationId(raw: string | undefined | null): stri
37
37
  return createHash("sha256").update(trimmed).digest("hex").slice(0, LOG_CONVERSATION_ID_LEN);
38
38
  }
39
39
 
40
+ const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
41
+ /** Real thread links stay well under this bound; larger pastes skip unwrapping so per-row matching stays cheap. */
42
+ const LOG_CONVERSATION_LINK_MAX = 512;
43
+
44
+ /**
45
+ * Unwrap a pasted `codex://threads/<id>` deep link to the bare thread id. Codex's
46
+ * "copy session link" clipboard carries that URI form while clients send the bare id
47
+ * on the wire, so Logs search accepts either paste without changing what is persisted.
48
+ * Query/fragment metadata (e.g. `?hostId=…`) belongs to the link, not the id, and is
49
+ * dropped. The scan is a bounded linear walk — no ambiguous regex — because this runs
50
+ * per log row on every filter evaluation.
51
+ */
52
+ export function unwrapLogConversationQuery(query: string): string {
53
+ const trimmed = query.trim();
54
+ if (
55
+ trimmed.length > LOG_CONVERSATION_LINK_MAX ||
56
+ trimmed.length <= CODEX_THREAD_LINK_PREFIX.length ||
57
+ !trimmed.toLowerCase().startsWith(CODEX_THREAD_LINK_PREFIX)
58
+ ) {
59
+ return trimmed;
60
+ }
61
+ const rest = trimmed.slice(CODEX_THREAD_LINK_PREFIX.length);
62
+ const metaIndex = rest.search(/[?#]/);
63
+ const segment = (metaIndex === -1 ? rest : rest.slice(0, metaIndex)).replace(/\/+$/, "");
64
+ // A thread id is a single path segment; deeper paths or embedded whitespace keep the
65
+ // whole paste as the query, so foreign `codex://` URIs still fail to match.
66
+ return segment !== "" && !/[/\s]/.test(segment) ? segment : trimmed;
67
+ }
68
+
69
+ /**
70
+ * The candidate ids a conversation query may legitimately match. Unwrapping a pasted
71
+ * `codex://threads/<id>` link must add a match path, not replace one: a client can
72
+ * literally send a `codex://threads/…` session id, which is hashed whole for storage,
73
+ * so the untouched paste stays a candidate too.
74
+ */
75
+ export function logConversationQueryCandidates(query: string): string[] {
76
+ const trimmed = query.trim();
77
+ const unwrapped = unwrapLogConversationQuery(trimmed);
78
+ return unwrapped === trimmed ? [unwrapped] : [unwrapped, trimmed];
79
+ }
80
+
40
81
  /**
41
82
  * Filter match: accept either the persisted digest or the original preimage
42
83
  * (so pasting from Logs detail or the client-facing session id both work).
@@ -46,11 +87,13 @@ export function matchesLogConversationId(
46
87
  query: string | undefined | null,
47
88
  ): boolean {
48
89
  if (!stored) return false;
49
- const trimmed = typeof query === "string" ? query.trim() : "";
50
- if (!trimmed) return false;
51
- if (stored === trimmed) return true;
52
- const hashed = normalizeLogConversationId(trimmed);
53
- return hashed !== undefined && stored === hashed;
90
+ for (const candidate of logConversationQueryCandidates(typeof query === "string" ? query : "")) {
91
+ if (!candidate) continue;
92
+ if (stored === candidate) return true;
93
+ const hashed = normalizeLogConversationId(candidate);
94
+ if (hashed !== undefined && stored === hashed) return true;
95
+ }
96
+ return false;
54
97
  }
55
98
 
56
99
  /**
@@ -21,6 +21,8 @@ export interface FinalRequestAttributionFacts {
21
21
  readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined;
22
22
  readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
23
23
  readonly terminalSource?: "upstream" | "synthetic" | undefined;
24
+ /** A failure cause proved by the transport's in-process verdict. */
25
+ readonly causeHint?: RequestFailureCause | undefined;
24
26
  /** The REQUEST's first-output observation, not the final attempt's. See below. */
25
27
  readonly outputObserved: boolean;
26
28
  readonly locallyAnswered: boolean;
@@ -53,6 +55,7 @@ export function attributeFinalRequest(facts: FinalRequestAttributionFacts): Stam
53
55
  ...(facts.closeReason ? { closeReason: facts.closeReason } : {}),
54
56
  ...(facts.transportPhase ? { transportPhase: facts.transportPhase } : {}),
55
57
  ...(facts.terminalSource ? { terminalSource: facts.terminalSource } : {}),
58
+ ...(facts.causeHint ? { causeHint: facts.causeHint } : {}),
56
59
  ...(facts.attempt?.streamAborted === true ? { streamAborted: true } : {}),
57
60
  outputObserved: facts.outputObserved,
58
61
  // The one fact that can raise a stage above `semantic-output`, and the reason it is counted