@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 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
- This is not `codex exec`, which starts a fresh session every time. The bridge speaks JSON-RPC to the real Codex app-server, so the thread keeps its history, its `cwd`, its model and its rollout file and a human can watch it run in the Codex desktop app instead of reading the transcript afterwards.
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 ──> thread shows in Codex Desktop
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
- claude-bridge ───────┤ named pipe / unix socket native tools ├──> the thread already open in Codex Desktop
49
+ both bridges ───────┤ named pipe / unix socket native tools ├──> visible project tasks in Codex Desktop
30
50
  └────────────────────────────────────────────────────────────────────┘
31
51
  ```
32
52
 
33
- - 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.
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`. `loadedOnly: true` shows only threads live inside the app-server. Windows and macOS rows carry a `codex://threads/<id>` deep link. | read-only |
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; one must already be running. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.5",
3
+ "version": "1.13.1",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
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.12.5";
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");
@@ -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.12.5";
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.12.5";
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, but the turn is still running inside Codex.",
218
- `Read it later with read_codex_thread, or stop it with interrupt_codex_turn (turnId ${result.turnId}).`,
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, sends the prompt, releases the bridge writer lock, and opens the exact thread in " +
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("Open the finished session in Codex Desktop on Windows or macOS"),
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: "Create a brand new Codex thread in the shared app-server and return its threadId.",
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: false,
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.12.5";
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({ socketPath: relaySocketPath(), dispatch, log });
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
- mcp.server.onclose = () => startup.stop();
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
  }
@@ -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: nativeDispatchParams(args),
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) return response;
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
  }
@@ -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 };