@yansigit/opencodex 2.33.0 → 2.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/gui/dist/assets/index-CIDo4y4k.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -1
  4. package/src/adapters/command-code.ts +101 -20
  5. package/src/adapters/cursor/envelope-echo.ts +162 -0
  6. package/src/adapters/cursor/live-transport.ts +3 -1
  7. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  8. package/src/adapters/cursor/native-exec-network.ts +3 -5
  9. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  10. package/src/adapters/cursor/native-exec-shell.ts +13 -25
  11. package/src/adapters/cursor/native-exec.ts +18 -10
  12. package/src/adapters/cursor/protobuf-events.ts +28 -2
  13. package/src/adapters/cursor/protobuf-request.ts +20 -3
  14. package/src/adapters/cursor/request-builder.ts +7 -0
  15. package/src/adapters/cursor/tool-definitions.ts +22 -1
  16. package/src/adapters/cursor/tool-result-normalize.ts +21 -8
  17. package/src/adapters/cursor/types.ts +7 -0
  18. package/src/adapters/cursor.ts +114 -0
  19. package/src/adapters/google-aistudio-parser.ts +49 -0
  20. package/src/adapters/google.ts +108 -16
  21. package/src/adapters/openai-responses.ts +1 -0
  22. package/src/chat/inbound.ts +15 -0
  23. package/src/cli/index.ts +1 -1
  24. package/src/codex/catalog/provider-fetch.ts +34 -0
  25. package/src/generated/compatibility-version.json +91 -47
  26. package/src/generated/model-metadata.ts +3 -0
  27. package/src/oauth/aistudio-native-daemon.ts +62 -0
  28. package/src/oauth/aistudio-session-sync.ts +95 -0
  29. package/src/oauth/google-aistudio-auth.ts +98 -0
  30. package/src/oauth/key-providers.ts +8 -0
  31. package/src/oauth/login-cli.ts +66 -1
  32. package/src/providers/derive.ts +1 -1
  33. package/src/providers/quota.ts +90 -38
  34. package/src/providers/registry.ts +24 -3
  35. package/src/router.ts +3 -0
  36. package/src/routing/account-pool/cooldown.ts +8 -0
  37. package/src/routing/account-pool/index.ts +1 -0
  38. package/src/server/aistudio-ws-hub.ts +295 -0
  39. package/src/server/auth-cors.ts +1 -0
  40. package/src/server/chat-completions.ts +2 -0
  41. package/src/server/index.ts +94 -0
  42. package/src/server/management/logs-usage-routes.ts +11 -5
  43. package/src/server/management/oauth-account-routes.ts +13 -3
  44. package/src/server/port-reclaim.ts +19 -1
  45. package/src/server/request-log-conversation.ts +12 -0
  46. package/src/server/request-log.ts +2 -1
  47. package/src/server/responses/core.ts +4 -3
  48. package/src/server/responses/policy-fallback.ts +1 -1
  49. package/src/server/ws-bridge.ts +2 -1
  50. package/src/smoke/fingerprint-cache.ts +133 -0
  51. package/src/smoke/live-scenarios.ts +33 -0
  52. package/src/smoke/runner.ts +119 -0
  53. package/src/types/provider.ts +2 -1
  54. package/src/types/request.ts +2 -0
  55. package/src/types/tools.ts +31 -7
  56. package/src/usage/command-code-manifest.ts +116 -0
  57. package/src/usage/cost.ts +2 -2
  58. package/src/usage/expected-prices.ts +83 -0
  59. package/src/usage/log.ts +2 -2
  60. package/src/usage/summary.ts +34 -12
  61. package/src/web-search/index.ts +16 -8
  62. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DKLr4LTE.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-CIDo4y4k.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DrSQdTRd.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yansigit/opencodex",
3
- "version": "2.33.0",
3
+ "version": "2.33.1",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -39,10 +39,12 @@
39
39
  "dev:gui": "cd gui && bun run dev",
40
40
  "start": "bun run src/cli/index.ts start",
41
41
  "test": "bun scripts/test.ts",
42
+ "test:smoke": "bun scripts/live-smoke.ts",
42
43
  "typecheck": "bun x tsc --noEmit",
43
44
  "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high",
44
45
  "privacy:scan": "bun scripts/privacy-scan.ts",
45
46
  "generate:model-metadata": "bun scripts/generate-model-metadata.ts",
47
+ "sync:cursor-pricing": "bun scripts/sync-cursor-pricing.ts",
46
48
  "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package",
47
49
  "prepare:package": "bun scripts/prepare-package.ts",
48
50
  "prepack": "bun run prepare:package",
@@ -33,9 +33,9 @@ export function formatCommandCodeErrorBody(_status: number, _headers: Headers, p
33
33
  const payload = JSON.parse(payloadText) as unknown;
34
34
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "";
35
35
  const error = (payload as Record<string, unknown>).error;
36
- const message = error && typeof error === "object" && !Array.isArray(error)
36
+ const message = (error && typeof error === "object" && !Array.isArray(error)
37
37
  ? (error as Record<string, unknown>).message
38
- : undefined;
38
+ : undefined) ?? (payload as Record<string, unknown>).message;
39
39
  return typeof message === "string" && message.trim()
40
40
  ? redactSecretString(message.trim()).slice(0, 400)
41
41
  : "";
@@ -174,13 +174,17 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
174
174
  if (choice === "none") return [];
175
175
  const tools = parsed.context.tools ?? [];
176
176
  if (isAllowedToolChoice(choice)) {
177
- return tools.filter(toolChoiceToolPredicate(choice, tools));
177
+ return tools
178
+ .filter(toolChoiceToolPredicate(choice, tools))
179
+ .sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name)));
178
180
  }
179
181
  if (choice && typeof choice !== "string") {
180
182
  const selected = resolveToolChoiceWireName(tools, choice.name);
181
- return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected);
183
+ return tools
184
+ .filter(tool => namespacedToolName(tool.namespace, tool.name) === selected)
185
+ .sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name)));
182
186
  }
183
- return tools;
187
+ return [...tools].sort((left, right) => namespacedToolName(left.namespace, left.name).localeCompare(namespacedToolName(right.namespace, right.name)));
184
188
  }
185
189
 
186
190
  function toolChoiceInstruction(parsed: OcxParsedRequest): string | undefined {
@@ -211,6 +215,9 @@ function currentWorkingDirectory(): string | undefined {
211
215
 
212
216
  /** Cap the workspace listing so a large directory does not ship every entry name upstream. */
213
217
  const MAX_WORKSPACE_STRUCTURE_ENTRIES = 64;
218
+ /** Cap directory entries scanned while selecting the stable workspace prefix. */
219
+ export const MAX_WORKSPACE_STRUCTURE_SCAN_ENTRIES = 4096;
220
+ // ponytail: the bounded scan trades complete directory coverage for request latency; raise only with measured need.
214
221
  /** Cap how many recent commit subjects the config carries. */
215
222
  const MAX_RECENT_COMMITS = 8;
216
223
  /** Cap each recent commit entry to keep the request bounded even for long subjects. */
@@ -227,22 +234,44 @@ function projectSlug(cwd: string): string {
227
234
  return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace";
228
235
  }
229
236
 
237
+ function firstUserText(parsed: OcxParsedRequest): string | undefined {
238
+ for (const msg of parsed.context.messages) {
239
+ if (msg.role !== "user") continue;
240
+ if (typeof msg.content === "string") return msg.content;
241
+ const first = msg.content.find(part => part.type === "text" && typeof part.text === "string");
242
+ if (first && first.type === "text") return first.text;
243
+ }
244
+ return undefined;
245
+ }
246
+
230
247
  export function commandCodeSessionId(parsed: OcxParsedRequest): string {
248
+ if (parsed._commandCodeSessionId) return parsed._commandCodeSessionId;
231
249
  // Shared prompt-cache cohorts intentionally do not identify one conversation. Keep them out
232
250
  // of upstream session affinity or unrelated conversations can pin to the same worker.
233
251
  const threadId = parsed._clientThreadId?.trim();
234
252
  const replayId = parsed._reasoningReplayScope?.clientThreadId?.trim();
253
+ const cursorId = parsed._cursorConversationId?.trim();
235
254
  const cacheKey = !parsed._promptCacheKeyIsSharedCohort ? parsed.options.promptCacheKey?.trim() : undefined;
255
+ const rootText = firstUserText(parsed);
236
256
  const identity = threadId
237
257
  ? ["thread", threadId]
238
258
  : replayId
239
259
  ? ["replay", replayId]
240
- : cacheKey
241
- ? ["cache", cacheKey]
242
- : undefined;
243
- if (!identity) return randomUUID();
244
- const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex");
245
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
260
+ : cursorId
261
+ ? ["cursor", cursorId]
262
+ : cacheKey
263
+ ? ["cache", cacheKey]
264
+ : rootText
265
+ ? ["root", rootText]
266
+ : undefined;
267
+ const sessionId = !identity
268
+ ? randomUUID()
269
+ : (() => {
270
+ const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex");
271
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
272
+ })();
273
+ parsed._commandCodeSessionId = sessionId;
274
+ return sessionId;
246
275
  }
247
276
 
248
277
  interface GitWorkspaceInfo {
@@ -254,6 +283,8 @@ interface GitWorkspaceInfo {
254
283
  }
255
284
 
256
285
  export const workspaceMetadataCache = new Map<string, { collectedAt: number; value: GitWorkspaceInfo }>();
286
+ export const workspaceConfigCache = new Map<string, { collectedAt: number; value: Record<string, unknown>; sessionId?: string }>();
287
+ export const SESSION_WORKSPACE_CONFIG_TTL_MS = 60 * 60_000;
257
288
 
258
289
  /**
259
290
  * Evict expired entries first, then the oldest live entry if at capacity.
@@ -280,6 +311,34 @@ export function pruneWorkspaceMetadataCache(now: number): void {
280
311
  }
281
312
  }
282
313
 
314
+ export function pruneWorkspaceConfigCache(now: number): void {
315
+ for (const [key, entry] of workspaceConfigCache) {
316
+ if (!entry.sessionId && now - entry.collectedAt >= WORKSPACE_METADATA_TTL_MS) {
317
+ workspaceConfigCache.delete(key);
318
+ }
319
+ }
320
+ if (workspaceConfigCache.size >= MAX_WORKSPACE_METADATA_ENTRIES) {
321
+ let oldestKey: string | null = null;
322
+ let oldestAt = Infinity;
323
+ for (const [key, entry] of workspaceConfigCache) {
324
+ if (entry.sessionId) continue;
325
+ if (entry.collectedAt < oldestAt) {
326
+ oldestAt = entry.collectedAt;
327
+ oldestKey = key;
328
+ }
329
+ }
330
+ if (oldestKey === null) {
331
+ for (const [key, entry] of workspaceConfigCache) {
332
+ if (entry.collectedAt < oldestAt) {
333
+ oldestAt = entry.collectedAt;
334
+ oldestKey = key;
335
+ }
336
+ }
337
+ }
338
+ if (oldestKey !== null) workspaceConfigCache.delete(oldestKey);
339
+ }
340
+ }
341
+
283
342
  const execFile = promisify(execFileCallback);
284
343
 
285
344
  /** Best-effort git metadata for the upstream config contract; every read fails safe and stays off the event loop. */
@@ -316,32 +375,53 @@ async function gitWorkspaceInfo(cwd: string | undefined): Promise<GitWorkspaceIn
316
375
  return value;
317
376
  }
318
377
 
319
- async function commandCodeConfig(cwd: string | undefined): Promise<Record<string, unknown>> {
378
+ export async function commandCodeConfig(cwd: string | undefined, sessionId?: string): Promise<Record<string, unknown>> {
379
+ const cacheKey = sessionId ? `${sessionId}:${cwd ?? ""}` : (cwd ?? "");
380
+ const now = Date.now();
381
+ const cached = cacheKey ? workspaceConfigCache.get(cacheKey) : undefined;
382
+ if (cacheKey) {
383
+ if (cached && (sessionId || now - cached.collectedAt < WORKSPACE_METADATA_TTL_MS)) return cached.value;
384
+ }
320
385
  let structure: string[] = [];
321
386
  if (cwd) {
322
387
  try {
323
- // Iterate and stop after the cap instead of materializing every entry: a directory with a
324
- // huge number of names must not stall the request path for 64 metadata rows.
388
+ // Keep only the lexicographically smallest entries within the bounded scan so filesystem
389
+ // enumeration order cannot change the selected prefix for the scanned portion.
325
390
  const dir = await opendir(cwd);
326
391
  try {
327
- for await (const entry of dir) {
392
+ const entries = dir[Symbol.asyncIterator]();
393
+ for (let scanned = 0; scanned < MAX_WORKSPACE_STRUCTURE_SCAN_ENTRIES; scanned += 1) {
394
+ const next = await entries.next();
395
+ if (next.done) break;
396
+ const entry = next.value;
328
397
  if (entry.name.startsWith(".")) continue;
329
398
  structure.push(entry.name);
330
- if (structure.length >= MAX_WORKSPACE_STRUCTURE_ENTRIES) break;
399
+ if (structure.length > MAX_WORKSPACE_STRUCTURE_ENTRIES) {
400
+ structure.sort();
401
+ structure.pop();
402
+ }
331
403
  }
332
404
  } finally {
333
405
  await dir.close().catch(() => undefined);
334
406
  }
335
407
  } catch { /* workspace metadata is optional */ }
336
408
  }
409
+ structure.sort();
337
410
  const git = await gitWorkspaceInfo(cwd);
338
- return {
411
+ const value = {
339
412
  ...(cwd ? { workingDir: cwd } : {}),
340
- date: new Date().toISOString().slice(0, 10),
413
+ date: sessionId && typeof cached?.value.date === "string"
414
+ ? cached.value.date
415
+ : new Date(now).toISOString().slice(0, 10),
341
416
  environment: process.platform,
342
417
  structure,
343
418
  ...git,
344
419
  };
420
+ if (cacheKey) {
421
+ if (!workspaceConfigCache.has(cacheKey)) pruneWorkspaceConfigCache(now);
422
+ workspaceConfigCache.set(cacheKey, { collectedAt: now, value, ...(sessionId ? { sessionId } : {}) });
423
+ }
424
+ return value;
345
425
  }
346
426
 
347
427
  function usage(value: unknown): OcxUsage | undefined {
@@ -490,6 +570,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
490
570
  async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
491
571
  if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code");
492
572
  const cwd = currentWorkingDirectory();
573
+ const sessionId = commandCodeSessionId(parsed);
493
574
  const tools = visibleTools(parsed);
494
575
  const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice);
495
576
  const choiceInstruction = toolChoiceInstruction(parsed);
@@ -500,7 +581,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
500
581
  ].join("\n\n"), parsed.modelId);
501
582
  const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning);
502
583
  const body = {
503
- config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null,
584
+ config: await commandCodeConfig(cwd, sessionId), memory: "", taste: null, skills: null,
504
585
  permissionMode: "standard", mode: "agent",
505
586
  params: {
506
587
  model: canonicalCommandCodeModelId(parsed.modelId),
@@ -523,7 +604,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
523
604
  "x-cli-environment": "production",
524
605
  "x-taste-learning": "false",
525
606
  "x-co-flag": "false",
526
- "x-session-id": commandCodeSessionId(parsed),
607
+ "x-session-id": sessionId,
527
608
  };
528
609
  if (cwd) headers["x-project-slug"] = projectSlug(cwd);
529
610
  return {
@@ -0,0 +1,162 @@
1
+ /**
2
+ * External Cursor output quarantine (devlog 260826 gaps 10-11).
3
+ *
4
+ * Gap 10: flattened tool-result history can prime an external model to echo the
5
+ * "[Tool Result]" envelope as its own reply.
6
+ *
7
+ * Gap 11: a model can invent a blocked native-tool attempt in visible commentary
8
+ * even though the only real current-turn tool is the advertised Codex bridge.
9
+ *
10
+ * Both failures are observable only at the output boundary. The sniffers below
11
+ * hold a bounded prefix until it either diverges or proves the failure, allowing
12
+ * cursor.ts to retry before any invalid text reaches the client.
13
+ */
14
+
15
+ const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const;
16
+ const MAX_SNIFF_BYTES = 40;
17
+ const MAX_ROUTING_COMMENTARY_BYTES = 512;
18
+ /** Aggregate quarantine cap: past this, flush and disarm. */
19
+ const MAX_HOLD_BYTES = 8 * 1024;
20
+ const encoder = new TextEncoder();
21
+
22
+ export class CursorToolResultEchoError extends Error {
23
+ readonly code = "cursor_tool_result_echo";
24
+ constructor(marker: string) {
25
+ super(
26
+ "Cursor external model echoed the replayed tool-result envelope (\"" + marker
27
+ + "\") instead of continuing the task.",
28
+ );
29
+ this.name = "CursorToolResultEchoError";
30
+ }
31
+ }
32
+
33
+ export class CursorRoutingCommentaryError extends Error {
34
+ readonly code = "cursor_routing_commentary_hallucination";
35
+ constructor() {
36
+ super("Cursor external model invented a blocked tool surface before any tool call occurred.");
37
+ this.name = "CursorRoutingCommentaryError";
38
+ }
39
+ }
40
+
41
+ export type EchoSnifferDecision =
42
+ | { kind: "hold" }
43
+ | { kind: "flush" }
44
+ | { kind: "echo"; marker: string };
45
+
46
+ /**
47
+ * Incremental envelope-prefix sniffer. Leading whitespace is tolerated so a
48
+ * marker copied after a newline is still caught.
49
+ */
50
+ export class CursorEnvelopeEchoSniffer {
51
+ private buffered = "";
52
+ private byteCount = 0;
53
+ private done = false;
54
+
55
+ get settled(): boolean {
56
+ return this.done;
57
+ }
58
+
59
+ feed(textDelta: string): EchoSnifferDecision {
60
+ if (this.done) return { kind: "flush" };
61
+ this.buffered += textDelta;
62
+ this.byteCount += encoder.encode(textDelta).byteLength;
63
+ const probe = this.buffered.replace(/^\s+/, "");
64
+ for (const marker of ECHO_MARKERS) {
65
+ if (probe.startsWith(marker)) {
66
+ this.done = true;
67
+ return { kind: "echo", marker };
68
+ }
69
+ }
70
+ const stillPrefix = ECHO_MARKERS.some(marker =>
71
+ probe.length < marker.length && marker.startsWith(probe),
72
+ );
73
+ if (stillPrefix && this.byteCount <= MAX_SNIFF_BYTES && this.buffered.length < MAX_HOLD_BYTES) {
74
+ return { kind: "hold" };
75
+ }
76
+ this.done = true;
77
+ return { kind: "flush" };
78
+ }
79
+
80
+ finish(): EchoSnifferDecision {
81
+ if (this.done) return { kind: "flush" };
82
+ this.done = true;
83
+ return { kind: "flush" };
84
+ }
85
+ }
86
+
87
+ export type RoutingCommentaryDecision =
88
+ | { kind: "hold" }
89
+ | { kind: "flush" }
90
+ | { kind: "hallucination" };
91
+
92
+ const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash|filesystem)\b/giu;
93
+ const ROUTING_TOOL_HINT =
94
+ /(?:\b(?:shell|read|grep|list|bash|filesystem)\b|exec_command|shell_command|브리지|네이티브\s*(?:셸|쉘))/iu;
95
+ const ROUTING_FAILURE_OR_BRIDGE_CLAIM =
96
+ /(?:blocked|unavailable|interrupted|bridged|bridge|차단|중단|막혀)/iu;
97
+ const ROUTING_REDIRECT_CLAIM =
98
+ /(?:exec_command|shell_command|\bexec\b|브리지|bridged|bridge|redirected|fallback|switch(?:ed|ing)?|host shell|전환|우회|통과(?:되|하)|다른\s*(?:도구|경로)|경로로)/iu;
99
+
100
+ /**
101
+ * Quarantines the first line of code-mode / bridge output long enough to reject
102
+ * an impossible routing claim. It requires a failure or bridge claim plus either an
103
+ * explicit redirect to another execution surface or two distinct unadvertised
104
+ * native-tool names; a legitimate sentence such as "Shell is unavailable on
105
+ * this OS" therefore passes.
106
+ */
107
+ export class CursorRoutingCommentarySniffer {
108
+ private buffered = "";
109
+ private byteCount = 0;
110
+ private done = false;
111
+
112
+ get settled(): boolean {
113
+ return this.done;
114
+ }
115
+
116
+ feed(textDelta: string): RoutingCommentaryDecision {
117
+ if (this.done) return { kind: "flush" };
118
+ this.buffered += textDelta;
119
+ this.byteCount += encoder.encode(textDelta).byteLength;
120
+ if (this.matchesHallucination()) {
121
+ this.done = true;
122
+ return { kind: "hallucination" };
123
+ }
124
+ const lineBreakCount = (this.buffered.match(/\n/gu) ?? []).length;
125
+ const hasRoutingHint = ROUTING_TOOL_HINT.test(this.buffered) || ROUTING_FAILURE_OR_BRIDGE_CLAIM.test(this.buffered);
126
+ const pendingFailureClaim =
127
+ ROUTING_TOOL_HINT.test(this.buffered)
128
+ && ROUTING_FAILURE_OR_BRIDGE_CLAIM.test(this.buffered)
129
+ && lineBreakCount < 2;
130
+ if (
131
+ this.byteCount < MAX_ROUTING_COMMENTARY_BYTES
132
+ && this.buffered.length < MAX_HOLD_BYTES
133
+ && (lineBreakCount === 0 || pendingFailureClaim)
134
+ && (hasRoutingHint || this.byteCount < 64)
135
+ ) {
136
+ return { kind: "hold" };
137
+ }
138
+ this.done = true;
139
+ return { kind: "flush" };
140
+ }
141
+
142
+ finish(): RoutingCommentaryDecision {
143
+ if (this.done) return { kind: "flush" };
144
+ this.done = true;
145
+ return this.matchesHallucination() ? { kind: "hallucination" } : { kind: "flush" };
146
+ }
147
+
148
+ private matchesHallucination(): boolean {
149
+ if (!ROUTING_FAILURE_OR_BRIDGE_CLAIM.test(this.buffered)) return false;
150
+ const nativeTools = new Set(
151
+ [...this.buffered.matchAll(ROUTING_NATIVE_TOOL_NAME)].map(match => match[1]?.toLowerCase()),
152
+ );
153
+ if (nativeTools.size === 0) return false;
154
+ return ROUTING_REDIRECT_CLAIM.test(this.buffered) || nativeTools.size >= 2;
155
+ }
156
+ }
157
+
158
+ export const CURSOR_ECHO_RETRY_CONTINUATION_TEXT =
159
+ "Your previous reply copied an internal tool-output record verbatim and was rejected. Continue the original task now: issue the next required tool call, or answer in your own words if no tool is needed.";
160
+
161
+ export const CURSOR_ROUTING_COMMENTARY_RETRY_TEXT =
162
+ "Your previous reply claimed an execution-surface failure that did not occur and was rejected. Use the current tool catalog as ground truth. Perform the requested operation through the advertised execution tool now, with no commentary before the tool call.";
@@ -75,6 +75,7 @@ import { desktopDepsFromConfig } from "./native-exec-desktop";
75
75
  import {
76
76
  buildCursorToolDefinitions,
77
77
  cursorRequestAdvertisesApplyPatch,
78
+ cursorRequestUsesCodeMode,
78
79
  cursorRequestHasShellAlias,
79
80
  cursorToolArgNormalizeSchema,
80
81
  cursorToolWireName,
@@ -646,6 +647,7 @@ class LiveCursorTransport implements CursorTransport {
646
647
  this.execContext = {
647
648
  ...this.execContext,
648
649
  clientToolDefs,
650
+ codeMode: cursorRequestUsesCodeMode(request.tools, request.toolChoice),
649
651
  rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
650
652
  structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
651
653
  };
@@ -1509,7 +1511,7 @@ class LiveCursorTransport implements CursorTransport {
1509
1511
  const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
1510
1512
  && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
1511
1513
  const mapped = mapCursorProtobufServerMessage(message, state);
1512
- if (mapped.some(event => event.type === "text")) this.sawAssistantText = true;
1514
+ if (mapped.some(event => event.type === "text" || event.type === "thinking" || event.type === "tool_call_start" || event.type === "tool_call_end")) this.sawAssistantText = true;
1513
1515
  const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted"
1514
1516
  && !awaitedNativeArgsBeforeMapping
1515
1517
  && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
@@ -34,6 +34,7 @@ import {
34
34
  type GrepArgs,
35
35
  } from "./gen/agent_pb";
36
36
  import { errorText, execBytes, lineCount, textDecoder, textEncoder } from "./native-exec-common";
37
+ import { nativeLocalExecDisabledMessage, type CursorNativeExecPolicyContext } from "./native-exec-policy";
37
38
 
38
39
  const MAX_GREP_FILES = 500;
39
40
  const MAX_GREP_RESULTS = 200;
@@ -46,14 +47,14 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi
46
47
  return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`;
47
48
  }
48
49
 
49
- const NATIVE_LOCAL_EXEC_DISABLED =
50
- "Cursor-native filesystem tools are not executed locally. Use a catalog tool for this work: `shell_command` / `exec_command` (or the listed `mcp_opencodex-responses_*` display alias) with host-shell-safe equivalents: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits.";
50
+ export const NATIVE_LOCAL_EXEC_DISABLED =
51
+ "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.";
51
52
 
52
- export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
53
+ export function rejectReadExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
53
54
  if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
54
55
  const path = resolve(execMsg.message.value.path);
55
56
  return execBytes(execMsg, "readResult", create(ReadResultSchema, {
56
- result: { case: "error", value: create(ReadErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) },
57
+ result: { case: "error", value: create(ReadErrorSchema, { path, error: nativeLocalExecDisabledMessage(opts) }) },
57
58
  }));
58
59
  }
59
60
 
@@ -98,13 +99,13 @@ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structu
98
99
  }));
99
100
  }
100
101
 
101
- export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
102
+ export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
102
103
  if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec");
103
104
  const path = resolve(execMsg.message.value.path);
104
105
  return execBytes(execMsg, "writeResult", create(WriteResultSchema, {
105
106
  result: {
106
107
  case: "rejected",
107
- value: create(WriteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
108
+ value: create(WriteRejectedSchema, { path, reason: `${nativeLocalExecDisabledMessage(opts)} No file was changed.` }),
108
109
  },
109
110
  }));
110
111
  }
@@ -147,13 +148,13 @@ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, struct
147
148
  }));
148
149
  }
149
150
 
150
- export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
151
+ export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
151
152
  if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec");
152
153
  const path = resolve(execMsg.message.value.path);
153
154
  return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, {
154
155
  result: {
155
156
  case: "rejected",
156
- value: create(DeleteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
157
+ value: create(DeleteRejectedSchema, { path, reason: `${nativeLocalExecDisabledMessage(opts)} No file was changed.` }),
157
158
  },
158
159
  }));
159
160
  }
@@ -188,11 +189,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array {
188
189
  }
189
190
  }
190
191
 
191
- export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
192
+ export function rejectLsExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
192
193
  if (execMsg.message.case !== "lsArgs") throw new Error("invalid ls exec");
193
194
  const path = resolve(execMsg.message.value.path);
194
195
  return execBytes(execMsg, "lsResult", create(LsResultSchema, {
195
- result: { case: "error", value: create(LsErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) },
196
+ result: { case: "error", value: create(LsErrorSchema, { path, error: nativeLocalExecDisabledMessage(opts) }) },
196
197
  }));
197
198
  }
198
199
 
@@ -256,8 +257,8 @@ function grepError(execMsg: ExecServerMessage, error: string): Uint8Array {
256
257
  }));
257
258
  }
258
259
 
259
- export function rejectGrepExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
260
- return grepError(execMsg, NATIVE_LOCAL_EXEC_DISABLED);
260
+ export function rejectGrepExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
261
+ return grepError(execMsg, nativeLocalExecDisabledMessage(opts));
261
262
  }
262
263
 
263
264
  export function grepExec(execMsg: ExecServerMessage): Uint8Array {
@@ -1,19 +1,17 @@
1
1
  import { create } from "@bufbuild/protobuf";
2
2
  import { FetchErrorSchema, FetchResultSchema, FetchSuccessSchema, type ExecServerMessage } from "./gen/agent_pb";
3
3
  import { errorText, execBytes } from "./native-exec-common";
4
+ import { nativeFetchDisabledMessage, type CursorNativeExecPolicyContext } from "./native-exec-policy";
4
5
 
5
6
  export interface CursorNativeNetworkDeps {
6
7
  fetch?: typeof fetch;
7
8
  }
8
9
 
9
- const NATIVE_FETCH_DISABLED =
10
- "Cursor-native fetch is not executed locally. Use the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget.";
11
-
12
- export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
10
+ export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, opts: CursorNativeExecPolicyContext = {}): Uint8Array {
13
11
  if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
14
12
  const args = execMsg.message.value;
15
13
  return execBytes(execMsg, "fetchResult", create(FetchResultSchema, {
16
- result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: NATIVE_FETCH_DISABLED }) },
14
+ result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: nativeFetchDisabledMessage(opts) }) },
17
15
  }));
18
16
  }
19
17
 
@@ -0,0 +1,47 @@
1
+ export interface CursorNativeExecPolicyContext {
2
+ codeMode?: boolean;
3
+ }
4
+
5
+ export function nativeLocalExecDisabledMessage(opts: CursorNativeExecPolicyContext = {}): string {
6
+ if (opts.codeMode) {
7
+ return (
8
+ "Cursor-native local tools are policy-redirected to the Codex bridge for this request; this is not a permissions denial. "
9
+ + "Use the top-level `exec` tool and call nested helpers inside its JavaScript body as `await tools.<name>(args)` "
10
+ + "(for example `await tools.exec_command({cmd: \"ls\"})`, `await tools.apply_patch(input)`, or another helper listed in `ALL_TOOLS`). "
11
+ + "Do not call `shell_command` or `exec_command` at the top level in code mode."
12
+ );
13
+ }
14
+ return (
15
+ "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call."
16
+ );
17
+ }
18
+
19
+ export function nativeFetchDisabledMessage(opts: CursorNativeExecPolicyContext = {}): string {
20
+ if (opts.codeMode) {
21
+ return (
22
+ "Cursor-native fetch is policy-redirected to the Codex bridge for this request; this is not a permissions denial. "
23
+ + "Use the top-level `exec` tool and call a nested shell helper inside its JavaScript body as `await tools.exec_command({cmd: \"curl ...\"})` "
24
+ + "(or another helper listed in `ALL_TOOLS`). Do not call `shell_command` or `exec_command` at the top level in code mode."
25
+ );
26
+ }
27
+ return (
28
+ "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call."
29
+ );
30
+ }
31
+
32
+ export function nativeShellDisabledMessage(opts: CursorNativeExecPolicyContext = {}): string {
33
+ if (opts.codeMode) {
34
+ return (
35
+ "Cursor-native shell is policy-redirected to the Codex bridge for this request; this is not a permissions denial. "
36
+ + "Use the top-level `exec` tool and call a nested shell helper inside its JavaScript body as `await tools.exec_command({cmd: \"...\"})` "
37
+ + "(or another helper listed in `ALL_TOOLS`). Do not call `shell_command` or `exec_command` at the top level in code mode."
38
+ );
39
+ }
40
+ return (
41
+ "Re-issue this command NOW through the catalog shell tool (`shell_command` or `exec_command`; the long `mcp_opencodex-responses_*` display name is the same tool). "
42
+ + "Adapt the command for the Codex client host shell before calling the bridge "
43
+ + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). "
44
+ + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands. "
45
+ + "Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call."
46
+ );
47
+ }