opencode-codex-control 0.0.0-tegami-trusted-publish-setup → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saatvik Arya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rhys Sullivan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,24 @@
1
+ # NOTICE
2
+
3
+ This project is licensed under the MIT License (see `LICENSE`). It includes
4
+ code adapted from a third party, reproduced here with its license.
5
+
6
+ ## Executor
7
+
8
+ Portions of this project are derived from **Executor**
9
+ (<https://github.com/RhysSullivan/executor>), which is licensed under the MIT
10
+ License, Copyright (c) 2026 Rhys Sullivan. The full license text is in
11
+ `LICENSE-EXECUTOR`.
12
+
13
+ Specifically, these files adapt Executor's MCP plugin SDK
14
+ (`packages/plugins/mcp/src/sdk/`):
15
+
16
+ | This project | Derived from Executor |
17
+ | ------------------------------- | -------------------------------- |
18
+ | `src/tools/computer-use.ts` | `codex-sky-tools.ts` |
19
+ | `src/tools/chrome.ts` | `codex-browser-tools.ts` |
20
+ | `src/codex/repl.ts` | `codex-repl.ts` |
21
+ | `src/codex/permissions.ts` | `codex-permissions.ts` |
22
+
23
+ The Chrome surface was retargeted to the current Chrome plugin API (the `ax`
24
+ and `playwright` namespaces) rather than Executor's original `dom_cua` surface.
package/README.md CHANGED
@@ -1,6 +1,132 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # opencode-codex-control
2
+
3
+ An [OpenCode](https://opencode.ai) V2 plugin that exposes **Codex Computer Use**
4
+ and **Codex Chrome** as native tools, by bridging Codex's `app-server` JSON-RPC.
5
+
6
+ > [!IMPORTANT]
7
+ > Unofficial. Not affiliated with OpenAI, the Codex team, or the OpenCode team.
8
+ > It drives an existing Codex install on your machine and requires the same
9
+ > plugins and macOS permissions Codex itself needs. Codex Computer Use is
10
+ > macOS-only; Chrome control depends on Codex's bundled Chrome plugin.
11
+
12
+ ## Why a plugin and not an MCP server
13
+
14
+ Codex's Computer Use and Chrome plugins are not MCP servers. Computer Use ships
15
+ as a `node-repl` content variant, Chrome ships no server at all, and the
16
+ Computer Use service only honours calls from a Codex-host session. The working
17
+ path is `codex app-server` calling the bundled `node_repl` server's `js` tool,
18
+ which imports `@oai/sky` or Chrome's `browser-client.mjs`.
19
+
20
+ This plugin owns one `codex app-server` connection, compiles each typed tool
21
+ call into the JavaScript program that performs it, and registers the results
22
+ with OpenCode's tool registry. It has no runtime dependencies and spawns
23
+ nothing until the first tool call.
24
+
25
+ ## Tools
26
+
27
+ - `computer_use.*` — macOS desktop control: `list_apps`, `get_app_state`,
28
+ `click`, `type_text`, `press_key`, `paste`, `scroll`, `drag`, `select_text`,
29
+ `set_value`, `perform_secondary_action`.
30
+ - `chrome.*` — the real Chrome: `list_tabs`, `new_tab`, `navigate`, `page_info`,
31
+ `read_page`, `read_dom`, `click`, `type_text`, `press_key`, `scroll`,
32
+ `set_value`, `select_text`, `perform_secondary_action`, `drag`,
33
+ `find_elements`, `go_back`, `go_forward`, `reload`, `close_tab`,
34
+ `export_content`.
35
+
36
+ Interaction uses the accessibility (`ax`) API and `playwright` for reads; the
37
+ `dom_cua`/`cua` namespaces are filtered out on Chrome's extension backend.
38
+
39
+ ## Requirements
40
+
41
+ - OpenCode V2 (`opencode2`).
42
+ - The `codex` CLI on `PATH` (or set `codexCli`).
43
+ - Computer Use: the shared "Codex Computer Use" app under `$CODEX_HOME`.
44
+ - Chrome: Codex's bundled Chrome plugin and the ChatGPT browser extension, used
45
+ once inside Codex.
46
+
47
+ Each tool reports an ordered setup path when its surface is not installed.
48
+
49
+ ## Install
50
+
51
+ From npm, in `~/.config/opencode/opencode.jsonc`:
52
+
53
+ ```jsonc
54
+ {
55
+ "$schema": "https://opencode.ai/config.json",
56
+ "plugins": ["opencode-codex-control"]
57
+ }
58
+ ```
59
+
60
+ From a local checkout (useful while developing):
61
+
62
+ ```jsonc
63
+ {
64
+ "plugins": ["/absolute/path/to/opencode-codex-control"]
65
+ }
66
+ ```
67
+
68
+ ## Options
69
+
70
+ ```jsonc
71
+ {
72
+ "plugins": [
73
+ {
74
+ "package": "opencode-codex-control",
75
+ "options": {
76
+ "codexHome": "~/.codex",
77
+ "codexCli": "codex",
78
+ "computerUse": true,
79
+ "chrome": true
80
+ }
81
+ }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ ## Permissions and approvals
87
+
88
+ Every tool carries `options.permission` (`computer_use` or `chrome`), so the
89
+ OpenCode permission layer is the consent boundary. To require a prompt for
90
+ either surface:
91
+
92
+ ```jsonc
93
+ {
94
+ "permissions": [{ "action": "chrome", "resource": "*", "effect": "ask" }]
95
+ }
96
+ ```
97
+
98
+ Codex's own approval prompts (for example Chrome's per-site access) arrive as
99
+ `mcpServer/elicitation/request`. Because the OpenCode tool call has already been
100
+ allowed, the plugin answers them automatically and logs the origin as
101
+ `[codex-control] Codex approval accept (…)`. Treat this as coarse consent for
102
+ now; finer-grained grants are a follow-up.
103
+
104
+ ## Development
105
+
106
+ ```sh
107
+ bun install
108
+ bun run typecheck
109
+ bun test
110
+ ```
111
+
112
+ `bun run smoke` exercises the real bridge end to end (it needs a working Codex
113
+ install and will launch apps or open browser tabs), so it is not part of CI.
114
+
115
+ ## Layout
116
+
117
+ ```
118
+ index.ts # entrypoint the OpenCode loader imports
119
+ src/plugin.ts # plugin definition + tool registration
120
+ src/controller.ts # connection lifecycle; call -> result
121
+ src/codex/appserver.ts # codex app-server child + JSON-RPC client
122
+ src/codex/install.ts # CLI/install discovery
123
+ src/codex/permissions.ts # macOS TCC denial -> setup instructions
124
+ src/codex/repl.ts # safe argument encoding + JSON result wrapper
125
+ src/tools/computer-use.ts
126
+ src/tools/chrome.ts
127
+ ```
128
+
129
+ ## License and attribution
130
+
131
+ MIT. Portions are adapted from [Executor](https://github.com/RhysSullivan/executor)
132
+ (MIT, Copyright (c) 2026 Rhys Sullivan) — see `NOTICE` and `LICENSE-EXECUTOR`.
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/plugin";
package/package.json CHANGED
@@ -1,5 +1,57 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "opencode-codex-control",
3
- "version": "0.0.0-tegami-trusted-publish-setup",
4
- "description": "Placeholder published by Tegami for npm trusted publishing setup."
4
+ "version": "0.1.0",
5
+ "description": "OpenCode plugin that exposes Codex Computer Use and Chrome as native tools",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Saatvik Arya",
9
+ "homepage": "https://github.com/aryasaatvik/opencode-codex-control#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/aryasaatvik/opencode-codex-control.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/aryasaatvik/opencode-codex-control/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "codex",
21
+ "computer-use",
22
+ "chrome",
23
+ "desktop-automation",
24
+ "browser-automation"
25
+ ],
26
+ "exports": {
27
+ ".": "./index.ts"
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "src",
32
+ "README.md",
33
+ "LICENSE",
34
+ "LICENSE-EXECUTOR",
35
+ "NOTICE"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "bun test",
44
+ "smoke": "bun scripts/smoke.ts",
45
+ "tegami": "bun scripts/tegami.mts",
46
+ "version:packages": "bun run tegami version",
47
+ "release:check": "bun run typecheck && bun test",
48
+ "release": "bun run release:check && bun run tegami publish"
49
+ },
50
+ "devDependencies": {
51
+ "@opencode/plugin": "0.0.0-beta-19425",
52
+ "@types/bun": "1.3.9",
53
+ "@types/node": "24.12.2",
54
+ "tegami": "1.4.0",
55
+ "typescript": "5.8.2"
56
+ }
5
57
  }
@@ -0,0 +1,415 @@
1
+ // Codex app-server bridge.
2
+ //
3
+ // Computer Use and Chrome are not MCP servers a client can spawn. Since the
4
+ // 2026-08-28 Codex update the Computer Use service only honours calls from a
5
+ // session registered by a Codex host process, and Chrome ships no server at
6
+ // all. The supported path is `codex app-server` — Codex's own JSON-RPC front
7
+ // end — whose `mcpServer/tool/call` invokes a plugin tool directly with no
8
+ // model turn and no inference.
9
+ //
10
+ // Both surfaces are driven through the bundled `node_repl` server's `js`
11
+ // tool: the program compiled by `tools/computer-use`/`tools/chrome` runs
12
+ // inside it, imports `@oai/sky` or the Chrome browser client, and writes one
13
+ // JSON value back. Chrome additionally refuses to run without Codex turn
14
+ // metadata, which this bridge supplies because it starts no turns.
15
+ //
16
+ // The wire is newline-delimited JSON managed here. It is NOT MCP-shaped
17
+ // (`_meta: null`, its own notification families), so it cannot go through a
18
+ // generic MCP stdio transport.
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { randomUUID } from "node:crypto";
22
+
23
+ import { permissionFailure, permissionFailureMessage } from "./permissions";
24
+
25
+ export interface ElicitationRequest {
26
+ readonly message: string;
27
+ readonly mode?: string;
28
+ readonly url?: string;
29
+ readonly meta: Record<string, unknown>;
30
+ readonly requestedSchema?: unknown;
31
+ }
32
+
33
+ export type ElicitationAction = "accept" | "decline" | "cancel";
34
+
35
+ export interface CodexAppServerConfig {
36
+ /** The `codex` CLI to spawn. */
37
+ readonly codexCli: string;
38
+ /** Value for the child's `CODEX_HOME`; omitted leaves the inherited one. */
39
+ readonly codexHome?: string;
40
+ /**
41
+ * How to answer a plugin approval prompt. Defaults to accepting: the
42
+ * OpenCode tool call the user already allowed is the consent boundary, so
43
+ * Codex's own prompt is redundant here.
44
+ */
45
+ readonly onElicitation?: (
46
+ request: ElicitationRequest,
47
+ ) => ElicitationAction | Promise<ElicitationAction>;
48
+ /** Diagnostics: startup transitions and other notifications. */
49
+ readonly onLog?: (message: string) => void;
50
+ }
51
+
52
+ export interface ToolCallResult {
53
+ /** The joined text content of the tool call. */
54
+ readonly text: string;
55
+ readonly isError: boolean;
56
+ readonly structuredContent?: unknown;
57
+ readonly meta?: unknown;
58
+ }
59
+
60
+ interface Pending {
61
+ resolve: (value: unknown) => void;
62
+ reject: (error: Error) => void;
63
+ }
64
+
65
+ interface RpcMessage {
66
+ id?: number | string;
67
+ method?: string;
68
+ params?: unknown;
69
+ result?: unknown;
70
+ error?: unknown;
71
+ }
72
+
73
+ const INITIALIZE_TIMEOUT_MS = 30_000;
74
+ const DEFAULT_CALL_TIMEOUT_MS = 60_000;
75
+
76
+ /** `mcpServer/startupStatus/updated` — Codex reports every server it runs. */
77
+ const STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
78
+
79
+ export class CodexAppServer {
80
+ readonly #config: CodexAppServerConfig;
81
+ #child: ReturnType<typeof spawn> | undefined;
82
+ #stdoutBuffer = "";
83
+ #stderrTail = "";
84
+ #nextId = 1;
85
+ #threadId: string | undefined;
86
+ #starting: Promise<void> | undefined;
87
+ #ready = false;
88
+ #closed = false;
89
+ #queue: Promise<unknown> = Promise.resolve();
90
+ readonly #pending = new Map<number, Pending>();
91
+
92
+ constructor(config: CodexAppServerConfig) {
93
+ this.#config = config;
94
+ }
95
+
96
+ get threadId(): string | undefined {
97
+ return this.#threadId;
98
+ }
99
+
100
+ /** Start and hand-shake on first use; a warm instance is a no-op. */
101
+ async ensureReady(): Promise<void> {
102
+ if (this.#closed) throw new Error("The Codex app-server connection has been closed.");
103
+ if (this.#ready) return;
104
+ this.#starting ??= this.#handshake();
105
+ await this.#starting;
106
+ }
107
+
108
+ async #handshake(): Promise<void> {
109
+ let child: ReturnType<typeof spawn>;
110
+ try {
111
+ child = spawn(this.#config.codexCli, ["app-server"], {
112
+ stdio: ["pipe", "pipe", "pipe"],
113
+ env: {
114
+ ...process.env,
115
+ ...(this.#config.codexHome === undefined ? {} : { CODEX_HOME: this.#config.codexHome }),
116
+ },
117
+ });
118
+ } catch (error) {
119
+ throw new Error(
120
+ `Could not start the Codex app-server with "${this.#config.codexCli}": ${
121
+ error instanceof Error ? error.message : String(error)
122
+ }`,
123
+ );
124
+ }
125
+ this.#child = child;
126
+ child.stdout?.setEncoding("utf8");
127
+ child.stdout?.on("data", (chunk: string) => this.#onStdout(chunk));
128
+ child.stderr?.setEncoding("utf8");
129
+ child.stderr?.on("data", (chunk: string) => {
130
+ // Keep a short tail so a startup failure can explain itself.
131
+ this.#stderrTail = (this.#stderrTail + chunk).slice(-2000);
132
+ });
133
+ child.stdin?.on("error", () => undefined);
134
+ child.stdout?.on("error", () => undefined);
135
+ child.on("error", (error: Error) => this.#failAll(error));
136
+ child.on("exit", () => this.#failAll(new Error("Codex app-server exited.")));
137
+
138
+ await this.#request(
139
+ "initialize",
140
+ { clientInfo: { name: "opencode", title: "OpenCode", version: "0.1.0" } },
141
+ INITIALIZE_TIMEOUT_MS,
142
+ );
143
+ this.#notify("initialized", {});
144
+ const started = (await this.#request(
145
+ "thread/start",
146
+ // `approvalPolicy: "on-request"` is load-bearing: on a thread whose
147
+ // policy declines MCP elicitations, Codex answers every plugin prompt
148
+ // itself and never forwards it, surfacing as an unexplained denial on
149
+ // any tool that asks.
150
+ { sessionStartSource: "startup", approvalPolicy: "on-request" },
151
+ INITIALIZE_TIMEOUT_MS,
152
+ )) as { thread?: { id?: string } } | undefined;
153
+ const threadId = started?.thread?.id;
154
+ if (typeof threadId !== "string") {
155
+ throw new Error("Codex app-server returned no thread id from thread/start.");
156
+ }
157
+ this.#threadId = threadId;
158
+ this.#ready = true;
159
+ }
160
+
161
+ /** Call one tool on one Codex MCP server. */
162
+ async callTool(
163
+ server: string,
164
+ tool: string,
165
+ args: unknown,
166
+ options?: { readonly timeoutMs?: number },
167
+ ): Promise<ToolCallResult> {
168
+ await this.ensureReady();
169
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
170
+ return this.#serialize(async () => {
171
+ const reply = await this.#request(
172
+ "mcpServer/tool/call",
173
+ {
174
+ threadId: this.#threadId,
175
+ server,
176
+ tool,
177
+ arguments: args ?? {},
178
+ // Codex stamps a REPL call with the turn that issued it, and the
179
+ // Chrome client refuses to run without it. This bridge starts no
180
+ // turns, so it supplies the same shape: the pooled thread is the
181
+ // session, each call is its own turn.
182
+ _meta: {
183
+ "x-codex-turn-metadata": {
184
+ session_id: this.#threadId,
185
+ turn_id: randomUUID(),
186
+ },
187
+ },
188
+ },
189
+ timeoutMs,
190
+ );
191
+ return normalizeToolCall(reply);
192
+ });
193
+ }
194
+
195
+ /** Run one JavaScript program in Codex's `node_repl`. */
196
+ async callJs(program: {
197
+ readonly code: string;
198
+ readonly title: string;
199
+ readonly timeoutMs?: number;
200
+ }): Promise<ToolCallResult> {
201
+ const timeoutMs = program.timeoutMs ?? 30_000;
202
+ return this.callTool(
203
+ "node_repl",
204
+ "js",
205
+ {
206
+ code: program.code,
207
+ title: program.title,
208
+ ...(program.timeoutMs === undefined ? {} : { timeout_ms: program.timeoutMs }),
209
+ },
210
+ // Leave headroom under the caller so a slow page fails as a page error
211
+ // rather than an abandoned request.
212
+ { timeoutMs: timeoutMs + 20_000 },
213
+ );
214
+ }
215
+
216
+ async close(): Promise<void> {
217
+ this.#closed = true;
218
+ const child = this.#child;
219
+ this.#child = undefined;
220
+ if (child === undefined) return;
221
+ child.stdin?.end();
222
+ child.kill("SIGTERM");
223
+ const escalate = setTimeout(() => child.kill("SIGKILL"), 3000);
224
+ escalate.unref?.();
225
+ }
226
+
227
+ /** Serialize calls: one real desktop and one real browser can only be
228
+ * driven by one action at a time, however many OpenCode sessions exist. */
229
+ #serialize<T>(fn: () => Promise<T>): Promise<T> {
230
+ const next = this.#queue.then(fn, fn);
231
+ this.#queue = next.then(
232
+ () => undefined,
233
+ () => undefined,
234
+ );
235
+ return next;
236
+ }
237
+
238
+ #request(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
239
+ const child = this.#child;
240
+ if (child === undefined || this.#closed) {
241
+ return Promise.reject(new Error("Codex app-server is not running."));
242
+ }
243
+ const id = this.#nextId++;
244
+ return new Promise<unknown>((resolve, reject) => {
245
+ const timer = setTimeout(() => {
246
+ this.#pending.delete(id);
247
+ const tail = this.#stderrTail.trim();
248
+ reject(
249
+ new Error(
250
+ `Codex app-server did not answer ${method} within ${timeoutMs}ms.${
251
+ tail.length === 0 ? "" : `\n${tail}`
252
+ }`,
253
+ ),
254
+ );
255
+ }, timeoutMs);
256
+ this.#pending.set(id, {
257
+ resolve: (value) => {
258
+ clearTimeout(timer);
259
+ resolve(value);
260
+ },
261
+ reject: (error) => {
262
+ clearTimeout(timer);
263
+ reject(error);
264
+ },
265
+ });
266
+ this.#send({ jsonrpc: "2.0", id, method, params });
267
+ });
268
+ }
269
+
270
+ #notify(method: string, params: unknown): void {
271
+ this.#send({ jsonrpc: "2.0", method, params });
272
+ }
273
+
274
+ #send(message: unknown): void {
275
+ this.#child?.stdin?.write(`${JSON.stringify(message)}\n`, () => undefined);
276
+ }
277
+
278
+ #onStdout(chunk: string): void {
279
+ this.#stdoutBuffer += chunk;
280
+ const lines = this.#stdoutBuffer.split("\n");
281
+ this.#stdoutBuffer = lines.pop() ?? "";
282
+ for (const line of lines) {
283
+ const trimmed = line.trim();
284
+ if (trimmed.length === 0) continue;
285
+ let message: RpcMessage;
286
+ try {
287
+ message = JSON.parse(trimmed) as RpcMessage;
288
+ } catch {
289
+ continue;
290
+ }
291
+ this.#dispatch(message);
292
+ }
293
+ }
294
+
295
+ #dispatch(message: RpcMessage): void {
296
+ const isResponse =
297
+ message.method === undefined &&
298
+ typeof message.id === "number" &&
299
+ (message.result !== undefined || message.error !== undefined);
300
+ if (isResponse) {
301
+ const pending = this.#pending.get(message.id as number);
302
+ if (pending === undefined) return;
303
+ this.#pending.delete(message.id as number);
304
+ if (message.error === undefined) {
305
+ pending.resolve(message.result);
306
+ } else {
307
+ pending.reject(new Error(rpcErrorMessage(message.error)));
308
+ }
309
+ return;
310
+ }
311
+ if (message.method !== undefined && message.id !== undefined) {
312
+ void this.#handleServerRequest(message);
313
+ return;
314
+ }
315
+ if (message.method !== undefined) {
316
+ this.#handleNotification(message.method, message.params);
317
+ }
318
+ }
319
+
320
+ async #handleServerRequest(message: RpcMessage): Promise<void> {
321
+ if (message.method === "mcpServer/elicitation/request") {
322
+ const action = await this.#answerElicitation(message.params);
323
+ this.#send({ jsonrpc: "2.0", id: message.id, result: { action } });
324
+ return;
325
+ }
326
+ // The bridge starts no turns, so there is nothing else it can answer.
327
+ this.#send({
328
+ jsonrpc: "2.0",
329
+ id: message.id,
330
+ error: { code: -32601, message: `OpenCode does not handle ${message.method}` },
331
+ });
332
+ }
333
+
334
+ async #answerElicitation(rawParams: unknown): Promise<ElicitationAction> {
335
+ const params = (rawParams ?? {}) as Record<string, unknown>;
336
+ const meta =
337
+ params["_meta"] !== null && typeof params["_meta"] === "object"
338
+ ? (params["_meta"] as Record<string, unknown>)
339
+ : {};
340
+ const connector =
341
+ typeof meta["connector_name"] === "string" ? (meta["connector_name"] as string) : undefined;
342
+ const message =
343
+ typeof params["message"] === "string"
344
+ ? (params["message"] as string)
345
+ : `Approve this ${connector ?? "Codex"} request?`;
346
+ const request: ElicitationRequest = {
347
+ message,
348
+ mode: typeof params["mode"] === "string" ? (params["mode"] as string) : undefined,
349
+ url: typeof params["url"] === "string" ? (params["url"] as string) : undefined,
350
+ meta,
351
+ requestedSchema: params["requestedSchema"],
352
+ };
353
+ const decided = await this.#config.onElicitation?.(request);
354
+ this.#config.onLog?.(
355
+ `Codex approval ${decided ?? "accept"}${connector === undefined ? "" : ` (${connector})`}: ${message}`,
356
+ );
357
+ return decided ?? "accept";
358
+ }
359
+
360
+ #handleNotification(method: string, params: unknown): void {
361
+ if (method !== STARTUP_STATUS_METHOD) return;
362
+ const entry = (params ?? {}) as { name?: unknown; status?: unknown };
363
+ if (typeof entry.name !== "string" || typeof entry.status !== "string") return;
364
+ this.#config.onLog?.(`Codex MCP server ${entry.name} is ${entry.status}`);
365
+ }
366
+
367
+ #failAll(error: Error): void {
368
+ this.#ready = false;
369
+ for (const pending of this.#pending.values()) pending.reject(error);
370
+ this.#pending.clear();
371
+ }
372
+ }
373
+
374
+ const normalizeToolCall = (raw: unknown): ToolCallResult => {
375
+ const result = (raw ?? {}) as {
376
+ content?: unknown;
377
+ structuredContent?: unknown;
378
+ isError?: unknown;
379
+ _meta?: unknown;
380
+ };
381
+ const content = Array.isArray(result.content) ? result.content : [];
382
+ const text = content
383
+ .map((block) => {
384
+ const b = block as { text?: unknown };
385
+ return typeof b.text === "string" ? b.text : "";
386
+ })
387
+ .filter((value) => value.length > 0)
388
+ .join("\n");
389
+ return {
390
+ text,
391
+ isError: result.isError === true,
392
+ ...(result.structuredContent === undefined
393
+ ? {}
394
+ : { structuredContent: result.structuredContent }),
395
+ ...(result._meta === undefined ? {} : { meta: result._meta }),
396
+ };
397
+ };
398
+
399
+ const rpcErrorMessage = (error: unknown): string => {
400
+ const e = (error ?? {}) as { message?: unknown; data?: unknown };
401
+ const message = typeof e.message === "string" ? e.message : JSON.stringify(error);
402
+ return typeof e.data === "string" && e.data.length > 0 ? `${message} (${e.data})` : message;
403
+ };
404
+
405
+ /** Throw an actionable error for a failed tool call, translating macOS
406
+ * permission denials into the exact System Settings entry to enable. */
407
+ export const toolCallError = (
408
+ result: ToolCallResult,
409
+ surface: string | undefined,
410
+ ): Error | undefined => {
411
+ if (!result.isError) return undefined;
412
+ const text = result.text.length > 0 ? result.text : "The Codex tool call failed.";
413
+ const permission = permissionFailure(text, surface);
414
+ return new Error(permission === null ? text : permissionFailureMessage(permission));
415
+ };