@minhspark/codex-mcp-bridge 1.16.1 → 1.18.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 +24 -0
- package/README.md +41 -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 +6 -4
- package/src/codex-sender-context.mjs +5 -4
- package/src/index.mjs +7 -5
- package/src/native-relay-companion.mjs +1 -6
- package/src/peer-protocol.mjs +27 -10
- package/src/prompt-guidance.mjs +30 -0
- package/src/vscode-bridge.mjs +163 -0
- package/src/vscode-delivery.mjs +19 -0
- package/src/vscode-ipc.mjs +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.18.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Guide agents to write every cross-agent prompt in English with a fixed section order: Goal, Context, Task, Scope, Constraints, Done when, Reply format. The Codex, Claude and VS Code bridges share one definition in their server instructions, sending-tool descriptions and prompt fields, and text supplied by the user is still sent unchanged.
|
|
12
|
+
|
|
13
|
+
## [1.17.0] - 2026-09-21
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Bridge existing Claude Code and Codex conversations inside VS Code on Windows, with same-project session checks and delivery receipts.
|
|
18
|
+
- Mirror published releases to GitHub Packages alongside npm.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- Repair current bridge client configurations while refusing to overwrite changes from active client writers.
|
|
24
|
+
- Distinguish unreadable process identity from a confirmed identity change during session validation.
|
|
25
|
+
- Preserve hosted demo media and keep analytics Pages deployment working when analytics sources are incomplete.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Document release notification subscriptions and improve receipt test reliability under load.
|
|
30
|
+
|
|
7
31
|
## [1.16.1] - 2026-09-15
|
|
8
32
|
|
|
9
33
|
### 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:
|
|
@@ -209,6 +231,22 @@ Example requests:
|
|
|
209
231
|
|
|
210
232
|
Use the exact project directory and destination task. If several Claude sessions match, specify the task ID. A `reply_received` receipt confirms a reply; a timeout does not mean the task stopped, so inspect it before retrying.
|
|
211
233
|
|
|
234
|
+
The sending tools ask agents to write each prompt in English with these sections, dropping any that do not apply: `Goal`, `Context`, `Task`, `Scope`, `Constraints`, `Done when`, `Reply format`. The first line names the sender, the project and the purpose. Text the user supplied is sent unchanged.
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
[From Claude Code · my-app · edit coordination]
|
|
238
|
+
|
|
239
|
+
## Goal
|
|
240
|
+
Avoid conflicting edits while Claude Code patches `src/export.ps1`.
|
|
241
|
+
|
|
242
|
+
## Task
|
|
243
|
+
1. Do not modify `src/export.ps1` or `README.txt` until told otherwise.
|
|
244
|
+
2. Write any unsaved edits to those files to disk now.
|
|
245
|
+
|
|
246
|
+
## Reply format
|
|
247
|
+
Exactly one line: `DONE — changed: <files>` or `DONE — no changes`.
|
|
248
|
+
```
|
|
249
|
+
|
|
212
250
|
## Important behavior
|
|
213
251
|
|
|
214
252
|
- Desktop mode uses the native relay and the apps' permissions; it does not fall back to an external app-server.
|
|
@@ -286,7 +324,7 @@ For unresolved failures, [open an issue](https://github.com/buidangminh23/codex-
|
|
|
286
324
|
|
|
287
325
|
## Repository analytics
|
|
288
326
|
|
|
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.
|
|
327
|
+
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
328
|
|
|
291
329
|
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
330
|
|
|
@@ -346,4 +384,4 @@ npm test
|
|
|
346
384
|
|
|
347
385
|
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
386
|
|
|
349
|
-
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
|
387
|
+
[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.18.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
|
@@ -22,10 +22,11 @@ import { readClaudeAccountContext } from "./desktop-account-context.mjs";
|
|
|
22
22
|
import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
|
|
23
23
|
import { resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
|
|
24
24
|
import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
25
|
+
import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
|
|
25
26
|
|
|
26
27
|
exitForVersionRequest(import.meta.url);
|
|
27
28
|
|
|
28
|
-
const VERSION = "1.
|
|
29
|
+
const VERSION = "1.18.0";
|
|
29
30
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
30
31
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
31
32
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -261,7 +262,8 @@ const server = new McpServer(
|
|
|
261
262
|
"The sender class follows the verified approval policy; automated review flags are reported but never change it. " +
|
|
262
263
|
"A Desktop recipient whose settings refuse or hold inbound messages, or whose task metadata shows a different permission class without an explicit accept, is refused before sending, because Claude Desktop cannot show the approval dialog. " +
|
|
263
264
|
"Never launch a CLI session or an external app-server as a substitute. A receipt confirms a reply, not visual verification in the app. " +
|
|
264
|
-
"A held receipt does not prove that Desktop exposes an approval button; verify the UI before asking the user to approve."
|
|
265
|
+
"A held receipt does not prove that Desktop exposes an approval button; verify the UI before asking the user to approve. " +
|
|
266
|
+
AGENT_PROMPT_GUIDANCE,
|
|
265
267
|
},
|
|
266
268
|
);
|
|
267
269
|
|
|
@@ -280,7 +282,7 @@ registerTool(
|
|
|
280
282
|
inputSchema: {
|
|
281
283
|
requestId: z.string().describe("Stable UUID for this creation; reuse exactly when inspecting or retrying"),
|
|
282
284
|
cwd: z.string().describe("Exact absolute directory of the existing Claude Desktop project"),
|
|
283
|
-
prompt: z.string().describe(
|
|
285
|
+
prompt: z.string().describe(`Initial prompt for the NEW conversation; shown for user confirmation. ${PROMPT_FIELD_HINT}`),
|
|
284
286
|
},
|
|
285
287
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
286
288
|
},
|
|
@@ -369,7 +371,7 @@ registerTool(
|
|
|
369
371
|
"The sender's current permissions are verified per call; environment overrides and manual binding cannot bypass an unknown sender. It never creates a replacement session.",
|
|
370
372
|
inputSchema: {
|
|
371
373
|
target: z.string().describe("Use auto for current-account discovery in expectedCwd, an exact session identity, or new to prepare a separate Desktop conversation requiring user confirmation"),
|
|
372
|
-
message: z.string().describe(
|
|
374
|
+
message: z.string().describe(`The message text to deliver. ${PROMPT_FIELD_HINT}`),
|
|
373
375
|
expectedCwd: z.string().optional().describe("Exact absolute project directory independently verified by the caller; required in Desktop-only mode"),
|
|
374
376
|
expectedTaskId: z.string().optional().describe("Exact native Claude Desktop task ID from list_claude_sessions; verify its title in the app before sending"),
|
|
375
377
|
waitSec: z
|
|
@@ -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
|
@@ -30,10 +30,11 @@ import { createRuntimeState } from "./runtime-state.mjs";
|
|
|
30
30
|
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
31
31
|
import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
32
32
|
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
33
|
+
import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
|
|
33
34
|
|
|
34
35
|
exitForVersionRequest(import.meta.url);
|
|
35
36
|
|
|
36
|
-
const VERSION = "1.
|
|
37
|
+
const VERSION = "1.18.0";
|
|
37
38
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
38
39
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
39
40
|
|
|
@@ -345,7 +346,8 @@ const server = new McpServer(
|
|
|
345
346
|
"requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
|
|
346
347
|
"Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
|
|
347
348
|
"Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
348
|
-
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring."
|
|
349
|
+
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring. " +
|
|
350
|
+
AGENT_PROMPT_GUIDANCE,
|
|
349
351
|
},
|
|
350
352
|
);
|
|
351
353
|
|
|
@@ -400,7 +402,7 @@ registerTool(
|
|
|
400
402
|
"return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind.",
|
|
401
403
|
inputSchema: {
|
|
402
404
|
cwd: z.string().describe("Absolute project directory where Codex must work"),
|
|
403
|
-
prompt: z.string().describe(
|
|
405
|
+
prompt: z.string().describe(`The complete task Claude is delegating to Codex. ${PROMPT_FIELD_HINT}`),
|
|
404
406
|
name: z.string().min(1).max(200).optional().describe("Optional Codex session title; otherwise one is derived from the prompt"),
|
|
405
407
|
timeoutSec: z
|
|
406
408
|
.number()
|
|
@@ -494,7 +496,7 @@ registerTool(
|
|
|
494
496
|
"Do not close the task, create a replacement, or ask the user to copy the message manually.",
|
|
495
497
|
inputSchema: {
|
|
496
498
|
threadId: z.string().describe("Codex thread id (UUID) - get it from list_codex_threads"),
|
|
497
|
-
prompt: z.string().describe(
|
|
499
|
+
prompt: z.string().describe(`The message to send to Codex as a new user turn. ${PROMPT_FIELD_HINT}`),
|
|
498
500
|
timeoutSec: z
|
|
499
501
|
.number()
|
|
500
502
|
.int()
|
|
@@ -703,7 +705,7 @@ registerTool(
|
|
|
703
705
|
description: "Start a Codex task. In Desktop mode include the initial prompt to create and assign a visible task atomically; use delegate_to_codex to also wait for its reply.",
|
|
704
706
|
inputSchema: {
|
|
705
707
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
706
|
-
prompt: z.string().min(1).optional().describe(
|
|
708
|
+
prompt: z.string().min(1).optional().describe(`Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately. ${PROMPT_FIELD_HINT}`),
|
|
707
709
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
708
710
|
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
709
711
|
},
|
|
@@ -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.18.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,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every message this bridge carries is a prompt one agent wrote for another.
|
|
3
|
+
* A single paragraph that runs the request into the reply format leaves the
|
|
4
|
+
* recipient guessing where the task ends, so the sending tools all describe
|
|
5
|
+
* one shape. It lives here so the Codex, Claude and VS Code bridges cannot
|
|
6
|
+
* drift apart.
|
|
7
|
+
*/
|
|
8
|
+
export const PROMPT_SECTIONS = Object.freeze([
|
|
9
|
+
"Goal",
|
|
10
|
+
"Context",
|
|
11
|
+
"Task",
|
|
12
|
+
"Scope",
|
|
13
|
+
"Constraints",
|
|
14
|
+
"Done when",
|
|
15
|
+
"Reply format",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const AGENT_PROMPT_GUIDANCE =
|
|
19
|
+
"Write every prompt you compose for another agent in English, in this order, omitting sections that do not apply: " +
|
|
20
|
+
"a first line naming the sender, project and purpose, e.g. [From Claude Code · <project> · <purpose>]; " +
|
|
21
|
+
"## Goal, the outcome in one sentence; ## Context, measured facts, paths with line numbers and existing code to reuse; " +
|
|
22
|
+
"## Task, numbered imperative steps; ## Scope, the files that may and must not change; " +
|
|
23
|
+
"## Constraints, conventions and tests, plus: if the current state differs from this description, stop and report instead of reconciling it; " +
|
|
24
|
+
"## Done when, checkable criteria; ## Reply format, the exact shape of the reply. " +
|
|
25
|
+
"A short coordination message needs only Goal, Context, Task and Reply format. " +
|
|
26
|
+
"Send text the user supplied verbatim, and keep file names, identifiers and user-facing strings in their original language.";
|
|
27
|
+
|
|
28
|
+
export const PROMPT_FIELD_HINT =
|
|
29
|
+
`The prompt for the recipient agent, in English with the sections ${PROMPT_SECTIONS.join(", ")} ` +
|
|
30
|
+
"(omit any that do not apply); text the user supplied is sent verbatim";
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
|
|
14
|
+
|
|
15
|
+
const host = process.argv[2];
|
|
16
|
+
if (!["claude", "codex"].includes(host)) throw new Error("Specify the calling extension: claude or codex");
|
|
17
|
+
if (process.platform !== "win32") throw new Error("VS Code bridge preview currently supports Windows only");
|
|
18
|
+
const roots = (process.env.VSCODE_BRIDGE_ALLOWED_ROOTS ?? "").split(path.delimiter).filter(Boolean).map((root) => fs.realpathSync.native(root));
|
|
19
|
+
if (!roots.length || roots.some((root) => path.parse(root).root === root)) throw new Error("Configure explicit project roots in VSCODE_BRIDGE_ALLOWED_ROOTS");
|
|
20
|
+
const sessions = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
|
|
21
|
+
const peer = new PeerEndpoint({ name: "codex-vscode-bridge" });
|
|
22
|
+
const unresolved = new Map();
|
|
23
|
+
const deliveryOwners = new Map();
|
|
24
|
+
const result = (value, isError = false) => ({ content: [{ type: "text", text: JSON.stringify(value) }], isError });
|
|
25
|
+
const samePath = (left, right) => path.relative(fs.realpathSync.native(left), fs.realpathSync.native(right)) === "";
|
|
26
|
+
|
|
27
|
+
function assertRoot(cwd) {
|
|
28
|
+
const resolved = fs.realpathSync.native(cwd);
|
|
29
|
+
if (!roots.some((root) => {
|
|
30
|
+
const relative = path.relative(root, resolved);
|
|
31
|
+
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`);
|
|
32
|
+
})) throw new Error("The session is outside the configured project roots");
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function caller(meta) {
|
|
37
|
+
if (host === "codex") {
|
|
38
|
+
const sender = readCodexSenderContext(meta, { originator: "codex_vscode" });
|
|
39
|
+
if (sender.status !== "verified") throw new Error(sender.reason);
|
|
40
|
+
assertRoot(sender.cwd);
|
|
41
|
+
return sender;
|
|
42
|
+
}
|
|
43
|
+
const ancestry = await readProcessAncestry();
|
|
44
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && ancestry.some((row) => row.pid === session.pid && row.processStart === session.processStart));
|
|
45
|
+
if (matches.length !== 1) throw new Error("This MCP server must be launched by one live Claude Code VS Code session");
|
|
46
|
+
assertClaudeSessionProcess(matches[0]);
|
|
47
|
+
assertRoot(matches[0].cwd);
|
|
48
|
+
return matches[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function codexTask(threadId, cwd) {
|
|
52
|
+
if (!/^[0-9a-f-]{36}$/.test(threadId)) throw new Error("Invalid task ID");
|
|
53
|
+
const file = findRollout(sessions, threadId);
|
|
54
|
+
const state = readState(file, 64 * 1024 * 1024);
|
|
55
|
+
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");
|
|
56
|
+
assertRoot(state.session.cwd);
|
|
57
|
+
if (!samePath(state.session.cwd, cwd) || !samePath(state.context.cwd, cwd)) throw new Error("Target and caller must use the same project directory");
|
|
58
|
+
return { ...state, file };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskCandidates(cwd) {
|
|
62
|
+
const queue = [{ directory: sessions, depth: 0 }];
|
|
63
|
+
const rows = [];
|
|
64
|
+
let count = 0;
|
|
65
|
+
while (queue.length) {
|
|
66
|
+
const { directory, depth } = queue.pop();
|
|
67
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
68
|
+
if (++count > 100000) throw new Error("Session scan limit exceeded");
|
|
69
|
+
if (entry.isSymbolicLink()) continue;
|
|
70
|
+
const file = path.join(directory, entry.name);
|
|
71
|
+
if (depth < 3 && entry.isDirectory() && /^\d{2,4}$/.test(entry.name)) queue.push({ directory: file, depth: depth + 1 });
|
|
72
|
+
if (depth !== 3 || !entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
73
|
+
const fd = fs.openSync(file, "r");
|
|
74
|
+
let first;
|
|
75
|
+
try { const data = Buffer.alloc(65536); const size = fs.readSync(fd, data); first = JSON.parse(data.subarray(0, size).toString("utf8").split("\n")[0]); }
|
|
76
|
+
catch { continue; }
|
|
77
|
+
finally { fs.closeSync(fd); }
|
|
78
|
+
const session = first.payload;
|
|
79
|
+
if (first.type === "session_meta" && session?.originator === "codex_vscode" && session.source === "vscode" && samePath(session.cwd, cwd)) rows.push({ threadId: session.id, cwd: session.cwd });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return rows.slice(-50);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const server = new McpServer({ name: `vscode-${host}-bridge`, version: "0.1.0" });
|
|
86
|
+
function tool(name, description, inputSchema, action) {
|
|
87
|
+
server.registerTool(name, { description, inputSchema }, async (args, extra) => {
|
|
88
|
+
try { return result(await action(args, await caller(extra?._meta), extra?._meta)); }
|
|
89
|
+
catch (error) { return result({ error: error.message, ...(error.msgId ? { msgId: error.msgId } : {}), ...(error.delivery ? { delivery: error.delivery } : {}) }, true); }
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
tool("vscode_bridge_status", "Verify this extension caller and list same-project counterparts in VS Code. Desktop sessions are excluded.", {}, async (_, sender) => {
|
|
94
|
+
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 })) };
|
|
95
|
+
const ipc = await new VSCodeIpc().connect();
|
|
96
|
+
try {
|
|
97
|
+
const tasks = [];
|
|
98
|
+
for (const candidate of taskCandidates(sender.cwd)) {
|
|
99
|
+
try { await ipc.owner(candidate.threadId); tasks.push(candidate); } catch {}
|
|
100
|
+
}
|
|
101
|
+
return { host, cwd: sender.cwd, tasks };
|
|
102
|
+
} finally { ipc.close(); }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (host === "claude") {
|
|
106
|
+
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. ${AGENT_PROMPT_GUIDANCE}`, { threadId: z.string(), message: z.string().min(1).max(50000).describe(PROMPT_FIELD_HINT) }, async ({ threadId, message }, sender, meta) => {
|
|
107
|
+
if (unresolved.has(threadId)) throw new Error("An earlier send is unresolved. Read its reply before sending again.");
|
|
108
|
+
const inspect = () => {
|
|
109
|
+
const state = codexTask(threadId, sender.cwd);
|
|
110
|
+
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");
|
|
111
|
+
};
|
|
112
|
+
inspect();
|
|
113
|
+
const ipc = await new VSCodeIpc().connect();
|
|
114
|
+
try {
|
|
115
|
+
return await ipc.send(threadId, message, { beforeSend: async () => {
|
|
116
|
+
const current = await caller(meta);
|
|
117
|
+
if (current.sessionId !== sender.sessionId || current.processStart !== sender.processStart) throw new Error("Caller session changed");
|
|
118
|
+
inspect();
|
|
119
|
+
if (unresolved.has(threadId)) throw new Error("Another send is already pending for this task");
|
|
120
|
+
unresolved.set(threadId, "submission outcome unknown");
|
|
121
|
+
} }).then((receipt) => { unresolved.set(threadId, receipt.turnId); return receipt; });
|
|
122
|
+
} finally { ipc.close(); }
|
|
123
|
+
});
|
|
124
|
+
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) => {
|
|
125
|
+
const state = codexTask(threadId, sender.cwd);
|
|
126
|
+
const completion = classifyCodexCompletion(state.lifecycle, turnId);
|
|
127
|
+
if (["completed", "failed", "interrupted"].includes(completion.status) && unresolved.get(threadId) === turnId) unresolved.delete(threadId);
|
|
128
|
+
return { threadId, ...completion };
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
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. ${AGENT_PROMPT_GUIDANCE}`, { sessionId: z.string(), message: z.string().min(1).max(50000).describe(PROMPT_FIELD_HINT), waitSec: z.number().int().min(0).max(45).default(30) }, async ({ sessionId, message, waitSec }, sender, meta) => {
|
|
132
|
+
const inspect = () => {
|
|
133
|
+
const matches = listClaudeSessions().filter((session) => session.entrypoint === "claude-vscode" && session.sessionId === sessionId);
|
|
134
|
+
if (matches.length !== 1) throw new Error("No unique live Claude Code VS Code session matches");
|
|
135
|
+
const session = matches[0];
|
|
136
|
+
assertClaudeSessionCwd(session, sender.cwd);
|
|
137
|
+
assertClaudeSessionProcess(session);
|
|
138
|
+
const policy = readClaudeInboundPolicy(session.cwd);
|
|
139
|
+
if (["hold", "refuse"].includes(policy.value)) throw new Error(`Recipient crossSessionInbound is ${policy.value}; no message sent`);
|
|
140
|
+
return session;
|
|
141
|
+
};
|
|
142
|
+
const recipient = inspect();
|
|
143
|
+
await peer.start();
|
|
144
|
+
const receipt = await peer.sendAndWait(recipient.socket, message, { timeoutMs: waitSec * 1000, permissionMode: sender.mode, transcriptSession: { sessionId, cwd: recipient.cwd }, beforeSend: async () => {
|
|
145
|
+
const current = await caller(meta);
|
|
146
|
+
if (current.turnId !== sender.turnId || current.mode !== sender.mode) throw new Error("Caller turn or permissions changed");
|
|
147
|
+
const target = inspect();
|
|
148
|
+
if (target.pid !== recipient.pid || target.socket !== recipient.socket || target.processStart !== recipient.processStart) throw new Error("Recipient session changed");
|
|
149
|
+
} }).catch((error) => {
|
|
150
|
+
if (error.msgId) deliveryOwners.set(error.msgId, sender.threadId);
|
|
151
|
+
throw error;
|
|
152
|
+
});
|
|
153
|
+
deliveryOwners.set(receipt.msgId, sender.threadId);
|
|
154
|
+
return classifyClaudeDelivery(receipt, sender.mode);
|
|
155
|
+
});
|
|
156
|
+
tool("read_claude_vscode_delivery", "Inspect an earlier send without resending it.", { msgId: z.string() }, async ({ msgId }, sender) => {
|
|
157
|
+
if (deliveryOwners.get(msgId) !== sender.threadId) throw new Error("This receipt does not belong to the calling task");
|
|
158
|
+
return peer.readDelivery(msgId) ?? { status: "unknown" };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.on("exit", () => peer.stop());
|
|
163
|
+
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
|
+
}
|