@bitkyc08/opencodex 2.43.0 → 2.44.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 (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -140,6 +140,10 @@ let blobOldestEvictableAt: number | null = null;
140
140
  let rejectedEntryTooLarge = 0;
141
141
  let rejectedPinnedSaturation = 0;
142
142
  let blobExpiryAccountingTimer: ReturnType<typeof setTimeout> | undefined;
143
+ /** Earliest unpinned storedAt+ttl; skip the write-time TTL walk while this is in the future. */
144
+ let blobNextUnpinnedExpiryAt: number | null = null;
145
+ /** Earliest unpinned remote expiry strictly in the future; drives the single reclassify timer. */
146
+ let blobNextRemoteExpiryAt: number | null = null;
143
147
 
144
148
  function isExpired(entry: CursorBlobEntry, now: number): boolean {
145
149
  return now - entry.storedAt >= blobLimits.ttlMs;
@@ -157,6 +161,8 @@ function recomputeBlobClassAccounting(): void {
157
161
  let pinnedBytes = 0;
158
162
  let evictableBytes = 0;
159
163
  let oldestAt: number | null = null;
164
+ let nextUnpinnedExpiry = Number.POSITIVE_INFINITY;
165
+ let nextRemoteExpiry = Number.POSITIVE_INFINITY;
160
166
  for (const [k, entry] of blobs) {
161
167
  const requestPinned = entry.requestPins.size > 0;
162
168
  const provenancePinned = entry.provenance === "remote-setBlobArgs" && !isExpired(entry, now);
@@ -169,11 +175,20 @@ function recomputeBlobClassAccounting(): void {
169
175
  evictableBytes += entry.sizeBytes + k.length;
170
176
  oldestAt = oldestAt === null ? entry.storedAt : Math.min(oldestAt, entry.storedAt);
171
177
  }
178
+ if (!requestPinned) {
179
+ const expiresAt = entry.storedAt + blobLimits.ttlMs;
180
+ nextUnpinnedExpiry = Math.min(nextUnpinnedExpiry, expiresAt);
181
+ if (entry.provenance === "remote-setBlobArgs" && expiresAt > now) {
182
+ nextRemoteExpiry = Math.min(nextRemoteExpiry, expiresAt);
183
+ }
184
+ }
172
185
  }
173
186
  blobLocalBytes = localBytes;
174
187
  blobPinnedBytes = pinnedBytes;
175
188
  blobEvictableBytes = evictableBytes;
176
189
  blobOldestEvictableAt = oldestAt;
190
+ blobNextUnpinnedExpiryAt = Number.isFinite(nextUnpinnedExpiry) ? nextUnpinnedExpiry : null;
191
+ blobNextRemoteExpiryAt = Number.isFinite(nextRemoteExpiry) ? nextRemoteExpiry : null;
177
192
  scheduleBlobExpiryAccounting(now);
178
193
  }
179
194
 
@@ -185,13 +200,8 @@ function reconcileBlobClassAccountingAndEnforce(): void {
185
200
  function scheduleBlobExpiryAccounting(now: number): void {
186
201
  if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
187
202
  blobExpiryAccountingTimer = undefined;
188
- let nextExpiry = Number.POSITIVE_INFINITY;
189
- for (const entry of blobs.values()) {
190
- if (entry.provenance !== "remote-setBlobArgs" || entry.requestPins.size > 0) continue;
191
- const expiresAt = entry.storedAt + blobLimits.ttlMs;
192
- if (expiresAt > now) nextExpiry = Math.min(nextExpiry, expiresAt);
193
- }
194
- if (!Number.isFinite(nextExpiry)) return;
203
+ const nextExpiry = blobNextRemoteExpiryAt;
204
+ if (nextExpiry === null || nextExpiry <= now || !Number.isFinite(nextExpiry)) return;
195
205
  blobExpiryAccountingTimer = setTimeout(() => {
196
206
  blobExpiryAccountingTimer = undefined;
197
207
  reconcileBlobClassAccountingAndEnforce();
@@ -199,6 +209,41 @@ function scheduleBlobExpiryAccounting(now: number): void {
199
209
  blobExpiryAccountingTimer.unref?.();
200
210
  }
201
211
 
212
+ /**
213
+ * O(1) class/timer update for a newly admitted key when no other row changed.
214
+ * Full-map recompute stays on replacement, eviction, pin changes, and TTL fire —
215
+ * the 4096-entry ceiling fill must not walk the store on every remote admit.
216
+ */
217
+ function accountAdmittedBlob(k: string, entry: CursorBlobEntry, now: number): void {
218
+ const requestPinned = entry.requestPins.size > 0;
219
+ const expired = isExpired(entry, now);
220
+ const provenancePinned = entry.provenance === "remote-setBlobArgs" && !expired;
221
+ const logicalBytes = entry.sizeBytes + k.length;
222
+ if (entry.provenance === "local-regenerated") blobLocalBytes += entry.sizeBytes;
223
+ if (requestPinned || provenancePinned) blobPinnedBytes += logicalBytes;
224
+ if (!requestPinned && (entry.provenance === "local-regenerated" || expired)) {
225
+ blobEvictableBytes += logicalBytes;
226
+ blobOldestEvictableAt = blobOldestEvictableAt === null ? entry.storedAt : Math.min(blobOldestEvictableAt, entry.storedAt);
227
+ }
228
+ if (requestPinned) return;
229
+ const expiresAt = entry.storedAt + blobLimits.ttlMs;
230
+ blobNextUnpinnedExpiryAt = blobNextUnpinnedExpiryAt === null
231
+ ? expiresAt
232
+ : Math.min(blobNextUnpinnedExpiryAt, expiresAt);
233
+ if (entry.provenance !== "remote-setBlobArgs" || expiresAt <= now) return;
234
+ const previousRemoteExpiry = blobNextRemoteExpiryAt;
235
+ blobNextRemoteExpiryAt = previousRemoteExpiry === null
236
+ ? expiresAt
237
+ : Math.min(previousRemoteExpiry, expiresAt);
238
+ if (
239
+ !blobExpiryAccountingTimer
240
+ || previousRemoteExpiry === null
241
+ || expiresAt < previousRemoteExpiry
242
+ ) {
243
+ scheduleBlobExpiryAccounting(now);
244
+ }
245
+ }
246
+
202
247
  function deleteBlob(k: string, recompute = true): number {
203
248
  const entry = blobs.get(k);
204
249
  if (!entry) return 0;
@@ -246,9 +291,11 @@ function setBlob(
246
291
  }
247
292
 
248
293
  const removals = new Set<string>();
249
- for (const [candidateKey, entry] of blobs) {
250
- if (candidateKey === k && sameData) continue;
251
- if (entry.requestPins.size === 0 && isExpired(entry, now)) removals.add(candidateKey);
294
+ if (blobNextUnpinnedExpiryAt !== null && now >= blobNextUnpinnedExpiryAt) {
295
+ for (const [candidateKey, entry] of blobs) {
296
+ if (candidateKey === k && sameData) continue;
297
+ if (entry.requestPins.size === 0 && isExpired(entry, now)) removals.add(candidateKey);
298
+ }
252
299
  }
253
300
 
254
301
  const existingRemovedByTtl = existing !== undefined && removals.has(k);
@@ -326,7 +373,12 @@ function setBlob(
326
373
  blobBytes += entry.sizeBytes;
327
374
  blobKeyBytes += k.length;
328
375
  for (const scope of entry.requestPins) blobRequestScopes.get(scope)?.keys.add(k);
329
- reconcileBlobClassAccountingAndEnforce();
376
+ if (removals.size > 0 || existing !== undefined) {
377
+ reconcileBlobClassAccountingAndEnforce();
378
+ } else {
379
+ accountAdmittedBlob(k, entry, now);
380
+ enforceAppOwnedMemoryBudget();
381
+ }
330
382
  return { admitted: true, replaced: existing !== undefined };
331
383
  }
332
384
 
@@ -5,7 +5,7 @@ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from
5
5
  import { CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, cursorRequestAdvertisesApplyPatch, cursorToolAllowedByChoice, cursorToolWireName, OCX_RESPONSES_TOOL_PROVIDER } from "./tool-naming";
6
6
  import { CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, cursorToolInputSchema } from "./tool-schemas";
7
7
  export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming";
8
- export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas";
8
+ export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_FREEFORM_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas";
9
9
  export { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, CURSOR_GENERIC_TOOL_USE_USER_HINT, isGenericToolUseCountDemoPrompt, requestedCursorToolUseCount, shouldAppendCursorGenericToolUseHint, appendCursorGenericToolUseHint, shouldUseNativeExecOnlyForGenericToolUse, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote } from "./tool-guidance";
10
10
 
11
11
  /**
@@ -10,11 +10,54 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
10
10
  tty: { type: "boolean", description: "True allocates a PTY for the command; false or omitted uses plain pipes." },
11
11
  yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." },
12
12
  max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." },
13
+ sandbox_permissions: {
14
+ type: "string",
15
+ enum: ["use_default", "require_escalated"],
16
+ description: "Per-command sandbox override. Defaults to use_default; use require_escalated for unsandboxed execution.",
17
+ },
18
+ justification: {
19
+ type: "string",
20
+ description: "User-facing approval question for require_escalated; omit otherwise.",
21
+ },
22
+ prefix_rule: {
23
+ type: "array",
24
+ items: { type: "string" },
25
+ description: "Reusable approval prefix for cmd, only with sandbox_permissions: require_escalated.",
26
+ },
27
+ login: {
28
+ type: "boolean",
29
+ description: "True runs the shell with login semantics; false disables them. Defaults to true.",
30
+ },
13
31
  },
14
32
  required: ["cmd"],
15
33
  additionalProperties: false,
16
34
  } as const;
17
35
 
36
+ /** Cursor represents a Responses freeform tool body as one string-valued input field. */
37
+ export const CURSOR_FREEFORM_INPUT_SCHEMA = {
38
+ type: "object",
39
+ properties: { input: { type: "string" } },
40
+ required: ["input"],
41
+ additionalProperties: false,
42
+ } as const;
43
+
44
+ function cursorFreeformInputSchema(tool: OcxTool): unknown {
45
+ const properties = tool.parameters?.properties;
46
+ const input = properties && typeof properties === "object" && !Array.isArray(properties)
47
+ ? (properties as Record<string, unknown>).input
48
+ : undefined;
49
+ const description = input && typeof input === "object" && !Array.isArray(input)
50
+ ? (input as Record<string, unknown>).description
51
+ : undefined;
52
+ if (typeof description !== "string") return CURSOR_FREEFORM_INPUT_SCHEMA;
53
+ return {
54
+ ...CURSOR_FREEFORM_INPUT_SCHEMA,
55
+ properties: {
56
+ input: { ...CURSOR_FREEFORM_INPUT_SCHEMA.properties.input, description },
57
+ },
58
+ };
59
+ }
60
+
18
61
  /**
19
62
  * Structured single-replacement schema advertised to Cursor models in addition to the freeform
20
63
  * `apply_patch` tool. Cursor-trained models reliably emit exact-match replacements (the native
@@ -71,6 +114,10 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = {
71
114
  yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." },
72
115
  max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." },
73
116
  max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." },
117
+ sandbox_permissions: { type: "string", enum: ["use_default", "require_escalated"] },
118
+ justification: { type: "string" },
119
+ prefix_rule: { type: "array", items: { type: "string" } },
120
+ login: { type: "boolean" },
74
121
  },
75
122
  required: ["command"],
76
123
  } as const;
@@ -78,6 +125,12 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = {
78
125
 
79
126
  /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */
80
127
  export function cursorToolInputSchema(tool: OcxTool): unknown {
128
+ if (tool.freeform) {
129
+ if (isBareCodexShellBridgeTool(tool)) {
130
+ throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`);
131
+ }
132
+ return cursorFreeformInputSchema(tool);
133
+ }
81
134
  return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {});
82
135
  }
83
136
 
@@ -87,6 +140,12 @@ export function cursorToolInputSchema(tool: OcxTool): unknown {
87
140
  * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399).
88
141
  */
89
142
  export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown {
143
+ if (tool.freeform) {
144
+ if (isBareCodexShellBridgeTool(tool)) {
145
+ throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`);
146
+ }
147
+ return cursorFreeformInputSchema(tool);
148
+ }
90
149
  if (isBareCodexShellBridgeTool(tool)) {
91
150
  return shellBridgeArgNormalizeSchema(tool);
92
151
  }
@@ -44,7 +44,7 @@ import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-i
44
44
  import { sniffImageDimensions } from "./anthropic-image-guard";
45
45
  import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
46
46
  import { convertKiroToolContext } from "./kiro-tools";
47
- import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
47
+ import { EMPTY_EXEC_OUTPUT_MESSAGE, normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
48
48
  import { identifyRoutedModel } from "./identity";
49
49
  import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
50
50
  import {
@@ -663,7 +663,7 @@ export function buildKiroPayload(
663
663
  }
664
664
  const systemPrefix = systemParts.length > 0 ? `${systemParts.join("\n\n")}\n\n` : "";
665
665
  const turns: KiroTurn[] = [];
666
- const priorCalls = new Map<string, { wireName: string }>();
666
+ const priorCalls = new Map<string, { wireName: string; rawId: string }>();
667
667
  const pushUser = (content: string, images: KiroImage[] = [], toolResults: KiroToolResult[] = []): void => {
668
668
  const last = turns.at(-1);
669
669
  if (last?.kind === "user") {
@@ -695,7 +695,27 @@ export function buildKiroPayload(
695
695
  }
696
696
  };
697
697
 
698
+ let adjacentResult: {
699
+ rawId: string;
700
+ result: KiroToolResult;
701
+ texts: string[];
702
+ count: number;
703
+ hasImages: boolean;
704
+ } | undefined;
705
+ const finishAdjacentResult = (): void => {
706
+ if (adjacentResult && adjacentResult.count > 1) {
707
+ if (adjacentResult.texts.some(text => text.trim())) {
708
+ adjacentResult.result.content = adjacentResult.texts.map(text => ({ text }));
709
+ } else if (adjacentResult.hasImages || adjacentResult.result.status === "error") {
710
+ adjacentResult.result.content = [{ text: KIRO_EMPTY_TOOL_RESULT_MESSAGE }];
711
+ }
712
+ }
713
+ adjacentResult = undefined;
714
+ };
715
+
698
716
  for (const msg of kiroPayloadMessages(parsed)) {
717
+ // Original-message adjacency matters even when a turn is collapsed or skipped below.
718
+ if (msg.role !== "toolResult") finishAdjacentResult();
699
719
  if (msg.role === "user" || msg.role === "developer") {
700
720
  const text = userContentText((msg as { content: string | OcxContentPart[] }).content);
701
721
  const images = extractKiroImages((msg as { content: string | OcxContentPart[] }).content);
@@ -714,7 +734,7 @@ export function buildKiroPayload(
714
734
  if (priorCalls.has(toolUseId)) throw new Error(`Kiro history contains duplicate tool call id ${JSON.stringify(tc.id)}`);
715
735
  const wireName = namespacedToolName(tc.namespace, tc.name);
716
736
  const name = registry.alias(wireName);
717
- priorCalls.set(toolUseId, { wireName });
737
+ priorCalls.set(toolUseId, { wireName, rawId: tc.id });
718
738
  return { name, input: (tc.arguments ?? {}) as Record<string, unknown>, toolUseId };
719
739
  });
720
740
  if (!text && toolUses.length === 0) {
@@ -735,26 +755,52 @@ export function buildKiroPayload(
735
755
  // the task instead of calling text()/notify(). Checked before `text.trim()` because the
736
756
  // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would
737
757
  // otherwise pass through as if it were real output.
738
- const resultText = normalizeEmptyExecToolResultText(text, {
758
+ const normalizedExecText = normalizeEmptyExecToolResultText(text, {
739
759
  toolName: tr.toolName,
740
760
  toolNamespace: tr.toolNamespace,
741
- }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
761
+ });
762
+ const resultText = normalizedExecText ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
742
763
  const images = extractKiroImages(tr.content);
743
764
  const toolUseId = normalizeToolId(tr.toolCallId);
744
- if (!priorCalls.has(toolUseId)) {
765
+ const call = priorCalls.get(toolUseId);
766
+ if (!call || call.rawId !== tr.toolCallId) {
745
767
  throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
746
768
  }
769
+ // Keep real whitespace and failed wrappers, but no empty-success wrapper boilerplate.
770
+ const rawGroupText = text.length > 0 && (!text.trim() || normalizedExecText !== EMPTY_EXEC_OUTPUT_MESSAGE)
771
+ ? text : undefined;
772
+ const last = turns.at(-1);
773
+ if (
774
+ adjacentResult?.rawId === tr.toolCallId
775
+ && last?.kind === "user"
776
+ && last.toolResults.at(-1) === adjacentResult.result
777
+ ) {
778
+ adjacentResult.count += 1;
779
+ adjacentResult.hasImages ||= images.length > 0;
780
+ if (rawGroupText !== undefined) adjacentResult.texts.push(rawGroupText);
781
+ last.images.push(...images);
782
+ if (tr.isError) adjacentResult.result.status = "error";
783
+ continue;
784
+ }
785
+ finishAdjacentResult();
747
786
  // Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix.
748
787
  // Passing it here would push proxy filler AHEAD of a human instruction that Claude Code
749
788
  // sends in the same turn (mid-turn steering / queued_command, issue #543), burying the
750
789
  // newest user intent behind boilerplate. Backfill below only when nothing else speaks.
751
- pushUser("", images, [{
790
+ const result: KiroToolResult = {
752
791
  content: [{ text: resultText }],
753
792
  status: tr.isError ? "error" : "success",
754
793
  toolUseId,
755
- }]);
794
+ };
795
+ pushUser("", images, [result]);
796
+ adjacentResult = {
797
+ rawId: tr.toolCallId, result,
798
+ texts: rawGroupText === undefined ? [] : [rawGroupText],
799
+ count: 1, hasImages: images.length > 0,
800
+ };
756
801
  }
757
802
  }
803
+ finishAdjacentResult();
758
804
 
759
805
  if (turns.length === 0 || turns[0].kind === "assistant") {
760
806
  turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
@@ -1660,6 +1660,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1660
1660
  let bufferBytes = 0;
1661
1661
  interface PendingToolCall {
1662
1662
  key: string;
1663
+ indexKey?: string;
1663
1664
  id: string;
1664
1665
  name: string;
1665
1666
  args: string;
@@ -1848,17 +1849,29 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1848
1849
  const rawId = rawToolCall.id;
1849
1850
  const idDelta = typeof rawId === "string" ? rawId : "";
1850
1851
  const rawIndex = rawToolCall.index;
1852
+ // Only missing/null indexes are absent; every claimed index must be valid.
1853
+ // Unsafe integers can collapse distinct wire indexes onto the same JS number.
1854
+ // Reject before an alias can bind or any pending call can consume the fragment.
1855
+ if (rawIndex !== undefined && rawIndex !== null
1856
+ && (typeof rawIndex !== "number"
1857
+ || !Number.isSafeInteger(rawIndex)
1858
+ || rawIndex < 0)) {
1859
+ return yield* terminateWithError({
1860
+ ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage),
1861
+ message: "upstream response contained invalid tool calls (invalid index)",
1862
+ });
1863
+ }
1851
1864
 
1852
- // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible
1865
+ // Resolve the pending call BEFORE judging repeated string fields. Some OpenAI-compatible
1853
1866
  // streamers repeat an already-sent field as a non-string placeholder on a
1854
1867
  // continuation delta; judging first meant the whole stream died with a 502 even
1855
1868
  // though the value being repeated was already held in canonical form.
1856
- const key = typeof rawIndex === "number"
1857
- ? `i:${rawIndex}`
1858
- : idDelta
1859
- ? `id:${idDelta}`
1860
- : pendingToolCalls[pendingToolCalls.length - 1]?.key;
1869
+ const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined;
1870
+ const key = indexKey ?? (idDelta
1871
+ ? `id:${idDelta}`
1872
+ : pendingToolCalls[pendingToolCalls.length - 1]?.key);
1861
1873
  let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
1874
+ if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey);
1862
1875
  if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
1863
1876
  if (!call) {
1864
1877
  call = {
@@ -1872,6 +1885,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1872
1885
  pendingToolCalls.push(call);
1873
1886
  budget.openCall(call.key);
1874
1887
  }
1888
+ // An ID-only call may learn its index from a later ID+index fragment. Retain that
1889
+ // alias without changing the key that owns its argument budget. Only the first
1890
+ // observed index binds: a repeated ID on a different index must not alias both.
1891
+ if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey;
1875
1892
 
1876
1893
  // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name
1877
1894
  // says nothing about whether `arguments` was ever sent as a string, so it cannot
@@ -1,3 +1,4 @@
1
+ import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
4
  import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
@@ -2355,6 +2356,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2355
2356
  parsed._rawBody,
2356
2357
  forward || parsed._previousResponseInputExpanded === true,
2357
2358
  );
2359
+ if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody);
2358
2360
  outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
2359
2361
  // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
2360
2362
  // tier write so a force-fast/default decision can never mutate parsed._rawBody.
@@ -2427,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2427
2429
  // Codex 0.147 emits private namespace tool groups, while public/third-party Responses
2428
2430
  // gateways accept only flat tool variants. Run after custom/tool-search lowering so
2429
2431
  // namespace children already carry their final public kind before they are promoted.
2430
- const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
2432
+ const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames);
2431
2433
  outBody = rewritten.body;
2432
2434
  convertedRoutedNamespaceToolAliases = rewritten.aliases;
2433
2435
  // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
@@ -0,0 +1,35 @@
1
+ /** Match the Go destination, including user-renamed provider entries. */
2
+ export function isOpenCodeGo(baseUrl: string): boolean {
3
+ try {
4
+ const url = new URL(baseUrl);
5
+ return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1";
6
+ } catch { return false; }
7
+ }
8
+
9
+ /** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */
10
+ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
11
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
12
+ const record = body as Record<string, unknown>;
13
+ if (!Array.isArray(record.input)) return body;
14
+ let changed = false;
15
+ const input = record.input.map((item: unknown) => {
16
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
17
+ const message = item as Record<string, unknown>;
18
+ if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item;
19
+ // Genuine ciphertext and unknown part types must retain their existing fail-closed path.
20
+ if (!message.content.every(part => part && typeof part === "object"
21
+ && ["input_text", "input_image", "input_file"].includes(part.type))) return item;
22
+ const identities = Object.fromEntries(["author", "recipient"]
23
+ .filter(key => typeof message[key] === "string")
24
+ .map(key => [key, message[key]]));
25
+ changed = true;
26
+ return {
27
+ type: "message", role: "user",
28
+ content: [
29
+ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []),
30
+ ...message.content,
31
+ ],
32
+ };
33
+ });
34
+ return changed ? { ...record, input } : body;
35
+ }
@@ -20,6 +20,7 @@ import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneM
20
20
  import { claudeConfigDir } from "./gateway-cache";
21
21
  import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config";
22
22
  import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
23
+ import { AnthropicRequestError } from "./inbound-records";
23
24
  import { knownModelIdsForProvider } from "../router";
24
25
  import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
25
26
 
@@ -97,11 +98,17 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
97
98
  const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
98
99
  const blockedSkillsFor = (model: string): readonly string[] => {
99
100
  const unmarked = stripOneMillionMarker(model);
100
- const nativePassthrough = config.claudeCode?.nativePassthrough !== false
101
- && !unmarked.includes("/")
102
- && /^(claude|anthropic)(?:-|$)/i.test(unmarked)
103
- && resolveInboundModel(unmarked, config.claudeCode) === unmarked;
104
- return nativePassthrough ? [] : blockedSkills;
101
+ try {
102
+ const nativePassthrough = config.claudeCode?.nativePassthrough !== false
103
+ && !unmarked.includes("/")
104
+ && /^(claude|anthropic)(?:-|$)/i.test(unmarked)
105
+ && resolveInboundModel(unmarked, config.claudeCode) === unmarked;
106
+ return nativePassthrough ? [] : blockedSkills;
107
+ } catch (error) {
108
+ // A stale Desktop selector must not break the roster or acquire native exemptions.
109
+ if (error instanceof AnthropicRequestError) return blockedSkills;
110
+ throw error;
111
+ }
105
112
  };
106
113
  const defs: ClaudeAgentDef[] = [];
107
114
  const usedNames = new Set<string>();
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
5
+
6
+ export interface Desktop3pConfigLibraryOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ platform?: NodeJS.Platform;
9
+ homeDir?: string;
10
+ }
11
+
12
+ /**
13
+ * Resolve the config library from the same user-data root Claude Desktop uses. Keeping this in one
14
+ * helper prevents the writer and dashboard status probe from agreeing on a path Desktop never reads.
15
+ *
16
+ * The resolution itself lives in `./desktop-3p-paths`, which ports Claude Desktop's own `GE()`
17
+ * branch for branch — including the `-3p` suffix the app appends to its userData root. Dropping
18
+ * that suffix points us at a directory Desktop never reads (GitHub #539).
19
+ */
20
+ export function resolveDesktop3pConfigLibraryPath(
21
+ options: Desktop3pConfigLibraryOptions = {},
22
+ ): string {
23
+ if (options.env === undefined && options.platform === undefined && options.homeDir === undefined) {
24
+ return claudeDesktopConfigLibraryDir();
25
+ }
26
+ return resolveConfigLibraryDir({
27
+ env: options.env ?? process.env,
28
+ platform: options.platform ?? process.platform,
29
+ home: options.homeDir ?? homedir(),
30
+ });
31
+ }
32
+
33
+ export interface Desktop3pMetadataEntry {
34
+ id: string;
35
+ name: string;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface Desktop3pMetadata {
40
+ appliedId?: string;
41
+ entries: Desktop3pMetadataEntry[];
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export function parseMetadata(path: string): Desktop3pMetadata {
46
+ if (!existsSync(path)) return { entries: [] };
47
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Desktop3pMetadata>;
48
+ if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
49
+ return { ...parsed, entries: parsed.entries };
50
+ }
51
+
52
+ export const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
53
+
54
+ export function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ export function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
59
+ return entry?.name === "opencodex" || entry?.name === "opencodex-standard";
60
+ }
61
+
62
+ /** A gateway row is removable; the selected standard row must always remain. */
63
+ export function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
64
+ return entry?.name === "opencodex";
65
+ }
66
+
67
+ export function profilePath(libraryPath: string, id: string): string {
68
+ if (!SAFE_DESKTOP_PROFILE_ID.test(id)) throw new Error("desktop_profile_id_unsafe");
69
+ return join(libraryPath, `${id}.json`);
70
+ }
71
+
72
+ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([
73
+ "inferenceProvider",
74
+ "inferenceCredentialKind",
75
+ "inferenceGatewayBaseUrl",
76
+ "inferenceGatewayApiKey",
77
+ "modelDiscoveryEnabled",
78
+ "inferenceModels",
79
+ ]);
80
+
81
+ export function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
82
+ if (!existsSync(path)) return {};
83
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
84
+ if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object");
85
+ return Object.fromEntries(
86
+ Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)),
87
+ );
88
+ }
89
+