@minhspark/codex-mcp-bridge 1.13.0 → 1.13.2

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,33 @@
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.2] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - Prevent duplicate Desktop tasks across concurrent MCP processes and restarts with durable creation receipts. The same workspace and explicit title reuse the verified existing task even after completion or an edited brief; continuations remain explicit sends to its ID.
10
+ - Bound the entire Desktop tool call to 40 seconds, including creation, opening, observation, and queued sends. Expired queued prompts are never sent later, and an accepted task keeps running with its ID available to the caller.
11
+ - Preserve confirmed task IDs when the first turn fails or needs attention; block uncertain creation retries without automatically expiring receipts or abandoned locks. Reusing a receipt cannot expand thread authorization.
12
+ - Keep project selection restricted to exact existing local projects. Explain that Desktop's recent-task snapshot can omit visible agent-created tasks, so absence is not evidence that a retry should create another one.
13
+
14
+ ### Verification
15
+
16
+ - Windows suite: 322 passed, 0 failed, 8 platform-specific skips. Includes real concurrent MCP processes, restart reuse, lost acknowledgements, persisted receipt validation, strict owned-policy checks, and call deadlines.
17
+ - Archived two completed duplicate tasks and retained the latest task in its existing project. A direct native read verified its exact workspace before recording the existing creation receipt.
18
+
19
+ ## [1.13.1] - 2026-09-05
20
+
21
+ ### Fixed
22
+
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+
27
+ ### Verification
28
+
29
+ - Windows suite: 300 passed, 0 failed, 8 platform-specific skips. Added absent-relay, zero-external-connection, native-list authorization, and overlapping-send regressions.
30
+ - 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.
31
+
5
32
  ## [1.13.0] - 2026-09-05
6
33
 
7
34
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@ The bridge preserves task history and working directories across messages. Enabl
12
12
 
13
13
  ### Visible tasks in the correct Desktop project
14
14
 
15
- Install the native companion, add the exact checkout directory as a local project in Codex Desktop, and enable Desktop task delivery:
15
+ Install the native companion, choose an existing local project in Codex Desktop, and enable Desktop task delivery:
16
16
 
17
17
  ```bash
18
18
  codex-native-relay-install --desktop-tasks
@@ -20,11 +20,19 @@ codex-native-relay-install --desktop-tasks
20
20
 
21
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
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.
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 creates projects or selects a parent directory, remote namesake, or a new worktree. Missing or ambiguous project matches return an error before creation; choose an existing project's directory or continue a task already in the requested workspace.
24
24
 
25
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
26
 
27
- The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` reports the selected mode; `native_relay_status` reports both endpoints.
27
+ Desktop calls share a maximum 40-second budget across creation, opening, queueing, and observation, so a slow task returns its confirmed ID before the caller's usual 60-second timeout. The task keeps running in Desktop. If creation itself cannot be confirmed, the bridge reports uncertainty and blocks another creation instead of inventing an ID.
28
+
29
+ Creation receipts persist in `$CODEX_HOME/bridge-task-receipts` (default `~/.codex/bridge-task-receipts`). The same canonical workspace and explicit title reuse the existing task, including after completion or a bridge restart; an edited brief is not resent. Use `send_to_codex_thread` to continue it, or a distinct title for separate work. Without an explicit title, the key uses the exact prompt hash. Receipts store hashes rather than prompt text and never grant thread permissions: `owned` policy still requires authorization after restart. Pending or uncertain receipts and abandoned creation locks fail closed without automatic expiry; inspect the actual task before repairing them.
30
+
31
+ 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.
32
+
33
+ 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.
34
+
35
+ `list_codex_threads` filters Desktop's recent/pinned task snapshot by authorization, workspace/title, and result limit. The snapshot can omit agent-created tasks visible in the sidebar and does not cover the complete archive. Absence from this list does not mean creation failed: read the returned task ID directly. Desktop does not expose the external server's `loadedOnly` state; requesting it returns an explanation without contacting that server.
28
36
 
29
37
  ## Architecture
30
38
 
@@ -46,7 +54,7 @@ both bridges ───────┤ named pipe / unix socket
46
54
  └────────────────────────────────────────────────────────────────────┘
47
55
  ```
48
56
 
49
- - The app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits.
57
+ - 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.
50
58
  - 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.
51
59
  - The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
52
60
  - 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.
@@ -314,7 +322,7 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
314
322
  |---|---|---|
315
323
  | `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 |
316
324
  | `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 |
317
- | `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 |
325
+ | `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 |
318
326
  | `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 |
319
327
  | `read_codex_thread` | Reads the recent conversation without sending anything. | read-only |
320
328
  | `interrupt_codex_turn` | Stops a turn that is still running. | destructive |
@@ -506,7 +514,7 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
506
514
 
507
515
  A path counts as coming from elsewhere when it names a drive letter (`D:\project`), a UNC share (`\\server\share\project`), or an attached volume (`/Volumes/<label>/project`, `/mnt/<label>/project`, `/media/<user>/<label>/project`). No particular letter, share, or label is blessed, so any dual-boot or external-disk layout works without configuration. The bridge then looks for that project under `$HOME`, then under **its own parent directory** — a bridge checked out at `~/code/codex-mcp-bridge` makes `~/code` the obvious place to find a sibling project. Override the list with `CODEX_BRIDGE_WORKSPACE_ROOTS`, provide deterministic source/target pairs with `CODEX_BRIDGE_PATH_MAP='{"L:\\project":"C:\\project"}'`, or set `CODEX_BRIDGE_REMAP=0` to switch heuristic rewriting off entirely.
508
516
 
509
- Note: Codex Desktop does **not** group threads by directory the sidebar has `Pinned` and everything else, and the protocol exposes no API to file a thread under a section (`thread/start` takes no `sectionId`; `thread/metadata/update` only patches gitInfo). What ties a thread to a project is its `cwd`.
517
+ Desktop task mode assigns tasks to the existing saved project through Desktop's native API. The separate app-server protocol does not provide that project assignment; a thread's `cwd` alone is not evidence that it appears under the expected Desktop project.
510
518
 
511
519
  **Connection errors or a silent stall on the first call after rebooting.** The app-server does not survive a restart, so the first call after boot has to bring it back. Since 1.5.0 `connect()` retries once for boot-time transients, `onclose` only tears down its own socket (a late close from the previous one used to wipe the freshly established connection), and **a turn interrupted by a dropped connection ends immediately with status `disconnected`** instead of waiting out `timeoutSec` (240s by default). Verify with `npm test`. No LaunchAgent is needed for this — the bridge spawns an app-server on demand, and running a LaunchAgent alongside Codex Desktop only contends for the `~/.codex` state.
512
520
 
@@ -520,7 +528,7 @@ The bridge reads these from the environment its MCP client hands it — there is
520
528
  |---|---|---|
521
529
  | `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. |
522
530
  | `CODEX_BIN` | auto-detected | Path to `codex` used for autostart. |
523
- | `CODEX_BRIDGE_AUTOSTART` | `1` | `0` = never spawn an app-server; one must already be running. |
531
+ | `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. |
524
532
  | `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. |
525
533
  | `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. |
526
534
  | `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.13.0",
3
+ "version": "1.13.2",
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",
@@ -50,6 +50,7 @@ cfg.mcpServers = cfg.mcpServers ?? {};
50
50
  */
51
51
  const previousEnv = (reset ? {} : cfg.mcpServers["codex-bridge"]?.env) ?? {};
52
52
  const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
53
+ const desktopMode = settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0");
53
54
 
54
55
  const kept = Object.keys(previousEnv).filter(
55
56
  (name) => process.env[name] === undefined && name !== "CODEX_BIN",
@@ -86,7 +87,8 @@ cfg.mcpServers["codex-bridge"] = {
86
87
  CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
87
88
  CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
88
89
  CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
89
- CODEX_BRIDGE_DESKTOP_TASKS: settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0"),
90
+ CODEX_BRIDGE_DESKTOP_TASKS: desktopMode,
91
+ CODEX_BRIDGE_AUTOSTART: desktopMode === "1" ? "0" : settled("CODEX_BRIDGE_AUTOSTART", "1"),
90
92
  ...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
91
93
  ? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
92
94
  : {}),
@@ -8,7 +8,7 @@ import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
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.13.0";
11
+ const VERSION = "1.13.2";
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";
@@ -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.13.0";
11
+ const VERSION = "1.13.2";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
@@ -0,0 +1,133 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import * as fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { homeDir } from "./platform.mjs";
6
+
7
+ const HASH = /^[a-f0-9]{64}$/;
8
+ const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name"]);
9
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
10
+ const normalizedName = (name) => name?.normalize("NFC").trim().replace(/\s+/g, " ");
11
+ const canonicalCwd = (cwd) => process.platform === "win32" ? path.normalize(cwd).toLowerCase() : path.normalize(cwd);
12
+ const nonemptyString = (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 8192 && !/[\u0000-\u001f]/.test(value);
13
+
14
+ function assertKey(key) {
15
+ if (typeof key !== "string" || !HASH.test(key)) throw new Error("Invalid Desktop task receipt key; refusing unsafe filesystem access.");
16
+ }
17
+
18
+ function unsafeReceipt(key, cause) {
19
+ return new Error(`Desktop task receipt ${key} is unsafe or corrupt. Do not resend creation; inspect the existing Desktop task and repair the receipt first.`, { cause });
20
+ }
21
+
22
+ function validateReceipt(key, receipt) {
23
+ const valid = receipt && typeof receipt === "object" && !Array.isArray(receipt)
24
+ && Object.keys(receipt).every((field) => FIELDS.has(field))
25
+ && receipt.version === 1 && receipt.key === key && HASH.test(receipt.promptHash)
26
+ && nonemptyString(receipt.cwd) && path.isAbsolute(receipt.cwd)
27
+ && ["pending", "unknown", "known"].includes(receipt.state)
28
+ && Number.isSafeInteger(receipt.startedAt) && receipt.startedAt >= 0
29
+ && ["threadId", "projectId", "projectName", "name"].every((field) => receipt[field] === undefined || nonemptyString(receipt[field]))
30
+ && (receipt.state !== "known" || nonemptyString(receipt.threadId));
31
+ if (!valid) throw unsafeReceipt(key);
32
+ const expected = digest(JSON.stringify([canonicalCwd(receipt.cwd), normalizedName(receipt.name) || receipt.promptHash]));
33
+ if (expected !== key) throw unsafeReceipt(key);
34
+ return receipt;
35
+ }
36
+
37
+ export class DesktopTaskReceipts {
38
+ constructor({ directory = path.join(process.env.CODEX_HOME ?? path.join(homeDir(), ".codex"), "bridge-task-receipts") } = {}) {
39
+ this.directory = path.resolve(directory);
40
+ }
41
+
42
+ key({ cwd, prompt, name }) {
43
+ if (!nonemptyString(cwd) || !path.isAbsolute(cwd) || typeof prompt !== "string" || (name !== undefined && typeof name !== "string")) {
44
+ throw new Error("Desktop task receipts require an absolute validated cwd, a prompt string, and an optional name string.");
45
+ }
46
+ const promptHash = digest(prompt);
47
+ return { key: digest(JSON.stringify([canonicalCwd(cwd), normalizedName(name) || promptHash])), promptHash };
48
+ }
49
+
50
+ async ensureDirectory(create = false) {
51
+ if (create) await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
52
+ try {
53
+ const info = await fs.lstat(this.directory);
54
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Desktop task receipt directory is unsafe; refusing creation or resend.");
55
+ return true;
56
+ } catch (err) {
57
+ if (!create && err.code === "ENOENT") return false;
58
+ throw err;
59
+ }
60
+ }
61
+
62
+ async read(key) {
63
+ assertKey(key);
64
+ if (!await this.ensureDirectory()) return null;
65
+ const file = path.join(this.directory, `${key}.json`);
66
+ let handle;
67
+ try {
68
+ const info = await fs.lstat(file);
69
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw unsafeReceipt(key);
70
+ handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
71
+ const opened = await handle.stat();
72
+ if (!opened.isFile() || opened.nlink !== 1 || opened.size > 65536) throw unsafeReceipt(key);
73
+ return validateReceipt(key, JSON.parse(await handle.readFile("utf8")));
74
+ } catch (err) {
75
+ if (err.code === "ENOENT") return null;
76
+ throw unsafeReceipt(key, err);
77
+ } finally {
78
+ await handle?.close();
79
+ }
80
+ }
81
+
82
+ async write(key, receipt) {
83
+ assertKey(key);
84
+ validateReceipt(key, receipt);
85
+ await this.ensureDirectory(true);
86
+ await this.read(key);
87
+ const temporary = path.join(this.directory, `${key}.${randomUUID()}.tmp`);
88
+ let handle;
89
+ try {
90
+ handle = await fs.open(temporary, "wx", 0o600);
91
+ await handle.writeFile(`${JSON.stringify(receipt)}\n`, "utf8");
92
+ await handle.sync();
93
+ await handle.close();
94
+ handle = undefined;
95
+ await fs.rename(temporary, path.join(this.directory, `${key}.json`));
96
+ if (process.platform !== "win32") {
97
+ const directory = await fs.open(this.directory, "r");
98
+ try {
99
+ await directory.sync();
100
+ } finally {
101
+ await directory.close();
102
+ }
103
+ }
104
+ } finally {
105
+ await handle?.close();
106
+ await fs.unlink(temporary).catch((err) => { if (err.code !== "ENOENT") throw err; });
107
+ }
108
+ }
109
+
110
+ async withLock(key, callback) {
111
+ assertKey(key);
112
+ await this.ensureDirectory(true);
113
+ const file = path.join(this.directory, `${key}.lock`);
114
+ const token = randomUUID();
115
+ let handle;
116
+ try {
117
+ handle = await fs.open(file, "wx", 0o600);
118
+ } catch (err) {
119
+ if (err.code === "EEXIST") {
120
+ throw new Error(`Desktop task creation ${key} is already in progress or a previous process left its lock. Do not resend; inspect the existing task. Locks never expire automatically.`, { cause: err });
121
+ }
122
+ throw err;
123
+ }
124
+ try {
125
+ await handle.writeFile(token, "utf8");
126
+ await handle.sync();
127
+ return await callback();
128
+ } finally {
129
+ await handle.close();
130
+ if (await fs.readFile(file, "utf8") === token) await fs.unlink(file);
131
+ }
132
+ }
133
+ }
package/src/index.mjs CHANGED
@@ -22,10 +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";
25
+ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
26
26
  import { desktopTasksConfigured } from "./native-relay.mjs";
27
27
 
28
- const VERSION = "1.13.0";
28
+ const VERSION = "1.13.2";
29
29
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
30
30
 
31
31
  /**
@@ -47,7 +47,7 @@ const security = new BridgeSecurityPolicy();
47
47
  const desktopTasksEnabled = desktopTasksConfigured();
48
48
  const desktopTasks = new DesktopTaskDelivery({ security });
49
49
 
50
- const client = new CodexAppServerClient({
50
+ const client = desktopTasksEnabled ? null : new CodexAppServerClient({
51
51
  clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
52
52
  log,
53
53
  });
@@ -111,29 +111,31 @@ function threadNameFor({ cwd, prompt, name }) {
111
111
  }
112
112
 
113
113
  async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
114
+ const deadline = Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
114
115
  const workspace = resolveWorkspacePath(cwd);
115
116
  const created = await desktopTasks.create({
116
117
  cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
117
- model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT,
118
+ dedupeName: name ?? "", model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
118
119
  });
119
120
  const notes = [];
120
121
  if (workspace.note) notes.push(workspace.note);
121
122
  if (openInApp ?? DEFAULT_OPEN_IN_APP) {
122
123
  try {
123
- await desktopTasks.open(created.threadId);
124
+ await desktopTasks.open(created.threadId, { deadline });
124
125
  notes.push("opened in Codex Desktop while the task runs");
125
126
  } catch (err) {
126
127
  notes.push(`task was accepted; opening its page failed: ${err.message}`);
127
128
  }
128
129
  }
129
130
  const lines = [
130
- "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
131
+ created.reused ? "Reused the existing Codex Desktop task; the prompt was not resent" : "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
131
132
  `cwd: ${created.cwd}`, `projectId: ${created.projectId}`, `project: ${created.projectName}`,
132
- "permissions: Codex Desktop settings; no external app-server writer", ...notes,
133
+ "permissions: Codex Desktop settings; no external app-server writer",
134
+ ...(created.promptChanged ? ["The edited brief was not sent. Use send_to_codex_thread with this threadId for a continuation, or a distinct title for separate work."] : []), ...notes,
133
135
  ];
134
- if (!waitForReply) return textResult([...lines, "status: accepted; the task is running in Desktop"].join("\n"));
136
+ if (!waitForReply) return textResult([...lines, created.reused ? "status: existing task; read it to check its current progress" : "status: accepted; the task is running in Desktop"].join("\n"));
135
137
  try {
136
- const result = await desktopTasks.wait(created.threadId, { timeoutMs: (timeoutSec ?? 240) * 1000 });
138
+ const result = await desktopTasks.wait(created.threadId, { timeoutMs: Math.max(0, deadline - Date.now()) });
137
139
  return textResult([...lines, "", formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
138
140
  } catch (err) {
139
141
  return textResult([...lines, `Task was accepted; observation failed: ${err.message}`, "Do not resend the prompt. Inspect the existing task."].join("\n"), true);
@@ -212,9 +214,9 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
212
214
  }
213
215
 
214
216
  function formatThreadRow(t) {
215
- const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
217
+ const title = t.title || t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
216
218
  const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
217
- const status = t.status?.type ?? "?";
219
+ const status = typeof t.status === "string" ? t.status : t.status?.type ?? "?";
218
220
  const deepLink = supportsCodexThreadLinks() ? `\n open: ${codexThreadUrl(t.id)}` : "";
219
221
  const authorized = security.isThreadAuthorized(t.id, t.cwd)
220
222
  ? ""
@@ -293,7 +295,7 @@ server.registerTool(
293
295
  .min(10)
294
296
  .max(3600)
295
297
  .optional()
296
- .describe("How long to wait for the turn to finish (default 240s)"),
298
+ .describe("How long to observe the task (Desktop caps the entire call, including creation, at 40s; the task continues and its threadId is returned)"),
297
299
  model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
298
300
  effort: z
299
301
  .enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
@@ -383,7 +385,7 @@ server.registerTool(
383
385
  .min(10)
384
386
  .max(3600)
385
387
  .optional()
386
- .describe("How long to wait for the turn to finish (default 240s)"),
388
+ .describe("How long to observe the task (Desktop caps the entire call at 40s; the task continues and its threadId is returned)"),
387
389
  cwd: z.string().optional().describe("Override the working directory for this turn"),
388
390
  model: z.string().optional().describe("Override the model for this turn"),
389
391
  effort: z
@@ -408,7 +410,8 @@ server.registerTool(
408
410
  },
409
411
  },
410
412
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
411
- return client.withThread(threadId, async () => {
413
+ const deadline = desktopTasksEnabled ? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
414
+ return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
412
415
  const notes = [];
413
416
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
414
417
  const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
@@ -416,18 +419,18 @@ server.registerTool(
416
419
  if (desktopTasksEnabled) {
417
420
  const workspace = cwd ? resolveWorkspacePath(cwd) : null;
418
421
  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 });
422
+ const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name, deadline });
420
423
  notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
421
424
  if (shouldOpen) {
422
425
  try {
423
- await desktopTasks.open(threadId);
426
+ await desktopTasks.open(threadId, { deadline });
424
427
  notes.push("opened in Codex Desktop while the task runs");
425
428
  } catch (err) {
426
429
  notes.push(`task was accepted; opening its page failed: ${err.message}`);
427
430
  }
428
431
  }
429
432
  try {
430
- const result = await desktopTasks.wait(threadId, { timeoutMs: (timeoutSec ?? 240) * 1000, previousTurnId: delivered.previousTurnId });
433
+ const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, deadline - Date.now()), previousTurnId: delivered.previousTurnId });
431
434
  return textResult([...notes, formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
432
435
  } catch (err) {
433
436
  return textResult([...notes, `threadId: ${threadId}`, `Task was accepted; observation failed: ${err.message}. Do not resend.`].join("\n"), true);
@@ -487,7 +490,7 @@ server.registerTool(
487
490
  } catch (err) {
488
491
  return failure(err);
489
492
  }
490
- });
493
+ }, { deadline }).catch(failure);
491
494
  },
492
495
  );
493
496
 
@@ -513,6 +516,11 @@ server.registerTool(
513
516
  },
514
517
  async ({ limit, cwd, searchTerm, loadedOnly }) => {
515
518
  try {
519
+ if (desktopTasksEnabled) {
520
+ const workspace = cwd ? resolveWorkspacePath(cwd) : null;
521
+ const { rows, coverage } = await desktopTasks.list({ limit, cwd: workspace?.path, searchTerm, loadedOnly });
522
+ 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."}`);
523
+ }
516
524
  const params = { limit: limit ?? 15 };
517
525
  if (cwd) {
518
526
  const workspace = resolveWorkspacePath(cwd);
@@ -631,8 +639,9 @@ server.registerTool(
631
639
  async ({ threadId, limit }) => {
632
640
  try {
633
641
  if (desktopTasksEnabled) {
634
- await desktopTasks.inspect(threadId);
635
- const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) });
642
+ const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
643
+ await desktopTasks.inspect(threadId, undefined, { deadline });
644
+ const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
636
645
  return textResult(JSON.stringify(response, null, 2));
637
646
  }
638
647
  await assertThreadAccess(threadId);
@@ -718,9 +727,10 @@ server.registerTool(
718
727
  async ({ threadId, background }) => {
719
728
  try {
720
729
  if (desktopTasksEnabled) {
721
- await desktopTasks.inspect(threadId);
730
+ const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
731
+ await desktopTasks.inspect(threadId, undefined, { deadline });
722
732
  if (background) await openThreadInCodexApp(threadId, { activate: false });
723
- else await desktopTasks.open(threadId);
733
+ else await desktopTasks.open(threadId, { deadline });
724
734
  return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
725
735
  }
726
736
  await assertThreadAccess(threadId);
@@ -754,6 +764,7 @@ server.registerTool(
754
764
  },
755
765
  async () => {
756
766
  try {
767
+ if (desktopTasksEnabled) return textResult("Desktop-only mode does not manage an external app-server. Codex Desktop and its running tasks were left unchanged.");
757
768
  const result = await client.stopServer();
758
769
  if (result.stillListening) {
759
770
  return textResult("The app-server is still listening after the stop request; its thread writer locks are not confirmed released.", true);
@@ -784,6 +795,22 @@ server.registerTool(
784
795
  },
785
796
  async () => {
786
797
  const summary = security.summary();
798
+ if (desktopTasksEnabled) {
799
+ const native = await desktopTasks.status();
800
+ return textResult([
801
+ `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
802
+ `bridge version: ${VERSION}`,
803
+ `node: ${process.version} at ${process.execPath}`,
804
+ "desktop tasks: enabled; Desktop permissions, exact saved project, immediate visibility",
805
+ `native relay: ${native.available ? "available; verified through Codex Desktop" : "unavailable"}`,
806
+ `native endpoint: ${native.socketPath}`,
807
+ ...(native.available ? [`local projects: ${native.localProjects}`] : [`reason: ${native.reason}`]),
808
+ "app-server: disabled in Desktop-only mode; no external endpoint is contacted",
809
+ "autostart: off; external app-server fallback is disabled",
810
+ `security: thread policy ${security.threadPolicy}, ${summary.allowAllRoots ? "all directories" : `${summary.allowedRoots.length} allowed root(s)`}; task permissions belong to Codex Desktop`,
811
+ `claude desktop config: ${claudeDesktopConfigPath()}`,
812
+ ].join("\n"), !native.available);
813
+ }
787
814
  const up = await client.isServerUp();
788
815
  let liveThreads = null;
789
816
  if (up) {
@@ -836,4 +863,4 @@ server.registerTool(
836
863
 
837
864
  const transport = new StdioServerTransport();
838
865
  await server.connect(transport);
839
- log(`ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
866
+ 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})`);
@@ -20,7 +20,7 @@ import {
20
20
  } from "./native-relay.mjs";
21
21
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
22
22
 
23
- const VERSION = "1.13.0";
23
+ const VERSION = "1.13.2";
24
24
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
25
25
 
26
26
  function errorResponse(code, message) {
@@ -178,6 +178,9 @@ export function validateDesktopOperation(operation, args) {
178
178
  case "list_projects":
179
179
  valid = exactObject(args, []);
180
180
  break;
181
+ case "list_threads":
182
+ valid = exactObject(args, ["limit"]) && optionalInteger(args.limit, 1, 50);
183
+ break;
181
184
  case "create_thread":
182
185
  valid = exactObject(args, ["prompt", "target", "model", "thinking", "title"]) &&
183
186
  nonempty(args.prompt) && optionalText(args.title) && modelSettings() &&
@@ -1,7 +1,8 @@
1
- import { NativeDesktopRelay, desktopTaskSocketPath } from "./native-relay.mjs";
1
+ import { NativeDesktopRelay, desktopTaskSocketPath, desktopTasksConfigured } from "./native-relay.mjs";
2
2
  import { runTurn } from "./turn.mjs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
5
6
 
6
7
  /**
7
8
  * Which backend puts a message into a Codex thread.
@@ -19,6 +20,7 @@ import path from "node:path";
19
20
  */
20
21
  export const NATIVE_BACKEND = "codex-desktop-native";
21
22
  export const APP_SERVER_BACKEND = "app-server";
23
+ export const DESKTOP_TOOL_BUDGET_MS = 40000;
22
24
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
23
25
 
24
26
  export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
@@ -34,49 +36,146 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
34
36
  if (matches.length !== 1) {
35
37
  throw new Error(matches.length
36
38
  ? `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.`);
39
+ : `No saved local Codex Desktop project exactly matches ${cwd}. Choose an existing saved project's directory or continue an existing task in this workspace. The bridge will not create a project or substitute a different directory.`);
38
40
  }
39
41
  return matches[0];
40
42
  }
41
43
 
42
44
  export class DesktopTaskDelivery {
43
- constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now } = {}) {
45
+ constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts() } = {}) {
44
46
  this.relay = relay;
45
47
  this.security = security;
46
48
  this.sleep = sleep;
47
49
  this.now = now;
50
+ this.receipts = receipts;
51
+ this.threadOperations = new Map();
48
52
  }
49
53
 
50
- async request(operation, args) {
51
- const response = await this.relay.requestDesktop(operation, args);
52
- return response.result;
54
+ async request(operation, args, { deadline } = {}) {
55
+ try {
56
+ const remaining = deadline === undefined ? undefined : deadline - this.now();
57
+ if (remaining !== undefined && remaining <= 0) throw new Error("The bridge's response deadline has elapsed; this operation was not sent");
58
+ const response = await this.relay.requestDesktop(operation, args, remaining === undefined ? undefined : { timeoutMs: remaining });
59
+ return response.result;
60
+ } catch (err) {
61
+ 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 });
62
+ }
63
+ }
64
+
65
+ async withThread(threadId, operation, { deadline } = {}) {
66
+ const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
67
+ const current = previous.catch(() => {}).then(() => {
68
+ if (deadline !== undefined && this.now() >= deadline) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
69
+ return operation();
70
+ });
71
+ this.threadOperations.set(threadId, current);
72
+ const cleanup = () => { if (this.threadOperations.get(threadId) === current) this.threadOperations.delete(threadId); };
73
+ current.then(cleanup, cleanup);
74
+ if (deadline === undefined) return current;
75
+ let timer;
76
+ try {
77
+ return await Promise.race([current, new Promise((_, reject) => {
78
+ timer = setTimeout(() => reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again.")), Math.max(1, deadline - this.now()));
79
+ })]);
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+
85
+ async status() {
86
+ try {
87
+ const response = await this.request("list_projects", {});
88
+ if (!Array.isArray(response?.projects)) throw new Error("Desktop returned no project list");
89
+ return { available: true, socketPath: this.relay.socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length };
90
+ } catch (err) {
91
+ return { available: false, socketPath: this.relay.socketPath, reason: err.message };
92
+ }
93
+ }
94
+
95
+ async list({ limit = 15, cwd, searchTerm, loadedOnly = false } = {}) {
96
+ 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.");
97
+ if (cwd) this.security.assertCwd(cwd);
98
+ const response = await this.request("list_threads", { limit: 50 });
99
+ if (!Array.isArray(response?.threads) || !Array.isArray(response?.pinnedThreads)) throw new Error("Codex Desktop returned an invalid thread list");
100
+ 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");
101
+ const seen = new Set();
102
+ const rows = [...response.pinnedThreads, ...response.threads].filter((thread) => {
103
+ if (thread.kind !== "codex" || thread.hostId !== "local" || !thread.id || !thread.cwd || seen.has(thread.id)) return false;
104
+ seen.add(thread.id);
105
+ if (!this.security.isThreadAuthorized(thread.id, thread.cwd)) return false;
106
+ try {
107
+ this.security.assertCwd(thread.cwd);
108
+ if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) return false;
109
+ } catch {
110
+ return false;
111
+ }
112
+ return !searchTerm || String(thread.title ?? "").toLowerCase().includes(searchTerm.toLowerCase());
113
+ }).slice(0, limit);
114
+ return { rows, coverage: "Codex Desktop's recent/pinned snapshot; local Codex workspaces only. Agent-created tasks visible in the sidebar can be omitted; read a known task ID directly before assuming creation failed." };
115
+ }
116
+
117
+ async reuseReceipt(receipt, { cwd, promptHash, deadline }) {
118
+ if (!receipt) return null;
119
+ if (path.relative(cwd, realpathSync.native(receipt.cwd))) throw new Error("The stored Desktop creation receipt belongs to another workspace. No prompt was sent.");
120
+ if (receipt.state !== "known") throw new Error(`An earlier Desktop creation is ${receipt.state} and may already have started. ${receipt.threadId ? `threadId: ${receipt.threadId}. ` : ""}Do not resend or create another task; inspect the existing Desktop task and resolve its creation receipt first.`);
121
+ let response;
122
+ try {
123
+ this.security.assertThread(receipt.threadId, cwd);
124
+ response = await this.request("read_thread", { threadId: receipt.threadId, hostId: "local", turnLimit: 1 }, { deadline });
125
+ } catch (err) {
126
+ throw new Error(`Existing Desktop task ${receipt.threadId} could not be verified. No prompt was resent and no duplicate task was created: ${err.message}`, { cause: err });
127
+ }
128
+ const thread = response?.thread;
129
+ if (thread?.id !== receipt.threadId || thread.hostId !== "local" || !thread.cwd || path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId} did not confirm the requested local workspace. No prompt was sent.`);
130
+ this.security.assertCwd(thread.cwd);
131
+ this.security.assertThread(thread.id, thread.cwd);
132
+ return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, projectId: receipt.projectId, projectName: receipt.projectName, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
53
133
  }
54
134
 
55
- async create({ cwd, prompt, name, model, effort }) {
135
+ async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
136
+ this.security.assertCwd(cwd);
137
+ cwd = realpathSync.native(cwd);
56
138
  this.security.assertCwd(cwd);
57
- const listed = await this.request("list_projects", {});
139
+ dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
140
+ const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
141
+ const options = { cwd, promptHash: identity.promptHash, deadline };
142
+ const reused = await this.reuseReceipt(await this.receipts.read(identity.key), options);
143
+ if (reused) return reused;
144
+ const listed = await this.request("list_projects", {}, { deadline });
58
145
  if (!Array.isArray(listed?.projects)) throw new Error("Codex Desktop returned no project list; no task was created.");
59
146
  const project = matchDesktopProject(listed.projects, cwd);
60
- const response = await this.request("create_thread", {
61
- prompt,
62
- title: name,
63
- target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
64
- ...(model ? { model } : {}),
65
- ...(effort ? { thinking: effort } : {}),
147
+ return this.receipts.withLock(identity.key, async () => {
148
+ const existing = await this.reuseReceipt(await this.receipts.read(identity.key), options);
149
+ if (existing) return existing;
150
+ if (this.now() >= deadline) throw new Error("The response deadline elapsed before Desktop creation. No task was created.");
151
+ const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
152
+ await this.receipts.write(identity.key, receipt);
153
+ let threadId;
154
+ let creationConfirmed = false;
155
+ try {
156
+ const response = await this.request("create_thread", {
157
+ prompt, title: name,
158
+ target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
159
+ ...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}),
160
+ }, { deadline });
161
+ const confirmedId = response?.threadId ?? response?.conversationId;
162
+ threadId = confirmedId;
163
+ if (!threadId && (response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown")) threadId = response?.clientThreadId;
164
+ if (typeof confirmedId !== "string" || !confirmedId.trim() || response?.status === "outcome-unknown" || response.hostId !== "local") throw new Error(`Desktop creation is not confirmed: ${JSON.stringify(response)}`);
165
+ await this.receipts.write(identity.key, { ...receipt, state: "known", threadId });
166
+ creationConfirmed = true;
167
+ this.security.registerThread(threadId);
168
+ if (response.firstTurn && response.firstTurn.status !== "accepted") throw new Error(`Desktop created task ${threadId}; its first turn reports ${response.firstTurn.status ?? "an unconfirmed state"}: ${JSON.stringify(response)}`);
169
+ return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
170
+ } catch (err) {
171
+ if (!creationConfirmed) await this.receipts.write(identity.key, { ...receipt, state: "unknown", ...(typeof threadId === "string" && threadId ? { threadId } : {}) }).catch(() => {});
172
+ throw new Error(`${err.message}. ${threadId ? `threadId: ${threadId}. ` : ""}Do not resend the prompt. The creation receipt blocks duplicate tasks even after a bridge restart.`, { cause: err });
173
+ }
66
174
  });
67
- const threadId = response?.threadId ?? response?.conversationId;
68
- if (!threadId || response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown") {
69
- throw new Error(`Desktop creation is not confirmed. Do not resend the prompt: ${JSON.stringify(response)}`);
70
- }
71
- this.security.registerThread(threadId);
72
- if (response.hostId !== "local" || (response.firstTurn && response.firstTurn.status !== "accepted")) {
73
- throw new Error(`Desktop created task ${threadId}, but did not confirm a local running turn: ${JSON.stringify(response)}. Inspect this task before retrying.`);
74
- }
75
- return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
76
175
  }
77
176
 
78
- async inspect(threadId, cwd) {
79
- const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 });
177
+ async inspect(threadId, cwd, { deadline } = {}) {
178
+ const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline });
80
179
  const thread = response?.thread;
81
180
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
82
181
  this.security.assertThread(threadId, thread.cwd);
@@ -87,14 +186,14 @@ export class DesktopTaskDelivery {
87
186
  return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
88
187
  }
89
188
 
90
- async send({ threadId, prompt, cwd, model, effort, name }) {
91
- const inspected = await this.inspect(threadId, cwd);
92
- if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) });
189
+ async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
190
+ const inspected = await this.inspect(threadId, cwd, { deadline });
191
+ if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline });
93
192
  const response = await this.request("send_message_to_thread", {
94
193
  threadId, prompt,
95
194
  ...(model ? { model } : {}),
96
195
  ...(effort ? { thinking: effort } : {}),
97
- });
196
+ }, { deadline });
98
197
  if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
99
198
  (response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
100
199
  (response?.firstTurn && response.firstTurn.status !== "accepted")) {
@@ -103,8 +202,8 @@ export class DesktopTaskDelivery {
103
202
  return { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
104
203
  }
105
204
 
106
- async open(threadId) {
107
- const response = await this.request("navigate_to_codex_page", { threadId });
205
+ async open(threadId, { deadline } = {}) {
206
+ const response = await this.request("navigate_to_codex_page", { threadId }, { deadline });
108
207
  if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
109
208
  }
110
209
 
@@ -113,10 +212,18 @@ export class DesktopTaskDelivery {
113
212
  let cursor;
114
213
  let turnId = null;
115
214
  let text = "";
215
+ const expired = () => ({ threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt });
116
216
  for (;;) {
117
- const response = await this.request("wait_threads", {
118
- targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
119
- });
217
+ if (this.now() - startedAt >= timeoutMs) return expired();
218
+ let response;
219
+ try {
220
+ response = await this.request("wait_threads", {
221
+ targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
222
+ }, { deadline: startedAt + timeoutMs });
223
+ } catch (err) {
224
+ if (this.now() - startedAt >= timeoutMs) return expired();
225
+ throw err;
226
+ }
120
227
  const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
121
228
  if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
122
229
  cursor = poll.cursor;
@@ -133,7 +240,7 @@ export class DesktopTaskDelivery {
133
240
  return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
134
241
  }
135
242
  }
136
- if (this.now() - startedAt >= timeoutMs) return { threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt };
243
+ if (this.now() - startedAt >= timeoutMs) return expired();
137
244
  await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
138
245
  }
139
246
  }
@@ -144,6 +251,7 @@ export function createThreadDelivery({
144
251
  relay = new NativeDesktopRelay(),
145
252
  log = () => {},
146
253
  timeoutMs = 240000,
254
+ desktopOnly = desktopTasksConfigured(),
147
255
  releaseAfterTurn =
148
256
  process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN !== undefined
149
257
  ? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
@@ -168,6 +276,7 @@ export function createThreadDelivery({
168
276
  return { backend: NATIVE_BACKEND, threadId, ack };
169
277
  } catch (err) {
170
278
  if (err.reachedCompanion || err.code !== "RELAY_UNREACHABLE") throw err;
279
+ if (desktopOnly) throw new Error(`Codex Desktop relay is unavailable: ${err.message}. Desktop-only mode will not start or use an external app-server.`);
171
280
  log(`native relay unreachable (${err.message}); falling back to the app-server path`);
172
281
  }
173
282
  } else if (status.reason !== reportedUnavailable) {
@@ -175,6 +284,7 @@ export function createThreadDelivery({
175
284
  log(`native relay not in use: ${status.reason}`);
176
285
  }
177
286
 
287
+ 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.`);
178
288
  if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
179
289
  const send = async () => {
180
290
  await codex.ensureThreadAttached(threadId);
@@ -200,7 +310,7 @@ export function createThreadDelivery({
200
310
  const status = relay.status();
201
311
  return status.enabled
202
312
  ? `${NATIVE_BACKEND} via ${status.socketPath}`
203
- : `${APP_SERVER_BACKEND} (${status.reason})`;
313
+ : desktopOnly ? `${NATIVE_BACKEND} unavailable (${status.reason}); external app-server disabled` : `${APP_SERVER_BACKEND} (${status.reason})`;
204
314
  }
205
315
 
206
316
  return { deliver, describe };