@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,42 @@
1
+ import type { OcxProviderConfig } from "../../types";
2
+
3
+ /**
4
+ * One MCP server opencodex starts/connects and exposes to the Cursor agent as callable tools.
5
+ * Either `command` (stdio: opencodex spawns the server as a child process) or `url`
6
+ * (streamable-http: opencodex connects to a remote MCP server) must be set.
7
+ */
8
+ export interface CursorMcpServerConfig {
9
+ /** stdio: executable to spawn (e.g. "npx", "node", "uvx"). */
10
+ command?: string;
11
+ /** stdio: arguments for the spawned command. */
12
+ args?: string[];
13
+ /** stdio: extra environment variables for the child process. */
14
+ env?: Record<string, string>;
15
+ /** stdio: working directory for the child process. */
16
+ cwd?: string;
17
+ /** streamable-http: remote MCP server URL (alternative to `command`). */
18
+ url?: string;
19
+ /** streamable-http: extra headers for the remote connection. */
20
+ headers?: Record<string, string>;
21
+ /** Set false to keep the server in config but not connect. Default true. */
22
+ enabled?: boolean;
23
+ /** Optional namespace prepended to advertised tool names to avoid collisions. */
24
+ toolPrefix?: string;
25
+ }
26
+
27
+ export interface ResolvedMcpServer extends CursorMcpServerConfig {
28
+ serverName: string;
29
+ }
30
+
31
+ /**
32
+ * Resolve the enabled, connectable MCP servers from a provider config. A server is
33
+ * connectable only if it declares either a `command` (stdio) or a `url` (http).
34
+ */
35
+ export function resolveMcpServers(provider: OcxProviderConfig): ResolvedMcpServer[] {
36
+ const raw = provider.mcpServers;
37
+ if (!raw) return [];
38
+ return Object.entries(raw)
39
+ .map(([serverName, cfg]) => ({ serverName, ...cfg }))
40
+ .filter(server => server.enabled !== false)
41
+ .filter(server => Boolean(server.command || server.url));
42
+ }
@@ -0,0 +1,236 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import type { ResolvedMcpServer } from "./mcp-config";
6
+
7
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
8
+ const DEFAULT_CALL_TIMEOUT_MS = 120_000;
9
+
10
+ /** A tool discovered on a connected MCP server, with its opencodex-advertised name. */
11
+ export interface McpToolHandle {
12
+ serverName: string;
13
+ toolName: string;
14
+ /** Name advertised to the Cursor agent (toolPrefix applied). */
15
+ advertisedName: string;
16
+ description: string;
17
+ inputSchema: unknown;
18
+ }
19
+
20
+ /** Normalized MCP tool-call result (SDK-shape-agnostic). */
21
+ export interface McpCallResult {
22
+ isError: boolean;
23
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
24
+ }
25
+
26
+ export interface McpResourceListing {
27
+ uri: string;
28
+ name?: string;
29
+ description?: string;
30
+ mimeType?: string;
31
+ server: string;
32
+ }
33
+
34
+ export interface McpResourceContent {
35
+ uri: string;
36
+ mimeType?: string;
37
+ text?: string;
38
+ blob?: Uint8Array;
39
+ }
40
+
41
+ export interface CursorMcpManagerOptions {
42
+ connectTimeoutMs?: number;
43
+ callTimeoutMs?: number;
44
+ /** Test seam: provide a transport factory instead of spawning real processes. */
45
+ transportFactory?: (server: ResolvedMcpServer) => Transport;
46
+ log?: (message: string) => void;
47
+ }
48
+
49
+ interface ConnectedServer {
50
+ server: ResolvedMcpServer;
51
+ client: Client;
52
+ }
53
+
54
+ /**
55
+ * Owns the lifecycle of MCP client connections for one Cursor stream. Lazily connects to the
56
+ * configured servers, discovers their tools/resources, and executes tool/resource calls.
57
+ *
58
+ * Connection failures are isolated per-server: one unreachable server never blocks the others
59
+ * and never throws out of `ensureConnected`. Tool-level errors from a server resolve as
60
+ * `{ isError: true }` (they do not throw); only protocol/transport failures throw from
61
+ * `callTool`/`listResources`/`readResource`, and callers are expected to map those to typed
62
+ * protobuf error results.
63
+ */
64
+ export class CursorMcpManager {
65
+ private readonly connectTimeoutMs: number;
66
+ private readonly callTimeoutMs: number;
67
+ private connected?: Promise<void>;
68
+ private readonly servers = new Map<string, ConnectedServer>();
69
+ /** advertisedName -> { serverName, original toolName } */
70
+ private readonly toolIndex = new Map<string, { serverName: string; toolName: string; handle: McpToolHandle }>();
71
+
72
+ constructor(
73
+ private readonly resolved: ResolvedMcpServer[],
74
+ private readonly options: CursorMcpManagerOptions = {},
75
+ ) {
76
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
77
+ this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
78
+ }
79
+
80
+ /** Idempotent, lazy connect + tool discovery across all servers. Never throws. */
81
+ ensureConnected(): Promise<void> {
82
+ if (!this.connected) this.connected = this.connectAll();
83
+ return this.connected;
84
+ }
85
+
86
+ private async connectAll(): Promise<void> {
87
+ await Promise.all(this.resolved.map(server => this.connectOne(server)));
88
+ }
89
+
90
+ private async connectOne(server: ResolvedMcpServer): Promise<void> {
91
+ try {
92
+ const transport = this.options.transportFactory
93
+ ? this.options.transportFactory(server)
94
+ : this.createTransport(server);
95
+ const client = new Client({ name: "opencodex", version: "1.0.0" });
96
+ await this.withTimeout(client.connect(transport), this.connectTimeoutMs, `connect ${server.serverName}`);
97
+ this.servers.set(server.serverName, { server, client });
98
+ await this.indexTools(server, client);
99
+ } catch (err) {
100
+ this.options.log?.(`[cursor-mcp] server "${server.serverName}" failed to connect: ${errText(err)}`);
101
+ }
102
+ }
103
+
104
+ private createTransport(server: ResolvedMcpServer): Transport {
105
+ if (server.command) {
106
+ return new StdioClientTransport({
107
+ command: server.command,
108
+ args: server.args ?? [],
109
+ env: server.env,
110
+ cwd: server.cwd,
111
+ });
112
+ }
113
+ if (server.url) {
114
+ return new StreamableHTTPClientTransport(new URL(server.url), {
115
+ requestInit: server.headers ? { headers: server.headers } : undefined,
116
+ });
117
+ }
118
+ throw new Error(`MCP server "${server.serverName}" has neither command nor url`);
119
+ }
120
+
121
+ private async indexTools(server: ResolvedMcpServer, client: Client): Promise<void> {
122
+ const prefix = server.toolPrefix ?? "";
123
+ const { tools } = await this.withTimeout(client.listTools(), this.connectTimeoutMs, `listTools ${server.serverName}`);
124
+ for (const tool of tools ?? []) {
125
+ const advertisedName = `${prefix}${tool.name}`;
126
+ const handle: McpToolHandle = {
127
+ serverName: server.serverName,
128
+ toolName: tool.name,
129
+ advertisedName,
130
+ description: tool.description ?? "",
131
+ inputSchema: tool.inputSchema ?? {},
132
+ };
133
+ this.toolIndex.set(advertisedName, { serverName: server.serverName, toolName: tool.name, handle });
134
+ }
135
+ }
136
+
137
+ async listToolHandles(): Promise<McpToolHandle[]> {
138
+ await this.ensureConnected();
139
+ return [...this.toolIndex.values()].map(entry => entry.handle);
140
+ }
141
+
142
+ async resolveTool(advertisedName: string): Promise<McpToolHandle | undefined> {
143
+ await this.ensureConnected();
144
+ return this.toolIndex.get(advertisedName)?.handle;
145
+ }
146
+
147
+ async toolNames(): Promise<string[]> {
148
+ await this.ensureConnected();
149
+ return [...this.toolIndex.keys()];
150
+ }
151
+
152
+ /** Throws only on protocol/transport failure or unknown tool; tool-level errors resolve. */
153
+ async callTool(advertisedName: string, args: Record<string, unknown>): Promise<McpCallResult> {
154
+ await this.ensureConnected();
155
+ const entry = this.toolIndex.get(advertisedName);
156
+ if (!entry) throw new Error(`MCP tool not found: ${advertisedName}`);
157
+ const conn = this.servers.get(entry.serverName);
158
+ if (!conn) throw new Error(`MCP server not connected: ${entry.serverName}`);
159
+ const result = await this.withTimeout(
160
+ conn.client.callTool({ name: entry.toolName, arguments: args }),
161
+ this.callTimeoutMs,
162
+ `callTool ${advertisedName}`,
163
+ );
164
+ return {
165
+ isError: Boolean((result as { isError?: boolean }).isError),
166
+ content: normalizeContent((result as { content?: unknown[] }).content),
167
+ };
168
+ }
169
+
170
+ async listResources(server?: string): Promise<McpResourceListing[]> {
171
+ await this.ensureConnected();
172
+ const targets = server ? [this.servers.get(server)].filter(Boolean) as ConnectedServer[] : [...this.servers.values()];
173
+ const out: McpResourceListing[] = [];
174
+ for (const conn of targets) {
175
+ const { resources } = await this.withTimeout(conn.client.listResources(), this.callTimeoutMs, `listResources ${conn.server.serverName}`);
176
+ for (const r of resources ?? []) {
177
+ out.push({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType, server: conn.server.serverName });
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ async readResource(server: string, uri: string): Promise<McpResourceContent> {
184
+ await this.ensureConnected();
185
+ const conn = this.servers.get(server);
186
+ if (!conn) throw new Error(`MCP server not connected: ${server}`);
187
+ const result = await this.withTimeout(conn.client.readResource({ uri }), this.callTimeoutMs, `readResource ${uri}`);
188
+ const first = (result.contents ?? [])[0] as { uri?: string; mimeType?: string; text?: string; blob?: string } | undefined;
189
+ if (!first) return { uri, mimeType: undefined, text: "" };
190
+ return {
191
+ uri: first.uri ?? uri,
192
+ mimeType: first.mimeType,
193
+ text: typeof first.text === "string" ? first.text : undefined,
194
+ blob: typeof first.blob === "string" ? Uint8Array.from(Buffer.from(first.blob, "base64")) : undefined,
195
+ };
196
+ }
197
+
198
+ async dispose(): Promise<void> {
199
+ const conns = [...this.servers.values()];
200
+ this.servers.clear();
201
+ this.toolIndex.clear();
202
+ await Promise.all(conns.map(async conn => {
203
+ try {
204
+ await conn.client.close();
205
+ } catch (err) {
206
+ this.options.log?.(`[cursor-mcp] dispose "${conn.server.serverName}": ${errText(err)}`);
207
+ }
208
+ }));
209
+ }
210
+
211
+ private async withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
212
+ let timer: ReturnType<typeof setTimeout> | undefined;
213
+ try {
214
+ return await Promise.race([
215
+ promise,
216
+ new Promise<never>((_, reject) => {
217
+ timer = setTimeout(() => reject(new Error(`MCP ${label} timed out after ${ms}ms`)), ms);
218
+ }),
219
+ ]);
220
+ } finally {
221
+ if (timer) clearTimeout(timer);
222
+ }
223
+ }
224
+ }
225
+
226
+ function normalizeContent(content: unknown[] | undefined): McpCallResult["content"] {
227
+ if (!Array.isArray(content)) return [];
228
+ return content.map(item => {
229
+ const block = item as { type?: string; text?: string; data?: string; mimeType?: string };
230
+ return { type: block.type ?? "text", text: block.text, data: block.data, mimeType: block.mimeType };
231
+ });
232
+ }
233
+
234
+ function errText(err: unknown): string {
235
+ return err instanceof Error ? err.message : String(err);
236
+ }
@@ -0,0 +1,46 @@
1
+ import type { AdapterEvent } from "../../types";
2
+ import { cursorExecResult } from "./exec-policy";
3
+ import type { CursorClientMessage, CursorServerMessage } from "./types";
4
+ import type { CursorKvStore } from "./kv-store";
5
+
6
+ export interface CursorMessageMapperState {
7
+ kv: CursorKvStore;
8
+ writeClient(message: CursorClientMessage): void;
9
+ }
10
+
11
+ export function mapCursorServerMessage(
12
+ message: CursorServerMessage,
13
+ state: CursorMessageMapperState,
14
+ ): AdapterEvent[] {
15
+ switch (message.type) {
16
+ case "text":
17
+ return [{ type: "text_delta", text: message.text }];
18
+ case "thinking":
19
+ return [{ type: "thinking_delta", thinking: message.thinking }];
20
+ case "tool_call_start":
21
+ return [{ type: "tool_call_start", id: message.id, name: message.name }];
22
+ case "tool_call_delta":
23
+ return [{ type: "tool_call_delta", arguments: message.arguments }];
24
+ case "tool_call_end":
25
+ return [{ type: "tool_call_end" }];
26
+ case "done":
27
+ return [{ type: "done", usage: message.usage }];
28
+ case "error":
29
+ return [{ type: "error", message: message.message, ...(message.usage ? { usage: message.usage } : {}) }];
30
+ case "heartbeat":
31
+ // Liveness only: keeps the bridge's stall watchdog from tripping upstream_stall_timeout while
32
+ // Cursor silently assembles (parallel) tool calls. The bridge resets stallTicks on any adapter
33
+ // event and ignores unknown event types, so this emits no Responses protocol event.
34
+ return [{ type: "heartbeat" }];
35
+ case "kv_get":
36
+ state.writeClient({ type: "kv_value", key: message.key, value: state.kv.get(message.key) });
37
+ return [];
38
+ case "kv_set":
39
+ state.kv.set(message.key, message.value);
40
+ state.writeClient({ type: "kv_stored", key: message.key });
41
+ return [];
42
+ case "exec":
43
+ state.writeClient(cursorExecResult(message.requestId, message.execCase));
44
+ return [];
45
+ }
46
+ }
@@ -0,0 +1,55 @@
1
+ import { create, toBinary } from "@bufbuild/protobuf";
2
+ import {
3
+ AgentClientMessageSchema,
4
+ ExecClientControlMessageSchema,
5
+ ExecClientMessageSchema,
6
+ ExecClientStreamCloseSchema,
7
+ type ExecClientMessage,
8
+ type ExecServerMessage,
9
+ } from "./gen/agent_pb";
10
+
11
+ export const textDecoder = new TextDecoder();
12
+ export const textEncoder = new TextEncoder();
13
+
14
+ export function clientBytes(message: Parameters<typeof create<typeof AgentClientMessageSchema>>[1]): Uint8Array {
15
+ return toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, message));
16
+ }
17
+
18
+ export function execBytes(execMsg: ExecServerMessage, messageCase: ExecClientMessage["message"]["case"], value: unknown): Uint8Array {
19
+ return clientBytes({
20
+ message: {
21
+ case: "execClientMessage",
22
+ value: create(ExecClientMessageSchema, {
23
+ id: execMsg.id,
24
+ execId: execMsg.execId,
25
+ message: { case: messageCase, value: value as never },
26
+ }),
27
+ },
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Exec-channel stream close acknowledgement (`execClientControlMessage.streamClose`). Cursor keeps
33
+ * a streamed exec (e.g. `shellStreamArgs`) — and with it the whole turn — pending until the client
34
+ * closes the exec stream; stream deltas and even the `exit` event alone are not treated as
35
+ * completion. Mirrors jawcode `sendExecClientStreamClose`.
36
+ */
37
+ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array {
38
+ return clientBytes({
39
+ message: {
40
+ case: "execClientControlMessage",
41
+ value: create(ExecClientControlMessageSchema, {
42
+ message: { case: "streamClose", value: create(ExecClientStreamCloseSchema, { id: execMsg.id }) },
43
+ }),
44
+ },
45
+ });
46
+ }
47
+
48
+ export function errorText(err: unknown): string {
49
+ return err instanceof Error ? err.message : String(err);
50
+ }
51
+
52
+ export function lineCount(text: string): number {
53
+ if (text.length === 0) return 0;
54
+ return text.split(/\r\n|\r|\n/).length;
55
+ }
@@ -0,0 +1,177 @@
1
+ import { spawn } from "node:child_process";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import {
4
+ ComputerUseErrorSchema,
5
+ ComputerUseResultSchema,
6
+ ComputerUseSuccessSchema,
7
+ RecordScreenDiscardSuccessSchema,
8
+ RecordScreenFailureSchema,
9
+ RecordScreenResultSchema,
10
+ RecordScreenSaveSuccessSchema,
11
+ RecordScreenStartSuccessSchema,
12
+ type ComputerUseArgs,
13
+ type ComputerUseResult,
14
+ type RecordScreenArgs,
15
+ type RecordScreenResult,
16
+ } from "./gen/agent_pb";
17
+ import { errorText } from "./native-exec-common";
18
+ import type { CursorNativeToolDeps } from "./native-exec-tools";
19
+
20
+ const DEFAULT_DESKTOP_TIMEOUT_MS = 30_000;
21
+
22
+ /**
23
+ * Opt-in external executor for computer-use / record-screen. opencodex is a headless proxy and
24
+ * cannot drive a screen itself; set these commands only when running on a host that can. Each
25
+ * command receives the request as JSON on stdin and must print a JSON result on stdout.
26
+ */
27
+ export interface DesktopExecutorConfig {
28
+ /** Command (run via `sh -c`) handling computer-use. Receives `{toolCallId, actions}` on stdin. */
29
+ computerUseCommand?: string;
30
+ /** Command handling record-screen. Receives `{mode, toolCallId, saveAsFilename?}` on stdin. */
31
+ recordScreenCommand?: string;
32
+ cwd?: string;
33
+ env?: Record<string, string>;
34
+ /** Max time to wait for the external process. Default 30s. */
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ /**
39
+ * Build `computerUse` / `recordScreen` deps from external executor commands. Returns `{}` when no
40
+ * command is configured (the dispatcher then falls back to the honest "not supported" default).
41
+ * Every method maps results to protobuf and NEVER throws — a throw would propagate into the
42
+ * stream loop and fail the conversation.
43
+ */
44
+ export function desktopDepsFromConfig(config?: DesktopExecutorConfig): CursorNativeToolDeps {
45
+ if (!config?.computerUseCommand && !config?.recordScreenCommand) return {};
46
+ const deps: CursorNativeToolDeps = {};
47
+ if (config.computerUseCommand) {
48
+ deps.computerUse = (args: ComputerUseArgs) => runComputerUse(config, args);
49
+ }
50
+ if (config.recordScreenCommand) {
51
+ deps.recordScreen = (args: RecordScreenArgs) => runRecordScreen(config, args);
52
+ }
53
+ return deps;
54
+ }
55
+
56
+ async function runComputerUse(config: DesktopExecutorConfig, args: ComputerUseArgs): Promise<ComputerUseResult> {
57
+ const actionCount = args.actions.length;
58
+ try {
59
+ const out = await runExternalJson(config.computerUseCommand!, {
60
+ toolCallId: args.toolCallId,
61
+ actions: args.actions,
62
+ }, config);
63
+ if (out && typeof out === "object" && "error" in out) {
64
+ return computerUseError(String((out as { error: unknown }).error), actionCount);
65
+ }
66
+ const result = out as { screenshot?: string; screenshotPath?: string; durationMs?: number; log?: string };
67
+ return create(ComputerUseResultSchema, {
68
+ result: { case: "success", value: create(ComputerUseSuccessSchema, {
69
+ actionCount,
70
+ durationMs: typeof result?.durationMs === "number" ? result.durationMs : 0,
71
+ screenshot: result?.screenshot,
72
+ screenshotPath: result?.screenshotPath,
73
+ log: result?.log,
74
+ }) },
75
+ });
76
+ } catch (err) {
77
+ return computerUseError(errorText(err), actionCount);
78
+ }
79
+ }
80
+
81
+ async function runRecordScreen(config: DesktopExecutorConfig, args: RecordScreenArgs): Promise<RecordScreenResult> {
82
+ try {
83
+ const out = await runExternalJson(config.recordScreenCommand!, {
84
+ mode: args.mode,
85
+ toolCallId: args.toolCallId,
86
+ saveAsFilename: args.saveAsFilename,
87
+ }, config) as Record<string, unknown>;
88
+ if (out?.startSuccess) {
89
+ const s = out.startSuccess as { wasPriorRecordingCancelled?: boolean; wasSaveAsFilenameIgnored?: boolean };
90
+ return create(RecordScreenResultSchema, { result: { case: "startSuccess", value: create(RecordScreenStartSuccessSchema, {
91
+ wasPriorRecordingCancelled: Boolean(s.wasPriorRecordingCancelled),
92
+ wasSaveAsFilenameIgnored: Boolean(s.wasSaveAsFilenameIgnored),
93
+ }) } });
94
+ }
95
+ if (out?.saveSuccess) {
96
+ const s = out.saveSuccess as { path?: string; recordingDurationMs?: number };
97
+ return create(RecordScreenResultSchema, { result: { case: "saveSuccess", value: create(RecordScreenSaveSuccessSchema, {
98
+ path: String(s.path ?? ""),
99
+ recordingDurationMs: BigInt(Math.trunc(s.recordingDurationMs ?? 0)),
100
+ }) } });
101
+ }
102
+ if (out?.discardSuccess) {
103
+ return create(RecordScreenResultSchema, { result: { case: "discardSuccess", value: create(RecordScreenDiscardSuccessSchema, {}) } });
104
+ }
105
+ const failure = out?.failure as { error?: unknown } | undefined;
106
+ return recordScreenFailure(failure?.error ? String(failure.error) : "record-screen executor returned no recognized result");
107
+ } catch (err) {
108
+ return recordScreenFailure(errorText(err));
109
+ }
110
+ }
111
+
112
+ function computerUseError(error: string, actionCount: number): ComputerUseResult {
113
+ return create(ComputerUseResultSchema, {
114
+ result: { case: "error", value: create(ComputerUseErrorSchema, { error, actionCount, durationMs: 0 }) },
115
+ });
116
+ }
117
+
118
+ function recordScreenFailure(error: string): RecordScreenResult {
119
+ return create(RecordScreenResultSchema, {
120
+ result: { case: "failure", value: create(RecordScreenFailureSchema, { error }) },
121
+ });
122
+ }
123
+
124
+ /** Spawn `command` via the shell, write `payload` as JSON to stdin, return parsed stdout JSON. */
125
+ function runExternalJson(command: string, payload: unknown, config: DesktopExecutorConfig): Promise<unknown> {
126
+ const timeoutMs = config.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
127
+ return new Promise((resolve, reject) => {
128
+ const child = spawn("sh", ["-c", command], {
129
+ cwd: config.cwd,
130
+ env: config.env ? { ...process.env, ...config.env } : process.env,
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ });
133
+ let stdout = "";
134
+ let stderr = "";
135
+ let settled = false;
136
+ const timer = setTimeout(() => {
137
+ if (settled) return;
138
+ settled = true;
139
+ child.kill("SIGKILL");
140
+ reject(new Error(`desktop executor timed out after ${timeoutMs}ms`));
141
+ }, timeoutMs);
142
+
143
+ child.stdout.on("data", chunk => { stdout += chunk.toString(); });
144
+ child.stderr.on("data", chunk => { stderr += chunk.toString(); });
145
+ child.on("error", err => {
146
+ if (settled) return;
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ reject(err);
150
+ });
151
+ child.on("close", code => {
152
+ if (settled) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ if (code !== 0) {
156
+ reject(new Error(`desktop executor exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
157
+ return;
158
+ }
159
+ try {
160
+ resolve(JSON.parse(stdout.trim()));
161
+ } catch {
162
+ reject(new Error(`desktop executor produced invalid JSON: ${stdout.slice(0, 200)}`));
163
+ }
164
+ });
165
+
166
+ try {
167
+ child.stdin.write(JSON.stringify(payload));
168
+ child.stdin.end();
169
+ } catch (err) {
170
+ if (!settled) {
171
+ settled = true;
172
+ clearTimeout(timer);
173
+ reject(err);
174
+ }
175
+ }
176
+ });
177
+ }