@minhspark/codex-mcp-bridge 1.12.4 → 1.13.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/CHANGELOG.md +29 -0
- package/README.md +20 -4
- package/package.json +1 -1
- package/scripts/check-claude-bridge.mjs +1 -0
- package/scripts/check.mjs +2 -0
- package/scripts/install-claude-desktop.mjs +2 -0
- package/scripts/install-native-relay.mjs +6 -2
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +88 -9
- package/src/native-relay-companion.mjs +57 -5
- package/src/native-relay.mjs +135 -3
- package/src/thread-delivery.mjs +121 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.13.0] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Opt-in Desktop task delivery: `delegate_to_codex` creates a task in the exact saved local project and opens it while running. `send_to_codex_thread` continues through Desktop without a second writer. `start_codex_thread` accepts an initial prompt and requires it in Desktop mode.
|
|
10
|
+
- Enable with `codex-native-relay-install --desktop-tasks` or `CODEX_BRIDGE_DESKTOP_TASKS=1`. Desktop mode uses Desktop permissions and preserves bridge workspace/thread authorization. Missing, ambiguous, remote, or parent-only project matches fail before creation; the requested checkout is preserved.
|
|
11
|
+
- Strictly allowlisted native operations and a separate Desktop task socket allow upgrades alongside an older legacy relay owner. Read/open operations use Desktop too; timed-out native tasks point to Desktop's Stop control.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Distinguish task acceptance, model failure, attention requests, and observation timeout. Follow-up polling ignores the previous completed turn; unconfirmed creation or sending is never automatically retried through another backend.
|
|
16
|
+
- Mark prompt-bearing task creation as potentially destructive in MCP annotations, and preserve the shared Desktop opt-in when bootstrapping a relay executor.
|
|
17
|
+
|
|
18
|
+
### Verification
|
|
19
|
+
|
|
20
|
+
- Added project matching, authorization, protocol validation, socket transport, uncertain acknowledgement, attention, timeout, and full MCP creation/opening tests.
|
|
21
|
+
- A real Windows Desktop task appeared under PCC4SH and completed with `cwd=C:\PCC4SH`, `HEAD=74ed553`. A subsequent native message reached that same task and reported an account usage-limit failure; a second successful model response was not claimed.
|
|
22
|
+
|
|
23
|
+
## [1.12.5] - 2026-09-05
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Preserve the configured environment when the Claude diagnostic starts its MCP child, including sender permission mode and native-relay settings. The MCP SDK's default environment whitelist otherwise drops these settings and can make the diagnostic exercise a different receiver policy than the installed bridge.
|
|
28
|
+
|
|
29
|
+
### Verification
|
|
30
|
+
|
|
31
|
+
- The live Codex MCP bridge returned a real Claude Desktop answer through the UUID-correlated transcript path with the expected workspace. The native Codex relay also delivered its marker into the existing Desktop task. No receiver permission settings or Desktop tool restrictions were changed.
|
|
32
|
+
- The live `check:claude` command reported bridge 1.12.5, received the correlated Desktop response, and exited with code 0.
|
|
33
|
+
|
|
5
34
|
## [1.12.4] - 2026-09-05
|
|
6
35
|
|
|
7
36
|
### Fixed
|
package/README.md
CHANGED
|
@@ -8,7 +8,23 @@
|
|
|
8
8
|
|
|
9
9
|
A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction uses unix sockets on macOS/Linux and named pipes on Windows).
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The bridge preserves task history and working directories across messages. Enable **Desktop tasks** to create, assign, and watch work directly in Codex Desktop. The separate app-server backend remains available for CLI use; it cannot provide live Desktop viewing while it owns the task's writer lock.
|
|
12
|
+
|
|
13
|
+
### Visible tasks in the correct Desktop project
|
|
14
|
+
|
|
15
|
+
Install the native companion, add the exact checkout directory as a local project in Codex Desktop, and enable Desktop task delivery:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
codex-native-relay-install --desktop-tasks
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Reload the native companion and the Claude MCP client after upgrading. The installer stores the opt-in in the existing `~/.codex/native-relay.json`; `CODEX_BRIDGE_DESKTOP_TASKS=1` also enables it, and an explicit `0` overrides the shared setting. Desktop mode uses **Codex Desktop permissions**, while the bridge's workspace and thread authorization checks still apply. Existing installations keep their app-server permission settings unless they opt in.
|
|
22
|
+
|
|
23
|
+
Call `delegate_to_codex` with `cwd`, `prompt`, and optionally `name`. The bridge resolves the real directory, selects the saved local project with that exact path, starts in its existing checkout, and opens the task immediately while it runs. `openInApp: false` suppresses page navigation; project assignment still happens. It never selects a parent directory, remote namesake, or a new worktree. An unsaved worktree must first be saved as its own local project; missing or ambiguous project matches return an error before creating a task.
|
|
24
|
+
|
|
25
|
+
`start_codex_thread` requires `prompt` in Desktop mode and returns after acceptance. Use `delegate_to_codex` to also wait for the reply. `send_to_codex_thread` continues the existing Desktop task without attaching another writer; its cwd cannot be changed. On timeout, the task continues. Inspect it in Desktop and use its Stop button to interrupt it. Quota failures and approval/input requests remain visible; delivery does not bypass them. An uncertain creation or send is never automatically repeated through another backend.
|
|
26
|
+
|
|
27
|
+
The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` reports the selected mode; `native_relay_status` reports both endpoints.
|
|
12
28
|
|
|
13
29
|
## Architecture
|
|
14
30
|
|
|
@@ -16,7 +32,7 @@ Two MCP servers, one living inside each agent:
|
|
|
16
32
|
|
|
17
33
|
```
|
|
18
34
|
┌──────────────── codex-mcp-bridge (runs inside Claude) ──────────────┐
|
|
19
|
-
Claude Desktop ──────┤ stdio WebSocket ├──> codex app-server
|
|
35
|
+
Claude Desktop ──────┤ stdio WebSocket ├──> separate codex app-server
|
|
20
36
|
└────────────────────────────────────────────────────────────────────┘
|
|
21
37
|
|
|
22
38
|
┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
|
|
@@ -26,14 +42,14 @@ Codex ───────────────┤ stdio unix so
|
|
|
26
42
|
Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
|
|
27
43
|
|
|
28
44
|
┌── codex-native-relay (launched by Codex Desktop, Windows/macOS) ────┐
|
|
29
|
-
|
|
45
|
+
both bridges ───────┤ named pipe / unix socket native tools ├──> visible project tasks in Codex Desktop
|
|
30
46
|
└────────────────────────────────────────────────────────────────────┘
|
|
31
47
|
```
|
|
32
48
|
|
|
33
49
|
- The app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits.
|
|
34
50
|
- Every client pointed at the same URL shares **one app-server**, so `thread/resume` with a `threadId` rejoins the running thread instead of opening a new session.
|
|
35
51
|
- The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
|
|
36
|
-
- `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running.
|
|
52
|
+
- In app-server mode, `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running. Desktop mode instead creates and assigns the task through the app immediately.
|
|
37
53
|
- The **native relay** (Windows/macOS, optional) is the third line: a thread the human is watching in Codex Desktop belongs to the app, and a second app-server cannot write to it. Instead of taking the thread away, `claude-bridge` hands the message to a companion the app itself launched, and the app delivers it. See [Codex Desktop native relay](#codex-desktop-native-relay).
|
|
38
54
|
|
|
39
55
|
## Requirements
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
7
7
|
const transport = new StdioClientTransport({
|
|
8
8
|
command: process.execPath,
|
|
9
9
|
args: [path.join(root, "src", "claude-bridge.mjs")],
|
|
10
|
+
env: { ...process.env },
|
|
10
11
|
stderr: "inherit",
|
|
11
12
|
});
|
|
12
13
|
const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
|
package/scripts/check.mjs
CHANGED
|
@@ -13,6 +13,8 @@ const bridgeEnvNames = [
|
|
|
13
13
|
"CODEX_BRIDGE_AUTO_APPROVE_ACK",
|
|
14
14
|
"CODEX_BRIDGE_APPROVAL_POLICY",
|
|
15
15
|
"CODEX_BRIDGE_AUTOSTART",
|
|
16
|
+
"CODEX_BRIDGE_DESKTOP_TASKS",
|
|
17
|
+
"CODEX_NATIVE_RELAY_SOCKET",
|
|
16
18
|
"CODEX_BRIDGE_EFFORT",
|
|
17
19
|
"CODEX_BRIDGE_MODEL",
|
|
18
20
|
"CODEX_BRIDGE_PATH_MAP",
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
|
|
7
|
+
import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
7
8
|
|
|
8
9
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
10
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
@@ -85,6 +86,7 @@ cfg.mcpServers["codex-bridge"] = {
|
|
|
85
86
|
CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
|
|
86
87
|
CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
|
|
87
88
|
CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
|
|
89
|
+
CODEX_BRIDGE_DESKTOP_TASKS: settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0"),
|
|
88
90
|
...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
|
|
89
91
|
? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
|
|
90
92
|
: {}),
|
|
@@ -5,10 +5,10 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
import { CodexAppServerClient } from "../src/app-server-client.mjs";
|
|
8
|
-
import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
|
|
8
|
+
import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath, writeRelayConfig } from "../src/native-relay.mjs";
|
|
9
9
|
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.
|
|
11
|
+
const VERSION = "1.13.0";
|
|
12
12
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
13
|
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
14
14
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
|
@@ -81,5 +81,9 @@ if (existing) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
console.log(`\nrelay socket: ${relaySocketPath()}`);
|
|
84
|
+
if (process.argv.includes("--desktop-tasks")) {
|
|
85
|
+
writeRelayConfig({ ...readRelayConfig(), desktopTasks: true });
|
|
86
|
+
console.log("Desktop task creation enabled: exact saved local projects, immediate visibility, Codex Desktop permissions.");
|
|
87
|
+
}
|
|
84
88
|
console.log("Restart Codex Desktop so it launches the companion, then check with native_relay_status.");
|
|
85
89
|
console.log("remove: node scripts/install-native-relay.mjs --remove");
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.
|
|
11
|
+
const VERSION = "1.13.0";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
package/src/index.mjs
CHANGED
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
} from "./platform.mjs";
|
|
23
23
|
import { runTurn } from "./turn.mjs";
|
|
24
24
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
25
|
+
import { DesktopTaskDelivery } from "./thread-delivery.mjs";
|
|
26
|
+
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
25
27
|
|
|
26
|
-
const VERSION = "1.
|
|
28
|
+
const VERSION = "1.13.0";
|
|
27
29
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -42,6 +44,8 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
|
|
|
42
44
|
const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
43
45
|
const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
|
|
44
46
|
const security = new BridgeSecurityPolicy();
|
|
47
|
+
const desktopTasksEnabled = desktopTasksConfigured();
|
|
48
|
+
const desktopTasks = new DesktopTaskDelivery({ security });
|
|
45
49
|
|
|
46
50
|
const client = new CodexAppServerClient({
|
|
47
51
|
clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
|
|
@@ -106,6 +110,36 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
106
110
|
);
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
114
|
+
const workspace = resolveWorkspacePath(cwd);
|
|
115
|
+
const created = await desktopTasks.create({
|
|
116
|
+
cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
|
|
117
|
+
model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT,
|
|
118
|
+
});
|
|
119
|
+
const notes = [];
|
|
120
|
+
if (workspace.note) notes.push(workspace.note);
|
|
121
|
+
if (openInApp ?? DEFAULT_OPEN_IN_APP) {
|
|
122
|
+
try {
|
|
123
|
+
await desktopTasks.open(created.threadId);
|
|
124
|
+
notes.push("opened in Codex Desktop while the task runs");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
notes.push(`task was accepted; opening its page failed: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const lines = [
|
|
130
|
+
"Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
|
|
131
|
+
`cwd: ${created.cwd}`, `projectId: ${created.projectId}`, `project: ${created.projectName}`,
|
|
132
|
+
"permissions: Codex Desktop settings; no external app-server writer", ...notes,
|
|
133
|
+
];
|
|
134
|
+
if (!waitForReply) return textResult([...lines, "status: accepted; the task is running in Desktop"].join("\n"));
|
|
135
|
+
try {
|
|
136
|
+
const result = await desktopTasks.wait(created.threadId, { timeoutMs: (timeoutSec ?? 240) * 1000 });
|
|
137
|
+
return textResult([...lines, "", formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return textResult([...lines, `Task was accepted; observation failed: ${err.message}`, "Do not resend the prompt. Inspect the existing task."].join("\n"), true);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
async function createCodexThread({ cwd, model, name, prompt }) {
|
|
110
144
|
const workspace = resolveWorkspacePath(cwd);
|
|
111
145
|
security.assertCwd(workspace.path);
|
|
@@ -189,7 +223,7 @@ function formatThreadRow(t) {
|
|
|
189
223
|
return `- ${t.id}\n title: ${title}\n cwd: ${t.cwd ?? "?"}\n updated: ${updated} status: ${status} source: ${t.source ?? "?"}${deepLink}${authorized}`;
|
|
190
224
|
}
|
|
191
225
|
|
|
192
|
-
function formatTurn(result) {
|
|
226
|
+
function formatTurn(result, { desktop = false } = {}) {
|
|
193
227
|
const lines = [];
|
|
194
228
|
lines.push(`thread: ${result.threadId}`);
|
|
195
229
|
lines.push(`turn: ${result.turnId ?? "?"} status: ${result.status}`);
|
|
@@ -214,8 +248,9 @@ function formatTurn(result) {
|
|
|
214
248
|
if (result.status === "timeout") {
|
|
215
249
|
lines.push(
|
|
216
250
|
"",
|
|
217
|
-
"NOTE: the bridge stopped waiting
|
|
218
|
-
|
|
251
|
+
"NOTE: the bridge stopped waiting; it did not pause or cancel the task.",
|
|
252
|
+
desktop ? "Inspect the task in Codex Desktop; use its Stop button to stop the running turn. Do not resend the prompt." :
|
|
253
|
+
`Read it later with read_codex_thread, or stop it with interrupt_codex_turn (turnId ${result.turnId}).`,
|
|
219
254
|
);
|
|
220
255
|
}
|
|
221
256
|
if (result.status === "disconnected") {
|
|
@@ -234,7 +269,8 @@ const server = new McpServer(
|
|
|
234
269
|
{
|
|
235
270
|
instructions:
|
|
236
271
|
"Bridge Claude work into Codex. Prefer delegate_to_codex: it creates a named Codex thread at the " +
|
|
237
|
-
"requested cwd
|
|
272
|
+
"requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
|
|
273
|
+
"Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
|
|
238
274
|
"Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
239
275
|
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring.",
|
|
240
276
|
},
|
|
@@ -266,7 +302,7 @@ server.registerTool(
|
|
|
266
302
|
openInApp: z
|
|
267
303
|
.boolean()
|
|
268
304
|
.optional()
|
|
269
|
-
.describe("
|
|
305
|
+
.describe("Show the task in Codex Desktop; native tasks open immediately while running"),
|
|
270
306
|
releaseAfterTurn: z
|
|
271
307
|
.boolean()
|
|
272
308
|
.optional()
|
|
@@ -284,6 +320,7 @@ server.registerTool(
|
|
|
284
320
|
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
285
321
|
const notes = [];
|
|
286
322
|
try {
|
|
323
|
+
if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp });
|
|
287
324
|
const created = await createCodexThread({ cwd, prompt, name, model });
|
|
288
325
|
if (created.workspace.note) notes.push(created.workspace.note);
|
|
289
326
|
if (shouldOpen && !shouldRelease) {
|
|
@@ -376,6 +413,26 @@ server.registerTool(
|
|
|
376
413
|
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
377
414
|
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
378
415
|
try {
|
|
416
|
+
if (desktopTasksEnabled) {
|
|
417
|
+
const workspace = cwd ? resolveWorkspacePath(cwd) : null;
|
|
418
|
+
if (workspace) security.assertCwd(workspace.path);
|
|
419
|
+
const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name });
|
|
420
|
+
notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
|
|
421
|
+
if (shouldOpen) {
|
|
422
|
+
try {
|
|
423
|
+
await desktopTasks.open(threadId);
|
|
424
|
+
notes.push("opened in Codex Desktop while the task runs");
|
|
425
|
+
} catch (err) {
|
|
426
|
+
notes.push(`task was accepted; opening its page failed: ${err.message}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const result = await desktopTasks.wait(threadId, { timeoutMs: (timeoutSec ?? 240) * 1000, previousTurnId: delivered.previousTurnId });
|
|
431
|
+
return textResult([...notes, formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
|
|
432
|
+
} catch (err) {
|
|
433
|
+
return textResult([...notes, `threadId: ${threadId}`, `Task was accepted; observation failed: ${err.message}. Do not resend.`].join("\n"), true);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
379
436
|
const authorizedThread = await assertThreadAccess(threadId);
|
|
380
437
|
let resolvedCwd = null;
|
|
381
438
|
if (cwd) {
|
|
@@ -519,21 +576,27 @@ server.registerTool(
|
|
|
519
576
|
"start_codex_thread",
|
|
520
577
|
{
|
|
521
578
|
title: "Start a new Codex thread",
|
|
522
|
-
description: "
|
|
579
|
+
description: "Start a Codex task. In Desktop mode include the initial prompt to create and assign a visible task atomically; use delegate_to_codex to also wait for its reply.",
|
|
523
580
|
inputSchema: {
|
|
524
581
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
582
|
+
prompt: z.string().min(1).optional().describe("Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately"),
|
|
525
583
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
526
584
|
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
527
585
|
},
|
|
528
586
|
annotations: {
|
|
529
587
|
readOnlyHint: false,
|
|
530
|
-
destructiveHint:
|
|
588
|
+
destructiveHint: true,
|
|
531
589
|
idempotentHint: false,
|
|
532
590
|
openWorldHint: true,
|
|
533
591
|
},
|
|
534
592
|
},
|
|
535
|
-
async ({ cwd, model, name }) => {
|
|
593
|
+
async ({ cwd, model, name, prompt }) => {
|
|
536
594
|
try {
|
|
595
|
+
if (desktopTasksEnabled) {
|
|
596
|
+
if (!prompt?.trim()) throw new Error("Desktop task creation requires the initial prompt. Use delegate_to_codex, or pass prompt to start_codex_thread. No task was created.");
|
|
597
|
+
return await delegateDesktopTask({ cwd, model, name, prompt, waitForReply: false });
|
|
598
|
+
}
|
|
599
|
+
if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
|
|
537
600
|
const created = await createCodexThread({ cwd, model, name });
|
|
538
601
|
return textResult(
|
|
539
602
|
[
|
|
@@ -567,6 +630,11 @@ server.registerTool(
|
|
|
567
630
|
},
|
|
568
631
|
async ({ threadId, limit }) => {
|
|
569
632
|
try {
|
|
633
|
+
if (desktopTasksEnabled) {
|
|
634
|
+
await desktopTasks.inspect(threadId);
|
|
635
|
+
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) });
|
|
636
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
637
|
+
}
|
|
570
638
|
await assertThreadAccess(threadId);
|
|
571
639
|
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
572
640
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -610,6 +678,10 @@ server.registerTool(
|
|
|
610
678
|
},
|
|
611
679
|
async ({ threadId, turnId }) => {
|
|
612
680
|
try {
|
|
681
|
+
if (desktopTasksEnabled) {
|
|
682
|
+
await desktopTasks.inspect(threadId);
|
|
683
|
+
return textResult("This task is owned by Codex Desktop. Use its Stop button; the separate app-server cannot interrupt a Desktop turn.", true);
|
|
684
|
+
}
|
|
613
685
|
await assertThreadAccess(threadId);
|
|
614
686
|
const res = await client.call("thread/read", { threadId });
|
|
615
687
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -645,6 +717,12 @@ server.registerTool(
|
|
|
645
717
|
},
|
|
646
718
|
async ({ threadId, background }) => {
|
|
647
719
|
try {
|
|
720
|
+
if (desktopTasksEnabled) {
|
|
721
|
+
await desktopTasks.inspect(threadId);
|
|
722
|
+
if (background) await openThreadInCodexApp(threadId, { activate: false });
|
|
723
|
+
else await desktopTasks.open(threadId);
|
|
724
|
+
return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
|
|
725
|
+
}
|
|
648
726
|
await assertThreadAccess(threadId);
|
|
649
727
|
const res = await client.call("thread/read", { threadId });
|
|
650
728
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -726,6 +804,7 @@ server.registerTool(
|
|
|
726
804
|
`autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
|
|
727
805
|
`desktop links: ${supportsCodexThreadLinks() ? "codex:// available" : "not available on this platform"}`,
|
|
728
806
|
`security: thread policy ${security.threadPolicy} (${summary.allowAllThreads ? "all threads" : `${summary.authorizedThreads} pre-authorized thread(s)`}), ${summary.allowAllRoots ? "all directories" : `${summary.allowedRoots.length} allowed root(s)`}, sandbox ${security.sandbox}, approvals ${security.approvalPolicy}`,
|
|
807
|
+
`desktop tasks: ${desktopTasksEnabled ? "enabled; Desktop permissions, exact saved project, immediate visibility" : "disabled; app-server permissions (enable CODEX_BRIDGE_DESKTOP_TASKS=1 to use Desktop permissions)"}`,
|
|
729
808
|
`live threads: ${liveThreads ?? "(unknown)"}`,
|
|
730
809
|
`claude desktop config: ${claudeDesktopConfigPath()}`,
|
|
731
810
|
];
|
|
@@ -14,10 +14,13 @@ import {
|
|
|
14
14
|
RELAY_PROTOCOL_VERSION,
|
|
15
15
|
relaySocketPath,
|
|
16
16
|
resolveRelayThreadId,
|
|
17
|
+
desktopTaskSocketPath,
|
|
18
|
+
validateDesktopOperation,
|
|
19
|
+
decodeNativeToolResult,
|
|
17
20
|
} from "./native-relay.mjs";
|
|
18
21
|
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
19
22
|
|
|
20
|
-
const VERSION = "1.
|
|
23
|
+
const VERSION = "1.13.0";
|
|
21
24
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
22
25
|
|
|
23
26
|
function errorResponse(code, message) {
|
|
@@ -40,8 +43,11 @@ function errorCode(err) {
|
|
|
40
43
|
*/
|
|
41
44
|
export async function handleRelayRequest(
|
|
42
45
|
payload,
|
|
43
|
-
{ dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
|
|
46
|
+
{ dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
|
|
44
47
|
) {
|
|
48
|
+
if (payload && typeof payload === "object" && Object.hasOwn(payload, "operation")) {
|
|
49
|
+
return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env });
|
|
50
|
+
}
|
|
45
51
|
if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
|
|
46
52
|
Object.keys(payload).some((key) => !["v", "targetThreadId", "message"].includes(key)) ||
|
|
47
53
|
(payload.v !== undefined && payload.v !== RELAY_PROTOCOL_VERSION)) {
|
|
@@ -85,6 +91,37 @@ export async function handleRelayRequest(
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env }) {
|
|
95
|
+
if (Array.isArray(payload) || payload.v !== RELAY_PROTOCOL_VERSION ||
|
|
96
|
+
Object.keys(payload).some((key) => !["v", "operation", "arguments"].includes(key))) {
|
|
97
|
+
return errorResponse("RELAY_BAD_REQUEST", "expected an allowlisted Desktop operation");
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
validateDesktopOperation(payload.operation, payload.arguments);
|
|
101
|
+
if (typeof dispatchDesktop !== "function") {
|
|
102
|
+
return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
|
|
103
|
+
}
|
|
104
|
+
const executorThreadId = resolveExecutor(env).threadId;
|
|
105
|
+
if (payload.operation === "send_message_to_thread" && payload.arguments.threadId === executorThreadId) {
|
|
106
|
+
return errorResponse("RELAY_BAD_REQUEST", "The relay executor cannot receive its own relayed message");
|
|
107
|
+
}
|
|
108
|
+
const nativeResult = await dispatchDesktop({
|
|
109
|
+
executorThreadId,
|
|
110
|
+
operation: payload.operation,
|
|
111
|
+
arguments: payload.arguments,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
v: RELAY_PROTOCOL_VERSION,
|
|
116
|
+
operation: payload.operation,
|
|
117
|
+
executorThreadId,
|
|
118
|
+
result: decodeNativeToolResult(nativeResult),
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return errorResponse(errorCode(err), err?.message ?? String(err));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
/**
|
|
89
126
|
* Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
|
|
90
127
|
*
|
|
@@ -95,6 +132,7 @@ export class RelaySocketServer {
|
|
|
95
132
|
constructor({
|
|
96
133
|
socketPath,
|
|
97
134
|
dispatch,
|
|
135
|
+
dispatchDesktop,
|
|
98
136
|
resolveExecutor = resolveRelayThreadId,
|
|
99
137
|
restrictSocket = (target) => {
|
|
100
138
|
if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
|
|
@@ -103,6 +141,7 @@ export class RelaySocketServer {
|
|
|
103
141
|
} = {}) {
|
|
104
142
|
this.socketPath = socketPath;
|
|
105
143
|
this.dispatch = dispatch;
|
|
144
|
+
this.dispatchDesktop = dispatchDesktop;
|
|
106
145
|
this.resolveExecutor = resolveExecutor;
|
|
107
146
|
this.restrictSocket = restrictSocket;
|
|
108
147
|
this.log = logFn;
|
|
@@ -238,10 +277,11 @@ export class RelaySocketServer {
|
|
|
238
277
|
}
|
|
239
278
|
const response = await handleRelayRequest(payload, {
|
|
240
279
|
dispatch: this.dispatch,
|
|
280
|
+
dispatchDesktop: this.dispatchDesktop,
|
|
241
281
|
resolveExecutor: this.resolveExecutor,
|
|
242
282
|
});
|
|
243
283
|
if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
|
|
244
|
-
else this.log(`relayed a message into thread ${response.targetThreadId}`);
|
|
284
|
+
else this.log(response.operation ? `completed Desktop operation ${response.operation}` : `relayed a message into thread ${response.targetThreadId}`);
|
|
245
285
|
this.#reply(socket, response);
|
|
246
286
|
}
|
|
247
287
|
|
|
@@ -338,7 +378,17 @@ if (invokedDirectly) {
|
|
|
338
378
|
const nativeTools = new NativeToolsClient();
|
|
339
379
|
const dispatch = (args) => nativeTools.dispatch(args);
|
|
340
380
|
|
|
341
|
-
const relay = new RelaySocketServer({
|
|
381
|
+
const relay = new RelaySocketServer({
|
|
382
|
+
socketPath: relaySocketPath(),
|
|
383
|
+
dispatch,
|
|
384
|
+
dispatchDesktop: (args) => nativeTools.dispatchDesktop(args),
|
|
385
|
+
log,
|
|
386
|
+
});
|
|
387
|
+
const desktopRelay = desktopTaskSocketPath() === relay.socketPath ? relay : new RelaySocketServer({
|
|
388
|
+
socketPath: desktopTaskSocketPath(),
|
|
389
|
+
dispatchDesktop: (args) => nativeTools.dispatchDesktop(args),
|
|
390
|
+
log,
|
|
391
|
+
});
|
|
342
392
|
|
|
343
393
|
mcp.registerTool(
|
|
344
394
|
"native_relay_status",
|
|
@@ -370,6 +420,7 @@ if (invokedDirectly) {
|
|
|
370
420
|
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
371
421
|
`companion: codex-native-relay ${VERSION}`,
|
|
372
422
|
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
|
|
423
|
+
`desktop tasks: ${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`,
|
|
373
424
|
`executor: ${executor}`,
|
|
374
425
|
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
375
426
|
`native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
|
|
@@ -381,7 +432,8 @@ if (invokedDirectly) {
|
|
|
381
432
|
);
|
|
382
433
|
|
|
383
434
|
const startup = startRelayWhenAvailable({ nativeTools, relay, log });
|
|
384
|
-
|
|
435
|
+
const desktopStartup = desktopRelay === relay ? startup : startRelayWhenAvailable({ nativeTools, relay: desktopRelay, log });
|
|
436
|
+
mcp.server.onclose = () => { startup.stop(); desktopStartup.stop(); };
|
|
385
437
|
await mcp.connect(new StdioServerTransport());
|
|
386
438
|
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
387
439
|
}
|
package/src/native-relay.mjs
CHANGED
|
@@ -66,6 +66,16 @@ export function relaySocketPath(env = process.env) {
|
|
|
66
66
|
return env.CODEX_NATIVE_RELAY_SOCKET ?? (IS_WINDOWS ? WINDOWS_RELAY_SOCKET : path.join(codexHome(env), RELAY_SOCKET_NAME));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function desktopTaskSocketPath(env = process.env) {
|
|
70
|
+
return env.CODEX_NATIVE_RELAY_SOCKET ?? `${relaySocketPath(env)}-desktop-tasks`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function desktopTasksConfigured(env = process.env) {
|
|
74
|
+
return env.CODEX_BRIDGE_DESKTOP_TASKS !== undefined
|
|
75
|
+
? env.CODEX_BRIDGE_DESKTOP_TASKS === "1"
|
|
76
|
+
: readRelayConfig(env)?.desktopTasks === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
export function relayConfigPath(env = process.env) {
|
|
70
80
|
return path.join(codexHome(env), RELAY_CONFIG_NAME);
|
|
71
81
|
}
|
|
@@ -141,6 +151,108 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
|
|
|
141
151
|
};
|
|
142
152
|
}
|
|
143
153
|
|
|
154
|
+
const DESKTOP_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
|
155
|
+
|
|
156
|
+
function exactObject(value, keys) {
|
|
157
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) &&
|
|
158
|
+
Object.keys(value).every((key) => keys.includes(key));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function nonempty(value) {
|
|
162
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function optionalText(value) {
|
|
166
|
+
return value === undefined || nonempty(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function optionalInteger(value, min, max) {
|
|
170
|
+
return value === undefined || Number.isSafeInteger(value) && value >= min && value <= max;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function validateDesktopOperation(operation, args) {
|
|
174
|
+
let valid = false;
|
|
175
|
+
const modelSettings = () => optionalText(args.model) &&
|
|
176
|
+
(args.thinking === undefined || DESKTOP_EFFORTS.has(args.thinking));
|
|
177
|
+
switch (operation) {
|
|
178
|
+
case "list_projects":
|
|
179
|
+
valid = exactObject(args, []);
|
|
180
|
+
break;
|
|
181
|
+
case "create_thread":
|
|
182
|
+
valid = exactObject(args, ["prompt", "target", "model", "thinking", "title"]) &&
|
|
183
|
+
nonempty(args.prompt) && optionalText(args.title) && modelSettings() &&
|
|
184
|
+
exactObject(args.target, ["type", "projectId", "environment"]) &&
|
|
185
|
+
args.target.type === "project" && nonempty(args.target.projectId) &&
|
|
186
|
+
exactObject(args.target.environment, ["type"]) && args.target.environment.type === "local";
|
|
187
|
+
break;
|
|
188
|
+
case "send_message_to_thread":
|
|
189
|
+
valid = exactObject(args, ["threadId", "prompt", "model", "thinking"]) &&
|
|
190
|
+
nonempty(args.threadId) && nonempty(args.prompt) && modelSettings();
|
|
191
|
+
break;
|
|
192
|
+
case "read_thread":
|
|
193
|
+
valid = exactObject(args, ["threadId", "hostId", "cursor", "turnLimit", "includeOutputs", "maxOutputCharsPerItem"]) &&
|
|
194
|
+
nonempty(args.threadId) && (args.hostId === undefined || args.hostId === "local") &&
|
|
195
|
+
optionalText(args.cursor) && optionalInteger(args.turnLimit, 1, 10) &&
|
|
196
|
+
(args.includeOutputs === undefined || typeof args.includeOutputs === "boolean") &&
|
|
197
|
+
optionalInteger(args.maxOutputCharsPerItem, 1, 16000);
|
|
198
|
+
break;
|
|
199
|
+
case "wait_threads":
|
|
200
|
+
valid = exactObject(args, ["targets", "timeoutMs"]) && args.timeoutMs === 0 &&
|
|
201
|
+
Array.isArray(args.targets) && args.targets.length >= 1 && args.targets.length <= 8 &&
|
|
202
|
+
args.targets.every((target) => exactObject(target, ["threadId", "hostId", "afterCursor"]) &&
|
|
203
|
+
nonempty(target.threadId) && (target.hostId === undefined || target.hostId === "local") &&
|
|
204
|
+
optionalText(target.afterCursor));
|
|
205
|
+
break;
|
|
206
|
+
case "navigate_to_codex_page":
|
|
207
|
+
valid = exactObject(args, ["threadId"]) && nonempty(args.threadId);
|
|
208
|
+
break;
|
|
209
|
+
case "set_thread_title":
|
|
210
|
+
valid = exactObject(args, ["threadId", "title"]) && nonempty(args.threadId) && nonempty(args.title);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (!valid) throw new NativeRelayError(`Unsupported or invalid Desktop operation: ${operation}`, "RELAY_BAD_REQUEST");
|
|
214
|
+
return args;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function nativeDesktopOperationParams({ executorThreadId, operation, arguments: args }) {
|
|
218
|
+
validateDesktopOperation(operation, args);
|
|
219
|
+
return {
|
|
220
|
+
arguments: args,
|
|
221
|
+
callId: `codex-native-relay-${randomUUID()}`,
|
|
222
|
+
namespace: "codex_app",
|
|
223
|
+
threadId: executorThreadId,
|
|
224
|
+
tool: operation,
|
|
225
|
+
turnId: `codex-native-relay-turn-${randomUUID()}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function decodeNativeToolResult(result) {
|
|
230
|
+
if (result?.success !== true || result?.isError === true) {
|
|
231
|
+
const detail = (result?.contentItems ?? result?.content ?? []).filter((item) => typeof item?.text === "string").map((item) => item.text).join("\n").slice(0, 2000);
|
|
232
|
+
throw new NativeRelayError(detail || "Codex Desktop did not confirm the requested operation", "NATIVE_DISPATCH_FAILED");
|
|
233
|
+
}
|
|
234
|
+
let decoded = result.structuredContent;
|
|
235
|
+
if (decoded === undefined) {
|
|
236
|
+
const items = result.contentItems ?? result.content;
|
|
237
|
+
if (Array.isArray(items)) {
|
|
238
|
+
const text = items.filter((item) => item?.type === "inputText" || item?.type === "text")
|
|
239
|
+
.map((item) => item.text).filter((value) => typeof value === "string").join("\n");
|
|
240
|
+
if (text) {
|
|
241
|
+
try {
|
|
242
|
+
decoded = JSON.parse(text);
|
|
243
|
+
} catch {
|
|
244
|
+
decoded = { text };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
decoded ??= result;
|
|
250
|
+
if (decoded?.isError === true || decoded?.success === false) {
|
|
251
|
+
throw new NativeRelayError("Codex Desktop rejected the requested operation", "NATIVE_DISPATCH_FAILED");
|
|
252
|
+
}
|
|
253
|
+
return decoded;
|
|
254
|
+
}
|
|
255
|
+
|
|
144
256
|
const execFileAsync = promisify(execFile);
|
|
145
257
|
|
|
146
258
|
function splitDesktopCommandLine(commandLine, platform) {
|
|
@@ -389,12 +501,20 @@ export class NativeToolsClient {
|
|
|
389
501
|
}
|
|
390
502
|
|
|
391
503
|
async dispatch(args) {
|
|
504
|
+
return this.#request(nativeDispatchParams(args));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async dispatchDesktop(args) {
|
|
508
|
+
return this.#request(nativeDesktopOperationParams(args));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async #request(params) {
|
|
392
512
|
const id = this.nextId++;
|
|
393
513
|
const payload = Buffer.from(JSON.stringify({
|
|
394
514
|
jsonrpc: "2.0",
|
|
395
515
|
id,
|
|
396
516
|
method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
397
|
-
params
|
|
517
|
+
params,
|
|
398
518
|
}));
|
|
399
519
|
if (payload.length > MAX_FRAME_BYTES) {
|
|
400
520
|
throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
|
|
@@ -503,6 +623,15 @@ export class NativeDesktopRelay {
|
|
|
503
623
|
|
|
504
624
|
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
|
|
505
625
|
const request = { v: RELAY_PROTOCOL_VERSION, targetThreadId, message };
|
|
626
|
+
return this.#request(request, timeoutMs);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async requestDesktop(operation, args, { timeoutMs = this.timeoutMs } = {}) {
|
|
630
|
+
validateDesktopOperation(operation, args);
|
|
631
|
+
return this.#request({ v: RELAY_PROTOCOL_VERSION, operation, arguments: args }, timeoutMs);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async #request(request, timeoutMs) {
|
|
506
635
|
const line = `${JSON.stringify(request)}\n`;
|
|
507
636
|
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
508
637
|
throw new NativeRelayError(
|
|
@@ -512,7 +641,10 @@ export class NativeDesktopRelay {
|
|
|
512
641
|
}
|
|
513
642
|
|
|
514
643
|
const response = await this.#roundTrip(line, timeoutMs);
|
|
515
|
-
if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION)
|
|
644
|
+
if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) {
|
|
645
|
+
if (request.operation && response.operation !== request.operation) throw new NativeRelayError("The relay answered a different Desktop operation; do not retry this request", "RELAY_BAD_RESPONSE", { reachedCompanion: true });
|
|
646
|
+
return response;
|
|
647
|
+
}
|
|
516
648
|
throw new NativeRelayError(
|
|
517
649
|
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
518
650
|
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
@@ -613,7 +745,7 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
|
|
|
613
745
|
try {
|
|
614
746
|
await client.call("thread/name/set", { threadId, name });
|
|
615
747
|
} catch {}
|
|
616
|
-
writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
748
|
+
writeRelayConfig({ ...readRelayConfig(env), relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
617
749
|
} finally {
|
|
618
750
|
release = await client.releaseThread(threadId);
|
|
619
751
|
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { NativeDesktopRelay } from "./native-relay.mjs";
|
|
1
|
+
import { NativeDesktopRelay, desktopTaskSocketPath } from "./native-relay.mjs";
|
|
2
2
|
import { runTurn } from "./turn.mjs";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Which backend puts a message into a Codex thread.
|
|
@@ -19,6 +21,124 @@ export const NATIVE_BACKEND = "codex-desktop-native";
|
|
|
19
21
|
export const APP_SERVER_BACKEND = "app-server";
|
|
20
22
|
const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
21
23
|
|
|
24
|
+
export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
|
|
25
|
+
const requested = canonicalize(cwd);
|
|
26
|
+
const matches = projects.filter((project) => {
|
|
27
|
+
if (project.projectKind !== "local" || project.hostId !== "local" || !project.path || !project.projectId) return false;
|
|
28
|
+
try {
|
|
29
|
+
return paths.relative(requested, canonicalize(project.path)) === "";
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
if (matches.length !== 1) {
|
|
35
|
+
throw new Error(matches.length
|
|
36
|
+
? `Multiple Codex Desktop projects match ${cwd}; keep one saved project for this directory before delegating.`
|
|
37
|
+
: `No saved local Codex Desktop project exactly matches ${cwd}. Add that directory as a project in Codex Desktop first.`);
|
|
38
|
+
}
|
|
39
|
+
return matches[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DesktopTaskDelivery {
|
|
43
|
+
constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now } = {}) {
|
|
44
|
+
this.relay = relay;
|
|
45
|
+
this.security = security;
|
|
46
|
+
this.sleep = sleep;
|
|
47
|
+
this.now = now;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async request(operation, args) {
|
|
51
|
+
const response = await this.relay.requestDesktop(operation, args);
|
|
52
|
+
return response.result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async create({ cwd, prompt, name, model, effort }) {
|
|
56
|
+
this.security.assertCwd(cwd);
|
|
57
|
+
const listed = await this.request("list_projects", {});
|
|
58
|
+
if (!Array.isArray(listed?.projects)) throw new Error("Codex Desktop returned no project list; no task was created.");
|
|
59
|
+
const project = matchDesktopProject(listed.projects, cwd);
|
|
60
|
+
const response = await this.request("create_thread", {
|
|
61
|
+
prompt,
|
|
62
|
+
title: name,
|
|
63
|
+
target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
|
|
64
|
+
...(model ? { model } : {}),
|
|
65
|
+
...(effort ? { thinking: effort } : {}),
|
|
66
|
+
});
|
|
67
|
+
const threadId = response?.threadId ?? response?.conversationId;
|
|
68
|
+
if (!threadId || response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown") {
|
|
69
|
+
throw new Error(`Desktop creation is not confirmed. Do not resend the prompt: ${JSON.stringify(response)}`);
|
|
70
|
+
}
|
|
71
|
+
this.security.registerThread(threadId);
|
|
72
|
+
if (response.hostId !== "local" || (response.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
73
|
+
throw new Error(`Desktop created task ${threadId}, but did not confirm a local running turn: ${JSON.stringify(response)}. Inspect this task before retrying.`);
|
|
74
|
+
}
|
|
75
|
+
return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async inspect(threadId, cwd) {
|
|
79
|
+
const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 });
|
|
80
|
+
const thread = response?.thread;
|
|
81
|
+
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
82
|
+
this.security.assertThread(threadId, thread.cwd);
|
|
83
|
+
this.security.assertCwd(thread.cwd);
|
|
84
|
+
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
85
|
+
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
86
|
+
}
|
|
87
|
+
return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async send({ threadId, prompt, cwd, model, effort, name }) {
|
|
91
|
+
const inspected = await this.inspect(threadId, cwd);
|
|
92
|
+
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) });
|
|
93
|
+
const response = await this.request("send_message_to_thread", {
|
|
94
|
+
threadId, prompt,
|
|
95
|
+
...(model ? { model } : {}),
|
|
96
|
+
...(effort ? { thinking: effort } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
99
|
+
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|
|
100
|
+
(response?.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
101
|
+
throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
|
|
102
|
+
}
|
|
103
|
+
return { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async open(threadId) {
|
|
107
|
+
const response = await this.request("navigate_to_codex_page", { threadId });
|
|
108
|
+
if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async wait(threadId, { timeoutMs = 240000, previousTurnId = null } = {}) {
|
|
112
|
+
const startedAt = this.now();
|
|
113
|
+
let cursor;
|
|
114
|
+
let turnId = null;
|
|
115
|
+
let text = "";
|
|
116
|
+
for (;;) {
|
|
117
|
+
const response = await this.request("wait_threads", {
|
|
118
|
+
targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
|
|
119
|
+
});
|
|
120
|
+
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
121
|
+
if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
|
|
122
|
+
cursor = poll.cursor;
|
|
123
|
+
const turn = poll.latestTurn;
|
|
124
|
+
const threadStatus = poll.thread.status?.type;
|
|
125
|
+
if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
|
|
126
|
+
return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
|
|
127
|
+
}
|
|
128
|
+
if (turn?.id && turn.id !== previousTurnId) {
|
|
129
|
+
turnId = turn.id;
|
|
130
|
+
if (poll.latestAssistantMessage?.turnId === turnId && poll.latestAssistantMessage?.phase === "final_answer") text = poll.latestAssistantMessage.text ?? text;
|
|
131
|
+
const status = turn.status;
|
|
132
|
+
if (RELEASE_STATUSES.has(status)) {
|
|
133
|
+
return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (this.now() - startedAt >= timeoutMs) return { threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt };
|
|
137
|
+
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
22
142
|
export function createThreadDelivery({
|
|
23
143
|
codex,
|
|
24
144
|
relay = new NativeDesktopRelay(),
|