@bitkyc08/opencodex 2.83.0 → 2.84.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 (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -0,0 +1,205 @@
1
+ /**
2
+ * V1 collaboration evidence nested inside Codex's code-mode `exec` tool (#6844).
3
+ *
4
+ * In code-mode-only turns codex-rs does not put the collaboration tools on the wire. When they are
5
+ * ENABLED it renders each one into the custom `exec` description instead
6
+ * (code-mode-protocol/src/description.rs, build_exec_tool_description): a top-level heading line,
7
+ * the tool's own description, then an "exec tool declaration:" ts fence holding
8
+ * "declare const tools: { <name>(args: ...): Promise<...>; };". A namespaced tool is flattened to
9
+ * <namespace>__<name> (tools/src/code_mode.rs, code_mode_name_for_tool_name), and V1 always
10
+ * registers under the fixed namespace multi_agent_v1, so a rendered V1 surface reads
11
+ * multi_agent_v1__spawn_agent.
12
+ *
13
+ * That rendering is the only proof of V1 inside exec itself. When the model supports tool search,
14
+ * codex-rs registers V1 as DEFERRED and leaves it out of the description entirely, and every
15
+ * OpenCodex catalog row advertises tool search. Such a turn carries no V1 evidence here; a bare
16
+ * exec, the ALL_TOOLS mention, and the saved OpenCodex mode prove nothing about the conversation
17
+ * (codex-rs pins the version per session). The deferred case has its own, separate evidence source
18
+ * in deferred-collab-surface.ts, which uses this file's genuine-exec check as a host guard.
19
+ *
20
+ * A tool description is copied into exec verbatim, so one that reproduces a complete top-level V1
21
+ * section is indistinguishable from the host's own rendering. This parser cannot authenticate the
22
+ * host; it only refuses everything short of that exact shape.
23
+ */
24
+ import { CODE_MODE_EXEC_TOOL_NAME, declaresCodeModeExec, type OcxTool } from "../../types";
25
+
26
+ export type CodeModeV1Evidence = "v1" | "ambiguous" | null;
27
+
28
+ /** What a request's code-mode exec proves about collaboration, before any combination policy. */
29
+ export interface CodeModeExecAnalysis {
30
+ /** A codex-rs code-mode exec is declared: bare, freeform, template-led, no bare shell bridge. */
31
+ genuineExec: boolean;
32
+ /** That exec renders a complete multi_agent_v1__spawn_agent section. */
33
+ renderedV1: boolean;
34
+ /** That exec mentions a spawn_agent or V2 companion that is not V1's, quoted or not. */
35
+ foreignCollabClaim: boolean;
36
+ }
37
+
38
+ /** First line of codex-rs EXEC_DESCRIPTION_TEMPLATE; an overridden or foreign exec fails closed. */
39
+ const CODE_MODE_EXEC_TEMPLATE_HEADER = "Run JavaScript code to orchestrate/compose tool calls";
40
+ const MULTI_AGENT_V1_SPAWN = "multi_agent_v1__spawn_agent";
41
+ const SPAWN = "spawn_agent";
42
+ const V2_COMPANIONS = ["send_message", "followup_task", "interrupt_agent", "list_agents"] as const;
43
+ const DECLARATION_LEAD = "exec tool declaration:";
44
+
45
+ const TOOL_HEADING = /^### `([A-Za-z_$][\w$]*)`(?: \(`([^`]+)`\))?$/;
46
+ const DECLARATION_FENCE = "```ts";
47
+ // CommonMark fences: up to three spaces of indentation, three or more backticks or tildes. A fence
48
+ // closes only on the same character, at least as long, with nothing but whitespace after it.
49
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
50
+ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
51
+
52
+ interface Fence { char: string; length: number }
53
+
54
+ function openedFence(line: string): Fence | undefined {
55
+ const match = FENCE_OPEN.exec(line);
56
+ if (!match) return undefined;
57
+ const run = match[1]!;
58
+ // A backtick fence's info string may not contain a backtick (that line is inline code).
59
+ if (run[0] === "`" && match[2]!.includes("`")) return undefined;
60
+ return { char: run[0]!, length: run.length };
61
+ }
62
+
63
+ function closesFence(line: string, fence: Fence): boolean {
64
+ const match = FENCE_CLOSE.exec(line);
65
+ return match !== null && match[1]![0] === fence.char && match[1]!.length >= fence.length;
66
+ }
67
+
68
+ function isCodeModeExec(tool: OcxTool): boolean {
69
+ return tool.name === CODE_MODE_EXEC_TOOL_NAME && !tool.namespace && tool.freeform === true
70
+ && typeof tool.description === "string"
71
+ && tool.description.startsWith(CODE_MODE_EXEC_TEMPLATE_HEADER);
72
+ }
73
+
74
+ /** A nested tool rendered as a heading plus its own declaration; `raw` is the pre-normalization name. */
75
+ interface RenderedTool { name: string; raw: string }
76
+
77
+ /** The declaration fence codex-rs opens: three backticks and "ts". */
78
+ const DECLARATION_FENCE_SHAPE: Fence = { char: "`", length: 3 };
79
+
80
+ /** A complete declaration as render_code_mode_sample emits it, single- or multi-line. */
81
+ function isCompleteDeclaration(body: readonly string[], name: string): boolean {
82
+ const text = body.join("\n");
83
+ const prefix = "declare const tools: { " + name + "(";
84
+ if (!text.startsWith(prefix)) return false;
85
+ // A named, typed parameter (`args` for functions, `input` for freeform) and a non-empty return type.
86
+ return /^(?:args|input): \S[\s\S]*\): Promise<[^>\s][\s\S]*>; \};$/.test(text.slice(prefix.length));
87
+ }
88
+
89
+ /** codex-rs normalize_code_mode_identifier: invalid identifier characters become `_`. */
90
+ function normalizeCodeModeIdentifier(raw: string): string {
91
+ let identifier = "";
92
+ for (const [index, ch] of [...raw].entries()) {
93
+ const valid = index === 0 ? /[A-Za-z_$]/.test(ch) : /[A-Za-z0-9_$]/.test(ch);
94
+ identifier += valid ? ch : "_";
95
+ }
96
+ return identifier === "" ? "_" : identifier;
97
+ }
98
+
99
+ /** A heading as render_tool_heading emits it: a raw suffix only when normalization changed the name. */
100
+ function renderedHeading(match: RegExpExecArray): RenderedTool | undefined {
101
+ const name = match[1]!;
102
+ const raw = match[2];
103
+ if (raw === undefined) return { name, raw: name };
104
+ return raw !== name && normalizeCodeModeIdentifier(raw) === name ? { name, raw } : undefined;
105
+ }
106
+
107
+ /**
108
+ * Nested tools whose top-level heading is followed by the complete declaration codex-rs renders for
109
+ * the same name. A section ends at its own declaration, not at the next heading, because tool and
110
+ * namespace descriptions (V1 spawn's guidance, a configured usage hint) carry their own Markdown,
111
+ * including example headings and example declarations. Pending headings form a stack: a
112
+ * declaration is owned by the outermost pending heading with its name, which is removed together
113
+ * with anything opened after it; a declaration nobody owns is an example and leaves the stack
114
+ * alone. Lines inside a quoted fence can neither open a section nor declare a tool.
115
+ */
116
+ function renderedNestedTools(description: string): RenderedTool[] {
117
+ const lines = description.split("\n");
118
+ const rendered: RenderedTool[] = [];
119
+ const pending: RenderedTool[] = [];
120
+ let fence: Fence | undefined;
121
+ for (let i = 0; i < lines.length; i += 1) {
122
+ const line = lines[i]!;
123
+ if (fence) {
124
+ if (closesFence(line, fence)) fence = undefined;
125
+ continue;
126
+ }
127
+ const match = TOOL_HEADING.exec(line);
128
+ if (match) {
129
+ const heading = renderedHeading(match);
130
+ if (heading) pending.push(heading);
131
+ continue;
132
+ }
133
+ if (line === DECLARATION_LEAD && lines[i + 1] === DECLARATION_FENCE) {
134
+ let close = i + 2;
135
+ while (close < lines.length && !closesFence(lines[close]!, DECLARATION_FENCE_SHAPE)) close += 1;
136
+ if (close >= lines.length) return rendered; // unterminated: nothing after it is structure
137
+ const body = lines.slice(i + 2, close);
138
+ // The outermost pending heading with the declared name owns it, so a same-name heading quoted
139
+ // inside the tool's own description cannot take over the genuine section.
140
+ const owner = pending.findIndex(tool => isCompleteDeclaration(body, tool.name));
141
+ if (owner >= 0) {
142
+ rendered.push(pending[owner]!);
143
+ pending.length = owner;
144
+ }
145
+ i = close;
146
+ continue;
147
+ }
148
+ fence = openedFence(line);
149
+ }
150
+ return rendered;
151
+ }
152
+
153
+ /** A nested name that claims a collaboration surface other than V1's. */
154
+ function isForeignCollabName(name: string): boolean {
155
+ if (name.startsWith("mcp__")) return false; // MCP server tools are never native collaboration
156
+ if (name === MULTI_AGENT_V1_SPAWN) return false;
157
+ if (name === SPAWN || name.endsWith("__" + SPAWN)) return true;
158
+ return V2_COMPANIONS.some(companion => name === companion || name.endsWith("__" + companion));
159
+ }
160
+
161
+ /** Every name a heading or declaration line mentions, quoted or not. */
162
+ function mentionedToolNames(description: string): string[] {
163
+ const names: string[] = [];
164
+ for (const line of description.split("\n")) {
165
+ const heading = TOOL_HEADING.exec(line);
166
+ if (heading) {
167
+ names.push(heading[1]!);
168
+ if (heading[2] !== undefined) names.push(heading[2]);
169
+ }
170
+ for (const match of line.matchAll(/declare const tools: \{ ([A-Za-z_$][\w$]*)\(/g)) names.push(match[1]!);
171
+ }
172
+ return names;
173
+ }
174
+
175
+ /**
176
+ * Reads every genuine code-mode exec once. The two collaboration halves are deliberately
177
+ * asymmetric. V1 evidence needs a rendered section (a heading followed by its complete
178
+ * declaration). The contradiction check reads every heading and declaration line, inside fences or
179
+ * not and whoever "owns" it, so text copied into the listing can only push a turn toward null; it
180
+ * cannot hide another surface. The contradiction is reported even without rendered V1, because
181
+ * deferred V1 evidence must also yield to a rendered foreign surface.
182
+ */
183
+ export function analyzeCodeModeExec(tools: readonly OcxTool[] | undefined): CodeModeExecAnalysis {
184
+ const analysis: CodeModeExecAnalysis = { genuineExec: false, renderedV1: false, foreignCollabClaim: false };
185
+ if (!tools || tools.length === 0) return analysis;
186
+ if (!declaresCodeModeExec(new Set(tools.filter(tool => !tool.namespace).map(tool => tool.name)))) return analysis;
187
+ for (const tool of tools) {
188
+ if (!isCodeModeExec(tool)) continue;
189
+ analysis.genuineExec = true;
190
+ // A rendered V1 spawn heading carries no raw-name suffix: the identifier needs no normalization.
191
+ if (renderedNestedTools(tool.description).some(rendered => rendered.raw === MULTI_AGENT_V1_SPAWN)) analysis.renderedV1 = true;
192
+ if (mentionedToolNames(tool.description).some(isForeignCollabName)) analysis.foreignCollabClaim = true;
193
+ }
194
+ return analysis;
195
+ }
196
+
197
+ /**
198
+ * "v1" when a genuine code-mode exec renders the V1 spawn tool and nothing that claims another
199
+ * collaboration surface; "ambiguous" when it renders both; null when it proves nothing.
200
+ */
201
+ export function codeModeV1Evidence(tools: readonly OcxTool[] | undefined): CodeModeV1Evidence {
202
+ const analysis = analyzeCodeModeExec(tools);
203
+ if (!analysis.renderedV1) return null;
204
+ return analysis.foreignCollabClaim ? "ambiguous" : "v1";
205
+ }
@@ -11,7 +11,9 @@ import type { CodexWsSession } from "./codex-ws-session";
11
11
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
12
12
  MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, markCodexWsPreludeProjection, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
13
13
  codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
14
- markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
14
+ markCodexWsSocketDeath, codexWsWrapperStatus, isCodexWsTerminalEvent, type CodexWsFailureStage, type CodexWsStageRecord,
15
+ type CodexWsTerminalEvent, codexWsPreOutputRefusalStatus, isEmptyCodexWsLifecycleEvent, codexWsFrameResponseIds,
16
+ markCodexWsPreOutputRefusal, type CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
15
17
 
16
18
  interface ExchangeOptions {
17
19
  nativeControl?: NativeResponseControl;
@@ -26,6 +28,13 @@ interface ExchangeOptions {
26
28
  onPhysicalDispatch?: () => void;
27
29
  /** Bun version string the caller gated on; stamped onto the stage record. */
28
30
  bunVersion?: string;
31
+ /**
32
+ * Opt-in (#6969): how long empty lifecycle frames may be held so an authoritative upstream
33
+ * refusal that follows them, before any output, can settle as a replayable gateway status instead
34
+ * of a committed 200. Absent or zero keeps the exchange exactly as it was. Ignored on native
35
+ * control exchanges and on transports without the metadata channel.
36
+ */
37
+ preOutputRefusalHold?: CodexWsPreOutputRefusalHold;
29
38
  }
30
39
 
31
40
  const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
@@ -75,6 +84,8 @@ function rejectionHeaders(source: Record<string, unknown>, prelude: Headers): He
75
84
  * responses_websocket.rs accepts status/status_code and scalar header values;
76
85
  * unlike its native client, this relay converts only precommit 4xx. Returning a
77
86
  * post-send 5xx or fetch rejection could cause the outer retry wrapper to resend.
87
+ * The one 5xx exception is the opt-in pre-output refusal below (#6969): a structured upstream
88
+ * refusal with nothing relayed but empty lifecycle frames, which the caller may replay.
78
89
  */
79
90
  function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Headers): Response | null {
80
91
  if (payload.type !== "error" || payload.stream_id !== undefined) return null;
@@ -92,9 +103,20 @@ function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Hea
92
103
  }), { status, headers });
93
104
  }
94
105
 
106
+ /** The client-facing error of a pre-output refusal projection, from the refusal's own error object. */
107
+ function refusalErrorBody(source: unknown): Record<string, string> {
108
+ const error = record(source) ? source : {};
109
+ return {
110
+ type: typeof error.type === "string" ? error.type : "server_error",
111
+ ...(typeof error.code === "string" ? { code: error.code } : {}),
112
+ message: typeof error.message === "string" ? error.message : "Upstream declined the request before any output",
113
+ };
114
+ }
115
+
95
116
  /** The sole SSE exchange state machine for both one-shot and retained sockets. */
96
117
  export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
97
- const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion, nativeControl, beforeContinuation } = options;
118
+ const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion, nativeControl, beforeContinuation,
119
+ preOutputRefusalHold } = options;
98
120
  const { frameText, headers } = prepared;
99
121
  const signal = init.signal ?? undefined;
100
122
  return new Promise<Response>((resolve, reject) => {
@@ -120,6 +142,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
120
142
  // Numeric close code for the durable stage record; the reason string stays
121
143
  // out of it on purpose (#4191 content-free contract).
122
144
  let closeCode: number | null = null;
145
+ // #6740: which terminal frame the upstream itself sent, before any synthetic tail.
146
+ let terminalEvent: CodexWsTerminalEvent | null = null;
147
+ let terminalErrorStatus: number | null = null;
123
148
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
124
149
  const encoder = new TextEncoder();
125
150
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
@@ -135,6 +160,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
135
160
  // The resolved 200, retained so a later body failure can replace its
136
161
  // success-shaped stage record with the failure-shaped one (#4191).
137
162
  let committedResponse: Response | null = null;
163
+ // #6969: the pre-output refusal hold. While it is open, empty lifecycle frames are queued on the
164
+ // not-yet-exposed stream without resolving it. Every settle other than an eligible refusal
165
+ // commits first (deferredCommit), so the outcome is exactly what the exchange produced before.
166
+ const holdEnabled = (preOutputRefusalHold?.maxMs ?? 0) > 0 && metadata !== null && !nativeControl;
167
+ // Drops for good on any identity the exchange cannot tie to one response: a stream id, two
168
+ // different ids in one frame, or an id that differs from the first one seen. Independent of
169
+ // socket reuse, so a cold one-shot socket is held to the same rule as a pooled one.
170
+ let refusalEligible = holdEnabled;
171
+ let refusalResponseId: string | null = null;
172
+ let heldHeaders: Headers | null = null;
173
+ let deferredCommit = false;
174
+ let holdTimer: ReturnType<typeof setTimeout> | undefined;
138
175
  const stream = new ReadableStream<Uint8Array>({
139
176
  start(c) { controller = c; },
140
177
  cancel() {
@@ -149,6 +186,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
149
186
  clearTimeout(upgradeTimer);
150
187
  clearTimeout(silenceTimer);
151
188
  clearTimeout(pingTimer);
189
+ clearTimeout(holdTimer);
152
190
  signal?.removeEventListener("abort", onAbort);
153
191
  metadata?.finish();
154
192
  correlation?.finish();
@@ -200,14 +238,20 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
200
238
  reused: session.reused,
201
239
  ocxVersion: codexWsOcxVersion(),
202
240
  bunVersion: bunVersion ?? "unknown",
241
+ terminalEvent,
242
+ terminalErrorStatus,
203
243
  });
204
244
 
205
245
  const commitResponse = () => {
206
246
  if (responseCommitted) return;
207
247
  responseCommitted = true;
248
+ deferredCommit = false;
249
+ clearTimeout(holdTimer);
208
250
  clearTimeout(silenceTimer);
209
251
  clearTimeout(pingTimer);
210
- const responseHeaders = metadata?.snapshot() ?? new Headers();
252
+ // A held preamble froze the prelude headers at the first Responses event, which is the moment
253
+ // they were frozen before the hold existed.
254
+ const responseHeaders = heldHeaders ? new Headers(heldHeaders) : metadata?.snapshot() ?? new Headers();
211
255
  responseHeaders.set("content-type", "text/event-stream; charset=utf-8");
212
256
  const response = new Response(stream, { status: 200, headers: responseHeaders });
213
257
  metadata?.commit();
@@ -218,9 +262,73 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
218
262
  resolve(response);
219
263
  };
220
264
 
265
+ /**
266
+ * Open the hold at the first empty lifecycle frame: freeze the prelude and bound the wait by
267
+ * both the hold's own maximum and its absolute end. False when no time is left, so the frame
268
+ * commits exactly as it would without the hold.
269
+ */
270
+ const beginHold = (): boolean => {
271
+ if (heldHeaders) return true;
272
+ if (!metadata || !preOutputRefusalHold) return false;
273
+ const waitMs = Math.min(preOutputRefusalHold.maxMs, preOutputRefusalHold.until - Date.now());
274
+ if (waitMs <= 0) return false;
275
+ heldHeaders = metadata.snapshot();
276
+ metadata.commit();
277
+ holdTimer = setTimeout(() => {
278
+ if (!terminal && !responseCommitted) commitResponse();
279
+ }, waitMs);
280
+ return true;
281
+ };
282
+
283
+ const noteRefusalIdentity = (payload: Record<string, unknown>) => {
284
+ const ids = codexWsFrameResponseIds(payload);
285
+ if (payload.stream_id !== undefined || ids === null || ids.length > 1) {
286
+ refusalEligible = false;
287
+ return;
288
+ }
289
+ const id = ids[0];
290
+ if (id === undefined) return;
291
+ if (refusalResponseId === null) refusalResponseId = id;
292
+ else if (id !== refusalResponseId) refusalEligible = false;
293
+ };
294
+
295
+ /**
296
+ * Settle an authoritative pre-output refusal (#6969) as the gateway status the upstream stated,
297
+ * without the non-replayable marker: nothing but empty lifecycle frames was relayed, and the
298
+ * upstream ended that response itself. The caller's transient ladder and pool move may send the
299
+ * turn again; if neither does, the registered restore hands the client the original stream,
300
+ * which is already complete on `stream`.
301
+ */
302
+ const resolveRefusal = (status: number, payload: Record<string, unknown>, headers: Headers, prelude: Headers) => {
303
+ responseCommitted = true;
304
+ deferredCommit = false;
305
+ clearTimeout(holdTimer);
306
+ const stage = stageRecord(null);
307
+ const source = payload.type === "error"
308
+ ? payload.error
309
+ : record(payload.response) ? payload.response.error : undefined;
310
+ const projection = new Response(JSON.stringify({ error: refusalErrorBody(source) }), { status, headers });
311
+ markCodexWsPreludeProjection(projection);
312
+ markCodexWsStage(projection, stage);
313
+ const observed = Boolean(metadata && onQuota);
314
+ markCodexWsPreOutputRefusal(projection, () => {
315
+ const restoredHeaders = new Headers(prelude);
316
+ restoredHeaders.set("content-type", "text/event-stream; charset=utf-8");
317
+ const restored = new Response(stream, { status: 200, headers: restoredHeaders });
318
+ markCodexWsResponse(restored, observed);
319
+ // The same record object, so the attempt that adopted it keeps reading one stage.
320
+ markCodexWsStage(restored, stage);
321
+ return restored;
322
+ });
323
+ resolve(projection);
324
+ };
325
+
221
326
  const failStream = (error: unknown, status: 502 | 504 = 502, { socketDied = false } = {}) => {
222
327
  if (terminal) return;
223
328
  terminal = true;
329
+ // A held preamble or refusal was a commit deferred, not avoided: settle as the committed
330
+ // body failure it would have been, never as a new pre-response gateway status.
331
+ if (deferredCommit) commitResponse();
224
332
  if (sent && !responseCommitted && metadata) {
225
333
  // Nothing has been promised to the client yet, so the honest answer is a gateway
226
334
  // status, not a 200 whose body then fails. The frame may already be executing
@@ -305,7 +413,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
305
413
  reject(reason);
306
414
  return;
307
415
  }
308
- if (!responseCommitted && metadata) {
416
+ if (!responseCommitted && metadata && !deferredCommit) {
309
417
  // Sent, unacknowledged. The proxy's own connect deadline is an origin-silence
310
418
  // verdict and settles like one; a caller abort is the caller's decision, so the
311
419
  // exchange rejects with that reason and disposing the socket cancels the turn.
@@ -497,16 +605,23 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
497
605
  }
498
606
  if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
499
607
  let steeringEnded = false;
608
+ let refusalStatus: number | undefined;
609
+ let refusalHeaders: Headers | undefined;
610
+ let refusalPrelude: Headers | undefined;
500
611
  if (!controlFrame) {
501
612
  firstResponseAt ??= Date.now();
502
613
  try {
503
614
  if (nativeControl) steeringEnded = nativeControl.observe(normalized.payload);
504
615
  else correlation?.accept(normalized.payload);
505
616
  } catch (error) { failStream(error); return; }
617
+ const holdOpen = refusalEligible && sent && !responseCommitted;
618
+ if (holdOpen) noteRefusalIdentity(normalized.payload);
506
619
  // Correlation must run first: a reused socket's foreign-stream error settles as a
507
620
  // non-replayable 502 above, never as the refused-create 4xx projection below, which
508
621
  // is the one status family that could authorize an account replay.
509
- if (metadata && sent && !responseCommitted && type === "error") {
622
+ // Only before anything was held: after an empty preamble a 4xx frame keeps today's
623
+ // in-body delivery, so a held 429 can never become a quota rotation it was not before.
624
+ if (metadata && sent && !responseCommitted && !deferredCommit && type === "error") {
510
625
  let rejection: Response | null;
511
626
  try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); }
512
627
  catch (error) { failStream(error); return; }
@@ -520,7 +635,30 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
520
635
  return;
521
636
  }
522
637
  }
523
- commitResponse();
638
+ let holdFrame = false;
639
+ if (holdOpen && refusalEligible) {
640
+ refusalStatus = codexWsPreOutputRefusalStatus(normalized.payload);
641
+ if (refusalStatus !== undefined) {
642
+ // Built before anything settles: a refusal whose metadata cannot be projected within the
643
+ // header bounds is delivered the way it was before, in the committed body.
644
+ try {
645
+ refusalPrelude = heldHeaders ?? metadata?.snapshot() ?? new Headers();
646
+ const declared = normalized.payload.headers;
647
+ refusalHeaders = rejectionHeaders(record(declared) ? declared : {}, refusalPrelude);
648
+ } catch {
649
+ refusalStatus = undefined;
650
+ }
651
+ }
652
+ holdFrame = refusalStatus === undefined && isEmptyCodexWsLifecycleEvent(normalized.payload) && beginHold();
653
+ }
654
+ if (refusalStatus === undefined && !holdFrame) commitResponse();
655
+ else deferredCommit = true;
656
+ // Record the terminal the upstream sent as soon as it is accepted, before the relay-size and
657
+ // queue checks below: a body failure that follows is still an exchange that received it.
658
+ if (!nativeControl && isCodexWsTerminalEvent(type)) {
659
+ terminalEvent = type;
660
+ terminalErrorStatus = type === "error" ? (codexWsWrapperStatus(normalized.payload) ?? null) : null;
661
+ }
524
662
  }
525
663
  const prefix = encoder.encode(`event: ${type}\ndata: `);
526
664
  const suffix = encoder.encode("\n\n");
@@ -558,6 +696,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
558
696
  markCodexWsStage(committedResponse, stageRecord(null));
559
697
  }
560
698
  session.release(completedId);
699
+ if (refusalStatus !== undefined && refusalHeaders && refusalPrelude && !responseCommitted) {
700
+ resolveRefusal(refusalStatus, normalized.payload, refusalHeaders, refusalPrelude);
701
+ }
561
702
  }
562
703
  };
563
704