@bitkyc08/opencodex 2.65.0 → 2.66.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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-DyHlFeNB.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
  5. package/gui/dist/assets/index-BiwnVAUm.js +86 -0
  6. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -523,6 +523,54 @@ function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
523
523
  return provider.apiKeyTransport === "bearer";
524
524
  }
525
525
 
526
+ /** The `anthropic-version` every Messages request from this proxy pins. */
527
+ export const ANTHROPIC_API_VERSION = "2023-06-01";
528
+
529
+ /**
530
+ * The fixed headers of every Messages request this proxy builds, before credentials. Shared by
531
+ * the adapter and the managed native lane so both pin the same version and client identity.
532
+ */
533
+ export function anthropicBaseRequestHeaders(stream: boolean | undefined): Record<string, string> {
534
+ return {
535
+ "Content-Type": "application/json",
536
+ "anthropic-version": ANTHROPIC_API_VERSION,
537
+ "Accept": stream ? "text/event-stream" : "application/json",
538
+ "User-Agent": "@anthropic-ai/sdk/0.74.0",
539
+ };
540
+ }
541
+
542
+ /** Key-auth credential placement: `x-api-key`, or a bearer when the provider asks for one. */
543
+ export function applyAnthropicKeyAuth(headers: Record<string, string>, provider: OcxProviderConfig): void {
544
+ if (typeof provider.apiKey !== "string") return;
545
+ if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`;
546
+ else headers["x-api-key"] = provider.apiKey;
547
+ }
548
+
549
+ /**
550
+ * OAuth (Claude Pro/Max) credential placement: the bearer, the OAuth beta pair and the Claude
551
+ * Code client fingerprint. Shared by the adapter and the managed native lane.
552
+ */
553
+ export function applyAnthropicOAuthAuth(headers: Record<string, string>, accessToken: string): void {
554
+ headers["Authorization"] = `Bearer ${accessToken}`;
555
+ headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
556
+ // Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty
557
+ // header set is a non-first-party signature. (cch billing-header signing is intentionally
558
+ // out of scope — brittle and version-coupled.)
559
+ Object.assign(headers, CLAUDE_CODE_HEADERS);
560
+ headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(accessToken);
561
+ headers["x-client-request-id"] = crypto.randomUUID();
562
+ }
563
+
564
+ /** The provider's Messages endpoint, refusing a base URL with an unresolved `{placeholder}`. */
565
+ export function resolveAnthropicMessagesUrl(provider: Pick<OcxProviderConfig, "baseUrl">): string {
566
+ const url = anthropicMessagesUrl(provider.baseUrl);
567
+ const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
568
+ if (unresolvedPlaceholder) {
569
+ throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
570
+ }
571
+ return url;
572
+ }
573
+
526
574
  /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
527
575
  function reasoningBudget(effort: string): number {
528
576
  switch (effort) {
@@ -802,6 +850,9 @@ function messagesToAnthropicFormat(
802
850
  if (text) preface.push({ type: "text", text });
803
851
  } else if (part.type === "thinking") {
804
852
  const t = part as OcxThinkingContent;
853
+ // History minted under another serving identity (or already rejected as opaque) is not
854
+ // this destination's to verify: drop its opaque blocks, as the Responses passthrough does.
855
+ if (parsed._stripReasoningEncryptedContent === true) continue;
805
856
  // Redacted blocks replay verbatim FIRST (they preceded the visible thinking block
806
857
  // in the original stream order preserved by the bridge envelope).
807
858
  for (const data of t.redacted ?? []) {
@@ -1129,35 +1180,15 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1129
1180
  body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true };
1130
1181
  }
1131
1182
 
1132
- const url = anthropicMessagesUrl(provider.baseUrl);
1133
- const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
1134
- if (unresolvedPlaceholder) {
1135
- throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
1136
- }
1183
+ const url = resolveAnthropicMessagesUrl(provider);
1137
1184
  // Anthropic fast mode: `speed` is only accepted beside its beta; without it the API
1138
1185
  // answers 400 "speed: Extra inputs are not permitted". The beta is merged below, after
1139
1186
  // any header override, so a request never carries one without the other.
1140
1187
  const fastSpeed = anthropicFastSpeed(parsed, provider);
1141
1188
  if (fastSpeed) body.speed = fastSpeed.value;
1142
- const headers: Record<string, string> = {
1143
- "Content-Type": "application/json",
1144
- "anthropic-version": "2023-06-01",
1145
- "Accept": parsed.stream ? "text/event-stream" : "application/json",
1146
- "User-Agent": "@anthropic-ai/sdk/0.74.0",
1147
- };
1148
- if (isOAuth) {
1149
- headers["Authorization"] = `Bearer ${provider.apiKey}`;
1150
- headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
1151
- // Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty
1152
- // header set is a non-first-party signature. (cch billing-header signing is intentionally
1153
- // out of scope — brittle and version-coupled.)
1154
- Object.assign(headers, CLAUDE_CODE_HEADERS);
1155
- headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey);
1156
- headers["x-client-request-id"] = crypto.randomUUID();
1157
- } else {
1158
- if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`;
1159
- else headers["x-api-key"] = provider.apiKey;
1160
- }
1189
+ const headers = anthropicBaseRequestHeaders(parsed.stream);
1190
+ if (isOAuth) applyAnthropicOAuthAuth(headers, provider.apiKey);
1191
+ else applyAnthropicKeyAuth(headers, provider);
1161
1192
  if (provider.headers) Object.assign(headers, provider.headers);
1162
1193
  mergeAnthropicBetaHeader(headers, fastSpeed?.betas ?? []);
1163
1194
 
@@ -80,14 +80,11 @@ export async function* readJsonLines(
80
80
  const maxLineBytes = limits.maxLineBytes ?? MAX_STREAM_LINE_BYTES;
81
81
  const maxTotalBytes = limits.maxTotalBytes ?? MAX_STREAM_TOTAL_BYTES;
82
82
  const decoder = new TextDecoder();
83
- const encoder = new TextEncoder();
84
- let buffer = "";
83
+ let parts: string[] = [];
84
+ let lineBytes = 0;
85
85
  let totalBytes = 0;
86
86
 
87
87
  const flushLine = function* (line: string): Generator<StreamMessage> {
88
- if (encoder.encode(line).byteLength > maxLineBytes) {
89
- throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
90
- }
91
88
  const trimmed = line.trim();
92
89
  if (!trimmed) return; // Blank lines and whitespace-only lines are ignored as padding.
93
90
  let parsed: unknown;
@@ -108,26 +105,39 @@ export async function* readJsonLines(
108
105
  yield parsed as StreamMessage;
109
106
  };
110
107
 
108
+ // Decode continuously for split UTF-8/BOM semantics, but search and measure only new
109
+ // decoded segments. Joining once per frame avoids repeatedly flattening a growing rope.
110
+ const consume = function* (text: string): Generator<StreamMessage> {
111
+ let start = 0;
112
+ while (start < text.length) {
113
+ const newline = text.indexOf("\n", start);
114
+ const end = newline < 0 ? text.length : newline;
115
+ const part = text.slice(start, end);
116
+ lineBytes += Buffer.byteLength(part);
117
+ if (lineBytes > maxLineBytes) {
118
+ throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
119
+ }
120
+ if (part) parts.push(part);
121
+ if (newline < 0) break;
122
+ const line = parts.join("");
123
+ parts = [];
124
+ lineBytes = 0;
125
+ yield* flushLine(line);
126
+ start = newline + 1;
127
+ }
128
+ };
129
+
111
130
  for await (const chunk of chunks) {
112
131
  totalBytes += chunk.byteLength;
113
132
  if (totalBytes > maxTotalBytes) {
114
133
  throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling");
115
134
  }
116
- buffer += decoder.decode(chunk, { stream: true });
117
- let newline = buffer.indexOf("\n");
118
- while (newline >= 0) {
119
- const line = buffer.slice(0, newline);
120
- buffer = buffer.slice(newline + 1);
121
- yield* flushLine(line);
122
- newline = buffer.indexOf("\n");
123
- }
124
- if (encoder.encode(buffer).byteLength > maxLineBytes) {
125
- throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling");
126
- }
135
+ yield* consume(decoder.decode(chunk, { stream: true }));
127
136
  }
128
- // Flush the decoder's trailing bytes and any final line without a newline terminator.
129
- buffer += decoder.decode();
130
- if (buffer.trim()) yield* flushLine(buffer);
137
+ // Flush incomplete UTF-8 through the same decoded-byte accounting before parsing EOF.
138
+ yield* consume(decoder.decode());
139
+ const finalLine = parts.join("");
140
+ if (finalLine.trim()) yield* flushLine(finalLine);
131
141
  }
132
142
 
133
143
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -1,3 +1,4 @@
1
+ import { CursorForegroundShellOwner } from "./native-foreground-shell";
1
2
  import http2 from "node:http2";
2
3
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
3
4
  import { namespacedToolName, type OcxProviderConfig, type OcxUsage } from "../../types";
@@ -531,6 +532,8 @@ class LiveCursorTransport implements CursorTransport {
531
532
  private mcpPrepared?: Promise<void>;
532
533
  private releaseMcpObservation?: () => void;
533
534
  private blobRequestScope?: CursorBlobRequestScopeToken;
535
+ private readonly foregroundShellOwner = new CursorForegroundShellOwner();
536
+ private requestSignal?: AbortSignal;
534
537
  private shellCleanup?: Promise<BackgroundShellTerminationReport>;
535
538
  // Per-turn diagnostic counters/timestamps when provider debug is on (`ocx debug provider on`). Stamped in open(), cleared on
536
539
  // close; safe to read after a stream failure because open() owns the only writer before run().
@@ -610,6 +613,7 @@ class LiveCursorTransport implements CursorTransport {
610
613
  }
611
614
 
612
615
  async *run(request: CursorRunRequest, signal?: AbortSignal): AsyncIterable<CursorServerMessage> {
616
+ this.requestSignal = signal;
613
617
  const queue: Array<{ message: CursorServerMessage; bytes: number }> = [];
614
618
  let notify: (() => void) | undefined;
615
619
  let done = false;
@@ -947,10 +951,14 @@ class LiveCursorTransport implements CursorTransport {
947
951
  }
948
952
 
949
953
  private startShellCleanup(): Promise<BackgroundShellTerminationReport> {
950
- return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId);
954
+ return this.shellCleanup ??= Promise.all([
955
+ terminateBackgroundShellsForSession(this.shellOwnerId),
956
+ this.foregroundShellOwner.close(),
957
+ ]).then(([backgroundReport]) => backgroundReport);
951
958
  }
952
959
 
953
960
  async close(): Promise<void> {
961
+ const shellCleanup = this.startShellCleanup();
954
962
  if (this.heartbeat) clearInterval(this.heartbeat);
955
963
  if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer);
956
964
  this.clearPendingFinalize();
@@ -963,10 +971,11 @@ class LiveCursorTransport implements CursorTransport {
963
971
  this.releaseMcpObservation?.();
964
972
  this.releaseMcpObservation = undefined;
965
973
  void this.mcpManager?.dispose();
966
- await this.startShellCleanup();
974
+ await shellCleanup;
967
975
  }
968
976
 
969
977
  private cancelCursorRun(): void {
978
+ void this.startShellCleanup();
970
979
  this.expectedClose = true;
971
980
  this.clearPendingFinalize();
972
981
  if (this.heartbeat) clearInterval(this.heartbeat);
@@ -1164,6 +1173,7 @@ class LiveCursorTransport implements CursorTransport {
1164
1173
  fail,
1165
1174
  finish,
1166
1175
  clearTimer: () => {
1176
+ void this.startShellCleanup();
1167
1177
  this.clearFirstFrameTimer();
1168
1178
  this.clearStreamHealthTimer();
1169
1179
  },
@@ -1403,6 +1413,8 @@ class LiveCursorTransport implements CursorTransport {
1403
1413
  failAndClear(realErr);
1404
1414
  };
1405
1415
  const onStreamEnd = () => {
1416
+ // Native frameWork can be awaiting a shell; cancel before waiting for it to drain.
1417
+ void this.startShellCleanup();
1406
1418
  this.clearFirstFrameTimer();
1407
1419
  debugProviderDiagnostic("cursor", "stream-end", {
1408
1420
  committed: this.committed,
@@ -1496,6 +1508,7 @@ class LiveCursorTransport implements CursorTransport {
1496
1508
  stream!.on("trailers", onTrailers);
1497
1509
  stream!.on("error", onStreamError);
1498
1510
  stream!.on("end", onStreamEnd);
1511
+ stream!.on("close", () => { void this.startShellCleanup(); });
1499
1512
  }
1500
1513
 
1501
1514
  signal?.addEventListener("abort", () => {
@@ -1569,7 +1582,9 @@ class LiveCursorTransport implements CursorTransport {
1569
1582
  // an eventual invalid_argument cannot cause the adapter's fresh-conversation fallback to
1570
1583
  // run the same local action twice.
1571
1584
  push({ type: "local_side_effect" });
1572
- const replies = await handleCursorNativeExec(message.message.value, this.execContext);
1585
+ const replies = await handleCursorNativeExec(message.message.value, {
1586
+ ...this.execContext, foregroundShellOwner: this.foregroundShellOwner, signal: this.requestSignal,
1587
+ });
1573
1588
  for (const reply of replies) this.writeConnectFrame(encodeConnectFrame(reply));
1574
1589
  return;
1575
1590
  }
@@ -1,5 +1,6 @@
1
- import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
+ import { foregroundShellUnavailableMessage, runForegroundShell, type CursorForegroundShellOwner } from "./native-foreground-shell";
3
4
  import { create } from "@bufbuild/protobuf";
4
5
  import {
5
6
  BackgroundShellSpawnErrorSchema,
@@ -123,39 +124,11 @@ export function rejectShellExecForPolicy(execMsg: ExecServerMessage, hint?: stri
123
124
  return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now(), hint));
124
125
  }
125
126
 
126
- export function shellExec(execMsg: ExecServerMessage): Uint8Array {
127
- if (execMsg.message.case !== "shellArgs") throw new Error("invalid shell exec");
128
- const args = execMsg.message.value;
129
- const cwd = resolve(args.workingDirectory || process.cwd());
130
- const started = Date.now();
131
- const result = spawnSync(args.command, { cwd, shell: true, encoding: "utf8", timeout: args.hardTimeout || 120_000 });
132
- const elapsed = Date.now() - started;
133
- const stdout = String(result.stdout ?? "");
134
- const stderr = String(result.stderr ?? "");
135
- const code = typeof result.status === "number" ? result.status : 1;
136
- if (code === 0) {
137
- return execBytes(execMsg, "shellResult", create(ShellResultSchema, {
138
- result: {
139
- case: "success",
140
- value: create(ShellSuccessSchema, { command: args.command, workingDirectory: cwd, exitCode: code, signal: "", stdout, stderr, executionTime: elapsed }),
141
- },
142
- }));
143
- }
144
- return execBytes(execMsg, "shellResult", create(ShellResultSchema, {
145
- result: {
146
- case: "failure",
147
- value: create(ShellFailureSchema, {
148
- command: args.command,
149
- workingDirectory: cwd,
150
- exitCode: code,
151
- signal: String(result.signal ?? ""),
152
- stdout,
153
- stderr,
154
- executionTime: elapsed,
155
- aborted: !!result.error,
156
- }),
157
- },
158
- }));
127
+ export function shellExec(execMsg: ExecServerMessage, redirectHint?: string): Uint8Array {
128
+ // Synchronous shellArgs needs the same admission fence as shellStreamArgs;
129
+ // changing wire shape must not bypass the missing descendant owner.
130
+ // Without a catalog hint, keep the default bridge redirect (#604) after the reason.
131
+ return rejectShellExecForPolicy(execMsg, foregroundShellUnavailableMessage(redirectHint ?? nativeShellDisabledMessage()));
159
132
  }
160
133
 
161
134
  export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array[] {
@@ -178,7 +151,12 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, hint?
178
151
  ];
179
152
  }
180
153
 
181
- export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8Array[]> {
154
+ export async function shellStreamExec(
155
+ execMsg: ExecServerMessage,
156
+ owner?: CursorForegroundShellOwner,
157
+ signal?: AbortSignal,
158
+ redirectHint?: string,
159
+ ): Promise<Uint8Array[]> {
182
160
  if (execMsg.message.case !== "shellStreamArgs") throw new Error("invalid shell stream exec");
183
161
  const args = execMsg.message.value;
184
162
  const cwd = resolve(args.workingDirectory || process.cwd());
@@ -188,36 +166,13 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8
188
166
  event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) },
189
167
  })),
190
168
  ];
191
- const result = await new Promise<{ stdout: string; stderr: string; code: number; aborted: boolean }>(resolvePromise => {
192
- const child = spawn(args.command, { cwd, shell: true });
193
- let stdout = "";
194
- let stderr = "";
195
- let aborted = false;
196
- const timeout = setTimeout(() => {
197
- aborted = true;
198
- child.kill();
199
- }, args.hardTimeout || 120_000);
200
- child.stdout.on("data", chunk => {
201
- stdout += String(chunk);
202
- });
203
- child.stderr.on("data", chunk => {
204
- stderr += String(chunk);
205
- });
206
- child.on("close", code => {
207
- clearTimeout(timeout);
208
- resolvePromise({ stdout, stderr, code: code ?? 1, aborted });
209
- });
210
- child.on("error", err => {
211
- clearTimeout(timeout);
212
- resolvePromise({ stdout, stderr: stderr + errorText(err), code: 1, aborted });
213
- });
214
- });
215
- if (result.stdout) {
169
+ const result = await runForegroundShell(args.command, cwd, args.hardTimeout ?? 120_000, owner, signal, redirectHint ?? nativeShellDisabledMessage());
170
+ if (result.stdout && !result.aborted) {
216
171
  replies.push(execBytes(execMsg, "shellStream", create(ShellStreamSchema, {
217
172
  event: { case: "stdout", value: create(ShellStreamStdoutSchema, { data: result.stdout }) },
218
173
  })));
219
174
  }
220
- if (result.stderr) {
175
+ if (result.stderr && !result.aborted) {
221
176
  replies.push(execBytes(execMsg, "shellStream", create(ShellStreamSchema, {
222
177
  event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: result.stderr }) },
223
178
  })));
@@ -237,7 +192,7 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8
237
192
  command: args.command,
238
193
  workingDirectory: cwd,
239
194
  exitCode: result.code,
240
- signal: "",
195
+ signal: result.signal,
241
196
  stdout: result.stdout,
242
197
  stderr: result.stderr,
243
198
  executionTime: Date.now() - started,
@@ -251,7 +206,7 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8
251
206
  command: args.command,
252
207
  workingDirectory: cwd,
253
208
  exitCode: result.code,
254
- signal: "",
209
+ signal: result.signal,
255
210
  stdout: result.stdout,
256
211
  stderr: result.stderr,
257
212
  executionTime: Date.now() - started,
@@ -1,3 +1,4 @@
1
+ import type { CursorForegroundShellOwner } from "./native-foreground-shell";
1
2
  import { createHash } from "node:crypto";
2
3
  import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
3
4
  import { create } from "@bufbuild/protobuf";
@@ -66,6 +67,8 @@ export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDep
66
67
  export interface CursorNativeExecContext extends CursorNativeExecDeps {
67
68
  /** Stable owner for background shells created by this transport session. */
68
69
  sessionId?: string;
70
+ foregroundShellOwner?: CursorForegroundShellOwner;
71
+ signal?: AbortSignal;
69
72
  mcpToolDefs?: McpToolDefinition[];
70
73
  clientToolDefs?: McpToolDefinition[];
71
74
  /** Unsafe opt-in escape hatch for Cursor server-driven local fs/shell/fetch execution. */
@@ -706,8 +709,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
706
709
  if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)];
707
710
  if (execCase === "lsArgs") return [lsExec(execMsg)];
708
711
  if (execCase === "grepArgs") return [grepExec(execMsg)];
709
- if (execCase === "shellArgs") return [shellExec(execMsg)];
710
- if (execCase === "shellStreamArgs") return shellStreamExec(execMsg);
712
+ if (execCase === "shellArgs") return [shellExec(execMsg, deps.nativeExecRedirectHint)];
713
+ if (execCase === "shellStreamArgs") return shellStreamExec(execMsg, deps.foregroundShellOwner, deps.signal, deps.nativeExecRedirectHint);
711
714
  if (execCase === "backgroundShellSpawnArgs") return [backgroundShellSpawnExec(execMsg, deps.sessionId ?? "")];
712
715
  if (execCase === "writeShellStdinArgs") return [writeShellStdinExec(execMsg, deps.sessionId ?? "")];
713
716
  if (execCase === "fetchArgs") return [await fetchExec(execMsg, deps)];
@@ -0,0 +1,44 @@
1
+ export interface ForegroundShellResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ code: number;
5
+ aborted: boolean;
6
+ signal: string;
7
+ }
8
+
9
+ /** Keep the transport's shutdown fence even while foreground execution is unavailable. */
10
+ export class CursorForegroundShellOwner {
11
+ private closed = false;
12
+
13
+ get activeCount(): number { return 0; }
14
+ get isClosed(): boolean { return this.closed; }
15
+
16
+ async close(): Promise<void> { this.closed = true; }
17
+ }
18
+
19
+ export function foregroundShellUnavailableMessage(redirectHint?: string): string {
20
+ return "Cursor foreground native shell is unavailable without kernel-backed descendant ownership. "
21
+ + (redirectHint ?? "Use the client shell tool instead.");
22
+ }
23
+
24
+ export function runForegroundShell(
25
+ _command: string,
26
+ _cwd: string,
27
+ _hardTimeout: number,
28
+ owner = new CursorForegroundShellOwner(),
29
+ signal?: AbortSignal,
30
+ redirectHint?: string,
31
+ ): Promise<ForegroundShellResult> {
32
+ // A POSIX process group is not a descendant owner: a child can leave it with
33
+ // setsid(), including after closing its inherited pipes. Windows needs a job
34
+ // object for the same lifetime guarantee. Neither backend exists here, so reject
35
+ // BEFORE spawn rather than reporting group disappearance as complete cleanup.
36
+ const cancelled = owner.isClosed || signal?.aborted;
37
+ return Promise.resolve({
38
+ stdout: "",
39
+ stderr: cancelled ? "Cursor shell cancelled." : foregroundShellUnavailableMessage(redirectHint),
40
+ code: 1,
41
+ aborted: true,
42
+ signal: "",
43
+ });
44
+ }
@@ -1289,7 +1289,13 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C
1289
1289
  // The status line is carried on the error. A cap or an expired credential
1290
1290
  // delivered instead as a Connect EOS trailer is mapped by
1291
1291
  // connectTrailerHttpStatus at the trailer sites below.
1292
- throw new CloudChatError(`GetChatMessage failed (HTTP ${resp.status})`, undefined, undefined, resp.status);
1292
+ const error = new CloudChatError(`GetChatMessage failed (HTTP ${resp.status})`, undefined, undefined, resp.status);
1293
+ // No consumer will drain this body. Cancellation must neither replace the
1294
+ // status error nor delay it if a transport's cancel promise never settles.
1295
+ try {
1296
+ void resp.body?.cancel(error).catch(() => undefined);
1297
+ } catch { /* Non-conforming streams can throw synchronously from cancel. */ }
1298
+ throw error;
1293
1299
  }
1294
1300
  if (!resp.body) {
1295
1301
  throw new CloudChatError('GetChatMessage response had no body stream');
@@ -20,8 +20,11 @@
20
20
  * `function_call_output` is parsed with `isError: false`. Cursor combines this set with
21
21
  * `isFailedEmptyExecWrapper` below for Computer Use, where a failed wrapper is separately marked
22
22
  * `isError`.
23
+ *
24
+ * `(?=(X))\1` pins each wildcard run to its maximal match — without it, adjacent `\n+`/`\s*`
25
+ * runs can repartition a newline block combinatorially (the ReDoS shape this had before).
23
26
  */
24
- export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)[^\n]*\n+)?(?:Wall time[^\n]*\n+)?(?:Output:\s*)?(?:<empty>)?\s*$/;
27
+ export const EMPTY_EXEC_OUTPUT_REGEX = /^(?:(?:Script completed|Command finished|Execution finished)(?=([^\n]*))\1(?=(\n+))\2)?(?:Wall time(?=([^\n]*))\3(?=(\n+))\4)?(?:Output:(?=(\s*))\5)?(?:<empty>)?(?=(\s*))\6$/;
25
28
 
26
29
  function skipFailedWrapperBlankSeparators(text: string, start: number): number {
27
30
  let index = start;
@@ -456,6 +456,18 @@ function messagesToGeminiFormat(
456
456
  }
457
457
  }
458
458
 
459
+ // A functionCall turn may not open the request: the upstream requires it to follow a user or
460
+ // function-response turn, and rejects with "function call turn comes immediately after a user
461
+ // turn or after a function response turn" (HTTP 400, #5008). Context compaction can truncate a
462
+ // long history so it opens on an assistant tool call. Prepend a user nudge, the same repair
463
+ // Kiro applies to assistant-head turns (src/adapters/kiro/payload.ts). A model head carrying
464
+ // only text is left alone: no upstream rule against it is demonstrated, and repairing it would
465
+ // inject a turn into valid requests.
466
+ const firstTurn = contents[0] as { role?: string; parts?: Array<{ functionCall?: unknown }> } | undefined;
467
+ if (firstTurn?.role === "model" && firstTurn.parts?.some(p => p.functionCall !== undefined)) {
468
+ contents.unshift({ role: "user", parts: [{ text: "(continue)" }] });
469
+ }
470
+
459
471
  // Gemini API and Claude-on-Antigravity reject assistant-tail (model-tail in Gemini terms)
460
472
  // histories. Gemini fails upstream with "Requests ending with a model turn are not supported"
461
473
  // (HTTP 400), while Claude fails with "This model does not support assistant message prefill.
@@ -88,6 +88,7 @@ interface NativeStreamToolCall {
88
88
  nativeIndex?: number;
89
89
  arguments: Record<string, unknown>;
90
90
  argumentBytes: number;
91
+ metadataBytes: number;
91
92
  }
92
93
 
93
94
  interface NativeStreamState {
@@ -106,6 +107,10 @@ type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value
106
107
 
107
108
  const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]);
108
109
  const NATIVE_TOOL_ID_MAX_LENGTH = 256;
110
+ const NATIVE_TOOL_NAME_MAX_BYTES = 1024;
111
+ const NATIVE_MAX_PENDING_TOOL_CALLS = 128;
112
+ // Account for the retained Map key and call bookkeeping in addition to the provider's name.
113
+ const NATIVE_TOOL_CALL_BOOKKEEPING_BYTES = 128;
109
114
  const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u;
110
115
 
111
116
  function isRecord(value: unknown): value is JsonRecord {
@@ -598,6 +603,10 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
598
603
  }
599
604
  const fn = rawCall.function;
600
605
  if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name");
606
+ const nameBytes = new TextEncoder().encode(fn.name).byteLength;
607
+ if (nameBytes > NATIVE_TOOL_NAME_MAX_BYTES) {
608
+ throw new Error(`ollama-native response tool call name exceeded ${NATIVE_TOOL_NAME_MAX_BYTES} bytes`);
609
+ }
601
610
  const args = assertObjectArguments(fn.arguments, "response tool call");
602
611
  const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined;
603
612
  const nativeId = validNativeToolCallId(rawCall.id);
@@ -610,6 +619,9 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
610
619
  if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) {
611
620
  throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested");
612
621
  }
622
+ if (!existing && state.toolCalls.size >= NATIVE_MAX_PENDING_TOOL_CALLS) {
623
+ throw new Error(`ollama-native response exceeded ${NATIVE_MAX_PENDING_TOOL_CALLS} pending tool calls`);
624
+ }
613
625
  if (existing) {
614
626
  if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function");
615
627
  if (nativeId && existing.nativeId && nativeId !== existing.nativeId) {
@@ -627,12 +639,25 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg
627
639
  ...(index !== undefined ? { nativeIndex: index } : {}),
628
640
  arguments: args,
629
641
  argumentBytes: 0,
642
+ metadataBytes: 0,
630
643
  };
631
644
  budget.openCall(call.budgetKey);
632
645
  try {
646
+ const metadataBytes = nameBytes + NATIVE_TOOL_CALL_BOOKKEEPING_BYTES;
647
+ // Name and bookkeeping are retained call metadata, not arguments: charging them to the
648
+ // call's budget key would consume the per-call argument allowance. Keep them on the
649
+ // shared retained budget and release them when the call closes.
650
+ budget.chargeRetained(metadataBytes, {
651
+ kind: "tool_args",
652
+ });
653
+ call.metadataBytes = metadataBytes;
633
654
  replaceNativeToolArguments(call, args, budget);
634
655
  state.toolCalls.set(key, call);
635
656
  } catch (error) {
657
+ if (call.metadataBytes > 0) {
658
+ budget.releaseRetained(call.metadataBytes, { kind: "tool_args" });
659
+ call.metadataBytes = 0;
660
+ }
636
661
  budget.closeCall(call.budgetKey);
637
662
  throw error;
638
663
  }
@@ -687,7 +712,13 @@ function replaceNativeToolArguments(
687
712
  }
688
713
 
689
714
  function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void {
690
- for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey);
715
+ for (const call of state.toolCalls.values()) {
716
+ if (call.metadataBytes > 0) {
717
+ budget.releaseRetained(call.metadataBytes, { kind: "tool_args" });
718
+ call.metadataBytes = 0;
719
+ }
720
+ budget.closeCall(call.budgetKey);
721
+ }
691
722
  }
692
723
 
693
724
  function nativeBodyMessage(value: unknown): JsonRecord {