@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 +27 -0
- package/README.md +15 -7
- package/package.json +1 -1
- package/scripts/install-claude-desktop.mjs +3 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/desktop-task-receipts.mjs +133 -0
- package/src/index.mjs +50 -23
- package/src/native-relay-companion.mjs +1 -1
- package/src/native-relay.mjs +3 -0
- package/src/thread-delivery.mjs +146 -36
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
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`.
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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:
|
|
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.
|
|
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";
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.13.
|
|
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.
|
|
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",
|
|
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: (
|
|
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
|
|
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
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/src/native-relay.mjs
CHANGED
|
@@ -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() &&
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -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}.
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
...(
|
|
65
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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 };
|