@minhspark/codex-mcp-bridge 1.10.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.
@@ -0,0 +1,367 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { homeDir } from "./platform.mjs";
9
+
10
+ const SOCKET_DIR = "/tmp/cc-socks";
11
+ const PEER_PROTOCOL_VERSION = 1;
12
+ const CLAUDE_VERSION_HINT = "2.1.229";
13
+ const PS_BIN = "/bin/ps";
14
+
15
+ const sessionsDir = () => path.join(homeDir(), ".claude", "sessions");
16
+ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
17
+
18
+ /**
19
+ * A Claude Code session advertises itself in ~/.claude/sessions/<pid>.json and
20
+ * listens for peer messages on a unix socket. Messages are newline-delimited
21
+ * JSON; the wrapper element is what Claude renders in its chat surface.
22
+ */
23
+ export function buildFrame({ text, fromSocket, priority = "next" }) {
24
+ return {
25
+ msgV: PEER_PROTOCOL_VERSION,
26
+ msg_id: crypto.randomUUID(),
27
+ type: "user",
28
+ message: {
29
+ role: "user",
30
+ content: `<cross-session-message from="uds:${fromSocket}" from-mode="bypass">\n${text}\n</cross-session-message>`,
31
+ },
32
+ priority,
33
+ from: `uds:${fromSocket}`,
34
+ };
35
+ }
36
+
37
+ export function parseFrame(line) {
38
+ const frame = JSON.parse(line);
39
+ const raw = frame?.message?.content ?? "";
40
+ const inner = raw.match(/<cross-session-message[^>]*>\n?([\s\S]*?)\n?<\/cross-session-message>/);
41
+ return {
42
+ msgId: frame.msg_id ?? null,
43
+ from: frame.from ?? null,
44
+ fromSocket: (frame.from ?? "").replace(/^uds:/, "") || null,
45
+ text: (inner ? inner[1] : raw).trim(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
51
+ * absolute path - a bare lookup throws ENOENT and takes the whole server down
52
+ * before it can answer the client's initialize call. The timestamp only guards
53
+ * against pid reuse, so an empty value is an acceptable fallback.
54
+ */
55
+ function readProcessStart(pid) {
56
+ try {
57
+ return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)]).toString().trim();
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function isProcessAlive(pid) {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch (err) {
68
+ return err.code === "EPERM";
69
+ }
70
+ }
71
+
72
+ export const BRIDGE_ENTRYPOINT = "codex-bridge";
73
+
74
+ export function listClaudeSessions({ includeDead = false, includeBridges = false } = {}) {
75
+ const dir = sessionsDir();
76
+ if (!fs.existsSync(dir)) return [];
77
+ const rows = [];
78
+ for (const file of fs.readdirSync(dir)) {
79
+ if (!file.endsWith(".json")) continue;
80
+ let entry;
81
+ try {
82
+ entry = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (!entry?.pid || !entry?.messagingSocketPath) continue;
87
+ if (entry.entrypoint === BRIDGE_ENTRYPOINT && !includeBridges) continue;
88
+ const alive = isProcessAlive(entry.pid) && fs.existsSync(entry.messagingSocketPath);
89
+ if (!alive && !includeDead) continue;
90
+ rows.push({
91
+ pid: entry.pid,
92
+ name: entry.name ?? null,
93
+ sessionId: entry.sessionId ?? null,
94
+ cwd: entry.cwd ?? null,
95
+ kind: entry.kind ?? null,
96
+ entrypoint: entry.entrypoint ?? null,
97
+ startedAt: entry.startedAt ?? null,
98
+ socket: entry.messagingSocketPath,
99
+ alive,
100
+ });
101
+ }
102
+ return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
103
+ }
104
+
105
+ export function findClaudeSession(target) {
106
+ const sessions = listClaudeSessions();
107
+ const needle = String(target).trim();
108
+ return (
109
+ sessions.find((s) => String(s.pid) === needle) ??
110
+ sessions.find((s) => s.sessionId === needle) ??
111
+ sessions.find((s) => s.name === needle) ??
112
+ sessions.find((s) => (s.name ?? "").toLowerCase().includes(needle.toLowerCase())) ??
113
+ null
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
119
+ * where the slug rewrites more than just path separators (/mnt/dev_disk ->
120
+ * -mnt-dev-disk, losing the underscore), so the file is located by scanning
121
+ * rather than by reconstructing the slug.
122
+ */
123
+ function findTranscriptFile(sessionId, cwd) {
124
+ const dir = projectsDir();
125
+ const guess = path.join(dir, String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
126
+ if (fs.existsSync(guess)) return guess;
127
+ if (!fs.existsSync(dir)) return guess;
128
+ for (const project of fs.readdirSync(dir)) {
129
+ const candidate = path.join(dir, project, `${sessionId}.jsonl`);
130
+ if (fs.existsSync(candidate)) return candidate;
131
+ }
132
+ return guess;
133
+ }
134
+
135
+ export function readTranscript(sessionId, cwd, limit = 10) {
136
+ const file = findTranscriptFile(sessionId, cwd);
137
+ if (!fs.existsSync(file)) return { file, messages: [] };
138
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
139
+ const messages = [];
140
+ for (const line of lines) {
141
+ let entry;
142
+ try {
143
+ entry = JSON.parse(line);
144
+ } catch {
145
+ continue;
146
+ }
147
+ const role = entry?.message?.role;
148
+ if (role !== "user" && role !== "assistant") continue;
149
+ if (entry.isMeta || entry.isSidechain) continue;
150
+ const content = entry?.message?.content;
151
+ const text = Array.isArray(content)
152
+ ? content
153
+ .filter((c) => c?.type === "text")
154
+ .map((c) => c.text ?? "")
155
+ .join("\n")
156
+ : String(content ?? "");
157
+ if (!text.trim()) continue;
158
+ messages.push({ role, text: text.trim() });
159
+ }
160
+ return { file, messages: messages.slice(-limit) };
161
+ }
162
+
163
+ /**
164
+ * Registers this process as a peer that Claude sessions can see in their agent
165
+ * list and reply to. Without a registered socket, Claude has no address to
166
+ * answer on and the conversation stays one-way.
167
+ */
168
+ export class PeerEndpoint {
169
+ constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {} } = {}) {
170
+ this.name = name;
171
+ this.cwd = cwd;
172
+ this.log = log;
173
+ this.pid = process.pid;
174
+ this.socketPath = path.join(SOCKET_DIR, `${this.pid}.sock`);
175
+ this.registryPath = path.join(sessionsDir(), `${this.pid}.json`);
176
+ this.keyPath = null;
177
+ this.server = null;
178
+ this.inbox = [];
179
+ this.listeners = new Set();
180
+ this.started = false;
181
+ }
182
+
183
+ /**
184
+ * A bridge killed with SIGKILL leaves its registry entry and socket behind,
185
+ * and Claude then lists a crowd of dead peers with the same name. Sweep the
186
+ * leftovers of previous bridge runs before advertising this one.
187
+ */
188
+ #sweepDeadBridges() {
189
+ const dir = sessionsDir();
190
+ if (!fs.existsSync(dir)) return;
191
+ for (const file of fs.readdirSync(dir)) {
192
+ if (!file.endsWith(".json")) continue;
193
+ const registry = path.join(dir, file);
194
+ let entry;
195
+ try {
196
+ entry = JSON.parse(fs.readFileSync(registry, "utf8"));
197
+ } catch {
198
+ continue;
199
+ }
200
+ if (entry?.entrypoint !== BRIDGE_ENTRYPOINT) continue;
201
+ if (!entry.pid || entry.pid === this.pid || isProcessAlive(entry.pid)) continue;
202
+ for (const stale of [registry, entry.messagingSocketPath, ...fs.readdirSync(dir)
203
+ .filter((f) => f.startsWith(`${entry.pid}.`) && f.endsWith(".key"))
204
+ .map((f) => path.join(dir, f))]) {
205
+ if (!stale) continue;
206
+ try {
207
+ fs.rmSync(stale, { force: true });
208
+ } catch {}
209
+ }
210
+ this.log(`swept dead bridge peer pid ${entry.pid}`);
211
+ }
212
+ }
213
+
214
+ async start() {
215
+ if (this.started) return;
216
+ this.#sweepDeadBridges();
217
+ const procStart = readProcessStart(this.pid);
218
+ const peerToken = crypto.randomBytes(16).toString("hex");
219
+ const keyHash = crypto.createHash("sha256").update(`${peerToken}${procStart}`).digest("hex");
220
+ this.keyPath = path.join(sessionsDir(), `${this.pid}.${keyHash}.key`);
221
+
222
+ fs.mkdirSync(SOCKET_DIR, { recursive: true });
223
+ fs.mkdirSync(sessionsDir(), { recursive: true });
224
+ if (fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
225
+
226
+ await new Promise((resolve, reject) => {
227
+ this.server = net.createServer((socket) => this.#handleConnection(socket));
228
+ this.server.on("error", reject);
229
+ this.server.listen(this.socketPath, resolve);
230
+ });
231
+ fs.chmodSync(this.socketPath, 0o600);
232
+
233
+ this.registry = {
234
+ pid: this.pid,
235
+ sessionId: crypto.randomUUID(),
236
+ cwd: this.cwd,
237
+ startedAt: Date.now(),
238
+ procStart,
239
+ version: CLAUDE_VERSION_HINT,
240
+ peerProtocol: PEER_PROTOCOL_VERSION,
241
+ kind: "interactive",
242
+ entrypoint: BRIDGE_ENTRYPOINT,
243
+ messagingSocketPath: this.socketPath,
244
+ name: this.name,
245
+ nameSource: "derived",
246
+ };
247
+ fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
248
+ fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, procStart }), { mode: 0o600 });
249
+
250
+ for (const signal of ["SIGINT", "SIGTERM"]) {
251
+ process.on(signal, () => {
252
+ this.stop();
253
+ process.exit(0);
254
+ });
255
+ }
256
+ process.on("exit", () => this.stop());
257
+
258
+ this.started = true;
259
+ this.log(`peer "${this.name}" listening on ${this.socketPath}`);
260
+ }
261
+
262
+ #handleConnection(socket) {
263
+ let buffer = "";
264
+ socket.on("data", (chunk) => {
265
+ buffer += chunk.toString("utf8");
266
+ let index;
267
+ while ((index = buffer.indexOf("\n")) >= 0) {
268
+ const line = buffer.slice(0, index).trim();
269
+ buffer = buffer.slice(index + 1);
270
+ if (!line) continue;
271
+ let message;
272
+ try {
273
+ message = parseFrame(line);
274
+ } catch {
275
+ this.log(`ignored malformed peer frame (${line.slice(0, 80)})`);
276
+ continue;
277
+ }
278
+ const record = { ...message, receivedAt: Date.now() };
279
+ this.inbox.push(record);
280
+ this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
281
+ for (const listener of [...this.listeners]) {
282
+ try {
283
+ listener(record);
284
+ } catch (err) {
285
+ this.log(`peer listener error: ${err.message}`);
286
+ }
287
+ }
288
+ }
289
+ });
290
+ socket.on("error", (err) => this.log(`peer socket error: ${err.message}`));
291
+ }
292
+
293
+ /**
294
+ * Codex starts one bridge per session, so several peers advertise at once.
295
+ * Renaming to the bound thread is what lets a human tell them apart in
296
+ * Claude's agent list.
297
+ */
298
+ rename(name) {
299
+ if (!name || name === this.name) return this.name;
300
+ this.name = name;
301
+ if (this.started && this.registry) {
302
+ this.registry.name = name;
303
+ fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
304
+ this.log(`peer renamed to "${name}"`);
305
+ }
306
+ return this.name;
307
+ }
308
+
309
+ onMessage(listener) {
310
+ this.listeners.add(listener);
311
+ return () => this.listeners.delete(listener);
312
+ }
313
+
314
+ async send(targetSocket, text, { priority = "next" } = {}) {
315
+ const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
316
+ await new Promise((resolve, reject) => {
317
+ const client = net.connect({ path: targetSocket }, () => {
318
+ client.write(`${JSON.stringify(frame)}\n`, () => {
319
+ client.end();
320
+ resolve();
321
+ });
322
+ });
323
+ client.on("error", reject);
324
+ });
325
+ return frame.msg_id;
326
+ }
327
+
328
+ /**
329
+ * Claude answers with a fresh msg_id rather than an in-reply-to field, so a
330
+ * reply is matched by origin socket and arrival time.
331
+ */
332
+ waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now() } = {}) {
333
+ const existing = this.inbox.find((m) => m.fromSocket === fromSocket && m.receivedAt >= since);
334
+ if (existing) return Promise.resolve(existing);
335
+ return new Promise((resolve) => {
336
+ const timer = globalThis.setTimeout(() => {
337
+ unsubscribe();
338
+ resolve(null);
339
+ }, timeoutMs);
340
+ const unsubscribe = this.onMessage((record) => {
341
+ if (record.fromSocket !== fromSocket) return;
342
+ globalThis.clearTimeout(timer);
343
+ unsubscribe();
344
+ resolve(record);
345
+ });
346
+ });
347
+ }
348
+
349
+ drainInbox(limit = 20) {
350
+ const messages = this.inbox.slice(-limit);
351
+ this.inbox = [];
352
+ return messages;
353
+ }
354
+
355
+ stop() {
356
+ try {
357
+ this.server?.close();
358
+ } catch {}
359
+ for (const file of [this.socketPath, this.registryPath, this.keyPath]) {
360
+ if (!file) continue;
361
+ try {
362
+ fs.rmSync(file, { force: true });
363
+ } catch {}
364
+ }
365
+ this.started = false;
366
+ }
367
+ }
@@ -0,0 +1,320 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export const IS_MACOS = process.platform === "darwin";
11
+ export const IS_WINDOWS = process.platform === "win32";
12
+ export const IS_LINUX = process.platform === "linux";
13
+
14
+ export const PLATFORM_LABEL = IS_MACOS
15
+ ? "macOS"
16
+ : IS_WINDOWS
17
+ ? "Windows"
18
+ : IS_LINUX
19
+ ? "Linux"
20
+ : process.platform;
21
+
22
+ const CODEX_DESKTOP_APP_MACOS = "/Applications/ChatGPT.app";
23
+ const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources/codex`;
24
+ const CODEX_THREAD_URL_PREFIX = "codex://threads/";
25
+
26
+ /**
27
+ * Read per call rather than at import, so a client that changes the
28
+ * environment does not have to restart the bridge to be believed.
29
+ */
30
+ const remapEnabled = () => process.env.CODEX_BRIDGE_REMAP !== "0";
31
+
32
+ /**
33
+ * `os.homedir()` reads HOME on macOS and Linux but USERPROFILE on Windows, so
34
+ * a process that sets only HOME is obeyed on two platforms and ignored on the
35
+ * third. Every module resolves the home directory through here so all three
36
+ * behave the same.
37
+ */
38
+ export const homeDir = () => process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
39
+
40
+ /**
41
+ * A path that names a drive letter or an attached volume was almost certainly
42
+ * written on the other machine - that is the whole signal, and it needs no
43
+ * configuration to read. Naming one particular drive here would have been a
44
+ * fact about one machine, and the generic form covers every dual-boot and
45
+ * external-disk layout instead of a single blessed one.
46
+ */
47
+ const FOREIGN_ROOT_PATTERNS = [
48
+ /^[A-Za-z]:\/(.+)$/,
49
+ /^\/Volumes\/[^/]+\/(.+)$/,
50
+ /^\/mnt\/[^/]+\/(.+)$/,
51
+ /^\/media\/[^/]+\/[^/]+\/(.+)$/,
52
+ ];
53
+
54
+ /**
55
+ * Where the bridge itself is checked out answers the question "where does this
56
+ * user keep projects?" without anyone having to say so: a bridge living in
57
+ * ~/code/codex-mcp-bridge makes ~/code the obvious place to look for a sibling
58
+ * project. That is a measurement, not a guess, and it keeps one developer's
59
+ * directory layout out of the source.
60
+ */
61
+ function bridgeParentDir() {
62
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
63
+ return path.dirname(repoRoot);
64
+ }
65
+
66
+ /**
67
+ * Set CODEX_BRIDGE_WORKSPACE_ROOTS to take over the search entirely - it
68
+ * replaces the derived roots rather than adding to them, so the order is
69
+ * exactly what was written.
70
+ */
71
+ function workspaceRoots() {
72
+ const configured = process.env.CODEX_BRIDGE_WORKSPACE_ROOTS;
73
+ if (configured) {
74
+ return configured
75
+ .split(path.delimiter)
76
+ .map((entry) => entry.trim())
77
+ .filter(Boolean);
78
+ }
79
+ return [homeDir(), bridgeParentDir()];
80
+ }
81
+
82
+ function isRunnable(candidate) {
83
+ if (!candidate || !existsSync(candidate)) return false;
84
+ if (IS_WINDOWS) return true;
85
+ try {
86
+ accessSync(candidate, constants.X_OK);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function windowsCodexCandidates() {
94
+ const roaming = process.env.APPDATA;
95
+ const local = process.env.LOCALAPPDATA;
96
+ return [
97
+ local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
98
+ roaming && path.join(roaming, "npm", "codex.cmd"),
99
+ process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
100
+ ];
101
+ }
102
+
103
+ function unixCodexCandidates() {
104
+ const home = homeDir();
105
+ return [
106
+ path.join(home, ".local", "bin", "codex"),
107
+ path.join(home, ".npm-global", "bin", "codex"),
108
+ "/opt/homebrew/bin/codex",
109
+ "/usr/local/bin/codex",
110
+ path.join(home, ".volta", "bin", "codex"),
111
+ path.join(home, ".bun", "bin", "codex"),
112
+ path.join(home, ".cargo", "bin", "codex"),
113
+ path.join(home, ".codex", "packages", "standalone", "current", "codex"),
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * The desktop app ships its own codex build and holds ~/.codex/state_*.sqlite
119
+ * open while it runs. Spawning the app-server from a different CLI build makes
120
+ * two versions write that same state, so prefer the app's binary whenever the
121
+ * app is installed and fall back to the standalone CLI installs otherwise.
122
+ */
123
+ function macosCodexCandidates() {
124
+ return [
125
+ hasCodexDesktopApp() ? CODEX_DESKTOP_BIN_MACOS : null,
126
+ ...unixCodexCandidates(),
127
+ ];
128
+ }
129
+
130
+ function pathCandidates() {
131
+ const separator = IS_WINDOWS ? ";" : ":";
132
+ const names = IS_WINDOWS ? ["codex.exe", "codex.cmd"] : ["codex"];
133
+ return (process.env.PATH ?? "")
134
+ .split(separator)
135
+ .filter(Boolean)
136
+ .flatMap((dir) => names.map((name) => path.join(dir, name)));
137
+ }
138
+
139
+ /**
140
+ * Claude Desktop and launchd start child processes with a trimmed PATH, so a
141
+ * bare `codex` lookup fails on macOS where the CLI usually lives under
142
+ * ~/.local/bin, ~/.npm-global/bin or Homebrew. Probe every known install
143
+ * location for the running platform before giving up on PATH resolution.
144
+ */
145
+ export function resolveCodexBin(explicit) {
146
+ const candidates = [
147
+ explicit,
148
+ process.env.CODEX_BIN,
149
+ ...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
150
+ ...pathCandidates(),
151
+ ].filter(Boolean);
152
+
153
+ for (const candidate of candidates) {
154
+ if (isRunnable(candidate)) return candidate;
155
+ }
156
+ return "codex";
157
+ }
158
+
159
+ /**
160
+ * The macOS and Linux `codex` launcher is a Node script with a
161
+ * `#!/usr/bin/env node` shebang, so the spawned child needs a PATH that
162
+ * actually contains node - the trimmed PATH handed to MCP servers does not.
163
+ */
164
+ /**
165
+ * A path is only usable as a Codex cwd when the agent can also write to it.
166
+ * The NTFS mounts this project is shared through are read-only on macOS, so an
167
+ * existence check alone would still hand Codex a directory it cannot edit.
168
+ */
169
+ export function isWritableDir(target) {
170
+ try {
171
+ accessSync(target, constants.W_OK);
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function normalizeSeparators(input) {
179
+ return input.replace(/\\/g, "/").replace(/\/+$/, "") || input;
180
+ }
181
+
182
+ function foreignRootRelative(input) {
183
+ const normalized = normalizeSeparators(input);
184
+ for (const pattern of FOREIGN_ROOT_PATTERNS) {
185
+ const match = normalized.match(pattern);
186
+ if (match) return match[1];
187
+ }
188
+ return null;
189
+ }
190
+
191
+ function remapCandidates(input) {
192
+ const relative = foreignRootRelative(input);
193
+ if (!relative) return [];
194
+ const segments = relative.split("/").filter(Boolean);
195
+ return workspaceRoots().map((root) => path.join(root, ...segments));
196
+ }
197
+
198
+ /**
199
+ * The same project lives at a different absolute path on each machine: on the
200
+ * shared drive's own letter under Windows, under its mount point when that
201
+ * drive is visible from macOS, and in a native checkout otherwise. Handing
202
+ * Codex the wrong one starts the thread against a directory the user is not
203
+ * looking at, or one the agent cannot write to - which then stalls the turn on
204
+ * a permission request instead of failing outright.
205
+ *
206
+ * The path as given always goes first: if this machine can already write
207
+ * there, no rewriting is warranted and guessing would be the bug. Rewriting
208
+ * only happens for a path this machine cannot use, which is exactly the case
209
+ * it was written for - macOS mounts NTFS read-only, so the drive a Windows
210
+ * brief quotes is visible and useless at the same time.
211
+ */
212
+ export function resolveWorkspacePath(input) {
213
+ if (!input) return { path: input, remapped: false, writable: false, note: null };
214
+ const original = input;
215
+ const candidates = [input, ...(remapEnabled() ? remapCandidates(input) : [])];
216
+ const ordered = candidates.filter((candidate, index) => candidates.indexOf(candidate) === index);
217
+
218
+ const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
219
+ if (writable) {
220
+ return {
221
+ path: writable,
222
+ remapped: writable !== original,
223
+ writable: true,
224
+ note:
225
+ writable !== original
226
+ ? `cwd remapped for ${PLATFORM_LABEL}: ${original} -> ${writable}`
227
+ : null,
228
+ };
229
+ }
230
+
231
+ const existing = ordered.find((candidate) => existsSync(candidate));
232
+ if (existing) {
233
+ return {
234
+ path: existing,
235
+ remapped: existing !== original,
236
+ writable: false,
237
+ note: `cwd ${existing} exists but is not writable on ${PLATFORM_LABEL}; Codex will fail on any file edit.`,
238
+ };
239
+ }
240
+
241
+ throw new Error(
242
+ `No usable working directory for "${original}" on ${PLATFORM_LABEL}. Tried: ${ordered.join(", ")}.`,
243
+ );
244
+ }
245
+
246
+ export function spawnEnv(extra = {}) {
247
+ const separator = IS_WINDOWS ? ";" : ":";
248
+ const systemDirs = IS_WINDOWS
249
+ ? []
250
+ : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
251
+ const merged = [path.dirname(process.execPath), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
252
+ .filter(Boolean)
253
+ .filter((dir, index, all) => all.indexOf(dir) === index)
254
+ .join(separator);
255
+ return { ...process.env, PATH: merged, ...extra };
256
+ }
257
+
258
+ export function claudeDesktopConfigPath() {
259
+ const home = homeDir();
260
+ if (IS_MACOS) {
261
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
262
+ }
263
+ if (IS_WINDOWS) {
264
+ const roaming = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
265
+ return path.join(roaming, "Claude", "claude_desktop_config.json");
266
+ }
267
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
268
+ return path.join(configHome, "Claude", "claude_desktop_config.json");
269
+ }
270
+
271
+ export function launchAgentPath() {
272
+ return path.join(homeDir(), "Library", "LaunchAgents", "com.codex-mcp-bridge.app-server.plist");
273
+ }
274
+
275
+ export const LAUNCH_AGENT_LABEL = "com.codex-mcp-bridge.app-server";
276
+
277
+ export function codexThreadUrl(threadId) {
278
+ return `${CODEX_THREAD_URL_PREFIX}${threadId}`;
279
+ }
280
+
281
+ export function hasCodexDesktopApp() {
282
+ return IS_MACOS && existsSync(CODEX_DESKTOP_APP_MACOS);
283
+ }
284
+
285
+ export function isLaunchAgentInstalled() {
286
+ return IS_MACOS && existsSync(launchAgentPath());
287
+ }
288
+
289
+ /**
290
+ * The desktop app runs its own stdio app-server against the same ~/.codex
291
+ * sqlite state. A second long-lived app-server contends for that state and the
292
+ * app's UI stutters, so the two should not both sit idle in the background.
293
+ */
294
+ export function isDesktopAppServerRunning() {
295
+ if (!IS_MACOS) return false;
296
+ try {
297
+ const out = execFileSync("/bin/ps", ["-Ao", "command="], { maxBuffer: 4 * 1024 * 1024 }).toString();
298
+ return out.split("\n").some((line) => line.includes("ChatGPT.app") && line.includes("app-server"));
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Bring a thread to the foreground in the Codex desktop app so a human can
306
+ * watch the turn run instead of only reading the transcript afterwards.
307
+ * macOS registers the `codex://` scheme through /Applications/ChatGPT.app.
308
+ */
309
+ export async function openThreadInCodexApp(threadId, { activate = true } = {}) {
310
+ const url = codexThreadUrl(threadId);
311
+ if (!IS_MACOS) {
312
+ throw new Error(`Opening a Codex thread in the desktop app is macOS-only. Open ${url} manually.`);
313
+ }
314
+ if (!hasCodexDesktopApp()) {
315
+ throw new Error(`Codex desktop app not found at ${CODEX_DESKTOP_APP_MACOS}. Install it to use ${url}.`);
316
+ }
317
+ const args = activate ? [url] : ["-g", url];
318
+ await execFileAsync("open", args, { timeout: 10000 });
319
+ return url;
320
+ }