@minhspark/codex-mcp-bridge 1.16.1 → 1.17.0
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 +18 -0
- package/README.md +25 -3
- package/package.json +1 -1
- package/scripts/collect-repo-analytics.mjs +11 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/refresh-hosted-analytics.mjs +5 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-sender-context.mjs +5 -4
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +1 -6
- package/src/peer-protocol.mjs +27 -10
- package/src/vscode-bridge.mjs +162 -0
- package/src/vscode-delivery.mjs +19 -0
- package/src/vscode-ipc.mjs +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.17.0] - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Bridge existing Claude Code and Codex conversations inside VS Code on Windows, with same-project session checks and delivery receipts.
|
|
12
|
+
- Mirror published releases to GitHub Packages alongside npm.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Keep discovering the Codex Desktop native tools pipe when it is absent during companion startup. Retry with capped backoff until Desktop is ready or the companion closes, without sending user messages or requiring another task to restart the relay.
|
|
17
|
+
- Repair current bridge client configurations while refusing to overwrite changes from active client writers.
|
|
18
|
+
- Distinguish unreadable process identity from a confirmed identity change during session validation.
|
|
19
|
+
- Preserve hosted demo media and keep analytics Pages deployment working when analytics sources are incomplete.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Document release notification subscriptions and improve receipt test reliability under load.
|
|
24
|
+
|
|
7
25
|
## [1.16.1] - 2026-09-15
|
|
8
26
|
|
|
9
27
|
### Changed
|
package/README.md
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
Send prompts and replies between **Claude and Codex**, keeping each conversation in its own app. Supports Windows, macOS, and Linux; native Desktop integration supports Windows and macOS.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
https://github.com/user-attachments/assets/98b23989-826f-4d7d-9dcb-ad7dd2739095
|
|
10
|
+
|
|
11
|
+
## Release notifications
|
|
12
|
+
|
|
13
|
+
To receive new release notifications, open [this repository](https://github.com/buidangminh23/codex-mcp-bridge), select **Watch → Custom → Releases**, then click **Apply**. Choose GitHub or email delivery in your [notification settings](https://github.com/settings/notifications).
|
|
14
|
+
|
|
15
|
+
Starring the repository or downloading/installing a package does not subscribe you to release notifications. Notifications do not update your installed copy; follow the installation instructions to update.
|
|
16
|
+
|
|
17
|
+
[View release notes](https://github.com/buidangminh23/codex-mcp-bridge/releases).
|
|
10
18
|
|
|
11
19
|
## Project statistics
|
|
12
20
|
|
|
@@ -18,6 +26,20 @@ Send prompts and replies between **Claude and Codex**, keeping each conversation
|
|
|
18
26
|
|
|
19
27
|
## Installation
|
|
20
28
|
|
|
29
|
+
### GitHub Packages
|
|
30
|
+
|
|
31
|
+
The repository-linked copy is [@buidangminh23/codex-mcp-bridge](https://github.com/buidangminh23/codex-mcp-bridge/packages)
|
|
32
|
+
on npm.pkg.github.com. The npmjs.com package remains @minhspark/codex-mcp-bridge.
|
|
33
|
+
GitHub's npm registry requires authentication with a classic token with read:packages,
|
|
34
|
+
even for public packages. Authenticate locally and never commit a token:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm login --scope=@buidangminh23 --registry=https://npm.pkg.github.com --auth-type=legacy
|
|
38
|
+
npm install -g @buidangminh23/codex-mcp-bridge --registry=https://npm.pkg.github.com
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then follow the platform registration and verification instructions below.
|
|
42
|
+
|
|
21
43
|
[Windows](#windows-powershell) · [macOS](#macos-terminal) · [Linux / WSL](#linux--wsl-bash) · [Claude Code registration](#register-claude-code) · [Verify](#verify-the-installation) · [Troubleshooting](#troubleshooting)
|
|
22
44
|
|
|
23
45
|
Choose the mode for the conversations you want to connect:
|
|
@@ -286,7 +308,7 @@ For unresolved failures, [open an issue](https://github.com/buidangminh23/codex-
|
|
|
286
308
|
|
|
287
309
|
## Repository analytics
|
|
288
310
|
|
|
289
|
-
The live dashboard polls the aggregate API every 30 seconds. Installation counts reflect reports received by the server; this is not a count of currently online processes. Public GitHub/npm sources are refreshed with a short cache, but their own statistics may be delayed.
|
|
311
|
+
The live dashboard polls the aggregate API every 30 seconds. Installation counts reflect reports received by the server; this is not a count of currently online processes. Public GitHub/npm sources are refreshed with a short cache, but their own statistics may be delayed. GitHub Actions refreshes and archives statistics hourly through the `Repository analytics` workflow, including private GitHub traffic; scheduled runs may be delayed by GitHub. That workflow needs the `ANALYTICS_TOKEN` repository secret set to a token with push access to this repository (fine-grained: Contents read/write plus Administration read); the default workflow token cannot read GitHub traffic, so without it the views/clones sources are retained from the last successful run and the run is reported as incomplete while the Pages deployment still succeeds. The README image is a snapshot and may be cached by GitHub; open the live dashboard for automatic updates. Source timestamps show freshness; missing data is unavailable, not zero. Only aggregate figures are published. Installation IDs stay in the private database.
|
|
290
312
|
|
|
291
313
|
Repository owners can view [GitHub traffic](https://github.com/buidangminh23/codex-mcp-bridge/graphs/traffic) for recent views and clones. Downloads and clones include updates, reinstalls, and automation; they do not measure active users. GitHub traffic only covers the recent 14-day window, so collect it regularly to keep a longer history.
|
|
292
314
|
|
|
@@ -346,4 +368,4 @@ npm test
|
|
|
346
368
|
|
|
347
369
|
On Windows, use `node --test --test-concurrency=2` (also avoids npm versions that reject forwarded flags). CI tests Node 22 and 24 on Linux, macOS, and Windows. Tests use isolated fixtures and do not spend model quota. For the optional telemetry endpoint, also run `deno test --allow-env supabase/functions/bridge-usage/contract-check.ts`.
|
|
348
370
|
|
|
349
|
-
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
|
371
|
+
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
package/package.json
CHANGED
|
@@ -45,6 +45,11 @@ function traffic(data, key) {
|
|
|
45
45
|
return { count: count(data.count), uniques: count(data.uniques), [key]: data[key].map(row => ({ timestamp: `${day(row.timestamp)}T00:00:00Z`, count: count(row.count), uniques: count(row.uniques) })) };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export function httpStatus(error) {
|
|
49
|
+
const match = /\(HTTP (\d{3})\)/.exec(`${error?.stderr ?? ''}\n${error?.message ?? ''}`);
|
|
50
|
+
return match ? Number(match[1]) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
async function github(endpoint, paginate = false) {
|
|
49
54
|
const args = ['api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', endpoint];
|
|
50
55
|
if (paginate) args.push('--paginate', '--slurp');
|
|
@@ -79,7 +84,12 @@ export async function collectSources(options, { gh = github, fetcher = fetch, no
|
|
|
79
84
|
};
|
|
80
85
|
await Promise.all(Object.entries(jobs).map(async ([source, job]) => {
|
|
81
86
|
try { snapshot[source] = await job(); }
|
|
82
|
-
catch
|
|
87
|
+
catch (error) {
|
|
88
|
+
const entry = { source, message: source === 'npm' ? 'Request failed or response invalid. Check npm package, network, and npm API availability.' : 'Request failed or response invalid. Check gh authentication, repository permissions, network, and GitHub API limits.' };
|
|
89
|
+
const status = httpStatus(error);
|
|
90
|
+
if (status) entry.status = status;
|
|
91
|
+
snapshot.errors.push(entry);
|
|
92
|
+
}
|
|
83
93
|
}));
|
|
84
94
|
snapshot.errors.sort((a, b) => a.source.localeCompare(b.source));
|
|
85
95
|
return snapshot;
|
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.
|
|
25
|
+
const VERSION = "1.17.0";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
@@ -28,6 +28,10 @@ const directory = path.resolve('public-analytics');
|
|
|
28
28
|
await mkdir(directory, { recursive: true });
|
|
29
29
|
for (const [name, content] of Object.entries(publicFiles(history, usage))) await writeFile(path.join(directory, name), content);
|
|
30
30
|
console.log(`Published aggregate analytics: ${sha}`);
|
|
31
|
-
for (const error of snapshot.errors)
|
|
31
|
+
for (const error of snapshot.errors) {
|
|
32
|
+
const status = error.status ? ` (HTTP ${error.status})` : '';
|
|
33
|
+
const hint = error.status === 403 && ['views', 'clones'].includes(error.source) ? ' GitHub traffic requires the ANALYTICS_TOKEN secret to be a token with push access (fine-grained: Administration read); the default workflow token cannot read traffic.' : '';
|
|
34
|
+
console.error(`::warning::${error.source} was not refreshed${status}; previous data retained.${hint}`);
|
|
35
|
+
}
|
|
32
36
|
if (usageFailed) console.error('::warning::Usage was not refreshed; previous data retained.');
|
|
33
37
|
if (snapshot.errors.length || usageFailed) process.exitCode = 1;
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
|
25
25
|
|
|
26
26
|
exitForVersionRequest(import.meta.url);
|
|
27
27
|
|
|
28
|
-
const VERSION = "1.
|
|
28
|
+
const VERSION = "1.17.0";
|
|
29
29
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
30
30
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
31
31
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -29,7 +29,7 @@ function reviewFlag(metadata, field) {
|
|
|
29
29
|
return "invalid";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function findRollout(sessions, threadId) {
|
|
32
|
+
export function findRollout(sessions, threadId) {
|
|
33
33
|
if (!fs.lstatSync(sessions).isDirectory()) throw new Error("The Codex sessions path is not a regular directory");
|
|
34
34
|
const queue = [{ directory: sessions, depth: 0 }];
|
|
35
35
|
const matches = [];
|
|
@@ -57,7 +57,7 @@ function findRollout(sessions, threadId) {
|
|
|
57
57
|
return matches[0];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function readState(file, maxBytes) {
|
|
60
|
+
export function readState(file, maxBytes) {
|
|
61
61
|
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
62
62
|
try {
|
|
63
63
|
const before = fs.fstatSync(descriptor);
|
|
@@ -120,7 +120,7 @@ function permissionClass(context, metadata) {
|
|
|
120
120
|
throw new Error("The caller's effective approval policy is unsupported");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
123
|
+
export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES, originator = "Codex Desktop" } = {}) {
|
|
124
124
|
const metadata = object(meta) ? meta[METADATA_KEY] : undefined;
|
|
125
125
|
if (!object(metadata) || typeof metadata.thread_id !== "string" || typeof metadata.turn_id !== "string" || !UUID.test(metadata.thread_id) || !UUID.test(metadata.turn_id)) return unavailable("This MCP call has no valid host-supplied Codex task and turn identity");
|
|
126
126
|
const identity = { threadId: metadata.thread_id, turnId: metadata.turn_id,
|
|
@@ -134,7 +134,8 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
134
134
|
const file = findRollout(sessions, identity.threadId);
|
|
135
135
|
const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
136
136
|
const { session, context, lifecycle } = state;
|
|
137
|
-
if (
|
|
137
|
+
if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
|
|
138
|
+
if (session?.id !== identity.threadId || session.originator !== originator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
|
|
138
139
|
if (context?.turn_id !== identity.turnId || lifecycle?.turn_id !== identity.turnId || !STARTED.has(lifecycle?.type)) throw new Error("The calling turn is no longer the latest active Codex turn");
|
|
139
140
|
if (typeof context.cwd !== "string" || !path.isAbsolute(context.cwd) || typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The caller's workspace is missing or invalid");
|
|
140
141
|
const cwd = fs.realpathSync.native(context.cwd);
|
package/src/index.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSender
|
|
|
33
33
|
|
|
34
34
|
exitForVersionRequest(import.meta.url);
|
|
35
35
|
|
|
36
|
-
const VERSION = "1.
|
|
36
|
+
const VERSION = "1.17.0";
|
|
37
37
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
38
38
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
39
39
|
|
|
@@ -34,7 +34,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.
|
|
37
|
+
const VERSION = "1.17.0";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
|
@@ -635,11 +635,6 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
|
|
|
635
635
|
} catch (err) {
|
|
636
636
|
if (stopped) return;
|
|
637
637
|
if (!nativeConnected) nativeTools.close();
|
|
638
|
-
if (!nativeTools.socketPath && !nativeTools.hasDiscoveredSocket) {
|
|
639
|
-
logFn(`native relay unavailable (${err.message})`);
|
|
640
|
-
resolveReady(false);
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
638
|
logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
|
|
644
639
|
timer = globalThis.setTimeout(() => {
|
|
645
640
|
timer = null;
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -104,7 +104,11 @@ function readPeerToken(socket) {
|
|
|
104
104
|
const identity = readPeerProcessIdentity(key);
|
|
105
105
|
const hasIdentity = Object.hasOwn(key, "procStart") || (IS_WINDOWS && Object.hasOwn(key, "procStartFt"));
|
|
106
106
|
if (!identity && (hasIdentity || (IS_WINDOWS && hardenedBridgeEnabled()))) throw new Error("Peer process identity missing or invalid");
|
|
107
|
-
if (identity
|
|
107
|
+
if (identity) {
|
|
108
|
+
const current = readProcessStart(candidates[0].pid);
|
|
109
|
+
if (current === null) throw new Error("The live peer process identity could not be read; message was not sent. That failure is not itself evidence the process changed.");
|
|
110
|
+
if (identity !== current) throw new Error("Peer process identity changed");
|
|
111
|
+
}
|
|
108
112
|
return key.peerToken;
|
|
109
113
|
} catch {
|
|
110
114
|
if (IS_WINDOWS) throw new Error("The destination inbox authentication key is missing or invalid; message was not sent");
|
|
@@ -123,21 +127,31 @@ export function readPeerProcessIdentity(entry, platform = process.platform) {
|
|
|
123
127
|
return values[0];
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
const PROCESS_START_TIMEOUT_MS = 30000;
|
|
131
|
+
|
|
126
132
|
/**
|
|
127
133
|
* MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
|
|
128
134
|
* absolute path - a bare lookup throws ENOENT and takes the whole server down
|
|
129
|
-
* before it can answer the client's initialize call.
|
|
130
|
-
*
|
|
135
|
+
* before it can answer the client's initialize call.
|
|
136
|
+
*
|
|
137
|
+
* A failed read is reported as null rather than as an empty string, because
|
|
138
|
+
* the callers compare this against a recorded identity and an empty string
|
|
139
|
+
* silently compares as "changed". A cold PowerShell on a contended Windows
|
|
140
|
+
* runner has been measured in this repo between 5s and 15s, so the previous
|
|
141
|
+
* 3s ceiling turned ordinary slowness into a report that the peer process had
|
|
142
|
+
* been swapped - the alarming reading of the two, and the one that fails a
|
|
143
|
+
* send. The budget now matches what the suites already give their own
|
|
144
|
+
* identity reads.
|
|
131
145
|
*/
|
|
132
146
|
function readProcessStart(pid) {
|
|
133
147
|
try {
|
|
134
148
|
if (IS_WINDOWS) {
|
|
135
149
|
const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
136
|
-
return execFileSync(shell, ["-NoProfile", "-NonInteractive", "-Command", `[System.Diagnostics.Process]::GetProcessById(${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout:
|
|
150
|
+
return execFileSync(shell, ["-NoProfile", "-NonInteractive", "-Command", `[System.Diagnostics.Process]::GetProcessById(${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: PROCESS_START_TIMEOUT_MS, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
|
|
137
151
|
}
|
|
138
|
-
return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout:
|
|
152
|
+
return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: PROCESS_START_TIMEOUT_MS }).toString().trim();
|
|
139
153
|
} catch {
|
|
140
|
-
return
|
|
154
|
+
return null;
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -251,10 +265,13 @@ export function assertClaudeSessionCwd(session, expectedCwd) {
|
|
|
251
265
|
}
|
|
252
266
|
|
|
253
267
|
export function assertClaudeSessionProcess(session) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
const changed = "The live Claude process identity is missing or changed. No message was sent; reopen the existing Desktop task and inspect its session again.";
|
|
269
|
+
if (!session?.alive || typeof session.processStart !== "string" || !session.processStart) throw new Error(changed);
|
|
270
|
+
const current = readProcessStart(session.pid);
|
|
271
|
+
if (current === null) {
|
|
272
|
+
throw new Error("The live Claude process identity could not be read, so this send was refused rather than sent unverified. That failure is not itself evidence the session changed; inspect the existing Desktop task before retrying.");
|
|
257
273
|
}
|
|
274
|
+
if (current !== session.processStart) throw new Error(changed);
|
|
258
275
|
}
|
|
259
276
|
|
|
260
277
|
/**
|
|
@@ -462,7 +479,7 @@ export class PeerEndpoint {
|
|
|
462
479
|
|
|
463
480
|
async #startOnce() {
|
|
464
481
|
this.#sweepDeadBridges();
|
|
465
|
-
const procStart = readProcessStart(this.pid);
|
|
482
|
+
const procStart = readProcessStart(this.pid) ?? "";
|
|
466
483
|
const peerToken = crypto.randomBytes(16).toString("hex");
|
|
467
484
|
this.peerToken = peerToken;
|
|
468
485
|
this.keyPath = peerKeyPath(this.pid, this.socketPath);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { VSCodeIpc } from "./vscode-ipc.mjs";
|
|
8
|
+
import { classifyClaudeDelivery, classifyCodexCompletion } from "./vscode-delivery.mjs";
|
|
9
|
+
import { findRollout, readState, readCodexSenderContext } from "./codex-sender-context.mjs";
|
|
10
|
+
import { readProcessAncestry } from "./claude-sender-context.mjs";
|
|
11
|
+
import { listClaudeSessions, assertClaudeSessionCwd, assertClaudeSessionProcess, PeerEndpoint } from "./peer-protocol.mjs";
|
|
12
|
+
import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
13
|
+
|
|
14
|
+
const host = process.argv[2];
|
|
15
|
+
if (!["claude", "codex"].includes(host)) throw new Error("Specify the calling extension: claude or codex");
|
|
16
|
+
if (process.platform !== "win32") throw new Error("VS Code bridge preview currently supports Windows only");
|
|
17
|
+
const roots = (process.env.VSCODE_BRIDGE_ALLOWED_ROOTS ?? "").split(path.delimiter).filter(Boolean).map((root) => fs.realpathSync.native(root));
|
|
18
|
+
if (!roots.length || roots.some((root) => path.parse(root).root === root)) throw new Error("Configure explicit project roots in VSCODE_BRIDGE_ALLOWED_ROOTS");
|
|
19
|
+
const sessions = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
|
|
20
|
+
const peer = new PeerEndpoint({ name: "codex-vscode-bridge" });
|
|
21
|
+
const unresolved = new Map();
|
|
22
|
+
const deliveryOwners = new Map();
|
|
23
|
+
const result = (value, isError = false) => ({ content: [{ type: "text", text: JSON.stringify(value) }], isError });
|
|
24
|
+
const samePath = (left, right) => path.relative(fs.realpathSync.native(left), fs.realpathSync.native(right)) === "";
|
|
25
|
+
|
|
26
|
+
function assertRoot(cwd) {
|
|
27
|
+
const resolved = fs.realpathSync.native(cwd);
|
|
28
|
+
if (!roots.some((root) => {
|
|
29
|
+
const relative = path.relative(root, resolved);
|
|
30
|
+
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
31
|
+
})) throw new Error("The session is outside the configured project roots");
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function caller(meta) {
|
|
36
|
+
if (host === "codex") {
|
|
37
|
+
const sender = readCodexSenderContext(meta, { originator: "codex_vscode" });
|
|
38
|
+
if (sender.status !== "verified") throw new Error(sender.reason);
|
|
39
|
+
assertRoot(sender.cwd);
|
|
40
|
+
return sender;
|
|
41
|
+
}
|
|
42
|
+
const ancestry = await readProcessAncestry();
|
|
43
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && ancestry.some((row) => row.pid === session.pid && row.processStart === session.processStart));
|
|
44
|
+
if (matches.length !== 1) throw new Error("This MCP server must be launched by one live Claude Code VS Code session");
|
|
45
|
+
assertClaudeSessionProcess(matches[0]);
|
|
46
|
+
assertRoot(matches[0].cwd);
|
|
47
|
+
return matches[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function codexTask(threadId, cwd) {
|
|
51
|
+
if (!/^[0-9a-f-]{36}$/.test(threadId)) throw new Error("Invalid task ID");
|
|
52
|
+
const file = findRollout(sessions, threadId);
|
|
53
|
+
const state = readState(file, 64 * 1024 * 1024);
|
|
54
|
+
if (state.session?.id !== threadId || state.session.originator !== "codex_vscode" || state.session.source !== "vscode") throw new Error("The target is not a Codex VS Code task");
|
|
55
|
+
assertRoot(state.session.cwd);
|
|
56
|
+
if (!samePath(state.session.cwd, cwd) || !samePath(state.context.cwd, cwd)) throw new Error("Target and caller must use the same project directory");
|
|
57
|
+
return { ...state, file };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function taskCandidates(cwd) {
|
|
61
|
+
const queue = [{ directory: sessions, depth: 0 }];
|
|
62
|
+
const rows = [];
|
|
63
|
+
let count = 0;
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
const { directory, depth } = queue.pop();
|
|
66
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
67
|
+
if (++count > 100000) throw new Error("Session scan limit exceeded");
|
|
68
|
+
if (entry.isSymbolicLink()) continue;
|
|
69
|
+
const file = path.join(directory, entry.name);
|
|
70
|
+
if (depth < 3 && entry.isDirectory() && /^\d{2,4}$/.test(entry.name)) queue.push({ directory: file, depth: depth + 1 });
|
|
71
|
+
if (depth !== 3 || !entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
72
|
+
const fd = fs.openSync(file, "r");
|
|
73
|
+
let first;
|
|
74
|
+
try { const data = Buffer.alloc(65536); const size = fs.readSync(fd, data); first = JSON.parse(data.subarray(0, size).toString("utf8").split("\n")[0]); }
|
|
75
|
+
catch { continue; }
|
|
76
|
+
finally { fs.closeSync(fd); }
|
|
77
|
+
const session = first.payload;
|
|
78
|
+
if (first.type === "session_meta" && session?.originator === "codex_vscode" && session.source === "vscode" && samePath(session.cwd, cwd)) rows.push({ threadId: session.id, cwd: session.cwd });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return rows.slice(-50);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const server = new McpServer({ name: `vscode-${host}-bridge`, version: "0.1.0" });
|
|
85
|
+
function tool(name, description, inputSchema, action) {
|
|
86
|
+
server.registerTool(name, { description, inputSchema }, async (args, extra) => {
|
|
87
|
+
try { return result(await action(args, await caller(extra?._meta), extra?._meta)); }
|
|
88
|
+
catch (error) { return result({ error: error.message, ...(error.msgId ? { msgId: error.msgId } : {}), ...(error.delivery ? { delivery: error.delivery } : {}) }, true); }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
tool("vscode_bridge_status", "Verify this extension caller and list same-project counterparts in VS Code. Desktop sessions are excluded.", {}, async (_, sender) => {
|
|
93
|
+
if (host === "codex") return { host, cwd: sender.cwd, sessions: listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && samePath(session.cwd, sender.cwd)).map(({ sessionId, cwd }) => ({ sessionId, cwd })) };
|
|
94
|
+
const ipc = await new VSCodeIpc().connect();
|
|
95
|
+
try {
|
|
96
|
+
const tasks = [];
|
|
97
|
+
for (const candidate of taskCandidates(sender.cwd)) {
|
|
98
|
+
try { await ipc.owner(candidate.threadId); tasks.push(candidate); } catch {}
|
|
99
|
+
}
|
|
100
|
+
return { host, cwd: sender.cwd, tasks };
|
|
101
|
+
} finally { ipc.close(); }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (host === "claude") {
|
|
105
|
+
tool("send_to_codex_vscode", "Send to an existing Codex extension task in this project, preserving its selected permissions. Never retries or opens another app. Use read_codex_vscode_reply after submission.", { threadId: z.string(), message: z.string().min(1).max(50000) }, async ({ threadId, message }, sender, meta) => {
|
|
106
|
+
if (unresolved.has(threadId)) throw new Error("An earlier send is unresolved. Read its reply before sending again.");
|
|
107
|
+
const inspect = () => {
|
|
108
|
+
const state = codexTask(threadId, sender.cwd);
|
|
109
|
+
if (!["task_complete", "task_completed", "turn_complete", "turn_completed", "turn_aborted", "task_aborted"].includes(state.lifecycle?.type)) throw new Error("The target is busy or its lifecycle is unknown; wait for it to finish");
|
|
110
|
+
};
|
|
111
|
+
inspect();
|
|
112
|
+
const ipc = await new VSCodeIpc().connect();
|
|
113
|
+
try {
|
|
114
|
+
return await ipc.send(threadId, message, { beforeSend: async () => {
|
|
115
|
+
const current = await caller(meta);
|
|
116
|
+
if (current.sessionId !== sender.sessionId || current.processStart !== sender.processStart) throw new Error("Caller session changed");
|
|
117
|
+
inspect();
|
|
118
|
+
if (unresolved.has(threadId)) throw new Error("Another send is already pending for this task");
|
|
119
|
+
unresolved.set(threadId, "submission outcome unknown");
|
|
120
|
+
} }).then((receipt) => { unresolved.set(threadId, receipt.turnId); return receipt; });
|
|
121
|
+
} finally { ipc.close(); }
|
|
122
|
+
});
|
|
123
|
+
tool("read_codex_vscode_reply", "Read completion of the exact returned turn ID. A submission receipt alone does not prove completion.", { threadId: z.string(), turnId: z.string() }, async ({ threadId, turnId }, sender) => {
|
|
124
|
+
const state = codexTask(threadId, sender.cwd);
|
|
125
|
+
const completion = classifyCodexCompletion(state.lifecycle, turnId);
|
|
126
|
+
if (["completed", "failed", "interrupted"].includes(completion.status) && unresolved.get(threadId) === turnId) unresolved.delete(threadId);
|
|
127
|
+
return { threadId, ...completion };
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
tool("send_to_claude_vscode", "Send to the exact Claude Code VS Code session in this project. Never changes recipient permissions. Do not ask the recipient to call back while this synchronous request waits.", { sessionId: z.string(), message: z.string().min(1).max(50000), waitSec: z.number().int().min(0).max(45).default(30) }, async ({ sessionId, message, waitSec }, sender, meta) => {
|
|
131
|
+
const inspect = () => {
|
|
132
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && session.sessionId === sessionId);
|
|
133
|
+
if (matches.length !== 1) throw new Error("No unique live Claude Code VS Code session matches");
|
|
134
|
+
const session = matches[0];
|
|
135
|
+
assertClaudeSessionCwd(session, sender.cwd);
|
|
136
|
+
assertClaudeSessionProcess(session);
|
|
137
|
+
const policy = readClaudeInboundPolicy(session.cwd);
|
|
138
|
+
if (["hold", "refuse"].includes(policy.value)) throw new Error(`Recipient crossSessionInbound is ${policy.value}; no message sent`);
|
|
139
|
+
return session;
|
|
140
|
+
};
|
|
141
|
+
const recipient = inspect();
|
|
142
|
+
await peer.start();
|
|
143
|
+
const receipt = await peer.sendAndWait(recipient.socket, message, { timeoutMs: waitSec * 1000, permissionMode: sender.mode, transcriptSession: { sessionId, cwd: recipient.cwd }, beforeSend: async () => {
|
|
144
|
+
const current = await caller(meta);
|
|
145
|
+
if (current.turnId !== sender.turnId || current.mode !== sender.mode) throw new Error("Caller turn or permissions changed");
|
|
146
|
+
const target = inspect();
|
|
147
|
+
if (target.pid !== recipient.pid || target.socket !== recipient.socket || target.processStart !== recipient.processStart) throw new Error("Recipient session changed");
|
|
148
|
+
} }).catch((error) => {
|
|
149
|
+
if (error.msgId) deliveryOwners.set(error.msgId, sender.threadId);
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
152
|
+
deliveryOwners.set(receipt.msgId, sender.threadId);
|
|
153
|
+
return classifyClaudeDelivery(receipt, sender.mode);
|
|
154
|
+
});
|
|
155
|
+
tool("read_claude_vscode_delivery", "Inspect an earlier send without resending it.", { msgId: z.string() }, async ({ msgId }, sender) => {
|
|
156
|
+
if (deliveryOwners.get(msgId) !== sender.threadId) throw new Error("This receipt does not belong to the calling task");
|
|
157
|
+
return peer.readDelivery(msgId) ?? { status: "unknown" };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
process.on("exit", () => peer.stop());
|
|
162
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const BLOCKED = new Set(["held", "refused", "denied", "expired", "dropped"]);
|
|
2
|
+
|
|
3
|
+
export function classifyClaudeDelivery(receipt, senderMode) {
|
|
4
|
+
if (receipt.reply) return { ...receipt, status: "reply_received" };
|
|
5
|
+
const status = receipt.delivery?.status ?? "sent_unconfirmed";
|
|
6
|
+
if (BLOCKED.has(status)) {
|
|
7
|
+
throw Object.assign(new Error(`Claude returned ${status}: ${receipt.delivery.reason ?? "the message was not delivered to the conversation"}. Sender permission class: ${senderMode}. This does not prove an approval control exists in the VS Code extension. Report the receipt to the user; do not resend, change permissions, or claim that the user can click an unverified approval button.`), { msgId: receipt.msgId, delivery: receipt.delivery });
|
|
8
|
+
}
|
|
9
|
+
return { ...receipt, status };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function classifyCodexCompletion(lifecycle, turnId) {
|
|
13
|
+
if (lifecycle?.turn_id !== turnId) return { status: "pending_or_unavailable", turnId };
|
|
14
|
+
if (["turn_aborted", "task_aborted"].includes(lifecycle.type)) return { status: "interrupted", turnId };
|
|
15
|
+
if (!["task_complete", "task_completed", "turn_complete", "turn_completed"].includes(lifecycle.type)) return { status: "pending_or_unavailable", turnId };
|
|
16
|
+
if (lifecycle.error) return { status: "failed", turnId, error: lifecycle.error };
|
|
17
|
+
if (typeof lifecycle.last_agent_message !== "string") return { status: "response_unavailable", turnId };
|
|
18
|
+
return { status: "completed", turnId, text: lifecycle.last_agent_message };
|
|
19
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export class VSCodeIpc {
|
|
5
|
+
constructor({ socketPath = "\\\\.\\pipe\\codex-ipc", timeoutMs = 10000 } = {}) {
|
|
6
|
+
this.socketPath = socketPath;
|
|
7
|
+
this.timeoutMs = timeoutMs;
|
|
8
|
+
this.pending = new Map();
|
|
9
|
+
this.buffer = Buffer.alloc(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async connect() {
|
|
13
|
+
this.socket = net.createConnection(this.socketPath);
|
|
14
|
+
this.socket.on("data", (data) => {
|
|
15
|
+
try {
|
|
16
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
17
|
+
while (this.buffer.length >= 4) {
|
|
18
|
+
const length = this.buffer.readUInt32LE(0);
|
|
19
|
+
if (!length || length > 16 * 1024 * 1024) throw new Error("Invalid IPC frame length");
|
|
20
|
+
if (this.buffer.length < length + 4) break;
|
|
21
|
+
const frame = JSON.parse(this.buffer.subarray(4, length + 4).toString("utf8"));
|
|
22
|
+
this.buffer = this.buffer.subarray(length + 4);
|
|
23
|
+
if (frame.type !== "response") continue;
|
|
24
|
+
const pending = this.pending.get(frame.requestId);
|
|
25
|
+
if (!pending) continue;
|
|
26
|
+
this.pending.delete(frame.requestId);
|
|
27
|
+
clearTimeout(pending.timer);
|
|
28
|
+
if (frame.resultType !== "success") pending.reject(new Error(`IPC ${pending.method}: ${frame.error ?? "request failed"}`));
|
|
29
|
+
else if (pending.target && frame.handledByClientId !== pending.target) pending.reject(new Error("IPC owner changed"));
|
|
30
|
+
else pending.resolve(frame);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) { this.close(error); }
|
|
33
|
+
});
|
|
34
|
+
this.socket.on("error", (error) => this.close(error));
|
|
35
|
+
this.socket.on("close", () => this.close(new Error("IPC connection closed")));
|
|
36
|
+
const response = await this.request("initialize", { clientType: "codex-mcp-bridge" }, 0);
|
|
37
|
+
if (typeof response.result?.clientId !== "string") throw new Error("IPC initialization omitted client identity");
|
|
38
|
+
this.clientId = response.result.clientId;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
request(method, params, version = 1, target) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const requestId = randomUUID();
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
this.pending.delete(requestId);
|
|
47
|
+
reject(new Error(`IPC ${method} timed out; inspect the original task before retrying a send`));
|
|
48
|
+
}, this.timeoutMs);
|
|
49
|
+
this.pending.set(requestId, { resolve, reject, timer, method, target });
|
|
50
|
+
const body = Buffer.from(JSON.stringify({ type: "request", requestId, sourceClientId: this.clientId ?? "initializing-client", version, method, params, ...(target ? { targetClientId: target } : {}) }));
|
|
51
|
+
const header = Buffer.alloc(4);
|
|
52
|
+
header.writeUInt32LE(body.length);
|
|
53
|
+
this.socket.write(Buffer.concat([header, body]));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async owner(threadId) {
|
|
58
|
+
const response = await this.request("thread-owner-discovery", { hostId: "local", conversationId: threadId });
|
|
59
|
+
if (!response.handledByClientId) throw new Error("No live owner for this task");
|
|
60
|
+
return response.handledByClientId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async send(threadId, message, { beforeSend } = {}) {
|
|
64
|
+
const owner = await this.owner(threadId);
|
|
65
|
+
await beforeSend?.();
|
|
66
|
+
const response = await this.request("thread-follower-start-turn", {
|
|
67
|
+
conversationId: threadId,
|
|
68
|
+
turnStart: { request: { threadId, input: [{ type: "text", text: message, text_elements: [] }] }, context: { inheritThreadSettings: true } },
|
|
69
|
+
}, 2, owner);
|
|
70
|
+
const turnId = response.result?.result?.turn?.id;
|
|
71
|
+
if (typeof turnId !== "string") throw new Error("Submission was acknowledged without a turn ID; inspect the original task before retrying");
|
|
72
|
+
return { threadId, turnId, owner, status: "submitted" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(error = new Error("IPC client closed")) {
|
|
76
|
+
this.socket?.destroy();
|
|
77
|
+
for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
|
|
78
|
+
this.pending.clear();
|
|
79
|
+
}
|
|
80
|
+
}
|