@minhspark/codex-mcp-bridge 1.12.5 → 1.13.1
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 +31 -0
- package/README.md +27 -7
- package/package.json +1 -1
- package/scripts/check.mjs +2 -0
- package/scripts/install-claude-desktop.mjs +4 -0
- package/scripts/install-native-relay.mjs +6 -2
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +115 -14
- package/src/native-relay-companion.mjs +57 -5
- package/src/native-relay.mjs +138 -3
- package/src/thread-delivery.mjs +173 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
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.1] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Make Desktop task mode independent of the external app-server for status, thread discovery, diagnostics, and fallback delivery. A missing native relay reports an actionable failure without starting another process or taking a task's writer lock.
|
|
10
|
+
- Verify native connectivity through Desktop projects and list its authorized local recent/pinned Codex tasks. Explain the snapshot coverage and reject unsupported `loadedOnly` queries instead of returning external-server state.
|
|
11
|
+
- Persist external autostart off when installing Claude Desktop in Desktop task mode, while preserving explicit legacy mode and existing permission settings. Reconnect the native companion after upgrading to enable the new allowlisted thread-list operation.
|
|
12
|
+
|
|
13
|
+
### Verification
|
|
14
|
+
|
|
15
|
+
- Windows suite: 300 passed, 0 failed, 8 platform-specific skips. Added absent-relay, zero-external-connection, native-list authorization, and overlapping-send regressions.
|
|
16
|
+
- Live Windows checks returned bridge 1.13.1, eight native local projects, recent Desktop tasks, and an existing task's history while port 8791 remained stopped.
|
|
17
|
+
|
|
18
|
+
## [1.13.0] - 2026-09-05
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
- Mark prompt-bearing task creation as potentially destructive in MCP annotations, and preserve the shared Desktop opt-in when bootstrapping a relay executor.
|
|
30
|
+
|
|
31
|
+
### Verification
|
|
32
|
+
|
|
33
|
+
- Added project matching, authorization, protocol validation, socket transport, uncertain acknowledgement, attention, timeout, and full MCP creation/opening tests.
|
|
34
|
+
- 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.
|
|
35
|
+
|
|
5
36
|
## [1.12.5] - 2026-09-05
|
|
6
37
|
|
|
7
38
|
### Fixed
|
package/README.md
CHANGED
|
@@ -8,7 +8,27 @@
|
|
|
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` verifies the native connection through Desktop's local project list; `native_relay_status` reports both endpoints. Reconnect the companion after upgrading so it accepts the current native operations.
|
|
28
|
+
|
|
29
|
+
Desktop mode never contacts, starts, or falls back to an external app-server, including during status checks and thread discovery. The Claude Desktop installer records `CODEX_BRIDGE_AUTOSTART=0` in this mode. If Desktop or its companion is unavailable, the bridge reports the failure and leaves existing tasks in Desktop. `stop_codex_app_server` is a no-op in Desktop mode; stop an obsolete external service through its own launcher after confirming it has no active work.
|
|
30
|
+
|
|
31
|
+
`list_codex_threads` lists authorized local Codex tasks from Desktop's latest 50 non-pinned tasks and all pinned tasks, then applies workspace/title filters and the requested result limit. This snapshot does not cover the complete archive. Desktop does not expose the external server's `loadedOnly` state; requesting it returns an explanation without contacting that server.
|
|
12
32
|
|
|
13
33
|
## Architecture
|
|
14
34
|
|
|
@@ -16,7 +36,7 @@ Two MCP servers, one living inside each agent:
|
|
|
16
36
|
|
|
17
37
|
```
|
|
18
38
|
┌──────────────── codex-mcp-bridge (runs inside Claude) ──────────────┐
|
|
19
|
-
Claude Desktop ──────┤ stdio WebSocket ├──> codex app-server
|
|
39
|
+
Claude Desktop ──────┤ stdio WebSocket ├──> separate codex app-server
|
|
20
40
|
└────────────────────────────────────────────────────────────────────┘
|
|
21
41
|
|
|
22
42
|
┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
|
|
@@ -26,14 +46,14 @@ Codex ───────────────┤ stdio unix so
|
|
|
26
46
|
Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
|
|
27
47
|
|
|
28
48
|
┌── codex-native-relay (launched by Codex Desktop, Windows/macOS) ────┐
|
|
29
|
-
|
|
49
|
+
both bridges ───────┤ named pipe / unix socket native tools ├──> visible project tasks in Codex Desktop
|
|
30
50
|
└────────────────────────────────────────────────────────────────────┘
|
|
31
51
|
```
|
|
32
52
|
|
|
33
|
-
-
|
|
53
|
+
- In app-server mode, the app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; with autostart enabled, if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits. Desktop mode does not use this path.
|
|
34
54
|
- 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
55
|
- 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.
|
|
56
|
+
- 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
57
|
- 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
58
|
|
|
39
59
|
## Requirements
|
|
@@ -298,7 +318,7 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
|
|
|
298
318
|
|---|---|---|
|
|
299
319
|
| `delegate_to_codex` | Creates a named Codex thread at the requested project `cwd`, sends Claude's prompt, returns the reply, releases the bridge writer lock, and opens the exact session in Codex Desktop when enabled. | destructive |
|
|
300
320
|
| `send_to_codex_thread` | Sends a prompt as a user turn into `threadId`, waits for `turn/completed`, returns Codex's reply plus an activity trail (commands run, files changed). | destructive |
|
|
301
|
-
| `list_codex_threads` | Lists threads (id, title, cwd, last update, status) so you can pick the **exact** `threadId`.
|
|
321
|
+
| `list_codex_threads` | Lists threads (id, title, cwd, last update, status) so you can pick the **exact** `threadId`. Desktop mode uses recent/pinned authorized local tasks; app-server mode supports `loadedOnly: true`. Windows and macOS rows carry a `codex://threads/<id>` deep link. | read-only |
|
|
302
322
|
| `start_codex_thread` | Opens a new Codex thread at a permitted `cwd`, optionally names it, and returns its `threadId`; the bridge applies its configured safe sandbox and approval policy. | writes |
|
|
303
323
|
| `read_codex_thread` | Reads the recent conversation without sending anything. | read-only |
|
|
304
324
|
| `interrupt_codex_turn` | Stops a turn that is still running. | destructive |
|
|
@@ -504,7 +524,7 @@ The bridge reads these from the environment its MCP client hands it — there is
|
|
|
504
524
|
|---|---|---|
|
|
505
525
|
| `CODEX_APP_SERVER_URL` | `ws://127.0.0.1:8791` | Shared **loopback-only** app-server endpoint. Non-loopback endpoints are rejected because this bridge does not implement remote WebSocket authentication. |
|
|
506
526
|
| `CODEX_BIN` | auto-detected | Path to `codex` used for autostart. |
|
|
507
|
-
| `CODEX_BRIDGE_AUTOSTART` | `1` | `0` = never spawn an app-server
|
|
527
|
+
| `CODEX_BRIDGE_AUTOSTART` | `1` in app-server mode | `0` = never spawn an external app-server. Always off in Desktop mode, which does not need one. |
|
|
508
528
|
| `CODEX_BRIDGE_THREAD_POLICY` | `owned` in the direct server; installer writes `roots` for new v1.11.2 entries | What authorizes a thread: `owned` (created by this bridge, or listed in `CODEX_BRIDGE_ALLOWED_THREADS`) or `roots` (working inside `CODEX_BRIDGE_ALLOWED_ROOTS`). Existing config values are preserved on upgrade. |
|
|
509
529
|
| `CODEX_BRIDGE_ALLOWED_THREADS` | empty | Exact comma-separated thread IDs permitted for read/send/interrupt/open/list; `*` explicitly permits every thread ID. Under `roots`, the workspace check still runs. |
|
|
510
530
|
| `CODEX_BRIDGE_ALLOWED_ROOTS` | `*` in the v1.11.2 installer | Absolute project directories permitted for `cwd`, separated by `:` (`;` on Windows); `*` means every usable workspace. |
|
package/package.json
CHANGED
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();
|
|
@@ -49,6 +50,7 @@ cfg.mcpServers = cfg.mcpServers ?? {};
|
|
|
49
50
|
*/
|
|
50
51
|
const previousEnv = (reset ? {} : cfg.mcpServers["codex-bridge"]?.env) ?? {};
|
|
51
52
|
const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
|
|
53
|
+
const desktopMode = settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0");
|
|
52
54
|
|
|
53
55
|
const kept = Object.keys(previousEnv).filter(
|
|
54
56
|
(name) => process.env[name] === undefined && name !== "CODEX_BIN",
|
|
@@ -85,6 +87,8 @@ cfg.mcpServers["codex-bridge"] = {
|
|
|
85
87
|
CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
|
|
86
88
|
CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
|
|
87
89
|
CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
|
|
90
|
+
CODEX_BRIDGE_DESKTOP_TASKS: desktopMode,
|
|
91
|
+
CODEX_BRIDGE_AUTOSTART: desktopMode === "1" ? "0" : settled("CODEX_BRIDGE_AUTOSTART", "1"),
|
|
88
92
|
...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
|
|
89
93
|
? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
|
|
90
94
|
: {}),
|
|
@@ -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.1";
|
|
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.1";
|
|
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.1";
|
|
27
29
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -42,8 +44,10 @@ 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
|
-
const client = new CodexAppServerClient({
|
|
50
|
+
const client = desktopTasksEnabled ? null : new CodexAppServerClient({
|
|
47
51
|
clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
|
|
48
52
|
log,
|
|
49
53
|
});
|
|
@@ -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);
|
|
@@ -178,9 +212,9 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
|
|
|
178
212
|
}
|
|
179
213
|
|
|
180
214
|
function formatThreadRow(t) {
|
|
181
|
-
const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
|
|
215
|
+
const title = t.title || t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
|
|
182
216
|
const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
|
|
183
|
-
const status = t.status?.type ?? "?";
|
|
217
|
+
const status = typeof t.status === "string" ? t.status : t.status?.type ?? "?";
|
|
184
218
|
const deepLink = supportsCodexThreadLinks() ? `\n open: ${codexThreadUrl(t.id)}` : "";
|
|
185
219
|
const authorized = security.isThreadAuthorized(t.id, t.cwd)
|
|
186
220
|
? ""
|
|
@@ -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) {
|
|
@@ -371,11 +408,31 @@ server.registerTool(
|
|
|
371
408
|
},
|
|
372
409
|
},
|
|
373
410
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
374
|
-
return client.withThread(threadId, async () => {
|
|
411
|
+
return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
|
|
375
412
|
const notes = [];
|
|
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) {
|
|
@@ -456,6 +513,11 @@ server.registerTool(
|
|
|
456
513
|
},
|
|
457
514
|
async ({ limit, cwd, searchTerm, loadedOnly }) => {
|
|
458
515
|
try {
|
|
516
|
+
if (desktopTasksEnabled) {
|
|
517
|
+
const workspace = cwd ? resolveWorkspacePath(cwd) : null;
|
|
518
|
+
const { rows, coverage } = await desktopTasks.list({ limit, cwd: workspace?.path, searchTerm, loadedOnly });
|
|
519
|
+
return textResult(`${rows.length} Codex thread(s) via Codex Desktop:\n${coverage}\n\n${rows.length ? rows.map(formatThreadRow).join("\n") : "No matching authorized local Codex tasks in this snapshot."}`);
|
|
520
|
+
}
|
|
459
521
|
const params = { limit: limit ?? 15 };
|
|
460
522
|
if (cwd) {
|
|
461
523
|
const workspace = resolveWorkspacePath(cwd);
|
|
@@ -519,21 +581,27 @@ server.registerTool(
|
|
|
519
581
|
"start_codex_thread",
|
|
520
582
|
{
|
|
521
583
|
title: "Start a new Codex thread",
|
|
522
|
-
description: "
|
|
584
|
+
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
585
|
inputSchema: {
|
|
524
586
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
587
|
+
prompt: z.string().min(1).optional().describe("Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately"),
|
|
525
588
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
526
589
|
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
527
590
|
},
|
|
528
591
|
annotations: {
|
|
529
592
|
readOnlyHint: false,
|
|
530
|
-
destructiveHint:
|
|
593
|
+
destructiveHint: true,
|
|
531
594
|
idempotentHint: false,
|
|
532
595
|
openWorldHint: true,
|
|
533
596
|
},
|
|
534
597
|
},
|
|
535
|
-
async ({ cwd, model, name }) => {
|
|
598
|
+
async ({ cwd, model, name, prompt }) => {
|
|
536
599
|
try {
|
|
600
|
+
if (desktopTasksEnabled) {
|
|
601
|
+
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.");
|
|
602
|
+
return await delegateDesktopTask({ cwd, model, name, prompt, waitForReply: false });
|
|
603
|
+
}
|
|
604
|
+
if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
|
|
537
605
|
const created = await createCodexThread({ cwd, model, name });
|
|
538
606
|
return textResult(
|
|
539
607
|
[
|
|
@@ -567,6 +635,11 @@ server.registerTool(
|
|
|
567
635
|
},
|
|
568
636
|
async ({ threadId, limit }) => {
|
|
569
637
|
try {
|
|
638
|
+
if (desktopTasksEnabled) {
|
|
639
|
+
await desktopTasks.inspect(threadId);
|
|
640
|
+
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) });
|
|
641
|
+
return textResult(JSON.stringify(response, null, 2));
|
|
642
|
+
}
|
|
570
643
|
await assertThreadAccess(threadId);
|
|
571
644
|
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
572
645
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -610,6 +683,10 @@ server.registerTool(
|
|
|
610
683
|
},
|
|
611
684
|
async ({ threadId, turnId }) => {
|
|
612
685
|
try {
|
|
686
|
+
if (desktopTasksEnabled) {
|
|
687
|
+
await desktopTasks.inspect(threadId);
|
|
688
|
+
return textResult("This task is owned by Codex Desktop. Use its Stop button; the separate app-server cannot interrupt a Desktop turn.", true);
|
|
689
|
+
}
|
|
613
690
|
await assertThreadAccess(threadId);
|
|
614
691
|
const res = await client.call("thread/read", { threadId });
|
|
615
692
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -645,6 +722,12 @@ server.registerTool(
|
|
|
645
722
|
},
|
|
646
723
|
async ({ threadId, background }) => {
|
|
647
724
|
try {
|
|
725
|
+
if (desktopTasksEnabled) {
|
|
726
|
+
await desktopTasks.inspect(threadId);
|
|
727
|
+
if (background) await openThreadInCodexApp(threadId, { activate: false });
|
|
728
|
+
else await desktopTasks.open(threadId);
|
|
729
|
+
return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
|
|
730
|
+
}
|
|
648
731
|
await assertThreadAccess(threadId);
|
|
649
732
|
const res = await client.call("thread/read", { threadId });
|
|
650
733
|
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
@@ -676,6 +759,7 @@ server.registerTool(
|
|
|
676
759
|
},
|
|
677
760
|
async () => {
|
|
678
761
|
try {
|
|
762
|
+
if (desktopTasksEnabled) return textResult("Desktop-only mode does not manage an external app-server. Codex Desktop and its running tasks were left unchanged.");
|
|
679
763
|
const result = await client.stopServer();
|
|
680
764
|
if (result.stillListening) {
|
|
681
765
|
return textResult("The app-server is still listening after the stop request; its thread writer locks are not confirmed released.", true);
|
|
@@ -706,6 +790,22 @@ server.registerTool(
|
|
|
706
790
|
},
|
|
707
791
|
async () => {
|
|
708
792
|
const summary = security.summary();
|
|
793
|
+
if (desktopTasksEnabled) {
|
|
794
|
+
const native = await desktopTasks.status();
|
|
795
|
+
return textResult([
|
|
796
|
+
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
797
|
+
`bridge version: ${VERSION}`,
|
|
798
|
+
`node: ${process.version} at ${process.execPath}`,
|
|
799
|
+
"desktop tasks: enabled; Desktop permissions, exact saved project, immediate visibility",
|
|
800
|
+
`native relay: ${native.available ? "available; verified through Codex Desktop" : "unavailable"}`,
|
|
801
|
+
`native endpoint: ${native.socketPath}`,
|
|
802
|
+
...(native.available ? [`local projects: ${native.localProjects}`] : [`reason: ${native.reason}`]),
|
|
803
|
+
"app-server: disabled in Desktop-only mode; no external endpoint is contacted",
|
|
804
|
+
"autostart: off; external app-server fallback is disabled",
|
|
805
|
+
`security: thread policy ${security.threadPolicy}, ${summary.allowAllRoots ? "all directories" : `${summary.allowedRoots.length} allowed root(s)`}; task permissions belong to Codex Desktop`,
|
|
806
|
+
`claude desktop config: ${claudeDesktopConfigPath()}`,
|
|
807
|
+
].join("\n"), !native.available);
|
|
808
|
+
}
|
|
709
809
|
const up = await client.isServerUp();
|
|
710
810
|
let liveThreads = null;
|
|
711
811
|
if (up) {
|
|
@@ -726,6 +826,7 @@ server.registerTool(
|
|
|
726
826
|
`autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
|
|
727
827
|
`desktop links: ${supportsCodexThreadLinks() ? "codex:// available" : "not available on this platform"}`,
|
|
728
828
|
`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}`,
|
|
829
|
+
`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
830
|
`live threads: ${liveThreads ?? "(unknown)"}`,
|
|
730
831
|
`claude desktop config: ${claudeDesktopConfigPath()}`,
|
|
731
832
|
];
|
|
@@ -757,4 +858,4 @@ server.registerTool(
|
|
|
757
858
|
|
|
758
859
|
const transport = new StdioServerTransport();
|
|
759
860
|
await server.connect(transport);
|
|
760
|
-
log(`ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
|
|
861
|
+
log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
|
|
@@ -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.1";
|
|
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,111 @@ 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 "list_threads":
|
|
182
|
+
valid = exactObject(args, ["limit"]) && optionalInteger(args.limit, 1, 50);
|
|
183
|
+
break;
|
|
184
|
+
case "create_thread":
|
|
185
|
+
valid = exactObject(args, ["prompt", "target", "model", "thinking", "title"]) &&
|
|
186
|
+
nonempty(args.prompt) && optionalText(args.title) && modelSettings() &&
|
|
187
|
+
exactObject(args.target, ["type", "projectId", "environment"]) &&
|
|
188
|
+
args.target.type === "project" && nonempty(args.target.projectId) &&
|
|
189
|
+
exactObject(args.target.environment, ["type"]) && args.target.environment.type === "local";
|
|
190
|
+
break;
|
|
191
|
+
case "send_message_to_thread":
|
|
192
|
+
valid = exactObject(args, ["threadId", "prompt", "model", "thinking"]) &&
|
|
193
|
+
nonempty(args.threadId) && nonempty(args.prompt) && modelSettings();
|
|
194
|
+
break;
|
|
195
|
+
case "read_thread":
|
|
196
|
+
valid = exactObject(args, ["threadId", "hostId", "cursor", "turnLimit", "includeOutputs", "maxOutputCharsPerItem"]) &&
|
|
197
|
+
nonempty(args.threadId) && (args.hostId === undefined || args.hostId === "local") &&
|
|
198
|
+
optionalText(args.cursor) && optionalInteger(args.turnLimit, 1, 10) &&
|
|
199
|
+
(args.includeOutputs === undefined || typeof args.includeOutputs === "boolean") &&
|
|
200
|
+
optionalInteger(args.maxOutputCharsPerItem, 1, 16000);
|
|
201
|
+
break;
|
|
202
|
+
case "wait_threads":
|
|
203
|
+
valid = exactObject(args, ["targets", "timeoutMs"]) && args.timeoutMs === 0 &&
|
|
204
|
+
Array.isArray(args.targets) && args.targets.length >= 1 && args.targets.length <= 8 &&
|
|
205
|
+
args.targets.every((target) => exactObject(target, ["threadId", "hostId", "afterCursor"]) &&
|
|
206
|
+
nonempty(target.threadId) && (target.hostId === undefined || target.hostId === "local") &&
|
|
207
|
+
optionalText(target.afterCursor));
|
|
208
|
+
break;
|
|
209
|
+
case "navigate_to_codex_page":
|
|
210
|
+
valid = exactObject(args, ["threadId"]) && nonempty(args.threadId);
|
|
211
|
+
break;
|
|
212
|
+
case "set_thread_title":
|
|
213
|
+
valid = exactObject(args, ["threadId", "title"]) && nonempty(args.threadId) && nonempty(args.title);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (!valid) throw new NativeRelayError(`Unsupported or invalid Desktop operation: ${operation}`, "RELAY_BAD_REQUEST");
|
|
217
|
+
return args;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function nativeDesktopOperationParams({ executorThreadId, operation, arguments: args }) {
|
|
221
|
+
validateDesktopOperation(operation, args);
|
|
222
|
+
return {
|
|
223
|
+
arguments: args,
|
|
224
|
+
callId: `codex-native-relay-${randomUUID()}`,
|
|
225
|
+
namespace: "codex_app",
|
|
226
|
+
threadId: executorThreadId,
|
|
227
|
+
tool: operation,
|
|
228
|
+
turnId: `codex-native-relay-turn-${randomUUID()}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function decodeNativeToolResult(result) {
|
|
233
|
+
if (result?.success !== true || result?.isError === true) {
|
|
234
|
+
const detail = (result?.contentItems ?? result?.content ?? []).filter((item) => typeof item?.text === "string").map((item) => item.text).join("\n").slice(0, 2000);
|
|
235
|
+
throw new NativeRelayError(detail || "Codex Desktop did not confirm the requested operation", "NATIVE_DISPATCH_FAILED");
|
|
236
|
+
}
|
|
237
|
+
let decoded = result.structuredContent;
|
|
238
|
+
if (decoded === undefined) {
|
|
239
|
+
const items = result.contentItems ?? result.content;
|
|
240
|
+
if (Array.isArray(items)) {
|
|
241
|
+
const text = items.filter((item) => item?.type === "inputText" || item?.type === "text")
|
|
242
|
+
.map((item) => item.text).filter((value) => typeof value === "string").join("\n");
|
|
243
|
+
if (text) {
|
|
244
|
+
try {
|
|
245
|
+
decoded = JSON.parse(text);
|
|
246
|
+
} catch {
|
|
247
|
+
decoded = { text };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
decoded ??= result;
|
|
253
|
+
if (decoded?.isError === true || decoded?.success === false) {
|
|
254
|
+
throw new NativeRelayError("Codex Desktop rejected the requested operation", "NATIVE_DISPATCH_FAILED");
|
|
255
|
+
}
|
|
256
|
+
return decoded;
|
|
257
|
+
}
|
|
258
|
+
|
|
144
259
|
const execFileAsync = promisify(execFile);
|
|
145
260
|
|
|
146
261
|
function splitDesktopCommandLine(commandLine, platform) {
|
|
@@ -389,12 +504,20 @@ export class NativeToolsClient {
|
|
|
389
504
|
}
|
|
390
505
|
|
|
391
506
|
async dispatch(args) {
|
|
507
|
+
return this.#request(nativeDispatchParams(args));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async dispatchDesktop(args) {
|
|
511
|
+
return this.#request(nativeDesktopOperationParams(args));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async #request(params) {
|
|
392
515
|
const id = this.nextId++;
|
|
393
516
|
const payload = Buffer.from(JSON.stringify({
|
|
394
517
|
jsonrpc: "2.0",
|
|
395
518
|
id,
|
|
396
519
|
method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
397
|
-
params
|
|
520
|
+
params,
|
|
398
521
|
}));
|
|
399
522
|
if (payload.length > MAX_FRAME_BYTES) {
|
|
400
523
|
throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
|
|
@@ -503,6 +626,15 @@ export class NativeDesktopRelay {
|
|
|
503
626
|
|
|
504
627
|
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
|
|
505
628
|
const request = { v: RELAY_PROTOCOL_VERSION, targetThreadId, message };
|
|
629
|
+
return this.#request(request, timeoutMs);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async requestDesktop(operation, args, { timeoutMs = this.timeoutMs } = {}) {
|
|
633
|
+
validateDesktopOperation(operation, args);
|
|
634
|
+
return this.#request({ v: RELAY_PROTOCOL_VERSION, operation, arguments: args }, timeoutMs);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async #request(request, timeoutMs) {
|
|
506
638
|
const line = `${JSON.stringify(request)}\n`;
|
|
507
639
|
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
508
640
|
throw new NativeRelayError(
|
|
@@ -512,7 +644,10 @@ export class NativeDesktopRelay {
|
|
|
512
644
|
}
|
|
513
645
|
|
|
514
646
|
const response = await this.#roundTrip(line, timeoutMs);
|
|
515
|
-
if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION)
|
|
647
|
+
if (response?.ok === true && response.v === RELAY_PROTOCOL_VERSION) {
|
|
648
|
+
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 });
|
|
649
|
+
return response;
|
|
650
|
+
}
|
|
516
651
|
throw new NativeRelayError(
|
|
517
652
|
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
518
653
|
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
@@ -613,7 +748,7 @@ export async function bootstrapRelayThread(client, { cwd = homeDir(), env = proc
|
|
|
613
748
|
try {
|
|
614
749
|
await client.call("thread/name/set", { threadId, name });
|
|
615
750
|
} catch {}
|
|
616
|
-
writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
751
|
+
writeRelayConfig({ ...readRelayConfig(env), relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
617
752
|
} finally {
|
|
618
753
|
release = await client.releaseThread(threadId);
|
|
619
754
|
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { NativeDesktopRelay } from "./native-relay.mjs";
|
|
1
|
+
import { NativeDesktopRelay, desktopTaskSocketPath, desktopTasksConfigured } 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,11 +21,178 @@ 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
|
+
this.threadOperations = new Map();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async request(operation, args) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await this.relay.requestDesktop(operation, args);
|
|
54
|
+
return response.result;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`Codex Desktop operation ${operation} failed: ${err.message}. Desktop-only mode will not start or use an external app-server. Open Codex Desktop and reconnect its native relay, then inspect the existing task before retrying a send.`, { cause: err });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async withThread(threadId, operation) {
|
|
61
|
+
const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
|
|
62
|
+
const current = previous.catch(() => {}).then(operation);
|
|
63
|
+
this.threadOperations.set(threadId, current);
|
|
64
|
+
try {
|
|
65
|
+
return await current;
|
|
66
|
+
} finally {
|
|
67
|
+
if (this.threadOperations.get(threadId) === current) this.threadOperations.delete(threadId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async status() {
|
|
72
|
+
try {
|
|
73
|
+
const response = await this.request("list_projects", {});
|
|
74
|
+
if (!Array.isArray(response?.projects)) throw new Error("Desktop returned no project list");
|
|
75
|
+
return { available: true, socketPath: this.relay.socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { available: false, socketPath: this.relay.socketPath, reason: err.message };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async list({ limit = 15, cwd, searchTerm, loadedOnly = false } = {}) {
|
|
82
|
+
if (loadedOnly) throw new Error("Codex Desktop does not expose a loaded-only thread list. Omit loadedOnly to list its recent and pinned local tasks; no external app-server was contacted.");
|
|
83
|
+
if (cwd) this.security.assertCwd(cwd);
|
|
84
|
+
const response = await this.request("list_threads", { limit: 50 });
|
|
85
|
+
if (!Array.isArray(response?.threads) || !Array.isArray(response?.pinnedThreads)) throw new Error("Codex Desktop returned an invalid thread list");
|
|
86
|
+
if (response.unavailableHosts?.some((host) => host === "local" || host?.hostId === "local")) throw new Error("The local Codex Desktop host is unavailable; its thread list could not be confirmed");
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const rows = [...response.pinnedThreads, ...response.threads].filter((thread) => {
|
|
89
|
+
if (thread.kind !== "codex" || thread.hostId !== "local" || !thread.id || !thread.cwd || seen.has(thread.id)) return false;
|
|
90
|
+
seen.add(thread.id);
|
|
91
|
+
if (!this.security.isThreadAuthorized(thread.id, thread.cwd)) return false;
|
|
92
|
+
try {
|
|
93
|
+
this.security.assertCwd(thread.cwd);
|
|
94
|
+
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) return false;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return !searchTerm || String(thread.title ?? "").toLowerCase().includes(searchTerm.toLowerCase());
|
|
99
|
+
}).slice(0, limit);
|
|
100
|
+
return { rows, coverage: "Codex Desktop's latest 50 non-pinned tasks and all pinned tasks; local Codex workspaces only" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async create({ cwd, prompt, name, model, effort }) {
|
|
104
|
+
this.security.assertCwd(cwd);
|
|
105
|
+
const listed = await this.request("list_projects", {});
|
|
106
|
+
if (!Array.isArray(listed?.projects)) throw new Error("Codex Desktop returned no project list; no task was created.");
|
|
107
|
+
const project = matchDesktopProject(listed.projects, cwd);
|
|
108
|
+
const response = await this.request("create_thread", {
|
|
109
|
+
prompt,
|
|
110
|
+
title: name,
|
|
111
|
+
target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
|
|
112
|
+
...(model ? { model } : {}),
|
|
113
|
+
...(effort ? { thinking: effort } : {}),
|
|
114
|
+
});
|
|
115
|
+
const threadId = response?.threadId ?? response?.conversationId;
|
|
116
|
+
if (!threadId || response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown") {
|
|
117
|
+
throw new Error(`Desktop creation is not confirmed. Do not resend the prompt: ${JSON.stringify(response)}`);
|
|
118
|
+
}
|
|
119
|
+
this.security.registerThread(threadId);
|
|
120
|
+
if (response.hostId !== "local" || (response.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
121
|
+
throw new Error(`Desktop created task ${threadId}, but did not confirm a local running turn: ${JSON.stringify(response)}. Inspect this task before retrying.`);
|
|
122
|
+
}
|
|
123
|
+
return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async inspect(threadId, cwd) {
|
|
127
|
+
const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 });
|
|
128
|
+
const thread = response?.thread;
|
|
129
|
+
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
130
|
+
this.security.assertThread(threadId, thread.cwd);
|
|
131
|
+
this.security.assertCwd(thread.cwd);
|
|
132
|
+
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
133
|
+
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
134
|
+
}
|
|
135
|
+
return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async send({ threadId, prompt, cwd, model, effort, name }) {
|
|
139
|
+
const inspected = await this.inspect(threadId, cwd);
|
|
140
|
+
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) });
|
|
141
|
+
const response = await this.request("send_message_to_thread", {
|
|
142
|
+
threadId, prompt,
|
|
143
|
+
...(model ? { model } : {}),
|
|
144
|
+
...(effort ? { thinking: effort } : {}),
|
|
145
|
+
});
|
|
146
|
+
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
147
|
+
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|
|
148
|
+
(response?.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
149
|
+
throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
|
|
150
|
+
}
|
|
151
|
+
return { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async open(threadId) {
|
|
155
|
+
const response = await this.request("navigate_to_codex_page", { threadId });
|
|
156
|
+
if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async wait(threadId, { timeoutMs = 240000, previousTurnId = null } = {}) {
|
|
160
|
+
const startedAt = this.now();
|
|
161
|
+
let cursor;
|
|
162
|
+
let turnId = null;
|
|
163
|
+
let text = "";
|
|
164
|
+
for (;;) {
|
|
165
|
+
const response = await this.request("wait_threads", {
|
|
166
|
+
targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
|
|
167
|
+
});
|
|
168
|
+
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
169
|
+
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)}`);
|
|
170
|
+
cursor = poll.cursor;
|
|
171
|
+
const turn = poll.latestTurn;
|
|
172
|
+
const threadStatus = poll.thread.status?.type;
|
|
173
|
+
if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
|
|
174
|
+
return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
|
|
175
|
+
}
|
|
176
|
+
if (turn?.id && turn.id !== previousTurnId) {
|
|
177
|
+
turnId = turn.id;
|
|
178
|
+
if (poll.latestAssistantMessage?.turnId === turnId && poll.latestAssistantMessage?.phase === "final_answer") text = poll.latestAssistantMessage.text ?? text;
|
|
179
|
+
const status = turn.status;
|
|
180
|
+
if (RELEASE_STATUSES.has(status)) {
|
|
181
|
+
return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (this.now() - startedAt >= timeoutMs) return { threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt };
|
|
185
|
+
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
22
190
|
export function createThreadDelivery({
|
|
23
191
|
codex,
|
|
24
192
|
relay = new NativeDesktopRelay(),
|
|
25
193
|
log = () => {},
|
|
26
194
|
timeoutMs = 240000,
|
|
195
|
+
desktopOnly = desktopTasksConfigured(),
|
|
27
196
|
releaseAfterTurn =
|
|
28
197
|
process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN !== undefined
|
|
29
198
|
? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
|
|
@@ -48,6 +217,7 @@ export function createThreadDelivery({
|
|
|
48
217
|
return { backend: NATIVE_BACKEND, threadId, ack };
|
|
49
218
|
} catch (err) {
|
|
50
219
|
if (err.reachedCompanion || err.code !== "RELAY_UNREACHABLE") throw err;
|
|
220
|
+
if (desktopOnly) throw new Error(`Codex Desktop relay is unavailable: ${err.message}. Desktop-only mode will not start or use an external app-server.`);
|
|
51
221
|
log(`native relay unreachable (${err.message}); falling back to the app-server path`);
|
|
52
222
|
}
|
|
53
223
|
} else if (status.reason !== reportedUnavailable) {
|
|
@@ -55,6 +225,7 @@ export function createThreadDelivery({
|
|
|
55
225
|
log(`native relay not in use: ${status.reason}`);
|
|
56
226
|
}
|
|
57
227
|
|
|
228
|
+
if (desktopOnly) throw new Error(`Codex Desktop relay is unavailable: ${status.reason ?? "no native acknowledgement"}. Desktop-only mode will not start or use an external app-server.`);
|
|
58
229
|
if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
|
|
59
230
|
const send = async () => {
|
|
60
231
|
await codex.ensureThreadAttached(threadId);
|
|
@@ -80,7 +251,7 @@ export function createThreadDelivery({
|
|
|
80
251
|
const status = relay.status();
|
|
81
252
|
return status.enabled
|
|
82
253
|
? `${NATIVE_BACKEND} via ${status.socketPath}`
|
|
83
|
-
: `${APP_SERVER_BACKEND} (${status.reason})`;
|
|
254
|
+
: desktopOnly ? `${NATIVE_BACKEND} unavailable (${status.reason}); external app-server disabled` : `${APP_SERVER_BACKEND} (${status.reason})`;
|
|
84
255
|
}
|
|
85
256
|
|
|
86
257
|
return { deliver, describe };
|