@minhspark/codex-mcp-bridge 1.13.1 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +38 -5
- package/package.json +5 -2
- package/scripts/install-claude-desktop.mjs +3 -0
- package/scripts/install-codex-mcp.mjs +3 -0
- package/scripts/install-native-relay.mjs +4 -1
- package/scripts/install-npm-footer.mjs +99 -0
- package/scripts/npm-footer.ps1 +140 -0
- package/scripts/npm-footer.sh +81 -0
- package/src/claude-bridge.mjs +4 -1
- package/src/cli-version.mjs +20 -0
- package/src/desktop-task-receipts.mjs +133 -0
- package/src/index.mjs +35 -21
- package/src/native-relay-companion.mjs +4 -1
- package/src/thread-delivery.mjs +135 -38
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.13.3] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Verify the current saved project's identity, canonical directory, and task membership before reusing a Desktop creation receipt. Project drift is rejected; omitted recent/pinned metadata reports unverified assignment while preserving the task ID without resending or creating a duplicate.
|
|
10
|
+
- Support `--version` and `-v` on every packaged command without starting servers, opening sockets, or mutating client configuration.
|
|
11
|
+
- Preserve PowerShell's `$?` and conditional `&&`/`||` behavior as well as `$LASTEXITCODE` by invoking its footer through a script alias.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Opt-in Bash, Zsh, and PowerShell installation footers for `npm install -g @minhspark/codex-mcp-bridge@latest`: first installation, changed version, unchanged version, failure, dry run, and unverifiable metadata are distinguished after npm's final output while preserving its exit code.
|
|
16
|
+
- `codex-npm-footer-install` backs up and updates shell profiles idempotently, refuses unmanaged npm wrappers, supports removal, and explains how to reload an already-open terminal. Package installation does not change profiles automatically.
|
|
17
|
+
|
|
18
|
+
### Verification
|
|
19
|
+
|
|
20
|
+
- Regression coverage includes same-workspace project moves, deleted/repointed/replaced projects, incomplete Desktop snapshots, deadlines and authorization, shell argument boundaries, dry-run configuration, failure exit codes, metadata validation, and profile preservation.
|
|
21
|
+
|
|
22
|
+
## [1.13.2] - 2026-09-05
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
### Verification
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
- 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.
|
|
35
|
+
|
|
5
36
|
## [1.13.1] - 2026-09-05
|
|
6
37
|
|
|
7
38
|
### Fixed
|
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,15 +20,21 @@ 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
|
+
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
|
+
Reusing a receipt also verifies that the saved project still has the same identity and exact directory, then checks the task's current project membership in Desktop's recent/pinned listing. A moved task or deleted/repointed project is rejected before sending or creating anything. A task omitted from that listing keeps its known ID and reports `project assignment: unverified`; stored receipt metadata is not presented as current membership. If a checkout has moved, edit the existing project's folder in Desktop, then inspect the existing task's workspace. Do not add another project or append the obsolete directory merely to satisfy a match.
|
|
32
|
+
|
|
27
33
|
The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` verifies the native connection through Desktop's local project list; `native_relay_status` reports both endpoints. Reconnect the companion after upgrading so it accepts the current native operations.
|
|
28
34
|
|
|
29
35
|
Desktop mode never contacts, starts, or falls back to an external app-server, including during status checks and thread discovery. The Claude Desktop installer records `CODEX_BRIDGE_AUTOSTART=0` in this mode. If Desktop or its companion is unavailable, the bridge reports the failure and leaves existing tasks in Desktop. `stop_codex_app_server` is a no-op in Desktop mode; stop an obsolete external service through its own launcher after confirming it has no active work.
|
|
30
36
|
|
|
31
|
-
`list_codex_threads`
|
|
37
|
+
`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.
|
|
32
38
|
|
|
33
39
|
## Architecture
|
|
34
40
|
|
|
@@ -118,7 +124,7 @@ Installing straight from the repository works the same way and needs no registry
|
|
|
118
124
|
npm install -g git+https://github.com/buidangminh23/codex-mcp-bridge.git
|
|
119
125
|
```
|
|
120
126
|
|
|
121
|
-
Either route puts
|
|
127
|
+
Either route puts the bridge servers, configuration installers, native relay, and `codex-npm-footer-install` on your PATH. Wherever this README runs `node scripts/install-claude-desktop.mjs`, an installed copy runs `codex-mcp-bridge-install` instead. Every packaged command supports `--version` and `-v` without starting a server or changing configuration.
|
|
122
128
|
|
|
123
129
|
#### Upgrading an install you already have
|
|
124
130
|
|
|
@@ -126,6 +132,33 @@ Either route puts four commands on your PATH — `codex-mcp-bridge` and `claude-
|
|
|
126
132
|
|
|
127
133
|
Re-running `codex-mcp-bridge-install` is **not** required to upgrade, and before 1.11.1 it actively hurt: it replaced the whole entry, discarding `CODEX_BRIDGE_ALLOWED_THREADS`, any hand-added `CODEX_BRIDGE_THREAD_POLICY`, and resetting `CODEX_BRIDGE_ALLOWED_ROOTS` to the install directory. From 1.11.1 it keeps what is already there — an environment variable you pass wins, the existing value is the fallback, and `--reset` gives you the defaults back.
|
|
128
134
|
|
|
135
|
+
#### Show the final npm installation result
|
|
136
|
+
|
|
137
|
+
Enable the optional shell integration once after installing the package:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
codex-npm-footer-install --shell zsh
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use `--shell bash` on Bash or `--shell powershell` in PowerShell 7. A source checkout can run `npm run install:footer -- --shell zsh`. The installer backs up existing profiles, preserves unrelated content, and prints a command to load the integration into the current terminal. New login and interactive Bash/Zsh shells load it automatically; PowerShell uses its current-user all-hosts profile. A terminal started before setup must run the printed reload command or open a new shell. Existing unmanaged `npm` functions are refused for review rather than overwritten. Use `--profile /absolute/path` to select a custom profile or `--remove` to remove the managed loader.
|
|
144
|
+
|
|
145
|
+
The integration leaves the command unchanged:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
npm install -g @minhspark/codex-mcp-bridge@latest
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Measured result | Final line |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| No previous installation | `Successfully installed: @minhspark/codex-mcp-bridge v<version>` |
|
|
154
|
+
| Version changed | `Successfully updated: @minhspark/codex-mcp-bridge v<before> -> v<after>` |
|
|
155
|
+
| Same version after reinstalling | `Already up to date: @minhspark/codex-mcp-bridge v<version>` |
|
|
156
|
+
| npm failed | `Failed to install: ... (exit code <code>). See npm error above.` |
|
|
157
|
+
| Dry run | `Dry run completed: ... (no changes applied).` |
|
|
158
|
+
| Mode or installed metadata cannot be verified | A warning instead of a success claim |
|
|
159
|
+
|
|
160
|
+
The footer runs after npm finishes and keeps npm's output and exit code. It supports `install`/`i`, explicit global installation, one bridge package, dry-run flags, common install booleans, and a single `--prefix`. Other commands, multiple packages, unknown options, and machine-readable or silent output modes pass through without a footer. Installing the package itself does not alter shell profiles. A successful install confirms files on disk; restart the MCP client separately to load the new bridge code.
|
|
161
|
+
|
|
129
162
|
**Clone it** — right if you intend to read, test or change the code:
|
|
130
163
|
|
|
131
164
|
```bash
|
|
@@ -510,7 +543,7 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
|
|
|
510
543
|
|
|
511
544
|
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.
|
|
512
545
|
|
|
513
|
-
|
|
546
|
+
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.
|
|
514
547
|
|
|
515
548
|
**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.
|
|
516
549
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
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",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"codex-mcp-bridge-install": "scripts/install-claude-desktop.mjs",
|
|
28
28
|
"claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs",
|
|
29
29
|
"codex-native-relay": "src/native-relay-companion.mjs",
|
|
30
|
-
"codex-native-relay-install": "scripts/install-native-relay.mjs"
|
|
30
|
+
"codex-native-relay-install": "scripts/install-native-relay.mjs",
|
|
31
|
+
"codex-npm-footer-install": "scripts/install-npm-footer.mjs"
|
|
31
32
|
},
|
|
32
33
|
"files": [
|
|
33
34
|
"src",
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
"install:relay": "node scripts/install-native-relay.mjs",
|
|
54
55
|
"uninstall:relay": "node scripts/install-native-relay.mjs --remove",
|
|
55
56
|
"install:agent": "node scripts/install-launch-agent.mjs",
|
|
57
|
+
"install:footer": "node scripts/install-npm-footer.mjs",
|
|
58
|
+
"uninstall:footer": "node scripts/install-npm-footer.mjs --remove",
|
|
56
59
|
"uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
|
|
57
60
|
"version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs scripts/install-native-relay.mjs"
|
|
58
61
|
},
|
|
@@ -5,6 +5,9 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
|
|
6
6
|
import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
8
|
+
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
9
|
+
|
|
10
|
+
exitForVersionRequest(import.meta.url);
|
|
8
11
|
|
|
9
12
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
13
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
@@ -5,6 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
8
|
+
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
9
|
+
|
|
10
|
+
exitForVersionRequest(import.meta.url);
|
|
8
11
|
|
|
9
12
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
13
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
@@ -7,8 +7,11 @@ import { fileURLToPath } from "node:url";
|
|
|
7
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
|
+
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
exitForVersionRequest(import.meta.url);
|
|
13
|
+
|
|
14
|
+
const VERSION = "1.13.3";
|
|
12
15
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
16
|
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
14
17
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
8
|
+
|
|
9
|
+
exitForVersionRequest(import.meta.url);
|
|
10
|
+
|
|
11
|
+
const begin = "# codex-mcp-bridge npm footer: begin";
|
|
12
|
+
const end = "# codex-mcp-bridge npm footer: end";
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
let shell = process.platform === "win32" ? "powershell" : path.basename(process.env.SHELL || "/bin/bash");
|
|
15
|
+
const profiles = [];
|
|
16
|
+
let remove = false;
|
|
17
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
18
|
+
const arg = args[index];
|
|
19
|
+
if (arg === "--remove") remove = true;
|
|
20
|
+
else if (["--shell", "--profile"].includes(arg) && args[index + 1]) {
|
|
21
|
+
const value = args[++index];
|
|
22
|
+
if (arg === "--shell") shell = value;
|
|
23
|
+
else profiles.push(path.resolve(value));
|
|
24
|
+
} else throw new Error(`Unsupported argument: ${arg}`);
|
|
25
|
+
}
|
|
26
|
+
if (!["bash", "zsh", "powershell"].includes(shell)) throw new Error("Choose --shell bash, zsh, or powershell.");
|
|
27
|
+
const footerDirectory = path.join(os.homedir(), ".config", "codex-mcp-bridge");
|
|
28
|
+
const footerName = shell === "powershell" ? "npm-footer.ps1" : "npm-footer.sh";
|
|
29
|
+
const footerPath = path.join(footerDirectory, footerName);
|
|
30
|
+
if (!profiles.length) {
|
|
31
|
+
if (shell === "powershell") {
|
|
32
|
+
profiles.push(execFileSync("pwsh", ["-NoProfile", "-Command", "$PROFILE.CurrentUserAllHosts"], { encoding: "utf8" }).trim());
|
|
33
|
+
} else if (shell === "zsh") {
|
|
34
|
+
const directory = process.env.ZDOTDIR || os.homedir();
|
|
35
|
+
profiles.push(path.join(directory, ".zshrc"), path.join(directory, ".zprofile"));
|
|
36
|
+
} else {
|
|
37
|
+
profiles.push(path.join(os.homedir(), ".bashrc"));
|
|
38
|
+
profiles.push([".bash_profile", ".bash_login", ".profile"].map((name) => path.join(os.homedir(), name)).find((file) => fs.existsSync(file)) || path.join(os.homedir(), ".profile"));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const quote = (value) => shell === "powershell" ? `'${value.replaceAll("'", "''")}'` : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
42
|
+
const loader = shell === "powershell"
|
|
43
|
+
? `if (Test-Path -LiteralPath ${quote(footerPath)}) { . ${quote(footerPath)} }`
|
|
44
|
+
: `if [ -n "\${BASH_VERSION:-}\${ZSH_VERSION:-}" ] && [ -r ${quote(footerPath)} ]; then . ${quote(footerPath)}; fi`;
|
|
45
|
+
const edits = [...new Set(profiles)].map((file) => {
|
|
46
|
+
if (!path.isAbsolute(file)) throw new Error(`Profile path must be absolute: ${file}`);
|
|
47
|
+
if (fs.existsSync(file) && (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink())) throw new Error(`Refusing to replace a non-regular profile: ${file}`);
|
|
48
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
49
|
+
const newline = before.includes("\r\n") || shell === "powershell" ? "\r\n" : "\n";
|
|
50
|
+
const normalized = before.replaceAll("\r\n", "\n");
|
|
51
|
+
const begins = normalized.split(begin).length - 1;
|
|
52
|
+
const ends = normalized.split(end).length - 1;
|
|
53
|
+
if (begins !== ends || begins > 1) throw new Error(`Ambiguous managed footer block: ${file}`);
|
|
54
|
+
let unmanaged = normalized;
|
|
55
|
+
if (begins) {
|
|
56
|
+
const start = normalized.indexOf(begin);
|
|
57
|
+
const finish = normalized.indexOf(end, start);
|
|
58
|
+
if (finish < start) throw new Error(`Malformed managed footer block: ${file}`);
|
|
59
|
+
unmanaged = normalized.slice(0, start) + normalized.slice(finish + end.length).replace(/^\n/, "");
|
|
60
|
+
}
|
|
61
|
+
if (!remove && /^\s*(?:function\s+(?:global:)?)?npm\s*(?:\(\s*\))?\s*\{/im.test(unmanaged)) {
|
|
62
|
+
throw new Error(`An existing npm wrapper must be reviewed before installing the footer: ${file}`);
|
|
63
|
+
}
|
|
64
|
+
const after = (remove ? unmanaged : `${begin}\n${loader}\n${end}\n${unmanaged}`).replaceAll("\n", newline);
|
|
65
|
+
return { file, before, after };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!remove) {
|
|
69
|
+
const source = fs.readFileSync(fileURLToPath(new URL(`./${footerName}`, import.meta.url)), "utf8");
|
|
70
|
+
fs.mkdirSync(footerDirectory, { recursive: true, mode: 0o700 });
|
|
71
|
+
if (fs.existsSync(footerPath) && (!fs.lstatSync(footerPath).isFile() || fs.lstatSync(footerPath).isSymbolicLink())) throw new Error(`Unsafe footer destination: ${footerPath}`);
|
|
72
|
+
if (!fs.existsSync(footerPath) || fs.readFileSync(footerPath, "utf8") !== source) {
|
|
73
|
+
fs.writeFileSync(footerPath, source, { mode: 0o600 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const { file, before, after } of edits) {
|
|
77
|
+
if (before === after) { console.log(`Unchanged: ${file}`); continue; }
|
|
78
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
79
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
80
|
+
if (current !== before) throw new Error(`Profile changed during installation; rerun after reviewing it: ${file}`);
|
|
81
|
+
if (fs.existsSync(file)) {
|
|
82
|
+
const backup = `${file}.bak-npm-footer-${Date.now()}-${process.pid}`;
|
|
83
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
84
|
+
console.log(`Backup: ${backup}`);
|
|
85
|
+
}
|
|
86
|
+
const temporary = `${file}.tmp-npm-footer-${process.pid}`;
|
|
87
|
+
let temporaryCreated = false;
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(temporary, after, { mode: 0o600, flag: "wx" });
|
|
90
|
+
temporaryCreated = true;
|
|
91
|
+
fs.renameSync(temporary, file);
|
|
92
|
+
} finally {
|
|
93
|
+
if (temporaryCreated && fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
94
|
+
}
|
|
95
|
+
console.log(`${remove ? "Removed" : "Installed"}: ${file}`);
|
|
96
|
+
}
|
|
97
|
+
console.log(remove
|
|
98
|
+
? "Open a new shell to use npm without the footer."
|
|
99
|
+
: `Reload the current shell with: . ${quote(footerPath)}`);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
if ($MyInvocation.InvocationName -eq '.') {
|
|
2
|
+
Set-Alias -Name npm -Value $PSCommandPath -Scope Global
|
|
3
|
+
return
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
$npmArguments = @($args)
|
|
7
|
+
$nativeNpm = Get-Command npm -CommandType Application, ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
8
|
+
if (-not $nativeNpm) {
|
|
9
|
+
throw [System.Management.Automation.CommandNotFoundException]::new('The npm executable was not found on PATH.')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
$matchesInstall = $npmArguments.Count -ge 3 -and $npmArguments[0] -cin @('install', 'i')
|
|
13
|
+
$isGlobal = $false
|
|
14
|
+
$targetCount = 0
|
|
15
|
+
$prefixArguments = @()
|
|
16
|
+
$dryRunOverride = $null
|
|
17
|
+
for ($argumentIndex = 1; $matchesInstall -and $argumentIndex -lt $npmArguments.Count; $argumentIndex++) {
|
|
18
|
+
$argument = [string]$npmArguments[$argumentIndex]
|
|
19
|
+
if ($argument -cin @('-g', '--global', '--global=true')) {
|
|
20
|
+
$isGlobal = $true
|
|
21
|
+
} elseif ($argument -cmatch '^@minhspark/codex-mcp-bridge(?:@[^\s]+)?$') {
|
|
22
|
+
$targetCount++
|
|
23
|
+
} elseif ($argument -cin @('--no-fund', '--no-audit', '--force', '--ignore-scripts', '--foreground-scripts')) {
|
|
24
|
+
continue
|
|
25
|
+
} elseif ($argument -cin @('--dry-run', '--dry-run=true')) {
|
|
26
|
+
$dryRunOverride = $true
|
|
27
|
+
} elseif ($argument -cin @('--no-dry-run', '--dry-run=false')) {
|
|
28
|
+
$dryRunOverride = $false
|
|
29
|
+
} elseif ($argument -ceq '--prefix') {
|
|
30
|
+
$argumentIndex++
|
|
31
|
+
if ($argumentIndex -ge $npmArguments.Count -or -not $npmArguments[$argumentIndex] -or [string]$npmArguments[$argumentIndex] -clike '-*' -or $prefixArguments.Count) {
|
|
32
|
+
$matchesInstall = $false
|
|
33
|
+
} else {
|
|
34
|
+
$prefixArguments = @('--prefix', [string]$npmArguments[$argumentIndex])
|
|
35
|
+
}
|
|
36
|
+
} elseif ($argument -cmatch '^--prefix=(.+)$' -and -not $prefixArguments.Count) {
|
|
37
|
+
$prefixArguments = @($argument)
|
|
38
|
+
} else {
|
|
39
|
+
$matchesInstall = $false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (-not $matchesInstall -or -not $isGlobal -or $targetCount -ne 1) {
|
|
44
|
+
$global:LASTEXITCODE = 0
|
|
45
|
+
& $nativeNpm @npmArguments
|
|
46
|
+
exit $LASTEXITCODE
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$PSNativeCommandUseErrorActionPreference = $false
|
|
50
|
+
$readVersion = {
|
|
51
|
+
param([string]$PackageRoot)
|
|
52
|
+
try {
|
|
53
|
+
$metadataPath = Join-Path $PackageRoot '@minhspark/codex-mcp-bridge/package.json'
|
|
54
|
+
if (-not [System.IO.File]::Exists($metadataPath)) {
|
|
55
|
+
return @{ Status = 'missing'; Version = $null }
|
|
56
|
+
}
|
|
57
|
+
$metadata = [System.IO.File]::ReadAllText($metadataPath) | ConvertFrom-Json -ErrorAction Stop
|
|
58
|
+
if ($metadata.name -cne '@minhspark/codex-mcp-bridge' -or $metadata.version -isnot [string] -or $metadata.version -cnotmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') {
|
|
59
|
+
return @{ Status = 'unknown'; Version = $null }
|
|
60
|
+
}
|
|
61
|
+
return @{ Status = 'known'; Version = $metadata.version }
|
|
62
|
+
} catch {
|
|
63
|
+
return @{ Status = 'unknown'; Version = $null }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
$readRoot = {
|
|
67
|
+
try {
|
|
68
|
+
$global:LASTEXITCODE = 0
|
|
69
|
+
$rootOutput = @(& $nativeNpm root --global @prefixArguments 2>$null)
|
|
70
|
+
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -ne 1) {
|
|
71
|
+
return $null
|
|
72
|
+
}
|
|
73
|
+
$packageRoot = ([string]$rootOutput[0]).Trim()
|
|
74
|
+
if (-not $packageRoot -or -not [System.IO.Path]::IsPathRooted($packageRoot)) {
|
|
75
|
+
return $null
|
|
76
|
+
}
|
|
77
|
+
return $packageRoot
|
|
78
|
+
} catch {
|
|
79
|
+
return $null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$dryRun = $dryRunOverride
|
|
84
|
+
if ($null -eq $dryRun) {
|
|
85
|
+
try {
|
|
86
|
+
$global:LASTEXITCODE = 0
|
|
87
|
+
$configOutput = @(& $nativeNpm config get dry-run --global @prefixArguments 2>$null)
|
|
88
|
+
if ($LASTEXITCODE -eq 0 -and $configOutput.Count -eq 1) {
|
|
89
|
+
$configValue = ([string]$configOutput[0]).Trim()
|
|
90
|
+
if ($configValue -ceq 'true') {
|
|
91
|
+
$dryRun = $true
|
|
92
|
+
} elseif ($configValue -ceq 'false') {
|
|
93
|
+
$dryRun = $false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
$dryRun = $null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
$beforeRoot = & $readRoot
|
|
102
|
+
$before = @{ Status = 'unknown'; Version = $null }
|
|
103
|
+
if ($beforeRoot) {
|
|
104
|
+
$before = & $readVersion $beforeRoot
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$global:LASTEXITCODE = 0
|
|
108
|
+
& $nativeNpm @npmArguments
|
|
109
|
+
$npmExitCode = $LASTEXITCODE
|
|
110
|
+
try {
|
|
111
|
+
if ($npmExitCode -ne 0) {
|
|
112
|
+
Write-Host "Failed to install: @minhspark/codex-mcp-bridge (exit code $npmExitCode). See npm error above."
|
|
113
|
+
exit $npmExitCode
|
|
114
|
+
}
|
|
115
|
+
if ($null -eq $dryRun) {
|
|
116
|
+
Write-Host 'Warning: npm completed, but installation mode could not be verified.'
|
|
117
|
+
exit $npmExitCode
|
|
118
|
+
}
|
|
119
|
+
if ($dryRun) {
|
|
120
|
+
Write-Host 'Dry run completed: @minhspark/codex-mcp-bridge (no changes applied).'
|
|
121
|
+
exit $npmExitCode
|
|
122
|
+
}
|
|
123
|
+
$afterRoot = & $readRoot
|
|
124
|
+
$after = @{ Status = 'unknown'; Version = $null }
|
|
125
|
+
if ($afterRoot) {
|
|
126
|
+
$after = & $readVersion $afterRoot
|
|
127
|
+
}
|
|
128
|
+
if ($after.Status -ne 'known' -or $before.Status -eq 'unknown' -or $beforeRoot -cne $afterRoot) {
|
|
129
|
+
Write-Host 'Warning: npm completed, but the installed @minhspark/codex-mcp-bridge version could not be verified.'
|
|
130
|
+
} elseif ($before.Status -eq 'missing') {
|
|
131
|
+
Write-Host "Successfully installed: @minhspark/codex-mcp-bridge v$($after.Version)"
|
|
132
|
+
} elseif ($before.Version -ceq $after.Version) {
|
|
133
|
+
Write-Host "Already up to date: @minhspark/codex-mcp-bridge v$($after.Version)"
|
|
134
|
+
} else {
|
|
135
|
+
Write-Host "Successfully updated: @minhspark/codex-mcp-bridge v$($before.Version) -> v$($after.Version)"
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
$global:LASTEXITCODE = $npmExitCode
|
|
139
|
+
}
|
|
140
|
+
exit $npmExitCode
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
_codex_bridge_installed_version() {
|
|
2
|
+
command node --input-type=module -e 'import fs from "node:fs"; const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (value.name !== "@minhspark/codex-mcp-bridge" || typeof value.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value.version)) process.exit(1); process.stdout.write(value.version);' "$1/@minhspark/codex-mcp-bridge/package.json" 2>/dev/null
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
npm() {
|
|
6
|
+
if [ "$#" -eq 0 ]; then command npm; return $?; fi
|
|
7
|
+
local bridge_command="${1-}" bridge_global=0 bridge_packages=0 bridge_supported=1
|
|
8
|
+
local bridge_dry_run=auto bridge_root= bridge_before= bridge_after= bridge_install_status=0
|
|
9
|
+
local bridge_after_root= bridge_mode=unknown bridge_before_verified=1
|
|
10
|
+
local -a bridge_arguments bridge_context
|
|
11
|
+
bridge_arguments=("$@")
|
|
12
|
+
bridge_context=(--global)
|
|
13
|
+
case "$bridge_command" in
|
|
14
|
+
install|i) shift ;;
|
|
15
|
+
*) command npm "${bridge_arguments[@]}"; return $? ;;
|
|
16
|
+
esac
|
|
17
|
+
while [ "$#" -gt 0 ]; do
|
|
18
|
+
case "$1" in
|
|
19
|
+
-g|--global|--global=true) bridge_global=1 ;;
|
|
20
|
+
@minhspark/codex-mcp-bridge|@minhspark/codex-mcp-bridge@?*) bridge_packages=$((bridge_packages + 1)) ;;
|
|
21
|
+
--dry-run|--dry-run=true) bridge_dry_run=true ;;
|
|
22
|
+
--no-dry-run|--dry-run=false) bridge_dry_run=false ;;
|
|
23
|
+
--no-fund|--no-audit|--force|--ignore-scripts|--foreground-scripts) ;;
|
|
24
|
+
--prefix=*)
|
|
25
|
+
if [ "${#bridge_context[@]}" -ne 1 ] || [ "$1" = --prefix= ]; then bridge_supported=0; break; fi
|
|
26
|
+
bridge_context+=("$1")
|
|
27
|
+
;;
|
|
28
|
+
--prefix)
|
|
29
|
+
if [ "$#" -lt 2 ] || [ "${#bridge_context[@]}" -ne 1 ] || [ -z "$2" ]; then bridge_supported=0; break; fi
|
|
30
|
+
case "$2" in -*) bridge_supported=0; break ;; esac
|
|
31
|
+
bridge_context+=("$1" "$2")
|
|
32
|
+
shift
|
|
33
|
+
;;
|
|
34
|
+
*) bridge_supported=0 ;;
|
|
35
|
+
esac
|
|
36
|
+
shift
|
|
37
|
+
done
|
|
38
|
+
if [ "$bridge_supported" -ne 1 ] || [ "$bridge_global" -ne 1 ] || [ "$bridge_packages" -ne 1 ]; then
|
|
39
|
+
command npm "${bridge_arguments[@]}"
|
|
40
|
+
return $?
|
|
41
|
+
fi
|
|
42
|
+
if [ "$bridge_dry_run" = auto ]; then
|
|
43
|
+
bridge_mode="$(command npm config get dry-run "${bridge_context[@]}" 2>/dev/null)" || bridge_mode=unknown
|
|
44
|
+
else
|
|
45
|
+
bridge_mode="$bridge_dry_run"
|
|
46
|
+
fi
|
|
47
|
+
if [ "$bridge_mode" = false ]; then
|
|
48
|
+
bridge_root="$(command npm root "${bridge_context[@]}" 2>/dev/null)" || bridge_root=
|
|
49
|
+
case "$bridge_root" in
|
|
50
|
+
/*)
|
|
51
|
+
if [ -e "$bridge_root/@minhspark/codex-mcp-bridge/package.json" ]; then
|
|
52
|
+
bridge_before="$(_codex_bridge_installed_version "$bridge_root")" || bridge_before_verified=0
|
|
53
|
+
fi
|
|
54
|
+
;;
|
|
55
|
+
*) bridge_before_verified=0 ;;
|
|
56
|
+
esac
|
|
57
|
+
fi
|
|
58
|
+
command npm "${bridge_arguments[@]}" || bridge_install_status=$?
|
|
59
|
+
if [ "$bridge_install_status" -ne 0 ]; then
|
|
60
|
+
printf 'Failed to install: @minhspark/codex-mcp-bridge (exit code %s). See npm error above.\n' "$bridge_install_status" >&2
|
|
61
|
+
elif [ "$bridge_mode" = true ]; then
|
|
62
|
+
printf 'Dry run completed: @minhspark/codex-mcp-bridge (no changes applied).\n'
|
|
63
|
+
elif [ "$bridge_mode" != false ]; then
|
|
64
|
+
printf 'Warning: npm completed, but installation mode could not be verified.\n' >&2
|
|
65
|
+
else
|
|
66
|
+
bridge_after_root="$(command npm root "${bridge_context[@]}" 2>/dev/null)" || bridge_after_root=
|
|
67
|
+
case "$bridge_after_root" in
|
|
68
|
+
/*) bridge_after="$(_codex_bridge_installed_version "$bridge_after_root")" || bridge_after= ;;
|
|
69
|
+
esac
|
|
70
|
+
if [ -z "$bridge_after" ] || [ "$bridge_before_verified" -ne 1 ] || [ "$bridge_after_root" != "$bridge_root" ]; then
|
|
71
|
+
printf 'Warning: npm completed, but the installed @minhspark/codex-mcp-bridge version could not be verified.\n' >&2
|
|
72
|
+
elif [ "$bridge_after_root" = "$bridge_root" ] && [ "$bridge_after" = "$bridge_before" ]; then
|
|
73
|
+
printf 'Already up to date: @minhspark/codex-mcp-bridge v%s\n' "$bridge_after"
|
|
74
|
+
elif [ "$bridge_after_root" = "$bridge_root" ] && [ -n "$bridge_before" ]; then
|
|
75
|
+
printf 'Successfully updated: @minhspark/codex-mcp-bridge v%s -> v%s\n' "$bridge_before" "$bridge_after"
|
|
76
|
+
else
|
|
77
|
+
printf 'Successfully installed: @minhspark/codex-mcp-bridge v%s\n' "$bridge_after"
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
return "$bridge_install_status"
|
|
81
|
+
}
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -7,8 +7,11 @@ import { CodexAppServerClient } from "./app-server-client.mjs";
|
|
|
7
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
|
+
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
exitForVersionRequest(import.meta.url);
|
|
13
|
+
|
|
14
|
+
const VERSION = "1.13.3";
|
|
12
15
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
16
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
17
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, writeSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export function exitForVersionRequest(entrypointUrl) {
|
|
5
|
+
if (!process.argv.slice(2).some((arg) => arg === "--version" || arg === "-v")) return;
|
|
6
|
+
if (!process.argv[1]) return;
|
|
7
|
+
|
|
8
|
+
let entrypoint;
|
|
9
|
+
try {
|
|
10
|
+
entrypoint = realpathSync(process.argv[1]);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
if (entrypoint !== realpathSync(fileURLToPath(entrypointUrl))) return;
|
|
16
|
+
|
|
17
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
18
|
+
writeSync(process.stdout.fd, `${version}\n`);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
@@ -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,13 @@ 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
|
+
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
exitForVersionRequest(import.meta.url);
|
|
30
|
+
|
|
31
|
+
const VERSION = "1.13.3";
|
|
29
32
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -111,29 +114,37 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
117
|
+
const deadline = Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
114
118
|
const workspace = resolveWorkspacePath(cwd);
|
|
115
119
|
const created = await desktopTasks.create({
|
|
116
120
|
cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
|
|
117
|
-
model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT,
|
|
121
|
+
dedupeName: name ?? "", model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
|
|
118
122
|
});
|
|
119
123
|
const notes = [];
|
|
120
124
|
if (workspace.note) notes.push(workspace.note);
|
|
121
125
|
if (openInApp ?? DEFAULT_OPEN_IN_APP) {
|
|
122
126
|
try {
|
|
123
|
-
await desktopTasks.open(created.threadId);
|
|
124
|
-
notes.push("opened in Codex Desktop while the task runs");
|
|
127
|
+
await desktopTasks.open(created.threadId, { deadline });
|
|
128
|
+
notes.push(created.reused ? "opened the existing task in Codex Desktop" : "opened in Codex Desktop while the task runs");
|
|
125
129
|
} catch (err) {
|
|
126
|
-
notes.push(
|
|
130
|
+
notes.push(`${created.reused ? "existing task retained" : "task was accepted"}; opening its page failed: ${err.message}`);
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
const lines = [
|
|
130
|
-
"Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
|
|
131
|
-
`cwd: ${created.cwd}`,
|
|
132
|
-
|
|
134
|
+
created.reused ? "Reused the existing Codex Desktop task; the prompt was not resent" : "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
|
|
135
|
+
`cwd: ${created.cwd}`,
|
|
136
|
+
...(created.projectAssignmentStatus === "unverified"
|
|
137
|
+
? [`expected projectId: ${created.expectedProjectId}`, `expected project: ${created.expectedProjectName ?? "(unnamed)"}`, "project assignment: unverified", created.projectAssignmentNote]
|
|
138
|
+
: [`projectId: ${created.projectId}`, `project: ${created.projectName ?? "(unnamed)"}`, ...(created.projectAssignmentStatus === "verified" ? ["project assignment: verified in Desktop's current listing"] : [])]),
|
|
139
|
+
"permissions: Codex Desktop settings; no external app-server writer",
|
|
140
|
+
...(created.promptChanged ? [created.projectAssignmentStatus === "unverified"
|
|
141
|
+
? "The edited brief was not sent. Inspect this task's project assignment before continuing it."
|
|
142
|
+
: "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
143
|
];
|
|
134
|
-
if (
|
|
144
|
+
if (created.projectAssignmentStatus === "unverified") return textResult([...lines, "status: existing task retained; project assignment needs inspection"].join("\n"));
|
|
145
|
+
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
146
|
try {
|
|
136
|
-
const result = await desktopTasks.wait(created.threadId, { timeoutMs: (
|
|
147
|
+
const result = await desktopTasks.wait(created.threadId, { timeoutMs: Math.max(0, deadline - Date.now()) });
|
|
137
148
|
return textResult([...lines, "", formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
|
|
138
149
|
} catch (err) {
|
|
139
150
|
return textResult([...lines, `Task was accepted; observation failed: ${err.message}`, "Do not resend the prompt. Inspect the existing task."].join("\n"), true);
|
|
@@ -293,7 +304,7 @@ server.registerTool(
|
|
|
293
304
|
.min(10)
|
|
294
305
|
.max(3600)
|
|
295
306
|
.optional()
|
|
296
|
-
.describe("How long to
|
|
307
|
+
.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
308
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
298
309
|
effort: z
|
|
299
310
|
.enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
|
|
@@ -383,7 +394,7 @@ server.registerTool(
|
|
|
383
394
|
.min(10)
|
|
384
395
|
.max(3600)
|
|
385
396
|
.optional()
|
|
386
|
-
.describe("How long to
|
|
397
|
+
.describe("How long to observe the task (Desktop caps the entire call at 40s; the task continues and its threadId is returned)"),
|
|
387
398
|
cwd: z.string().optional().describe("Override the working directory for this turn"),
|
|
388
399
|
model: z.string().optional().describe("Override the model for this turn"),
|
|
389
400
|
effort: z
|
|
@@ -408,6 +419,7 @@ server.registerTool(
|
|
|
408
419
|
},
|
|
409
420
|
},
|
|
410
421
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
422
|
+
const deadline = desktopTasksEnabled ? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
|
|
411
423
|
return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
|
|
412
424
|
const notes = [];
|
|
413
425
|
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
@@ -416,18 +428,18 @@ server.registerTool(
|
|
|
416
428
|
if (desktopTasksEnabled) {
|
|
417
429
|
const workspace = cwd ? resolveWorkspacePath(cwd) : null;
|
|
418
430
|
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 });
|
|
431
|
+
const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name, deadline });
|
|
420
432
|
notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
|
|
421
433
|
if (shouldOpen) {
|
|
422
434
|
try {
|
|
423
|
-
await desktopTasks.open(threadId);
|
|
435
|
+
await desktopTasks.open(threadId, { deadline });
|
|
424
436
|
notes.push("opened in Codex Desktop while the task runs");
|
|
425
437
|
} catch (err) {
|
|
426
438
|
notes.push(`task was accepted; opening its page failed: ${err.message}`);
|
|
427
439
|
}
|
|
428
440
|
}
|
|
429
441
|
try {
|
|
430
|
-
const result = await desktopTasks.wait(threadId, { timeoutMs: (
|
|
442
|
+
const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, deadline - Date.now()), previousTurnId: delivered.previousTurnId });
|
|
431
443
|
return textResult([...notes, formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
|
|
432
444
|
} catch (err) {
|
|
433
445
|
return textResult([...notes, `threadId: ${threadId}`, `Task was accepted; observation failed: ${err.message}. Do not resend.`].join("\n"), true);
|
|
@@ -487,7 +499,7 @@ server.registerTool(
|
|
|
487
499
|
} catch (err) {
|
|
488
500
|
return failure(err);
|
|
489
501
|
}
|
|
490
|
-
});
|
|
502
|
+
}, { deadline }).catch(failure);
|
|
491
503
|
},
|
|
492
504
|
);
|
|
493
505
|
|
|
@@ -636,8 +648,9 @@ server.registerTool(
|
|
|
636
648
|
async ({ threadId, limit }) => {
|
|
637
649
|
try {
|
|
638
650
|
if (desktopTasksEnabled) {
|
|
639
|
-
|
|
640
|
-
|
|
651
|
+
const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
652
|
+
await desktopTasks.inspect(threadId, undefined, { deadline });
|
|
653
|
+
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
|
|
641
654
|
return textResult(JSON.stringify(response, null, 2));
|
|
642
655
|
}
|
|
643
656
|
await assertThreadAccess(threadId);
|
|
@@ -723,9 +736,10 @@ server.registerTool(
|
|
|
723
736
|
async ({ threadId, background }) => {
|
|
724
737
|
try {
|
|
725
738
|
if (desktopTasksEnabled) {
|
|
726
|
-
|
|
739
|
+
const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
740
|
+
await desktopTasks.inspect(threadId, undefined, { deadline });
|
|
727
741
|
if (background) await openThreadInCodexApp(threadId, { activate: false });
|
|
728
|
-
else await desktopTasks.open(threadId);
|
|
742
|
+
else await desktopTasks.open(threadId, { deadline });
|
|
729
743
|
return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
|
|
730
744
|
}
|
|
731
745
|
await assertThreadAccess(threadId);
|
|
@@ -19,8 +19,11 @@ import {
|
|
|
19
19
|
decodeNativeToolResult,
|
|
20
20
|
} from "./native-relay.mjs";
|
|
21
21
|
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
22
|
+
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
exitForVersionRequest(import.meta.url);
|
|
25
|
+
|
|
26
|
+
const VERSION = "1.13.3";
|
|
24
27
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
25
28
|
|
|
26
29
|
function errorResponse(code, message) {
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { NativeDesktopRelay, desktopTaskSocketPath, desktopTasksConfigured } fro
|
|
|
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,37 +36,49 @@ 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;
|
|
48
51
|
this.threadOperations = new Map();
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
async request(operation, args) {
|
|
54
|
+
async request(operation, args, { deadline } = {}) {
|
|
52
55
|
try {
|
|
53
|
-
const
|
|
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 });
|
|
54
59
|
return response.result;
|
|
55
60
|
} catch (err) {
|
|
56
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 });
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
async withThread(threadId, operation) {
|
|
65
|
+
async withThread(threadId, operation, { deadline } = {}) {
|
|
61
66
|
const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
|
|
62
|
-
const current = previous.catch(() => {}).then(
|
|
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
|
+
});
|
|
63
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;
|
|
64
76
|
try {
|
|
65
|
-
return await current
|
|
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
|
+
})]);
|
|
66
80
|
} finally {
|
|
67
|
-
|
|
81
|
+
clearTimeout(timer);
|
|
68
82
|
}
|
|
69
83
|
}
|
|
70
84
|
|
|
@@ -97,34 +111,109 @@ export class DesktopTaskDelivery {
|
|
|
97
111
|
}
|
|
98
112
|
return !searchTerm || String(thread.title ?? "").toLowerCase().includes(searchTerm.toLowerCase());
|
|
99
113
|
}).slice(0, limit);
|
|
100
|
-
return { rows, coverage: "Codex Desktop's
|
|
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." };
|
|
101
115
|
}
|
|
102
116
|
|
|
103
|
-
async
|
|
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
|
+
const assignment = await this.receiptProjectAssignment(receipt, cwd, { deadline });
|
|
133
|
+
return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async receiptProjectAssignment(receipt, cwd, { deadline }) {
|
|
137
|
+
let project;
|
|
138
|
+
try {
|
|
139
|
+
const listed = await this.request("list_projects", {}, { deadline });
|
|
140
|
+
if (!Array.isArray(listed?.projects)) throw new Error("Desktop returned no project list");
|
|
141
|
+
project = matchDesktopProject(listed.projects, cwd);
|
|
142
|
+
if (receipt.projectId && receipt.projectId !== project.projectId) throw new Error("The saved project identity changed for this directory");
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new Error(`Existing Desktop task ${receipt.threadId}'s saved project could not be verified: ${err.message}. No prompt was resent and no duplicate task was created. Inspect this existing task and its project settings.`, { cause: err });
|
|
145
|
+
}
|
|
146
|
+
const unverified = (reason) => ({
|
|
147
|
+
expectedProjectId: project.projectId,
|
|
148
|
+
expectedProjectName: project.label,
|
|
149
|
+
projectAssignmentStatus: "unverified",
|
|
150
|
+
projectAssignmentNote: `${reason} The task's current project assignment is unverified. Its workspace and existing ID were retained; no prompt was resent and no duplicate task was created. Inspect this task in Codex Desktop.`,
|
|
151
|
+
});
|
|
152
|
+
let snapshot;
|
|
153
|
+
try {
|
|
154
|
+
snapshot = await this.request("list_threads", { limit: 50 }, { deadline });
|
|
155
|
+
if (!Array.isArray(snapshot?.threads) || !Array.isArray(snapshot?.pinnedThreads)) throw new Error("Desktop returned an invalid thread list");
|
|
156
|
+
if (snapshot.unavailableHosts?.some((host) => host === "local" || host?.hostId === "local")) throw new Error("The local Desktop host is unavailable");
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return unverified(`Desktop's recent/pinned listing could not be confirmed: ${err.message}.`);
|
|
159
|
+
}
|
|
160
|
+
const observed = [...snapshot.pinnedThreads, ...snapshot.threads].filter((thread) => thread?.id === receipt.threadId && thread.kind === "codex" && thread.hostId === "local");
|
|
161
|
+
if (observed.length === 0) return unverified("This task is absent from Desktop's recent/pinned listing.");
|
|
162
|
+
for (const thread of observed) {
|
|
163
|
+
if (thread.projectId !== undefined && thread.projectId !== project.projectId) throw new Error(`Existing Desktop task ${receipt.threadId}'s project assignment changed. No prompt was resent and no duplicate task was created. Inspect its assignment in Codex Desktop.`);
|
|
164
|
+
if (thread.cwd) {
|
|
165
|
+
this.security.assertCwd(thread.cwd);
|
|
166
|
+
if (path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId}'s listed workspace changed. No prompt was resent and no duplicate task was created.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (observed.some((thread) => thread.projectId === undefined || !thread.cwd)) return unverified("Desktop's listing omitted this task's project or workspace metadata.");
|
|
170
|
+
return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
174
|
+
this.security.assertCwd(cwd);
|
|
175
|
+
cwd = realpathSync.native(cwd);
|
|
104
176
|
this.security.assertCwd(cwd);
|
|
105
|
-
|
|
177
|
+
dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
|
|
178
|
+
const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
|
|
179
|
+
const options = { cwd, promptHash: identity.promptHash, deadline };
|
|
180
|
+
const reused = await this.reuseReceipt(await this.receipts.read(identity.key), options);
|
|
181
|
+
if (reused) return reused;
|
|
182
|
+
const listed = await this.request("list_projects", {}, { deadline });
|
|
106
183
|
if (!Array.isArray(listed?.projects)) throw new Error("Codex Desktop returned no project list; no task was created.");
|
|
107
184
|
const project = matchDesktopProject(listed.projects, cwd);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
...(
|
|
113
|
-
|
|
185
|
+
return this.receipts.withLock(identity.key, async () => {
|
|
186
|
+
const existing = await this.reuseReceipt(await this.receipts.read(identity.key), options);
|
|
187
|
+
if (existing) return existing;
|
|
188
|
+
if (this.now() >= deadline) throw new Error("The response deadline elapsed before Desktop creation. No task was created.");
|
|
189
|
+
const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
|
|
190
|
+
await this.receipts.write(identity.key, receipt);
|
|
191
|
+
let threadId;
|
|
192
|
+
let creationConfirmed = false;
|
|
193
|
+
try {
|
|
194
|
+
const response = await this.request("create_thread", {
|
|
195
|
+
prompt, title: name,
|
|
196
|
+
target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
|
|
197
|
+
...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}),
|
|
198
|
+
}, { deadline });
|
|
199
|
+
const confirmedId = response?.threadId ?? response?.conversationId;
|
|
200
|
+
threadId = confirmedId;
|
|
201
|
+
if (!threadId && (response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown")) threadId = response?.clientThreadId;
|
|
202
|
+
if (typeof confirmedId !== "string" || !confirmedId.trim() || response?.status === "outcome-unknown" || response.hostId !== "local") throw new Error(`Desktop creation is not confirmed: ${JSON.stringify(response)}`);
|
|
203
|
+
await this.receipts.write(identity.key, { ...receipt, state: "known", threadId });
|
|
204
|
+
creationConfirmed = true;
|
|
205
|
+
this.security.registerThread(threadId);
|
|
206
|
+
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)}`);
|
|
207
|
+
return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (!creationConfirmed) await this.receipts.write(identity.key, { ...receipt, state: "unknown", ...(typeof threadId === "string" && threadId ? { threadId } : {}) }).catch(() => {});
|
|
210
|
+
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 });
|
|
211
|
+
}
|
|
114
212
|
});
|
|
115
|
-
const threadId = response?.threadId ?? response?.conversationId;
|
|
116
|
-
if (!threadId || response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown") {
|
|
117
|
-
throw new Error(`Desktop creation is not confirmed. Do not resend the prompt: ${JSON.stringify(response)}`);
|
|
118
|
-
}
|
|
119
|
-
this.security.registerThread(threadId);
|
|
120
|
-
if (response.hostId !== "local" || (response.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
121
|
-
throw new Error(`Desktop created task ${threadId}, but did not confirm a local running turn: ${JSON.stringify(response)}. Inspect this task before retrying.`);
|
|
122
|
-
}
|
|
123
|
-
return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
|
|
124
213
|
}
|
|
125
214
|
|
|
126
|
-
async inspect(threadId, cwd) {
|
|
127
|
-
const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 });
|
|
215
|
+
async inspect(threadId, cwd, { deadline } = {}) {
|
|
216
|
+
const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline });
|
|
128
217
|
const thread = response?.thread;
|
|
129
218
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
130
219
|
this.security.assertThread(threadId, thread.cwd);
|
|
@@ -135,14 +224,14 @@ export class DesktopTaskDelivery {
|
|
|
135
224
|
return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
|
|
136
225
|
}
|
|
137
226
|
|
|
138
|
-
async send({ threadId, prompt, cwd, model, effort, name }) {
|
|
139
|
-
const inspected = await this.inspect(threadId, cwd);
|
|
140
|
-
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) });
|
|
227
|
+
async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
|
|
228
|
+
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
229
|
+
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline });
|
|
141
230
|
const response = await this.request("send_message_to_thread", {
|
|
142
231
|
threadId, prompt,
|
|
143
232
|
...(model ? { model } : {}),
|
|
144
233
|
...(effort ? { thinking: effort } : {}),
|
|
145
|
-
});
|
|
234
|
+
}, { deadline });
|
|
146
235
|
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
147
236
|
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|
|
148
237
|
(response?.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
@@ -151,8 +240,8 @@ export class DesktopTaskDelivery {
|
|
|
151
240
|
return { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
|
|
152
241
|
}
|
|
153
242
|
|
|
154
|
-
async open(threadId) {
|
|
155
|
-
const response = await this.request("navigate_to_codex_page", { threadId });
|
|
243
|
+
async open(threadId, { deadline } = {}) {
|
|
244
|
+
const response = await this.request("navigate_to_codex_page", { threadId }, { deadline });
|
|
156
245
|
if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
|
|
157
246
|
}
|
|
158
247
|
|
|
@@ -161,10 +250,18 @@ export class DesktopTaskDelivery {
|
|
|
161
250
|
let cursor;
|
|
162
251
|
let turnId = null;
|
|
163
252
|
let text = "";
|
|
253
|
+
const expired = () => ({ threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
164
254
|
for (;;) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
255
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
256
|
+
let response;
|
|
257
|
+
try {
|
|
258
|
+
response = await this.request("wait_threads", {
|
|
259
|
+
targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
|
|
260
|
+
}, { deadline: startedAt + timeoutMs });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
168
265
|
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
169
266
|
if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
|
|
170
267
|
cursor = poll.cursor;
|
|
@@ -181,7 +278,7 @@ export class DesktopTaskDelivery {
|
|
|
181
278
|
return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
|
|
182
279
|
}
|
|
183
280
|
}
|
|
184
|
-
if (this.now() - startedAt >= timeoutMs) return
|
|
281
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
185
282
|
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
186
283
|
}
|
|
187
284
|
}
|