@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.
Files changed (84) hide show
  1. package/README.md +9 -0
  2. package/bin/ocx.mjs +70 -5
  3. package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
  4. package/gui/dist/assets/index-DbTEyo46.js +9 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +9 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/arg-codec.ts +38 -0
  10. package/src/adapters/cursor/arg-normalize.ts +88 -0
  11. package/src/adapters/cursor/cursor-errors.ts +85 -0
  12. package/src/adapters/cursor/discovery.ts +144 -0
  13. package/src/adapters/cursor/effort-map.ts +74 -0
  14. package/src/adapters/cursor/exec-policy.ts +44 -0
  15. package/src/adapters/cursor/framing.ts +136 -0
  16. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  17. package/src/adapters/cursor/kv-store.ts +25 -0
  18. package/src/adapters/cursor/live-models.ts +93 -0
  19. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  20. package/src/adapters/cursor/live-transport.ts +758 -0
  21. package/src/adapters/cursor/mcp-config.ts +42 -0
  22. package/src/adapters/cursor/mcp-manager.ts +236 -0
  23. package/src/adapters/cursor/message-mapper.ts +46 -0
  24. package/src/adapters/cursor/native-exec-common.ts +55 -0
  25. package/src/adapters/cursor/native-exec-desktop.ts +177 -0
  26. package/src/adapters/cursor/native-exec-fs.ts +284 -0
  27. package/src/adapters/cursor/native-exec-mcp.ts +151 -0
  28. package/src/adapters/cursor/native-exec-network.ts +32 -0
  29. package/src/adapters/cursor/native-exec-shell.ts +191 -0
  30. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  31. package/src/adapters/cursor/native-exec.ts +177 -0
  32. package/src/adapters/cursor/protobuf-events.ts +309 -0
  33. package/src/adapters/cursor/protobuf-request.ts +347 -0
  34. package/src/adapters/cursor/request-builder.ts +98 -0
  35. package/src/adapters/cursor/tool-definitions.ts +301 -0
  36. package/src/adapters/cursor/transport-retry.ts +116 -0
  37. package/src/adapters/cursor/transport.ts +47 -0
  38. package/src/adapters/cursor/types.ts +36 -0
  39. package/src/adapters/cursor.ts +99 -0
  40. package/src/adapters/google.ts +7 -1
  41. package/src/adapters/kiro.ts +15 -0
  42. package/src/adapters/openai-chat.ts +7 -2
  43. package/src/adapters/run-turn-queue.ts +58 -0
  44. package/src/adapters/tool-catalog-nudge.ts +71 -0
  45. package/src/bridge.ts +7 -1
  46. package/src/cli-help.ts +9 -2
  47. package/src/cli-status.ts +7 -5
  48. package/src/cli.ts +122 -79
  49. package/src/codex-catalog.ts +213 -71
  50. package/src/codex-history-provider.ts +31 -14
  51. package/src/codex-inject.ts +17 -9
  52. package/src/codex-paths.ts +2 -1
  53. package/src/codex-shim.ts +30 -7
  54. package/src/codex-sync.ts +70 -0
  55. package/src/config.ts +58 -2
  56. package/src/doctor.ts +4 -2
  57. package/src/index.ts +1 -0
  58. package/src/model-cache.ts +22 -2
  59. package/src/oauth/callback-server.ts +44 -16
  60. package/src/oauth/cursor.ts +188 -0
  61. package/src/oauth/index.ts +29 -3
  62. package/src/oauth/key-providers.ts +20 -33
  63. package/src/oauth/login-cli.ts +7 -4
  64. package/src/open-url.ts +5 -1
  65. package/src/ports.ts +13 -0
  66. package/src/process-control.ts +76 -0
  67. package/src/provider-label.ts +10 -5
  68. package/src/providers/derive.ts +30 -3
  69. package/src/providers/registry.ts +39 -1
  70. package/src/proxy-liveness.ts +122 -0
  71. package/src/responses/parser.ts +1 -0
  72. package/src/responses/state.ts +83 -0
  73. package/src/router.ts +38 -23
  74. package/src/server/adapter-resolve.ts +3 -0
  75. package/src/server.ts +130 -18
  76. package/src/service.ts +94 -32
  77. package/src/types.ts +24 -1
  78. package/src/update-job.ts +360 -0
  79. package/src/update.ts +73 -11
  80. package/src/usage-log.ts +3 -3
  81. package/src/usage-summary.ts +3 -2
  82. package/src/win-paths.ts +68 -0
  83. package/gui/dist/assets/index-DIBiVVC0.css +0 -1
  84. 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
+ }