@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,118 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import {
|
|
3
|
+
ComputerUseErrorSchema,
|
|
4
|
+
ComputerUseResultSchema,
|
|
5
|
+
ListMcpResourcesErrorSchema,
|
|
6
|
+
ListMcpResourcesExecResultSchema,
|
|
7
|
+
McpErrorSchema,
|
|
8
|
+
McpResultSchema,
|
|
9
|
+
ReadMcpResourceErrorSchema,
|
|
10
|
+
ReadMcpResourceExecResultSchema,
|
|
11
|
+
RecordScreenFailureSchema,
|
|
12
|
+
RecordScreenResultSchema,
|
|
13
|
+
type ComputerUseArgs,
|
|
14
|
+
type ComputerUseResult,
|
|
15
|
+
type ExecServerMessage,
|
|
16
|
+
type ListMcpResourcesExecResult,
|
|
17
|
+
type McpArgs,
|
|
18
|
+
type McpResult,
|
|
19
|
+
type ReadMcpResourceExecArgs,
|
|
20
|
+
type ReadMcpResourceExecResult,
|
|
21
|
+
type RecordScreenArgs,
|
|
22
|
+
type RecordScreenResult,
|
|
23
|
+
} from "./gen/agent_pb";
|
|
24
|
+
import { errorText, execBytes } from "./native-exec-common";
|
|
25
|
+
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
26
|
+
|
|
27
|
+
export interface CursorNativeToolDeps {
|
|
28
|
+
mcp?: (args: McpArgs) => McpResult | Promise<McpResult>;
|
|
29
|
+
listMcpResources?: () => ListMcpResourcesExecResult | Promise<ListMcpResourcesExecResult>;
|
|
30
|
+
readMcpResource?: (args: ReadMcpResourceExecArgs) => ReadMcpResourceExecResult | Promise<ReadMcpResourceExecResult>;
|
|
31
|
+
computerUse?: (args: ComputerUseArgs) => ComputerUseResult | Promise<ComputerUseResult>;
|
|
32
|
+
recordScreen?: (args: RecordScreenArgs) => RecordScreenResult | Promise<RecordScreenResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function mcpExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise<Uint8Array> {
|
|
36
|
+
if (execMsg.message.case !== "mcpArgs") throw new Error("invalid mcp exec");
|
|
37
|
+
try {
|
|
38
|
+
if (execMsg.message.value.providerIdentifier === OCX_RESPONSES_TOOL_PROVIDER) {
|
|
39
|
+
return execBytes(execMsg, "mcpResult", create(McpResultSchema, {
|
|
40
|
+
result: { case: "error", value: create(McpErrorSchema, { error: "Responses client tools are surfaced to Codex and must not execute through the local MCP native-exec channel." }) },
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
const result = deps.mcp
|
|
44
|
+
? await deps.mcp(execMsg.message.value)
|
|
45
|
+
: create(McpResultSchema, { result: { case: "error", value: create(McpErrorSchema, { error: "No local MCP executor is configured inside opencodex." }) } });
|
|
46
|
+
return execBytes(execMsg, "mcpResult", result);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return execBytes(execMsg, "mcpResult", create(McpResultSchema, {
|
|
49
|
+
result: { case: "error", value: create(McpErrorSchema, { error: errorText(err) }) },
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listMcpResourcesExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise<Uint8Array> {
|
|
55
|
+
if (execMsg.message.case !== "listMcpResourcesExecArgs") throw new Error("invalid list mcp resources exec");
|
|
56
|
+
// No executor wired (no MCP configured, or prepareMcp() stripped deps after a setup
|
|
57
|
+
// failure): return a typed error, symmetric with readMcpResource. An empty-success here
|
|
58
|
+
// would falsely imply "connected, zero resources" and mask a misconfiguration.
|
|
59
|
+
const result = deps.listMcpResources
|
|
60
|
+
? await deps.listMcpResources()
|
|
61
|
+
: create(ListMcpResourcesExecResultSchema, {
|
|
62
|
+
result: { case: "error", value: create(ListMcpResourcesErrorSchema, {
|
|
63
|
+
error: "No local MCP resource executor is configured inside opencodex.",
|
|
64
|
+
}) },
|
|
65
|
+
});
|
|
66
|
+
return execBytes(execMsg, "listMcpResourcesExecResult", result);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function readMcpResourceExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise<Uint8Array> {
|
|
70
|
+
if (execMsg.message.case !== "readMcpResourceExecArgs") throw new Error("invalid read mcp resource exec");
|
|
71
|
+
const args = execMsg.message.value;
|
|
72
|
+
try {
|
|
73
|
+
const result = deps.readMcpResource
|
|
74
|
+
? await deps.readMcpResource(args)
|
|
75
|
+
: create(ReadMcpResourceExecResultSchema, {
|
|
76
|
+
result: { case: "error", value: create(ReadMcpResourceErrorSchema, { uri: args.uri, error: "No local MCP resource executor is configured inside opencodex." }) },
|
|
77
|
+
});
|
|
78
|
+
return execBytes(execMsg, "readMcpResourceExecResult", result);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return execBytes(execMsg, "readMcpResourceExecResult", create(ReadMcpResourceExecResultSchema, {
|
|
81
|
+
result: { case: "error", value: create(ReadMcpResourceErrorSchema, { uri: args.uri, error: errorText(err) }) },
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function computerUseExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise<Uint8Array> {
|
|
87
|
+
if (execMsg.message.case !== "computerUseArgs") throw new Error("invalid computer use exec");
|
|
88
|
+
const args = execMsg.message.value;
|
|
89
|
+
try {
|
|
90
|
+
const result = deps.computerUse
|
|
91
|
+
? await deps.computerUse(args)
|
|
92
|
+
: create(ComputerUseResultSchema, {
|
|
93
|
+
result: { case: "error", value: create(ComputerUseErrorSchema, { error: "computer-use is not supported in this headless opencodex proxy. Configure provider.desktopExecutor.computerUseCommand to enable it.", actionCount: args.actions.length, durationMs: 0 }) },
|
|
94
|
+
});
|
|
95
|
+
return execBytes(execMsg, "computerUseResult", result);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return execBytes(execMsg, "computerUseResult", create(ComputerUseResultSchema, {
|
|
98
|
+
result: { case: "error", value: create(ComputerUseErrorSchema, { error: errorText(err), actionCount: args.actions.length, durationMs: 0 }) },
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function recordScreenExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise<Uint8Array> {
|
|
104
|
+
if (execMsg.message.case !== "recordScreenArgs") throw new Error("invalid record screen exec");
|
|
105
|
+
const args = execMsg.message.value;
|
|
106
|
+
try {
|
|
107
|
+
const result = deps.recordScreen
|
|
108
|
+
? await deps.recordScreen(args)
|
|
109
|
+
: create(RecordScreenResultSchema, {
|
|
110
|
+
result: { case: "failure", value: create(RecordScreenFailureSchema, { error: "record-screen is not supported in this headless opencodex proxy. Configure provider.desktopExecutor.recordScreenCommand to enable it." }) },
|
|
111
|
+
});
|
|
112
|
+
return execBytes(execMsg, "recordScreenResult", result);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return execBytes(execMsg, "recordScreenResult", create(RecordScreenResultSchema, {
|
|
115
|
+
result: { case: "failure", value: create(RecordScreenFailureSchema, { error: errorText(err) }) },
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import {
|
|
4
|
+
DiagnosticsResultSchema,
|
|
5
|
+
DiagnosticsSuccessSchema,
|
|
6
|
+
GetBlobResultSchema,
|
|
7
|
+
KvClientMessageSchema,
|
|
8
|
+
McpErrorSchema,
|
|
9
|
+
McpResultSchema,
|
|
10
|
+
RequestContextResultSchema,
|
|
11
|
+
RequestContextSchema,
|
|
12
|
+
RequestContextSuccessSchema,
|
|
13
|
+
SetBlobResultSchema,
|
|
14
|
+
type ExecServerMessage,
|
|
15
|
+
type KvServerMessage,
|
|
16
|
+
} from "./gen/agent_pb";
|
|
17
|
+
import { deleteExec, grepExec, lsExec, readExec, rejectDeleteExecForApplyPatch, rejectWriteExecForApplyPatch, writeExec } from "./native-exec-fs";
|
|
18
|
+
import { fetchExec, type CursorNativeNetworkDeps } from "./native-exec-network";
|
|
19
|
+
import { backgroundShellSpawnExec, shellExec, shellStreamExec, writeShellStdinExec } from "./native-exec-shell";
|
|
20
|
+
import {
|
|
21
|
+
computerUseExec,
|
|
22
|
+
listMcpResourcesExec,
|
|
23
|
+
mcpExec,
|
|
24
|
+
readMcpResourceExec,
|
|
25
|
+
recordScreenExec,
|
|
26
|
+
type CursorNativeToolDeps,
|
|
27
|
+
} from "./native-exec-tools";
|
|
28
|
+
import { clientBytes, execBytes } from "./native-exec-common";
|
|
29
|
+
import type { McpToolDefinition } from "./gen/agent_pb";
|
|
30
|
+
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
31
|
+
|
|
32
|
+
export type CursorNativeExecDeps = CursorNativeNetworkDeps & CursorNativeToolDeps;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Execution context for a Cursor stream: the per-call executors plus the MCP tool definitions
|
|
36
|
+
* advertised to the server via `requestContextResult`. Without `mcpToolDefs`, the server is
|
|
37
|
+
* never told any MCP tools exist, so it never sends `mcpArgs`.
|
|
38
|
+
*/
|
|
39
|
+
export interface CursorNativeExecContext extends CursorNativeExecDeps {
|
|
40
|
+
mcpToolDefs?: McpToolDefinition[];
|
|
41
|
+
clientToolDefs?: McpToolDefinition[];
|
|
42
|
+
/** apply_patch is visible for this request; Cursor-native write/delete must not bypass Codex. */
|
|
43
|
+
rejectNativeFileMutations?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Content-addressed blob store shared across streams. Bounded: without eviction a long-running
|
|
48
|
+
* proxy accumulates every conversation's prompt blobs forever (unbounded memory) and any stale
|
|
49
|
+
* blob stays servable indefinitely — a cross-conversation contamination enabler if Cursor's
|
|
50
|
+
* server-side state ever references old ids (devlog 260702 P0). Continuation requests re-store
|
|
51
|
+
* their blobs on every turn (`rootPromptMessages` → `storeCursorBlob`), so TTL + cap eviction is
|
|
52
|
+
* safe for live sessions: only genuinely abandoned entries age out.
|
|
53
|
+
*/
|
|
54
|
+
const BLOB_TTL_MS = 15 * 60 * 1000;
|
|
55
|
+
const BLOB_MAX_ENTRIES = 4096;
|
|
56
|
+
const blobs = new Map<string, { data: Uint8Array; storedAt: number }>();
|
|
57
|
+
|
|
58
|
+
function evictStaleBlobs(now: number): void {
|
|
59
|
+
if (blobs.size <= BLOB_MAX_ENTRIES) {
|
|
60
|
+
// TTL sweep only when the map has any chance of stale entries; Map iterates insertion order.
|
|
61
|
+
for (const [k, entry] of blobs) {
|
|
62
|
+
if (now - entry.storedAt <= BLOB_TTL_MS) break;
|
|
63
|
+
blobs.delete(k);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Over cap: drop oldest entries first (insertion order approximates recency because re-stores
|
|
68
|
+
// delete+set to refresh their position).
|
|
69
|
+
const excess = blobs.size - BLOB_MAX_ENTRIES;
|
|
70
|
+
let dropped = 0;
|
|
71
|
+
for (const k of blobs.keys()) {
|
|
72
|
+
if (dropped >= excess) break;
|
|
73
|
+
blobs.delete(k);
|
|
74
|
+
dropped++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setBlob(k: string, data: Uint8Array): void {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
blobs.delete(k); // refresh insertion order so live sessions stay newest
|
|
81
|
+
blobs.set(k, { data, storedAt: now });
|
|
82
|
+
evictStaleBlobs(now);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getBlob(k: string): Uint8Array | undefined {
|
|
86
|
+
const entry = blobs.get(k);
|
|
87
|
+
if (!entry) return undefined;
|
|
88
|
+
if (Date.now() - entry.storedAt > BLOB_TTL_MS) {
|
|
89
|
+
blobs.delete(k);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return entry.data;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function key(bytes: Uint8Array): string {
|
|
96
|
+
return Buffer.from(bytes).toString("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Store a blob (SHA-256 keyed) in the shared map that `handleCursorNativeKv` serves, and return its
|
|
101
|
+
* blob id. Cursor's `rootPromptMessagesJson`/turn entries are blob IDS, not inline content — the
|
|
102
|
+
* server fetches the bytes back via `getBlobArgs`. Mirrors jawcode `createBlobId`/`storeCursorBlob`.
|
|
103
|
+
*/
|
|
104
|
+
export function storeCursorBlob(data: Uint8Array): Uint8Array {
|
|
105
|
+
const blobId = new Uint8Array(createHash("sha256").update(data).digest());
|
|
106
|
+
setBlob(key(blobId), data);
|
|
107
|
+
return blobId;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: CursorNativeExecContext = {}): Promise<Uint8Array[]> {
|
|
111
|
+
const execCase = execMsg.message.case;
|
|
112
|
+
if (execCase === "requestContextArgs") {
|
|
113
|
+
const tools = [...(deps.mcpToolDefs ?? []), ...(deps.clientToolDefs ?? [])];
|
|
114
|
+
return [execBytes(execMsg, "requestContextResult", create(RequestContextResultSchema, {
|
|
115
|
+
result: { case: "success", value: create(RequestContextSuccessSchema, { requestContext: create(RequestContextSchema, { tools }) }) },
|
|
116
|
+
}))];
|
|
117
|
+
}
|
|
118
|
+
if (execCase === "readArgs") return [readExec(execMsg)];
|
|
119
|
+
if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg) : writeExec(execMsg)];
|
|
120
|
+
if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg) : deleteExec(execMsg)];
|
|
121
|
+
if (execCase === "lsArgs") return [lsExec(execMsg)];
|
|
122
|
+
if (execCase === "grepArgs") return [grepExec(execMsg)];
|
|
123
|
+
if (execCase === "shellArgs") return [shellExec(execMsg)];
|
|
124
|
+
if (execCase === "shellStreamArgs") return shellStreamExec(execMsg);
|
|
125
|
+
if (execCase === "backgroundShellSpawnArgs") return [backgroundShellSpawnExec(execMsg)];
|
|
126
|
+
if (execCase === "writeShellStdinArgs") return [writeShellStdinExec(execMsg)];
|
|
127
|
+
if (execCase === "fetchArgs") return [await fetchExec(execMsg, deps)];
|
|
128
|
+
if (execCase === "mcpArgs" && execMsg.message.value.providerIdentifier === OCX_RESPONSES_TOOL_PROVIDER) {
|
|
129
|
+
return [execBytes(execMsg, "mcpResult", create(McpResultSchema, {
|
|
130
|
+
result: {
|
|
131
|
+
case: "error",
|
|
132
|
+
value: create(McpErrorSchema, { error: "Cursor requested a client Responses tool through the native exec channel; bridge suspension is not implemented." }),
|
|
133
|
+
},
|
|
134
|
+
}))];
|
|
135
|
+
}
|
|
136
|
+
if (execCase === "mcpArgs") return [await mcpExec(execMsg, deps)];
|
|
137
|
+
if (execCase === "listMcpResourcesExecArgs") return [await listMcpResourcesExec(execMsg, deps)];
|
|
138
|
+
if (execCase === "readMcpResourceExecArgs") return [await readMcpResourceExec(execMsg, deps)];
|
|
139
|
+
if (execCase === "computerUseArgs") return [await computerUseExec(execMsg, deps)];
|
|
140
|
+
if (execCase === "recordScreenArgs") return [await recordScreenExec(execMsg, deps)];
|
|
141
|
+
if (execCase === "diagnosticsArgs") {
|
|
142
|
+
const path = execMsg.message.value.path;
|
|
143
|
+
return [execBytes(execMsg, "diagnosticsResult", create(DiagnosticsResultSchema, {
|
|
144
|
+
result: { case: "success", value: create(DiagnosticsSuccessSchema, { path, diagnostics: [], totalDiagnostics: 0 }) },
|
|
145
|
+
}))];
|
|
146
|
+
}
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
export function handleCursorNativeKv(kvMsg: KvServerMessage): Uint8Array {
|
|
152
|
+
if (kvMsg.message.case === "getBlobArgs") {
|
|
153
|
+
const blobData = getBlob(key(kvMsg.message.value.blobId));
|
|
154
|
+
return clientBytes({
|
|
155
|
+
message: {
|
|
156
|
+
case: "kvClientMessage",
|
|
157
|
+
value: create(KvClientMessageSchema, {
|
|
158
|
+
id: kvMsg.id,
|
|
159
|
+
message: { case: "getBlobResult", value: create(GetBlobResultSchema, blobData ? { blobData } : {}) },
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (kvMsg.message.case === "setBlobArgs") {
|
|
165
|
+
setBlob(key(kvMsg.message.value.blobId), kvMsg.message.value.blobData);
|
|
166
|
+
return clientBytes({
|
|
167
|
+
message: {
|
|
168
|
+
case: "kvClientMessage",
|
|
169
|
+
value: create(KvClientMessageSchema, {
|
|
170
|
+
id: kvMsg.id,
|
|
171
|
+
message: { case: "setBlobResult", value: create(SetBlobResultSchema, {}) },
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return clientBytes({ message: { case: "kvClientMessage", value: create(KvClientMessageSchema, { id: kvMsg.id }) } });
|
|
177
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { OcxUsage } from "../../types";
|
|
2
|
+
import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
|
|
3
|
+
import { decodeCursorArgsMap } from "./arg-codec";
|
|
4
|
+
import { normalizeArgKeys } from "./arg-normalize";
|
|
5
|
+
import { OCX_RESPONSES_TOOL_PROVIDER, normalizeCursorWireName, responsesToolNameFromCursorWire } from "./tool-definitions";
|
|
6
|
+
import type { CursorServerMessage } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface CursorProtobufEventState {
|
|
9
|
+
usage: OcxUsage;
|
|
10
|
+
/**
|
|
11
|
+
* Absolute conversation context size from Cursor's `conversationCheckpointUpdate.usedTokens`
|
|
12
|
+
* (authoritative cumulative context, NOT a per-turn delta). Kept separate from `usage.outputTokens`
|
|
13
|
+
* so it is never folded into the additive per-turn output count. Surfaced as `done.usage.totalTokens`
|
|
14
|
+
* so Codex's `last_token_usage.total_tokens` reflects the real active context. Mirrors the Kiro
|
|
15
|
+
* contextUsagePercentage SOT fix (devlog 142.10): absolute context and additive output must not
|
|
16
|
+
* share one field, or Codex double-counts (e.g. 10000 then 10300 surfacing as 20300).
|
|
17
|
+
*/
|
|
18
|
+
contextTokens?: number;
|
|
19
|
+
openToolCalls: Map<string, { name: string; args: string }>;
|
|
20
|
+
completedToolCalls: Set<string>;
|
|
21
|
+
/** Set once a terminal `done`/truncation has been emitted, so post-terminal frames stay inert. */
|
|
22
|
+
terminated?: boolean;
|
|
23
|
+
clientToolNames?: Set<string>;
|
|
24
|
+
parallelToolCalls?: boolean;
|
|
25
|
+
startedClientToolCalls: number;
|
|
26
|
+
/** Tool wire-name → original JSON Schema parameters object, for arg-key normalization. */
|
|
27
|
+
toolSchemas?: Map<string, unknown>;
|
|
28
|
+
/** Cursor wire-name → original Responses/Codex tool name for this request. */
|
|
29
|
+
cursorToolNameMap?: Map<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createCursorProtobufEventState(options: { clientToolNames?: Iterable<string>; parallelToolCalls?: boolean; toolSchemas?: Map<string, unknown>; cursorToolNameMap?: Map<string, string> } = {}): CursorProtobufEventState {
|
|
33
|
+
return {
|
|
34
|
+
// Cursor provides no authoritative usage frame; token counts are heuristic estimates from
|
|
35
|
+
// checkpoint/delta events, so mark estimated from the start.
|
|
36
|
+
usage: { inputTokens: 0, outputTokens: 0, estimated: true },
|
|
37
|
+
openToolCalls: new Map(),
|
|
38
|
+
completedToolCalls: new Set(),
|
|
39
|
+
...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}),
|
|
40
|
+
...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}),
|
|
41
|
+
startedClientToolCalls: 0,
|
|
42
|
+
...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}),
|
|
43
|
+
...(options.cursorToolNameMap ? { cursorToolNameMap: options.cursorToolNameMap } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Exported for live-transport's client-tool frame classification (finalize revocation). */
|
|
48
|
+
export function mcpArgsFromToolCall(toolCall: ToolCall | undefined): McpArgs | undefined {
|
|
49
|
+
if (toolCall?.tool.case !== "mcpToolCall") return undefined;
|
|
50
|
+
const args = toolCall.tool.value.args;
|
|
51
|
+
return args?.providerIdentifier === OCX_RESPONSES_TOOL_PROVIDER ? args : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mcpWireNameFromArgs(args: McpArgs | undefined): string | undefined {
|
|
55
|
+
const raw = args?.toolName || args?.name;
|
|
56
|
+
// Models may call the Cursor-displayed `mcp_<provider>_<tool>` name; fold it to the advertised name.
|
|
57
|
+
return raw && raw.length > 0 ? normalizeCursorWireName(raw) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mcpCursorWireName(toolCall: ToolCall | undefined): string | undefined {
|
|
61
|
+
return mcpWireNameFromArgs(mcpArgsFromToolCall(toolCall));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeMcpArgs(args: McpArgs | undefined): string {
|
|
65
|
+
return JSON.stringify(decodeCursorArgsMap(args?.args));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeMcpArgsNormalized(args: McpArgs | undefined, state: CursorProtobufEventState): string {
|
|
69
|
+
const decoded = decodeCursorArgsMap(args?.args);
|
|
70
|
+
const toolName = mcpWireNameFromArgs(args);
|
|
71
|
+
if (toolName && state.toolSchemas?.has(toolName)) {
|
|
72
|
+
return JSON.stringify(normalizeArgKeys(decoded, state.toolSchemas.get(toolName)));
|
|
73
|
+
}
|
|
74
|
+
return JSON.stringify(decoded);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasMcpArgBytes(args: McpArgs | undefined): boolean {
|
|
78
|
+
return Object.keys(args?.args ?? {}).length > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isCompleteJson(text: string): boolean {
|
|
82
|
+
if (text.length === 0) return false;
|
|
83
|
+
try {
|
|
84
|
+
JSON.parse(text);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Schema-normalize a JSON-text argument blob for a named tool, if a schema is known. */
|
|
92
|
+
function normalizeJsonText(text: string, toolName: string | undefined, state: CursorProtobufEventState): string {
|
|
93
|
+
if (!toolName || !state.toolSchemas?.has(toolName)) return text;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(text);
|
|
96
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
97
|
+
return JSON.stringify(normalizeArgKeys(parsed as Record<string, unknown>, state.toolSchemas.get(toolName)));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Not parseable as an object: leave as-is.
|
|
101
|
+
}
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the authoritative argument string for a completed client tool call.
|
|
107
|
+
*
|
|
108
|
+
* Cursor sends args two ways: incrementally as `argsTextDelta` (buffered into `open.args`, never
|
|
109
|
+
* streamed onward), and/or as a structured protobuf map on `toolCallCompleted`. We emit the args
|
|
110
|
+
* exactly once, at completion, so they can always be schema-normalized regardless of which form
|
|
111
|
+
* arrived. The completed map wins when present (canonical); otherwise the buffered streamed text is
|
|
112
|
+
* used. Returns an empty string when there are no args (the bridge serializes that as `{}`).
|
|
113
|
+
*/
|
|
114
|
+
function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state: CursorProtobufEventState): string {
|
|
115
|
+
if (hasMcpArgBytes(args)) return decodeMcpArgsNormalized(args, state);
|
|
116
|
+
const name = mcpWireNameFromArgs(args);
|
|
117
|
+
if (isCompleteJson(buffered)) return normalizeJsonText(buffered, name, state);
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function mapSyntheticMcpExecToToolEvents(
|
|
122
|
+
args: McpArgs,
|
|
123
|
+
fallbackCallId = "cursor_mcp_exec",
|
|
124
|
+
options: { allowEmptyArgs?: boolean; state?: CursorProtobufEventState } = {},
|
|
125
|
+
): CursorServerMessage[] {
|
|
126
|
+
if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return [];
|
|
127
|
+
if (options.allowEmptyArgs !== true && !hasMcpArgBytes(args)) return [];
|
|
128
|
+
const cursorWireName = mcpWireNameFromArgs(args);
|
|
129
|
+
if (!cursorWireName) return [{ type: "error", message: "Cursor requested a Responses tool without a tool name" }];
|
|
130
|
+
const callId = args.toolCallId || fallbackCallId;
|
|
131
|
+
if (options.state?.completedToolCalls.has(callId)) return [];
|
|
132
|
+
if (options.state) {
|
|
133
|
+
// Native-exec delivers the whole client tool call at once. Record it (no-op if already opened by
|
|
134
|
+
// an earlier started/partial event), then emit the atomic start -> delta -> end unit.
|
|
135
|
+
const out: CursorServerMessage[] = [...recordToolCall(options.state, callId, cursorWireName)];
|
|
136
|
+
if (out.some(event => event.type === "error")) return out;
|
|
137
|
+
const open = options.state.openToolCalls.get(callId);
|
|
138
|
+
const finalArgs = resolveCompletedArgs(open?.args ?? "", args, options.state);
|
|
139
|
+
out.push(...commitToolCall(options.state, callId, finalArgs));
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
// Stateless fallback (no shared event state): emit a complete, self-contained tool call.
|
|
143
|
+
return [
|
|
144
|
+
{ type: "tool_call_start", id: callId, name: responsesToolNameFromCursorWire(cursorWireName) },
|
|
145
|
+
{ type: "tool_call_delta", arguments: decodeMcpArgs(args) },
|
|
146
|
+
{ type: "tool_call_end", id: callId },
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record (open) a client tool call WITHOUT emitting `tool_call_start`. The outward start is deferred
|
|
152
|
+
* to completion (see commitToolCall) so each Cursor tool call surfaces to the bridge as one atomic,
|
|
153
|
+
* self-contained start -> delta -> end unit. This lets Cursor open several tool calls in parallel
|
|
154
|
+
* (or interleave their partial-arg streams) without cross-wiring: nothing reaches the single-current-
|
|
155
|
+
* call bridge until a call completes, and completed calls are emitted whole, one after another.
|
|
156
|
+
* Returns an error only for a genuinely unknown (un-advertised) tool name.
|
|
157
|
+
*/
|
|
158
|
+
function recordToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] {
|
|
159
|
+
if (state.completedToolCalls.has(callId)) return [];
|
|
160
|
+
if (state.openToolCalls.has(callId)) return [];
|
|
161
|
+
if (state.clientToolNames && !state.clientToolNames.has(cursorWireName)) {
|
|
162
|
+
return [{ type: "error", message: `Cursor requested unknown Responses tool: ${cursorWireName}` }];
|
|
163
|
+
}
|
|
164
|
+
state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(cursorWireName, state.cursorToolNameMap), args: "" });
|
|
165
|
+
state.startedClientToolCalls++;
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit a completed client tool call as one atomic unit: `tool_call_start` (deferred from open time),
|
|
171
|
+
* the full normalized arguments delta when present, then `tool_call_end`. The call must already be
|
|
172
|
+
* recorded in `openToolCalls`. Because each completion emits a whole non-interleaved unit, the bridge
|
|
173
|
+
* (which tracks a single current tool call) serializes parallel Cursor calls correctly.
|
|
174
|
+
*/
|
|
175
|
+
function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] {
|
|
176
|
+
const open = state.openToolCalls.get(callId);
|
|
177
|
+
if (!open) return [];
|
|
178
|
+
const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }];
|
|
179
|
+
if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs });
|
|
180
|
+
out.push(...endToolCall(state, callId));
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Buffer Cursor's cumulative `argsTextDelta` into the open call WITHOUT emitting a delta. Args are
|
|
186
|
+
* emitted once, normalized, at completion (see resolveCompletedArgs), so a mis-keyed or
|
|
187
|
+
* non-canonical streamed blob can still be repaired before Codex sees it. `argsTextDelta` is
|
|
188
|
+
* cumulative; keep the longest value seen.
|
|
189
|
+
*/
|
|
190
|
+
function bufferToolArgs(state: CursorProtobufEventState, callId: string, cumulative: string): void {
|
|
191
|
+
const open = state.openToolCalls.get(callId);
|
|
192
|
+
if (!open) return;
|
|
193
|
+
if (cumulative.length >= open.args.length) open.args = cumulative;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function endToolCall(state: CursorProtobufEventState, callId: string): CursorServerMessage[] {
|
|
197
|
+
if (!state.openToolCalls.has(callId)) return [];
|
|
198
|
+
state.openToolCalls.delete(callId);
|
|
199
|
+
state.completedToolCalls.add(callId);
|
|
200
|
+
return [{ type: "tool_call_end", id: callId }];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function mapCursorProtobufServerMessage(
|
|
204
|
+
serverMessage: AgentServerMessage,
|
|
205
|
+
state: CursorProtobufEventState,
|
|
206
|
+
): CursorServerMessage[] {
|
|
207
|
+
if (serverMessage.message.case === "conversationCheckpointUpdate") {
|
|
208
|
+
const usedTokens = serverMessage.message.value.tokenDetails?.usedTokens ?? 0;
|
|
209
|
+
// `usedTokens` is the ABSOLUTE conversation context size, not a per-turn output delta. Track it
|
|
210
|
+
// separately (monotonic max) and surface it as `done.usage.totalTokens`; folding it into
|
|
211
|
+
// `outputTokens` (which also accumulates `tokenDelta`) double-counts in Codex. See contextTokens.
|
|
212
|
+
if (usedTokens > (state.contextTokens ?? 0)) state.contextTokens = usedTokens;
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (serverMessage.message.case !== "interactionUpdate") return [];
|
|
217
|
+
const update = serverMessage.message.value.message;
|
|
218
|
+
switch (update.case) {
|
|
219
|
+
case "textDelta":
|
|
220
|
+
return update.value.text ? [{ type: "text", text: update.value.text }] : [];
|
|
221
|
+
case "thinkingDelta":
|
|
222
|
+
return update.value.text ? [{ type: "thinking", thinking: update.value.text }] : [];
|
|
223
|
+
case "toolCallStarted": {
|
|
224
|
+
const name = mcpCursorWireName(update.value.toolCall);
|
|
225
|
+
// Record the open call but defer the outward tool_call_start to completion (atomic emission).
|
|
226
|
+
return name ? recordToolCall(state, update.value.callId, name) : [];
|
|
227
|
+
}
|
|
228
|
+
case "partialToolCall": {
|
|
229
|
+
const out: CursorServerMessage[] = [];
|
|
230
|
+
const name = mcpCursorWireName(update.value.toolCall);
|
|
231
|
+
if (name) out.push(...recordToolCall(state, update.value.callId, name));
|
|
232
|
+
if (out.some(event => event.type === "error")) return out;
|
|
233
|
+
// Buffer cumulative args; do not emit a delta. Args are emitted once, normalized, at completion.
|
|
234
|
+
if (state.openToolCalls.has(update.value.callId)) {
|
|
235
|
+
bufferToolArgs(state, update.value.callId, update.value.argsTextDelta);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
case "toolCallDelta":
|
|
240
|
+
// Cursor's typed deltas currently cover native exec internals (shell/task/edit). Client
|
|
241
|
+
// Responses tools return as McpToolCall plus partial args text, so native deltas stay internal.
|
|
242
|
+
return [];
|
|
243
|
+
case "toolCallCompleted": {
|
|
244
|
+
const out: CursorServerMessage[] = [];
|
|
245
|
+
if (state.completedToolCalls.has(update.value.callId)) return [];
|
|
246
|
+
const name = mcpCursorWireName(update.value.toolCall);
|
|
247
|
+
const args = mcpArgsFromToolCall(update.value.toolCall);
|
|
248
|
+
const openBeforeStart = state.openToolCalls.get(update.value.callId);
|
|
249
|
+
// Empty-arg completion handling:
|
|
250
|
+
// - already open with empty args -> wait for the native-exec args path (do not commit yet).
|
|
251
|
+
// - never started + not advertised -> Cursor prelude noise, drop it.
|
|
252
|
+
// - advertised client tool, not yet open -> a legitimate no-arg call: commit it (start+end)
|
|
253
|
+
// so it is not silently dropped; the bridge serializes empty args as "{}".
|
|
254
|
+
if (name && !hasMcpArgBytes(args)) {
|
|
255
|
+
if (openBeforeStart && openBeforeStart.args.length === 0) return [];
|
|
256
|
+
// Only commit a no-arg call when the tool is *explicitly* advertised. Without an advertised
|
|
257
|
+
// tool list we cannot tell a real no-arg call from a Cursor prelude, so we keep dropping it.
|
|
258
|
+
const advertised = state.clientToolNames?.has(name) ?? false;
|
|
259
|
+
if (!openBeforeStart && !advertised) return [];
|
|
260
|
+
}
|
|
261
|
+
// Ensure the call is recorded (covers a completion with no prior started/partial event), then
|
|
262
|
+
// emit it as one atomic start -> delta -> end unit so parallel Cursor calls serialize cleanly.
|
|
263
|
+
if (name) out.push(...recordToolCall(state, update.value.callId, name));
|
|
264
|
+
if (out.some(event => event.type === "error")) return out;
|
|
265
|
+
const open = state.openToolCalls.get(update.value.callId);
|
|
266
|
+
if (open) {
|
|
267
|
+
const finalArgs = resolveCompletedArgs(open.args, args, state);
|
|
268
|
+
out.push(...commitToolCall(state, update.value.callId, finalArgs));
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
case "tokenDelta":
|
|
273
|
+
state.usage.outputTokens += update.value.tokens;
|
|
274
|
+
return [];
|
|
275
|
+
case "turnEnded":
|
|
276
|
+
return finalizeTurnEvents(state);
|
|
277
|
+
default:
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Finalize a Cursor turn. If any client tool call is still open (started but never completed),
|
|
284
|
+
* the stream was truncated and the partial tool call must not reach Codex as a completed call
|
|
285
|
+
* with corrupt/empty arguments. Emit an explicit error instead of done (fail-closed).
|
|
286
|
+
* Mirrors kiro-truncation.ts behavior.
|
|
287
|
+
*/
|
|
288
|
+
export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServerMessage[] {
|
|
289
|
+
state.terminated = true;
|
|
290
|
+
if (state.openToolCalls.size > 0) {
|
|
291
|
+
const openIds = [...state.openToolCalls.keys()].join(", ");
|
|
292
|
+
// Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit.
|
|
293
|
+
state.openToolCalls.clear();
|
|
294
|
+
return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }];
|
|
295
|
+
}
|
|
296
|
+
// Surface the absolute context size (when Cursor reported a checkpoint) as both totalTokens and
|
|
297
|
+
// the estimated input side of Codex's visible `input + output` counter. Codex status lines can
|
|
298
|
+
// render the additive pair instead of total_tokens, so leaving inputTokens at 0 makes a 16k-context
|
|
299
|
+
// first turn display as "9 used". Keep outputTokens as the per-turn delta and clamp the inferred
|
|
300
|
+
// input to 0 in case Cursor reports a checkpoint smaller than the streamed output delta.
|
|
301
|
+
const usage: OcxUsage = state.contextTokens !== undefined
|
|
302
|
+
? {
|
|
303
|
+
...state.usage,
|
|
304
|
+
inputTokens: Math.max(0, state.contextTokens - state.usage.outputTokens),
|
|
305
|
+
totalTokens: state.contextTokens,
|
|
306
|
+
}
|
|
307
|
+
: { ...state.usage };
|
|
308
|
+
return [{ type: "done", usage }];
|
|
309
|
+
}
|