@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822

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 (68) hide show
  1. package/README.md +5 -5
  2. package/gui/dist/assets/index-DyWYnr-t.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +3 -3
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +29 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/images.ts +704 -0
  10. package/src/adapters/cursor/live-models.ts +21 -26
  11. package/src/adapters/cursor/live-transport.ts +239 -8
  12. package/src/adapters/cursor/native-exec-common.ts +17 -0
  13. package/src/adapters/cursor/native-exec.ts +9 -4
  14. package/src/adapters/cursor/protobuf-events.ts +5 -1
  15. package/src/adapters/cursor/protobuf-request.ts +46 -9
  16. package/src/adapters/cursor/request-builder.ts +29 -14
  17. package/src/adapters/cursor/tool-definitions.ts +20 -0
  18. package/src/adapters/cursor/transport.ts +10 -0
  19. package/src/adapters/cursor/types.ts +8 -1
  20. package/src/adapters/cursor.ts +23 -5
  21. package/src/adapters/google.ts +16 -3
  22. package/src/adapters/openai-responses.ts +66 -20
  23. package/src/adapters/xai-web-search.ts +185 -0
  24. package/src/cli/agent.ts +2 -1
  25. package/src/cli/dispatch.ts +2 -2
  26. package/src/cli/doctor.ts +89 -0
  27. package/src/cli/help.ts +2 -0
  28. package/src/cli/registry.ts +7 -2
  29. package/src/codex/auth-context.ts +41 -2
  30. package/src/codex/catalog/effort.ts +1 -1
  31. package/src/codex/catalog/parsing.ts +2 -0
  32. package/src/codex/catalog/provider-fetch.ts +20 -5
  33. package/src/codex/coordinator-doctor.ts +332 -0
  34. package/src/codex/features.ts +58 -0
  35. package/src/codex/inject-coordination.ts +39 -6
  36. package/src/codex/transition-state.ts +12 -12
  37. package/src/generated/compatibility-version.json +86 -58
  38. package/src/lib/bun-stream-caps.ts +7 -4
  39. package/src/lib/errors.ts +8 -2
  40. package/src/oauth/cursor.ts +21 -0
  41. package/src/providers/command-code-efforts.ts +7 -0
  42. package/src/providers/cursor-pool.ts +72 -0
  43. package/src/providers/derive.ts +3 -0
  44. package/src/providers/fastwire.ts +12 -1
  45. package/src/providers/openai-sidecar.ts +1 -0
  46. package/src/providers/quota.ts +98 -25
  47. package/src/providers/registry.ts +115 -10
  48. package/src/providers/service-tier.ts +22 -7
  49. package/src/responses/custom-tool-compat.ts +24 -8
  50. package/src/responses/namespace-tool-compat.ts +2 -3
  51. package/src/router.ts +3 -0
  52. package/src/server/chat-completions.ts +4 -0
  53. package/src/server/chat-native.ts +20 -0
  54. package/src/server/management/agent-settings-routes.ts +16 -5
  55. package/src/server/management/config-routes.ts +25 -5
  56. package/src/server/management/vision-sidecar-options.ts +54 -19
  57. package/src/server/responses/compact.ts +1 -2
  58. package/src/server/responses/core.ts +54 -13
  59. package/src/service.ts +122 -14
  60. package/src/types/config.ts +9 -3
  61. package/src/types/provider.ts +6 -0
  62. package/src/usage/cost.ts +52 -38
  63. package/src/usage/expected-prices.ts +79 -9
  64. package/src/vision/backends.ts +97 -0
  65. package/src/vision/eligibility.ts +43 -22
  66. package/src/vision/index.ts +73 -5
  67. package/src/vision/routed-describe.ts +175 -0
  68. package/gui/dist/assets/index-BNESwCzn.js +0 -102
@@ -14,6 +14,7 @@
14
14
  * 5-byte gRPC/Connect frame makes the server mis-parse it ("illegal tag: field no 0").
15
15
  */
16
16
  import http2 from "node:http2";
17
+ import { cursorH2Pool } from "./h2-pool";
17
18
  import { fromBinary } from "@bufbuild/protobuf";
18
19
  import type { UpstreamHttpVersion } from "../../types";
19
20
  import { readBoundedResponseBytes } from "../../lib/bounded-body";
@@ -205,35 +206,29 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions)
205
206
  resolve(value);
206
207
  };
207
208
 
208
- let client: http2.ClientHttp2Session;
209
- try {
210
- client = http2.connect(baseUrl);
211
- } catch {
212
- return finish({ ok: false, error: "transport", detail: "HTTP/2 connection setup failed" });
213
- }
214
209
 
215
- const timer = setTimeout(() => {
216
- finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` });
217
- client.destroy();
218
- }, timeoutMs);
219
- const close = (value: CursorUsableModelsResult): void => {
220
- clearTimeout(timer);
221
- client.close();
222
- finish(value);
223
- };
210
+ const timer = setTimeout(() => {
211
+ // Cancel the borrowed pooled stream so it does not continue receiving
212
+ // body bytes after the caller has timed out (regression vs pre-pool behavior).
213
+ req?.destroy();
214
+ finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` });
215
+ }, timeoutMs);
216
+ const close = (value: CursorUsableModelsResult): void => {
217
+ clearTimeout(timer);
218
+ finish(value);
219
+ };
224
220
 
225
- client.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 session failed" }));
226
221
 
227
- let req: http2.ClientHttp2Stream;
228
- try {
229
- req = client.request({
230
- ":method": "POST",
231
- ":path": CURSOR_GET_USABLE_MODELS_PATH,
232
- ...cursorDiscoveryHeaders(opts),
233
- });
234
- } catch {
235
- return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
236
- }
222
+ let req: http2.ClientHttp2Stream;
223
+ try {
224
+ req = cursorH2Pool.request(baseUrl, {
225
+ ":method": "POST",
226
+ ":path": CURSOR_GET_USABLE_MODELS_PATH,
227
+ ...cursorDiscoveryHeaders(opts),
228
+ });
229
+ } catch {
230
+ return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
231
+ }
237
232
 
238
233
  let status = 0;
239
234
  const chunks: Buffer[] = [];
@@ -13,6 +13,8 @@ import {
13
13
  type TranslatorBudget,
14
14
  } from "../../lib/translator-budget";
15
15
  import { activePromptText, prepareCursorRunRequest } from "./protobuf-request";
16
+ import { prepareCursorRawMessages, resolveActiveCursorImages } from "./images";
17
+ import { cursorRequestMessagesFromRaw } from "./request-builder";
16
18
  import {
17
19
  createCursorContextUsageTracker,
18
20
  createCursorProtobufEventState,
@@ -89,6 +91,24 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
89
91
  const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a";
90
92
  const HEARTBEAT_MS = 5_000;
91
93
  const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000;
94
+ /**
95
+ * T04 (senpi #1062 second half): after the first frame, a turn with NO inbound decoded
96
+ * frames for this long is failed instead of waiting for the 300s bridge stall watchdog
97
+ * (issue #2210). Reset on every decoded AgentServerMessage.
98
+ */
99
+ const CURSOR_STREAM_SILENCE_FAIL_MS = 30_000;
100
+ /**
101
+ * A stream that produces ONLY liveness frames (server heartbeat / conversationCheckpointUpdate)
102
+ * for this long is equally stuck — the server is alive but the turn is not progressing.
103
+ * Reset on every decoded frame that is not liveness-only.
104
+ */
105
+ const CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000;
106
+ /**
107
+ * After `turnEnded` is decoded, the application turn is complete. A server that keeps
108
+ * HTTP/2 open past this point cannot hold the turn hostage (senpi #1062): we close our side
109
+ * after a short grace so any trailing frames (late usage, checkpoint) still land.
110
+ */
111
+ const TURN_ENDED_CLOSE_GRACE_MS = 500;
92
112
  const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000;
93
113
  const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
94
114
  const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750;
@@ -412,6 +432,18 @@ class LiveCursorTransport implements CursorTransport {
412
432
  private http1Connection?: CursorHttp1BidiConnection;
413
433
  private heartbeat?: ReturnType<typeof setInterval>;
414
434
  private firstFrameTimer?: ReturnType<typeof setTimeout>;
435
+ private turnEndedCloseTimer?: ReturnType<typeof setTimeout>;
436
+ /**
437
+ * T04 inbound stream-health watchdog. Armed after the request is on the wire, reset by
438
+ * every DECODED frame (raw chunks deliberately do not count — TLS keepalive noise must not
439
+ * defeat it), disarmed by any settle/expected-close path. One timer covers both thresholds:
440
+ * it always fires at min(lastInbound + silence, lastMeaningful + heartbeatOnly) and re-arms
441
+ * when neither deadline has actually elapsed.
442
+ */
443
+ private streamHealthTimer?: ReturnType<typeof setTimeout>;
444
+ private lastInboundFrameAt = 0;
445
+ private lastMeaningfulFrameAt = 0;
446
+ private streamHealthFail?: (error: Error) => void;
415
447
  private committed = false;
416
448
  private expectedClose = false;
417
449
  /**
@@ -569,10 +601,29 @@ class LiveCursorTransport implements CursorTransport {
569
601
 
570
602
  // Advertise MCP tools before the stream opens — the server only calls tools it was told about.
571
603
  await this.prepareMcp();
572
- const activeText = activePromptText(request);
573
- this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs);
574
- const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice);
575
- const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice);
604
+ // JPEG soft-cap rewrite for active-turn data: images before encode. Rebuild text
605
+ // messages from the prepared raw channel so omission markers replace stale
606
+ // pre-rewrite content that activePromptText and the tool filter would otherwise see.
607
+ const preparedRaw = await prepareCursorRawMessages(request.rawMessages, signal);
608
+ const preparedRawMessages = preparedRaw.messages;
609
+ const selectedImages = await resolveActiveCursorImages(
610
+ preparedRawMessages,
611
+ signal,
612
+ preparedRaw.images,
613
+ );
614
+ const preparedMessages = preparedRawMessages === request.rawMessages
615
+ ? request.messages
616
+ : cursorRequestMessagesFromRaw(preparedRawMessages);
617
+ const activeRequest: CursorRunRequest = {
618
+ ...request,
619
+ messages: preparedMessages,
620
+ rawMessages: preparedRawMessages,
621
+ selectedImages,
622
+ };
623
+ const activeText = activePromptText(activeRequest);
624
+ this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(activeRequest, this.clientToolFinalizeGraceMs);
625
+ const cursorVisibleTools = cursorToolsForActivePrompt(activeRequest.tools, activeText, activeRequest.toolChoice);
626
+ const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, activeRequest.toolChoice);
576
627
  // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive
577
628
  // conversion provenance only from tagged synthetic tools that also survive this final prompt
578
629
  // filter; a client tool with the same wire name can never opt into conversion by collision.
@@ -608,7 +659,7 @@ class LiveCursorTransport implements CursorTransport {
608
659
  });
609
660
  // Build the payload once. The estimate is only worth deriving when there is no
610
661
  // carry-forward to fall back on — with a carry present it would never be used (#373).
611
- const prepared = prepareCursorRunRequest(request, {
662
+ const prepared = prepareCursorRunRequest(activeRequest, {
612
663
  estimateInputTokens: contextUsage.carryForwardTokens === undefined,
613
664
  });
614
665
  this.blobRequestScope = prepared.blobRequestScope;
@@ -732,14 +783,100 @@ class LiveCursorTransport implements CursorTransport {
732
783
  }
733
784
  }
734
785
 
786
+ private clearStreamHealthTimer(): void {
787
+ if (this.streamHealthTimer) {
788
+ clearTimeout(this.streamHealthTimer);
789
+ this.streamHealthTimer = undefined;
790
+ }
791
+ this.streamHealthFail = undefined;
792
+ }
793
+
794
+ /**
795
+ * T04: arm (or re-arm) the inbound stream-health watchdog. `fail` is the turn's
796
+ * failAndClear; the timer owns nothing else. Never armed before the first decoded
797
+ * frame (the first-frame timer covers dial + first response), and disarmed by
798
+ * every settle / expected-close path alongside the other timers.
799
+ */
800
+ private armStreamHealthTimer(fail: (error: Error) => void): void {
801
+ if (this.streamHealthTimer) clearTimeout(this.streamHealthTimer);
802
+ if (this.expectedClose) return;
803
+ this.streamHealthFail = fail;
804
+ const silenceMs = this.input.streamSilenceFailMs ?? CURSOR_STREAM_SILENCE_FAIL_MS;
805
+ const heartbeatOnlyMs = this.input.streamHeartbeatOnlyFailMs ?? CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS;
806
+ const now = Date.now();
807
+ const deadline = Math.min(
808
+ this.lastInboundFrameAt + silenceMs,
809
+ this.lastMeaningfulFrameAt + heartbeatOnlyMs,
810
+ );
811
+ this.streamHealthTimer = setTimeout(() => {
812
+ this.streamHealthTimer = undefined;
813
+ const failFn = this.streamHealthFail;
814
+ if (!failFn || this.expectedClose) return;
815
+ const stalledFor = Date.now() - this.lastInboundFrameAt;
816
+ const meaningfulStalledFor = Date.now() - this.lastMeaningfulFrameAt;
817
+ if (stalledFor < silenceMs && meaningfulStalledFor < heartbeatOnlyMs) {
818
+ // A frame landed between arming and firing — re-arm for the fresh deadline.
819
+ this.armStreamHealthTimer(failFn);
820
+ return;
821
+ }
822
+ const heartbeatOnly = stalledFor < silenceMs;
823
+ debugProviderDiagnostic("cursor", "stream-health-timeout", {
824
+ stalledMs: stalledFor,
825
+ meaningfulStalledMs: meaningfulStalledFor,
826
+ heartbeatOnly,
827
+ framesReceived: this.framesReceived,
828
+ elapsedMs: Date.now() - this.turnStartedAt,
829
+ });
830
+ const reason = heartbeatOnly
831
+ ? `Cursor stream stalled: heartbeat-only traffic for ${Math.round(meaningfulStalledFor / 1000)}s without turn progress`
832
+ : `Cursor stream stalled: no inbound frames for ${Math.round(stalledFor / 1000)}s before turnEnded`;
833
+ failFn(new Error(reason));
834
+ try { this.stream?.close(); } catch { this.stream?.destroy(); }
835
+ this.session?.close();
836
+ this.http1Connection?.close();
837
+ }, Math.max(0, deadline - now));
838
+ }
839
+
840
+ /**
841
+ * T04: record a decoded inbound frame. Liveness-only frames (server heartbeat,
842
+ * conversationCheckpointUpdate) keep the silence clock fresh but not the progress
843
+ * clock — matching senpi's split so a server that only pings still fails at the
844
+ * heartbeat-only threshold.
845
+ */
846
+ private noteInboundFrame(livenessOnly: boolean): void {
847
+ const now = Date.now();
848
+ this.lastInboundFrameAt = now;
849
+ if (!livenessOnly) this.lastMeaningfulFrameAt = now;
850
+ if (this.streamHealthFail) this.armStreamHealthTimer(this.streamHealthFail);
851
+ }
852
+
853
+ /**
854
+ * A clean Connect END_STREAM owns the turn terminal even when Cursor keeps the
855
+ * HTTP body open or tears it down with an abort/reset immediately afterward.
856
+ * Stop client-side liveness work and classify that later transport close as
857
+ * expected without actively sending an RST_STREAM back to Cursor.
858
+ */
859
+ private markProtocolComplete(): void {
860
+ this.expectedClose = true;
861
+ this.clearPendingFinalize();
862
+ if (this.heartbeat) {
863
+ clearInterval(this.heartbeat);
864
+ this.heartbeat = undefined;
865
+ }
866
+ this.clearFirstFrameTimer();
867
+ this.clearStreamHealthTimer();
868
+ }
869
+
735
870
  private startShellCleanup(): Promise<BackgroundShellTerminationReport> {
736
871
  return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId);
737
872
  }
738
873
 
739
874
  async close(): Promise<void> {
740
875
  if (this.heartbeat) clearInterval(this.heartbeat);
876
+ if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer);
741
877
  this.clearPendingFinalize();
742
878
  this.clearFirstFrameTimer();
879
+ this.clearStreamHealthTimer();
743
880
  this.stream?.close();
744
881
  this.session?.close();
745
882
  this.http1Connection?.close();
@@ -755,6 +892,7 @@ class LiveCursorTransport implements CursorTransport {
755
892
  this.clearPendingFinalize();
756
893
  if (this.heartbeat) clearInterval(this.heartbeat);
757
894
  this.clearFirstFrameTimer();
895
+ this.clearStreamHealthTimer();
758
896
  if (this.http1Connection) {
759
897
  this.http1Connection.close();
760
898
  } else {
@@ -772,6 +910,46 @@ class LiveCursorTransport implements CursorTransport {
772
910
  void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ });
773
911
  }
774
912
 
913
+ /**
914
+ * T03 (#1062): after the server sends `turnEnded`, the application turn is complete.
915
+ * A server that keeps the HTTP/2 stream open past this point cannot hold the turn
916
+ * hostage until a 300s bridge idle timeout. Close our side after a short grace so any
917
+ * trailing frames (late usage, checkpoint) still land before we release the socket.
918
+ */
919
+ private closeAfterTurnEnded(): void {
920
+ if (this.turnEndedCloseTimer) return;
921
+ // The application turn is over: the T03 grace timer owns the socket from here.
922
+ // The T04 watchdog must disarm NOW, not at the grace close — a watchdog shorter
923
+ // than the grace would otherwise fail a completed turn.
924
+ this.clearStreamHealthTimer();
925
+ this.turnEndedCloseTimer = setTimeout(() => {
926
+ this.turnEndedCloseTimer = undefined;
927
+ // Only expectedClose (client-tool suspend cancel) blocks the close.
928
+ // emittedTerminal is intentionally NOT checked here: finalizeTurnEvents sets it
929
+ // synchronously during turnEnded mapping, ~500ms before this timer fires, so
930
+ // checking it would make the close unreachable on every real path (the exact
931
+ // scenario this PR exists to fix — senpi #1062).
932
+ if (this.expectedClose) return;
933
+ debugProviderDiagnostic("cursor", "turn-ended-close", {
934
+ committed: this.committed,
935
+ framesReceived: this.framesReceived,
936
+ });
937
+ this.expectedClose = true;
938
+ this.clearFirstFrameTimer();
939
+ this.clearStreamHealthTimer();
940
+ if (this.heartbeat) clearInterval(this.heartbeat);
941
+ if (this.http1Connection) {
942
+ this.http1Connection.close();
943
+ } else {
944
+ try {
945
+ this.stream?.close();
946
+ } catch {
947
+ this.stream?.destroy();
948
+ }
949
+ }
950
+ }, TURN_ENDED_CLOSE_GRACE_MS);
951
+ }
952
+
775
953
  private releaseBlobRequestScope(): void {
776
954
  const scope = this.blobRequestScope;
777
955
  if (!scope) return;
@@ -882,7 +1060,10 @@ class LiveCursorTransport implements CursorTransport {
882
1060
  const settler = createTerminalSettler({
883
1061
  fail,
884
1062
  finish,
885
- clearTimer: () => this.clearFirstFrameTimer(),
1063
+ clearTimer: () => {
1064
+ this.clearFirstFrameTimer();
1065
+ this.clearStreamHealthTimer();
1066
+ },
886
1067
  });
887
1068
  const failAndClear = (error: Error) => {
888
1069
  releaseBacklogLease();
@@ -979,10 +1160,54 @@ class LiveCursorTransport implements CursorTransport {
979
1160
  framesReceived: this.framesReceived,
980
1161
  elapsedMs: Date.now() - this.turnStartedAt,
981
1162
  } : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt });
982
- if (endError) failAndClear(endError);
1163
+ if (endError) {
1164
+ failAndClear(endError);
1165
+ return;
1166
+ }
1167
+ // Connect's clean END_STREAM envelope is the protocol terminal. Cursor's RunSSE body can
1168
+ // remain open after this frame (or close through an AbortError), so waiting for HTTP EOF
1169
+ // strands an otherwise completed turn until the outer bridge stall watchdog fires.
1170
+ //
1171
+ // Earlier frames in this serialized frameWork chain have already run. Preserve their real
1172
+ // turnEnded terminal when present; otherwise finalize the clean protocol end once so open
1173
+ // tool calls still fail closed, a text-only turn receives its normal done event, and a
1174
+ // drained client-tool turn does not lose the pending terminal when protocol cleanup clears
1175
+ // its grace timer.
1176
+ const hasPendingClientToolFinalization = this.pendingFinalize !== undefined;
1177
+ if (
1178
+ !this.expectedClose
1179
+ && !state.terminated
1180
+ && !this.emittedTerminal
1181
+ && (
1182
+ state.openToolCalls.size > 0
1183
+ || this.sawAssistantText
1184
+ || hasPendingClientToolFinalization
1185
+ )
1186
+ ) {
1187
+ const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0
1188
+ ? finalizeAfterDrain(state)
1189
+ : finalizeTurnEvents(state);
1190
+ for (const event of terminal) push(event);
1191
+ }
1192
+ this.markProtocolComplete();
1193
+ releaseBacklogLease();
1194
+ settler.settleFinish();
983
1195
  return;
984
1196
  }
985
- await this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push);
1197
+ const decoded = fromBinary(AgentServerMessageSchema, frame.payload);
1198
+ // T04: every decoded frame refreshes the silence clock; only non-liveness frames
1199
+ // refresh the progress clock. First decoded frame arms the watchdog (the first-frame
1200
+ // timer owned everything before this point).
1201
+ const decodedUpdate = decoded.message.case === "interactionUpdate" ? decoded.message.value.message?.case : undefined;
1202
+ const livenessOnly = decodedUpdate === "heartbeat" || decoded.message.case === "conversationCheckpointUpdate";
1203
+ if (!this.streamHealthFail) {
1204
+ const now = Date.now();
1205
+ this.lastInboundFrameAt = now;
1206
+ this.lastMeaningfulFrameAt = now;
1207
+ this.streamHealthFail = failAndClear;
1208
+ }
1209
+ this.noteInboundFrame(livenessOnly);
1210
+ await this.handleServerMessage(decoded, state, push);
986
1211
  };
987
1212
  const drainPendingFrames = () => {
988
1213
  const availableSlots = CURSOR_MAX_PENDING_FRAMES - this.pendingTransportFrames;
@@ -1252,6 +1477,12 @@ class LiveCursorTransport implements CursorTransport {
1252
1477
  // A completion may carry only callId. Capture its ownership before mapping removes the open
1253
1478
  // call, because the embedded-tool classifier cannot identify that valid compact frame.
1254
1479
  const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined;
1480
+ if (update?.case === "turnEnded") {
1481
+ // T03: the application turn is complete. Close our side of HTTP/2 after a short
1482
+ // grace so a held-open server response cannot pin the turn to the bridge's idle
1483
+ // timeout (senpi #1062). finalizeTurnEvents already emitted done via the mapper.
1484
+ this.closeAfterTurnEnded();
1485
+ }
1255
1486
  const completesOpenClientTool = update?.case === "toolCallCompleted"
1256
1487
  && state.openToolCalls.has(update.value.callId);
1257
1488
  const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
@@ -1,6 +1,7 @@
1
1
  import { create, toBinary } from "@bufbuild/protobuf";
2
2
  import {
3
3
  AgentClientMessageSchema,
4
+ ExecClientThrowSchema,
4
5
  ExecClientControlMessageSchema,
5
6
  ExecClientMessageSchema,
6
7
  ExecClientStreamCloseSchema,
@@ -49,6 +50,22 @@ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array {
49
50
  });
50
51
  }
51
52
 
53
+ /**
54
+ * Exec-channel typed throw (`execClientControlMessage.throw`). senpi's contract (T05):
55
+ * a frame that cannot be answered at all must get an explicit error reply + stream-close
56
+ * so the server unblocks with a known failure, instead of waiting forever on silence.
57
+ */
58
+ export function execThrowBytes(execMsg: ExecServerMessage, error: string): Uint8Array {
59
+ return clientBytes({
60
+ message: {
61
+ case: "execClientControlMessage",
62
+ value: create(ExecClientControlMessageSchema, {
63
+ message: { case: "throw", value: create(ExecClientThrowSchema, { id: execMsg.id, error }) },
64
+ }),
65
+ },
66
+ });
67
+ }
68
+
52
69
  export function errorText(err: unknown): string {
53
70
  return err instanceof Error ? err.message : String(err);
54
71
  }
@@ -50,7 +50,7 @@ import {
50
50
  recordScreenExec,
51
51
  type CursorNativeToolDeps,
52
52
  } from "./native-exec-tools";
53
- import { clientBytes, execBytes } from "./native-exec-common";
53
+ import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common";
54
54
  import type { McpToolDefinition } from "./gen/agent_pb";
55
55
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
56
56
 
@@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
603
603
  }))];
604
604
  }
605
605
  // Unknown exec case — Cursor added a new native exec type that our protobuf definition does not
606
- // include yet. Return an empty reply so the stream stays alive instead of throwing (which kills
607
- // the entire gRPC connection via failAndClear). Same class of bug as #116.
606
+ // include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server
607
+ // unblocks with a known failure. Previously this returned an empty reply (silence), which is
608
+ // the stall class senpi explicitly refused (#116 was about throwing into failAndClear and
609
+ // killing the whole connection; a typed in-band throw does not do that).
608
610
  debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId });
609
- return [];
611
+ return [
612
+ execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."),
613
+ execStreamCloseBytes(execMsg),
614
+ ];
610
615
  }
611
616
 
612
617
 
@@ -11,6 +11,7 @@ import {
11
11
  isCodexShellBridgeToolName,
12
12
  isCursorStructuredEditToolName,
13
13
  normalizeCursorWireName,
14
+ normalizeCursorTextToolMarkers,
14
15
  OCX_RESPONSES_TOOL_PROVIDER,
15
16
  resolveShellBridgeAliasKey,
16
17
  responsesToolNameFromCursorWire,
@@ -1243,7 +1244,10 @@ export function mapCursorProtobufServerMessage(
1243
1244
  const update = serverMessage.message.value.message;
1244
1245
  switch (update.case) {
1245
1246
  case "textDelta":
1246
- return update.value.text ? [{ type: "text", text: update.value.text }] : [];
1247
+ // #2305: fold Cursor display aliases inside textual pseudo tool-call markers back to
1248
+ // the advertised wire name before any client sees the text. Real frames are already
1249
+ // normalized structurally (mcpWireNameFromArgs above).
1250
+ return update.value.text ? [{ type: "text", text: normalizeCursorTextToolMarkers(update.value.text) }] : [];
1247
1251
  case "thinkingDelta":
1248
1252
  return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : [];
1249
1253
  case "toolCallStarted": {
@@ -15,6 +15,7 @@ import {
15
15
  storeCursorBlob,
16
16
  type CursorBlobRequestScopeToken,
17
17
  } from "./native-exec";
18
+ import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images";
18
19
  import { estimateTokens } from "../../lib/token-estimate";
19
20
  import { parseDataUrl } from "../image";
20
21
  import {
@@ -187,10 +188,12 @@ function assistantRootText(
187
188
  }
188
189
 
189
190
  // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata),
190
- // so prior history including assistant tool calls and tool results must be replayed here or a
191
- // ResumeAction has nothing model-visible to continue from. The active user message is excluded
192
- // because it travels in the action. Tool results are assistant-role text with a [Tool Result]
193
- // or [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Each entry is a SHA-256 blob ID.
191
+ // so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from.
192
+ // The active user message is excluded because it travels in the action. When the continuation cannot
193
+ // rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] /
194
+ // [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Native resume models
195
+ // already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto
196
+ // few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID.
194
197
  function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): {
195
198
  ids: Uint8Array[];
196
199
  byteLength: number;
@@ -211,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
211
214
  }
212
215
 
213
216
  const externalModel = isCursorExternalWireModel(request.modelId);
217
+ const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
214
218
  const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
215
219
  const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
216
220
 
@@ -219,7 +223,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
219
223
  const message = messages[i];
220
224
  if (!message) continue;
221
225
  if (message.role === "user" || message.role === "developer") {
222
- const text = contentText(message).trim();
226
+ const text = historyContentText(message).trim();
223
227
  // Cursor root replay expects OpenAI-style content parts for historical user messages.
224
228
  // A bare string survives blob hydration but external workers reject the completed replay
225
229
  // before tokenization (`usedTokens: 0`, then invalid_argument).
@@ -242,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
242
246
  }
243
247
  // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
244
248
  } else if (message.role === "toolResult") {
249
+ // Native resume models already receive the paired MCP result through turns[]. Replaying
250
+ // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto
251
+ // to echo that envelope as chat instead of continuing from the structured result.
252
+ if (!echoToolResultInRoot) continue;
245
253
  // #1920: the prefix must reflect the NORMALIZED error state (an empty
246
254
  // node_repl result is an error even when the runtime said isError=false).
247
255
  const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
@@ -336,7 +344,7 @@ function contentText(message: OcxMessage): string {
336
344
  .map(part => {
337
345
  if (part.type === "text") return part.text;
338
346
  if (part.type === "thinking") return part.thinking;
339
- if (part.type === "image") return `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`;
347
+ if (part.type === "image") return undefined;
340
348
  return undefined;
341
349
  })
342
350
  .filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -346,7 +354,26 @@ function contentText(message: OcxMessage): string {
346
354
  function contentToText(content: OcxToolResultMessage["content"]): string {
347
355
  if (typeof content === "string") return content;
348
356
  return content
349
- .map(part => part.type === "text" ? part.text : `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`)
357
+ .map(part => {
358
+ if (part.type === "text") return part.text;
359
+ if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER;
360
+ return undefined;
361
+ })
362
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
363
+ .join("\n");
364
+ }
365
+
366
+ /** History serializer. Replayed turns are text-only; never embed image bytes. */
367
+ function historyContentText(message: OcxMessage): string {
368
+ if (message.role === "toolResult" || typeof message.content === "string") return contentText(message);
369
+ return message.content
370
+ .map(part => {
371
+ if (part.type === "text") return part.text;
372
+ if (part.type === "thinking") return part.thinking;
373
+ if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER;
374
+ return undefined;
375
+ })
376
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
350
377
  .join("\n");
351
378
  }
352
379
 
@@ -721,8 +748,10 @@ function conversationTurns(
721
748
  flush();
722
749
  current = {
723
750
  userMessage: storeCursorBlob(toBinary(UserMessageSchema, create(UserMessageSchema, {
724
- text: contentText(message),
751
+ text: historyContentText(message),
725
752
  messageId: crypto.randomUUID(),
753
+ selectedContext: buildSelectedContext([], requestScope),
754
+ mode: 1,
726
755
  })), requestScope),
727
756
  steps: [],
728
757
  };
@@ -792,6 +821,7 @@ function buildPreparedCursorRunRequest(
792
821
  ? appendCursorGenericToolUseHint(request.tools, rawText)
793
822
  : rawText;
794
823
  const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
824
+ const selectedImages = request.selectedImages ?? [];
795
825
  // Native models resume the remembered Cursor conversation. External wire
796
826
  // models continue as userMessageAction so history-blob tool results stay
797
827
  // visible without a ResumeAction. Some native composer ids are also routed
@@ -799,7 +829,11 @@ function buildPreparedCursorRunRequest(
799
829
  // because a bare resumeAction makes them continue exploring with native tools
800
830
  // instead of answering (observed on composer-2.5; see discovery.ts).
801
831
  const externalToolContinuation = lastRawIsToolResult && cursorNeedsExternalToolContinuation(request.modelId);
802
- const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0))
832
+ // Image-only active turns (including soft-omitted images) stay userMessageAction.
833
+ const actionCase = (
834
+ externalToolContinuation
835
+ || (!lastRawIsToolResult && (text.trim().length > 0 || selectedImages.length > 0))
836
+ )
803
837
  ? "userMessageAction"
804
838
  : "resumeAction";
805
839
  const actionText = externalToolContinuation
@@ -813,6 +847,9 @@ function buildPreparedCursorRunRequest(
813
847
  userMessage: create(UserMessageSchema, {
814
848
  text: actionText,
815
849
  messageId: crypto.randomUUID(),
850
+ selectedContext: buildSelectedContext(selectedImages, requestScope),
851
+ // OmniRoute / cursor-agent always send mode=1 on UserMessage.
852
+ mode: 1,
816
853
  }),
817
854
  requestContext: buildRequestContext(),
818
855
  }),