@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -2,9 +2,14 @@ import type { AdapterFetchContext } from "./base";
2
2
  import type { SendClass } from "../lib/request-execution-budget";
3
3
  import type { AttemptRecoveryKind } from "../usage/log";
4
4
  import { abortError, SendBudgetExhaustedError } from "../lib/upstream-retry";
5
+ import {
6
+ releaseProviderRequestSlot,
7
+ sendTrackingRequestSlot,
8
+ type ProviderRequestSlot,
9
+ } from "../providers/request-pacing";
5
10
 
6
11
  type PacedFetch = typeof globalThis.fetch & {
7
- waitForPacing?: (signal?: AbortSignal) => Promise<void>;
12
+ waitForPacing?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
8
13
  unpacedFetch?: typeof globalThis.fetch;
9
14
  };
10
15
 
@@ -37,13 +42,15 @@ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallbac
37
42
  ctx.onPhysicalSend?.({ ordinal, ...(options.recovery ? { recovery: options.recovery } : {}) });
38
43
  return (executor.unpacedFetch ?? executor)(input, init);
39
44
  }) as typeof globalThis.fetch;
45
+ let slot: ProviderRequestSlot | undefined;
40
46
  try {
41
- await executor.waitForPacing?.(ctx.abortSignal);
47
+ slot = (await executor.waitForPacing?.(ctx.abortSignal)) || undefined;
42
48
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
43
49
  await options.beforeDispatch?.();
44
50
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
45
- return await options.dispatch(physicalExecutor);
51
+ return await sendTrackingRequestSlot(slot, () => options.dispatch(physicalExecutor));
46
52
  } finally {
53
+ releaseProviderRequestSlot(slot);
47
54
  permit?.release();
48
55
  }
49
56
  };
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "./azure";
3
3
  import type { ProviderAdapter } from "./base";
4
4
  import { createClaudeCliAdapter } from "./claude-cli/adapter";
5
5
  import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay";
6
+ import { withUniqueToolCallIds } from "./unique-tool-call-ids";
6
7
  import { createCodeBuddyAdapter } from "./codebuddy/adapter";
7
8
  import { createQoderAdapter } from "./qoder/adapter";
8
9
  import { createCommandCodeAdapter } from "./command-code";
@@ -84,7 +85,7 @@ export const ADAPTER_REGISTRY = {
84
85
  wire: "openai-chat",
85
86
  mutation: "codex-owned",
86
87
  create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) =>
87
- withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)),
88
+ withUniqueToolCallIds(withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider))),
88
89
  },
89
90
  "ollama-native": {
90
91
  wire: "ollama-native",
@@ -96,9 +96,32 @@ function usesUnicodePropertyEscape(pattern: string): boolean {
96
96
  return false;
97
97
  }
98
98
 
99
+ /**
100
+ * Rewrite each unescaped `\0` not followed by a digit to the equivalent `\x00`. Both spell NUL
101
+ * in ECMAScript and Python `re`, but some destinations' schema validators refuse `\0` inside a
102
+ * character class: Meta's Responses API answers 400 "is not a \"regex\"" to Claude Code's
103
+ * Artifact tool, whose file-path parameters carry `^[^\0]*$`. `\0` followed by a digit is an
104
+ * octal escape in Python, so it is left alone. Returns the input when nothing changed.
105
+ */
106
+ function rewriteNulEscapes(pattern: string): string {
107
+ let out: string | undefined;
108
+ let copied = 0;
109
+ for (let i = 0; i < pattern.length; i++) {
110
+ if (pattern[i] !== "\\") continue;
111
+ const next = pattern[i + 1];
112
+ if (next === "0" && !/[0-9]/.test(pattern[i + 2] ?? "")) {
113
+ out = (out ?? "") + pattern.slice(copied, i) + "\\x00";
114
+ copied = i + 2;
115
+ }
116
+ i++;
117
+ }
118
+ return out === undefined ? pattern : out + pattern.slice(copied);
119
+ }
120
+
99
121
  /**
100
122
  * Remove unsupported Unicode property escapes from scalar `pattern` constraints in ordinary
101
- * positive schema positions. This keeps built-in Artifact tools usable on Python-re backends;
123
+ * positive schema positions, and spell `\0` as `\x00` (rewriteNulEscapes). This keeps built-in
124
+ * Artifact tools usable on Python-re backends and on Meta's Responses API;
102
125
  * the omitted constraint is not enforced by this proxy and tools must validate their inputs.
103
126
  *
104
127
  * Regex-keyed objects are preserved. Removing a matcher can lose evaluated-property annotations
@@ -181,8 +204,13 @@ export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false):
181
204
  continue;
182
205
  }
183
206
  const [key, value] = next.value;
184
- if (!frame.inNameBag && key === "pattern" && typeof value === "string" && usesUnicodePropertyEscape(value)) {
185
- delete (cloneContainer(frame) as Record<string, unknown>)[key];
207
+ if (!frame.inNameBag && key === "pattern" && typeof value === "string") {
208
+ if (usesUnicodePropertyEscape(value)) {
209
+ delete (cloneContainer(frame) as Record<string, unknown>)[key];
210
+ continue;
211
+ }
212
+ const rewritten = rewriteNulEscapes(value);
213
+ if (rewritten !== value) (cloneContainer(frame) as Record<string, unknown>)[key] = rewritten;
186
214
  continue;
187
215
  }
188
216
  if (!frame.inNameBag && (PRESERVED_PATTERN_SUBTREES.has(key) || SCHEMA_LITERAL_VALUE_KEYS.has(key))) {
@@ -132,14 +132,22 @@ export interface AdapterEventPreflight {
132
132
  error?: Extract<AdapterEvent, { type: "error" }>;
133
133
  empty: boolean;
134
134
  replayUnsafe: boolean;
135
+ ready?: boolean;
136
+ timedOut?: boolean;
135
137
  }
136
138
 
137
139
  async function* replay(
138
140
  buffered: readonly AdapterEvent[],
139
141
  iterator: AsyncIterator<AdapterEvent>,
142
+ pendingNext?: Promise<IteratorResult<AdapterEvent>>,
140
143
  ): AsyncGenerator<AdapterEvent> {
141
144
  try {
142
145
  for (const event of buffered) yield event;
146
+ if (pendingNext) {
147
+ const next = await pendingNext;
148
+ if (next.done) return;
149
+ yield next.value;
150
+ }
143
151
  while (true) {
144
152
  const next = await iterator.next();
145
153
  if (next.done) return;
@@ -153,31 +161,61 @@ async function* replay(
153
161
  export async function preflightAdapterEvents(
154
162
  source: AsyncIterable<AdapterEvent>,
155
163
  classifyFirstEvent?: (event: AdapterEvent) => Extract<AdapterEvent, { type: "error" }> | undefined,
164
+ options?: { maxWaitMs?: number; honorReady?: boolean },
156
165
  ): Promise<AdapterEventPreflight> {
157
166
  const iterator = source[Symbol.asyncIterator]();
158
167
  const buffered: AdapterEvent[] = [];
159
168
  let replayUnsafe = false;
160
- while (true) {
161
- const next = await iterator.next();
162
- if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe };
163
- if (next.value.type === "heartbeat") {
164
- replayUnsafe ||= next.value.replayUnsafe === true;
169
+ const maxWaitMs = options?.maxWaitMs;
170
+ if (maxWaitMs !== undefined && maxWaitMs <= 0) {
171
+ return { stream: replay(buffered, iterator), empty: false, replayUnsafe, timedOut: true };
172
+ }
173
+ let timeout: ReturnType<typeof setTimeout> | undefined;
174
+ let timeoutPromise: Promise<"timeout"> | undefined;
175
+ if (maxWaitMs !== undefined) {
176
+ timeoutPromise = new Promise(resolve => {
177
+ timeout = setTimeout(() => resolve("timeout"), maxWaitMs);
178
+ });
179
+ }
180
+ try {
181
+ while (true) {
182
+ const pendingNext = iterator.next();
183
+ const raced = timeoutPromise ? await Promise.race([pendingNext, timeoutPromise]) : await pendingNext;
184
+ if (raced === "timeout") {
185
+ return {
186
+ stream: replay(buffered, iterator, pendingNext),
187
+ empty: false,
188
+ replayUnsafe,
189
+ timedOut: true,
190
+ };
191
+ }
192
+ const next = raced;
193
+ if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe };
194
+ if (next.value.type === "heartbeat") {
195
+ replayUnsafe ||= next.value.replayUnsafe === true;
196
+ // Preserve the latch in replay even after the original unsafe heartbeat is evicted.
197
+ buffered.push(replayUnsafe ? { ...next.value, replayUnsafe: true } : next.value);
198
+ if (next.value.preflightReady === true && options?.honorReady !== false) {
199
+ return { stream: replay(buffered, iterator), empty: false, replayUnsafe, ready: true };
200
+ }
201
+ if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
202
+ continue;
203
+ }
204
+ const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value);
205
+ if (classifiedError) {
206
+ buffered.push(classifiedError);
207
+ await iterator.return?.();
208
+ return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe };
209
+ }
165
210
  buffered.push(next.value);
166
- if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
167
- continue;
168
- }
169
- const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value);
170
- if (classifiedError) {
171
- buffered.push(classifiedError);
172
- await iterator.return?.();
173
- return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe };
174
- }
175
- buffered.push(next.value);
176
- if (next.value.type === "error") {
177
- await iterator.return?.();
178
- return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe };
211
+ if (next.value.type === "error") {
212
+ await iterator.return?.();
213
+ return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe };
214
+ }
215
+ return { stream: replay(buffered, iterator), empty: false, replayUnsafe };
179
216
  }
180
- return { stream: replay(buffered, iterator), empty: false, replayUnsafe };
217
+ } finally {
218
+ if (timeout !== undefined) clearTimeout(timeout);
181
219
  }
182
220
  }
183
221
 
@@ -221,10 +259,12 @@ export function createAdapterEventQueue(opts?: {
221
259
  // marker is not ordering — it is a latch. Dropping the incoming event
222
260
  // would discard the only record that Cursor already performed a local
223
261
  // side effect, and preflight would then permit an OAuth replay of it.
224
- if (event.replayUnsafe === true && tail.replayUnsafe !== true) {
225
- return { type: "heartbeat", replayUnsafe: true };
226
- }
227
- return tail;
262
+ if (event.replayUnsafe !== true && event.preflightReady !== true) return tail;
263
+ return {
264
+ type: "heartbeat",
265
+ ...(tail.replayUnsafe === true || event.replayUnsafe === true ? { replayUnsafe: true as const } : {}),
266
+ ...(tail.preflightReady === true || event.preflightReady === true ? { preflightReady: true as const } : {}),
267
+ };
228
268
  }
229
269
  if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) {
230
270
  if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return null;
@@ -0,0 +1,63 @@
1
+ import type { ProviderAdapter } from "./base";
2
+ import type { AdapterEvent, OcxMessage } from "../types";
3
+ import { createToolCallIdReminter, reservedToolCallIdsFromHistory } from "./openai-chat/tool-call-id-remint";
4
+
5
+ /**
6
+ * Give every tool call in a conversation a wire id the client has not already stored.
7
+ *
8
+ * The openai-chat adapter forwards `tool_calls[].id` upstream-verbatim, which is correct for an
9
+ * upstream that mints a fresh random id per call. An upstream that derives the id from the call's
10
+ * position in its response mints the same `call-0-0` on every turn. The client has already paired
11
+ * that id with an earlier call, so it drops the duplicate; the dropped call has no result, the turn
12
+ * reads as an assistant message with no content, and the model re-issues the same call forever.
13
+ *
14
+ * Applied as a wrapper rather than inside the adapter so the adapter's own id handling stays
15
+ * untouched, matching how this repository already scopes a wire-compatibility policy
16
+ * (`withClinePassDeepSeekV4ToolReplayCompatibility`).
17
+ *
18
+ * Reminting happens at emission — on the events the adapter yields — never at ingestion: ingestion
19
+ * matches streamed deltas and continuation fragments against the id the upstream sent, so rewriting
20
+ * there would strip a pending call of its own identity mid-stream. The ids to avoid come from the
21
+ * caller's own history, captured where the request is built because that is the only point that sees
22
+ * the inbound conversation; the client is the authority on which ids are taken, since it is the side
23
+ * that discards the duplicates the proxy would otherwise never observe.
24
+ *
25
+ * The first occurrence of an id is emitted byte-identical, so prompt-cache keys, reasoning-replay
26
+ * lookups, and upstreams that already mint unique ids are unaffected.
27
+ */
28
+ export function withUniqueToolCallIds(adapter: ProviderAdapter): ProviderAdapter {
29
+ // Set where the request is built and consumed by the two emission paths below — the same
30
+ // build-into-parse handoff the openai-chat adapter already uses for its requested model id. The
31
+ // identity default means a parse that runs without a build cannot fail: it has no history to
32
+ // collide with.
33
+ let remintToolCallId: (rawId: string) => string = id => id;
34
+
35
+ const remintEvents = (events: AdapterEvent[]): AdapterEvent[] =>
36
+ events.map(event => event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event);
37
+
38
+ return {
39
+ ...adapter,
40
+
41
+ async buildRequest(parsed, incoming) {
42
+ const history: OcxMessage[] | undefined = parsed.context?.messages;
43
+ remintToolCallId = createToolCallIdReminter(
44
+ Array.isArray(history) ? reservedToolCallIdsFromHistory(history) : [],
45
+ );
46
+ return adapter.buildRequest(parsed, incoming);
47
+ },
48
+
49
+ async *parseStream(response, budget, tierMetadata): AsyncGenerator<AdapterEvent> {
50
+ for await (const event of adapter.parseStream(response, budget, tierMetadata)) {
51
+ yield event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event;
52
+ }
53
+ },
54
+
55
+ ...(adapter.parseResponse
56
+ ? {
57
+ async parseResponse(response, budget, tierMetadata) {
58
+ return remintEvents(await adapter.parseResponse!(response, budget, tierMetadata));
59
+ },
60
+ }
61
+ : {}),
62
+ };
63
+ }
@@ -1,6 +1,7 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { debugProviderDiagnostic } from "../lib/debug";
3
3
  import { isXaiResponsesDestination } from "../providers/xai-transport";
4
+ import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools";
4
5
 
5
6
  const CODEX_WEB_SEARCH_TOOL = "web_search";
6
7
  const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
@@ -146,6 +147,24 @@ function hasToolType(tools: unknown, type: string): boolean {
146
147
  return Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && tool.type === type);
147
148
  }
148
149
 
150
+ /**
151
+ * OpenCode Go serves Grok on its Responses endpoint by relaying to xAI, and relays xAI's schema
152
+ * verbatim. Probed 2026-09-26 on `https://opencode.ai/zen/go/v1/responses` with grok-4.6 and
153
+ * grok-4.7, one field per request: `external_web_access` and `search_context_size` -> 400
154
+ * "Argument not supported", `web_search_preview` -> 422 `unknown variant`, while a plain
155
+ * `web_search` with `search_content_types`, `enable_image_search`, `user_location` or
156
+ * `filters` -> 200 and performs live searches. That is the xAI dialect exactly, so the same
157
+ * normalization applies. Sibling Go models (Luna, Muse Spark) accept the OpenAI fields and are
158
+ * left alone. Membership is by family prefix: an exact-id match is what let grok-4.7 reach this
159
+ * endpoint unnormalized once it joined grok-4.6 on the Responses wire.
160
+ */
161
+ function isOpenCodeGoGrokResponses(modelId: unknown, responseUrl: string | undefined): boolean {
162
+ return typeof modelId === "string"
163
+ && modelId.trim().toLowerCase().startsWith("grok-")
164
+ && typeof responseUrl === "string"
165
+ && isOpenCodeGoResponsesUrl(responseUrl);
166
+ }
167
+
149
168
  /**
150
169
  * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
151
170
  * providers or mutating the caller-owned request body.
@@ -157,12 +176,23 @@ function hasToolType(tools: unknown, type: string): boolean {
157
176
  * `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value,
158
177
  * `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical
159
178
  * to the public API, which is what makes one shared gate correct.
179
+ *
180
+ * Grok models on the exact OpenCode Go Responses URL speak the same dialect and share this gate;
181
+ * see `isOpenCodeGoGrokResponses`. `target` carries the final model id and request URL for that
182
+ * check, and callers without it keep the xAI-host-only behavior.
160
183
  */
161
184
  export function normalizeXaiResponsesWebSearch(
162
185
  body: unknown,
163
186
  provider: Pick<OcxProviderConfig, "baseUrl">,
187
+ target?: { modelId?: unknown; responseUrl?: string },
164
188
  ): unknown {
165
- if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
189
+ if (!isPlainObject(body)) return body;
190
+ const xaiDestination = isXaiResponsesDestination(provider);
191
+ if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body;
192
+ // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts)
193
+ // removes every wrapper before the wire and splits them into history and current turn by
194
+ // index against _replayPrefixLen, so deleting one would shift the current turn into history.
195
+ const keepEmptiedWrappers = !xaiDestination;
166
196
 
167
197
  let next: Record<string, unknown> = body;
168
198
  if (Array.isArray(body.tools)) {
@@ -188,7 +218,7 @@ export function normalizeXaiResponsesWebSearch(
188
218
  continue;
189
219
  }
190
220
  inputChanged = true;
191
- if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools });
221
+ if (rewritten.tools.length > 0 || keepEmptiedWrappers) input.push({ ...item, tools: rewritten.tools });
192
222
  }
193
223
  if (inputChanged) next = { ...next, input };
194
224
  }
package/src/bridge/sse.ts CHANGED
@@ -73,6 +73,12 @@ export function bridgeToResponsesSSE(
73
73
  options?: {
74
74
  responseId?: string;
75
75
  stallTimeoutSec?: number;
76
+ /**
77
+ * The upstream is local infrastructure (loopback / private / `.local` / `.lan`); an unset
78
+ * `stallTimeoutSec` then resolves to disabled so a slow local model is not cut mid-turn.
79
+ * Wire keep-alives still re-arm the client's idle clock either way.
80
+ */
81
+ localUpstream?: boolean;
76
82
  hideThinkingSummary?: boolean;
77
83
  /**
78
84
  * Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic
@@ -341,7 +347,9 @@ export function bridgeToResponsesSSE(
341
347
  ? encoder.encode(': opencodex heartbeat\n\n')
342
348
  : encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
343
349
  let stallTicks = 0;
344
- const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
350
+ const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec, {
351
+ localUpstream: options?.localUpstream,
352
+ });
345
353
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
346
354
 
347
355
  let currentMsg: {
@@ -1407,7 +1415,11 @@ export function bridgeToResponsesSSE(
1407
1415
  if (upstreamActivity) {
1408
1416
  upstreamActivity = false;
1409
1417
  stallTicks = 0;
1410
- } else if (++stallTicks >= maxStallTicks) {
1418
+ } else if (stallSec > 0 && ++stallTicks >= maxStallTicks) {
1419
+ // stallSec of 0 is an explicit disabled budget (local upstream, or the operator's
1420
+ // stallTimeoutSec: 0). Never let `maxStallTicks === 0` arm a kill on the first beat:
1421
+ // with it the `>= 0` comparison is true the moment a single tick lands, which would
1422
+ // terminate a healthy silent local model after ~2s instead of leaving it alone.
1411
1423
  if (!attemptTerminationCleanup(() => {
1412
1424
  if (currentMsg) closeCurrentMessage();
1413
1425
  if (currentReasoning) closeCurrentReasoning();
@@ -57,11 +57,21 @@ function contentToText(content: unknown): string {
57
57
  // route-eligibility predicate and this translator cannot drift apart again.
58
58
  const imageUrlFromPart = chatImageUrlFromPart;
59
59
 
60
- function videoUrlFromPart(part: Rec): string | null {
60
+ /**
61
+ * A caller's video part, with the `processing` mode Gemini's agentic video
62
+ * understanding is requested by (#3271). The object form is the only one that
63
+ * can carry it — `video_url` as a bare string has nowhere to put it.
64
+ */
65
+ function videoFromPart(part: Rec): { url: string; processing?: string } | null {
61
66
  if (part.type !== "video_url") return null;
62
67
  const videoUrl = part.video_url;
63
- if (typeof videoUrl === "string" && videoUrl.length > 0) return videoUrl;
64
- if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0) return videoUrl.url;
68
+ if (typeof videoUrl === "string" && videoUrl.length > 0) return { url: videoUrl };
69
+ if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0) {
70
+ const processing = typeof videoUrl.processing === "string" && videoUrl.processing.length > 0
71
+ ? videoUrl.processing
72
+ : undefined;
73
+ return { url: videoUrl.url, ...(processing ? { processing } : {}) };
74
+ }
65
75
  return null;
66
76
  }
67
77
 
@@ -91,9 +101,13 @@ function userContentToBlocks(content: unknown): Rec[] {
91
101
  });
92
102
  continue;
93
103
  }
94
- const videoUrl = videoUrlFromPart(raw);
95
- if (videoUrl) {
96
- blocks.push({ type: "input_video", video_url: videoUrl });
104
+ const video = videoFromPart(raw);
105
+ if (video) {
106
+ blocks.push({
107
+ type: "input_video",
108
+ video_url: video.url,
109
+ ...(video.processing ? { processing: video.processing } : {}),
110
+ });
97
111
  continue;
98
112
  }
99
113
  const file = fileFromPart(raw);
@@ -23,6 +23,7 @@ import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
23
23
  import { AnthropicRequestError } from "./inbound-records";
24
24
  import { knownModelIdsForProvider } from "../router";
25
25
  import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
26
+ import { siblingOfLivePort } from "../codex/sibling-start";
26
27
 
27
28
  export interface ClaudeAgentDef {
28
29
  file: string;
@@ -285,6 +286,9 @@ export function injectClaudeAgentDefs(
285
286
  /** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */
286
287
  rosterOverride?: readonly string[],
287
288
  ): string[] | null {
289
+ // `~/.claude/agents` is shared with the live proxy a sibling instance runs beside, which owns
290
+ // both its roster and its pruning (`src/codex/sibling-start.ts`).
291
+ if (siblingOfLivePort() !== null) return null;
288
292
  if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
289
293
  // Disabled: prune verified-owned files so stale definitions stop loading
290
294
  // in future sessions (audit 071 #3). The roster override is irrelevant here by
@@ -193,8 +193,26 @@ function collectDesktop3pModels(
193
193
  const rendered = renderDesktopProfile(reconciled, profileModels);
194
194
  const aliasesByRoute = new Map<string, string>();
195
195
  for (const model of rendered) {
196
+ const realAnthropic = model.route.startsWith("anthropic/claude-");
197
+ if (!realAnthropic && realAnthropicIds.has(model.name)) {
198
+ console.warn(`[opencodex] Claude Desktop profile alias ${model.name} for ${model.route} conflicts with a real Anthropic model; skipping the routed model`);
199
+ continue;
200
+ }
196
201
  aliasesByRoute.set(model.route, model.name);
197
- if (!model.route.startsWith("anthropic/claude-")) registry.set(model.name, model.route);
202
+ if (!realAnthropic) {
203
+ registry.set(model.name, model.route);
204
+ // Profiles persist their historical date slots, while Desktop receives
205
+ // a non-date wire id. Keep the date form resolvable for running/stale
206
+ // sessions during the migration.
207
+ const storedAlias = reconciled.assignments[model.route]?.alias;
208
+ if (storedAlias && storedAlias !== model.name) {
209
+ if (realAnthropicIds.has(storedAlias)) {
210
+ console.warn(`[opencodex] Claude Desktop stored alias ${storedAlias} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`);
211
+ } else {
212
+ registry.set(storedAlias, model.route);
213
+ }
214
+ }
215
+ }
198
216
  models.push({
199
217
  name: model.name,
200
218
  labelOverride: model.label,
@@ -203,25 +221,33 @@ function collectDesktop3pModels(
203
221
  ...(model.supports1m ? { supports1m: true, prefer1m: true } : {}),
204
222
  });
205
223
  }
206
- // Legacy hashes are compatibility-only and can collide. Bind them in stable route order so
207
- // changing a family default or rendered ordering can never silently rebind an old Desktop id.
224
+ // Hash aliases from both pre-profile generations are compatibility-only and can
225
+ // collide. Bind them in stable route order so changing a family default or
226
+ // rendered ordering can never silently rebind an old Desktop id.
208
227
  for (const model of [...rendered].sort((a, b) => a.route.localeCompare(b.route))) {
209
228
  if (model.route.startsWith("anthropic/claude-")) continue;
210
229
  const providerEnd = model.route.indexOf("/");
211
230
  const provider = model.route.slice(0, providerEnd);
212
231
  const id = model.route.slice(providerEnd + 1);
213
- const legacy = legacyDesktop3pAlias(provider, id);
214
- const existing = registry.get(legacy);
215
- if (existing && existing !== model.route) {
216
- console.warn(`[opencodex] Claude Desktop legacy alias collision: ${legacy} stays bound to ${existing}; ignoring ${model.route}`);
217
- continue;
232
+ for (const legacy of [desktop3pAlias(provider, id), legacyDesktop3pAlias(provider, id)]) {
233
+ if (realAnthropicIds.has(legacy)) {
234
+ console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`);
235
+ continue;
236
+ }
237
+ const existing = registry.get(legacy);
238
+ if (existing && existing !== model.route) {
239
+ console.warn(`[opencodex] Claude Desktop legacy alias collision: ${legacy} stays bound to ${existing}; ignoring ${model.route}`);
240
+ continue;
241
+ }
242
+ registry.set(legacy, model.route);
218
243
  }
219
- registry.set(legacy, model.route);
220
244
  }
221
245
  desktop3pAliasesByRoute = aliasesByRoute;
222
246
  return { models, registry, realAnthropicIds };
223
247
  }
224
248
 
249
+ const aliasesByRoute = new Map<string, string>();
250
+ const emittedNames = new Set<string>();
225
251
  for (const { provider, id, contextWindow } of candidates) {
226
252
  const route = `${provider}/${id}`;
227
253
  const alias = desktop3pAlias(provider, id);
@@ -232,13 +258,20 @@ function collectDesktop3pModels(
232
258
  // Real Anthropic model: keep it OUT of the decode registry — registering it would
233
259
  // make resolveInboundModel() non-identity and kill the sk-ant native passthrough
234
260
  // (audit 133 #1). It still appears in the static Desktop model list below.
261
+ if (emittedNames.has(alias)) continue;
235
262
  models.push({
236
263
  name: alias,
237
264
  labelOverride: `${displayModelId(id)} (${provider})`,
238
265
  anthropicFamilyTier: "opus",
239
266
  ...supports1m,
240
- ...(supports1m.supports1m ? { prefer1m: true as const } : {}),
267
+ ...(supports1m.supports1m ? { prefer1m: true as const } : {}),
241
268
  });
269
+ emittedNames.add(alias);
270
+ aliasesByRoute.set(route, alias);
271
+ continue;
272
+ }
273
+ if (realAnthropicIds.has(alias)) {
274
+ console.warn(`[opencodex] Claude Desktop 3P alias ${alias} for ${route} conflicts with a real Anthropic model; skipping the routed model`);
242
275
  continue;
243
276
  }
244
277
  const existingRoute = registry.get(alias);
@@ -250,7 +283,11 @@ function collectDesktop3pModels(
250
283
  registry.set(alias, route);
251
284
  // Back-compat decode for Desktop configs written before the opus-4-8 rename.
252
285
  const legacy = legacyDesktop3pAlias(provider, id);
253
- if (!registry.has(legacy)) registry.set(legacy, route);
286
+ if (realAnthropicIds.has(legacy)) {
287
+ console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${route} conflicts with a real Anthropic model; ignoring the alias`);
288
+ } else if (!registry.has(legacy)) {
289
+ registry.set(legacy, route);
290
+ }
254
291
  models.push({
255
292
  name: alias,
256
293
  labelOverride: `${displayModelId(id)} (${provider})`,
@@ -258,10 +295,11 @@ function collectDesktop3pModels(
258
295
  ...supports1m,
259
296
  ...(supports1m.supports1m ? { prefer1m: true as const } : {}),
260
297
  });
298
+ aliasesByRoute.set(route, alias);
261
299
  }
262
300
 
263
301
  if (models[0]) models[0].isFamilyDefault = true;
264
- desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)]));
302
+ desktop3pAliasesByRoute = aliasesByRoute;
265
303
  return { models, registry, realAnthropicIds };
266
304
  }
267
305
 
@@ -308,7 +346,9 @@ export function isUnresolvedDesktop3pAlias(id: string): boolean {
308
346
  // identity only; it neither enables a tier nor strips an exact full catalog ID.
309
347
  const base = id.endsWith("--fast") ? id.slice(0, -"--fast".length) : id;
310
348
  if (isKnownDesktop3pModelId(base)) return false;
311
- return validDateAlias(base) || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base);
349
+ return validDateAlias(base)
350
+ || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base)
351
+ || /^claude-opus-4-8-p[0-9a-z]{3}$/.test(base);
312
352
  }
313
353
 
314
354
  /** Alias selected by the installed profile registry, falling back to the legacy hash shape. */
@@ -28,9 +28,11 @@ export interface RenderedDesktopModel extends DesktopProfileModel {
28
28
  // design, so the set only grows). Years before 2026 stay rejected: dated
29
29
  // ids like `claude-opus-4-8-20250201` are real model snapshot ids, not
30
30
  // managed aliases, and the inbound decoder relies on that distinction.
31
- // Every emitted suffix stays 8 digits so modelMap date-stripping keeps
32
- // working.
31
+ // The persisted slot stays date-shaped for stable allocation and migration.
32
+ // renderDesktopProfile() converts it to a letter-first wire id because current
33
+ // Claude Desktop strips terminal dates before comparing model identities.
33
34
  const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/;
35
+ const PROFILE_WIRE_PREFIX = "claude-opus-4-8-";
34
36
  const LEGACY_YEAR = 2026;
35
37
  const LEGACY_DAY_COUNT = 365;
36
38
  const ALIAS_FIRST_YEAR = 2026;
@@ -123,6 +125,24 @@ export function validDateAlias(alias: string): boolean {
123
125
  return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
124
126
  }
125
127
 
128
+ /**
129
+ * Turn a persisted date slot into a unique non-date Desktop wire id.
130
+ *
131
+ * The 2026-2035 namespace has fewer than 3,888 days, so a `p`-prefixed
132
+ * three-character base36 slot has ample room. The four-character suffix is
133
+ * disjoint from both terminal dates and the historical three-character hash
134
+ * namespace used by profiles written before this allocator existed.
135
+ */
136
+ export function desktopProfileWireAlias(alias: string): string {
137
+ const match = DATE_ALIAS.exec(alias);
138
+ if (!match || !validDateAlias(alias)) return alias;
139
+ const year = Number(match[1]!.slice(0, 4));
140
+ const month = Number(match[1]!.slice(4, 6));
141
+ const day = Number(match[1]!.slice(6, 8));
142
+ const slot = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(ALIAS_FIRST_YEAR, 0, 1)) / 86_400_000);
143
+ return `${PROFILE_WIRE_PREFIX}p${slot.toString(36).padStart(3, "0")}`;
144
+ }
145
+
126
146
  export function parseDesktopProfile(value: unknown): DesktopProfile {
127
147
  if (!isPlainObject(value)) throw new DesktopProfileError("must be an object");
128
148
  // `appliedFingerprint`/`appliedAt` are written back by the apply route once a profile
@@ -235,7 +255,24 @@ export function reconcileDesktopProfile(
235
255
  const assignments: DesktopProfile["assignments"] = Object.fromEntries(
236
256
  Object.entries(profile.assignments).map(([route, assignment]) => [route, { ...assignment }]),
237
257
  );
238
- const used = new Set(Object.values(assignments).map(value => value.alias));
258
+ const activeRealAliases = new Set(models
259
+ .filter(model => isRealAnthropicRoute(model.route))
260
+ .map(model => routeModelId(model.route)));
261
+ const displacedRoutes = Object.entries(assignments)
262
+ .filter(([route, assignment]) => !isRealAnthropicRoute(route) && activeRealAliases.has(assignment.alias))
263
+ .map(([route]) => route)
264
+ .sort();
265
+ const displaced = new Set(displacedRoutes);
266
+ const used = new Set(Object.entries(assignments)
267
+ .filter(([route]) => !displaced.has(route))
268
+ .map(([, assignment]) => assignment.alias));
269
+ for (const alias of activeRealAliases) used.add(alias);
270
+ for (const route of displacedRoutes) {
271
+ const assignment = assignments[route]!;
272
+ const alias = allocateAlias(route, used);
273
+ used.add(alias);
274
+ assignments[route] = { ...assignment, alias };
275
+ }
239
276
  for (const model of [...models].sort((a, b) => a.route.localeCompare(b.route))) {
240
277
  if (assignments[model.route]) continue;
241
278
  const alias = allocateAlias(model.route, used);
@@ -319,7 +356,7 @@ export function renderDesktopProfile(
319
356
  const assignment = parsed.assignments[route]!;
320
357
  return {
321
358
  ...model,
322
- name: assignment.alias,
359
+ name: isRealAnthropicRoute(route) ? assignment.alias : desktopProfileWireAlias(assignment.alias),
323
360
  family: assignment.family,
324
361
  isFamilyDefault: effectiveDefaults[assignment.family] === route,
325
362
  supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000,