@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.
- package/README.md +5 -5
- package/gui/dist/assets/index-DyWYnr-t.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +29 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +239 -8
- package/src/adapters/cursor/native-exec-common.ts +17 -0
- package/src/adapters/cursor/native-exec.ts +9 -4
- package/src/adapters/cursor/protobuf-events.ts +5 -1
- package/src/adapters/cursor/protobuf-request.ts +46 -9
- package/src/adapters/cursor/request-builder.ts +29 -14
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor/types.ts +8 -1
- package/src/adapters/cursor.ts +23 -5
- package/src/adapters/google.ts +16 -3
- package/src/adapters/openai-responses.ts +66 -20
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/cli/agent.ts +2 -1
- package/src/cli/dispatch.ts +2 -2
- package/src/cli/doctor.ts +89 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/registry.ts +7 -2
- package/src/codex/auth-context.ts +41 -2
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +20 -5
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/features.ts +58 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +86 -58
- package/src/lib/bun-stream-caps.ts +7 -4
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/command-code-efforts.ts +7 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +3 -0
- package/src/providers/fastwire.ts +12 -1
- package/src/providers/openai-sidecar.ts +1 -0
- package/src/providers/quota.ts +98 -25
- package/src/providers/registry.ts +115 -10
- package/src/providers/service-tier.ts +22 -7
- package/src/responses/custom-tool-compat.ts +24 -8
- package/src/responses/namespace-tool-compat.ts +2 -3
- package/src/router.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/chat-native.ts +20 -0
- package/src/server/management/agent-settings-routes.ts +16 -5
- package/src/server/management/config-routes.ts +25 -5
- package/src/server/management/vision-sidecar-options.ts +54 -19
- package/src/server/responses/compact.ts +1 -2
- package/src/server/responses/core.ts +54 -13
- package/src/service.ts +122 -14
- package/src/types/config.ts +9 -3
- package/src/types/provider.ts +6 -0
- package/src/usage/cost.ts +52 -38
- package/src/usage/expected-prices.ts +79 -9
- package/src/vision/backends.ts +97 -0
- package/src/vision/eligibility.ts +43 -22
- package/src/vision/index.ts +73 -5
- package/src/vision/routed-describe.ts +175 -0
- 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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
req =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const
|
|
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(
|
|
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: () =>
|
|
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)
|
|
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
|
-
|
|
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.
|
|
607
|
-
//
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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 =
|
|
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
|
|
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 =>
|
|
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:
|
|
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
|
-
|
|
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
|
}),
|