@bitkyc08/opencodex 2.91.0 → 2.92.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 (148) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
  3. package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
  4. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  5. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  8. package/package.json +4 -3
  9. package/src/adapters/google-vertex-endpoint.ts +9 -1
  10. package/src/adapters/google-vertex-key-project.ts +223 -0
  11. package/src/adapters/google.ts +13 -3
  12. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  13. package/src/adapters/openai-chat.ts +2 -2
  14. package/src/adapters/openai-responses/request-strips.ts +60 -0
  15. package/src/adapters/registry.ts +5 -0
  16. package/src/adapters/workbuddy-schema.ts +61 -0
  17. package/src/adapters/workbuddy.ts +68 -0
  18. package/src/bridge/response-json.ts +1 -1
  19. package/src/bridge/sse.ts +1 -1
  20. package/src/chat/outbound.ts +11 -4
  21. package/src/cli/capabilities-agents-routing.ts +3 -3
  22. package/src/cli/codex-cli-update.ts +8 -0
  23. package/src/cli/combo-result.ts +6 -0
  24. package/src/cli/combo-stats.ts +3 -0
  25. package/src/cli/combo.ts +66 -42
  26. package/src/cli/launcher-context.ts +49 -2
  27. package/src/cli/link.ts +45 -32
  28. package/src/cli/provider.ts +2 -1
  29. package/src/cli/usage-compare.ts +68 -12
  30. package/src/client/link-ingress.ts +1 -1
  31. package/src/client/link-readiness.ts +97 -0
  32. package/src/client/link-relay-transport.ts +79 -11
  33. package/src/client/link-relay.ts +74 -15
  34. package/src/client/link-tunnel.ts +45 -18
  35. package/src/client/machine-listener.ts +20 -2
  36. package/src/client/runtime.ts +20 -2
  37. package/src/codex/account-store.ts +17 -3
  38. package/src/codex/app-server-restart-service.ts +6 -5
  39. package/src/codex/catalog/effort.ts +9 -2
  40. package/src/codex/catalog/gather-capture.ts +4 -2
  41. package/src/codex/catalog/provider-models.ts +14 -8
  42. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  43. package/src/codex/cli-install-provenance.ts +418 -0
  44. package/src/codex/cli-installation-targets.ts +5 -1
  45. package/src/codex/cli-update-plan.ts +43 -16
  46. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  47. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  48. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  49. package/src/codex/pool-refresh-backoff.ts +3 -1
  50. package/src/codex/user-identity.ts +35 -6
  51. package/src/codex/windows-current-sid.ts +162 -0
  52. package/src/combos/jev-quota-activation.ts +5 -1
  53. package/src/combos/jev-quota-route.ts +1 -1
  54. package/src/combos/jev-quota.ts +9 -1
  55. package/src/combos/jev-session-pin-config.ts +51 -0
  56. package/src/combos/jev.ts +6 -0
  57. package/src/combos/types.ts +19 -0
  58. package/src/config/provider-validation.ts +7 -0
  59. package/src/config/schema/leaf-validators.ts +1 -0
  60. package/src/generated/compatibility-version.json +194 -122
  61. package/src/lib/decision-quota-sink.ts +10 -0
  62. package/src/lib/translator-budget.ts +4 -4
  63. package/src/lib/upstream-retry.ts +4 -1
  64. package/src/link/diagnostics.ts +193 -0
  65. package/src/link/relay-auth.ts +6 -0
  66. package/src/link/status-projection.ts +39 -0
  67. package/src/link/supervisor.ts +45 -12
  68. package/src/link/tunnel-history.ts +109 -0
  69. package/src/link/tunnel-state.ts +80 -17
  70. package/src/messaging/remote-files.ts +24 -1
  71. package/src/oauth/generic-account-failover.ts +91 -7
  72. package/src/oauth/index.ts +15 -0
  73. package/src/oauth/model-discovery-auth.ts +5 -0
  74. package/src/oauth/store.ts +4 -1
  75. package/src/oauth/token-guardian.ts +7 -0
  76. package/src/oauth/types.ts +9 -0
  77. package/src/oauth/workbuddy.ts +139 -0
  78. package/src/providers/antigravity-quota-identity.ts +37 -0
  79. package/src/providers/base-url-choices.ts +8 -5
  80. package/src/providers/derive.ts +23 -3
  81. package/src/providers/model-discovery.ts +2 -0
  82. package/src/providers/model-rename-fields.ts +1 -0
  83. package/src/providers/quota/account-cache.ts +3 -2
  84. package/src/providers/quota/antigravity.ts +25 -14
  85. package/src/providers/quota/report-cache.ts +13 -4
  86. package/src/providers/quota/vendor-probes-key.ts +60 -0
  87. package/src/providers/quota/workbuddy.ts +83 -0
  88. package/src/providers/quota-decision-antigravity.ts +64 -0
  89. package/src/providers/quota-decision-publication.ts +11 -0
  90. package/src/providers/quota-decision-snapshot.ts +21 -4
  91. package/src/providers/quota-types.ts +2 -0
  92. package/src/providers/quota.ts +3 -1
  93. package/src/providers/registry/entries-core.ts +9 -0
  94. package/src/providers/registry/entries-extended.ts +20 -7
  95. package/src/providers/registry/model-ids.ts +3 -0
  96. package/src/providers/registry/model-seeds.ts +27 -28
  97. package/src/providers/registry/types.ts +19 -1
  98. package/src/providers/resolved-model-policy.ts +4 -4
  99. package/src/providers/routed-config.ts +15 -0
  100. package/src/providers/stale-model-roster-migration.ts +83 -15
  101. package/src/providers/workbuddy-credit-balance.ts +81 -0
  102. package/src/providers/workbuddy-models.ts +37 -0
  103. package/src/providers/workbuddy.ts +60 -0
  104. package/src/responses/reasoning-replay-cache.ts +18 -14
  105. package/src/router.ts +2 -0
  106. package/src/routing/analytics.ts +2 -3
  107. package/src/routing/fallback-policy.ts +3 -3
  108. package/src/server/auth-cors.ts +1 -0
  109. package/src/server/chat-completions.ts +14 -1
  110. package/src/server/chat-native-reasoning.ts +10 -3
  111. package/src/server/chat-native.ts +13 -2
  112. package/src/server/claude-messages.ts +3 -0
  113. package/src/server/index/link-relay-sessions.ts +9 -5
  114. package/src/server/index/optional-listeners.ts +4 -0
  115. package/src/server/local-management-read-client.ts +3 -1
  116. package/src/server/management/combo-routes.ts +10 -4
  117. package/src/server/management/link-routes.ts +5 -2
  118. package/src/server/management/provider-routes.ts +1 -1
  119. package/src/server/port-reclaim.ts +51 -9
  120. package/src/server/request-log-conversation.ts +49 -0
  121. package/src/server/request-log.ts +2 -0
  122. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  123. package/src/server/responses/compact.ts +10 -2
  124. package/src/server/responses/core-combo.ts +53 -31
  125. package/src/server/responses/core-lifetime.ts +2 -2
  126. package/src/server/responses/core-normalize.ts +6 -3
  127. package/src/server/responses/core-opaque-recovery.ts +1 -0
  128. package/src/server/responses/core-options.ts +5 -0
  129. package/src/server/responses/core-replay.ts +2 -1
  130. package/src/server/responses/jev-combo-choice.ts +39 -0
  131. package/src/server/responses/jev-session-pin.ts +203 -0
  132. package/src/server/responses/passthrough-delivery.ts +4 -0
  133. package/src/server/responses/request-prepare.ts +10 -5
  134. package/src/server/responses/request-transport.ts +25 -6
  135. package/src/server/responses/run-turn-execution.ts +2 -4
  136. package/src/server/responses/sidecar-execution.ts +2 -4
  137. package/src/server/responses-phase-inference.ts +267 -0
  138. package/src/server/sse-payload-rewrite.ts +14 -1
  139. package/src/service/definition.ts +20 -8
  140. package/src/types/config.ts +6 -0
  141. package/src/types/provider.ts +9 -1
  142. package/src/types/request.ts +1 -0
  143. package/src/types/wire.ts +1 -0
  144. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  145. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  146. package/src/usage/jev-stats.ts +54 -14
  147. package/gui/dist/assets/index-DGXS51sp.js +0 -94
  148. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
@@ -0,0 +1,267 @@
1
+ import type { OcxMessagePhase } from "../types";
2
+ import { Buffer } from "node:buffer";
3
+ import type { TranslatorBudget } from "../lib/translator-budget";
4
+ import type { SseBlockRewrite } from "./sse-payload-rewrite";
5
+ import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite";
6
+
7
+ /**
8
+ * Opt-in per-provider phase inference for Responses passthrough streams.
9
+ *
10
+ * OpenAI models annotate assistant messages with a commentary/final_answer
11
+ * phase; the Codex desktop UI collapses commentary into progress rows and
12
+ * only fully renders the final answer. Third-party gateways forward plain
13
+ * Responses items without that field, so every narration chunk renders
14
+ * expanded. The wire has no phase field, so phase is inferred from what
15
+ * follows each completed message, mirroring the openai-chat bridge strategy:
16
+ * a message followed by tool-call work is commentary; a message at turn end
17
+ * is the final answer. Messages already carrying commentary or final_answer
18
+ * are left untouched and forwarded without buffering; a null or unknown phase
19
+ * is inferred like a missing one. Each rewritten message stays
20
+ * buffered until the next protocol event (SSE keepalives keep it pending).
21
+ * Live text deltas are not buffered, but message completion waits for that
22
+ * event. Snapshots reuse each item decision when their phase is absent,
23
+ * matched by item id or, when either side lacks an id, by output index;
24
+ * explicit snapshot phases take precedence, and failed snapshots are untouched.
25
+ */
26
+
27
+ const TERMINAL_EVENT_TYPES = new Set([
28
+ "response.completed",
29
+ "response.incomplete",
30
+ "response.failed",
31
+ ]);
32
+ const TOOL_CALL_ITEM_TYPES = new Set([
33
+ "function_call",
34
+ "custom_tool_call",
35
+ "local_shell_call",
36
+ "shell_call",
37
+ "apply_patch_call",
38
+ "mcp_call",
39
+ "tool_search_call",
40
+ "web_search_call",
41
+ ]);
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return !!value && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ /** Only these two values are decisions; null or unknown phases count as missing. */
48
+ function isMessagePhase(value: unknown): value is OcxMessagePhase {
49
+ return value === "commentary" || value === "final_answer";
50
+ }
51
+
52
+ function isToolCallItem(item: unknown): boolean {
53
+ if (!isPlainObject(item)) return false;
54
+ const type = item.type;
55
+ return typeof type === "string"
56
+ && (TOOL_CALL_ITEM_TYPES.has(type)
57
+ // Unknown hosted/extension call shapes still prove the turn continued.
58
+ || (type.endsWith("_call") && !type.endsWith("_call_output")));
59
+ }
60
+
61
+ function parseDataPayload(block: string): Record<string, unknown> | null {
62
+ const payload = sseDataPayload(block);
63
+ if (payload === null) return null;
64
+ try {
65
+ const parsed = JSON.parse(payload) as unknown;
66
+ return isPlainObject(parsed) ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function isDoneSentinel(block: string): boolean {
73
+ return sseDataPayload(block)?.trim() === "[DONE]";
74
+ }
75
+
76
+ function isMessageDoneBlock(block: string): boolean {
77
+ const parsed = parseDataPayload(block);
78
+ return parsed?.type === "response.output_item.done"
79
+ && isPlainObject(parsed.item) && parsed.item.type === "message";
80
+ }
81
+
82
+ function withMessagePhase(block: string, phase: OcxMessagePhase): string {
83
+ const parsed = parseDataPayload(block);
84
+ if (!parsed || parsed.type !== "response.output_item.done") return block;
85
+ if (!isPlainObject(parsed.item) || isMessagePhase(parsed.item.phase)) return block;
86
+ const patched = { ...parsed, item: { ...parsed.item, phase } };
87
+ return replaceSseDataPayload(block, JSON.stringify(patched));
88
+ }
89
+
90
+ /** One per-item decision, findable by item id and by output index. */
91
+ interface PhaseDecision {
92
+ readonly id: string | null;
93
+ readonly index: number | null;
94
+ readonly phase: OcxMessagePhase;
95
+ }
96
+
97
+ interface PhaseDecisions {
98
+ readonly byId: ReadonlyMap<string, PhaseDecision>;
99
+ readonly byIndex: ReadonlyMap<number, PhaseDecision>;
100
+ }
101
+
102
+ function outputIndexOf(value: unknown): number | null {
103
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
104
+ }
105
+
106
+ /**
107
+ * The phase already emitted on the wire for the snapshot item at `position`.
108
+ * An id match wins. A position match is used only when the event or the
109
+ * snapshot item lacks an id, so two different ids never share a decision.
110
+ */
111
+ function decidedPhase(decisions: PhaseDecisions, id: string | null, position: number): OcxMessagePhase | undefined {
112
+ const byId = id !== null ? decisions.byId.get(id) : undefined;
113
+ if (byId) return byId.phase;
114
+ const byIndex = decisions.byIndex.get(position);
115
+ return byIndex && (byIndex.id === null || id === null) ? byIndex.phase : undefined;
116
+ }
117
+
118
+ function withSnapshotPhases(block: string, decisions: PhaseDecisions): string {
119
+ const parsed = parseDataPayload(block);
120
+ if (!parsed) return block;
121
+ const response = parsed.response;
122
+ if (!isPlainObject(response) || !Array.isArray(response.output)) return block;
123
+ let changed = false;
124
+ const output = response.output.map((item: unknown, position: number) => {
125
+ if (isPlainObject(item) && item.type === "message" && !isMessagePhase(item.phase)) {
126
+ // Mirror the decision already emitted for this item on the wire, so
127
+ // clients reconciling the final snapshot never re-expand a message
128
+ // whose output_item.done said commentary. Items the rewrite never saw
129
+ // (joined mid-stream, no buffered decision) fall back to final_answer.
130
+ const id = typeof item.id === "string" ? item.id : null;
131
+ const phase = decidedPhase(decisions, id, position) ?? "final_answer";
132
+ changed = true;
133
+ return { ...item, phase };
134
+ }
135
+ return item;
136
+ });
137
+ if (!changed) return block;
138
+ const patched = { ...parsed, response: { ...response, output } };
139
+ return replaceSseDataPayload(block, JSON.stringify(patched));
140
+ }
141
+
142
+ export function createResponsesPhaseInferenceBlockRewrite(budget: TranslatorBudget): SseBlockRewrite {
143
+ // At most one message-done block is held at a time; the next block decides
144
+ // its phase and is then processed itself. Malformed blocks pass through
145
+ // without consuming the pending decision. Per-item decisions fill missing
146
+ // snapshot phases. Both pending blocks and decisions share the request
147
+ // retention budget and are released at a terminal event or teardown.
148
+ let pending: string | null = null;
149
+ let pendingBytes = 0;
150
+ let decisionBytes = 0;
151
+ const byId = new Map<string, PhaseDecision>();
152
+ const byIndex = new Map<number, PhaseDecision>();
153
+ const decisions: PhaseDecisions = { byId, byIndex };
154
+ const scope = { kind: "retained_collectors" as const };
155
+
156
+ const clearState = (): void => {
157
+ budget.releaseRetained(pendingBytes + decisionBytes, scope);
158
+ pending = null;
159
+ pendingBytes = 0;
160
+ decisionBytes = 0;
161
+ byId.clear();
162
+ byIndex.clear();
163
+ };
164
+
165
+ const decisionSize = (decision: PhaseDecision): number =>
166
+ Buffer.byteLength(JSON.stringify([decision.id, decision.index, decision.phase]));
167
+
168
+ const rememberPhase = (item: unknown, outputIndex: unknown, phase: OcxMessagePhase): void => {
169
+ const id = isPlainObject(item) && typeof item.id === "string" ? item.id : null;
170
+ const index = outputIndexOf(outputIndex);
171
+ if (id === null && index === null) return;
172
+ const next: PhaseDecision = { id, index, phase };
173
+ const replaced = new Set<PhaseDecision>();
174
+ const previousById = id !== null ? byId.get(id) : undefined;
175
+ const previousByIndex = index !== null ? byIndex.get(index) : undefined;
176
+ if (previousById) replaced.add(previousById);
177
+ if (previousByIndex) replaced.add(previousByIndex);
178
+ let delta = decisionSize(next);
179
+ for (const previous of replaced) delta -= decisionSize(previous);
180
+ // Charge before mutating, so a budget rejection leaves state consistent.
181
+ if (delta > 0) budget.chargeRetained(delta, scope);
182
+ else if (delta < 0) budget.releaseRetained(-delta, scope);
183
+ for (const previous of replaced) {
184
+ if (previous.id !== null && byId.get(previous.id) === previous) byId.delete(previous.id);
185
+ if (previous.index !== null && byIndex.get(previous.index) === previous) byIndex.delete(previous.index);
186
+ }
187
+ if (id !== null) byId.set(id, next);
188
+ if (index !== null) byIndex.set(index, next);
189
+ decisionBytes += delta;
190
+ };
191
+
192
+ const releasePending = (phase: OcxMessagePhase): readonly string[] => {
193
+ if (pending === null) return [];
194
+ const block = pending;
195
+ const parsed = parseDataPayload(block);
196
+ rememberPhase(parsed?.item, parsed?.output_index, phase);
197
+ const patched = withMessagePhase(block, phase);
198
+ budget.releaseRetained(pendingBytes, scope);
199
+ pending = null;
200
+ pendingBytes = 0;
201
+ return [patched];
202
+ };
203
+
204
+ const rewrite = (block: string): readonly string[] => {
205
+ const parsed = parseDataPayload(block);
206
+ const type = typeof parsed?.type === "string" ? parsed.type : null;
207
+ if (TERMINAL_EVENT_TYPES.has(type!)) {
208
+ // Turn end: any still-buffered message is the final answer, and the
209
+ // terminal snapshot mirrors the same per-item decisions for consistency.
210
+ const released = releasePending("final_answer");
211
+ const snapshot = type === "response.failed" ? block : withSnapshotPhases(block, decisions);
212
+ clearState();
213
+ return [...released, snapshot];
214
+ }
215
+ if (type === "response.output_item.done" && isMessageDoneBlock(block)) {
216
+ const item = parsed?.item;
217
+ if (isPlainObject(item) && isMessagePhase(item.phase)) {
218
+ // An explicit upstream phase needs no inference: forward immediately
219
+ // instead of buffering a message that an abnormal teardown would
220
+ // otherwise drop before it was ever emitted.
221
+ const released = releasePending("commentary");
222
+ rememberPhase(item, parsed?.output_index, item.phase);
223
+ return [...released, block];
224
+ }
225
+ // A following message done means the turn continued past the buffered
226
+ // one (a phase change or a guarded continuation): commentary. Buffer
227
+ // the current block for classification by whatever comes next.
228
+ const released = releasePending("commentary");
229
+ const bytes = Buffer.byteLength(block);
230
+ budget.chargeRetained(bytes, scope);
231
+ pending = block;
232
+ pendingBytes = bytes;
233
+ return released;
234
+ }
235
+ if (type === null) {
236
+ // Comment keepalives and other non-JSON data blocks carry no
237
+ // classification evidence in either direction, so a still-buffered
238
+ // message stays pending across them. The [DONE] sentinel closes the
239
+ // stream cleanly, which proves the buffered message was the turn's
240
+ // final answer.
241
+ if (isDoneSentinel(block)) {
242
+ const released = releasePending("final_answer");
243
+ clearState();
244
+ return [...released, block];
245
+ }
246
+ return [block];
247
+ }
248
+ // Any other protocol event after a completed message proves the turn
249
+ // continued past it (tool calls, reasoning, a new item): commentary.
250
+ return [...releasePending("commentary"), block];
251
+ };
252
+
253
+ return Object.assign((block: string): readonly string[] => {
254
+ try { return rewrite(block); }
255
+ catch (error) {
256
+ clearState();
257
+ throw error;
258
+ }
259
+ }, {
260
+ dispose: () => {
261
+ // Relay teardown after a terminal event leaves nothing pending. An
262
+ // abnormal teardown before classification drops the retained block,
263
+ // since the stream is failing anyway and no phase was ever proven.
264
+ clearState();
265
+ },
266
+ });
267
+ }
@@ -96,6 +96,8 @@ export function createSseBlockBuffer(
96
96
  let buffer = "";
97
97
  let offset = 0;
98
98
  let scanOffset = 0;
99
+ let needsScan = false;
100
+ let lastCodeUnit = Number.NaN;
99
101
  let bufferBytes = 0;
100
102
  const delimiterPattern = /(?:\r\n|\r(?!\n)|\n){2}/g;
101
103
 
@@ -111,7 +113,7 @@ export function createSseBlockBuffer(
111
113
  if (!fragment) return;
112
114
  let fragmentBytes = Buffer.byteLength(fragment, "utf8");
113
115
  // Decoder output never splits a surrogate pair, but keep the helper exact for string callers.
114
- const last = buffer.charCodeAt(buffer.length - 1);
116
+ const last = lastCodeUnit;
115
117
  const first = fragment.charCodeAt(0);
116
118
  if (offset < buffer.length && last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) {
117
119
  fragmentBytes -= 2;
@@ -122,6 +124,8 @@ export function createSseBlockBuffer(
122
124
  try {
123
125
  compact();
124
126
  buffer += fragment;
127
+ lastCodeUnit = fragment.charCodeAt(fragment.length - 1);
128
+ needsScan ||= fragment.includes("\r") || fragment.includes("\n");
125
129
  reservation.commitRetained();
126
130
  budget.releaseRetained(bufferBytes, scope);
127
131
  bufferBytes = nextBytes;
@@ -131,11 +135,16 @@ export function createSseBlockBuffer(
131
135
  }
132
136
  },
133
137
  next() {
138
+ if (!needsScan) {
139
+ scanOffset = Math.max(offset, buffer.length - 3);
140
+ return null;
141
+ }
134
142
  delimiterPattern.lastIndex = scanOffset;
135
143
  const match = delimiterPattern.exec(buffer);
136
144
  if (!match) {
137
145
  // A delimiter is at most four code units. Revisit only its possible split prefix.
138
146
  scanOffset = Math.max(offset, buffer.length - 3);
147
+ needsScan = false;
139
148
  return null;
140
149
  }
141
150
  const start = match.index;
@@ -153,6 +162,8 @@ export function createSseBlockBuffer(
153
162
  buffer = "";
154
163
  offset = 0;
155
164
  scanOffset = 0;
165
+ needsScan = false;
166
+ lastCodeUnit = Number.NaN;
156
167
  }
157
168
  return { block, delimiter };
158
169
  },
@@ -164,6 +175,8 @@ export function createSseBlockBuffer(
164
175
  buffer = "";
165
176
  offset = 0;
166
177
  scanOffset = 0;
178
+ needsScan = false;
179
+ lastCodeUnit = Number.NaN;
167
180
  bufferBytes = 0;
168
181
  },
169
182
  };
@@ -7,7 +7,8 @@
7
7
  * route back through them (see `installedServiceListenPort` in `health.ts`, which
8
8
  * intentionally stays behind because it asks every backend).
9
9
  */
10
- import { loadConfig } from "../config";
10
+ import { getConfigDir, loadConfig } from "../config";
11
+ import { readClaudeInterceptProxyToken } from "../claude/intercept/proxy-auth";
11
12
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
12
13
  import { PROXY_ENV_KEYS } from "../lib/proxy-env";
13
14
 
@@ -87,19 +88,30 @@ export function parseBakedListenPort(read: () => string): number | null {
87
88
  * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
88
89
  * upper-case name is baked, so a definition never carries two spellings of one setting.
89
90
  */
90
- export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
91
+ export function resolvedProxyEnv(
92
+ env: NodeJS.ProcessEnv = process.env,
93
+ ownClaudeInterceptProxy?: string | null,
94
+ ): { name: string; value: string }[] {
95
+ const token = ownClaudeInterceptProxy === undefined ? readClaudeInterceptProxyToken(getConfigDir()) : null;
91
96
  const resolved: { name: string; value: string }[] = [];
92
97
  for (const key of PROXY_ENV_KEYS) {
93
- const value = [env[key]?.trim(), env[key.toLowerCase()]?.trim()].find(v => v && !OWN_CLAUDE_INTERCEPT_PROXY.test(v));
98
+ const value = [env[key]?.trim(), env[key.toLowerCase()]?.trim()].find(v => v && (
99
+ ownClaudeInterceptProxy === undefined ? !isOwnedClaudeInterceptProxy(v, token) : v !== ownClaudeInterceptProxy
100
+ ));
94
101
  if (value) resolved.push({ name: key, value });
95
102
  }
96
103
  return resolved;
97
104
  }
98
105
 
99
106
  /**
100
- * The URL Claude first-party writes into `~/.claude/settings.json` (`claudeInterceptProxyUrl`),
101
- * which every process Claude Code starts inherits. Baked into the service, it would send the
102
- * proxy's own outbound traffic back through its intercept listener. Only the token-bearing form
103
- * is matched: a plain `http://127.0.0.1:<port>` is a user's local proxy client and is kept.
107
+ * Match this installation's credential and exact URL components without creating a token.
108
+ * An inherited URL can still use an old bound port after the persisted config changes.
109
+ * Shape alone cannot establish ownership: a user proxy may also use an `opencodex` username.
104
110
  */
105
- const OWN_CLAUDE_INTERCEPT_PROXY = /^http:\/\/opencodex:[^@/]+@127\.0\.0\.1:\d{1,5}\/?$/i;
111
+ function isOwnedClaudeInterceptProxy(value: string, token: string | null): boolean {
112
+ if (!token) return false;
113
+ const prefix = `http://opencodex:${encodeURIComponent(token)}@127.0.0.1:`;
114
+ if (!value.startsWith(prefix)) return false;
115
+ const port = value.slice(prefix.length).replace(/\/$/, "");
116
+ return /^\d{1,5}$/.test(port) && Number(port) > 0 && Number(port) <= 65535;
117
+ }
@@ -1496,6 +1496,12 @@ export interface OcxComboConfig {
1496
1496
  decisionQuotaSignals?: boolean | null;
1497
1497
  /** Partial 0..100 tier overrides; defaults limited 70/nearlyExhausted 90. null clears. */
1498
1498
  decisionQuotaTiers?: import("../combos/jev-quota-config").JevQuotaTiers | null;
1499
+ /**
1500
+ * `strategy: "jev"` only, default off: keep an explicit client session on the target JEV chose
1501
+ * for it while the session stays active, so the provider prompt cache stays warm (#7525).
1502
+ * `ttlMs` is the idle window after the last successful turn (60000..86400000). null clears.
1503
+ */
1504
+ sessionPin?: import("../combos/jev-session-pin-config").JevSessionPinConfig | null;
1499
1505
  }
1500
1506
 
1501
1507
  export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude";
@@ -871,6 +871,14 @@ export interface OcxProviderConfig {
871
871
  * SSE/JSON; raw inspection state remains authoritative.
872
872
  */
873
873
  responsesSnapshotRepair?: boolean;
874
+ /**
875
+ * Infer the commentary/final_answer phase on assistant messages for Responses passthrough
876
+ * providers whose upstream omits it (e.g. third-party gateways). Codex desktop collapses
877
+ * commentary into progress rows; without this every narration renders fully expanded.
878
+ * Disabled by default. Applies in the client-facing SSE rewrite chain, including
879
+ * buffered JSON reconstructed from upstream SSE; ordinary upstream JSON is unchanged.
880
+ */
881
+ messagePhaseInference?: boolean;
874
882
  /**
875
883
  * Opt-in hosted-web-search bridge for this KEY-auth Responses passthrough provider (#3761).
876
884
  * Absent or disabled leaves the passthrough byte-identical to today.
@@ -1116,7 +1124,7 @@ export interface OcxProviderConfig {
1116
1124
  googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
1117
1125
  /** Google tool-schema compatibility policy. Omitted preserves compatible report-only behavior. */
1118
1126
  googleToolSchemaPolicy?: "compatible" | "reject-lossy";
1119
- /** Vertex AI GCP project id. API keys use it to select the project endpoint; ADC falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env. */
1127
+ /** Vertex AI GCP project id. API keys use it to select the project endpoint; "auto" resolves each API key's own project. ADC falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env. */
1120
1128
  project?: string;
1121
1129
  /** Vertex AI location, e.g. "us-central1" or "global". API-key project endpoints default to "global"; ADC falls back to GOOGLE_CLOUD_LOCATION env. */
1122
1130
  location?: string;
@@ -148,6 +148,7 @@ export interface OcxParsedRequest {
148
148
  _kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
149
149
  /** Account-scoped Zed user identity paired with the long-lived access token. */
150
150
  _zedAuthContext?: { userId: string };
151
+ _workbuddyAuthContext?: import("../oauth/types").WorkBuddyOAuthMetadata;
151
152
  /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
152
153
  _providerContinuation?: OcxProviderContinuationState;
153
154
  /** Persisted continuation considered only after the final physical route is known. */
package/src/types/wire.ts CHANGED
@@ -101,6 +101,7 @@ function normalizedEndpoint(value: unknown): string | undefined {
101
101
  * URL. The empty prefix covers every model id.
102
102
  */
103
103
  const CREDENTIAL_PINNED_ADAPTERS: Readonly<Record<string, Readonly<Record<string, string>>>> = Object.freeze({
104
+ "workbuddy-ai": Object.freeze({ "": "workbuddy-ai" }),
104
105
  "dsh-account": Object.freeze({ "": "dsh-account" }),
105
106
  });
106
107
 
@@ -16,3 +16,12 @@ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [
16
16
  "VOLTA_HOME",
17
17
  ];
18
18
  export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean;
19
+ export const NODE_LAUNCH_CONTEXT_MAX_CHARS: number;
20
+ export function codexCliInspectionLookupSeeds(
21
+ platform: string,
22
+ home: string | null,
23
+ nodePath: string | null,
24
+ serializedContextChars: number,
25
+ ): { home: string | null; nodePath: string | null };
26
+ export const CODEX_CLI_TRANSPORT_ENV_KEYS: readonly string[];
27
+ export function captureCodexCliTransportEnv(env: Readonly<NodeJS.ProcessEnv>): Record<string, string>;
@@ -16,6 +16,16 @@ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS = Object.freeze([
16
16
  "VOLTA_HOME",
17
17
  ]);
18
18
 
19
+ export const CODEX_CLI_TRANSPORT_ENV_KEYS = Object.freeze([
20
+ "http_proxy", "https_proxy", "all_proxy", "no_proxy", "node_extra_ca_certs",
21
+ ]);
22
+
23
+ /** Preserve spelling, aliases and empty values before Bun loads project dotenv. */
24
+ export function captureCodexCliTransportEnv(env) {
25
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) =>
26
+ CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase()) && typeof value === "string"));
27
+ }
28
+
19
29
  /**
20
30
  * Detect the read-only Codex CLI updater inspection namespace before Bun loads.
21
31
  * Keep this exact and argument-position based: malformed actions still inherit
@@ -28,3 +38,23 @@ export function isCodexCliUpdateInspectionArgv(argv) {
28
38
  const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof="));
29
39
  return args[0] === "system" && args[1] === "codex-cli-update";
30
40
  }
41
+
42
+ /** Serialized OCX_NODE_LAUNCH_CONTEXT ceiling; src/cli/launcher-context.ts refuses above it. */
43
+ export const NODE_LAUNCH_CONTEXT_MAX_CHARS = 64 * 1024;
44
+
45
+ /**
46
+ * The HOME/NODE_PATH seeds the update inspection's modeled Node lookup needs,
47
+ * bounded against the launch-context budget. They are POSIX-only: a Windows
48
+ * inspection never models the global lookup, so the seeds are not sent at all
49
+ * (and their env copies stay inherited for the child's own use). If carrying
50
+ * them would push the serialized context past the reader's limit they are
51
+ * dropped instead of taking the whole snapshot down with them — absent seeds
52
+ * leave only the global lane unverifiable, which fails the content
53
+ * observation closed while the selection context survives.
54
+ */
55
+ export function codexCliInspectionLookupSeeds(platform, home, nodePath, serializedContextChars) {
56
+ if (platform === "win32" || serializedContextChars > NODE_LAUNCH_CONTEXT_MAX_CHARS) {
57
+ return { home: null, nodePath: null };
58
+ }
59
+ return { home: home ?? null, nodePath: nodePath ?? null };
60
+ }
@@ -1,4 +1,5 @@
1
1
  import { normalizeJevQuotaSummary, type JevQuotaDecisionSummary } from "../combos/jev-quota-config";
2
+ import { JEV_SESSION_PIN_REASONS, type JevSessionPinReason } from "../combos/jev-session-pin-config";
2
3
  import type { OcxComboDefaultEffort } from "../types";
3
4
  import type { PersistedUsageEntry } from "./log";
4
5
  import { usageDisplayTotalTokens } from "./totals";
@@ -48,6 +49,10 @@ export interface PersistedJevDecisionV1 {
48
49
  outputTokens: number;
49
50
  totalTokens: number;
50
51
  };
52
+ /** The turn reused its session pin (#7525); no decision request was sent for it. */
53
+ pinned?: true;
54
+ /** `session` with `pinned`; otherwise why an existing session pin was released. */
55
+ pinReason?: JevSessionPinReason;
51
56
  }
52
57
 
53
58
  export interface JevStatsModelRow {
@@ -88,6 +93,8 @@ export interface JevStatsResponse {
88
93
  decisions: number;
89
94
  appliedDecisions: number;
90
95
  failOpenDecisions: number;
96
+ /** Requests that reused a session pin; they are not decisions and add only physical attempts. */
97
+ pinnedRequests: number;
91
98
  successfulRequests: number;
92
99
  requestsWithModelFallback: number;
93
100
  modelAttempts: number;
@@ -188,6 +195,13 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis
188
195
  const backend = value.backend === "typesafe" || value.backend === "systemone" || value.backend === "model"
189
196
  ? value.backend
190
197
  : undefined;
198
+ const pinReason = typeof value.pinReason === "string"
199
+ && (JEV_SESSION_PIN_REASONS as readonly string[]).includes(value.pinReason)
200
+ ? value.pinReason as JevSessionPinReason
201
+ : undefined;
202
+ // A pinned turn always says so with `session`; any other pairing is not a record this writes.
203
+ const pinned = value.pinned === true && pinReason === "session";
204
+ if ((value.pinned === true || pinReason === "session") && !pinned) return undefined;
191
205
  return {
192
206
  version: 1,
193
207
  comboId,
@@ -203,6 +217,8 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis
203
217
  ...(confidence !== undefined ? { confidence } : {}),
204
218
  ...(chosenProbability !== undefined ? { chosenProbability } : {}),
205
219
  ...(usage ? { usage } : {}),
220
+ ...(pinned ? { pinned: true as const } : {}),
221
+ ...(pinReason ? { pinReason } : {}),
206
222
  };
207
223
  }
208
224
 
@@ -210,6 +226,24 @@ interface MutableModelRow extends Omit<JevStatsModelRow, "efforts"> {
210
226
  effortCounts: Map<OcxComboDefaultEffort | null, number>;
211
227
  }
212
228
 
229
+ interface SentAttempt {
230
+ attempt: NonNullable<PersistedUsageEntry["attempts"]>[number];
231
+ provider: string;
232
+ model: string;
233
+ sendCount: number;
234
+ }
235
+
236
+ /** Attempts that physically sent, with bounded identities; zero-send rows never count. */
237
+ function sentAttempts(entry: PersistedUsageEntry): SentAttempt[] {
238
+ return (entry.attempts ?? []).flatMap(attempt => {
239
+ const sendCount = boundedCount(attempt.sendCount) ?? 0;
240
+ if (sendCount === 0) return [];
241
+ const provider = boundedIdentity(attempt.provider, MAX_TARGET_IDENTITY_CHARS);
242
+ const model = boundedIdentity(attempt.model, MAX_TARGET_IDENTITY_CHARS);
243
+ return provider && model ? [{ attempt, provider, model, sendCount }] : [];
244
+ });
245
+ }
246
+
213
247
  interface JevStatsAccumulatorOptions {
214
248
  comboId?: string | null;
215
249
  since?: number | null;
@@ -291,6 +325,7 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
291
325
  private snapshotWindowStart: number | null = null;
292
326
  private snapshotWindowEnd: number | null = null;
293
327
  private decisions = 0;
328
+ private pinnedRequests = 0;
294
329
  private appliedDecisions = 0;
295
330
  private successfulRequests = 0;
296
331
  private requestsWithModelFallback = 0;
@@ -358,6 +393,21 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
358
393
  || (this.until !== undefined && entry.timestamp > this.until)) return;
359
394
  const decision = normalizePersistedJevDecision(entry.jevDecision);
360
395
  if (!decision || (this.comboId !== null && decision.comboId !== this.comboId)) return;
396
+ const attempts = sentAttempts(entry);
397
+ // Request-level outcomes count every JEV-routed request, pinned or decided.
398
+ if (entry.status >= 200 && entry.status < 400) {
399
+ this.successfulRequests = saturatingAdd(this.successfulRequests, 1);
400
+ }
401
+ if (attempts.some(({ provider, model }) => provider !== decision.selected.provider
402
+ || model !== decision.selected.model)) {
403
+ this.requestsWithModelFallback = saturatingAdd(this.requestsWithModelFallback, 1);
404
+ }
405
+ this.addAttempts(attempts);
406
+ if (decision.pinned) {
407
+ // A session-pinned turn sent no decision request, so it stays out of every decision total.
408
+ this.pinnedRequests = saturatingAdd(this.pinnedRequests, 1);
409
+ return;
410
+ }
361
411
 
362
412
  const backend = decision.backend ?? "unknown";
363
413
  const backendRow = this.backends.get(backend)
@@ -369,9 +419,6 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
369
419
  this.averageLatencyMs = nextMean(this.averageLatencyMs, this.decisions, decision.latencyMs);
370
420
  this.decisions = saturatingAdd(this.decisions, 1);
371
421
  if (decision.gate === "apply") this.appliedDecisions = saturatingAdd(this.appliedDecisions, 1);
372
- if (entry.status >= 200 && entry.status < 400) {
373
- this.successfulRequests = saturatingAdd(this.successfulRequests, 1);
374
- }
375
422
  this.gates.set(decision.gate, saturatingAdd(this.gates.get(decision.gate) ?? 0, 1));
376
423
  if (decision.confidence !== undefined) {
377
424
  this.confidenceTotal = saturatingAdd(this.confidenceTotal, decision.confidence);
@@ -396,18 +443,9 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
396
443
  decision.selected.effort,
397
444
  saturatingAdd(selected.effortCounts.get(decision.selected.effort) ?? 0, 1),
398
445
  );
446
+ }
399
447
 
400
- const attempts = (entry.attempts ?? []).flatMap(attempt => {
401
- const sendCount = boundedCount(attempt.sendCount) ?? 0;
402
- if (sendCount === 0) return [];
403
- const provider = boundedIdentity(attempt.provider, MAX_TARGET_IDENTITY_CHARS);
404
- const model = boundedIdentity(attempt.model, MAX_TARGET_IDENTITY_CHARS);
405
- return provider && model ? [{ attempt, provider, model, sendCount }] : [];
406
- });
407
- if (attempts.some(({ provider, model }) => provider !== decision.selected.provider
408
- || model !== decision.selected.model)) {
409
- this.requestsWithModelFallback = saturatingAdd(this.requestsWithModelFallback, 1);
410
- }
448
+ private addAttempts(attempts: readonly SentAttempt[]): void {
411
449
  for (const { attempt, provider, model: modelId, sendCount } of attempts) {
412
450
  const model = this.rowFor(provider, modelId);
413
451
  model.attempts = saturatingAdd(model.attempts, sendCount);
@@ -449,6 +487,7 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
449
487
  cloned.snapshotWindowStart = this.snapshotWindowStart;
450
488
  cloned.snapshotWindowEnd = this.snapshotWindowEnd;
451
489
  cloned.decisions = this.decisions;
490
+ cloned.pinnedRequests = this.pinnedRequests;
452
491
  cloned.appliedDecisions = this.appliedDecisions;
453
492
  cloned.successfulRequests = this.successfulRequests;
454
493
  cloned.requestsWithModelFallback = this.requestsWithModelFallback;
@@ -492,6 +531,7 @@ class StreamingJevStatsAccumulator implements JevStatsAccumulator {
492
531
  decisions: this.decisions,
493
532
  appliedDecisions: this.appliedDecisions,
494
533
  failOpenDecisions: this.decisions - this.appliedDecisions,
534
+ pinnedRequests: this.pinnedRequests,
495
535
  successfulRequests: this.successfulRequests,
496
536
  requestsWithModelFallback: this.requestsWithModelFallback,
497
537
  modelAttempts: this.modelAttempts,