@bitkyc08/opencodex 2.63.0 → 2.64.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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-CpuDF3ci.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
  10. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  11. package/gui/dist/assets/index-SggB6t3z.js +86 -0
  12. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -138,25 +138,72 @@ function asString(value: unknown): string | undefined {
138
138
  return typeof value === "string" ? value : undefined;
139
139
  }
140
140
 
141
- /** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */
142
- export function usageFromResult(message: StreamMessage): OcxUsage | undefined {
143
- const usage = asRecord(message.usage);
144
- if (!usage) return undefined;
141
+ /** Extract OpenCodex usage from the Anthropic-shaped usage record shared by frames and deltas. */
142
+ function usageFromAnthropicShape(usage: Record<string, unknown>): OcxUsage | undefined {
145
143
  const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
146
144
  const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
147
145
  const cachedInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined;
148
146
  const cacheCreationInputTokens =
149
147
  typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined;
150
- if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === undefined) return undefined;
148
+ // A snapshot is zero-only when every counter is absent or zero. Testing only the cache-read
149
+ // field dropped a cache-creation-only snapshot (input/output 0 with, say, 200 cache-creation
150
+ // tokens), and a capture-only tool leg terminated at message_stop never sees a result frame
151
+ // that could carry those tokens instead, so the turn under-reported usage and cost.
152
+ const cacheReadTotal = cachedInputTokens ?? 0;
153
+ const cacheCreationTotal = cacheCreationInputTokens ?? 0;
154
+ if (inputTokens === 0 && outputTokens === 0 && cacheReadTotal === 0 && cacheCreationTotal === 0) {
155
+ return undefined;
156
+ }
157
+ return {
158
+ inputTokens,
159
+ outputTokens,
160
+ totalTokens: inputTokens + outputTokens,
161
+ ...(cacheReadTotal > 0 ? { cachedInputTokens: cacheReadTotal, cacheReadInputTokens: cacheReadTotal } : {}),
162
+ ...(cacheCreationTotal > 0 ? { cacheCreationInputTokens: cacheCreationTotal } : {}),
163
+ };
164
+ }
165
+
166
+ /** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */
167
+ export function usageFromResult(message: StreamMessage): OcxUsage | undefined {
168
+ const usage = asRecord(message.usage);
169
+ return usage ? usageFromAnthropicShape(usage) : undefined;
170
+ }
171
+
172
+ /**
173
+ * Fold a pre-result usage snapshot into the running partial usage.
174
+ *
175
+ * `message_delta` and assistant-frame snapshots are cumulative per message, but a later snapshot
176
+ * can repeat or extend an earlier one, so each field keeps its maximum. The `result` frame stays
177
+ * authoritative for a text-only turn; partial state exists so a capture-only tool-bridge turn —
178
+ * which is terminated at `message_stop` before any result frame can arrive — still reports real
179
+ * token usage instead of zero.
180
+ */
181
+ function mergePartialUsage(previous: OcxUsage | undefined, next: OcxUsage): OcxUsage {
182
+ if (!previous) return next;
183
+ const inputTokens = Math.max(previous.inputTokens, next.inputTokens);
184
+ const outputTokens = Math.max(previous.outputTokens, next.outputTokens);
185
+ const cacheRead = Math.max(
186
+ previous.cacheReadInputTokens ?? previous.cachedInputTokens ?? 0,
187
+ next.cacheReadInputTokens ?? next.cachedInputTokens ?? 0,
188
+ );
189
+ const cacheCreation = Math.max(previous.cacheCreationInputTokens ?? 0, next.cacheCreationInputTokens ?? 0);
151
190
  return {
152
191
  inputTokens,
153
192
  outputTokens,
154
193
  totalTokens: inputTokens + outputTokens,
155
- ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}),
156
- ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
194
+ ...(cacheRead > 0 ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}),
195
+ ...(cacheCreation > 0 ? { cacheCreationInputTokens: cacheCreation } : {}),
157
196
  };
158
197
  }
159
198
 
199
+ /** Record one usage snapshot; absent, malformed, or zero-only snapshots leave state untouched. */
200
+ function observePartialUsage(state: StreamParseState, value: unknown): void {
201
+ const usage = asRecord(value);
202
+ if (!usage) return;
203
+ const next = usageFromAnthropicShape(usage);
204
+ if (next) state.partialUsage = mergePartialUsage(state.partialUsage, next);
205
+ }
206
+
160
207
  /**
161
208
  * Mutable per-turn parse state shared across frames of one stream (§十二).
162
209
  * Thinking and text states are strictly decoupled.
@@ -166,6 +213,16 @@ export interface StreamParseState {
166
213
  sawPartialThinking: boolean;
167
214
  sawTerminalResult: boolean;
168
215
  openToolCallId?: string;
216
+ /** A `message_stop` stream event arrived: the assistant message is complete. */
217
+ sawMessageStop?: boolean;
218
+ /** Completed tool_use content blocks observed in this stream. */
219
+ completedToolCalls?: number;
220
+ /** Tool IDs already captured through partial events, for complete-assistant deduplication. */
221
+ partialToolCallIds?: Set<string>;
222
+ /** A complete assistant tool block had no matching partial capture. */
223
+ uncapturedToolUse?: boolean;
224
+ /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */
225
+ partialUsage?: OcxUsage;
169
226
  }
170
227
 
171
228
  /**
@@ -188,7 +245,8 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa
188
245
  if (type === "assistant") {
189
246
  // Fallback path: a complete assistant message. Surface text and thinking independently
190
247
  // only when the partial delta stream did not already carry them (§十二).
191
- const content = asRecord(message.message)?.content;
248
+ const messageRecord = asRecord(message.message);
249
+ const content = messageRecord?.content;
192
250
  if (Array.isArray(content)) {
193
251
  for (const block of content) {
194
252
  const part = asRecord(block);
@@ -200,9 +258,13 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa
200
258
  } else if (blockType === "thinking" && !state.sawPartialThinking) {
201
259
  const thinking = asString(part.thinking);
202
260
  if (thinking) events.push({ type: "thinking_delta", thinking });
261
+ } else if (blockType === "tool_use") {
262
+ const id = asString(part.id);
263
+ if (!id || !state.partialToolCallIds?.has(id)) state.uncapturedToolUse = true;
203
264
  }
204
265
  }
205
266
  }
267
+ observePartialUsage(state, messageRecord?.usage);
206
268
  return events;
207
269
  }
208
270
 
@@ -281,8 +343,9 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
281
343
  events.push({ type: "thinking_delta", thinking });
282
344
  }
283
345
  } else if (deltaType === "input_json_delta") {
284
- // Tool-input streaming. Inert while tools are disabled (Codex's catalog is not advertised),
285
- // but parsed so the seam is ready and an unexpected frame never crashes.
346
+ // Tool-input streaming. Live for capture-only bridge turns, where the advertised MCP
347
+ // catalog makes the CLI emit real tool_use blocks; parsed unconditionally so a stray
348
+ // frame on a tools-disabled turn is ignored rather than crashing.
286
349
  const partial = asString(delta?.partial_json);
287
350
  if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial });
288
351
  }
@@ -296,6 +359,7 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
296
359
  const name = asString(block?.name) ?? "tool";
297
360
  if (id) {
298
361
  state.openToolCallId = id;
362
+ state.partialToolCallIds?.add(id);
299
363
  events.push({ type: "tool_call_start", id, name });
300
364
  }
301
365
  }
@@ -305,14 +369,63 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
305
369
  if (eventType === "content_block_stop") {
306
370
  if (state.openToolCallId) {
307
371
  state.openToolCallId = undefined;
372
+ state.completedToolCalls = (state.completedToolCalls ?? 0) + 1;
308
373
  events.push({ type: "tool_call_end" });
309
374
  }
310
375
  return events;
311
376
  }
312
377
 
378
+ if (eventType === "message_stop") {
379
+ state.sawMessageStop = true;
380
+ return events;
381
+ }
382
+
383
+ if (eventType === "message_start") {
384
+ // Anthropic-shaped streams report input tokens on `message_start.message.usage` and output
385
+ // tokens later on `message_delta.usage`. A capture-only tool leg is terminated at
386
+ // `message_stop`, so without this branch the synthesized done(tool_use) undercounts input
387
+ // tokens whenever the CLI puts them here (and `message_stop` arrives before any assistant
388
+ // fallback frame that would otherwise carry them).
389
+ const messageRecord = asRecord(event.message);
390
+ observePartialUsage(state, messageRecord?.usage);
391
+ return events;
392
+ }
393
+
394
+ if (eventType === "message_delta") {
395
+ // Pre-result usage snapshots: a capture-only tool-bridge turn ends at message_stop with no
396
+ // result frame, so these snapshots are the only token accounting that leg will ever see.
397
+ observePartialUsage(state, event.usage);
398
+ return events;
399
+ }
400
+
313
401
  return events;
314
402
  }
315
403
 
404
+ /**
405
+ * Validate a `system/init` frame against an active capture-only tool bridge.
406
+ *
407
+ * With the bridge armed, the CLI must report exactly the bridge's MCP server as connected: a
408
+ * missing or failed server means the model never saw the advertised catalog, so the turn fails
409
+ * closed instead of silently degrading to a text-only answer.
410
+ */
411
+ export function toolBridgeInitError(message: StreamMessage, serverName: string): string | undefined {
412
+ if (message.type !== "system" || message.subtype !== "init") return undefined;
413
+ const servers = message.mcp_servers;
414
+ if (!Array.isArray(servers) || servers.length !== 1) {
415
+ return "Coding-agent system/init reported an unexpected MCP server set for the tool bridge.";
416
+ }
417
+ const server = servers[0];
418
+ if (
419
+ !server
420
+ || typeof server !== "object"
421
+ || server.name !== serverName
422
+ || server.status !== "connected"
423
+ ) {
424
+ return `Coding-agent system/init did not report the ${serverName} MCP server as connected.`;
425
+ }
426
+ return undefined;
427
+ }
428
+
316
429
  /** One content part on the stream-json input wire (Anthropic message shape). */
317
430
  type WireContentPart = Record<string, unknown>;
318
431
 
@@ -1,9 +1,21 @@
1
1
  import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
3
6
  import { commandInvocation } from "../../lib/win-exec";
7
+ import { isStandaloneBinary } from "../../lib/standalone";
4
8
  import { modelRecordValue } from "../../reasoning-effort";
5
9
  import type { IncomingMeta } from "../base";
6
- import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, projectedHistoryCharLimit, readJsonLines, type StreamParseState } from "./protocol";
10
+ import {
11
+ buildConversationInput,
12
+ CodingAgentProtocolError,
13
+ mapStreamMessageToEvents,
14
+ projectedHistoryCharLimit,
15
+ readJsonLines,
16
+ toolBridgeInitError,
17
+ type StreamParseState,
18
+ } from "./protocol";
7
19
  import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile";
8
20
 
9
21
  /** Injectable spawn for tests; production uses node:child_process. */
@@ -26,6 +38,8 @@ export interface CodingAgentDeps {
26
38
  platform?: NodeJS.Platform;
27
39
  /** Test seam for terminating a Windows CLI and all descendants. */
28
40
  killWindowsProcessTree?: KillWindowsProcessTreeFn;
41
+ /** Test seam for a catalog/config write failure after private bridge-directory creation. */
42
+ writeToolBridgeFile?: typeof writeFile;
29
43
  }
30
44
 
31
45
  const DEFAULT_TIMEOUT_MS = 300_000;
@@ -88,9 +102,41 @@ export interface CodingAgentTurnInput {
88
102
  buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[];
89
103
  /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */
90
104
  buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record<string, string>;
105
+ /**
106
+ * Opt-in capture-only tool bridge. When present with a non-empty catalog, the turn writes a
107
+ * validated catalog plus an MCP config to a private temp dir, passes `--mcp-config` (with exact
108
+ * `--allowedTools`) alongside the family's tools-disabled args, translates captured tool_use
109
+ * names back to request wire names, and terminates the process tree at `message_stop` because
110
+ * the capture-only MCP handler intentionally never answers. Execution stays with the client.
111
+ */
112
+ toolBridge?: CodingAgentToolBridgeInput;
91
113
  deps: CodingAgentDeps;
92
114
  }
93
115
 
116
+ /** Opt-in capture-only tool bridge for one coding-agent CLI turn. */
117
+ export interface CodingAgentToolBridgeInput {
118
+ /** MCP server name advertised to the CLI; tool_use blocks render it as `mcp__<name>__<tool>`. */
119
+ serverName: string;
120
+ /** Absolute path of the capture-only MCP server module, run with the serving runtime. */
121
+ serverModulePath: string;
122
+ /** Validated tool catalog advertised over ListTools; the server never executes a call. */
123
+ tools: ReadonlyArray<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
124
+ /** CLI-emitted tool name (`mcp__<server>__<tool>`) to the request's wire tool name. */
125
+ emittedNameMap: Map<string, string>;
126
+ /** Captured tool_use blocks accepted in one assistant message. */
127
+ maxTurnToolCalls: number;
128
+ /**
129
+ * The request's `tool_choice` requires a tool call (`required`, or a named selection).
130
+ * The nested CLI has no documented force-tool flag, so this is enforced locally: a
131
+ * terminal text result on a required turn fails closed instead of silently succeeding.
132
+ */
133
+ requireToolCall?: boolean;
134
+ }
135
+
136
+ export function codeBuddyMcpInvocation(serverModulePath: string, catalogPath: string, standalone = isStandaloneBinary()): string[] {
137
+ return standalone ? ["__codebuddy-mcp", catalogPath] : [serverModulePath, catalogPath];
138
+ }
139
+
94
140
  /**
95
141
  * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十).
96
142
  *
@@ -153,7 +199,62 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
153
199
  return;
154
200
  }
155
201
 
202
+ const toolBridge = input.toolBridge;
203
+ const writeToolBridgeFile = deps.writeToolBridgeFile ?? writeFile;
204
+ let toolBridgeDir: string | undefined;
205
+ let toolBridgeMcpConfigPath: string | undefined;
206
+ if (toolBridge) {
207
+ if (toolBridge.tools.length === 0 || toolBridge.emittedNameMap.size === 0) {
208
+ emit({
209
+ type: "error",
210
+ message: "Coding-agent tool bridge was supplied without any isolated tools.",
211
+ status: 500,
212
+ errorType: "server_error",
213
+ code: "tool_bridge_empty",
214
+ retryable: false,
215
+ });
216
+ return;
217
+ }
218
+ try {
219
+ toolBridgeDir = await mkdtemp(join(tmpdir(), "ocx-coding-agent-tools-"));
220
+ const catalogPath = join(toolBridgeDir, "catalog.json");
221
+ toolBridgeMcpConfigPath = join(toolBridgeDir, "mcp.json");
222
+ await writeToolBridgeFile(catalogPath, JSON.stringify(toolBridge.tools), { encoding: "utf8", mode: 0o600, flag: "wx" });
223
+ await writeToolBridgeFile(
224
+ toolBridgeMcpConfigPath,
225
+ JSON.stringify({
226
+ mcpServers: {
227
+ [toolBridge.serverName]: {
228
+ type: "stdio",
229
+ command: process.execPath,
230
+ args: codeBuddyMcpInvocation(toolBridge.serverModulePath, catalogPath),
231
+ defer_loading: false,
232
+ alwaysLoad: true,
233
+ },
234
+ },
235
+ }),
236
+ { encoding: "utf8", mode: 0o600, flag: "wx" },
237
+ );
238
+ } catch {
239
+ emit({
240
+ type: "error",
241
+ message: "Coding-agent tool bridge could not be staged securely.",
242
+ status: 500,
243
+ errorType: "server_error",
244
+ code: "tool_bridge_setup_failed",
245
+ retryable: false,
246
+ });
247
+ if (toolBridgeDir) await rm(toolBridgeDir, { recursive: true, force: true }).catch(() => undefined);
248
+ return;
249
+ }
250
+ }
251
+
156
252
  const args = buildArgs(profile, parsed, provider);
253
+ if (toolBridge && toolBridgeMcpConfigPath) {
254
+ // Exact names close the wildcard domain; --strict-mcp-config (family args) keeps user
255
+ // servers out, so the capture server is the only capability this turn can reach.
256
+ args.push("--allowedTools", [...toolBridge.emittedNameMap.keys()].join(","), "--mcp-config", toolBridgeMcpConfigPath);
257
+ }
157
258
  const env = buildEnv(profile, apiKey);
158
259
  const invocation = commandInvocation(binary, args, platform, { env });
159
260
 
@@ -174,6 +275,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
174
275
  code: "cli_spawn_failed",
175
276
  retryable: false,
176
277
  });
278
+ // A synchronous spawn() throw skips the event-loop `finally` below, so the private
279
+ // bridge dir would leak unless it is removed here as well.
280
+ if (toolBridgeDir) await rm(toolBridgeDir, { recursive: true, force: true }).catch(() => undefined);
177
281
  return;
178
282
  }
179
283
 
@@ -218,6 +322,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
218
322
  return;
219
323
  } catch { /* fall back to terminating the direct child */ }
220
324
  }
325
+ // The capture-only MCP server is the CLI's child. Its stdin closes when the CLI dies, and
326
+ // mcp-server.ts exits on stdin EOF, so this ladder reaps the whole tree without knowing
327
+ // the grandchild pid.
221
328
  try { child.kill("SIGTERM"); } catch { /* already gone */ }
222
329
  killTimer = setTimeout(() => {
223
330
  try { child.kill("SIGKILL"); } catch { /* already gone */ }
@@ -253,11 +360,18 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
253
360
 
254
361
  let streamProtocolError: string | undefined;
255
362
  let turnError: string | undefined;
363
+ // A successful result frame that arrived after every captured tool call completed but
364
+ // before message_stop. The bridge contract still ends the leg with the synthesized
365
+ // done(tool_use) at message_stop, so the result-derived done(stop) is deferred and its
366
+ // usage (authoritative vendor accounting) is folded into the synthesis. Set inside the
367
+ // stream loop; read by the synthesis and the stream-end error selection below.
368
+ let deferredResultDone: Extract<AdapterEvent, { type: "done" }> | undefined;
256
369
  const state: StreamParseState = {
257
370
  sawPartialText: false,
258
371
  sawPartialThinking: false,
259
372
  sawTerminalResult: false,
260
373
  openToolCallId: undefined,
374
+ partialToolCallIds: toolBridge ? new Set<string>() : undefined,
261
375
  };
262
376
 
263
377
  try {
@@ -278,13 +392,209 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
278
392
  const stdout = child.stdout;
279
393
  if (!stdout) throw new CodingAgentProtocolError(`${profile.label} CLI produced no stdout stream`);
280
394
  try {
395
+ let initValidated = false;
396
+ let toolCallStarts = 0;
397
+ let failClosed = false;
281
398
  for await (const message of readJsonLines(stdout)) {
282
399
  if (incoming.abortSignal?.aborted) break;
283
- for (const event of mapStreamMessageToEvents(message, state)) {
400
+ if (toolBridge) {
401
+ const initError = toolBridgeInitError(message, toolBridge.serverName);
402
+ if (initError) {
403
+ emitOnce({
404
+ type: "error",
405
+ message: initError,
406
+ status: 502,
407
+ errorType: "upstream_error",
408
+ code: "tool_bridge_init_mismatch",
409
+ retryable: false,
410
+ });
411
+ kill();
412
+ break;
413
+ }
414
+ if (message.type === "system" && message.subtype === "init") initValidated = true;
415
+ }
416
+ const mappedEvents = mapStreamMessageToEvents(message, state);
417
+ if (toolBridge && state.uncapturedToolUse) {
418
+ emitOnce({
419
+ type: "error",
420
+ message: "Coding-agent CLI returned a tool call without a partial tool capture.",
421
+ status: 502,
422
+ errorType: "upstream_error",
423
+ code: "protocol_error",
424
+ retryable: false,
425
+ });
426
+ kill();
427
+ break;
428
+ }
429
+ for (const event of mappedEvents) {
430
+ if (toolBridge && !initValidated && event.type === "done") {
431
+ emitOnce({
432
+ type: "error",
433
+ message: "Coding-agent CLI ended before the tool bridge init handshake completed.",
434
+ status: 502,
435
+ errorType: "upstream_error",
436
+ code: "tool_bridge_init_missing",
437
+ retryable: false,
438
+ });
439
+ failClosed = true;
440
+ kill();
441
+ break;
442
+ }
443
+ if (toolBridge && event.type === "tool_call_start") {
444
+ // The catalog is only advertised once the CLI has acknowledged the bridge server in
445
+ // its init handshake. A tool call that arrives before that acknowledgement means the
446
+ // model acted on a catalog this bridge never validated, so fail closed before the
447
+ // call is counted or renamed. Checking at arrival matters: a later init frame used to
448
+ // set initValidated and let an early call finish as a successful done(tool_use).
449
+ if (!initValidated) {
450
+ emitOnce({
451
+ type: "error",
452
+ message: "Coding-agent CLI called a tool before the tool bridge init handshake completed.",
453
+ status: 502,
454
+ errorType: "upstream_error",
455
+ code: "tool_bridge_init_missing",
456
+ retryable: false,
457
+ });
458
+ failClosed = true;
459
+ kill();
460
+ break;
461
+ }
462
+ toolCallStarts += 1;
463
+ if (toolCallStarts > toolBridge.maxTurnToolCalls) {
464
+ emitOnce({
465
+ type: "error",
466
+ message: `Coding-agent CLI returned more than the ${toolBridge.maxTurnToolCalls}-tool-call turn limit.`,
467
+ status: 502,
468
+ errorType: "upstream_error",
469
+ code: "tool_call_limit",
470
+ retryable: false,
471
+ });
472
+ failClosed = true;
473
+ kill();
474
+ break;
475
+ }
476
+ const wireName = toolBridge.emittedNameMap.get(event.name);
477
+ if (wireName === undefined) {
478
+ emitOnce({
479
+ type: "error",
480
+ message: "Coding-agent CLI called a tool outside the isolated catalog.",
481
+ status: 502,
482
+ errorType: "upstream_error",
483
+ code: "undeclared_tool_call",
484
+ retryable: false,
485
+ });
486
+ failClosed = true;
487
+ kill();
488
+ break;
489
+ }
490
+ emitOnce({ ...event, name: wireName });
491
+ continue;
492
+ }
493
+ if (
494
+ toolBridge?.requireToolCall === true
495
+ && !terminalEmitted
496
+ && event.type === "done"
497
+ && event.stopReason !== "tool_use"
498
+ && (state.completedToolCalls ?? 0) === 0
499
+ ) {
500
+ // `tool_choice: required|named` on a bridge turn: a text-only terminal result must not
501
+ // become a successful completion the client can accept. The capture-only bridge has no
502
+ // way to force the nested CLI, so fail closed with the same stable error shape the
503
+ // other bridge contract violations use.
504
+ emitOnce({
505
+ type: "error",
506
+ message: "CodeBuddy finished without calling the required tool.",
507
+ status: 502,
508
+ errorType: "upstream_error",
509
+ code: "tool_call_required",
510
+ retryable: false,
511
+ });
512
+ failClosed = true;
513
+ kill();
514
+ break;
515
+ }
516
+ if (
517
+ toolBridge
518
+ && !terminalEmitted
519
+ && event.type === "done"
520
+ && toolCallStarts > 0
521
+ && (state.completedToolCalls ?? 0) !== toolCallStarts
522
+ ) {
523
+ // A terminal result that arrives while a captured tool call is still open must not
524
+ // become a successful completion the client can accept. The message_stop check after
525
+ // the event loop cannot cover this path: the CLI normally parks on the
526
+ // never-answering capture server, but a stream that delivers the result frame
527
+ // without (or before) message_stop emits done here, and started-but-unfinished
528
+ // calls slipped through as successful turns.
529
+ emitOnce({
530
+ type: "error",
531
+ message: "Coding-agent CLI ended with an incomplete tool call.",
532
+ status: 502,
533
+ errorType: "upstream_error",
534
+ code: "protocol_error",
535
+ retryable: false,
536
+ });
537
+ failClosed = true;
538
+ kill();
539
+ break;
540
+ }
541
+ if (
542
+ toolBridge
543
+ && !terminalEmitted
544
+ && event.type === "done"
545
+ && toolCallStarts > 0
546
+ && (state.completedToolCalls ?? 0) === toolCallStarts
547
+ ) {
548
+ // Every captured call completed and the CLI settled with a successful result before
549
+ // message_stop (instead of parking on the never-answering capture server). Emitting
550
+ // this done(stop) now would end the turn as a text completion and skip the
551
+ // synthesized done(tool_use) the client contract expects. Defer it: message_stop
552
+ // synthesis emits the terminal event with this frame's usage, and a stream that
553
+ // ends without message_stop fails closed with protocol_error below.
554
+ deferredResultDone = event;
555
+ continue;
556
+ }
284
557
  emitOnce(event.type === "error"
285
558
  ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) }
286
559
  : event);
287
560
  }
561
+ if (failClosed) break;
562
+ if (
563
+ toolBridge
564
+ && !terminalEmitted
565
+ && state.sawMessageStop
566
+ && toolCallStarts > 0
567
+ && (state.completedToolCalls ?? 0) !== toolCallStarts
568
+ ) {
569
+ emitOnce({
570
+ type: "error",
571
+ message: "Coding-agent CLI ended with an incomplete tool call.",
572
+ status: 502,
573
+ errorType: "upstream_error",
574
+ code: "protocol_error",
575
+ retryable: false,
576
+ });
577
+ kill();
578
+ break;
579
+ }
580
+ if (toolBridge && !terminalEmitted && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) {
581
+ // No init re-check here: a completed call implies a tool_call_start was mapped, and the
582
+ // arrival-time gate above already refuses any start that lands before the handshake.
583
+ // The capture-only MCP handler never answers, so the CLI parks after message_stop.
584
+ // The completed tool_use blocks are this turn's structured output: end the leg here
585
+ // and terminate the tree; the client executes, and the next request continues.
586
+ // Pre-result usage snapshots keep this terminated leg accountable: no result frame
587
+ // ever arrives for a turn parked on the never-answering capture server.
588
+ const terminalUsage = deferredResultDone?.usage ?? state.partialUsage;
589
+ emitOnce({
590
+ type: "done",
591
+ stopReason: "tool_use",
592
+ endTurn: false,
593
+ ...(terminalUsage ? { usage: terminalUsage } : {}),
594
+ });
595
+ kill();
596
+ break;
597
+ }
288
598
  if (terminalEmitted) break;
289
599
  }
290
600
  } catch (err) {
@@ -296,6 +606,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
296
606
  turnError = err instanceof Error ? err.message : String(err);
297
607
  } finally {
298
608
  cleanup();
609
+ if (toolBridgeDir) {
610
+ await rm(toolBridgeDir, { recursive: true, force: true }).catch(() => undefined);
611
+ }
299
612
  }
300
613
 
301
614
  // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and
@@ -353,6 +666,15 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
353
666
  code: "process_exit_error",
354
667
  retryable: false,
355
668
  });
669
+ } else if (toolBridge && deferredResultDone !== undefined && !state.sawMessageStop) {
670
+ emitOnce({
671
+ type: "error",
672
+ message: `${profile.label} CLI delivered a terminal result before message_stop on a tool-bridge turn.`,
673
+ status: 502,
674
+ errorType: "upstream_error",
675
+ code: "protocol_error",
676
+ retryable: false,
677
+ });
356
678
  } else if (!state.sawTerminalResult) {
357
679
  const msg = stderr
358
680
  ? `${profile.label} CLI ended without a terminal result frame: ${stderr}`