@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -18,6 +18,6 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string }
18
18
  */
19
19
  export function contentPartsToText(content: string | OcxContentPart[]): string {
20
20
  if (typeof content === "string") return content;
21
- const text = content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
21
+ const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
22
22
  return text || "[image]";
23
23
  }
@@ -1,4 +1,19 @@
1
1
  export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
2
+
3
+ /**
4
+ * Request-scoped CodeWhisperer service profile for AWS Builder ID accounts.
5
+ *
6
+ * Builder ID is a personal identity with no AWS account behind it, so AWS never mints an
7
+ * account-scoped `profile/<id>` ARN for it. The Kiro CLI resolves this the same way: it carries
8
+ * this fixed service profile on Builder ID requests. The embedded account id is Amazon's own, not
9
+ * the user's, which is why sending it is not the same as synthesizing an account identity.
10
+ *
11
+ * Request-scoped is load-bearing. This value must never be persisted into `KiroOAuthMetadata`,
12
+ * never seed region inference (it is `us-east-1` and would pin every Builder ID account there),
13
+ * and never participate in account matching.
14
+ */
15
+ export const KIRO_BUILDER_ID_SERVICE_PROFILE_ARN =
16
+ "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX";
2
17
  export const KIRO_CONTINUATION_MESSAGE =
3
18
  "Continue from the prior conversation. Do not quote or mention this instruction.";
4
19
  export const KIRO_COMPLETION_RETRY_MESSAGE =
@@ -7,6 +22,18 @@ export const KIRO_COMPLETION_RETRY_MESSAGE =
7
22
  export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached.";
8
23
  export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
9
24
 
25
+ /**
26
+ * Placeholder for the user turn Kiro requires after an assistant turn that ALREADY delivered its
27
+ * final answer.
28
+ *
29
+ * The protocol needs a trailing user turn, but the usual continuation/retry text instructs the
30
+ * model to keep working, which reopens a finished task and reads as a still-open goal. This states
31
+ * the delivered state and explicitly withholds a new request, so the turn stays structurally valid
32
+ * without asking for more work.
33
+ */
34
+ export const KIRO_ANSWER_DELIVERED_MESSAGE =
35
+ "The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction.";
36
+
10
37
  export const KIRO_COMPLETION_INSTRUCTIONS =
11
38
  `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
12
39
 
@@ -1,6 +1,7 @@
1
1
  import type { OcxParsedRequest, OcxTool } from "../types";
2
2
  import { namespacedToolName } from "../types";
3
3
  import { normalizeKiroModelId } from "../providers/kiro-models";
4
+ import { isCodexCodeModeExecTool } from "./tool-catalog-nudge";
4
5
  import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire";
5
6
 
6
7
  const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024;
@@ -103,7 +104,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
103
104
  // Seed with the root's own properties/required so a schema like
104
105
  // { type:"object", properties:{path}, required:["path"], oneOf:[...] } keeps them.
105
106
  if (obj.properties && typeof obj.properties === "object") {
106
- Object.assign(props, sanitizeKiroSchema(obj.properties) as Record<string, unknown>);
107
+ Object.assign(props, sanitizeSchemaMap(obj.properties) as Record<string, unknown>);
107
108
  }
108
109
  if (Array.isArray(obj.required)) {
109
110
  for (const r of obj.required) if (typeof r === "string") required.add(r);
@@ -118,7 +119,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
118
119
  if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
119
120
  const v = variant as Record<string, unknown>;
120
121
  if (v.properties && typeof v.properties === "object") {
121
- Object.assign(props, sanitizeKiroSchema(v.properties) as Record<string, unknown>);
122
+ Object.assign(props, sanitizeSchemaMap(v.properties) as Record<string, unknown>);
122
123
  }
123
124
  if (mergeRequired && Array.isArray(v.required)) {
124
125
  for (const r of v.required) if (typeof r === "string") required.add(r);
@@ -172,6 +173,18 @@ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], reg
172
173
  return `[opencodex] Kiro's outbound catalog budget allows ${kept} of ${kept + omitted.length} client tools this turn. Omitted and unavailable this turn: ${summary}.`;
173
174
  }
174
175
 
176
+ function boundedCatalogPriority(tool: OcxTool): number {
177
+ if (tool.loadedFromToolSearch) return 0;
178
+ // Codex code mode reaches shell, file edits, apply_patch and every MCP helper ONLY as nested
179
+ // `tools.<name>(...)` calls inside this one tool. Dropping it does not shrink the catalog, it
180
+ // makes the rest of the catalog uncallable -- so it outranks the search gateway and filler.
181
+ // It stays BEHIND `loadedFromToolSearch` because those are tools the model asked for by name
182
+ // this turn (#2475). Cursor pins its execution path the same way (request-builder.ts, #399).
183
+ if (isCodexCodeModeExecTool(tool)) return 1;
184
+ if (tool.toolSearch) return 2;
185
+ return 3;
186
+ }
187
+
175
188
  export function convertKiroToolContext(
176
189
  parsed: OcxParsedRequest,
177
190
  registry: KiroToolNameRegistry = createKiroToolNameRegistry(),
@@ -181,9 +194,7 @@ export function convertKiroToolContext(
181
194
  // Validate every listed name even when tool_choice:none emulates a tool-free turn.
182
195
  for (const tool of tools) registry.alias(namespacedToolName(tool.namespace, tool.name));
183
196
  const effectiveTools = parsed.options.toolChoice === "none" ? [] : tools;
184
- const convertedTools: unknown[] = [];
185
- let omittedAt = effectiveTools.length;
186
- for (const [index, tool] of effectiveTools.entries()) {
197
+ const convertedEntries = effectiveTools.map((tool, index) => {
187
198
  const description = tool.description || `Tool: ${tool.name}`;
188
199
  // Send the full namespaced wire name (e.g. mcp__chrome-devtools__navigate_page) so Kiro echoes
189
200
  // it back; the bridge's toolNsMap is keyed by this name and restores the MCP namespace Codex
@@ -198,19 +209,47 @@ export function convertKiroToolContext(
198
209
  inputSchema: { json: ensureRootObjectType(sanitizeKiroSchema(tool.parameters ?? {})) },
199
210
  },
200
211
  };
201
- // Preserve declaration order and only omit a suffix. Ranking tools would make a catalog change
202
- // silently alter which capability disappears; this deterministic policy is paired with a
203
- // model-visible omission notice so unavailable tools are explicit rather than assumed absent.
212
+ return { tool, index, converted };
213
+ });
214
+ const exceedsBudget = convertedEntries.length > MAX_KIRO_TOOL_COUNT
215
+ || serializedToolCatalogBytes(convertedEntries.map(entry => entry.converted)) > MAX_KIRO_TOOL_CATALOG_BYTES;
216
+ const candidates = exceedsBudget
217
+ ? convertedEntries.toSorted((a, b) => boundedCatalogPriority(a.tool) - boundedCatalogPriority(b.tool) || a.index - b.index)
218
+ : convertedEntries;
219
+ // Reserve a seat for the code-mode execution path before filling the rest.
220
+ //
221
+ // Priority alone cannot save it. `loadedFromToolSearch` tools outrank it and arrive unbounded
222
+ // (the Responses parser pushes every `tool_search_output` spec), so a session that accumulated
223
+ // MAX_KIRO_TOOL_COUNT loaded tools would exhaust the budget before reaching tier 1 and drop the
224
+ // one tool through which all of them are actually callable.
225
+ //
226
+ // Reservation rather than eviction: this lowers the room the fill loop sees, so it admits one
227
+ // fewer tool. It never removes a tool that already fit, which is what keeps #2475's loaded-result
228
+ // guarantee intact -- Cursor's `evictNonExecutionPath` exempts only the execution path and could
229
+ // evict a loaded tool instead.
230
+ const reserved = candidates.find(entry => isCodexCodeModeExecTool(entry.tool));
231
+ const admitted = new Set<number>();
232
+ const filled: unknown[] = [];
233
+ for (const entry of candidates) {
234
+ if (entry === reserved) continue;
235
+ // Measure the projected FINAL array: the byte budget is computed over the serialized array, so
236
+ // subtracting a standalone size would misjudge it by the separators JSON adds between entries.
237
+ const projected = reserved ? [...filled, entry.converted, reserved.converted] : [...filled, entry.converted];
204
238
  if (
205
- convertedTools.length >= MAX_KIRO_TOOL_COUNT
206
- || serializedToolCatalogBytes([...convertedTools, converted]) > MAX_KIRO_TOOL_CATALOG_BYTES
207
- ) {
208
- omittedAt = index;
209
- break;
210
- }
211
- convertedTools.push(converted);
239
+ projected.length > MAX_KIRO_TOOL_COUNT
240
+ || serializedToolCatalogBytes(projected) > MAX_KIRO_TOOL_CATALOG_BYTES
241
+ ) break;
242
+ filled.push(entry.converted);
243
+ admitted.add(entry.index);
212
244
  }
213
- const omittedTools = effectiveTools.slice(omittedAt);
245
+ if (reserved) admitted.add(reserved.index);
246
+ // Rebuild in sorted-candidate order so the wire order stays loaded -> exec -> gateway -> filler.
247
+ // Pushing the reserved entry after the loop would place it last instead.
248
+ const convertedTools = candidates.filter(entry => admitted.has(entry.index)).map(entry => entry.converted);
249
+ // Derive omissions by set difference. The old `candidates.slice(omittedAt)` assumed every
250
+ // candidate after the first rejection was omitted, which stops being true once one of them was
251
+ // reserved and admitted: the notice would name `exec` unavailable while it is on the wire.
252
+ const omittedTools = candidates.filter(entry => !admitted.has(entry.index)).map(entry => entry.tool);
214
253
  return {
215
254
  tools: convertedTools,
216
255
  systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [],
@@ -1,7 +1,7 @@
1
1
  import { decodeEventStream } from "../lib/eventstream-decoder";
2
2
  import { estimateTokens } from "../lib/token-estimate";
3
3
  import { debugProviderDiagnostic } from "../lib/debug";
4
- import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
4
+ import { resolveKiroApiRegion, resolveKiroRequestProfile } from "../oauth/kiro";
5
5
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
6
6
  import { modelRecordValue } from "../reasoning-effort";
7
7
  import { parseKiroEvent } from "./kiro-events";
@@ -33,17 +33,21 @@ import type {
33
33
  OcxTextContent,
34
34
  OcxToolCall,
35
35
  OcxToolResultMessage,
36
+ OcxTool,
36
37
  OcxUsage,
37
38
  } from "../types";
39
+ import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination";
38
40
  import type { ProviderAdapter } from "./base";
39
41
  import type { AdapterFetchContext, AdapterRequest } from "./base";
40
42
  import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
41
43
  import { sniffImageDimensions } from "./anthropic-image-guard";
42
44
  import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
43
45
  import { convertKiroToolContext } from "./kiro-tools";
46
+ import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
44
47
  import { identifyRoutedModel } from "./identity";
45
- import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge";
48
+ import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
46
49
  import {
50
+ KIRO_ANSWER_DELIVERED_MESSAGE,
47
51
  KIRO_COMPLETION_INSTRUCTIONS,
48
52
  KIRO_COMPLETION_RETRY_MESSAGE,
49
53
  KIRO_COMPLETION_TOOL_NAME,
@@ -318,21 +322,78 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
318
322
  if (choice !== undefined && choice !== "auto" && choice !== "none") {
319
323
  throw new Error("Kiro supports only automatic tool choice or tool_choice:none");
320
324
  }
321
- if (parsed.options.parallelToolCalls === true) {
322
- throw new Error("Kiro does not support parallel tool calls");
323
- }
324
325
  if (parsed.options.serviceTier !== undefined) {
325
326
  throw new Error("Kiro does not support service tiers");
326
327
  }
327
- const raw = parsed._rawBody as Record<string, unknown> | undefined;
328
- if (parsed._structuredOutput || raw?.text !== undefined) {
329
- throw new Error("Kiro does not support Responses text controls or structured output");
328
+ // Structured output is a real contract Kiro cannot honour: the wire has no
329
+ // schema-constrained response mode, so a caller expecting parseable JSON would receive
330
+ // prose and fail downstream. Refuse it.
331
+ //
332
+ // The rest of the Responses `text` object is not that. `text.verbosity` is a length
333
+ // preference and `text.format: {type:"text"}` is ordinary prose — the default output
334
+ // mode, which no capability flag governs and every correct client may send. Testing
335
+ // `_rawBody.text !== undefined` refused those turns for the mere PRESENCE of the key,
336
+ // the same mistake db040e70f removed one condition earlier where a permissive
337
+ // `parallel_tool_calls` hint was read as a requirement.
338
+ //
339
+ // Nothing needs stripping the way openai-responses strips a no-op verbosity:
340
+ // buildKiroPayload composes conversationState field by field from `parsed` and never
341
+ // spreads `_rawBody`, so a tolerated control is dropped by construction. The test
342
+ // asserts that absence so it stays true.
343
+ if (parsed._structuredOutput) {
344
+ throw new Error("Kiro does not support Responses structured output");
330
345
  }
331
346
  }
332
347
 
333
348
  type KiroTurn =
334
- | { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
335
- | { kind: "assistant"; content: string; toolUses: KiroToolUse[]; redactedReasoning?: string };
349
+ | {
350
+ kind: "user";
351
+ content: string;
352
+ images: KiroImage[];
353
+ toolResults: KiroToolResult[];
354
+ /**
355
+ * True only for the proxy-generated acknowledgement that follows a delivered final answer.
356
+ * A flag rather than a content comparison: a real user message may legitimately quote the
357
+ * same sentence, and treating that as internal state would strip its thinking tags and
358
+ * completion retry.
359
+ */
360
+ answerDeliveredAck?: boolean;
361
+ }
362
+ | {
363
+ kind: "assistant";
364
+ content: string;
365
+ toolUses: KiroToolUse[];
366
+ redactedReasoning?: string;
367
+ /**
368
+ * True when this assistant turn was the DELIVERED final answer (Responses
369
+ * `phase: "final_answer"`). A trailing assistant turn normally means the model stopped
370
+ * mid-task and needs a continuation prompt, but a delivered final answer already ended its
371
+ * turn — prompting it again restarts finished work as if a goal were still open.
372
+ */
373
+ finalAnswer?: boolean;
374
+ };
375
+
376
+ /**
377
+ * True when the LAST content-bearing message is an assistant final answer that closed its turn.
378
+ *
379
+ * Mirrors the turn-merge rule: a tool call in that message, or any later user/tool-result message,
380
+ * means work continued, so the turn is no longer terminal. Empty assistant messages are skipped
381
+ * rather than treated as continuation, since they carry no visible turn.
382
+ */
383
+ function hasTrailingDeliveredFinalAnswer(messages: readonly OcxMessage[], parsed?: OcxParsedRequest): boolean {
384
+ for (let i = messages.length - 1; i >= 0; i--) {
385
+ const msg = messages[i];
386
+ if (msg.role !== "assistant") return false;
387
+ const aMsg = msg as OcxAssistantMessage;
388
+ const hasToolCall = (aMsg.content ?? []).some(part => part.type === "toolCall");
389
+ if (hasToolCall) return false;
390
+ const hasText = (aMsg.content ?? []).some(part => part.type === "text" && part.text.trim());
391
+ if (!hasText) continue;
392
+ return aMsg.phase === "final_answer"
393
+ || (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages));
394
+ }
395
+ return false;
396
+ }
336
397
 
337
398
  function appendTurnText(target: string, next: string): string {
338
399
  if (!next) return target;
@@ -441,8 +502,19 @@ export function buildKiroPayload(
441
502
  const registry = createKiroToolNameRegistry();
442
503
  const toolContext = convertKiroToolContext(parsed, registry);
443
504
  const ordinaryTools = toolContext.tools;
505
+ // A turn whose history already ENDS with a delivered final answer has nothing to complete.
506
+ // Leaving completion "required" here would keep advertising codex_kiro_final_answer with its
507
+ // instructions, so the model answers again, or replies with ordinary text and trips the
508
+ // `needsFallback` retry, which ends its payload with KIRO_COMPLETION_RETRY_MESSAGE and reopens
509
+ // the finished task. Suppressing the mode is what actually closes that loop; the neutral
510
+ // acknowledgement below only stops the resume wording.
511
+ //
512
+ // Read from parsed messages because `completionMode` is needed to build the tool catalog, which
513
+ // happens before the turn list exists. `forcedCompletionMode` still wins: the fallback retry
514
+ // passes "text_fallback" explicitly and must not be silently downgraded.
515
+ const trailingDeliveredAnswer = hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed);
444
516
  const completionMode: KiroCompletionMode = forcedCompletionMode
445
- ?? (ordinaryTools.length > 0 ? "required" : "disabled");
517
+ ?? (ordinaryTools.length > 0 && !trailingDeliveredAnswer ? "required" : "disabled");
446
518
  const kiroTools = completionMode === "disabled"
447
519
  ? ordinaryTools
448
520
  : [...ordinaryTools, kiroCompletionTool()];
@@ -463,9 +535,32 @@ export function buildKiroPayload(
463
535
  // name for a tool that was never advertised and pollute the collision domain.
464
536
  const advertisedAlias = new Map<string, string>();
465
537
  for (const [alias, wireName] of registry.nameMap) advertisedAlias.set(wireName, alias);
538
+ // Code mode is decided on the EMITTED catalog, not the requested list.
539
+ //
540
+ // `freeform` only exists on the requested tool objects -- `kiroToolWireNames` has already
541
+ // reduced the emitted catalog to strings -- so the predicates must read the objects. But the
542
+ // SHAPE that matters is the one the model receives: the count/byte budget can drop a requested
543
+ // `exec_command` while `exec` survives, and scanning the requested list would then find a shell
544
+ // bridge the model cannot call and suppress code mode for a catalog that is code-mode-shaped.
545
+ // Intersecting the two keeps `tool_choice: "none"` and budget omission correct for free: both
546
+ // empty the emitted set, so nothing can be named.
547
+ const emittedToolNames = new Set(kiroToolWireNames(kiroTools));
548
+ const emittedAlias = (tool: OcxTool): string | undefined => {
549
+ const wireName = namespacedToolName(tool.namespace, tool.name);
550
+ // Read the recorded mapping; `registry.alias()` would REGISTER a name here.
551
+ const alias = advertisedAlias.get(wireName) ?? wireName;
552
+ return emittedToolNames.has(alias) ? alias : undefined;
553
+ };
554
+ const requestedTools = parsed.context.tools ?? [];
555
+ const emittedCodeModeExec = requestedTools.find(tool => isCodexCodeModeExecTool(tool) && emittedAlias(tool));
556
+ const emittedShellBridge = requestedTools.some(tool => isBareShellBridgeTool(tool) && emittedAlias(tool));
557
+ const codeModeExecName = emittedCodeModeExec && !emittedShellBridge
558
+ ? emittedAlias(emittedCodeModeExec)
559
+ : undefined;
466
560
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(
467
561
  kiroToolWireNames(kiroTools),
468
562
  name => advertisedAlias.get(name) ?? name,
563
+ codeModeExecName,
469
564
  );
470
565
  const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined;
471
566
  if (boundedNudge) systemParts.push(boundedNudge);
@@ -486,15 +581,24 @@ export function buildKiroPayload(
486
581
  turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
487
582
  }
488
583
  };
489
- const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
584
+ const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string, finalAnswer?: boolean): void => {
490
585
  const last = turns.at(-1);
491
586
  if (last?.kind === "assistant") {
492
587
  last.content = appendTurnText(last.content, content);
493
588
  last.toolUses.push(...toolUses);
494
589
  // Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
495
590
  if (redactedReasoning) last.redactedReasoning = redactedReasoning;
591
+ // A merged turn is final only if its LAST component was: commentary appended after a final
592
+ // answer means the model kept working, so the turn is no longer terminal.
593
+ last.finalAnswer = finalAnswer === true;
496
594
  } else {
497
- turns.push({ kind: "assistant", content, toolUses: [...toolUses], ...(redactedReasoning ? { redactedReasoning } : {}) });
595
+ turns.push({
596
+ kind: "assistant",
597
+ content,
598
+ toolUses: [...toolUses],
599
+ ...(redactedReasoning ? { redactedReasoning } : {}),
600
+ ...(finalAnswer ? { finalAnswer: true } : {}),
601
+ });
498
602
  }
499
603
  };
500
604
 
@@ -524,14 +628,24 @@ export function buildKiroPayload(
524
628
  const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
525
629
  if (hasReasoning) continue;
526
630
  }
527
- pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning);
631
+ // `phase` survives the Responses round trip (parser.ts assistant branch), so a replayed
632
+ // final answer is identifiable here rather than guessed from turn position.
633
+ pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning, aMsg.phase === "final_answer" && toolUses.length === 0);
528
634
  } else if (msg.role === "toolResult") {
529
635
  const tr = msg as OcxToolResultMessage;
530
636
  if (tr.containsEncryptedContent) {
531
637
  throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
532
638
  }
533
639
  const text = userContentText(tr.content);
534
- const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
640
+ // An empty code-mode exec result needs the SPECIFIC reason, not the generic fallback: the
641
+ // model otherwise reads a blank result, concludes its earlier context was lost, and restarts
642
+ // the task instead of calling text()/notify(). Checked before `text.trim()` because the
643
+ // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would
644
+ // otherwise pass through as if it were real output.
645
+ const resultText = normalizeEmptyExecToolResultText(text, {
646
+ toolName: tr.toolName,
647
+ toolNamespace: tr.toolNamespace,
648
+ }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
535
649
  const images = extractKiroImages(tr.content);
536
650
  const toolUseId = normalizeToolId(tr.toolCallId);
537
651
  if (!priorCalls.has(toolUseId)) {
@@ -552,12 +666,23 @@ export function buildKiroPayload(
552
666
  if (turns.length === 0 || turns[0].kind === "assistant") {
553
667
  turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
554
668
  }
555
- if (turns.at(-1)?.kind === "assistant") {
669
+ // Kiro requires the request to end with a user turn, so a trailing assistant turn always gets
670
+ // one appended (the pop below throws otherwise). What that turn SAYS is the load-bearing part.
671
+ //
672
+ // Normally a trailing assistant turn means the model stopped mid-task, and a continuation/retry
673
+ // prompt is correct. A DELIVERED final answer is the exception: the turn already ended, and
674
+ // telling that model to "continue" or to call the completion tool again reopens finished work —
675
+ // the completed-task-behaves-like-an-open-goal loop. It gets a neutral acknowledgement instead:
676
+ // structurally valid, but carrying no instruction to resume.
677
+ const trailing = turns.at(-1);
678
+ if (trailing?.kind === "assistant") {
679
+ const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE;
556
680
  turns.push({
557
681
  kind: "user",
558
- content: completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE,
682
+ content: trailing.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText,
559
683
  images: [],
560
684
  toolResults: [],
685
+ ...(trailing.finalAnswer ? { answerDeliveredAck: true } : {}),
561
686
  });
562
687
  }
563
688
 
@@ -573,6 +698,8 @@ export function buildKiroPayload(
573
698
 
574
699
  const currentTurn = turns.pop();
575
700
  if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
701
+ // Survives the pop as state, so the checks below never infer intent from user-supplied text.
702
+ const answerDeliveredAck = currentTurn.answerDeliveredAck === true;
576
703
  const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
577
704
  ? {
578
705
  assistantResponseMessage: {
@@ -603,10 +730,17 @@ export function buildKiroPayload(
603
730
  currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
604
731
  }
605
732
  if (completionMode === "text_fallback") {
606
- if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE) {
733
+ // Never append the retry instruction onto the answer-delivered acknowledgement: it exists
734
+ // precisely to avoid asking a finished turn for another completion call, and appending here
735
+ // would reinstate the loop it prevents.
736
+ if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) {
607
737
  currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE);
608
738
  }
609
- } else if (!currentUim.userInputMessageContext?.toolResults && currentUim.content !== KIRO_CONTINUATION_MESSAGE) {
739
+ } else if (
740
+ !currentUim.userInputMessageContext?.toolResults
741
+ && currentUim.content !== KIRO_CONTINUATION_MESSAGE
742
+ && !answerDeliveredAck
743
+ ) {
610
744
  currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
611
745
  }
612
746
 
@@ -1726,12 +1860,19 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1726
1860
  throw new Error("kiro token missing — run ocx login kiro");
1727
1861
  }
1728
1862
  const region = resolveKiroApiRegion(parsed._kiroAuthContext);
1729
- const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext);
1863
+ // Request-scoped: an AWS Builder ID account has no profile of its own and resolves to Kiro's
1864
+ // fixed service profile here, without that value ever becoming the account's stored identity.
1865
+ const requestProfile = resolveKiroRequestProfile(parsed._kiroAuthContext);
1866
+ const resolvedProfileArn = requestProfile.profileArn;
1730
1867
  const isApiKey = provider.apiKey.trim().startsWith("ksk_");
1731
1868
  const profileArn = isApiKey ? undefined : resolvedProfileArn;
1732
- // Builder ID and Kiro API keys have no profile ARN and are accepted only on Kiro's CLI
1733
- // request path. Enterprise profiles retain the existing IDE-shaped request.
1734
- const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide";
1869
+ // Builder ID and Kiro API keys are accepted only on Kiro's CLI request path; enterprise
1870
+ // profiles retain the IDE-shaped request. Builder ID now carries a profile ARN, so a truthy
1871
+ // `profileArn` no longer implies "enterprise". The wire path reads the resolver's own verdict
1872
+ // rather than re-deriving it, so the accountless path — where the auth type comes from the
1873
+ // local import, not the request context — cannot send the fallback inside an IDE-shaped call.
1874
+ const isBuilderId = requestProfile.builderIdFallback;
1875
+ const wireClient: KiroWireClient = isApiKey || isBuilderId || !profileArn ? "cli" : "ide";
1735
1876
  const fp = fingerprint().slice(0, 64);
1736
1877
  const headers: Record<string, string> = wireClient === "cli" ? {
1737
1878
  authorization: `Bearer ${provider.apiKey}`,
@@ -1863,6 +2004,24 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1863
2004
 
1864
2005
  return {
1865
2006
  name: "kiro",
2007
+ // A replayed history that already ENDS with a delivered final answer has nothing to ask Kiro.
2008
+ // Before this hook the adapter still appended a trailing user turn — a neutral acknowledgement,
2009
+ // but structurally still a prompt — and performed a real inference, so the model answered the
2010
+ // closed task again and the finished turn behaved like a still-open goal.
2011
+ //
2012
+ // Suppressing the completion contract (above) removed the instruction to complete; it could not
2013
+ // remove the inference. This is the boundary: no request is built, nothing is sent, and no token
2014
+ // estimate is recorded.
2015
+ //
2016
+ // The forced-fallback build is deliberately NOT consulted here: this hook runs on the inbound
2017
+ // turn only, and the adapter-owned bounded retry passes "text_fallback" through `build`
2018
+ // directly, never through this path.
2019
+ localTerminal(parsed: OcxParsedRequest) {
2020
+ return hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed)
2021
+ ? { reason: "kiro_final_answer_already_delivered" }
2022
+ : undefined;
2023
+ },
2024
+
1866
2025
  async buildRequest(parsed: OcxParsedRequest, incoming) {
1867
2026
  const built = await build(parsed);
1868
2027
  modelId = parsed.modelId;