@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,3 +1,5 @@
1
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
2
+
1
3
  /**
2
4
  * Antigravity (Cloud Code Assist) thoughtSignature reasoning-replay cache.
3
5
  *
@@ -10,18 +12,47 @@
10
12
  * Claude-on-Antigravity uses inline signature sanitization instead (see google-antigravity-wire).
11
13
  */
12
14
 
15
+ interface ReplayCall {
16
+ signature: string;
17
+ sizeBytes: number;
18
+ touchedAtMs: number;
19
+ }
20
+
13
21
  interface ReplayEntry {
14
22
  /** thoughtSignature keyed by functionCall identity (name + canonical args). */
15
- byCall: Map<string, string>;
23
+ byCall: Map<string, ReplayCall>;
24
+ bytes: number;
16
25
  expiresAtMs: number;
26
+ oldestAtMs: number | null;
17
27
  }
18
28
 
19
29
  const MIN_SIGNATURE_LEN = 16;
20
30
  const REPLAY_TTL_MS = 60 * 60 * 1000; // 1h
21
- const REPLAY_MAX_ENTRIES = 10_240;
31
+ export const ANTIGRAVITY_REPLAY_MAX_ENTRIES = 10_240;
22
32
  const REPLAY_EVICT_BATCH = 128;
33
+ const REPLAY_MAX_CALLS_PER_SESSION = 256;
34
+ export const ANTIGRAVITY_REPLAY_MAX_BYTES_PER_SESSION = 2 * 1024 * 1024;
35
+ export const ANTIGRAVITY_REPLAY_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
36
+ const REPLAY_MAX_SIGNATURE_BYTES = 64 * 1024;
37
+
38
+ interface ReplayLimits {
39
+ maxCallsPerSession: number;
40
+ maxBytesPerSession: number;
41
+ maxSignatureBytes: number;
42
+ }
43
+
44
+ const DEFAULT_REPLAY_LIMITS: ReplayLimits = {
45
+ maxCallsPerSession: REPLAY_MAX_CALLS_PER_SESSION,
46
+ maxBytesPerSession: ANTIGRAVITY_REPLAY_MAX_BYTES_PER_SESSION,
47
+ maxSignatureBytes: REPLAY_MAX_SIGNATURE_BYTES,
48
+ };
23
49
 
24
50
  const replayCache = new Map<string, ReplayEntry>();
51
+ const utf8 = new TextEncoder();
52
+ let replayLimits = { ...DEFAULT_REPLAY_LIMITS };
53
+ let replayBytes = 0;
54
+ let replayOldestSessionKey: string | undefined;
55
+ let replayOldestAt: number | null = null;
25
56
 
26
57
  function replayKey(model: string, sessionId: string): string {
27
58
  return `${model}::session:${sessionId}`;
@@ -57,12 +88,80 @@ function extractSignature(part: Record<string, unknown>): string | undefined {
57
88
  return undefined;
58
89
  }
59
90
 
91
+ function deleteReplaySession(key: string): number {
92
+ const entry = replayCache.get(key);
93
+ if (!entry) return 0;
94
+ replayCache.delete(key);
95
+ replayBytes -= entry.bytes;
96
+ if (replayOldestSessionKey === key) recomputeReplayOldestCandidate();
97
+ return entry.bytes;
98
+ }
99
+
100
+ function recomputeReplayOldestCandidate(): void {
101
+ replayOldestSessionKey = undefined;
102
+ replayOldestAt = null;
103
+ for (const [key, entry] of replayCache) {
104
+ if (entry.oldestAtMs === null || (replayOldestAt !== null && entry.oldestAtMs >= replayOldestAt)) continue;
105
+ replayOldestSessionKey = key;
106
+ replayOldestAt = entry.oldestAtMs;
107
+ }
108
+ }
109
+
110
+ function refreshReplaySessionCandidate(key: string, entry: ReplayEntry): void {
111
+ entry.oldestAtMs = entry.byCall.values().next().value?.touchedAtMs ?? null;
112
+ if (replayOldestSessionKey === key) {
113
+ recomputeReplayOldestCandidate();
114
+ return;
115
+ }
116
+ if (entry.oldestAtMs !== null && (replayOldestAt === null || entry.oldestAtMs < replayOldestAt)) {
117
+ replayOldestSessionKey = key;
118
+ replayOldestAt = entry.oldestAtMs;
119
+ }
120
+ }
121
+
122
+ function deleteExpiredReplaySessions(now: number): void {
123
+ for (const [key, entry] of replayCache) if (entry.expiresAtMs <= now) deleteReplaySession(key);
124
+ }
125
+
126
+ export function sweepExpiredAntigravityReplay(now = Date.now()): number {
127
+ const before = replayCache.size;
128
+ deleteExpiredReplaySessions(now);
129
+ return before - replayCache.size;
130
+ }
131
+
132
+ function deleteReplayCall(entry: ReplayEntry, callKey: string): number {
133
+ const call = entry.byCall.get(callKey);
134
+ if (!call) return 0;
135
+ entry.byCall.delete(callKey);
136
+ entry.bytes -= call.sizeBytes;
137
+ replayBytes -= call.sizeBytes;
138
+ return call.sizeBytes;
139
+ }
140
+
141
+ function evictInnerCalls(entry: ReplayEntry): void {
142
+ while (
143
+ entry.byCall.size > replayLimits.maxCallsPerSession
144
+ || entry.bytes > replayLimits.maxBytesPerSession
145
+ ) {
146
+ const oldest = entry.byCall.keys().next().value;
147
+ if (oldest === undefined) break;
148
+ deleteReplayCall(entry, oldest);
149
+ }
150
+ }
151
+
60
152
  function evictIfNeeded(): void {
61
- if (replayCache.size <= REPLAY_MAX_ENTRIES) return;
62
- const oldest = [...replayCache.entries()]
63
- .sort((a, b) => a[1].expiresAtMs - b[1].expiresAtMs)
64
- .slice(0, REPLAY_EVICT_BATCH);
65
- for (const [key] of oldest) replayCache.delete(key);
153
+ if (replayCache.size > ANTIGRAVITY_REPLAY_MAX_ENTRIES) {
154
+ const oldest = [...replayCache.entries()]
155
+ .sort((a, b) => a[1].expiresAtMs - b[1].expiresAtMs)
156
+ .slice(0, REPLAY_EVICT_BATCH);
157
+ for (const [key] of oldest) deleteReplaySession(key);
158
+ }
159
+ while (replayBytes > ANTIGRAVITY_REPLAY_MAX_TOTAL_BYTES) {
160
+ const oldestKey = [...replayCache.entries()]
161
+ .sort((a, b) => a[1].expiresAtMs - b[1].expiresAtMs)[0]?.[0];
162
+ if (oldestKey === undefined) return;
163
+ deleteReplaySession(oldestKey);
164
+ }
66
165
  }
67
166
 
68
167
  /** Gemini/Flash/Agent use the replay cache; Claude does not (inline sanitization instead). */
@@ -78,9 +177,16 @@ export function antigravityUsesReplayCache(model: string): boolean {
78
177
  */
79
178
  export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
80
179
  if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return;
180
+ const now = Date.now();
181
+ deleteExpiredReplaySessions(now);
81
182
  const key = replayKey(model, sessionId);
82
- const entry = replayCache.get(key) ?? { byCall: new Map<string, string>(), expiresAtMs: 0 };
83
- let changed = false;
183
+ const entry = replayCache.get(key) ?? {
184
+ byCall: new Map<string, ReplayCall>(),
185
+ bytes: 0,
186
+ expiresAtMs: 0,
187
+ oldestAtMs: null,
188
+ };
189
+ let inserted = false;
84
190
  for (const raw of parts) {
85
191
  if (!raw || typeof raw !== "object") continue;
86
192
  const part = raw as Record<string, unknown>;
@@ -89,12 +195,22 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
89
195
  const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
90
196
  const ck = fc ? functionCallKey(fc.name, fc.args) : undefined;
91
197
  if (!ck) continue; // only function-call signatures are replayable by identity
92
- if (entry.byCall.get(ck) !== sig) { entry.byCall.set(ck, sig); changed = true; }
198
+ const signatureBytes = utf8.encode(sig).byteLength;
199
+ const sizeBytes = utf8.encode(ck).byteLength + signatureBytes;
200
+ if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue;
201
+ deleteReplayCall(entry, ck);
202
+ entry.byCall.set(ck, { signature: sig, sizeBytes, touchedAtMs: now });
203
+ entry.bytes += sizeBytes;
204
+ replayBytes += sizeBytes;
205
+ inserted = true;
93
206
  }
94
- if (!changed && replayCache.has(key)) return;
95
- entry.expiresAtMs = Date.now() + REPLAY_TTL_MS;
207
+ if (!inserted) return;
208
+ evictInnerCalls(entry);
209
+ entry.expiresAtMs = now + REPLAY_TTL_MS;
96
210
  replayCache.set(key, entry);
211
+ refreshReplaySessionCandidate(key, entry);
97
212
  evictIfNeeded();
213
+ enforceAppOwnedMemoryBudget();
98
214
  }
99
215
 
100
216
  /**
@@ -104,11 +220,13 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
104
220
  */
105
221
  export function applyAntigravityReplay(model: string, sessionId: string, contents: unknown[]): unknown[] {
106
222
  if (!antigravityUsesReplayCache(model) || !Array.isArray(contents)) return contents;
223
+ const now = Date.now();
224
+ deleteExpiredReplaySessions(now);
107
225
  const entry = replayCache.get(replayKey(model, sessionId));
108
- if (!entry || entry.expiresAtMs <= Date.now()) {
109
- if (entry) replayCache.delete(replayKey(model, sessionId));
226
+ if (!entry) {
110
227
  return contents;
111
228
  }
229
+ let touched = false;
112
230
  for (const c of contents as { role?: string; parts?: unknown[] }[]) {
113
231
  if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue;
114
232
  for (const raw of c.parts) {
@@ -118,19 +236,68 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
118
236
  if (!fc) continue;
119
237
  if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue;
120
238
  const ck = functionCallKey(fc.name, fc.args);
121
- const sig = ck ? entry.byCall.get(ck) : undefined;
122
- if (sig) part.thoughtSignature = sig;
239
+ const call = ck ? entry.byCall.get(ck) : undefined;
240
+ if (call && ck) {
241
+ part.thoughtSignature = call.signature;
242
+ entry.byCall.delete(ck);
243
+ entry.byCall.set(ck, { ...call, touchedAtMs: now });
244
+ touched = true;
245
+ }
123
246
  }
124
247
  }
248
+ if (touched) refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
125
249
  return contents;
126
250
  }
127
251
 
128
252
  /** Drop the cache entry when upstream rejects a signature (clear-on-invalid). */
129
253
  export function clearAntigravityReplay(model: string, sessionId: string): void {
130
- replayCache.delete(replayKey(model, sessionId));
254
+ deleteReplaySession(replayKey(model, sessionId));
255
+ }
256
+
257
+ export function antigravityReplayMetrics(): {
258
+ sessions: number;
259
+ calls: number;
260
+ totalBytes: number;
261
+ largestSessionBytes: number;
262
+ } {
263
+ let calls = 0;
264
+ let largestSessionBytes = 0;
265
+ for (const entry of replayCache.values()) {
266
+ calls += entry.byCall.size;
267
+ largestSessionBytes = Math.max(largestSessionBytes, entry.bytes);
268
+ }
269
+ return { sessions: replayCache.size, calls, totalBytes: replayBytes, largestSessionBytes };
270
+ }
271
+
272
+ export function antigravityReplayRetainedStoreSnapshot(): {
273
+ count: number;
274
+ bytes: number;
275
+ evictableBytes: number;
276
+ pinnedBytes: number;
277
+ oldestAt: number | null;
278
+ } {
279
+ return {
280
+ count: replayCache.size,
281
+ bytes: replayBytes,
282
+ evictableBytes: replayBytes,
283
+ pinnedBytes: 0,
284
+ oldestAt: replayOldestAt,
285
+ };
286
+ }
287
+
288
+ export function evictOldestAntigravityReplayForBudget(): number {
289
+ return replayOldestSessionKey === undefined ? 0 : deleteReplaySession(replayOldestSessionKey);
290
+ }
291
+
292
+ export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits>): void {
293
+ __resetAntigravityReplayCache();
294
+ replayLimits = limits ? { ...DEFAULT_REPLAY_LIMITS, ...limits } : { ...DEFAULT_REPLAY_LIMITS };
131
295
  }
132
296
 
133
297
  /** Test seam. */
134
298
  export function __resetAntigravityReplayCache(): void {
135
299
  replayCache.clear();
300
+ replayBytes = 0;
301
+ replayOldestSessionKey = undefined;
302
+ replayOldestAt = null;
136
303
  }
@@ -23,6 +23,11 @@ import { compileGoogleWireBody } from "./google-wire-compiler";
23
23
  import { neutralizeIdentity } from "./identity";
24
24
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
25
25
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
26
+ import {
27
+ isTranslatorBudgetExceededError,
28
+ retainTranslatedEventBatch,
29
+ type TranslatorBudget,
30
+ } from "../lib/translator-budget";
26
31
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
27
32
  import { mapReasoningEffort } from "../reasoning-effort";
28
33
 
@@ -408,7 +413,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
408
413
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
409
414
  },
410
415
 
411
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
416
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
412
417
  if (!response.body) {
413
418
  yield { type: "error", message: "No response body" };
414
419
  return;
@@ -420,7 +425,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
420
425
 
421
426
  const reader = response.body.getReader();
422
427
  const decoder = new TextDecoder();
428
+ const budgetEncoder = new TextEncoder();
423
429
  let buffer = "";
430
+ let bufferBytes = 0;
424
431
  let pendingUsage: OcxUsage | undefined;
425
432
  let toolCallsStarted = 0;
426
433
  let lastFinishReason: string | undefined;
@@ -529,7 +536,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
529
536
  while (true) {
530
537
  const { done, value } = await reader.read();
531
538
  if (done) break;
532
- buffer += decoder.decode(value, { stream: true });
539
+ const nextBuffer = buffer + decoder.decode(value, { stream: true });
540
+ const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength;
541
+ const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
542
+ buffer = nextBuffer;
543
+ appendReservation.commitRetained();
544
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
545
+ bufferBytes = nextBufferBytes;
533
546
  // Cap incomplete frames before waiting for a newline — otherwise a single
534
547
  // unterminated data: payload can grow without bound.
535
548
  if (buffer.length > MAX_SSE_FRAME_BYTES) {
@@ -540,6 +553,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
540
553
 
541
554
  const lines = buffer.split("\n");
542
555
  buffer = lines.pop() ?? "";
556
+ const residualBytes = budgetEncoder.encode(buffer).byteLength;
557
+ const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
558
+ residualReservation.commitRetained();
559
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
560
+ bufferBytes = residualBytes;
543
561
 
544
562
  let sawLiveness = false;
545
563
  let sawContentEvent = false;
@@ -587,12 +605,23 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
587
605
  usage: pendingUsage,
588
606
  ...(stopReason ? { stopReason } : {}),
589
607
  };
608
+ } catch (error) {
609
+ if (!isTranslatorBudgetExceededError(error)) throw error;
610
+ try { await reader.cancel(error); } catch { /* already closed */ }
611
+ yield {
612
+ type: "error",
613
+ status: 502,
614
+ errorType: "upstream_error",
615
+ code: "translation_buffer_limit",
616
+ message: "upstream translation buffer exceeded the safe limit",
617
+ };
590
618
  } finally {
619
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
591
620
  reader.releaseLock();
592
621
  }
593
622
  },
594
623
 
595
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
624
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
596
625
  // Reject oversized responses before JSON parse. Prefer Content-Length when
597
626
  // present and truthful; always stream-read with a hard byte cap so a missing
598
627
  // or lying Content-Length cannot force a full in-memory buffer + parse.
@@ -602,6 +631,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
602
631
  return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }];
603
632
  }
604
633
  let rawText: string;
634
+ let rawTextBytes = 0;
605
635
  try {
606
636
  const reader = response.body?.getReader();
607
637
  if (!reader) return [{ type: "error", message: "google response had no body" }];
@@ -616,38 +646,58 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
616
646
  await reader.cancel().catch(() => {});
617
647
  return [{ type: "error", message: `google response too large (exceeded ${MAX_RESPONSE_BYTES} bytes)` }];
618
648
  }
649
+ budget.chargeRetained(value.byteLength, { kind: "retained_collectors" });
619
650
  chunks.push(value);
620
651
  }
621
652
  } finally {
622
653
  try { await reader.cancel(); } catch { /* ignore */ }
623
654
  reader.releaseLock();
624
655
  }
656
+ const bytesReservation = budget.reserveTransient(total, { kind: "retained_collectors" });
625
657
  const bytes = new Uint8Array(total);
626
658
  let offset = 0;
627
659
  for (const chunk of chunks) {
628
660
  bytes.set(chunk, offset);
629
661
  offset += chunk.byteLength;
630
662
  }
663
+ bytesReservation.commitRetained();
664
+ budget.releaseRetained(total, { kind: "retained_collectors" });
631
665
  rawText = new TextDecoder().decode(bytes);
666
+ rawTextBytes = new TextEncoder().encode(rawText).byteLength;
667
+ const textReservation = budget.reserveTransient(rawTextBytes, { kind: "retained_collectors" });
668
+ textReservation.commitRetained();
669
+ budget.releaseRetained(total, { kind: "retained_collectors" });
632
670
  } catch (err) {
633
671
  return [{ type: "error", message: err instanceof Error ? err.message : "failed to read google response body" }];
634
672
  }
635
673
  let raw: Record<string, unknown>;
674
+ let rawBytes = 0;
636
675
  try {
637
676
  raw = JSON.parse(rawText) as Record<string, unknown>;
677
+ rawBytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength;
678
+ const rawReservation = budget.reserveTransient(rawBytes, { kind: "retained_collectors" });
679
+ rawReservation.commitRetained();
680
+ budget.releaseRetained(rawTextBytes, { kind: "retained_collectors" });
638
681
  } catch {
682
+ budget.releaseRetained(rawTextBytes, { kind: "retained_collectors" });
639
683
  return [{ type: "error", message: "google response was not valid JSON" }];
640
684
  }
685
+ const finish = (events: AdapterEvent[]): AdapterEvent[] => {
686
+ retainTranslatedEventBatch(events, budget);
687
+ budget.releaseRetained(rawBytes, { kind: "retained_collectors" });
688
+ rawBytes = 0;
689
+ return events;
690
+ };
641
691
  if (raw.error) {
642
692
  const err = raw.error as { message?: string };
643
- return [{ type: "error", message: err.message ?? "upstream error" }];
693
+ return finish([{ type: "error", message: err.message ?? "upstream error" }]);
644
694
  }
645
695
  // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it.
646
696
  let json = raw;
647
697
  if (provider.googleMode === "cloud-code-assist") {
648
698
  const wrapped = raw.response;
649
699
  if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) {
650
- return [{ type: "error", message: "google-antigravity response missing response wrapper" }];
700
+ return finish([{ type: "error", message: "google-antigravity response missing response wrapper" }]);
651
701
  }
652
702
  json = wrapped as Record<string, unknown>;
653
703
  }
@@ -655,7 +705,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
655
705
 
656
706
  const candidates = json.candidates as { content?: { parts?: { text?: string; functionCall?: { name: string; args: unknown } }[] }; finishReason?: string }[] | undefined;
657
707
  if (!candidates?.length) {
658
- return [{ type: "error", message: "google response contained no candidates" }];
708
+ return finish([{ type: "error", message: "google response contained no candidates" }]);
659
709
  }
660
710
  let toolCallsStarted = 0;
661
711
  const imageBudget = createImageBudget();
@@ -694,7 +744,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
694
744
  // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
695
745
  if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
696
746
  && toolCallsStarted > 0 && isVertexTruncationReason(candidates?.[0]?.finishReason)) {
697
- return [{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }];
747
+ return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]);
698
748
  }
699
749
 
700
750
  const usage = json.usageMetadata as Record<string, number> | undefined;
@@ -702,7 +752,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
702
752
  type: "done",
703
753
  usage: usageFromGemini(usage),
704
754
  });
705
- return events;
755
+ return finish(events);
706
756
  },
707
757
  };
708
758
  }
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from "../types";
2
+ import type { TranslatorBudget } from "../lib/translator-budget";
2
3
 
3
4
  type ThinkingTag = "<thinking>" | "<think>" | "<reasoning>";
4
5
  type ParserState = "pre" | "thinking" | "streaming";
@@ -21,40 +22,53 @@ export class KiroThinkingParser {
21
22
  private thinkingBuffer = "";
22
23
  private closeTag = "";
23
24
 
25
+ constructor(private readonly budget?: TranslatorBudget) {}
26
+
27
+ private replaceCarry(field: "preBuffer" | "thinkingBuffer", next: string): void {
28
+ const previous = this[field];
29
+ if (previous === next) return;
30
+ const previousBytes = Buffer.byteLength(previous);
31
+ const nextBytes = Buffer.byteLength(next);
32
+ const reservation = this.budget?.reserveTransient(nextBytes, { kind: "reasoning" });
33
+ this[field] = next;
34
+ reservation?.commitRetained();
35
+ this.budget?.releaseRetained(previousBytes, { kind: "reasoning" });
36
+ }
37
+
24
38
  feed(text: string): AdapterEvent[] {
25
39
  if (!text) return [];
26
40
  if (this.state === "streaming") return [{ type: "text_delta", text }];
27
41
  if (this.state === "thinking") {
28
- this.thinkingBuffer += text;
42
+ this.replaceCarry("thinkingBuffer", this.thinkingBuffer + text);
29
43
  return this.drainThinking();
30
44
  }
31
- this.preBuffer += text;
45
+ this.replaceCarry("preBuffer", this.preBuffer + text);
32
46
  const stripped = this.preBuffer.trimStart();
33
47
  const openTag = OPEN_TAGS.find(tag => stripped.startsWith(tag));
34
48
  if (openTag) {
35
49
  this.state = "thinking";
36
50
  this.closeTag = closeTagFor(openTag);
37
- this.thinkingBuffer = stripped.slice(openTag.length);
38
- this.preBuffer = "";
51
+ this.replaceCarry("thinkingBuffer", stripped.slice(openTag.length));
52
+ this.replaceCarry("preBuffer", "");
39
53
  return this.drainThinking();
40
54
  }
41
55
  if (stripped.length <= MAX_OPEN_TAG && isPossibleOpenTagPrefix(stripped)) return [];
42
56
  this.state = "streaming";
43
57
  const out = this.preBuffer;
44
- this.preBuffer = "";
58
+ this.replaceCarry("preBuffer", "");
45
59
  return out ? [{ type: "text_delta", text: out }] : [];
46
60
  }
47
61
 
48
62
  flush(): AdapterEvent[] {
49
63
  if (this.state === "thinking") {
50
64
  const out = this.thinkingBuffer;
51
- this.thinkingBuffer = "";
65
+ this.replaceCarry("thinkingBuffer", "");
52
66
  this.state = "streaming";
53
67
  return out ? [{ type: "reasoning_raw_delta", text: out }] : [];
54
68
  }
55
69
  if (this.preBuffer) {
56
70
  const out = this.preBuffer;
57
- this.preBuffer = "";
71
+ this.replaceCarry("preBuffer", "");
58
72
  this.state = "streaming";
59
73
  return [{ type: "text_delta", text: out }];
60
74
  }
@@ -67,7 +81,7 @@ export class KiroThinkingParser {
67
81
  if (idx >= 0) {
68
82
  const thinking = this.thinkingBuffer.slice(0, idx);
69
83
  const after = this.thinkingBuffer.slice(idx + close.length).trimStart();
70
- this.thinkingBuffer = "";
84
+ this.replaceCarry("thinkingBuffer", "");
71
85
  this.state = "streaming";
72
86
  const events: AdapterEvent[] = [];
73
87
  if (thinking) events.push({ type: "reasoning_raw_delta", text: thinking });
@@ -76,7 +90,7 @@ export class KiroThinkingParser {
76
90
  }
77
91
  if (this.thinkingBuffer.length <= MAX_CLOSE_TAG) return [];
78
92
  const send = this.thinkingBuffer.slice(0, -MAX_CLOSE_TAG);
79
- this.thinkingBuffer = this.thinkingBuffer.slice(-MAX_CLOSE_TAG);
93
+ this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(-MAX_CLOSE_TAG));
80
94
  return send ? [{ type: "reasoning_raw_delta", text: send }] : [];
81
95
  }
82
96
  }
@@ -1,10 +1,15 @@
1
- import type { OcxParsedRequest } from "../types";
1
+ import type { OcxParsedRequest, OcxTool } from "../types";
2
2
  import { namespacedToolName } from "../types";
3
3
  import { normalizeKiroModelId } from "../providers/kiro-models";
4
4
  import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire";
5
5
 
6
6
  const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024;
7
7
  const MAX_KIRO_TOOL_DESCRIPTION_GPT_56_SOL = 9_216;
8
+ // Issue #719's successful Kiro probe used 49 outbound tools at about 108 KiB. Leave one of those
9
+ // slots for Kiro's private completion tool and headroom for the enclosing request/message fields.
10
+ export const MAX_KIRO_TOOL_COUNT = 48;
11
+ export const MAX_KIRO_TOOL_CATALOG_BYTES = 96_000;
12
+ const textEncoder = new TextEncoder();
8
13
 
9
14
  // JSON Schema validation/annotation keywords that Kiro's runtimeservice tool-spec validator
10
15
  // rejects ("ValidationException: Invalid tool use format."). Codex's built-in tools omit these,
@@ -147,6 +152,17 @@ function truncateDescription(description: string, limit: number): string {
147
152
  return `${description.slice(0, limit - 1)}…`;
148
153
  }
149
154
 
155
+ function serializedToolCatalogBytes(tools: readonly unknown[]): number {
156
+ return textEncoder.encode(JSON.stringify(tools)).byteLength;
157
+ }
158
+
159
+ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], registry: KiroToolNameRegistry): string {
160
+ const names = omitted.slice(0, 12).map(tool => registry.alias(namespacedToolName(tool.namespace, tool.name)));
161
+ const remainder = omitted.length - names.length;
162
+ const summary = `${names.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
163
+ return `[opencodex] Kiro's outbound catalog budget allows ${kept} of ${kept + omitted.length} client tools this turn. Omitted and unavailable this turn: ${summary}.`;
164
+ }
165
+
150
166
  export function convertKiroToolContext(
151
167
  parsed: OcxParsedRequest,
152
168
  registry: KiroToolNameRegistry = createKiroToolNameRegistry(),
@@ -156,24 +172,39 @@ export function convertKiroToolContext(
156
172
  // Validate every listed name even when tool_choice:none emulates a tool-free turn.
157
173
  for (const tool of tools) registry.alias(namespacedToolName(tool.namespace, tool.name));
158
174
  const effectiveTools = parsed.options.toolChoice === "none" ? [] : tools;
175
+ const convertedTools: unknown[] = [];
176
+ let omittedAt = effectiveTools.length;
177
+ for (const [index, tool] of effectiveTools.entries()) {
178
+ const description = tool.description || `Tool: ${tool.name}`;
179
+ // Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes
180
+ // it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex
181
+ // routes by. Kiro's runtimeservice rejects names with spaces or >64 chars, so normalize to a
182
+ // safe form and remember the mapping; the response parser restores the original wire name.
183
+ const wireName = namespacedToolName(tool.namespace, tool.name);
184
+ const toolName = registry.alias(wireName);
185
+ const converted = {
186
+ toolSpecification: {
187
+ name: toolName,
188
+ description: truncateDescription(description, descriptionLimit),
189
+ inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(tool.parameters ?? {})) },
190
+ },
191
+ };
192
+ // Preserve declaration order and only omit a suffix. Ranking tools would make a catalog change
193
+ // silently alter which capability disappears; this deterministic policy is paired with a
194
+ // model-visible omission notice so unavailable tools are explicit rather than assumed absent.
195
+ if (
196
+ convertedTools.length >= MAX_KIRO_TOOL_COUNT
197
+ || serializedToolCatalogBytes([...convertedTools, converted]) > MAX_KIRO_TOOL_CATALOG_BYTES
198
+ ) {
199
+ omittedAt = index;
200
+ break;
201
+ }
202
+ convertedTools.push(converted);
203
+ }
204
+ const omittedTools = effectiveTools.slice(omittedAt);
159
205
  return {
160
- tools: effectiveTools.map(t => {
161
- const description = t.description || `Tool: ${t.name}`;
162
- // Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes
163
- // it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex
164
- // routes by. Kiro's runtimeservice rejects names with spaces or >64 chars, so normalize to a
165
- // safe form and remember the mapping; the response parser restores the original wire name.
166
- const wireName = namespacedToolName(t.namespace, t.name);
167
- const toolName = registry.alias(wireName);
168
- return {
169
- toolSpecification: {
170
- name: toolName,
171
- description: truncateDescription(description, descriptionLimit),
172
- inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(t.parameters ?? {})) },
173
- },
174
- };
175
- }),
176
- systemAdditions: [],
206
+ tools: convertedTools,
207
+ systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [],
177
208
  nameMap: registry.nameMap,
178
209
  registry,
179
210
  };