@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
2
3
 
3
4
  /**
@@ -34,6 +35,8 @@ const REPLAY_MAX_CALLS_PER_SESSION = 256;
34
35
  export const ANTIGRAVITY_REPLAY_MAX_BYTES_PER_SESSION = 2 * 1024 * 1024;
35
36
  export const ANTIGRAVITY_REPLAY_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
36
37
  const REPLAY_MAX_SIGNATURE_BYTES = 64 * 1024;
38
+ /** Fixed 64-hex outer key length, counted once per session entry. */
39
+ const REPLAY_SESSION_KEY_BYTES = 64;
37
40
 
38
41
  interface ReplayLimits {
39
42
  maxCallsPerSession: number;
@@ -54,29 +57,202 @@ let replayBytes = 0;
54
57
  let replayOldestSessionKey: string | undefined;
55
58
  let replayOldestAt: number | null = null;
56
59
 
60
+ /**
61
+ * Fixed-size identity for a (model, sessionId) pair: SHA-256 over
62
+ * length-prefixed UTF-16 code units fed incrementally (no separator ambiguity
63
+ * — `("a\0b","c")` and `("a","b\0c")` derive different keys — and no raw
64
+ * model/session strings retained as Map keys, which the byte caps never
65
+ * counted).
66
+ */
67
+ /**
68
+ * Injective string feed for key derivation: length-prefixed in CODE UNITS,
69
+ * then each code unit as two little-endian bytes. TextEncoder/UTF-8 would
70
+ * fold lone surrogates into U+FFFD, colliding distinct strings (e.g.
71
+ * "�" and "�") into the same key.
72
+ */
73
+ function updateHashWithString(hash: ReturnType<typeof createHash>, value: string): void {
74
+ hash.update(String(value.length));
75
+ hash.update("\0");
76
+ const buf = Buffer.allocUnsafe(8192);
77
+ let offset = 0;
78
+ for (let index = 0; index < value.length; index += 1) {
79
+ buf.writeUInt16LE(value.charCodeAt(index), offset);
80
+ offset += 2;
81
+ if (offset === buf.length) {
82
+ hash.update(buf);
83
+ offset = 0;
84
+ }
85
+ }
86
+ if (offset > 0) hash.update(buf.subarray(0, offset));
87
+ }
88
+
57
89
  function replayKey(model: string, sessionId: string): string {
58
- return `${model}::session:${sessionId}`;
90
+ const hash = createHash("sha256");
91
+ updateHashWithString(hash, model);
92
+ updateHashWithString(hash, sessionId);
93
+ return hash.digest("hex");
94
+ }
95
+
96
+ /** Canonical output exceeding this budget is rejected DURING the walk — the
97
+ * pre-fix path materialized an unbounded canonical string before admission. */
98
+ const REPLAY_MAX_CANONICAL_ARGS_BYTES = 64 * 1024;
99
+ const CANONICAL_OVERFLOW = Symbol("canonical-overflow");
100
+
101
+ /** Byte-identical output to the old recursive canonicalJson, written incrementally. */
102
+ /**
103
+ * Key-count pre-check: every object key costs at least 4 canonical bytes
104
+ * (two quotes, colon, separator), so a wider object ALWAYS overflows the
105
+ * canonical budget — skip the sort and the walk. Object.keys allocation
106
+ * itself is linear and irreducible in JS (there is no streaming key API),
107
+ * but it is transient and never sorted or walked past this bound.
108
+ */
109
+ const CANONICAL_MAX_KEYS_PER_OBJECT = REPLAY_MAX_CANONICAL_ARGS_BYTES / 4;
110
+
111
+ /** Test-only scan instrumentation: proves overflow aborts the walk near the
112
+ * cap instead of scanning/materializing the whole input. */
113
+ let canonicalScanUnitsForTests = 0;
114
+ export function canonicalScanUnitsForTestsValue(): number {
115
+ return canonicalScanUnitsForTests;
59
116
  }
117
+ export function resetCanonicalScanUnitsForTests(): void {
118
+ canonicalScanUnitsForTests = 0;
119
+ }
120
+
121
+ const MAX_CANONICAL_DEPTH = 128;
60
122
 
61
- /** Recursively canonicalize a JSON value: object keys sorted, arrays preserved. */
62
- function canonicalJson(value: unknown): string {
63
- if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
64
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
65
- const entries = Object.keys(value as Record<string, unknown>).sort()
66
- .map(k => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`);
67
- return `{${entries.join(",")}}`;
123
+ function writeCanonicalJson(value: unknown, sink: (chunk: string) => void, depth = 0): void {
124
+ canonicalScanUnitsForTests += 1;
125
+ // Depth overflow is the same class as byte overflow: skip replay for this
126
+ // call instead of exhausting the stack on a pathological argument shape.
127
+ if (depth > MAX_CANONICAL_DEPTH) throw CANONICAL_OVERFLOW;
128
+ if (typeof value === "string") {
129
+ writeJsonStringEscaped(value, sink);
130
+ return;
131
+ }
132
+ if (value === null || typeof value !== "object") {
133
+ sink(JSON.stringify(value) ?? "null");
134
+ return;
135
+ }
136
+ if (Array.isArray(value)) {
137
+ sink("[");
138
+ for (let index = 0; index < value.length; index += 1) {
139
+ if (index > 0) sink(",");
140
+ // Array.prototype.map parity: holes produce NOTHING between the commas
141
+ // (old output `[1,,3]`), while an explicit undefined element is "null".
142
+ if (index in value) writeCanonicalJson(value[index], sink, depth + 1);
143
+ }
144
+ sink("]");
145
+ return;
146
+ }
147
+ const keys = Object.keys(value as Record<string, unknown>);
148
+ if (keys.length > CANONICAL_MAX_KEYS_PER_OBJECT) throw CANONICAL_OVERFLOW;
149
+ keys.sort();
150
+ sink("{");
151
+ keys.forEach((k, index) => {
152
+ if (index > 0) sink(",");
153
+ writeJsonStringEscaped(k, sink);
154
+ sink(":");
155
+ writeCanonicalJson((value as Record<string, unknown>)[k], sink, depth + 1);
156
+ });
157
+ sink("}");
68
158
  }
69
159
 
70
- /** Stable identity for a functionCall part: name + recursively canonicalized args. */
160
+ /**
161
+ * JSON.stringify string escaping, streamed in small chunks so the budget can
162
+ * reject mid-string — calling JSON.stringify on a multi-MiB primitive would
163
+ * materialize its full escaped form before the sink could refuse it.
164
+ * Semantics mirror JSON.stringify for strings exactly (ES2019 JSON
165
+ * superset): quotes/backslash and control characters are escaped, LONE
166
+ * surrogates become \uXXXX, and valid surrogate pairs pass through raw.
167
+ */
168
+ function writeJsonStringEscaped(value: string, sink: (chunk: string) => void): void {
169
+ sink('"');
170
+ let buffer = "";
171
+ for (const cp of value) {
172
+ canonicalScanUnitsForTests += 1;
173
+ const code = cp.codePointAt(0)!;
174
+ let escaped: string;
175
+ if (cp === '"') escaped = '\\"';
176
+ else if (cp === "\\") escaped = "\\\\";
177
+ else if (cp === "\b") escaped = "\\b";
178
+ else if (cp === "\f") escaped = "\\f";
179
+ else if (cp === "\n") escaped = "\\n";
180
+ else if (cp === "\r") escaped = "\\r";
181
+ else if (cp === "\t") escaped = "\\t";
182
+ else if (code < 0x20) escaped = `\\u${code.toString(16).padStart(4, "0")}`;
183
+ // Lone surrogates: JSON.stringify emits \uXXXX (a raw one would decode
184
+ // back as U+FFFD and collide with real U+FFFD content).
185
+ else if (code >= 0xd800 && code <= 0xdfff) escaped = `\\u${code.toString(16).padStart(4, "0")}`;
186
+ else escaped = cp;
187
+ buffer += escaped;
188
+ if (buffer.length >= 4096) {
189
+ sink(buffer);
190
+ buffer = "";
191
+ }
192
+ }
193
+ if (buffer.length > 0) sink(buffer);
194
+ sink('"');
195
+ }
196
+
197
+ /** Bounded canonicalization: null on overflow (skip replay for that call). */
198
+ function canonicalJsonBounded(value: unknown, maxBytes: number): string | null {
199
+ let written = 0;
200
+ const parts: string[] = [];
201
+ const sink = (chunk: string) => {
202
+ written += utf8.encode(chunk).byteLength;
203
+ if (written > maxBytes) throw CANONICAL_OVERFLOW;
204
+ parts.push(chunk);
205
+ };
206
+ try {
207
+ writeCanonicalJson(value, sink);
208
+ } catch (error) {
209
+ if (error === CANONICAL_OVERFLOW) return null;
210
+ throw error;
211
+ }
212
+ return parts.join("");
213
+ }
214
+
215
+ /**
216
+ * Stable identity for a functionCall part: fixed-size SHA-256 over
217
+ * length-prefixed name + canonical args. Overflow during canonicalization
218
+ * skips replay for that call (never materializes an unbounded string); other
219
+ * canonicalization failures keep the old name-only fallback semantics.
220
+ */
71
221
  function functionCallKey(name: unknown, args: unknown): string | undefined {
72
222
  if (typeof name !== "string" || name.length === 0) return undefined;
73
- let argsKey = "";
223
+ let canonical: string | null;
74
224
  try {
75
- argsKey = canonicalJson(args ?? {});
225
+ canonical = canonicalJsonBounded(args ?? {}, REPLAY_MAX_CANONICAL_ARGS_BYTES);
76
226
  } catch {
77
- argsKey = "";
227
+ canonical = "";
78
228
  }
79
- return `${name}::${argsKey}`;
229
+ if (canonical === null) return undefined;
230
+ const hash = createHash("sha256");
231
+ updateHashWithString(hash, name);
232
+ updateHashWithString(hash, canonical);
233
+ return hash.digest("hex");
234
+ }
235
+
236
+ /** Test-only key-derivation seam: the fixed-key regression cannot go red
237
+ * through snapshot.bytes (that metric never counted raw outer keys). */
238
+ export function antigravityReplayKeyForTests(model: string, sessionId: string): string {
239
+ return replayKey(model, sessionId);
240
+ }
241
+
242
+ export function antigravityFunctionCallKeyForTests(name: unknown, args: unknown): string | undefined {
243
+ return functionCallKey(name, args);
244
+ }
245
+
246
+ /** Test-only bounded-canonicalization seam: proves mid-walk rejection without
247
+ * materializing the escaped form (allocation guard). */
248
+ export function antigravityCanonicalJsonBoundedForTests(value: unknown, maxBytes: number): string | null {
249
+ return canonicalJsonBounded(value, maxBytes);
250
+ }
251
+
252
+ /** Test-only: the ACTUAL internal session keys, so tests can prove raw
253
+ * model/session strings are never retained as Map keys. */
254
+ export function antigravityReplaySessionKeysForTests(): string[] {
255
+ return [...replayCache.keys()];
80
256
  }
81
257
 
82
258
  function extractSignature(part: Record<string, unknown>): string | undefined {
@@ -123,6 +299,22 @@ function deleteExpiredReplaySessions(now: number): void {
123
299
  for (const [key, entry] of replayCache) if (entry.expiresAtMs <= now) deleteReplaySession(key);
124
300
  }
125
301
 
302
+ /**
303
+ * The lazy per-call expiry scan is O(sessions); at the 10,240-session cap
304
+ * every observe/apply would rescan the whole map — O(n²) under load. The 60s
305
+ * state-store sweeper is already the periodic expiry authority, so lazy scans
306
+ * are throttled to at most one per interval (expired entries may linger a few
307
+ * extra seconds; TTL is fuzzy at that scale by design).
308
+ */
309
+ const LAZY_SWEEP_INTERVAL_MS = 30_000;
310
+ let lastLazySweepAt = Number.NEGATIVE_INFINITY;
311
+
312
+ function deleteExpiredReplaySessionsThrottled(now: number): void {
313
+ if (now - lastLazySweepAt < LAZY_SWEEP_INTERVAL_MS) return;
314
+ lastLazySweepAt = now;
315
+ deleteExpiredReplaySessions(now);
316
+ }
317
+
126
318
  export function sweepExpiredAntigravityReplay(now = Date.now()): number {
127
319
  const before = replayCache.size;
128
320
  deleteExpiredReplaySessions(now);
@@ -173,39 +365,62 @@ export function antigravityUsesReplayCache(model: string): boolean {
173
365
  * Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
174
366
  * the functionCall identity (name + args). Accumulates across the whole session so a sequential
175
367
  * multi-step tool loop keeps EVERY prior call's signature, not just the latest part-index slot.
368
+ * A signature on a standalone thought part is paired with the next functionCall in the same
369
+ * array (#897); a call's own signature takes precedence and an unpaired one is dropped.
176
370
  * `parts` is the already-unwrapped `response.candidates[0].content.parts`.
177
371
  */
178
372
  export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
179
373
  if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return;
180
374
  const now = Date.now();
181
- deleteExpiredReplaySessions(now);
375
+ deleteExpiredReplaySessionsThrottled(now);
182
376
  const key = replayKey(model, sessionId);
183
- const entry = replayCache.get(key) ?? {
377
+ const existing = replayCache.get(key);
378
+ const entry = existing ?? {
184
379
  byCall: new Map<string, ReplayCall>(),
185
- bytes: 0,
380
+ bytes: REPLAY_SESSION_KEY_BYTES,
186
381
  expiresAtMs: 0,
187
382
  oldestAtMs: null,
188
383
  };
189
384
  let inserted = false;
385
+ let pendingThoughtSig: string | undefined;
190
386
  for (const raw of parts) {
191
387
  if (!raw || typeof raw !== "object") continue;
192
388
  const part = raw as Record<string, unknown>;
193
389
  const sig = extractSignature(part);
194
- if (!sig) continue;
195
390
  const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
196
- const ck = fc ? functionCallKey(fc.name, fc.args) : undefined;
391
+ if (!fc) {
392
+ // A signature on a standalone thought part pairs with the NEXT functionCall in this
393
+ // array: thought parts are stripped from replayed history, so the call part is the only
394
+ // carrier that survives (#897). Non-thought parts keep their own signature in history.
395
+ if (sig && part.thought === true) pendingThoughtSig = sig;
396
+ continue;
397
+ }
398
+ const callSig = sig ?? pendingThoughtSig; // a signature on the call part itself wins
399
+ pendingThoughtSig = undefined;
400
+ if (!callSig) continue;
401
+ const ck = functionCallKey(fc.name, fc.args);
197
402
  if (!ck) continue; // only function-call signatures are replayable by identity
198
- const signatureBytes = utf8.encode(sig).byteLength;
403
+ const signatureBytes = utf8.encode(callSig).byteLength;
199
404
  const sizeBytes = utf8.encode(ck).byteLength + signatureBytes;
200
405
  if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue;
201
406
  deleteReplayCall(entry, ck);
202
- entry.byCall.set(ck, { signature: sig, sizeBytes, touchedAtMs: now });
407
+ entry.byCall.set(ck, { signature: callSig, sizeBytes, touchedAtMs: now });
203
408
  entry.bytes += sizeBytes;
204
409
  replayBytes += sizeBytes;
205
410
  inserted = true;
206
411
  }
207
412
  if (!inserted) return;
413
+ // Charge the fixed outer key only when the session is actually stored.
414
+ if (!existing) replayBytes += REPLAY_SESSION_KEY_BYTES;
208
415
  evictInnerCalls(entry);
416
+ if (entry.byCall.size === 0) {
417
+ // The fixed session overhead can exceed the per-session cap on its own
418
+ // (test-sized limits): an entry holding zero calls is unusable — drop it
419
+ // instead of retaining an unevictable shell.
420
+ if (existing) deleteReplaySession(key);
421
+ else replayBytes -= REPLAY_SESSION_KEY_BYTES;
422
+ return;
423
+ }
209
424
  entry.expiresAtMs = now + REPLAY_TTL_MS;
210
425
  replayCache.set(key, entry);
211
426
  refreshReplaySessionCandidate(key, entry);
@@ -221,7 +436,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
221
436
  export function applyAntigravityReplay(model: string, sessionId: string, contents: unknown[]): unknown[] {
222
437
  if (!antigravityUsesReplayCache(model) || !Array.isArray(contents)) return contents;
223
438
  const now = Date.now();
224
- deleteExpiredReplaySessions(now);
439
+ deleteExpiredReplaySessionsThrottled(now);
225
440
  const entry = replayCache.get(replayKey(model, sessionId));
226
441
  if (!entry) {
227
442
  return contents;
@@ -296,6 +511,7 @@ export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits
296
511
 
297
512
  /** Test seam. */
298
513
  export function __resetAntigravityReplayCache(): void {
514
+ lastLazySweepAt = Number.NEGATIVE_INFINITY;
299
515
  replayCache.clear();
300
516
  replayBytes = 0;
301
517
  replayOldestSessionKey = undefined;
@@ -11,3 +11,14 @@ export function vertexTruncationErrorMessage(reason?: string): string {
11
11
  const suffix = reason ? ` (${redactSecretString(reason).slice(0, 160)})` : "";
12
12
  return `Vertex AI response truncated upstream before the turn completed${suffix}`;
13
13
  }
14
+
15
+ /**
16
+ * Whether a finished turn must fail closed. A truncation reason arriving mid tool call always
17
+ * does. MALFORMED_FUNCTION_CALL fails closed even with zero started calls: the malformed call
18
+ * is dropped upstream and usually never materializes as a part, so the turn is incomplete
19
+ * despite looking empty. MAX_TOKENS with no started call stays a plain token-limit stop.
20
+ */
21
+ export function isVertexTruncatedTurn(finishReason: string | undefined, toolCallsStarted: number): boolean {
22
+ if (!isVertexTruncationReason(finishReason)) return false;
23
+ return toolCallsStarted > 0 || finishReason === "MALFORMED_FUNCTION_CALL";
24
+ }
@@ -12,15 +12,15 @@ import type {
12
12
  OcxToolCall,
13
13
  OcxUsage,
14
14
  } from "../types";
15
- import { isAllowedToolChoice, namespacedToolName, toolAllowedByChoice } from "../types";
15
+ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
16
16
  import { contentPartsToText, parseDataUrl } from "./image";
17
17
  import { getVertexAccessToken } from "../lib/gcp-adc";
18
18
  import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
19
19
  import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
20
- import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google-truncation";
20
+ import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation";
21
21
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
22
22
  import { compileGoogleWireBody } from "./google-wire-compiler";
23
- import { neutralizeIdentity } from "./identity";
23
+ import { identifyRoutedModel } from "./identity";
24
24
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
25
25
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
26
26
  import {
@@ -122,15 +122,18 @@ function geminiToolResultText(content: string | OcxContentPart[]): string {
122
122
  return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
123
123
  }
124
124
 
125
- function messagesToGeminiFormat(parsed: OcxParsedRequest): { systemInstruction?: unknown; contents: unknown[] } {
125
+ function messagesToGeminiFormat(
126
+ parsed: OcxParsedRequest,
127
+ routedModelId = parsed.modelId,
128
+ ): { systemInstruction?: unknown; contents: unknown[] } {
126
129
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
127
130
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
128
131
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice);
129
- const systemText = neutralizeIdentity([
132
+ const systemText = identifyRoutedModel([
130
133
  ...(parsed.context.systemPrompt ?? []),
131
134
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
132
135
  GOOGLE_BREVITY_INSTRUCTION,
133
- ].join("\n\n"));
136
+ ].join("\n\n"), routedModelId);
134
137
  const systemInstruction = { parts: [{ text: systemText }] };
135
138
 
136
139
  const contents: unknown[] = [];
@@ -229,6 +232,28 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
229
232
  }];
230
233
  }
231
234
 
235
+ /**
236
+ * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in
237
+ * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent
238
+ * so the common case is byte-identical. The allowedTools variant already filters the
239
+ * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode.
240
+ */
241
+ function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record<string, unknown> | undefined {
242
+ const choice = parsed.options.toolChoice;
243
+ if (!choice || choice === "auto") return undefined;
244
+ if (choice === "none") return { functionCallingConfig: { mode: "NONE" } };
245
+ if (choice === "required") return { functionCallingConfig: { mode: "ANY" } };
246
+ if (isAllowedToolChoice(choice)) {
247
+ return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined;
248
+ }
249
+ return {
250
+ functionCallingConfig: {
251
+ mode: "ANY",
252
+ allowedFunctionNames: [resolveToolChoiceWireName(parsed.context.tools, choice.name)],
253
+ },
254
+ };
255
+ }
256
+
232
257
  function usageFromGemini(usage: Record<string, number> | undefined): OcxUsage | undefined {
233
258
  if (!usage) return undefined;
234
259
  return {
@@ -292,12 +317,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
292
317
  : {}),
293
318
 
294
319
  async buildRequest(parsed: OcxParsedRequest) {
295
- const { systemInstruction, contents } = messagesToGeminiFormat(parsed);
320
+ const routedModelId = provider.googleMode === "cloud-code-assist"
321
+ ? resolveAntigravityEffortWireModel(
322
+ parsed.modelId,
323
+ mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning),
324
+ ).wireModelId
325
+ : parsed.modelId;
326
+ const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId);
296
327
  const tools = toolsToGeminiFormat(parsed);
297
328
 
298
329
  const body: Record<string, unknown> = { contents };
299
330
  if (systemInstruction) body.systemInstruction = systemInstruction;
300
331
  if (tools) body.tools = tools;
332
+ // Only meaningful with declarations on the wire: mode ANY with an empty
333
+ // catalog is a guaranteed upstream 400.
334
+ const toolConfig = tools ? toolChoiceToGeminiToolConfig(parsed) : undefined;
335
+ if (toolConfig) body.toolConfig = toolConfig;
301
336
 
302
337
  const generationConfig: Record<string, unknown> = {};
303
338
  if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens;
@@ -349,6 +384,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
349
384
  const draftRequest: Record<string, unknown> = { ...body, sessionId };
350
385
  // Claude-on-Antigravity forces VALIDATED function calling (the real client always sets it).
351
386
  if (/claude/i.test(wireModelId)) {
387
+ // VALIDATED would defeat a client's tool_choice "none": honor it by dropping the
388
+ // declarations instead, the wire shape of a tool-less Claude turn.
389
+ if (parsed.options.toolChoice === "none") {
390
+ delete draftRequest.tools;
391
+ delete draftRequest.toolConfig;
392
+ }
352
393
  const existing = (draftRequest.toolConfig ?? {}) as Record<string, unknown>;
353
394
  const fcc = (existing.functionCallingConfig ?? {}) as Record<string, unknown>;
354
395
  draftRequest.toolConfig = { ...existing, functionCallingConfig: { ...fcc, mode: "VALIDATED" } };
@@ -587,7 +628,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
587
628
  // Fail-closed: a turn cut off mid tool call (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces
588
629
  // an error instead of a silently-incomplete done. Mirrors kiro-truncation.
589
630
  if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
590
- && toolCallsStarted > 0 && isVertexTruncationReason(lastFinishReason)) {
631
+ && isVertexTruncatedTurn(lastFinishReason, toolCallsStarted)) {
591
632
  yield { type: "error", message: vertexTruncationErrorMessage(lastFinishReason) };
592
633
  return;
593
634
  }
@@ -743,7 +784,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
743
784
  // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call
744
785
  // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
745
786
  if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
746
- && toolCallsStarted > 0 && isVertexTruncationReason(candidates?.[0]?.finishReason)) {
787
+ && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) {
747
788
  return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]);
748
789
  }
749
790
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Central identity neutralization.
2
+ * Central routed-model identity repair.
3
3
  *
4
4
  * Codex sends the SAME GPT-5 identity line to EVERY model at request time (the per-model catalog
5
5
  * `base_instructions` is ignored on the wire). For routed, non-OpenAI providers that line is both
@@ -8,10 +8,11 @@
8
8
  * into the upstream payload — a signature no first-party client (Claude Code, Gemini CLI, Kiro) ever
9
9
  * sends, and a likely ToS trigger.
10
10
  *
11
- * The neutral replacement keeps ONLY the necessary instruction (don't misreport as GPT-5/OpenAI)
12
- * and names no proxy. Provider-native identity blocks (e.g. the anthropic OAuth "You are a Claude
13
- * agent..." prefix) are layered on TOP of this by the individual adapters; this module never claims
14
- * to be a specific first-party client.
11
+ * The replacement keeps the necessary instruction (don't misreport as GPT-5/OpenAI), names the
12
+ * model id that is actually sent on the wire when it is safe to interpolate, and names no proxy.
13
+ * Provider-native identity blocks (e.g. the anthropic OAuth "You are a Claude agent..." prefix)
14
+ * are layered on TOP of this by the individual adapters; this module never claims to be a specific
15
+ * first-party client.
15
16
  */
16
17
 
17
18
  /** Historical exact identity line Codex injected for every model. */
@@ -37,7 +38,39 @@ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be
37
38
  * the leak can't reappear in one adapter while being fixed in another.
38
39
  */
39
40
  export function neutralizeIdentity(systemText: string): string {
40
- return systemText.replace(CODEX_GPT5_IDENTITY_RE, NEUTRAL_IDENTITY_LINE);
41
+ // A callback avoids `$&`, `$'`, and other replacement-string substitutions if this constant ever
42
+ // becomes configurable. Keep the same safe form in identifyRoutedModel below.
43
+ return systemText.replace(CODEX_GPT5_IDENTITY_RE, () => NEUTRAL_IDENTITY_LINE);
44
+ }
45
+
46
+ function safeRoutedModelIdentity(modelName: string): string | null {
47
+ // Callers pass the model id after adapter-specific wire normalization. Brackets remain valid for
48
+ // providers that intentionally send a suffix such as `[1m]`; the OpenAI-chat adapter strips that
49
+ // suffix before calling us only when modelSuffixBracketStrip is enabled.
50
+ const trimmed = modelName.trim();
51
+ if (trimmed.length === 0 || trimmed.length > 128) return null;
52
+ const allowedPunctuation = "._/@:+-[]~";
53
+ for (const char of trimmed) {
54
+ const code = char.charCodeAt(0);
55
+ const isAsciiAlphaNumeric = (code >= 48 && code <= 57)
56
+ || (code >= 65 && code <= 90)
57
+ || (code >= 97 && code <= 122);
58
+ if (!isAsciiAlphaNumeric && !allowedPunctuation.includes(char)) return null;
59
+ }
60
+ return trimmed;
61
+ }
62
+
63
+ /**
64
+ * Identity for a routed model. Callers pass the concrete model id that will be sent upstream, so
65
+ * identity questions can name it instead of falling back to Codex/GPT identity inherited from the
66
+ * native template.
67
+ */
68
+ export function identifyRoutedModel(systemText: string, modelName: string): string {
69
+ const identity = safeRoutedModelIdentity(modelName);
70
+ const replacement = identity
71
+ ? `You are a coding agent powered by the ${identity}. If asked which model you are, identify as ${identity}. Do not claim to be a different model or to have a different creator.`
72
+ : "You are a coding agent powered by the configured model. If asked which model you are, identify as configured model. Do not claim to be GPT-5 or made by OpenAI.";
73
+ return systemText.replace(CODEX_GPT5_IDENTITY_RE, () => replacement);
41
74
  }
42
75
 
43
76
  /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */
@@ -109,6 +109,17 @@ function classifyKiroFailure(
109
109
  retryable: false,
110
110
  };
111
111
  }
112
+ // #993: a gated model demanding a profileArn gets a stable, actionable code
113
+ // instead of the generic validation bucket. Non-retryable by definition.
114
+ if (evidence.includes("profilearn") && evidence.includes("required")) {
115
+ return {
116
+ message: "kiro_profile_required: Kiro requires a CodeWhisperer profileArn for this account and model. Re-login or re-import the matching Kiro account (ocx account login kiro --reauth) so the profile is captured, then retry.",
117
+ status: 400,
118
+ errorType: "invalid_request_error",
119
+ code: "kiro_profile_required",
120
+ retryable: false,
121
+ };
122
+ }
112
123
  if (
113
124
  evidence.includes("insufficient_quota")
114
125
  || evidence.includes("quota exhausted")
@@ -3,7 +3,8 @@ import { kiroTruncationReason } from "./kiro-truncation";
3
3
 
4
4
  export type ParsedKiroEvent =
5
5
  | { type: "content"; data?: string; modelId?: string }
6
- | { type: "reasoning"; data?: string }
6
+ | { type: "reasoning"; data?: string; redactedContent?: string }
7
+ | { type: "context_usage"; contextUsagePercentage: number }
7
8
  | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
8
9
  | { type: "truncation"; data: string }
9
10
  | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
@@ -17,6 +18,10 @@ const KNOWN_EVENT_TYPES = new Set([
17
18
  "toolUseEvent",
18
19
  "messageMetadataEvent",
19
20
  "metadataEvent",
21
+ // Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
22
+ // HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
23
+ // contextUsagePercentage stays supported as a fallback rather than being dropped.
24
+ "contextUsageEvent",
20
25
  "invalidStateEvent",
21
26
  "error",
22
27
  ]);
@@ -114,11 +119,17 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
114
119
  : {}),
115
120
  };
116
121
  case "reasoningContentEvent":
122
+ // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family
123
+ // (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not
124
+ // the model id. Both may be absent on a bare event.
117
125
  return {
118
126
  type: "reasoning",
119
127
  ...(optionalString(eventType, parsed, "text") !== undefined
120
128
  ? { data: optionalString(eventType, parsed, "text") }
121
129
  : {}),
130
+ ...(optionalString(eventType, parsed, "redactedContent") !== undefined
131
+ ? { redactedContent: optionalString(eventType, parsed, "redactedContent") }
132
+ : {}),
122
133
  };
123
134
  case "toolUseEvent":
124
135
  return {
@@ -161,6 +172,13 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
161
172
  ...(stopReason !== undefined ? { stopReason } : {}),
162
173
  };
163
174
  }
175
+ case "contextUsageEvent": {
176
+ const contextUsagePercentage = parsed.contextUsagePercentage;
177
+ if (typeof contextUsagePercentage !== "number" || !Number.isFinite(contextUsagePercentage)) {
178
+ return malformed(eventType, "contextUsagePercentage must be a finite number");
179
+ }
180
+ return { type: "context_usage", contextUsagePercentage };
181
+ }
164
182
  case "invalidStateEvent":
165
183
  return { type: "invalid_state", message: optionalString(eventType, parsed, "message") };
166
184
  case "error":
@@ -89,8 +89,16 @@ export class KiroThinkingParser {
89
89
  return events;
90
90
  }
91
91
  if (this.thinkingBuffer.length <= MAX_CLOSE_TAG) return [];
92
- const send = this.thinkingBuffer.slice(0, -MAX_CLOSE_TAG);
93
- this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(-MAX_CLOSE_TAG));
92
+ // Never split a surrogate pair at the send boundary: a lone high
93
+ // surrogate at the end of one delta encodes as U+FFFD. Move the cut one
94
+ // unit earlier so the whole pair stays in the carry.
95
+ let cut = this.thinkingBuffer.length - MAX_CLOSE_TAG;
96
+ if (cut > 0 && cut < this.thinkingBuffer.length) {
97
+ const atCut = this.thinkingBuffer.charCodeAt(cut - 1);
98
+ if (atCut >= 0xd800 && atCut <= 0xdbff) cut -= 1;
99
+ }
100
+ const send = this.thinkingBuffer.slice(0, cut);
101
+ this.replaceCarry("thinkingBuffer", this.thinkingBuffer.slice(cut));
94
102
  return send ? [{ type: "reasoning_raw_delta", text: send }] : [];
95
103
  }
96
104
  }
@@ -149,7 +149,16 @@ function toolDescriptionLimit(modelId: string): number {
149
149
  function truncateDescription(description: string, limit: number): string {
150
150
  if (description.length <= limit) return description;
151
151
  if (limit <= 1) return description.slice(0, limit);
152
- return `${description.slice(0, limit - 1)}…`;
152
+ let end = limit - 1;
153
+ // Never end the kept text on a lone high surrogate; one step back keeps
154
+ // the whole pair out instead of a U+FFFD-producing half.
155
+ if (description.charCodeAt(end - 1) >= 0xd800 && description.charCodeAt(end - 1) <= 0xdbff) end -= 1;
156
+ return `${description.slice(0, end)}…`;
157
+ }
158
+
159
+ /** Test-only: exercise the surrogate-safe description truncation directly. */
160
+ export function truncateDescriptionForTests(description: string, limit: number): string {
161
+ return truncateDescription(description, limit);
153
162
  }
154
163
 
155
164
  function serializedToolCatalogBytes(tools: readonly unknown[]): number {