@bitkyc08/opencodex 2.6.17 → 2.6.18
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 +9 -0
- package/bin/ocx.mjs +70 -5
- package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
- package/gui/dist/assets/index-DbTEyo46.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +9 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +88 -0
- package/src/adapters/cursor/cursor-errors.ts +85 -0
- package/src/adapters/cursor/discovery.ts +144 -0
- package/src/adapters/cursor/effort-map.ts +74 -0
- package/src/adapters/cursor/exec-policy.ts +44 -0
- package/src/adapters/cursor/framing.ts +136 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +25 -0
- package/src/adapters/cursor/live-models.ts +93 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +758 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +236 -0
- package/src/adapters/cursor/message-mapper.ts +46 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +177 -0
- package/src/adapters/cursor/native-exec-fs.ts +284 -0
- package/src/adapters/cursor/native-exec-mcp.ts +151 -0
- package/src/adapters/cursor/native-exec-network.ts +32 -0
- package/src/adapters/cursor/native-exec-shell.ts +191 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +177 -0
- package/src/adapters/cursor/protobuf-events.ts +309 -0
- package/src/adapters/cursor/protobuf-request.ts +347 -0
- package/src/adapters/cursor/request-builder.ts +98 -0
- package/src/adapters/cursor/tool-definitions.ts +301 -0
- package/src/adapters/cursor/transport-retry.ts +116 -0
- package/src/adapters/cursor/transport.ts +47 -0
- package/src/adapters/cursor/types.ts +36 -0
- package/src/adapters/cursor.ts +99 -0
- package/src/adapters/google.ts +7 -1
- package/src/adapters/kiro.ts +15 -0
- package/src/adapters/openai-chat.ts +7 -2
- package/src/adapters/run-turn-queue.ts +58 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/bridge.ts +7 -1
- package/src/cli-help.ts +9 -2
- package/src/cli-status.ts +7 -5
- package/src/cli.ts +122 -79
- package/src/codex-catalog.ts +213 -71
- package/src/codex-history-provider.ts +31 -14
- package/src/codex-inject.ts +17 -9
- package/src/codex-paths.ts +2 -1
- package/src/codex-shim.ts +30 -7
- package/src/codex-sync.ts +70 -0
- package/src/config.ts +58 -2
- package/src/doctor.ts +4 -2
- package/src/index.ts +1 -0
- package/src/model-cache.ts +22 -2
- package/src/oauth/callback-server.ts +44 -16
- package/src/oauth/cursor.ts +188 -0
- package/src/oauth/index.ts +29 -3
- package/src/oauth/key-providers.ts +20 -33
- package/src/oauth/login-cli.ts +7 -4
- package/src/open-url.ts +5 -1
- package/src/ports.ts +13 -0
- package/src/process-control.ts +76 -0
- package/src/provider-label.ts +10 -5
- package/src/providers/derive.ts +30 -3
- package/src/providers/registry.ts +39 -1
- package/src/proxy-liveness.ts +122 -0
- package/src/responses/parser.ts +1 -0
- package/src/responses/state.ts +83 -0
- package/src/router.ts +38 -23
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server.ts +130 -18
- package/src/service.ts +94 -32
- package/src/types.ts +24 -1
- package/src/update-job.ts +360 -0
- package/src/update.ts +73 -11
- package/src/usage-log.ts +3 -3
- package/src/usage-summary.ts +3 -2
- package/src/win-paths.ts +68 -0
- package/gui/dist/assets/index-DIBiVVC0.css +0 -1
- package/gui/dist/assets/index-DcnD944i.js +0 -9
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import http2 from "node:http2";
|
|
2
|
+
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
3
|
+
import { namespacedToolName, type OcxProviderConfig, type OcxUsage } from "../../types";
|
|
4
|
+
import { CONNECT_FLAG_END_STREAM, decodeAvailableConnectFrames, encodeConnectFrame } from "./framing";
|
|
5
|
+
import { activePromptText, encodeCursorRunRequest } from "./protobuf-request";
|
|
6
|
+
import { createCursorProtobufEventState, finalizeTurnEvents, mapCursorProtobufServerMessage, mapSyntheticMcpExecToToolEvents } from "./protobuf-events";
|
|
7
|
+
import {
|
|
8
|
+
AgentClientMessageSchema,
|
|
9
|
+
AgentServerMessageSchema,
|
|
10
|
+
AskQuestionInteractionResponseSchema,
|
|
11
|
+
AskQuestionRejectedSchema,
|
|
12
|
+
AskQuestionResultSchema,
|
|
13
|
+
ClientHeartbeatSchema,
|
|
14
|
+
CreatePlanRequestResponseSchema,
|
|
15
|
+
CreatePlanResultSchema,
|
|
16
|
+
CreatePlanSuccessSchema,
|
|
17
|
+
ExaFetchRequestResponseSchema,
|
|
18
|
+
ExaFetchRequestResponse_RejectedSchema,
|
|
19
|
+
ExaSearchRequestResponseSchema,
|
|
20
|
+
ExaSearchRequestResponse_RejectedSchema,
|
|
21
|
+
InteractionResponseSchema,
|
|
22
|
+
SetupVmEnvironmentResultSchema,
|
|
23
|
+
SetupVmEnvironmentSuccessSchema,
|
|
24
|
+
SwitchModeRequestResponseSchema,
|
|
25
|
+
SwitchModeRequestResponse_RejectedSchema,
|
|
26
|
+
WebSearchRequestResponseSchema,
|
|
27
|
+
WebSearchRequestResponse_RejectedSchema,
|
|
28
|
+
type AgentServerMessage,
|
|
29
|
+
type ExecServerMessage,
|
|
30
|
+
type InteractionQuery,
|
|
31
|
+
type InteractionResponse,
|
|
32
|
+
} from "./gen/agent_pb";
|
|
33
|
+
import { debugProviderDiagnostic } from "../../debug";
|
|
34
|
+
import { mcpArgsFromToolCall } from "./protobuf-events";
|
|
35
|
+
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
36
|
+
import { handleCursorNativeExec, handleCursorNativeKv, type CursorNativeExecContext } from "./native-exec";
|
|
37
|
+
import { resolveMcpServers } from "./mcp-config";
|
|
38
|
+
import { CursorMcpManager } from "./mcp-manager";
|
|
39
|
+
import { buildMcpToolDefinitions, mcpDepsFromManager } from "./native-exec-mcp";
|
|
40
|
+
import { desktopDepsFromConfig } from "./native-exec-desktop";
|
|
41
|
+
import {
|
|
42
|
+
buildCursorToolDefinitions,
|
|
43
|
+
cursorRequestAdvertisesApplyPatch,
|
|
44
|
+
cursorRequestHasShellAlias,
|
|
45
|
+
cursorToolInputSchema,
|
|
46
|
+
cursorToolWireName,
|
|
47
|
+
cursorToolsForActivePrompt,
|
|
48
|
+
isGenericToolUseCountDemoPrompt,
|
|
49
|
+
requestedCursorToolUseCount,
|
|
50
|
+
} from "./tool-definitions";
|
|
51
|
+
import type { CursorNativeToolDeps } from "./native-exec-tools";
|
|
52
|
+
import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "./types";
|
|
53
|
+
import type { CursorTransport, CursorTransportFactoryInput } from "./transport";
|
|
54
|
+
|
|
55
|
+
const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
|
|
56
|
+
const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
57
|
+
const HEARTBEAT_MS = 5_000;
|
|
58
|
+
const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000;
|
|
59
|
+
const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
|
|
60
|
+
const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750;
|
|
61
|
+
const GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS = 1_800;
|
|
62
|
+
const GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS = 125;
|
|
63
|
+
|
|
64
|
+
export class CursorMissingCredentialError extends Error {
|
|
65
|
+
readonly code = "cursor_missing_credential";
|
|
66
|
+
|
|
67
|
+
constructor() {
|
|
68
|
+
super("Cursor live transport requires a Cursor access token in provider.apiKey, Authorization, or OPENCODEX_CURSOR_TEST_TOKEN.");
|
|
69
|
+
this.name = "CursorMissingCredentialError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveCursorToken(provider: OcxProviderConfig, headers?: Headers): string {
|
|
74
|
+
const providerKey = provider.apiKey?.trim();
|
|
75
|
+
if (providerKey) return providerKey;
|
|
76
|
+
|
|
77
|
+
const forwarded = headers?.get("authorization") ?? headers?.get("Authorization");
|
|
78
|
+
if (forwarded?.toLowerCase().startsWith("bearer ")) return forwarded.slice("bearer ".length).trim();
|
|
79
|
+
|
|
80
|
+
const envToken = process.env.OPENCODEX_CURSOR_TEST_TOKEN?.trim();
|
|
81
|
+
if (envToken) return envToken;
|
|
82
|
+
throw new CursorMissingCredentialError();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a Connect end-stream (trailer) frame. Cursor terminates EVERY stream with this
|
|
87
|
+
* frame; success is signalled by the ABSENCE of an `error` field (typically `{}`), not by the
|
|
88
|
+
* absence of the frame. Returns null on success, an Error only on a real Connect error.
|
|
89
|
+
* Mirrors jawcode `parseConnectEndStream` (see devlog 350.98). Exported for unit testing.
|
|
90
|
+
*/
|
|
91
|
+
export function parseConnectEndStreamError(payload: Uint8Array): Error | null {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(new TextDecoder().decode(payload)) as { error?: { code?: string; message?: string } };
|
|
94
|
+
if (parsed?.error) {
|
|
95
|
+
return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${parsed.error.message ?? "Unknown error"}`);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
} catch {
|
|
99
|
+
return new Error("Cursor Connect end-stream error");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function encodeClientMessage(message: Parameters<typeof create<typeof AgentClientMessageSchema>>[1]): Uint8Array {
|
|
104
|
+
return encodeConnectFrame(toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, message)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decide how to handle an `execServerMessage.mcpArgs` frame for a client (Responses-provider) tool.
|
|
109
|
+
*
|
|
110
|
+
* A stateless Responses proxy cannot send Cursor a real `mcpResult` later (Cursor's MCP exec is
|
|
111
|
+
* synchronous on the live h2 stream; there is no deferred-result signal). So when Cursor asks us to
|
|
112
|
+
* run a client Responses tool we must:
|
|
113
|
+
* 1. surface the tool call to Codex (tool_call_start/delta/end),
|
|
114
|
+
* 2. deliberately END turn 1 as `done`/completed — Cursor will never send `turnEnded` because it
|
|
115
|
+
* is waiting for an `mcpResult` that never comes, so relying on the stall watchdog would make
|
|
116
|
+
* turn 1 `response.incomplete` and drop the conversation id (continuation dies at step 1), and
|
|
117
|
+
* 3. cancel the Cursor run WITHOUT writing any fake `mcpResult`.
|
|
118
|
+
* The real tool result arrives on the NEXT /v1/responses request as structured history.
|
|
119
|
+
*
|
|
120
|
+
* Pure (no I/O) so the decision is unit-testable. `handleServerMessage` performs the side effects.
|
|
121
|
+
*/
|
|
122
|
+
export interface McpArgsPlan {
|
|
123
|
+
handledByResponsesBridge: boolean;
|
|
124
|
+
events: CursorServerMessage[];
|
|
125
|
+
cancelCursorRun: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* The Responses bridge owns this exec and every known client tool call is committed, but turn 1 is
|
|
128
|
+
* NOT ended synchronously: a sibling call may still be announced in a later receive chunk. The
|
|
129
|
+
* transport arms a revocable grace timer and only ends the turn (see finalizeAfterDrain) if the set
|
|
130
|
+
* is still drained when it fires.
|
|
131
|
+
*/
|
|
132
|
+
finalizeWhenDrained: boolean;
|
|
133
|
+
writeMcpResult?: never;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function planMcpArgsHandling(
|
|
137
|
+
execMsg: ExecServerMessage,
|
|
138
|
+
state: ReturnType<typeof createCursorProtobufEventState>,
|
|
139
|
+
): McpArgsPlan {
|
|
140
|
+
if (execMsg.message.case !== "mcpArgs") {
|
|
141
|
+
return { handledByResponsesBridge: false, events: [], cancelCursorRun: false, finalizeWhenDrained: false };
|
|
142
|
+
}
|
|
143
|
+
const args = execMsg.message.value;
|
|
144
|
+
if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) {
|
|
145
|
+
// A real MCP server tool: native exec handles it (executed locally, real mcpResult written).
|
|
146
|
+
return { handledByResponsesBridge: false, events: [], cancelCursorRun: false, finalizeWhenDrained: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// From here on the Responses bridge owns the exec: never fall through to native exec, which would
|
|
150
|
+
// send Cursor a bogus "bridge suspension not implemented" mcpResult error.
|
|
151
|
+
const toolEvents = mapSyntheticMcpExecToToolEvents(args, `exec_${execMsg.id}`, {
|
|
152
|
+
allowEmptyArgs: true,
|
|
153
|
+
state,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (toolEvents.some(event => event.type === "error")) {
|
|
157
|
+
// The error is itself the terminal signal; do not also emit `done`.
|
|
158
|
+
return { handledByResponsesBridge: true, events: toolEvents, cancelCursorRun: true, finalizeWhenDrained: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parallel safety ("tool use N"): Cursor sends one exec mcpArgs per client tool call. An empty
|
|
162
|
+
// openToolCalls set proves only that every KNOWN call is committed, not that Cursor has finished
|
|
163
|
+
// announcing siblings — a sibling's toolCallStarted can still arrive in a later receive chunk. So
|
|
164
|
+
// never end turn 1 synchronously here: surface this call's events, and when the set is drained flag
|
|
165
|
+
// finalizeWhenDrained so the transport arms a revocable grace timer (finalizeAfterDrain re-checks
|
|
166
|
+
// the guard when it fires). While siblings are still open, just keep the stream open.
|
|
167
|
+
return {
|
|
168
|
+
handledByResponsesBridge: true,
|
|
169
|
+
events: toolEvents,
|
|
170
|
+
cancelCursorRun: false,
|
|
171
|
+
finalizeWhenDrained: state.openToolCalls.size === 0,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the `interactionResponse` reply for a server `interactionQuery`. Cursor's server-side agent
|
|
177
|
+
* BLOCKS on these queries until the client answers (matching `id`); an unanswered query is the
|
|
178
|
+
* proven cause of the heartbeat-only stall → watchdog `upstream_stall_timeout` → upstream 502 loop
|
|
179
|
+
* (devlog 260702_cursor-live-stability-rca). ocx is a headless non-interactive client, so:
|
|
180
|
+
* - createPlan: acknowledge success (the agent proceeds to execute); the plan text is surfaced to
|
|
181
|
+
* Codex as visible output so the user still sees it.
|
|
182
|
+
* - askQuestion: reject with a reason — the agent must proceed autonomously; there is no human to
|
|
183
|
+
* answer mid-turn. (Future: bridge to a Codex user-input request.)
|
|
184
|
+
* - switchMode / webSearch / exaSearch / exaFetch: reject (deterministic default; web search has
|
|
185
|
+
* its own sidecar path outside this transport).
|
|
186
|
+
* - setupVmEnvironment: the result schema has no error case — reply success so the agent is not
|
|
187
|
+
* left waiting; the command itself was never run locally.
|
|
188
|
+
* Pure (no I/O) for unit testing; `handleServerMessage` writes the frame and emits liveness.
|
|
189
|
+
*/
|
|
190
|
+
export function planInteractionQueryReply(query: InteractionQuery): { response: InteractionResponse; replyCase: string; planText?: string } {
|
|
191
|
+
const NON_INTERACTIVE_REASON = "opencodex bridge is non-interactive; proceed without this interaction.";
|
|
192
|
+
const q = query.query;
|
|
193
|
+
const respond = (result: InteractionResponse["result"]): InteractionResponse =>
|
|
194
|
+
create(InteractionResponseSchema, { id: query.id, result });
|
|
195
|
+
|
|
196
|
+
if (q.case === "createPlanRequestQuery") {
|
|
197
|
+
const args = q.value.args;
|
|
198
|
+
const parts = [
|
|
199
|
+
args?.name ? `Plan: ${args.name}` : undefined,
|
|
200
|
+
args?.overview?.trim() ? args.overview.trim() : undefined,
|
|
201
|
+
args?.plan?.trim() ? args.plan.trim() : undefined,
|
|
202
|
+
].filter((part): part is string => typeof part === "string" && part.length > 0);
|
|
203
|
+
return {
|
|
204
|
+
response: respond({
|
|
205
|
+
case: "createPlanRequestResponse",
|
|
206
|
+
value: create(CreatePlanRequestResponseSchema, {
|
|
207
|
+
result: create(CreatePlanResultSchema, { result: { case: "success", value: create(CreatePlanSuccessSchema, {}) } }),
|
|
208
|
+
}),
|
|
209
|
+
}),
|
|
210
|
+
replyCase: "createPlanRequestResponse:success",
|
|
211
|
+
planText: parts.length > 0 ? `${parts.join("\n\n")}\n` : undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (q.case === "askQuestionInteractionQuery") {
|
|
215
|
+
return {
|
|
216
|
+
response: respond({
|
|
217
|
+
case: "askQuestionInteractionResponse",
|
|
218
|
+
value: create(AskQuestionInteractionResponseSchema, {
|
|
219
|
+
result: create(AskQuestionResultSchema, {
|
|
220
|
+
result: { case: "rejected", value: create(AskQuestionRejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
|
|
221
|
+
}),
|
|
222
|
+
}),
|
|
223
|
+
}),
|
|
224
|
+
replyCase: "askQuestionInteractionResponse:rejected",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (q.case === "switchModeRequestQuery") {
|
|
228
|
+
return {
|
|
229
|
+
response: respond({
|
|
230
|
+
case: "switchModeRequestResponse",
|
|
231
|
+
value: create(SwitchModeRequestResponseSchema, {
|
|
232
|
+
result: { case: "rejected", value: create(SwitchModeRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
|
|
233
|
+
}),
|
|
234
|
+
}),
|
|
235
|
+
replyCase: "switchModeRequestResponse:rejected",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (q.case === "webSearchRequestQuery") {
|
|
239
|
+
return {
|
|
240
|
+
response: respond({
|
|
241
|
+
case: "webSearchRequestResponse",
|
|
242
|
+
value: create(WebSearchRequestResponseSchema, {
|
|
243
|
+
result: { case: "rejected", value: create(WebSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
|
|
244
|
+
}),
|
|
245
|
+
}),
|
|
246
|
+
replyCase: "webSearchRequestResponse:rejected",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (q.case === "exaSearchRequestQuery") {
|
|
250
|
+
return {
|
|
251
|
+
response: respond({
|
|
252
|
+
case: "exaSearchRequestResponse",
|
|
253
|
+
value: create(ExaSearchRequestResponseSchema, {
|
|
254
|
+
result: { case: "rejected", value: create(ExaSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
|
|
255
|
+
}),
|
|
256
|
+
}),
|
|
257
|
+
replyCase: "exaSearchRequestResponse:rejected",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (q.case === "exaFetchRequestQuery") {
|
|
261
|
+
return {
|
|
262
|
+
response: respond({
|
|
263
|
+
case: "exaFetchRequestResponse",
|
|
264
|
+
value: create(ExaFetchRequestResponseSchema, {
|
|
265
|
+
result: { case: "rejected", value: create(ExaFetchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
|
|
266
|
+
}),
|
|
267
|
+
}),
|
|
268
|
+
replyCase: "exaFetchRequestResponse:rejected",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (q.case === "setupVmEnvironmentArgs") {
|
|
272
|
+
return {
|
|
273
|
+
response: respond({
|
|
274
|
+
case: "setupVmEnvironmentResult",
|
|
275
|
+
value: create(SetupVmEnvironmentResultSchema, {
|
|
276
|
+
result: { case: "success", value: create(SetupVmEnvironmentSuccessSchema, {}) },
|
|
277
|
+
}),
|
|
278
|
+
}),
|
|
279
|
+
replyCase: "setupVmEnvironmentResult:success",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Unknown/future query case: an empty response still unblocks the matching id.
|
|
283
|
+
return { response: respond({ case: undefined, value: undefined } as InteractionResponse["result"]), replyCase: "empty" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Re-check the drain guard at grace-timer fire time and finalize turn 1 only if still drained. A
|
|
288
|
+
* sibling client tool call announced after the timer was armed reopens `openToolCalls`, so this
|
|
289
|
+
* returns `[]` (the pending finalize is revoked); a later drain re-arms it. Pure for unit testing.
|
|
290
|
+
*/
|
|
291
|
+
export function finalizeAfterDrain(state: ReturnType<typeof createCursorProtobufEventState>): CursorServerMessage[] {
|
|
292
|
+
if (state.terminated) return [];
|
|
293
|
+
if (state.openToolCalls.size > 0) return [];
|
|
294
|
+
return finalizeTurnEvents(state);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function clientToolFinalizeGraceMsForRequest(request: CursorRunRequest, baseGraceMs = CLIENT_TOOL_FINALIZE_GRACE_MS): number {
|
|
298
|
+
if (request.rawMessages?.at(-1)?.role === "toolResult") return baseGraceMs;
|
|
299
|
+
const text = activePromptText(request);
|
|
300
|
+
if (!cursorRequestHasShellAlias(request.tools) || !isGenericToolUseCountDemoPrompt(text)) return baseGraceMs;
|
|
301
|
+
const requestedCount = requestedCursorToolUseCount(text);
|
|
302
|
+
const expandedGraceMs = requestedCount
|
|
303
|
+
? Math.min(
|
|
304
|
+
GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS,
|
|
305
|
+
Math.max(GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS, requestedCount * GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS),
|
|
306
|
+
)
|
|
307
|
+
: GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS;
|
|
308
|
+
return Math.max(baseGraceMs, expandedGraceMs);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
class LiveCursorTransport implements CursorTransport {
|
|
312
|
+
private session?: http2.ClientHttp2Session;
|
|
313
|
+
private stream?: http2.ClientHttp2Stream;
|
|
314
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
315
|
+
private firstFrameTimer?: ReturnType<typeof setTimeout>;
|
|
316
|
+
private committed = false;
|
|
317
|
+
private expectedClose = false;
|
|
318
|
+
private pendingFinalize?: ReturnType<typeof setTimeout>;
|
|
319
|
+
private readonly clientToolFinalizeGraceMs: number;
|
|
320
|
+
private activeClientToolFinalizeGraceMs: number;
|
|
321
|
+
private readonly token: string;
|
|
322
|
+
private readonly mcpManager?: CursorMcpManager;
|
|
323
|
+
private readonly desktopDeps: CursorNativeToolDeps;
|
|
324
|
+
private execContext: CursorNativeExecContext = {};
|
|
325
|
+
private mcpPrepared?: Promise<void>;
|
|
326
|
+
|
|
327
|
+
constructor(private readonly input: CursorTransportFactoryInput) {
|
|
328
|
+
this.token = resolveCursorToken(input.provider, input.headers);
|
|
329
|
+
// Grace window before a drained client-tool turn is finalized. Small enough not to look like a
|
|
330
|
+
// stall, large enough to catch a sibling tool call announced in the next receive chunk. Injectable
|
|
331
|
+
// so the transport-level race test can drive it deterministically.
|
|
332
|
+
this.clientToolFinalizeGraceMs = input.clientToolFinalizeGraceMs ?? CLIENT_TOOL_FINALIZE_GRACE_MS;
|
|
333
|
+
this.activeClientToolFinalizeGraceMs = this.clientToolFinalizeGraceMs;
|
|
334
|
+
// Desktop (computer-use / record-screen) executors are available even with no MCP servers.
|
|
335
|
+
this.desktopDeps = desktopDepsFromConfig(input.provider.desktopExecutor);
|
|
336
|
+
this.execContext = { ...this.desktopDeps };
|
|
337
|
+
const servers = resolveMcpServers(input.provider);
|
|
338
|
+
if (servers.length > 0) {
|
|
339
|
+
this.mcpManager = new CursorMcpManager(servers, {
|
|
340
|
+
log: message => console.warn(message),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Connect MCP servers and compute the tool definitions advertised to the Cursor server.
|
|
347
|
+
* MUST complete before the first `requestContextArgs` (the server only calls MCP tools it was
|
|
348
|
+
* told about), so `run()` awaits this before opening the stream. Best-effort: any failure
|
|
349
|
+
* leaves an empty tool list and MCP disabled for the stream, never blocking the conversation.
|
|
350
|
+
*/
|
|
351
|
+
private prepareMcp(): Promise<void> {
|
|
352
|
+
if (!this.mcpManager) return Promise.resolve();
|
|
353
|
+
if (!this.mcpPrepared) {
|
|
354
|
+
this.mcpPrepared = (async () => {
|
|
355
|
+
try {
|
|
356
|
+
const mcpToolDefs = await buildMcpToolDefinitions(this.mcpManager!);
|
|
357
|
+
this.execContext = { ...this.desktopDeps, ...mcpDepsFromManager(this.mcpManager!), mcpToolDefs };
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.warn(`[cursor-mcp] preparation failed, MCP disabled for this stream: ${err instanceof Error ? err.message : String(err)}`);
|
|
360
|
+
this.execContext = { ...this.desktopDeps };
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
}
|
|
364
|
+
return this.mcpPrepared;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
toJSON(): Record<string, string> {
|
|
368
|
+
return { type: "LiveCursorTransport", credential: "redacted" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async *run(request: CursorRunRequest, signal?: AbortSignal): AsyncIterable<CursorServerMessage> {
|
|
372
|
+
const queue: CursorServerMessage[] = [];
|
|
373
|
+
let notify: (() => void) | undefined;
|
|
374
|
+
let done = false;
|
|
375
|
+
let failure: Error | undefined;
|
|
376
|
+
let state = createCursorProtobufEventState();
|
|
377
|
+
const wake = () => {
|
|
378
|
+
const fn = notify;
|
|
379
|
+
notify = undefined;
|
|
380
|
+
fn?.();
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const push = (message: CursorServerMessage) => {
|
|
384
|
+
queue.push(message);
|
|
385
|
+
wake();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Advertise MCP tools before the stream opens — the server only calls tools it was told about.
|
|
389
|
+
await this.prepareMcp();
|
|
390
|
+
const activeText = activePromptText(request);
|
|
391
|
+
this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs);
|
|
392
|
+
const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice);
|
|
393
|
+
const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice);
|
|
394
|
+
this.execContext = {
|
|
395
|
+
...this.execContext,
|
|
396
|
+
clientToolDefs,
|
|
397
|
+
rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
|
|
398
|
+
};
|
|
399
|
+
const toolSchemas = new Map<string, unknown>();
|
|
400
|
+
const cursorToolNameMap = new Map<string, string>();
|
|
401
|
+
for (const tool of cursorVisibleTools ?? []) {
|
|
402
|
+
const cursorWireName = cursorToolWireName(tool);
|
|
403
|
+
toolSchemas.set(cursorWireName, cursorToolInputSchema(tool));
|
|
404
|
+
cursorToolNameMap.set(cursorWireName, namespacedToolName(tool.namespace, tool.name));
|
|
405
|
+
}
|
|
406
|
+
state = createCursorProtobufEventState({
|
|
407
|
+
clientToolNames: clientToolDefs.map(tool => tool.toolName || tool.name),
|
|
408
|
+
parallelToolCalls: request.parallelToolCalls,
|
|
409
|
+
toolSchemas,
|
|
410
|
+
cursorToolNameMap,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
this.open(request, signal, state, push, err => {
|
|
414
|
+
failure = err;
|
|
415
|
+
wake();
|
|
416
|
+
}, () => {
|
|
417
|
+
done = true;
|
|
418
|
+
wake();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
while (!done || queue.length > 0) {
|
|
422
|
+
while (queue.length > 0) {
|
|
423
|
+
const message = queue.shift();
|
|
424
|
+
if (message) yield message;
|
|
425
|
+
}
|
|
426
|
+
if (failure) throw attachPartialUsage(failure, state);
|
|
427
|
+
if (done) break;
|
|
428
|
+
await new Promise<void>(resolve => {
|
|
429
|
+
notify = resolve;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (failure) throw attachPartialUsage(failure, state);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
writeClient(_message: CursorClientMessage): void {}
|
|
436
|
+
|
|
437
|
+
requestCommitted(): boolean {
|
|
438
|
+
return this.committed;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private clearFirstFrameTimer(): void {
|
|
442
|
+
if (this.firstFrameTimer) {
|
|
443
|
+
clearTimeout(this.firstFrameTimer);
|
|
444
|
+
this.firstFrameTimer = undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
close(): void {
|
|
449
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
450
|
+
this.clearPendingFinalize();
|
|
451
|
+
this.clearFirstFrameTimer();
|
|
452
|
+
this.stream?.close();
|
|
453
|
+
this.session?.close();
|
|
454
|
+
void this.mcpManager?.dispose();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private cancelCursorRun(): void {
|
|
458
|
+
this.expectedClose = true;
|
|
459
|
+
this.clearPendingFinalize();
|
|
460
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
461
|
+
this.clearFirstFrameTimer();
|
|
462
|
+
try {
|
|
463
|
+
this.stream?.close(http2.constants.NGHTTP2_CANCEL);
|
|
464
|
+
} catch {
|
|
465
|
+
this.stream?.destroy();
|
|
466
|
+
}
|
|
467
|
+
this.session?.close();
|
|
468
|
+
void this.mcpManager?.dispose();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private clearPendingFinalize(): void {
|
|
472
|
+
if (this.pendingFinalize) {
|
|
473
|
+
clearTimeout(this.pendingFinalize);
|
|
474
|
+
this.pendingFinalize = undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Any frame that records or commits a client tool call revokes a pending finalize: the call set is
|
|
480
|
+
* about to change, so the drain that armed the timer is no longer authoritative. The timer re-arms
|
|
481
|
+
* when the set drains again (see scheduleClientToolFinalize).
|
|
482
|
+
*/
|
|
483
|
+
private noteClientToolActivity(): void {
|
|
484
|
+
this.clearPendingFinalize();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Arm the revocable grace timer that ends a drained client-tool turn. On fire it re-checks the
|
|
489
|
+
* drain guard (finalizeAfterDrain): a sibling announced during the window reopened the set, so it
|
|
490
|
+
* emits nothing and waits for the next drain; otherwise it pushes the terminal `done` and cancels
|
|
491
|
+
* the Cursor run with RST_STREAM. No fake mcpResult is ever written.
|
|
492
|
+
*/
|
|
493
|
+
private scheduleClientToolFinalize(
|
|
494
|
+
state: ReturnType<typeof createCursorProtobufEventState>,
|
|
495
|
+
push: (message: CursorServerMessage) => void,
|
|
496
|
+
): void {
|
|
497
|
+
this.clearPendingFinalize();
|
|
498
|
+
this.pendingFinalize = setTimeout(() => {
|
|
499
|
+
this.pendingFinalize = undefined;
|
|
500
|
+
if (this.expectedClose) return;
|
|
501
|
+
const terminal = finalizeAfterDrain(state);
|
|
502
|
+
if (terminal.length === 0) return;
|
|
503
|
+
for (const event of terminal) push(event);
|
|
504
|
+
this.cancelCursorRun();
|
|
505
|
+
}, this.activeClientToolFinalizeGraceMs);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private open(
|
|
509
|
+
request: CursorRunRequest,
|
|
510
|
+
signal: AbortSignal | undefined,
|
|
511
|
+
state: ReturnType<typeof createCursorProtobufEventState>,
|
|
512
|
+
push: (message: CursorServerMessage) => void,
|
|
513
|
+
fail: (error: Error) => void,
|
|
514
|
+
finish: () => void,
|
|
515
|
+
): void {
|
|
516
|
+
this.session = http2.connect(this.input.provider.baseUrl || "https://api2.cursor.sh");
|
|
517
|
+
// The run request is buffered until the HTTP/2 session connects. Failures before `connect`
|
|
518
|
+
// (DNS, ECONNREFUSED, TLS, connect timeout) mean the server never received the request, so they
|
|
519
|
+
// are safe to retry. Once connected, bytes flush to the server and the turn must not be replayed.
|
|
520
|
+
this.session.on("connect", () => { this.committed = true; });
|
|
521
|
+
this.stream = this.session.request({
|
|
522
|
+
":method": "POST",
|
|
523
|
+
":path": CURSOR_RUN_PATH,
|
|
524
|
+
"content-type": "application/connect+proto",
|
|
525
|
+
"connect-protocol-version": "1",
|
|
526
|
+
te: "trailers",
|
|
527
|
+
authorization: `Bearer ${this.token}`,
|
|
528
|
+
"x-ghost-mode": "true",
|
|
529
|
+
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
|
|
530
|
+
"x-cursor-client-type": "cli",
|
|
531
|
+
"x-request-id": crypto.randomUUID(),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Single owner of the pre-first-frame deadline. Cleared by the first server frame/end-stream and
|
|
535
|
+
// by every terminal path (trailers, error, end, abort, close) so it can never leak.
|
|
536
|
+
const failAndClear = (error: Error) => {
|
|
537
|
+
this.clearFirstFrameTimer();
|
|
538
|
+
if (this.expectedClose) {
|
|
539
|
+
// We already emitted a terminal `done` and cancelled the run (client-tool suspension). The
|
|
540
|
+
// RST_STREAM CANCEL surfaces here as a stream error/abort; it is expected, not a failure.
|
|
541
|
+
finish();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
fail(error);
|
|
545
|
+
};
|
|
546
|
+
const session = this.session;
|
|
547
|
+
const stream = this.stream;
|
|
548
|
+
this.firstFrameTimer = setTimeout(() => {
|
|
549
|
+
this.firstFrameTimer = undefined;
|
|
550
|
+
try { stream.close(); } catch { /* already closing */ }
|
|
551
|
+
try { session.close(); } catch { /* already closing */ }
|
|
552
|
+
fail(new Error("Cursor transport timed out before first response"));
|
|
553
|
+
}, this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS);
|
|
554
|
+
|
|
555
|
+
let pending: Uint8Array<ArrayBufferLike> = new Uint8Array();
|
|
556
|
+
this.stream.on("data", chunk => {
|
|
557
|
+
this.clearFirstFrameTimer();
|
|
558
|
+
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
559
|
+
pending = concatBytes(pending, bytes);
|
|
560
|
+
try {
|
|
561
|
+
const decoded = decodeAvailableConnectFrames(pending);
|
|
562
|
+
pending = decoded.remainder;
|
|
563
|
+
const frames = decoded.frames;
|
|
564
|
+
for (const frame of frames) {
|
|
565
|
+
if ((frame.flags & CONNECT_FLAG_END_STREAM) === CONNECT_FLAG_END_STREAM) {
|
|
566
|
+
const endError = parseConnectEndStreamError(frame.payload);
|
|
567
|
+
if (endError) failAndClear(endError);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
void this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push).catch(err => {
|
|
571
|
+
failAndClear(err instanceof Error ? err : new Error(String(err)));
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
} catch (err) {
|
|
575
|
+
failAndClear(err instanceof Error ? err : new Error(String(err)));
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
this.stream.on("trailers", trailers => {
|
|
579
|
+
const status = trailers["grpc-status"];
|
|
580
|
+
if (status && status !== "0") failAndClear(new Error(`Cursor gRPC error ${status}`));
|
|
581
|
+
});
|
|
582
|
+
this.stream.on("error", err => failAndClear(err instanceof Error ? err : new Error(String(err))));
|
|
583
|
+
this.stream.on("end", () => { this.clearFirstFrameTimer(); finish(); });
|
|
584
|
+
|
|
585
|
+
signal?.addEventListener("abort", () => {
|
|
586
|
+
this.close();
|
|
587
|
+
failAndClear(new Error("Cursor request was aborted"));
|
|
588
|
+
}, { once: true });
|
|
589
|
+
|
|
590
|
+
this.stream.write(encodeConnectFrame(encodeCursorRunRequest(request)));
|
|
591
|
+
this.heartbeat = setInterval(() => {
|
|
592
|
+
this.stream?.write(encodeClientMessage({
|
|
593
|
+
message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
|
|
594
|
+
}));
|
|
595
|
+
}, HEARTBEAT_MS);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async handleServerMessage(
|
|
599
|
+
message: AgentServerMessage,
|
|
600
|
+
state: ReturnType<typeof createCursorProtobufEventState>,
|
|
601
|
+
push: (message: CursorServerMessage) => void,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
if (!this.stream) return;
|
|
604
|
+
debugProviderDiagnostic("cursor", "frame", describeCursorServerFrame(message));
|
|
605
|
+
if (message.message.case === "kvServerMessage") {
|
|
606
|
+
this.stream.write(encodeConnectFrame(handleCursorNativeKv(message.message.value)));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (message.message.case === "execServerMessage") {
|
|
610
|
+
const execMsg = message.message.value;
|
|
611
|
+
if (execMsg.message.case === "mcpArgs") {
|
|
612
|
+
const plan = planMcpArgsHandling(execMsg, state);
|
|
613
|
+
if (plan.handledByResponsesBridge) {
|
|
614
|
+
this.noteClientToolActivity();
|
|
615
|
+
for (const event of plan.events) push(event);
|
|
616
|
+
if (plan.cancelCursorRun) this.cancelCursorRun();
|
|
617
|
+
else if (plan.finalizeWhenDrained) this.scheduleClientToolFinalize(state, push);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const replies = await handleCursorNativeExec(message.message.value, this.execContext);
|
|
622
|
+
for (const reply of replies) this.stream.write(encodeConnectFrame(reply));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (message.message.case === "interactionQuery") {
|
|
626
|
+
// The server-side agent BLOCKS until this query is answered with the matching id; leaving it
|
|
627
|
+
// unanswered is the proven stall → watchdog → upstream-502 mechanism. Reply immediately with
|
|
628
|
+
// the non-interactive default and emit liveness so the bridge watchdog sees progress.
|
|
629
|
+
const query = message.message.value;
|
|
630
|
+
const plan = planInteractionQueryReply(query);
|
|
631
|
+
debugProviderDiagnostic("cursor", "interaction-query", { id: query.id, queryCase: query.query.case ?? "unknown", reply: plan.replyCase });
|
|
632
|
+
this.stream.write(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } }));
|
|
633
|
+
if (!state.terminated) {
|
|
634
|
+
if (plan.planText) push({ type: "text", text: plan.planText });
|
|
635
|
+
push({ type: "heartbeat" });
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const mapped = mapCursorProtobufServerMessage(message, state);
|
|
640
|
+
if (mapped.length > 0) {
|
|
641
|
+
// A client tool call announced/committed via interactionUpdate (toolCallStarted/partialToolCall/
|
|
642
|
+
// toolCallCompleted) changes the call set, so revoke any finalize armed by an earlier drain.
|
|
643
|
+
if (isClientToolFrame(message)) this.noteClientToolActivity();
|
|
644
|
+
for (const event of mapped) push(event);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// The frame produced no outward Responses event (e.g. toolCallStarted / partialToolCall args
|
|
648
|
+
// buffering, toolCallDelta, tokenDelta, or a checkpoint update). Tool-call protocol events are
|
|
649
|
+
// deferred to completion for atomic, parallel-safe emission, so a turn that silently assembles
|
|
650
|
+
// several tool calls can otherwise exceed the bridge's stall watchdog (upstream_stall_timeout).
|
|
651
|
+
// Emit a liveness heartbeat for these progress frames so the watchdog sees the upstream is alive.
|
|
652
|
+
// Never after a terminal (done/truncation): a stray post-terminal frame must stay fully inert.
|
|
653
|
+
if (!state.terminated && isCursorProgressFrame(message)) {
|
|
654
|
+
if (isClientToolFrame(message)) this.noteClientToolActivity();
|
|
655
|
+
push({ type: "heartbeat" });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Build the best-effort partial usage for a turn that failed before a clean `done` (upstream 502,
|
|
662
|
+
* stream error, abort). Mirrors the clean-finalize math in `finalizeTurnEvents`: the last absolute
|
|
663
|
+
* checkpoint (`contextTokens`) is the cumulative context, the streamed delta stays in outputTokens.
|
|
664
|
+
* Returns undefined when the stream died before ANY token signal (nothing meaningful to report).
|
|
665
|
+
* Exported for unit testing.
|
|
666
|
+
*/
|
|
667
|
+
export function partialUsageFromEventState(state: ReturnType<typeof createCursorProtobufEventState>): OcxUsage | undefined {
|
|
668
|
+
const out = state.usage.outputTokens;
|
|
669
|
+
const ctx = state.contextTokens;
|
|
670
|
+
if (ctx === undefined && out <= 0) return undefined;
|
|
671
|
+
return ctx !== undefined
|
|
672
|
+
? { ...state.usage, inputTokens: Math.max(0, ctx - out), totalTokens: ctx, estimated: true }
|
|
673
|
+
: { ...state.usage, estimated: true };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Attach partial usage to a transport failure so the adapter's error path can surface real token
|
|
678
|
+
* consumption for 502/stall rows instead of `usageStatus: unreported` with 0 tokens.
|
|
679
|
+
*/
|
|
680
|
+
function attachPartialUsage(failure: Error, state: ReturnType<typeof createCursorProtobufEventState>): Error {
|
|
681
|
+
const usage = partialUsageFromEventState(state);
|
|
682
|
+
if (usage) (failure as Error & { partialUsage?: OcxUsage }).partialUsage = usage;
|
|
683
|
+
return failure;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Compact frame descriptor for OCX_DEBUG_FRAMES diagnostics: outer case plus the inner
|
|
688
|
+
* interactionUpdate/exec case and tool-call union case when present. No payload content is logged.
|
|
689
|
+
*/
|
|
690
|
+
function describeCursorServerFrame(message: AgentServerMessage): Record<string, unknown> {
|
|
691
|
+
const out: Record<string, unknown> = { case: message.message.case ?? "unknown" };
|
|
692
|
+
if (message.message.case === "interactionUpdate") {
|
|
693
|
+
const update = message.message.value.message;
|
|
694
|
+
out.update = update.case ?? "unknown";
|
|
695
|
+
if (update.case === "toolCallStarted" || update.case === "partialToolCall" || update.case === "toolCallCompleted") {
|
|
696
|
+
out.toolCase = update.value.toolCall?.tool.case ?? "none";
|
|
697
|
+
out.callId = update.value.callId;
|
|
698
|
+
}
|
|
699
|
+
} else if (message.message.case === "execServerMessage") {
|
|
700
|
+
out.exec = message.message.value.message.case ?? "unknown";
|
|
701
|
+
} else if (message.message.case === "interactionQuery") {
|
|
702
|
+
out.query = message.message.value.query.case ?? "unknown";
|
|
703
|
+
out.id = message.message.value.id;
|
|
704
|
+
} else if (message.message.case === "kvServerMessage") {
|
|
705
|
+
out.kv = message.message.value.message.case ?? "unknown";
|
|
706
|
+
}
|
|
707
|
+
return out;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* True when a server frame represents real upstream progress that produced no outward Responses
|
|
712
|
+
* event (so the bridge's stall watchdog would otherwise see silence). Covers tool-call assembly,
|
|
713
|
+
* token/checkpoint accounting — the frames `mapCursorProtobufServerMessage` intentionally swallows.
|
|
714
|
+
*/
|
|
715
|
+
function isCursorProgressFrame(message: AgentServerMessage): boolean {
|
|
716
|
+
if (message.message.case === "conversationCheckpointUpdate") return true;
|
|
717
|
+
if (message.message.case !== "interactionUpdate") return false;
|
|
718
|
+
switch (message.message.value.message.case) {
|
|
719
|
+
case "toolCallStarted":
|
|
720
|
+
case "partialToolCall":
|
|
721
|
+
case "toolCallDelta":
|
|
722
|
+
case "tokenDelta":
|
|
723
|
+
return true;
|
|
724
|
+
default:
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* A tool-call lifecycle frame that can change the CLIENT tool call set (announce a new sibling or
|
|
731
|
+
* commit one). Used to revoke a pending finalize so a late-announced parallel call is never dropped.
|
|
732
|
+
* Only frames whose inner ToolCall is an ocx-bridged Responses tool (`mcpToolCall` with our provider)
|
|
733
|
+
* count: Cursor-native tool frames (readToolCall/editToolCall/...) are display-plane and must not
|
|
734
|
+
* revoke a pending client-tool finalize. Exported for unit testing.
|
|
735
|
+
*/
|
|
736
|
+
export function isClientToolFrame(message: AgentServerMessage): boolean {
|
|
737
|
+
if (message.message.case !== "interactionUpdate") return false;
|
|
738
|
+
const update = message.message.value.message;
|
|
739
|
+
switch (update.case) {
|
|
740
|
+
case "toolCallStarted":
|
|
741
|
+
case "partialToolCall":
|
|
742
|
+
case "toolCallCompleted":
|
|
743
|
+
return mcpArgsFromToolCall(update.value.toolCall) !== undefined;
|
|
744
|
+
default:
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
750
|
+
const out = new Uint8Array(a.length + b.length);
|
|
751
|
+
out.set(a);
|
|
752
|
+
out.set(b, a.length);
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function createLiveCursorTransport(input: CursorTransportFactoryInput): CursorTransport {
|
|
757
|
+
return new LiveCursorTransport(input);
|
|
758
|
+
}
|