@bitkyc08/opencodex 2.7.39 → 2.7.40

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 (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -1,27 +1,32 @@
1
1
  import { create, fromJson, toBinary, type JsonValue } from "@bufbuild/protobuf";
2
2
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
3
3
  import type { OcxRequestOptions, OcxTool } from "../../types";
4
- import { namespacedToolName } from "../../types";
4
+ import { namespacedToolName, toolChoiceAliases } from "../../types";
5
5
  import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb";
6
6
 
7
7
  export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
8
8
  export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
9
+ export const CODEX_SHELL_COMMAND_TOOL = "shell_command";
9
10
  export const CODEX_APPLY_PATCH_TOOL = "apply_patch";
10
11
  export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL;
11
- export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the `exec_command` tool with JSON arguments like {"cmd":"..."}.';
12
- export const CURSOR_SHELL_ALIAS_USER_HINT = "Use exec_command for this shell command.";
12
+ export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const;
13
+ export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE =
14
+ 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell; never say native shell is blocked.';
15
+ export const CURSOR_SHELL_ALIAS_USER_HINT =
16
+ "Use the Codex shell bridge tool listed this turn (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` name). Prefer that bridge over Cursor-native Shell/Read. Never say phrases like \"Native shell access is blocked\" or \"Shell access is blocked\" — silently use the bridge unless that bridge tool itself fails.";
13
17
  const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
14
18
 
15
19
  export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [
16
- "For generic tool-use/count demos, satisfy the request with repeated `exec_command` calls for harmless commands.",
17
- "`exec_command` is the Codex Responses bridge exec tool exposed through Cursor's tool protocol; do not describe it as an external MCP server tool.",
20
+ "For generic tool-use/count demos, satisfy the request with repeated Codex shell bridge calls (`shell_command` or `exec_command`) for harmless commands.",
21
+ "`shell_command` / `exec_command` are the Codex Responses shell bridge exposed through Cursor's tool protocol; do not describe them as an external MCP server tool.",
18
22
  "Do not use `run_shell` unless this turn's tool catalog lists it.",
19
- "A request for N tools means N separate `exec_command` invocations/results; never satisfy it with one chained shell command such as `cmd1 && cmd2`.",
20
- "For independent read-only or output-only commands, emit all requested `exec_command` calls in the same response before waiting when the runtime supports parallel tool calls.",
23
+ "A request for N tools means N separate shell-bridge invocations/results; never satisfy it with one chained shell command such as `cmd1 && cmd2`.",
24
+ "For independent read-only or output-only commands, emit all requested shell-bridge calls in the same response before waiting when the runtime supports parallel tool calls.",
21
25
  "The Cursor bridge may suspend after the first returned bridge tool call, so emit sibling calls together before any result is needed.",
22
- "If parallel emission is unavailable, continue with separate `exec_command` calls until the requested count has returned.",
26
+ "If parallel emission is unavailable, continue with separate shell-bridge calls until the requested count has returned.",
23
27
  "Do not use `tool_search`, external MCP, or resource discovery just to pad the count unless explicitly asked.",
24
28
  "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names.",
29
+ "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Do not narrate Cursor-native Shell/Read routing.",
25
30
  ].join(" ");
26
31
 
27
32
  export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
@@ -38,8 +43,91 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = {
38
43
  additionalProperties: false,
39
44
  } as const;
40
45
 
46
+ /**
47
+ * Responses/Codex-side schema used ONLY for arg-key normalization after Cursor returns a call.
48
+ * Cursor models are trained to emit `cmd`; Codex `shell_command` / `exec_command` validate
49
+ * `command`. Keeping `cmd` out of this schema lets `normalizeArgKeys` rewrite `cmd` → `command`.
50
+ */
51
+ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = {
52
+ type: "object",
53
+ properties: {
54
+ command: { type: "string", description: "Shell command to execute." },
55
+ workdir: { type: "string", description: "Working directory for the command. Defaults to the turn cwd." },
56
+ shell: { type: "string", description: "Shell binary to launch. Defaults to the user's default shell." },
57
+ tty: { type: "boolean", description: "True allocates a PTY for the command; false or omitted uses plain pipes." },
58
+ yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." },
59
+ max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." },
60
+ max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." },
61
+ },
62
+ required: ["command"],
63
+ } as const;
64
+
65
+ export function isCodexShellBridgeToolName(name: string): boolean {
66
+ return (CODEX_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name);
67
+ }
68
+
69
+ /**
70
+ * Direct key lookup, then shell_command/exec_command sibling aliases when the key is a bridge name.
71
+ * Used for catalog admission, schema normalize maps, and Responses name maps (#399).
72
+ */
73
+ export function resolveShellBridgeAliasKey<T>(
74
+ key: string,
75
+ lookup: (name: string) => T | undefined,
76
+ ): T | undefined {
77
+ const direct = lookup(key);
78
+ if (direct !== undefined) return direct;
79
+ if (!isCodexShellBridgeToolName(key)) return undefined;
80
+ for (const alias of CODEX_SHELL_BRIDGE_TOOL_NAMES) {
81
+ if (alias === key) continue;
82
+ const hit = lookup(alias);
83
+ if (hit !== undefined) return hit;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ export function cursorToolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
89
+ const aliases = new Set(toolChoiceAliases(tool));
90
+ if (isBareCodexShellBridgeTool(tool)) {
91
+ for (const alias of CODEX_SHELL_BRIDGE_TOOL_NAMES) aliases.add(alias);
92
+ }
93
+ return [...aliases];
94
+ }
95
+
96
+ function catalogHasBareCodexShellBridge(
97
+ catalog: readonly Pick<OcxTool, "namespace" | "name">[],
98
+ ): boolean {
99
+ return catalog.some(isBareCodexShellBridgeTool);
100
+ }
101
+
102
+ /**
103
+ * Catalog-aware tool_choice matching for Cursor.
104
+ * When a bare Codex shell bridge is in the catalog, raw `shell_command` / `exec_command`
105
+ * choices select only that bridge (never a namespaced remote with the same raw name).
106
+ * When no bare bridge exists, raw bridge names may select a namespaced tool by raw name.
107
+ * Explicit wire names (`mcp__remote__exec_command`) always match the namespaced tool.
108
+ */
109
+ function cursorToolChoiceMatches(
110
+ tool: Pick<OcxTool, "namespace" | "name">,
111
+ choiceName: string,
112
+ catalog: readonly Pick<OcxTool, "namespace" | "name">[],
113
+ ): boolean {
114
+ if (isCodexShellBridgeToolName(choiceName)) {
115
+ if (catalogHasBareCodexShellBridge(catalog)) {
116
+ return isBareCodexShellBridgeTool(tool);
117
+ }
118
+ return tool.name === choiceName || cursorToolWireName(tool) === choiceName;
119
+ }
120
+ if (tool.name === choiceName || cursorToolWireName(tool) === choiceName) return true;
121
+ return cursorToolChoiceAliases(tool).includes(choiceName);
122
+ }
123
+
124
+ export function isBareCodexShellBridgeTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
125
+ return !tool.namespace && isCodexShellBridgeToolName(tool.name);
126
+ }
127
+
128
+ /** @deprecated Prefer isBareCodexShellBridgeTool; kept for older call sites/tests. */
41
129
  function isBareCodexExecCommandTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
42
- return !tool.namespace && tool.name === CODEX_EXEC_COMMAND_TOOL;
130
+ return isBareCodexShellBridgeTool(tool);
43
131
  }
44
132
 
45
133
  export function cursorRequestHasShellAlias(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined): boolean {
@@ -50,7 +138,8 @@ export function cursorRequestAdvertisesApplyPatch(
50
138
  tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
51
139
  toolChoice?: OcxRequestOptions["toolChoice"],
52
140
  ): boolean {
53
- return tools?.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && cursorToolAllowedByChoice(tool, toolChoice)) ?? false;
141
+ const catalog = tools ?? [];
142
+ return catalog.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && cursorToolAllowedByChoice(tool, toolChoice, catalog));
54
143
  }
55
144
 
56
145
  export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): string {
@@ -60,8 +149,9 @@ export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): s
60
149
  /**
61
150
  * Cursor's harness shows MCP tools to the model as `mcp_<providerIdentifier>_<toolName>`; models
62
151
  * sometimes call that display name verbatim instead of the advertised short name (live 20:41/21:00
63
- * sessions: `mcp_opencodex-responses_exec_command`). Fold the display prefix back to the advertised
64
- * wire name so both spellings resolve to the same client tool instead of "unknown Responses tool".
152
+ * sessions: `mcp_opencodex-responses_exec_command` / `mcp_opencodex-responses_shell_command`).
153
+ * Fold the display prefix back to the advertised wire name, and treat `shell_command` /
154
+ * `exec_command` as the same Codex shell bridge, so alias thrash does not become "tool not found".
65
155
  */
66
156
  const CURSOR_MCP_DISPLAY_PREFIX = `mcp_${OCX_RESPONSES_TOOL_PROVIDER}_`;
67
157
 
@@ -71,15 +161,60 @@ export function normalizeCursorWireName(name: string): string {
71
161
 
72
162
  export function responsesToolNameFromCursorWire(name: string, cursorToolNameMap?: ReadonlyMap<string, string>): string {
73
163
  const normalized = normalizeCursorWireName(name);
74
- return cursorToolNameMap?.get(normalized) ?? normalized;
164
+ if (!cursorToolNameMap) return normalized;
165
+ return resolveShellBridgeAliasKey(normalized, alias => cursorToolNameMap.get(alias)) ?? normalized;
75
166
  }
76
167
 
168
+ /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */
77
169
  export function cursorToolInputSchema(tool: OcxTool): unknown {
78
170
  return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {});
79
171
  }
80
172
 
173
+ /**
174
+ * Schema used to normalize completed Cursor tool args back to Responses/Codex field names.
175
+ * Must NOT reuse `cursorToolInputSchema` for the shell bridge: advertising `cmd` while also
176
+ * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399).
177
+ */
178
+ export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown {
179
+ if (isBareCodexShellBridgeTool(tool)) {
180
+ return shellBridgeArgNormalizeSchema(tool);
181
+ }
182
+ return tool.parameters ?? {};
183
+ }
184
+
185
+ function shellBridgeArgNormalizeSchema(tool: OcxTool): unknown {
186
+ const parameters = tool.parameters;
187
+ if (!parameters || typeof parameters !== "object") return CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA;
188
+ const base = parameters as Record<string, unknown>;
189
+ const rawProps = base.properties && typeof base.properties === "object"
190
+ ? { ...(base.properties as Record<string, unknown>) }
191
+ : {};
192
+ const required = Array.isArray(base.required) ? [...base.required as unknown[]] : [];
193
+ const requiresCommand = required.includes("command") || "command" in rawProps;
194
+ const requiresCmd = required.includes("cmd") || "cmd" in rawProps;
195
+ const shouldRewriteCmdToCommand = tool.name === CODEX_SHELL_COMMAND_TOOL || requiresCommand;
196
+
197
+ if (!shouldRewriteCmdToCommand && requiresCmd) {
198
+ return parameters;
199
+ }
200
+
201
+ // Drop Cursor-preferred aliases so normalizeArgKeys can rewrite them to Responses keys.
202
+ delete rawProps.cmd;
203
+ const properties = {
204
+ ...CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties,
205
+ ...rawProps,
206
+ command: rawProps.command ?? CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties.command,
207
+ };
208
+ return {
209
+ ...base,
210
+ type: "object",
211
+ properties,
212
+ required: requiresCommand ? required : ["command"],
213
+ };
214
+ }
215
+
81
216
  function activeTextMentionsExecCommand(text: string): boolean {
82
- return /\bexec_command\b/i.test(text);
217
+ return /\b(?:exec_command|shell_command)\b/i.test(text);
83
218
  }
84
219
 
85
220
  function looksLikeShellCommandRequest(text: string): boolean {
@@ -127,9 +262,9 @@ function cursorGenericToolUseHint(text: string): string {
127
262
  const count = requestedCursorToolUseCount(text);
128
263
  if (!count) return CURSOR_GENERIC_TOOL_USE_USER_HINT;
129
264
  return [
130
- `This turn requests ${count} tool uses: emit exactly ${count} separate \`exec_command\` function calls/results.`,
131
- `One \`exec_command\` containing chained commands counts as 1 tool call, not ${count}.`,
132
- `Prefer one parallel tool-call batch containing all ${count} independent \`exec_command\` calls before waiting for results.`,
265
+ `This turn requests ${count} tool uses: emit exactly ${count} separate Codex shell bridge function calls/results (\`shell_command\` or \`exec_command\`).`,
266
+ `One shell-bridge call containing chained commands counts as 1 tool call, not ${count}.`,
267
+ `Prefer one parallel tool-call batch containing all ${count} independent shell-bridge calls before waiting for results.`,
133
268
  CURSOR_GENERIC_TOOL_USE_USER_HINT,
134
269
  ].join(" ");
135
270
  }
@@ -176,7 +311,8 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
176
311
  ): readonly T[] | undefined {
177
312
  if (!shouldUseNativeExecOnlyForGenericToolUse(tools, activeText)) return tools;
178
313
  const execTools = tools?.filter(isBareCodexExecCommandTool);
179
- if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice))) return tools;
314
+ const catalog = tools ?? [];
315
+ if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog))) return tools;
180
316
  return execTools && execTools.length > 0 ? execTools : tools;
181
317
  }
182
318
 
@@ -199,13 +335,17 @@ export function appendCursorShellAliasHint(
199
335
  return `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${CURSOR_SHELL_ALIAS_USER_HINT}`;
200
336
  }
201
337
 
202
- export function cursorToolAllowedByChoice(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
338
+ export function cursorToolAllowedByChoice(
339
+ tool: Pick<OcxTool, "namespace" | "name">,
340
+ toolChoice: OcxRequestOptions["toolChoice"] | undefined,
341
+ catalog: readonly Pick<OcxTool, "namespace" | "name">[] = [tool],
342
+ ): boolean {
203
343
  if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
204
344
  if (toolChoice === "none") return false;
205
345
  if ("allowedTools" in toolChoice) {
206
- return toolChoice.allowedTools.includes(tool.name) || toolChoice.allowedTools.includes(cursorToolWireName(tool));
346
+ return toolChoice.allowedTools.some(choiceName => cursorToolChoiceMatches(tool, choiceName, catalog));
207
347
  }
208
- return tool.name === toolChoice.name || cursorToolWireName(tool) === toolChoice.name;
348
+ return cursorToolChoiceMatches(tool, toolChoice.name, catalog);
209
349
  }
210
350
 
211
351
  function quotedNames(names: readonly string[]): string {
@@ -232,13 +372,15 @@ export function buildCursorToolGuidanceSystemNote(
232
372
  if (!tools?.length) return undefined;
233
373
  const wireNames = [...new Set(
234
374
  tools
235
- .filter(tool => cursorToolAllowedByChoice(tool, toolChoice))
375
+ .filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools))
236
376
  .map(tool => cursorToolWireName(tool)),
237
377
  )];
238
378
  if (wireNames.length === 0) return undefined;
239
379
 
240
380
  const listedNames = quotedNames(wireNames);
241
- const hasBareExec = wireNames.includes(CODEX_EXEC_COMMAND_TOOL);
381
+ const shellBridgeNames = wireNames.filter(isCodexShellBridgeToolName);
382
+ const hasBareExec = shellBridgeNames.length > 0;
383
+ const shellBridgeLabel = quotedNames(shellBridgeNames.length > 0 ? shellBridgeNames : [...CODEX_SHELL_BRIDGE_TOOL_NAMES]);
242
384
  const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice);
243
385
  const discoveryTools = discoveryToolLabel(wireNames);
244
386
  const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames);
@@ -249,31 +391,34 @@ export function buildCursorToolGuidanceSystemNote(
249
391
  ? `This turn does not expose neighboring-agent tool names ${quotedNames(unavailableNeighborNames)}; do not call or suggest them unless the catalog lists them.`
250
392
  : undefined,
251
393
  hasBareExec
252
- ? "`exec_command` is the Codex Responses bridge exec tool for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool."
394
+ ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.`
395
+ : undefined,
396
+ hasBareExec
397
+ ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user."
253
398
  : undefined,
254
399
  hasBareExec
255
- ? "Your tool list may display it under the longer name `mcp_opencodex-responses_exec_command`; both names are the SAME tool call whichever your list shows, and do not comment on the naming difference to the user."
400
+ ? "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Prefer the bridge over Cursor-native Shell/Read; do not narrate phrases like \"Native shell access is blocked\" — silently call `shell_command` / `exec_command`."
256
401
  : undefined,
257
402
  "Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.",
258
403
  hasBareExec
259
- ? "For file read/search/listing, use `exec_command` when no more specific listed tool is available."
404
+ ? `For file read/search/listing, use ${shellBridgeLabel} when no more specific listed tool is available.`
260
405
  : undefined,
261
406
  hasApplyPatch
262
407
  ? "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
263
408
  : undefined,
264
409
  hasBareExec
265
- ? "For tool-count demos, each counted tool must be a separate `exec_command` invocation/result; do not collapse several requested tools into one chained shell command."
410
+ ? "For tool-count demos, each counted tool must be a separate Codex shell-bridge invocation/result; do not collapse several requested tools into one chained shell command."
266
411
  : undefined,
267
412
  "For independent read-only tool-count or batch requests, prefer one response containing multiple tool calls before waiting for results when the runtime supports parallel tool calls.",
268
413
  hasBareExec
269
- ? "For bridge tool-count batches, emit sibling `exec_command` calls together before any result is needed because the bridge may suspend after a returned tool call."
414
+ ? "For bridge tool-count batches, emit sibling shell-bridge calls together before any result is needed because the bridge may suspend after a returned tool call."
270
415
  : undefined,
271
416
  discoveryTools
272
417
  ? `Use ${discoveryTools} only for explicit discovery/resource tasks, not generic tool-count demos.`
273
418
  : undefined,
274
419
  "Do not count or report a tool call unless a tool result was actually returned.",
275
420
  hasBareExec
276
- ? "If a built-in file read, directory listing, grep, or shell operation is rejected by the runtime, use \`exec_command\` with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). For file edits, use \`apply_patch\` when available."
421
+ ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). Do not tell the user access is blocked. For file edits, use \`apply_patch\` when available.`
277
422
  : undefined,
278
423
  ].filter((note): note is string => typeof note === "string");
279
424
  return notes.join(" ");
@@ -291,7 +436,7 @@ export function buildCursorToolDefinitions(
291
436
  toolChoice?: OcxRequestOptions["toolChoice"],
292
437
  ): McpToolDefinition[] {
293
438
  if (!tools?.length) return [];
294
- return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice)).map(tool => {
439
+ return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)).map(tool => {
295
440
  const wireName = cursorToolWireName(tool);
296
441
  return create(McpToolDefinitionSchema, {
297
442
  name: wireName,
@@ -61,6 +61,13 @@ function extractErrorDetail(parsed: unknown): string | undefined {
61
61
  return undefined;
62
62
  }
63
63
 
64
+ function developerSystemText(message: OcxMessage): string | undefined {
65
+ if (message.role !== "developer") return undefined;
66
+ if (typeof message.content === "string") return message.content;
67
+ if (message.content.some(part => part.type === "image")) return undefined;
68
+ return message.content.map(part => (part as OcxTextContent).text).join("");
69
+ }
70
+
64
71
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
65
72
  const out: unknown[] = [];
66
73
  const { context, options } = parsed;
@@ -112,7 +119,20 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
112
119
  const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
113
120
  ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
114
121
  : undefined;
115
- const systemParts = [...(context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])];
122
+ // Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
123
+ // backends require every system instruction to precede conversation history. Codex can
124
+ // append developer reminders after user turns, so fold text-only developer messages into
125
+ // the single leading system message instead of emitting role:"system" in place. Developer
126
+ // messages with images cannot be represented as system content and remain user-compatible
127
+ // vision messages at their original position below.
128
+ const developerSystemParts = context.messages
129
+ .map(developerSystemText)
130
+ .filter((part): part is string => part !== undefined && part.length > 0);
131
+ const systemParts = [
132
+ ...(context.systemPrompt ?? []),
133
+ ...developerSystemParts,
134
+ ...(toolCatalogNudge ? [toolCatalogNudge] : []),
135
+ ];
116
136
  if (systemParts.length > 0) {
117
137
  // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
118
138
  // base_instructions is ignored at request time). Neutralize that one identity line
@@ -126,18 +146,19 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
126
146
  switch (msg.role) {
127
147
  case "user":
128
148
  case "developer": {
129
- const role = msg.role === "developer" ? "system" : "user";
149
+ const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
150
+ const hasImages = parts?.some(p => p.type === "image") ?? false;
151
+ if (msg.role === "developer" && !hasImages) break;
130
152
  let chatMsg: Record<string, unknown>;
131
153
  if (typeof msg.content === "string") {
132
- chatMsg = { role, content: msg.content };
154
+ chatMsg = { role: "user", content: msg.content };
133
155
  } else {
134
- const parts = msg.content as OcxContentPart[];
135
- if (!parts.some(p => p.type === "image")) {
136
- chatMsg = { role, content: parts.map(p => (p as OcxTextContent).text).join("") };
156
+ if (!hasImages) {
157
+ chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
137
158
  } else {
138
159
  // Vision: chat-completions content-parts array. Images are only valid on the user role,
139
160
  // and the data URL goes straight into image_url.url (never the token-exploding text path).
140
- const chatParts = parts.map(p => p.type === "image"
161
+ const chatParts = parts!.map(p => p.type === "image"
141
162
  ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
142
163
  : { type: "text", text: (p as OcxTextContent).text });
143
164
  chatMsg = { role: "user", content: chatParts };
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
- import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
4
4
  import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
5
- import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
5
+ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
6
+ import { decodeServerSentEvents } from "../lib/sse-decoder";
7
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
6
8
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
7
9
  import { modelRecordValue } from "../reasoning-effort";
8
10
 
@@ -518,6 +520,81 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
518
520
  return tools.length === body.tools.length ? body : { ...body, tools };
519
521
  }
520
522
 
523
+ /** Replace every `input_image` part under a routed-compaction body with a short marker. */
524
+ function stripInputImagesDeep(value: unknown): unknown {
525
+ if (Array.isArray(value)) return value.map(stripInputImagesDeep);
526
+ if (!isPlainObject(value)) return value;
527
+ if (value.type === "input_image") {
528
+ return { type: "input_text", text: "[image omitted for compaction]" };
529
+ }
530
+ const out: Record<string, unknown> = {};
531
+ for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry);
532
+ return out;
533
+ }
534
+
535
+ /**
536
+ * Rewrite a compaction turn for an upstream that does not speak Codex's private
537
+ * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask
538
+ * for the handoff summary in plain terms instead (#422).
539
+ *
540
+ * The adapter builds from `parsed._rawBody`, so the summarizer prompt that
541
+ * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to
542
+ * be applied here. Images go too: a summary needs no pixels, and a text-only
543
+ * gateway would reject them.
544
+ */
545
+ function buildRoutedCompactionBody(body: unknown): unknown {
546
+ if (!isPlainObject(body)) return body;
547
+ const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
548
+ const input = Array.isArray(body.input) ? body.input : [];
549
+ const kept = input.filter(item => !isPlainObject(item)
550
+ // `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
551
+ // leaving it in would break the no-tools invariant even with `tools` removed.
552
+ || (item.type !== "compaction_trigger" && item.type !== "additional_tools"));
553
+ return {
554
+ ...rest,
555
+ input: [
556
+ ...(stripInputImagesDeep(kept) as unknown[]),
557
+ { type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] },
558
+ ],
559
+ };
560
+ }
561
+
562
+ /** Read the Responses `usage` block, if the gateway sent one. */
563
+ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
564
+ if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
565
+ const usage = payload.usage;
566
+ const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
567
+ const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
568
+ if (inputTokens === 0 && outputTokens === 0) return undefined;
569
+ return {
570
+ inputTokens,
571
+ outputTokens,
572
+ ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
573
+ };
574
+ }
575
+
576
+ function responsesPayloadText(response: unknown): string {
577
+ if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
578
+ return response.output
579
+ .filter(item => isPlainObject(item) && item.type === "message")
580
+ .flatMap(item => (Array.isArray((item as Record<string, unknown>).content)
581
+ ? (item as { content: unknown[] }).content
582
+ : []))
583
+ .filter(part => isPlainObject(part) && part.type === "output_text")
584
+ .map(part => String((part as { text?: unknown }).text ?? ""))
585
+ .join("");
586
+ }
587
+
588
+ function responsesErrorMessage(payload: unknown): string {
589
+ if (!isPlainObject(payload)) return "upstream compaction failed";
590
+ const err = payload.error;
591
+ if (typeof err === "string") return err;
592
+ if (isPlainObject(err) && typeof err.message === "string") return err.message;
593
+ const incomplete = payload.incomplete_details;
594
+ if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason;
595
+ return "upstream compaction failed";
596
+ }
597
+
521
598
  export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
522
599
  return {
523
600
  name: "openai-responses",
@@ -573,6 +650,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
573
650
  outBody = repairOversizedReplayCallIds(outBody);
574
651
  }
575
652
  outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
653
+ // Same predicate as the routedCompaction gate in handleResponses(): an
654
+ // authMode check would let a noncanonical custom forward provider skip this
655
+ // rewrite while the server still routes it as a summarizer turn (#422).
656
+ if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
657
+ outBody = buildRoutedCompactionBody(outBody);
658
+ }
576
659
  return {
577
660
  url,
578
661
  method: "POST",
@@ -585,8 +668,71 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
585
668
  };
586
669
  },
587
670
 
588
- async *parseStream(): AsyncGenerator<AdapterEvent> {
589
- yield { type: "error", message: "passthrough adapter should not parse stream" };
671
+ // The passthrough normally relays the upstream stream verbatim and never parses.
672
+ // The exception is a routed compaction turn: the server drives this adapter like
673
+ // an ordinary one so the bridge can build the single compaction item (#422).
674
+ async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
675
+ if (!response.body) {
676
+ yield { type: "error", message: "passthrough adapter received no response body" };
677
+ return;
678
+ }
679
+ let deltas = "";
680
+ let doneText = "";
681
+ let snapshot = "";
682
+ let usage: OcxUsage | undefined;
683
+ for await (const event of decodeServerSentEvents(response.body)) {
684
+ let payload: unknown;
685
+ try { payload = JSON.parse(event.data); } catch { continue; }
686
+ if (!isPlainObject(payload)) continue;
687
+ switch (payload.type) {
688
+ case "response.output_text.delta":
689
+ if (typeof payload.delta === "string") deltas += payload.delta;
690
+ break;
691
+ case "response.output_text.done":
692
+ if (typeof payload.text === "string") doneText += payload.text;
693
+ break;
694
+ case "response.failed":
695
+ case "error":
696
+ yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) };
697
+ return;
698
+ case "response.incomplete":
699
+ yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
700
+ return;
701
+ case "response.completed":
702
+ snapshot = responsesPayloadText(payload.response);
703
+ usage = usageFromResponsesPayload(payload.response);
704
+ break;
705
+ }
706
+ }
707
+ // Gateways differ in which of these they emit; prefer the authoritative
708
+ // completed snapshot so text is never double-counted.
709
+ const text = snapshot || doneText || deltas;
710
+ if (text) yield { type: "text_delta", text };
711
+ yield { type: "done", ...(usage ? { usage } : {}) };
712
+ },
713
+
714
+ async parseResponse(response: Response): Promise<AdapterEvent[]> {
715
+ let payload: unknown;
716
+ try { payload = await response.json(); } catch {
717
+ return [{ type: "error", message: "malformed upstream compaction response" }];
718
+ }
719
+ if (!isPlainObject(payload)) {
720
+ return [{ type: "error", message: "malformed upstream compaction response" }];
721
+ }
722
+ if (payload.error || payload.status === "failed") {
723
+ return [{ type: "error", message: responsesErrorMessage(payload) }];
724
+ }
725
+ if (payload.status === "incomplete") {
726
+ return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
727
+ }
728
+ const text = responsesPayloadText(payload);
729
+ if (!text) {
730
+ // A completed turn with no usable text cannot become a summary; saying so is
731
+ // better than installing an empty compaction as replacement history.
732
+ return [{ type: "error", message: "upstream compaction returned no summary text" }];
733
+ }
734
+ const usage = usageFromResponsesPayload(payload);
735
+ return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
590
736
  },
591
737
  };
592
738
  }
package/src/bridge.ts CHANGED
@@ -456,6 +456,17 @@ export function bridgeToResponsesSSE(
456
456
  if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
457
457
  }
458
458
  switch (event.type) {
459
+ case "assistant_boundary": {
460
+ // A guarded continuation starts a fresh assistant output item while keeping the
461
+ // intermediate, suspicious text in the same Responses turn.
462
+ if (currentMsg) closeCurrentMessage();
463
+ if (currentReasoning) closeCurrentReasoning();
464
+ if (currentRawReasoning) closeCurrentRawReasoning();
465
+ flushHiddenRawReasoning();
466
+ if (currentToolCall) closeCurrentToolCall();
467
+ flushHiddenReasoningEnvelope();
468
+ break;
469
+ }
459
470
  case "text_delta": {
460
471
  if (currentReasoning) closeCurrentReasoning();
461
472
  if (currentRawReasoning) closeCurrentRawReasoning();
@@ -971,6 +982,12 @@ export function buildResponseJSON(
971
982
 
972
983
  for (const e of events) {
973
984
  switch (e.type) {
985
+ case "assistant_boundary":
986
+ flushText();
987
+ flushSummaryReasoning();
988
+ flushRawReasoning();
989
+ flushToolCall();
990
+ break;
974
991
  case "text_delta":
975
992
  if (currentText && currentTextPhase !== e.phase) flushText();
976
993
  if (currentSummaryReasoning) flushSummaryReasoning();
@@ -1062,7 +1079,9 @@ export function buildResponseJSON(
1062
1079
  flushSummaryReasoning();
1063
1080
  flushRawReasoning();
1064
1081
  flushToolCall();
1065
- if (options?.compaction && !errorEvent) {
1082
+ // A truncated turn must never be installed as replacement history: emit the
1083
+ // compaction item only when the turn actually completed (#422).
1084
+ if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
1066
1085
  output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
1067
1086
  }
1068
1087