@xallindrome-labs/codex-bridge 0.0.0-stage → 0.2.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/.claude-plugin/marketplace.json +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/SECURITY.md +23 -0
- package/docs/demo.svg +40 -0
- package/lib.mjs +208 -0
- package/package.json +38 -4
- package/plugins/codex-jobs/.claude-plugin/plugin.json +9 -0
- package/plugins/codex-jobs/LICENSE +21 -0
- package/plugins/codex-jobs/README.md +45 -0
- package/plugins/codex-jobs/hooks/hooks.json +1 -0
- package/plugins/codex-jobs/hooks/register.tsx +390 -0
- package/plugins/codex-jobs/types/index.d.ts +54 -0
- package/runner.mjs +103 -0
- package/server.mjs +594 -0
- package/smoke-test.mjs +111 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codex-bridge: a dependency-free MCP stdio server that lets Claude Code
|
|
3
|
+
// consult the Codex CLI as a planner (read-only) or an image generator.
|
|
4
|
+
//
|
|
5
|
+
// Every call is a durable job: Codex runs under runner.mjs, launched outside
|
|
6
|
+
// Claude Code's process tree, and all state lives in ~/.codex-bridge/jobs.
|
|
7
|
+
// Jobs keep running if Claude closes, any session can collect their results,
|
|
8
|
+
// and jobs killed by a reboot/crash can be resumed where they stopped.
|
|
9
|
+
//
|
|
10
|
+
// Tools:
|
|
11
|
+
// codex_plan - read-only planning / design review / second opinion
|
|
12
|
+
// codex_code - OPT-IN: Codex implements a change, writing only inside CODEX_BRIDGE_WRITE_ROOT
|
|
13
|
+
// codex_image - image generation; images are saved into output_dir
|
|
14
|
+
// codex_result - status/result of jobs (optionally waits); lists jobs
|
|
15
|
+
// codex_resume - continue an interrupted/failed job where it left off
|
|
16
|
+
// codex_cancel - stop a running job
|
|
17
|
+
//
|
|
18
|
+
// Env:
|
|
19
|
+
// CODEX_BIN path to codex.exe (auto-detected otherwise)
|
|
20
|
+
// CODEX_BRIDGE_HOME job storage (default ~/.codex-bridge)
|
|
21
|
+
// CODEX_BRIDGE_IDLE_TIMEOUT_MS kill a run after this long with no Codex output (default 600000)
|
|
22
|
+
// CODEX_BRIDGE_PLAN_MODEL plan model (default: Codex config default)
|
|
23
|
+
// CODEX_BRIDGE_PLAN_EFFORT plan reasoning effort (default: Codex config default)
|
|
24
|
+
// CODEX_BRIDGE_WRITE_ROOT enables codex_code: the ONE folder Codex may write in (unset = tool off)
|
|
25
|
+
// CODEX_BRIDGE_WRITE_ALLOW_TMP 1 = codex_code may also write to the system temp dirs. Default: asks Codex to
|
|
26
|
+
// block them. Tested: honoured on Linux/macOS builds only if Codex supports it;
|
|
27
|
+
// NOT enforced on Windows (temp stays writable there)
|
|
28
|
+
// CODEX_BRIDGE_CODE_MODEL / _EFFORT codex_code model / reasoning effort (default: Codex config default)
|
|
29
|
+
// CODEX_BRIDGE_IMAGE_MODEL image model (default gpt-6-luna)
|
|
30
|
+
// CODEX_BRIDGE_IMAGE_EFFORT image reasoning effort (default high)
|
|
31
|
+
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import readline from 'node:readline';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import {
|
|
39
|
+
BRIDGE_HOME, EventTail, JOBS_DIR, attemptDir, buildCodexArgs, collectImages, insideWriteRoot, isWin, jobDir, jobImages,
|
|
40
|
+
pidAlive, readJson, resolveCodexBin, resolveWriteRoot, writeJson,
|
|
41
|
+
} from './lib.mjs';
|
|
42
|
+
|
|
43
|
+
const PKG = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
44
|
+
const SERVER_INFO = { name: 'codex-bridge', version: PKG.version };
|
|
45
|
+
const RUNNER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'runner.mjs');
|
|
46
|
+
const IDLE_TIMEOUT_MS = Number(process.env.CODEX_BRIDGE_IDLE_TIMEOUT_MS) || 10 * 60 * 1000;
|
|
47
|
+
const DEFAULTS = {
|
|
48
|
+
codex_plan: { model: process.env.CODEX_BRIDGE_PLAN_MODEL || '', effort: process.env.CODEX_BRIDGE_PLAN_EFFORT || '' },
|
|
49
|
+
codex_image: { model: process.env.CODEX_BRIDGE_IMAGE_MODEL || 'gpt-6-luna', effort: process.env.CODEX_BRIDGE_IMAGE_EFFORT || 'high' },
|
|
50
|
+
codex_code: { model: process.env.CODEX_BRIDGE_CODE_MODEL || '', effort: process.env.CODEX_BRIDGE_CODE_EFFORT || '' },
|
|
51
|
+
};
|
|
52
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
|
|
53
|
+
const MAX_WAIT_S = 240;
|
|
54
|
+
const STALE_HEARTBEAT_MS = 10 * 60 * 1000;
|
|
55
|
+
const PRUNE_AFTER_MS = 14 * 24 * 3600 * 1000;
|
|
56
|
+
const CODEX_BIN = resolveCodexBin();
|
|
57
|
+
const WRITE_ALLOW_TMP = process.env.CODEX_BRIDGE_WRITE_ALLOW_TMP === '1';
|
|
58
|
+
let WRITE_ROOT = null; // set only when the user opts in to codex_code
|
|
59
|
+
try { WRITE_ROOT = resolveWriteRoot(); } catch (err) { process.stderr.write(`codex-bridge: codex_code stays off: ${err.message}\n`); }
|
|
60
|
+
|
|
61
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
62
|
+
console.log(PKG.version);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
// Where the bundled codex-jobs pane add-on lives, for `claude --plugin-dir "$(npx ... --pane-path)"`.
|
|
66
|
+
if (process.argv.includes('--pane-path')) {
|
|
67
|
+
console.log(path.join(path.dirname(fileURLToPath(import.meta.url)), 'plugins', 'codex-jobs'));
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
if (process.argv.includes('--help') || process.argv.includes('-h') || process.stdin.isTTY) {
|
|
71
|
+
console.log(`codex-bridge ${PKG.version}: an MCP server (stdio) that lets Claude Code consult the Codex CLI.
|
|
72
|
+
It is started by an MCP client, not run by hand. Register it with Claude Code:
|
|
73
|
+
claude mcp add codex-bridge -- npx -y @xallindrome-labs/codex-bridge
|
|
74
|
+
Flags: --version, --help, --pane-path (print the folder of the bundled codex-jobs pane add-on)
|
|
75
|
+
Docs: ${PKG.homepage}`);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fs.mkdirSync(JOBS_DIR, { recursive: true });
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- prompts
|
|
82
|
+
|
|
83
|
+
const PLAN_PREAMBLE = `You are the PLANNER in a two-agent workflow. Claude Code is the implementer and will act on your output.
|
|
84
|
+
Rules:
|
|
85
|
+
- Do not modify, create or delete any files. You are in a read-only sandbox; read and inspect the project as much as you need.
|
|
86
|
+
- Ground every step in what is actually in the codebase (cite file paths, functions, existing patterns).
|
|
87
|
+
- Output a concrete, ordered plan: goal and acceptance criteria, numbered implementation steps with the files each touches, risks and open questions, and how to verify (tests/commands).
|
|
88
|
+
- Be concise; no preamble.
|
|
89
|
+
|
|
90
|
+
Request from Claude Code:
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const CODE_PREAMBLE = cwd => `You are the BUILDER in a two-agent workflow. Claude Code is the reviewer and will check your work.
|
|
94
|
+
Rules:
|
|
95
|
+
- Work only inside ${cwd}. Your sandbox allows writes there and nowhere else; do not try to work around it.
|
|
96
|
+
- Make the change that was asked for and nothing else: no unrelated refactors, renames or reformatting.
|
|
97
|
+
- Do not commit, push, create branches or change git config. Leave your changes uncommitted in the working tree so Claude can review the diff.
|
|
98
|
+
- Do not install global packages. The sandbox has no network access: if the task needs a dependency or the network, stop and say so instead of guessing.
|
|
99
|
+
- Run the project's tests or linters if you can and report the exact result. If something cannot run in the sandbox, say what and why.
|
|
100
|
+
- When finished, reply with: a short summary, the files you created, changed or deleted, and how you verified.
|
|
101
|
+
|
|
102
|
+
Task from Claude Code:
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
// Codex only generates; the runner collects each image from Codex's image store
|
|
106
|
+
// the moment it appears. (Left to save files itself, Codex may re-type the image
|
|
107
|
+
// as base64 through shell commands: slow, and can corrupt the file.)
|
|
108
|
+
const IMAGE_RULES = `- Use your built-in image generation tool for every image (one tool call per image). Don't draw images with code.
|
|
109
|
+
- Do NOT save, copy, convert or write any files: every image you generate is collected automatically. You are in a read-only sandbox.
|
|
110
|
+
- In this headless mode the image tool may return without showing you the image. That is expected: the image was still generated and collected. Never retry because of a missing preview.
|
|
111
|
+
- Don't generate extra drafts (every generated image is kept).`;
|
|
112
|
+
|
|
113
|
+
const IMAGE_PREAMBLE = count => `You are the IMAGE BUILDER in a two-agent workflow. Claude Code requested the image(s) below.
|
|
114
|
+
Rules:
|
|
115
|
+
${IMAGE_RULES}${count ? `\n- Generate exactly ${count} images.` : ''}
|
|
116
|
+
- When finished, reply with a one-line description of each image, in generation order.
|
|
117
|
+
|
|
118
|
+
Request:
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
function initialPrompt(tool, args) {
|
|
122
|
+
if (tool === 'codex_plan' || tool === 'codex_code') {
|
|
123
|
+
const preamble = tool === 'codex_code' ? CODE_PREAMBLE(args.cwd) : PLAN_PREAMBLE;
|
|
124
|
+
return (args.session_id ? '' : preamble) + args.request + (args.context ? `\n\nContext:\n${args.context}` : '');
|
|
125
|
+
}
|
|
126
|
+
return args.session_id ? `${args.prompt}\n\nRules:\n${IMAGE_RULES}` : IMAGE_PREAMBLE(args.count) + args.prompt;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resumePrompt(job) {
|
|
130
|
+
if (job.tool === 'codex_plan') {
|
|
131
|
+
return 'Your previous run on this request was interrupted before you finished. Continue from where you stopped and give your complete final answer.';
|
|
132
|
+
}
|
|
133
|
+
if (job.tool === 'codex_code') {
|
|
134
|
+
return 'Your previous run on this task was interrupted before you finished. Some of your edits may already be in the working tree: inspect the current state first, do not redo finished work, then finish the task and give your final summary (files changed, how you verified).';
|
|
135
|
+
}
|
|
136
|
+
const done = jobImages(job).length;
|
|
137
|
+
const remaining = job.args.count ? Math.max(job.args.count - done, 0) : null;
|
|
138
|
+
return `Your previous run on this request was interrupted before it finished. ${done} image(s) were already generated and collected; do NOT regenerate them.
|
|
139
|
+
${remaining !== null ? `Generate only the remaining ${remaining} of the ${job.args.count} requested images.` : 'Continue the original request and generate only the images that are still missing.'}
|
|
140
|
+
Rules:
|
|
141
|
+
${IMAGE_RULES}
|
|
142
|
+
- When finished, reply with a one-line description of each new image.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------- jobs
|
|
146
|
+
|
|
147
|
+
const WRITE_OFF = 'codex_code is switched off. To let Codex write files, the user must set CODEX_BRIDGE_WRITE_ROOT to the one folder it may edit, then restart the MCP server.';
|
|
148
|
+
const newJobId = () => `job-${new Date().toISOString().replace(/[-:T]/g, '').slice(2, 14)}-${crypto.randomBytes(2).toString('hex')}`;
|
|
149
|
+
const loadJob = id => readJson(path.join(jobDir(id), 'job.json'));
|
|
150
|
+
const saveJob = job => writeJson(path.join(jobDir(job.id), 'job.json'), job);
|
|
151
|
+
|
|
152
|
+
// Launches runner.mjs outside Claude Code's process tree / job object, so
|
|
153
|
+
// closing Claude doesn't kill it. On Windows that means WMI Win32_Process.Create.
|
|
154
|
+
function launchRunner(jobId, n, cwd) {
|
|
155
|
+
if (!isWin) {
|
|
156
|
+
const c = spawn(process.execPath, [RUNNER, jobId, String(n), BRIDGE_HOME], { cwd, detached: true, stdio: 'ignore' });
|
|
157
|
+
c.unref();
|
|
158
|
+
return Promise.resolve(c.pid);
|
|
159
|
+
}
|
|
160
|
+
const q = s => `'${String(s).replace(/'/g, "''")}'`;
|
|
161
|
+
const cmdLine = `"${process.execPath}" "${RUNNER}" ${jobId} ${n} "${BRIDGE_HOME}"`;
|
|
162
|
+
const ps = `$ErrorActionPreference = 'Stop'
|
|
163
|
+
$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }
|
|
164
|
+
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${q(cmdLine)}; CurrentDirectory = ${q(cwd)}; ProcessStartupInformation = $si }
|
|
165
|
+
"$($r.ReturnValue) $($r.ProcessId)"`;
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const p = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(ps, 'utf16le').toString('base64')], { windowsHide: true });
|
|
168
|
+
let out = '';
|
|
169
|
+
let err = '';
|
|
170
|
+
p.stdout.on('data', d => { out += d; });
|
|
171
|
+
p.stderr.on('data', d => { err += d; });
|
|
172
|
+
p.on('error', reject);
|
|
173
|
+
p.on('close', () => {
|
|
174
|
+
const [rv, pid] = out.trim().split(/\s+/).map(Number);
|
|
175
|
+
if (rv === 0 && pid) resolve(pid);
|
|
176
|
+
else reject(new Error(`Could not launch job runner (WMI ReturnValue ${rv}). ${err.trim()}`));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function launchAttempt(job, prompt, sessionId) {
|
|
182
|
+
const n = job.attempts + 1;
|
|
183
|
+
const dir = attemptDir(job.id, n);
|
|
184
|
+
fs.mkdirSync(dir); // throws EEXIST if another session launched this attempt first
|
|
185
|
+
const cwd = job.cwd ?? job.outDir;
|
|
186
|
+
const writes = job.tool === 'codex_code';
|
|
187
|
+
if (writes) { // re-checked on every attempt: a resume must not outlive the user's opt-in
|
|
188
|
+
if (!WRITE_ROOT) throw new Error(WRITE_OFF);
|
|
189
|
+
insideWriteRoot(WRITE_ROOT, cwd);
|
|
190
|
+
}
|
|
191
|
+
fs.writeFileSync(path.join(dir, 'prompt.txt'), prompt);
|
|
192
|
+
writeJson(path.join(dir, 'spec.json'), {
|
|
193
|
+
bin: CODEX_BIN,
|
|
194
|
+
cwd,
|
|
195
|
+
args: buildCodexArgs({
|
|
196
|
+
sandbox: writes ? 'workspace-write' : 'read-only',
|
|
197
|
+
cwd, sessionId, model: job.model, effort: job.effort,
|
|
198
|
+
images: n === 1 ? job.refs : [],
|
|
199
|
+
allowTmp: WRITE_ALLOW_TMP,
|
|
200
|
+
}),
|
|
201
|
+
env: process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {},
|
|
202
|
+
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
|
203
|
+
});
|
|
204
|
+
job.attempts = n;
|
|
205
|
+
job.attemptStarted = Date.now();
|
|
206
|
+
saveJob(job);
|
|
207
|
+
await launchRunner(job.id, n, cwd);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function startJob(tool, args) {
|
|
211
|
+
if (!CODEX_BIN) throw new Error('Codex CLI not found. Set CODEX_BIN to the path of codex.exe.');
|
|
212
|
+
const d = DEFAULTS[tool];
|
|
213
|
+
const job = {
|
|
214
|
+
id: newJobId(),
|
|
215
|
+
tool,
|
|
216
|
+
args,
|
|
217
|
+
model: args.model || d.model,
|
|
218
|
+
effort: args.effort || d.effort,
|
|
219
|
+
created: Date.now(),
|
|
220
|
+
attempts: 0,
|
|
221
|
+
};
|
|
222
|
+
if (tool === 'codex_plan' || tool === 'codex_code') {
|
|
223
|
+
job.cwd = path.resolve(args.cwd);
|
|
224
|
+
if (!fs.existsSync(job.cwd)) throw new Error(`cwd does not exist: ${job.cwd}`);
|
|
225
|
+
if (tool === 'codex_code') {
|
|
226
|
+
if (!WRITE_ROOT) throw new Error(WRITE_OFF);
|
|
227
|
+
job.cwd = insideWriteRoot(WRITE_ROOT, job.cwd);
|
|
228
|
+
args.cwd = job.cwd;
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
job.outDir = path.resolve(args.output_dir);
|
|
232
|
+
fs.mkdirSync(job.outDir, { recursive: true });
|
|
233
|
+
job.refs = (args.reference_images ?? []).map(p => path.resolve(p));
|
|
234
|
+
const missing = job.refs.filter(p => !fs.existsSync(p));
|
|
235
|
+
if (missing.length) throw new Error(`Reference image(s) not found: ${missing.join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
fs.mkdirSync(jobDir(job.id), { recursive: true });
|
|
238
|
+
await launchAttempt(job, initialPrompt(tool, args), args.session_id);
|
|
239
|
+
return job;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Status of a job's latest attempt, derived only from files on disk.
|
|
243
|
+
function jobStatus(job) {
|
|
244
|
+
const dir = attemptDir(job.id, job.attempts);
|
|
245
|
+
const result = readJson(path.join(dir, 'result.json'));
|
|
246
|
+
if (result) return { status: result.status, result };
|
|
247
|
+
const state = readJson(path.join(dir, 'state.json'));
|
|
248
|
+
let beatAge = Infinity;
|
|
249
|
+
try { beatAge = Date.now() - fs.statSync(path.join(dir, 'heartbeat')).mtimeMs; } catch {}
|
|
250
|
+
if (!state) return { status: Date.now() - job.attemptStarted < 60_000 ? 'running' : 'interrupted' };
|
|
251
|
+
if (pidAlive(state.runnerPid) && beatAge < STALE_HEARTBEAT_MS) return { status: 'running' };
|
|
252
|
+
return { status: 'interrupted' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Latest Codex thread id across attempts (resume keeps the same thread).
|
|
256
|
+
function jobThreadId(job) {
|
|
257
|
+
for (let n = job.attempts; n >= 1; n--) {
|
|
258
|
+
const t = new EventTail(path.join(attemptDir(job.id, n), 'events.jsonl')).poll().threadId;
|
|
259
|
+
if (t) return t;
|
|
260
|
+
}
|
|
261
|
+
return job.args.session_id || null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const secs = ms => (ms < 120_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`);
|
|
265
|
+
|
|
266
|
+
function imageCount(job) {
|
|
267
|
+
const k = jobImages(job).length;
|
|
268
|
+
return `images ${k}${job.args.count ? `/${job.args.count}` : ''}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function jobLine(job) {
|
|
272
|
+
const { status, result } = jobStatus(job);
|
|
273
|
+
const age = secs((result?.finished ?? Date.now()) - job.created);
|
|
274
|
+
const where = job.cwd ?? job.outDir;
|
|
275
|
+
const label = (job.args.request || job.args.prompt || '').replace(/\s+/g, ' ').slice(0, 70);
|
|
276
|
+
const extra = [job.tool === 'codex_image' ? imageCount(job) : null, job.attempts > 1 ? `attempt ${job.attempts}` : null].filter(Boolean).join(', ');
|
|
277
|
+
return `${job.id} [${job.tool}] ${status}, ${age}${extra ? `, ${extra}` : ''} - ${label} (${where})`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderResult(job, result) {
|
|
281
|
+
const lines = [];
|
|
282
|
+
if (job.tool === 'codex_image') {
|
|
283
|
+
const imgs = result.images ?? jobImages(job);
|
|
284
|
+
lines.push(imgs.length ? `Saved image(s)${job.args.count ? ` (${imgs.length}/${job.args.count})` : ''}:\n${imgs.map(p => `- ${p}`).join('\n')}` : 'No image files were found in output_dir.');
|
|
285
|
+
lines.push('', 'Codex said:');
|
|
286
|
+
}
|
|
287
|
+
if (result.status === 'cancelled') lines.push('Codex job was cancelled.');
|
|
288
|
+
else if (result.status === 'timed_out') lines.push(`Codex produced no output for ${secs(result.idleTimeoutMs)} and was stopped. Use codex_resume to continue.`);
|
|
289
|
+
else if (result.status === 'failed') {
|
|
290
|
+
lines.push(`Codex failed${result.failed ? `: ${result.failed}` : result.spawnError ? `: ${result.spawnError}` : ` (exit code ${result.exitCode})`}. Use codex_resume to retry from where it stopped.`);
|
|
291
|
+
if (result.stderrTail) lines.push(result.stderrTail);
|
|
292
|
+
}
|
|
293
|
+
if (result.message) lines.push(result.message);
|
|
294
|
+
if (job.tool === 'codex_code') {
|
|
295
|
+
lines.push('', result.status === 'done'
|
|
296
|
+
? `Codex edited files in ${job.cwd} and committed nothing. Review the diff (git diff / git status) before accepting it.`
|
|
297
|
+
: `Codex may have made partial edits in ${job.cwd}. Check git status / git diff before continuing.`);
|
|
298
|
+
}
|
|
299
|
+
lines.push('', '---',
|
|
300
|
+
`job_id: ${job.id}${job.attempts > 1 ? ` (attempt ${job.attempts})` : ''}`,
|
|
301
|
+
`session_id: ${result.threadId ?? 'unknown'} (pass as session_id to continue this Codex thread)`,
|
|
302
|
+
`model: ${job.model || 'codex default'}, effort: ${job.effort || 'codex default'}`);
|
|
303
|
+
if (result.errors?.length) lines.push(`codex warnings: ${result.errors.join(' | ')}`);
|
|
304
|
+
const isError = result.status !== 'done' || (job.tool === 'codex_image' && !(result.images ?? []).length);
|
|
305
|
+
return { isError, text: lines.join('\n') };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function interruptedText(job) {
|
|
309
|
+
const progress = job.tool === 'codex_image' ? ` (${imageCount(job)} saved so far)` : '';
|
|
310
|
+
return `${job.id} was interrupted before finishing${progress}: its Codex process is gone (reboot, crash or kill). ` +
|
|
311
|
+
'Call codex_resume to continue it from where it stopped.';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Waits for a job to finish, relaying Codex activity as progress. Returns null on timeout.
|
|
315
|
+
async function waitForJob(job, waitMs, onActivity) {
|
|
316
|
+
const deadline = Date.now() + waitMs;
|
|
317
|
+
let tail = null;
|
|
318
|
+
for (;;) {
|
|
319
|
+
job = loadJob(job.id) ?? job; // another session may have resumed it
|
|
320
|
+
const events = path.join(attemptDir(job.id, job.attempts), 'events.jsonl');
|
|
321
|
+
if (tail?.file !== events) tail = new EventTail(events);
|
|
322
|
+
tail.poll(ev => onActivity?.(tail.state.activity, ev));
|
|
323
|
+
const s = jobStatus(job);
|
|
324
|
+
if (s.status !== 'running') return { ...s, job };
|
|
325
|
+
if (Date.now() >= deadline) return null;
|
|
326
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function describe(s) {
|
|
331
|
+
if (s.status === 'interrupted') return { isError: true, text: interruptedText(s.job) };
|
|
332
|
+
return renderResult(s.job, s.result);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function listJobs() {
|
|
336
|
+
if (!fs.existsSync(JOBS_DIR)) return [];
|
|
337
|
+
return fs.readdirSync(JOBS_DIR).map(loadJob).filter(Boolean).sort((a, b) => b.created - a.created);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function pruneOldJobs() {
|
|
341
|
+
for (const job of listJobs()) {
|
|
342
|
+
const { status, result } = jobStatus(job);
|
|
343
|
+
if (status !== 'running' && Date.now() - (result?.finished ?? job.created) > PRUNE_AFTER_MS) {
|
|
344
|
+
fs.rmSync(jobDir(job.id), { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------- tools
|
|
350
|
+
|
|
351
|
+
const BACKGROUND_PROP = {
|
|
352
|
+
type: 'boolean',
|
|
353
|
+
description: 'Optional. true = return a job_id immediately and keep working; collect the result later with codex_result. Default false (wait for the answer).',
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const TOOLS = [
|
|
357
|
+
{
|
|
358
|
+
name: 'codex_plan',
|
|
359
|
+
description:
|
|
360
|
+
'Ask Codex (OpenAI) to act as the planner: produce an implementation plan, review a design, or give a second opinion. ' +
|
|
361
|
+
'Codex runs in a READ-ONLY sandbox in `cwd` and can inspect the project but not change it. Claude remains the implementer. ' +
|
|
362
|
+
'Calls can take several minutes; use background=true to keep working meanwhile. Including relevant code/diffs in `context` makes it faster. ' +
|
|
363
|
+
'Returns Codex\'s answer, a job_id and a session_id; pass session_id back to ask follow-ups in the same Codex thread.',
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
request: { type: 'string', description: 'What to plan or review. Include goals, constraints and acceptance criteria.' },
|
|
368
|
+
cwd: { type: 'string', description: 'Absolute path of the project directory Codex should inspect.' },
|
|
369
|
+
context: { type: 'string', description: 'Optional extra context: relevant diffs, error output, decisions already made.' },
|
|
370
|
+
session_id: { type: 'string', description: 'Optional. Continue an earlier Codex thread instead of starting fresh.' },
|
|
371
|
+
effort: { type: 'string', enum: EFFORTS, description: 'Optional reasoning effort. Default: the user\'s Codex config (high). Use medium/low for quick questions; xhigh/max for hard architecture problems.' },
|
|
372
|
+
model: { type: 'string', description: 'Optional Codex model override. Default: the user\'s Codex config.' },
|
|
373
|
+
background: BACKGROUND_PROP,
|
|
374
|
+
},
|
|
375
|
+
required: ['request', 'cwd'],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: 'codex_image',
|
|
380
|
+
description:
|
|
381
|
+
'Ask Codex to generate (or edit) raster images with its image generation tool. Images are saved into `output_dir` as they are made, and their paths returned. ' +
|
|
382
|
+
'Pass reference_images to edit or riff on existing images, and session_id to iterate on a previous result. Set `count` for batches so progress and resumes are tracked. ' +
|
|
383
|
+
'Runs on gpt-6-luna at high effort by default; leave model/effort unset unless the user asks. Calls take ~1 min per image; use background=true for batches.',
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: 'object',
|
|
386
|
+
properties: {
|
|
387
|
+
prompt: { type: 'string', description: 'Description of the image(s): subject, style, size/aspect ratio, background (e.g. transparent), variations.' },
|
|
388
|
+
output_dir: { type: 'string', description: 'Absolute directory where the images should be saved (created if missing).' },
|
|
389
|
+
count: { type: 'integer', minimum: 1, description: 'Optional number of images wanted. Enables progress (k/count) and precise resumes.' },
|
|
390
|
+
filename: { type: 'string', description: 'Optional base filename, e.g. "hero-banner.png".' },
|
|
391
|
+
reference_images: { type: 'array', items: { type: 'string' }, description: 'Optional absolute paths of images to attach as references or to edit.' },
|
|
392
|
+
session_id: { type: 'string', description: 'Optional. Continue an earlier Codex thread (e.g. "make it darker").' },
|
|
393
|
+
effort: { type: 'string', enum: EFFORTS, description: 'Optional. Default high; rarely needs changing.' },
|
|
394
|
+
model: { type: 'string', description: 'Optional. Default gpt-6-luna; rarely needs changing.' },
|
|
395
|
+
background: BACKGROUND_PROP,
|
|
396
|
+
},
|
|
397
|
+
required: ['prompt', 'output_dir'],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'codex_result',
|
|
402
|
+
description:
|
|
403
|
+
'Get the status or result of a Codex job (jobs persist across Claude sessions and restarts). With job_id: the result if finished, otherwise progress. ' +
|
|
404
|
+
`wait_seconds (max ${MAX_WAIT_S}) blocks until the job finishes or the wait expires. Without job_id: lists recent jobs.`,
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {
|
|
408
|
+
job_id: { type: 'string', description: 'Job id returned by codex_plan/codex_image.' },
|
|
409
|
+
wait_seconds: { type: 'number', description: `Optional. Seconds to wait for completion (0-${MAX_WAIT_S}). Default 0.` },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: 'codex_resume',
|
|
415
|
+
description:
|
|
416
|
+
'Continue a job that was interrupted (reboot/crash), timed out or failed, from where it stopped: it continues the same Codex thread, ' +
|
|
417
|
+
'and for image jobs first rescues images Codex already generated, then asks only for the missing ones. Returns immediately; collect with codex_result.',
|
|
418
|
+
inputSchema: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: { job_id: { type: 'string', description: 'Job id to resume.' } },
|
|
421
|
+
required: ['job_id'],
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'codex_cancel',
|
|
426
|
+
description: 'Stop a running Codex job.',
|
|
427
|
+
inputSchema: {
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: { job_id: { type: 'string', description: 'Job id to cancel.' } },
|
|
430
|
+
required: ['job_id'],
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
// Only listed when the user opted in with CODEX_BRIDGE_WRITE_ROOT.
|
|
436
|
+
if (WRITE_ROOT) {
|
|
437
|
+
TOOLS.splice(1, 0, {
|
|
438
|
+
name: 'codex_code',
|
|
439
|
+
description:
|
|
440
|
+
'Ask Codex (OpenAI) to IMPLEMENT a change: it edits files directly, inside ' + WRITE_ROOT + ' (the one folder the user allowed; temp files may also be writable). ' +
|
|
441
|
+
'It has no network access and does not commit. `cwd` must be that folder or one inside it. Claude stays the reviewer: afterwards read the diff (git diff) ' +
|
|
442
|
+
'and test before accepting, and fix or send follow-ups with session_id. Use it for well-specified, self-contained work; use codex_plan when you only need a plan. ' +
|
|
443
|
+
'Calls can take many minutes; use background=true to keep working meanwhile, and avoid editing the same files yourself while it runs.',
|
|
444
|
+
inputSchema: {
|
|
445
|
+
type: 'object',
|
|
446
|
+
properties: {
|
|
447
|
+
request: { type: 'string', description: 'What to implement. Include the goal, constraints, files involved and acceptance criteria / how to verify.' },
|
|
448
|
+
cwd: { type: 'string', description: `Absolute path of the project folder Codex edits. Must be ${WRITE_ROOT} or inside it.` },
|
|
449
|
+
context: { type: 'string', description: 'Optional extra context: a plan to follow, error output, decisions already made.' },
|
|
450
|
+
session_id: { type: 'string', description: 'Optional. Continue an earlier Codex thread (e.g. to fix review comments).' },
|
|
451
|
+
effort: { type: 'string', enum: EFFORTS, description: 'Optional reasoning effort. Default: the user\'s Codex config.' },
|
|
452
|
+
model: { type: 'string', description: 'Optional Codex model override. Default: the user\'s Codex config.' },
|
|
453
|
+
background: BACKGROUND_PROP,
|
|
454
|
+
},
|
|
455
|
+
required: ['request', 'cwd'],
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function callJobTool(name, args, onActivity) {
|
|
461
|
+
if (name === 'codex_code' && !WRITE_ROOT) throw new Error(WRITE_OFF);
|
|
462
|
+
if ((name === 'codex_plan' || name === 'codex_code') && (!args.request || !args.cwd)) throw new Error('request and cwd are required');
|
|
463
|
+
if (name === 'codex_image' && (!args.prompt || !args.output_dir)) throw new Error('prompt and output_dir are required');
|
|
464
|
+
if (args.effort && !EFFORTS.includes(args.effort)) throw new Error(`effort must be one of ${EFFORTS.join(', ')}`);
|
|
465
|
+
const job = await startJob(name, args);
|
|
466
|
+
if (args.background) {
|
|
467
|
+
return { text: `Started ${job.id}. It keeps running even if Claude closes. Collect it with codex_result (job_id "${job.id}", optionally wait_seconds).` };
|
|
468
|
+
}
|
|
469
|
+
const s = await waitForJob(job, Infinity, onActivity);
|
|
470
|
+
return describe(s);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function jobResult(args) {
|
|
474
|
+
if (!args.job_id) {
|
|
475
|
+
const jobs = listJobs().slice(0, 25);
|
|
476
|
+
return { text: jobs.length ? jobs.map(jobLine).join('\n') : 'No Codex jobs yet.' };
|
|
477
|
+
}
|
|
478
|
+
const job = loadJob(args.job_id);
|
|
479
|
+
if (!job) return { isError: true, text: `Unknown job_id: ${args.job_id}` };
|
|
480
|
+
const wait = Math.min(Math.max(Number(args.wait_seconds) || 0, 0), MAX_WAIT_S);
|
|
481
|
+
const s = await waitForJob(job, wait * 1000);
|
|
482
|
+
if (!s) {
|
|
483
|
+
const activity = new EventTail(path.join(attemptDir(job.id, job.attempts), 'events.jsonl')).poll().activity;
|
|
484
|
+
return { text: `${jobLine(job)}\nlast activity: ${activity}\nStill running. Call codex_result again later (optionally with wait_seconds).` };
|
|
485
|
+
}
|
|
486
|
+
return describe(s);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function resumeJob(args) {
|
|
490
|
+
const job = loadJob(args.job_id);
|
|
491
|
+
if (!job) return { isError: true, text: `Unknown job_id: ${args.job_id}` };
|
|
492
|
+
const { status } = jobStatus(job);
|
|
493
|
+
if (status === 'running') return { isError: true, text: `${job.id} is still running; nothing to resume.` };
|
|
494
|
+
if (status === 'done') return { isError: true, text: `${job.id} already finished. Use codex_result to read it, or session_id for a follow-up.` };
|
|
495
|
+
fs.rmSync(path.join(jobDir(job.id), 'cancel'), { force: true });
|
|
496
|
+
|
|
497
|
+
const threadId = jobThreadId(job);
|
|
498
|
+
let rescued = [];
|
|
499
|
+
if (job.tool === 'codex_image') rescued = collectImages(job, threadId, { settled: false });
|
|
500
|
+
const prompt = threadId ? resumePrompt(job) : `${initialPrompt(job.tool, job.args)}\n\n${resumePrompt(job)}`;
|
|
501
|
+
await launchAttempt(job, prompt, threadId);
|
|
502
|
+
return {
|
|
503
|
+
text: `Resumed ${job.id} as attempt ${job.attempts}${threadId ? ` in Codex thread ${threadId}` : ' (no Codex thread had started, so it starts fresh)'}.` +
|
|
504
|
+
(rescued.length ? `\nCollected ${rescued.length} image(s) Codex had generated just before the interruption.` : '') +
|
|
505
|
+
(job.tool === 'codex_image' ? `\nProgress: ${imageCount(job)}.` : '') +
|
|
506
|
+
`\nCollect it with codex_result (job_id "${job.id}").`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function cancelJob(args) {
|
|
511
|
+
const job = loadJob(args.job_id);
|
|
512
|
+
if (!job) return { isError: true, text: `Unknown job_id: ${args.job_id}` };
|
|
513
|
+
const { status } = jobStatus(job);
|
|
514
|
+
if (status !== 'running') return { text: `${job.id} is not running (${status}).` };
|
|
515
|
+
fs.writeFileSync(path.join(jobDir(job.id), 'cancel'), String(Date.now()));
|
|
516
|
+
return { text: `Cancelling ${job.id} (takes a couple of seconds).` };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------- MCP stdio
|
|
520
|
+
|
|
521
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
|
522
|
+
function reply(id, result) { send({ jsonrpc: '2.0', id, result }); }
|
|
523
|
+
function replyError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
|
|
524
|
+
|
|
525
|
+
function instructions() {
|
|
526
|
+
let text = 'Use codex_plan to get an implementation plan or second opinion from Codex (read-only), and codex_image to have Codex generate images. ' +
|
|
527
|
+
'Claude implements; Codex plans and builds images. Codex calls take minutes: when there is other work to do meanwhile, pass background=true and collect the result with codex_result. ' +
|
|
528
|
+
'Jobs are durable: they survive Claude closing, and codex_resume continues interrupted ones.';
|
|
529
|
+
if (WRITE_ROOT) {
|
|
530
|
+
text += ` The user also enabled codex_code: Codex may implement changes by editing files, but only inside ${WRITE_ROOT}. Review its diff before accepting it.`;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const interrupted = listJobs().filter(j => jobStatus(j).status === 'interrupted').slice(0, 10);
|
|
534
|
+
if (interrupted.length) {
|
|
535
|
+
text += `\n\nInterrupted Codex jobs from earlier sessions (tell the user; resume with codex_resume if they want them):\n${interrupted.map(jobLine).join('\n')}`;
|
|
536
|
+
}
|
|
537
|
+
} catch {}
|
|
538
|
+
return text;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function handle(msg) {
|
|
542
|
+
const { id, method, params } = msg;
|
|
543
|
+
if (id === undefined) return; // notification
|
|
544
|
+
|
|
545
|
+
switch (method) {
|
|
546
|
+
case 'initialize':
|
|
547
|
+
return reply(id, {
|
|
548
|
+
protocolVersion: params?.protocolVersion ?? '2025-06-18',
|
|
549
|
+
capabilities: { tools: {} },
|
|
550
|
+
serverInfo: SERVER_INFO,
|
|
551
|
+
instructions: instructions(),
|
|
552
|
+
});
|
|
553
|
+
case 'ping':
|
|
554
|
+
return reply(id, {});
|
|
555
|
+
case 'tools/list':
|
|
556
|
+
return reply(id, { tools: TOOLS });
|
|
557
|
+
case 'tools/call': {
|
|
558
|
+
const name = params?.name;
|
|
559
|
+
const args = params?.arguments ?? {};
|
|
560
|
+
const token = params?._meta?.progressToken;
|
|
561
|
+
let progress = 0;
|
|
562
|
+
const onActivity = token === undefined ? undefined : (message, ev) => {
|
|
563
|
+
if (ev.type === 'item.started' || ev.type === 'item.completed') {
|
|
564
|
+
send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: ++progress, message: `codex: ${message}` } });
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
try {
|
|
568
|
+
let out;
|
|
569
|
+
if (name === 'codex_plan' || name === 'codex_image' || name === 'codex_code') out = await callJobTool(name, args, onActivity);
|
|
570
|
+
else if (name === 'codex_result') out = await jobResult(args);
|
|
571
|
+
else if (name === 'codex_resume') out = await resumeJob(args);
|
|
572
|
+
else if (name === 'codex_cancel') out = cancelJob(args);
|
|
573
|
+
else return replyError(id, -32602, `Unknown tool: ${name}`);
|
|
574
|
+
return reply(id, { content: [{ type: 'text', text: out.text }], isError: !!out.isError });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
return reply(id, { content: [{ type: 'text', text: `codex-bridge error: ${err.message}` }], isError: true });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
default:
|
|
580
|
+
return replyError(id, -32601, `Method not found: ${method}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
585
|
+
if (!line.trim()) return;
|
|
586
|
+
let msg;
|
|
587
|
+
try { msg = JSON.parse(line); } catch { return replyError(null, -32700, 'Parse error'); }
|
|
588
|
+
handle(msg).catch(err => process.stderr.write(`codex-bridge: ${err.stack}\n`));
|
|
589
|
+
});
|
|
590
|
+
// Jobs run in their own processes, so the server can simply exit with stdin.
|
|
591
|
+
process.stdin.on('end', () => process.exit(0));
|
|
592
|
+
|
|
593
|
+
try { pruneOldJobs(); } catch {}
|
|
594
|
+
process.stderr.write(`codex-bridge ready (codex: ${CODEX_BIN ?? 'NOT FOUND'}, jobs: ${JOBS_DIR})\n`);
|