@xallindrome-labs/codex-bridge 0.1.0 → 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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "codex-bridge",
3
+ "owner": { "name": "Xallindrome", "url": "https://xallindrome.co" },
4
+ "metadata": { "description": "Claude Code add-ons for codex-bridge." },
5
+ "plugins": [
6
+ {
7
+ "name": "codex-jobs",
8
+ "source": "./plugins/codex-jobs",
9
+ "description": "A pane beside the chat that lists your codex-bridge jobs live: running, done, interrupted."
10
+ }
11
+ ]
12
+ }
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Codex jobs for Claude Code that survive crashes, closed windows and reboots, with Codex image generation built in.** A dependency-free MCP server built Windows-first: every call is a job saved on disk, so you can close Claude, lose power, or kill a process, and pick the work up again with `codex_resume`.
4
4
 
5
- Roles: **Claude Code implements. Codex plans and builds images.**
5
+ Roles: **Claude Code implements. Codex plans and builds images.** Optionally (opt-in, one folder), Codex can also write code while Claude reviews: see [Let Codex write code](#let-codex-write-code-opt-in). And an optional pane beside the chat lists your jobs live and shows your Codex usage and when it resets: see [Codex jobs pane](#codex-jobs-pane).
6
6
 
7
7
  ![Recorded session: Claude Code starts codex-bridge and asks Codex for a plan](docs/demo.svg)
8
8
 
@@ -26,11 +26,12 @@ Other MCP clients: run `npx -y @xallindrome-labs/codex-bridge` as a stdio server
26
26
  |---|---|---|---|
27
27
  | `codex_plan` | Codex writes implementation plans, design reviews and second opinions. It inspects the project in `cwd`. | `read-only` (always) | Your Codex config defaults |
28
28
  | `codex_image` | Codex generates or edits images. The bridge copies each one into `output_dir` as soon as it's made. | `read-only` (always) | **gpt-6-luna / high** |
29
+ | `codex_code` | **Off by default.** Codex implements a change by editing files in one folder you choose. See [below](#let-codex-write-code-opt-in). | `workspace-write`, that folder only, no network | Your Codex config defaults |
29
30
  | `codex_result` | Gets a job's result or progress (`wait_seconds` up to 240), or lists recent jobs. | | |
30
31
  | `codex_resume` | Continues an interrupted, timed-out or failed job from where it stopped. | | |
31
32
  | `codex_cancel` | Stops a running job. | | |
32
33
 
33
- `codex_plan` and `codex_image` accept:
34
+ `codex_plan`, `codex_image` and `codex_code` accept:
34
35
  - `effort`: `low`, `medium`, `high`, `xhigh`, `max` or `ultra` (Luna supports up to `max`).
35
36
  - `model`: overrides the default model for that call.
36
37
  - `background: true`: returns a `job_id` immediately so Claude can keep working.
@@ -38,6 +39,39 @@ Other MCP clients: run `npx -y @xallindrome-labs/codex-bridge` as a stdio server
38
39
 
39
40
  `codex_image` also takes `count`, the number of images wanted. Set it for batches: it enables `k/count` progress and resumes that ask for exactly the missing images.
40
41
 
42
+ ## Let Codex write code (opt-in)
43
+
44
+ By default Codex can only read. If you want it to implement things while Claude reviews, name the one folder it may edit:
45
+
46
+ ```bash
47
+ claude mcp add codex-bridge -e CODEX_BRIDGE_WRITE_ROOT=/path/to/your/project -- npx -y @xallindrome-labs/codex-bridge
48
+ ```
49
+
50
+ Restart Claude Code and a fourth tool, `codex_code`, appears. Ask for it in plain words: *"Have Codex implement the plan in the background, then review its diff."*
51
+
52
+ - **One folder.** `cwd` must be `CODEX_BRIDGE_WRITE_ROOT` or inside it. Anything else is refused, checked on real paths so `..` and symlinks don't escape. A drive root or your home folder is refused as too broad.
53
+ - **No network, no commits.** Codex runs with network access off and is told to leave changes uncommitted so you can see them in `git diff`. The no-commit rule is a prompt, not a sandbox rule.
54
+ - **Claude reviews.** Every `codex_code` result ends with a reminder to read the diff before accepting it. A failed or cancelled run says Codex may have left partial edits.
55
+ - **Same job machinery.** It survives restarts, `background: true` and `codex_resume` work the same way, and a resume re-checks the folder rule.
56
+ - **Temp files.** In our test on Windows 11, writes outside the folder and network access were blocked, but the system temp folder stayed writable. Treat temp as writable. `CODEX_BRIDGE_WRITE_ALLOW_TMP=1` stops asking Codex to block it.
57
+ - **Don't edit the same files yourself** while a `codex_code` job runs.
58
+
59
+ Unset the variable and restart, and the tool is gone; `codex_plan` and `codex_image` are always read-only.
60
+
61
+ ## Codex jobs pane
62
+
63
+ An optional add-on that opens a pane beside the Claude Code chat listing your Codex jobs live: running, done, interrupted, with age, request and `images k/n` for batches. It also shows your Codex usage: the 5-hour and weekly windows, percent used, when each resets, and how many images were generated in each (Codex has one shared meter, so images use the same limits). It only reads the job files on disk, so it shows jobs from any session, including ones that kept running after you closed Claude. It also puts `codex: N running` in the status line and shows a toast when a job you watched finishes.
64
+
65
+ At the prompt of a terminal Claude Code session:
66
+
67
+ ```
68
+ /plugin install codex-jobs --marketplace xallindrome-labs/codex-bridge
69
+ ```
70
+
71
+ In the desktop app, where `/plugin` does not run: click **+** next to the prompt box, then **Plugins**, **Add marketplace** with `xallindrome-labs/codex-bridge`, then **Add plugin**. Without a marketplace, for one session: `claude --plugin-dir "$(npx -y @xallindrome-labs/codex-bridge --pane-path)"`.
72
+
73
+ Then type `/codex-jobs`. It also opens by itself the first time a job is running in a session. Details and how it works are in [plugins/codex-jobs](plugins/codex-jobs/README.md). It needs a Claude Code build that supports mods; it is tested against the engine's own test kit, not every surface's paint.
74
+
41
75
  ## How it compares
42
76
 
43
77
  OpenAI's [codex-plugin-cc](https://github.com/openai/codex-plugin-cc) is the official way to use Codex from Claude Code, and it's good at what it does: slash commands for review and rescue. codex-bridge is an MCP server instead, with a different focus: **jobs that don't get lost**, and **images**.
@@ -81,7 +115,7 @@ Each attempt keeps its own `prompt.txt`, `events.jsonl` (the Codex event stream)
81
115
 
82
116
  Each attempt runs `codex exec --json` (or `codex exec resume <thread>`) with the prompt on stdin. No shell is involved.
83
117
 
84
- Every call runs read-only. New sessions pass `-s read-only` plus `-c sandbox_mode="read-only"`; resumed sessions pass `-c sandbox_mode="read-only"` (`codex exec resume` takes no `-s`). Your Codex config may default to `danger-full-access`, but that default never applies to bridge calls. The `plan` smoke test checks that a write attempted from a resumed session fails.
118
+ Every call runs read-only, except `codex_code` when you have opted in (it runs `workspace-write` in your one folder, with network off). New sessions pass `-s read-only` plus `-c sandbox_mode="read-only"`; resumed sessions pass `-c sandbox_mode="read-only"` (`codex exec resume` takes no `-s`). Your Codex config may default to `danger-full-access`, but that default never applies to bridge calls. The `plan` smoke test checks that a write attempted from a resumed session fails.
85
119
 
86
120
  | File | Role |
87
121
  |---|---|
@@ -110,6 +144,9 @@ Remove it with `claude mcp remove codex-bridge` (add `-s user` if you installed
110
144
  | `CODEX_BRIDGE_IDLE_TIMEOUT_MS` | `600000` (stops a run after 10 minutes with no Codex output) |
111
145
  | `CODEX_BRIDGE_PLAN_MODEL` / `CODEX_BRIDGE_PLAN_EFFORT` | Your Codex config defaults |
112
146
  | `CODEX_BRIDGE_IMAGE_MODEL` / `CODEX_BRIDGE_IMAGE_EFFORT` | `gpt-6-luna` / `high` |
147
+ | `CODEX_BRIDGE_WRITE_ROOT` | Unset: `codex_code` is off. Set to one folder to let `codex_code` edit files there. |
148
+ | `CODEX_BRIDGE_WRITE_ALLOW_TMP` | Unset. `1` stops asking Codex to block temp-folder writes (not enforced on Windows anyway). |
149
+ | `CODEX_BRIDGE_CODE_MODEL` / `CODEX_BRIDGE_CODE_EFFORT` | Your Codex config defaults |
113
150
 
114
151
  ## Test
115
152
 
package/SECURITY.md CHANGED
@@ -9,7 +9,8 @@ Please don't open a public issue for a vulnerability. This is a one-person proje
9
9
 
10
10
  ## What codex-bridge does that matters for security
11
11
 
12
- - It runs the Codex CLI on your machine, always with a **read-only sandbox**: `-c sandbox_mode="read-only"` on every call, plus `-s read-only` when starting a new session. Your Codex config's default sandbox mode never applies to bridge calls.
12
+ - It runs the Codex CLI on your machine, by default always with a **read-only sandbox**: `-c sandbox_mode="read-only"` on every call, plus `-s read-only` when starting a new session. Your Codex config's default sandbox mode never applies to bridge calls.
13
+ - **Opt-in exception: `codex_code`.** Only if you set `CODEX_BRIDGE_WRITE_ROOT` to one folder, an extra tool `codex_code` appears. It runs Codex in the `workspace-write` sandbox with network access off, and refuses any `cwd` that is not that folder or inside it (checked on the real path, so `..` and symlinks don't escape; a drive root or your home folder is refused as too broad). The check repeats on every resume. Everything else stays read-only. In our test on Windows 11, writes outside the folder and network access were blocked, but the system temp folder stayed writable even with the flags that ask Codex to block it (`CODEX_BRIDGE_WRITE_ALLOW_TMP` unset); treat temp as writable. Codex is told not to commit or push, but that is a prompt, not a sandbox rule: review the diff.
13
14
  - It starts Codex without a shell: arguments are passed as an array and the prompt goes in over stdin.
14
15
  - Prompts and any `context` you pass are sent to OpenAI through the Codex CLI, under your own Codex login. **Don't put secrets in prompts.**
15
16
  - Job files (prompts, Codex event streams, results) are stored unencrypted under `~/.codex-bridge/jobs/` (override with `CODEX_BRIDGE_HOME`) and deleted after 14 days.
package/lib.mjs CHANGED
@@ -34,10 +34,16 @@ export function resolveCodexBin() {
34
34
  }
35
35
 
36
36
  // `codex exec` (or `codex exec resume`) arguments; the prompt is always read from stdin.
37
- export function buildCodexArgs({ sandbox, cwd, sessionId, model, effort, images = [] }) {
37
+ // workspace-write is the opt-in codex_code sandbox: Codex may write in `cwd` only, with no network.
38
+ // Unless allowTmp, the system temp dirs are not writable either, so "one folder" really means one folder.
39
+ export function buildCodexArgs({ sandbox, cwd, sessionId, model, effort, images = [], allowTmp = false }) {
38
40
  const args = ['exec'];
39
41
  if (sessionId) args.push('resume');
40
42
  args.push('--json', '--skip-git-repo-check', '-c', `sandbox_mode="${sandbox}"`);
43
+ if (sandbox === 'workspace-write') {
44
+ args.push('-c', 'sandbox_workspace_write.network_access=false');
45
+ if (!allowTmp) args.push('-c', 'sandbox_workspace_write.exclude_tmpdir_env_var=true', '-c', 'sandbox_workspace_write.exclude_slash_tmp=true');
46
+ }
41
47
  if (!sessionId) args.push('-s', sandbox, '-C', cwd);
42
48
  if (model) args.push('-m', model);
43
49
  if (effort) args.push('-c', `model_reasoning_effort="${effort}"`);
@@ -47,6 +53,34 @@ export function buildCodexArgs({ sandbox, cwd, sessionId, model, effort, images
47
53
  return args;
48
54
  }
49
55
 
56
+ // ---------------------------------------------------------------- write root
57
+
58
+ // The one folder codex_code may write in (CODEX_BRIDGE_WRITE_ROOT), as a real path. Throws with a
59
+ // reason if it is unusable: missing, not a folder, a drive root or the home folder (too broad).
60
+ export function resolveWriteRoot(raw = process.env.CODEX_BRIDGE_WRITE_ROOT) {
61
+ if (!raw) return null;
62
+ let root;
63
+ try { root = fs.realpathSync(path.resolve(raw)); } catch { throw new Error(`CODEX_BRIDGE_WRITE_ROOT does not exist: ${raw}`); }
64
+ if (!fs.statSync(root).isDirectory()) throw new Error(`CODEX_BRIDGE_WRITE_ROOT is not a folder: ${raw}`);
65
+ let home = os.homedir();
66
+ try { home = fs.realpathSync(home); } catch {}
67
+ if (path.parse(root).root === root || root === home) {
68
+ throw new Error(`CODEX_BRIDGE_WRITE_ROOT is too broad (${root}); point it at one project folder`);
69
+ }
70
+ return root;
71
+ }
72
+
73
+ // Real path of `dir` if it is `root` or inside it, otherwise throws.
74
+ export function insideWriteRoot(root, dir) {
75
+ let real;
76
+ try { real = fs.realpathSync(path.resolve(dir)); } catch { throw new Error(`cwd does not exist: ${dir}`); }
77
+ const rel = path.relative(root, real);
78
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
79
+ throw new Error(`codex_code may only write inside ${root}; ${real} is outside it`);
80
+ }
81
+ return real;
82
+ }
83
+
50
84
  // ---------------------------------------------------------------- files
51
85
 
52
86
  export function readJson(p, fallback = null) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xallindrome-labs/codex-bridge",
3
- "version": "0.1.0",
4
- "description": "MCP server that lets Claude Code consult the Codex CLI for plans, second opinions and images, as durable background jobs.",
5
- "keywords": ["mcp", "model-context-protocol", "claude-code", "codex", "openai-codex", "planning", "image-generation"],
3
+ "version": "0.2.0",
4
+ "description": "MCP server that lets Claude Code consult the Codex CLI for plans, second opinions and images, as durable background jobs. Optional write mode, and a live jobs pane.",
5
+ "keywords": ["mcp", "model-context-protocol", "claude-code", "codex", "openai-codex", "planning", "image-generation", "claude-code-plugin"],
6
6
  "homepage": "https://github.com/xallindrome-labs/codex-bridge#readme",
7
7
  "bugs": "https://github.com/xallindrome-labs/codex-bridge/issues",
8
8
  "repository": {
@@ -25,7 +25,10 @@
25
25
  "lib.mjs",
26
26
  "smoke-test.mjs",
27
27
  "docs/demo.svg",
28
- "SECURITY.md"
28
+ "SECURITY.md",
29
+ ".claude-plugin/marketplace.json",
30
+ "plugins/codex-jobs",
31
+ "!plugins/codex-jobs/**/*.test.tsx"
29
32
  ],
30
33
  "engines": {
31
34
  "node": ">=20"
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "codex-jobs",
3
+ "version": "0.1.0",
4
+ "description": "A pane beside the chat that lists your codex-bridge jobs live: running, done, interrupted.",
5
+ "author": { "name": "Xallindrome", "url": "https://xallindrome.co" },
6
+ "homepage": "https://github.com/xallindrome-labs/codex-bridge#codex-jobs-pane",
7
+ "license": "MIT",
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Wilson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,45 @@
1
+ # codex-jobs
2
+
3
+ A Claude Code add-on (a "mod") that opens a pane beside the chat listing your [codex-bridge](https://github.com/xallindrome-labs/codex-bridge) jobs, live. It is read-only: it looks at the job files codex-bridge keeps on disk, so it shows jobs started by any Claude session, including ones that kept running after you closed Claude.
4
+
5
+ For each job it shows the tool (`plan`, `code` or `image`), the status (running, done, failed, interrupted, cancelled, timed out), how long it has run or took, the request, and `images k/n` for image batches. Above the list it shows your **Codex usage**: the 5-hour and weekly windows with percent used, a bar, when each resets, and how many images the bridge generated in each window. Codex has one shared meter, so images use the same limits as text; there is no separate image meter. A status-line entry shows `codex: N running`, and a short toast appears when a job you watched start finishes.
6
+
7
+ ## Install
8
+
9
+ At the prompt of a terminal Claude Code session:
10
+
11
+ ```
12
+ /plugin install codex-jobs --marketplace xallindrome-labs/codex-bridge
13
+ ```
14
+
15
+ From a shell: `claude plugin install codex-jobs --marketplace xallindrome-labs/codex-bridge`.
16
+
17
+ In the desktop app, where `/plugin` does not run: click **+** next to the prompt box, then **Plugins**, **Add marketplace** with `xallindrome-labs/codex-bridge`, then **Add plugin**.
18
+
19
+ Or, without a marketplace, from the npm package (this session only):
20
+
21
+ ```
22
+ claude --plugin-dir "$(npx -y @xallindrome-labs/codex-bridge --pane-path)"
23
+ ```
24
+
25
+ ## Use
26
+
27
+ - Type `/codex-jobs` to open the pane. It also opens by itself the first time a job is running in a session.
28
+ - The list refreshes every 3 seconds and shows the 12 newest jobs.
29
+ - If a job shows **interrupted**, its Codex process is gone (reboot, crash or kill). Ask Claude to run `codex_resume` with that job id.
30
+
31
+ It finds the jobs in `$CODEX_BRIDGE_HOME/jobs`, or `~/.codex-bridge/jobs` if that is not set. Usage comes from the newest Codex session logs in `$CODEX_HOME/sessions` (default `~/.codex/sessions`), where Codex records its usage windows; it is re-read every 30 seconds and shows when it was last written, so it can lag until Codex runs again. A window whose reset time has passed shows as reset.
32
+
33
+ ## What it needs
34
+
35
+ - codex-bridge, which creates the jobs. This add-on only reads local files (codex-bridge job files and Codex session logs); it does not call Codex or the network.
36
+ - A Claude Code build that supports mods (hooks modules and `Pane`). Whether a pane is drawn depends on the surface; the add-on is tested against the terminal, desktop, VS Code and mobile element tables, not every paint.
37
+
38
+ ## Develop
39
+
40
+ ```
41
+ claude plugin validate plugins/codex-jobs
42
+ claude plugin test plugins/codex-jobs
43
+ ```
44
+
45
+ MIT licensed, like codex-bridge. Built by [Xallindrome](https://xallindrome.co).
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,390 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register } from 'claude-code'
3
+
4
+ import type { ImageTally, JobRow, JobStatus, JobsSnapshot, Usage, UsageWindow } from '../types'
5
+
6
+ // A pane beside the chat that lists codex-bridge jobs. It only reads files:
7
+ // the job files codex-bridge keeps (jobs/<id>/job.json, attempt-<n>/result.json
8
+ // and heartbeat), so it works for jobs started by any Claude session, and the
9
+ // newest Codex session logs, where Codex records how much of its 5-hour and
10
+ // weekly usage windows is used and when each resets.
11
+
12
+ const PANE = 'codex-jobs'
13
+ const POLL_MS = 3000
14
+ const SHOWN = 12
15
+ // Jobs read per poll (the rest of the list is older). Finished ones are cached, so this is cheap.
16
+ const READ = 60
17
+ // Codex usage is re-read every this many polls (30s); its logs change slowly.
18
+ const USAGE_EVERY = 10
19
+ const USAGE_FILES = 4
20
+ const DAY_MS = 24 * 3600 * 1000
21
+ // The runner beats every 2s; a job whose heartbeat is older than this is dead.
22
+ const BEAT_STALE_MS = 30_000
23
+ const LAUNCH_GRACE_MS = 60_000
24
+
25
+ // Change the tag when the snapshot's fields change: it declines what an earlier
26
+ // version stored, which outlives a reload.
27
+ const snapshot = atom(
28
+ { plugin: 'codex-jobs', key: 'snapshot' } as const,
29
+ { rows: [], images: [], at: 0 } as JobsSnapshot,
30
+ { shape: 'snapshot-2' },
31
+ )
32
+
33
+ type Job = {
34
+ id: string
35
+ tool: string
36
+ created: number
37
+ attempts: number
38
+ attemptStarted?: number
39
+ cwd?: string
40
+ outDir?: string
41
+ args?: { request?: string; prompt?: string; count?: number }
42
+ }
43
+ type Result = { status: JobStatus; finished?: number; images?: string[] }
44
+
45
+ const ICON: Record<JobStatus, string> = {
46
+ running: '●',
47
+ done: '✓',
48
+ failed: '✗',
49
+ interrupted: '!',
50
+ cancelled: '○',
51
+ timed_out: '✗',
52
+ }
53
+ const COLOR: Record<JobStatus, string> = {
54
+ running: 'yellow',
55
+ done: 'green',
56
+ failed: 'red',
57
+ interrupted: 'red',
58
+ cancelled: 'gray',
59
+ timed_out: 'red',
60
+ }
61
+
62
+ export const ago = (ms: number): string => {
63
+ const s = Math.max(0, Math.round(ms / 1000))
64
+ if (s < 120) return `${s}s`
65
+ const m = Math.round(s / 60)
66
+ return m < 120 ? `${m}m` : `${Math.round(m / 60)}h`
67
+ }
68
+
69
+ // "3h 12m", "2d 4h": how long until a usage window resets.
70
+ export const until = (ms: number): string => {
71
+ const m = Math.max(0, Math.round(ms / 60_000))
72
+ if (m < 60) return `${m}m`
73
+ const h = Math.floor(m / 60)
74
+ if (h < 24) return `${h}h ${m % 60}m`
75
+ return `${Math.floor(h / 24)}d ${h % 24}h`
76
+ }
77
+
78
+ const bar = (percent: number, width = 10): string => {
79
+ const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
80
+ return '█'.repeat(filled) + '░'.repeat(width - filled)
81
+ }
82
+
83
+ // Jobs finished, failed or dead are history; only these can still change.
84
+ const isFinal = (s: JobStatus) => s !== 'running' && s !== 'interrupted'
85
+
86
+ // What this module remembers between ticks. A reload starts it over.
87
+ const cache = new Map<string, { mtime: number; row: JobRow }>()
88
+ let last = ''
89
+ let lastRunning = -1
90
+ let ticks = 0
91
+ let usage: Usage | undefined
92
+ let hasAutoOpened = false
93
+ let timer: { cancel: () => void } | undefined
94
+
95
+ const slash = (p: string) => p.replace(/\\/g, '/')
96
+
97
+ async function json<T>($: EngineInterface, path: string): Promise<T | undefined> {
98
+ try {
99
+ return JSON.parse(await $.fs.read(path)) as T
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ async function jobsDir($: EngineInterface): Promise<string | undefined> {
106
+ const custom = await $.env.get('CODEX_BRIDGE_HOME')
107
+ if (custom) return `${slash(custom)}/jobs`
108
+ const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
109
+ return user ? `${slash(user)}/.codex-bridge/jobs` : undefined
110
+ }
111
+
112
+ async function readRow($: EngineInterface, dir: string, id: string): Promise<JobRow | undefined> {
113
+ const jobFile = `${dir}/${id}/job.json`
114
+ const stat = await $.fs.stat(jobFile).catch(() => undefined)
115
+ if (!stat) return undefined
116
+ const hit = cache.get(id)
117
+ if (hit && hit.mtime === stat.mtimeMs && isFinal(hit.row.status)) return hit.row
118
+
119
+ const job = await json<Job>($, jobFile)
120
+ if (!job) return undefined
121
+ const attempt = `${dir}/${id}/attempt-${job.attempts}`
122
+ const result = await json<Result>($, `${attempt}/result.json`)
123
+
124
+ let status: JobStatus
125
+ if (result) {
126
+ status = result.status
127
+ } else {
128
+ const now = await $.clock.now()
129
+ const beat = await $.fs.stat(`${attempt}/heartbeat`).catch(() => undefined)
130
+ const isAlive = beat
131
+ ? now - beat.mtimeMs < BEAT_STALE_MS
132
+ : now - (job.attemptStarted ?? job.created) < LAUNCH_GRACE_MS
133
+ status = isAlive ? 'running' : 'interrupted'
134
+ }
135
+
136
+ let images: number | undefined
137
+ if (job.tool === 'codex_image') {
138
+ const copied = result?.images
139
+ ? result.images.length
140
+ : Object.keys((await json<Record<string, string>>($, `${dir}/${id}/copied.json`)) ?? {}).length
141
+ images = copied
142
+ }
143
+
144
+ const row: JobRow = {
145
+ id: job.id,
146
+ tool: job.tool,
147
+ status,
148
+ created: job.created,
149
+ finished: result?.finished,
150
+ label: (job.args?.request ?? job.args?.prompt ?? '').replace(/\s+/g, ' ').trim().slice(0, 160),
151
+ where: job.cwd ?? job.outDir ?? '',
152
+ attempts: job.attempts,
153
+ images,
154
+ count: job.args?.count,
155
+ }
156
+ cache.set(id, { mtime: stat.mtimeMs, row })
157
+ return row
158
+ }
159
+
160
+ const names = async ($: EngineInterface, dir: string, kind: 'dir' | 'file') =>
161
+ ((await $.fs.list(dir).catch(() => [])) ?? [])
162
+ .filter(entry => entry.kind === kind)
163
+ .map(entry => entry.name)
164
+ .sort()
165
+ .reverse()
166
+
167
+ const toWindow = (w: unknown): UsageWindow | undefined => {
168
+ const x = w as { used_percent?: number; window_minutes?: number; resets_at?: number } | null | undefined
169
+ if (typeof x?.used_percent !== 'number' || typeof x.window_minutes !== 'number' || typeof x.resets_at !== 'number') {
170
+ return undefined
171
+ }
172
+ return { usedPercent: x.used_percent, windowMinutes: x.window_minutes, resetsAt: x.resets_at * 1000 }
173
+ }
174
+
175
+ // Codex writes its usage windows into every session log (token_count events:
176
+ // payload.rate_limits.primary / .secondary). The newest log with a reading is
177
+ // the freshest one; the same meter covers text and image generation.
178
+ async function readUsage($: EngineInterface): Promise<Usage | undefined> {
179
+ const codexHome = await $.env.get('CODEX_HOME')
180
+ const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
181
+ const base = codexHome ?? (user ? `${user}/.codex` : undefined)
182
+ if (!base) return undefined
183
+ const root = `${slash(base)}/sessions`
184
+ const files: string[] = []
185
+ for (const year of await names($, root, 'dir')) {
186
+ for (const month of await names($, `${root}/${year}`, 'dir')) {
187
+ for (const day of await names($, `${root}/${year}/${month}`, 'dir')) {
188
+ const dayDir = `${root}/${year}/${month}/${day}`
189
+ for (const name of await names($, dayDir, 'file')) {
190
+ if (name.startsWith('rollout-') && name.endsWith('.jsonl')) files.push(`${dayDir}/${name}`)
191
+ if (files.length >= USAGE_FILES) break
192
+ }
193
+ if (files.length >= USAGE_FILES) break
194
+ }
195
+ if (files.length >= USAGE_FILES) break
196
+ }
197
+ if (files.length >= USAGE_FILES) break
198
+ }
199
+ for (const file of files) {
200
+ // A log over 4 MiB cannot be read whole; the next one is tried.
201
+ const text = await $.fs.read(file).catch(() => undefined)
202
+ if (!text) continue
203
+ const lines = text.split('\n')
204
+ for (let i = lines.length - 1; i >= 0; i--) {
205
+ const line = lines[i]
206
+ if (!line?.includes('"rate_limits":{')) continue
207
+ try {
208
+ const event = JSON.parse(line) as { timestamp?: string; payload?: { rate_limits?: { primary?: unknown; secondary?: unknown } } }
209
+ const limits = event.payload?.rate_limits
210
+ const primary = toWindow(limits?.primary)
211
+ const secondary = toWindow(limits?.secondary)
212
+ if (primary || secondary) {
213
+ return { primary, secondary, asOf: event.timestamp ? Date.parse(event.timestamp) || 0 : 0 }
214
+ }
215
+ } catch {
216
+ // A half-written last line; the one before it is fine.
217
+ }
218
+ }
219
+ }
220
+ return undefined
221
+ }
222
+
223
+ async function tick($: EngineInterface): Promise<void> {
224
+ const dir = await jobsDir($)
225
+ const now = await $.clock.now()
226
+ let rows: JobRow[] = []
227
+ let problem: string | undefined
228
+ if (!dir) {
229
+ problem = 'Cannot work out where codex-bridge keeps its jobs (no home folder).'
230
+ } else {
231
+ // A missing folder just means codex-bridge has not run yet.
232
+ const ids = (await names($, dir, 'dir')).filter(name => name.startsWith('job-')).slice(0, READ)
233
+ for (const id of cache.keys()) if (!ids.includes(id)) cache.delete(id)
234
+ const loaded = await Promise.all(ids.map(id => readRow($, dir, id).catch(() => undefined)))
235
+ rows = loaded.filter((row): row is JobRow => row !== undefined)
236
+ }
237
+
238
+ // Images the bridge saved in the last week, counted per usage window when drawn.
239
+ const images: ImageTally[] = rows
240
+ .filter(row => row.tool === 'codex_image' && (row.images ?? 0) > 0 && now - row.created < 7 * DAY_MS)
241
+ .map(row => ({ at: row.created, n: row.images ?? 0 }))
242
+ const isUsageTick = ticks++ % USAGE_EVERY === 0
243
+ if (isUsageTick) usage = await readUsage($).catch(() => usage)
244
+ rows = rows.slice(0, SHOWN)
245
+
246
+ const running = rows.filter(row => row.status === 'running').length
247
+ if (running !== lastRunning) {
248
+ lastRunning = running
249
+ $.ui.status(running > 0 ? `codex: ${running} running` : undefined)
250
+ }
251
+ if (running > 0 && !hasAutoOpened) {
252
+ hasAutoOpened = true
253
+ void $.ui.open({ id: PANE, title: 'Codex jobs' })
254
+ }
255
+
256
+ const key = JSON.stringify([rows, images, usage]) + (problem ?? '')
257
+ if (running > 0 || isUsageTick || key !== last) {
258
+ last = key
259
+ await update($, snapshot, () => ({ rows, images, usage, at: now, problem }))
260
+ }
261
+ }
262
+
263
+ // Tells the person when a job they were watching ends.
264
+ const wasRunning = new Set<string>()
265
+ function announce($: EngineInterface, rows: JobRow[]) {
266
+ for (const row of rows) {
267
+ if (row.status === 'running') {
268
+ wasRunning.add(row.id)
269
+ } else if (wasRunning.delete(row.id) && isFinal(row.status)) {
270
+ const verb = row.status === 'done' ? 'finished' : row.status.replace('_', ' ')
271
+ $.ui.toast(`Codex ${row.tool.replace('codex_', '')} ${verb}: ${row.label.slice(0, 50)}`)
272
+ }
273
+ }
274
+ }
275
+
276
+ export const register: Register = on => {
277
+ on('session.start', async ($, e, next) => {
278
+ await $.command.register({
279
+ name: 'codex-jobs',
280
+ description: 'Show your codex-bridge jobs in a pane',
281
+ })
282
+ timer?.cancel()
283
+ const run = () =>
284
+ tick($)
285
+ .then(async () => announce($, (await read($, snapshot)).rows))
286
+ .catch(() => {})
287
+ void run()
288
+ timer = $.clock.every(POLL_MS, () => void run())
289
+
290
+ return next(e)
291
+ })
292
+
293
+ on('command.run', { command: 'codex-jobs' }, async $ => {
294
+ await tick($).catch(() => {})
295
+ await $.ui.open({ id: PANE, title: 'Codex jobs' })
296
+
297
+ return { text: 'Codex jobs pane opened.' }
298
+ })
299
+
300
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
301
+ const { Box, Text } = $.ui.resolve(e)
302
+ const snap = await read($, snapshot)
303
+ const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 14) / 2))
304
+ const running = snap.rows.filter(row => row.status === 'running').length
305
+ const interrupted = snap.rows.filter(row => row.status === 'interrupted')
306
+ const windows = [
307
+ snap.usage?.primary && { label: '5-hour', ...snap.usage.primary },
308
+ snap.usage?.secondary && { label: 'Weekly', ...snap.usage.secondary },
309
+ ].filter((w): w is UsageWindow & { label: string } => !!w)
310
+
311
+ return (
312
+ <Box flexDirection="column" gap={1}>
313
+ <Box gap={1}>
314
+ <Text bold>Codex jobs</Text>
315
+ <Text dimColor>
316
+ {running} running, {snap.rows.length} shown
317
+ </Text>
318
+ </Box>
319
+ <Box flexDirection="column">
320
+ <Text bold>Codex usage</Text>
321
+ {windows.length === 0 && <Text dimColor>No usage reading yet. Codex records one after its first run.</Text>}
322
+ {windows.map(w => {
323
+ const isReset = w.resetsAt <= snap.at
324
+ // Images are counted from the start of this window, by when each job began.
325
+ const since = w.resetsAt - w.windowMinutes * 60_000
326
+ const imagesNow = snap.images.filter(i => i.at >= since).reduce((sum, i) => sum + i.n, 0)
327
+
328
+ return (
329
+ <Box flexDirection="column">
330
+ <Box gap={1}>
331
+ <Text>{w.label}</Text>
332
+ <Text color={w.usedPercent >= 90 ? 'red' : w.usedPercent >= 70 ? 'yellow' : 'green'}>
333
+ {bar(isReset ? 0 : w.usedPercent)} {isReset ? 0 : Math.round(w.usedPercent)}%
334
+ </Text>
335
+ <Text dimColor>{isReset ? 'window has reset' : `resets in ${until(w.resetsAt - snap.at)}`}</Text>
336
+ </Box>
337
+ <Text dimColor>
338
+ {' '}images this window: {isReset ? 0 : imagesNow}
339
+ </Text>
340
+ </Box>
341
+ )
342
+ })}
343
+ {windows.length > 0 && (
344
+ <Text dimColor>
345
+ Images share these limits; Codex has no separate image meter.
346
+ {snap.usage?.asOf ? ` Read ${ago(snap.at - snap.usage.asOf)} ago.` : ''}
347
+ </Text>
348
+ )}
349
+ </Box>
350
+ {snap.problem && <Text color="red">{snap.problem}</Text>}
351
+ {!snap.problem && snap.rows.length === 0 && (
352
+ <Text dimColor>
353
+ No Codex jobs yet. They appear here when Claude calls codex_plan, codex_code or codex_image.
354
+ </Text>
355
+ )}
356
+ {snap.rows.slice(0, room).map(row => {
357
+ const age = ago((row.finished ?? snap.at) - row.created)
358
+ const extra = [
359
+ row.tool === 'codex_image' ? `images ${row.images ?? 0}${row.count ? `/${row.count}` : ''}` : '',
360
+ row.attempts > 1 ? `attempt ${row.attempts}` : '',
361
+ ]
362
+ .filter(Boolean)
363
+ .join(', ')
364
+
365
+ return (
366
+ <Box flexDirection="column">
367
+ <Box gap={1}>
368
+ <Text color={COLOR[row.status]}>{ICON[row.status]}</Text>
369
+ <Text bold>{row.tool.replace('codex_', '')}</Text>
370
+ <Text color={COLOR[row.status]}>{row.status.replace('_', ' ')}</Text>
371
+ <Text dimColor>{age}</Text>
372
+ {extra && <Text dimColor>{extra}</Text>}
373
+ </Box>
374
+ <Text wrap="truncate-end" dimColor>
375
+ {row.label || row.id}
376
+ </Text>
377
+ </Box>
378
+ )
379
+ })}
380
+ {snap.rows.length > room && <Text dimColor>+{snap.rows.length - room} older</Text>}
381
+ {interrupted.length > 0 && (
382
+ <Text dimColor>
383
+ Interrupted: ask Claude to run codex_resume with job_id {interrupted[0]?.id}
384
+ {interrupted.length > 1 ? ` (and ${interrupted.length - 1} more)` : ''}.
385
+ </Text>
386
+ )}
387
+ </Box>
388
+ )
389
+ })
390
+ }
@@ -0,0 +1,54 @@
1
+ export type JobStatus = 'running' | 'done' | 'failed' | 'interrupted' | 'cancelled' | 'timed_out'
2
+
3
+ export type JobRow = {
4
+ id: string
5
+ tool: string
6
+ status: JobStatus
7
+ // Milliseconds since the epoch.
8
+ created: number
9
+ // Set once the job has a result.
10
+ finished?: number
11
+ // The request or prompt, one line.
12
+ label: string
13
+ // The folder the job works in (cwd for plans and code, output_dir for images).
14
+ where: string
15
+ attempts: number
16
+ // Image jobs only: saved so far, and how many were asked for.
17
+ images?: number
18
+ count?: number
19
+ }
20
+
21
+ // One of Codex's usage windows, as its last session log reported it.
22
+ export type UsageWindow = {
23
+ usedPercent: number
24
+ windowMinutes: number
25
+ // Milliseconds since the epoch.
26
+ resetsAt: number
27
+ }
28
+
29
+ export type Usage = {
30
+ // The 5-hour window and the weekly one. Codex reports one shared meter: images draw on it too.
31
+ primary?: UsageWindow
32
+ secondary?: UsageWindow
33
+ // When Codex wrote the reading.
34
+ asOf: number
35
+ }
36
+
37
+ // Images the bridge saved, by when the job started; the pane counts them per usage window.
38
+ export type ImageTally = { at: number; n: number }
39
+
40
+ export type JobsSnapshot = {
41
+ rows: JobRow[]
42
+ images: ImageTally[]
43
+ usage?: Usage
44
+ // When the snapshot was read; the pane's elapsed times count up from it.
45
+ at: number
46
+ // Set when the jobs folder could not be read.
47
+ problem?: string
48
+ }
49
+
50
+ declare module 'claude-code' {
51
+ interface PluginState {
52
+ 'codex-jobs': { snapshot: Shaped<JobsSnapshot> }
53
+ }
54
+ }
package/runner.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Runs one attempt of a codex-bridge job, detached from Claude Code, so the job
3
- // survives Claude closing or crashing. Usage: node runner.mjs <jobId> <attempt>
3
+ // survives Claude closing or crashing. Usage: node runner.mjs <jobId> <attempt> [bridgeHome]
4
4
  //
5
5
  // Reads jobs/<id>/job.json, attempt-<n>/spec.json, attempt-<n>/prompt.txt
6
6
  // Writes attempt-<n>/events.jsonl, stderr.txt, state.json, heartbeat, result.json
@@ -8,11 +8,15 @@
8
8
  import { spawn } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import {
11
+
12
+ const [jobId, n, bridgeHome] = process.argv.slice(2);
13
+ // On Windows the runner is started through WMI and does not inherit the server's environment,
14
+ // so the server passes its job storage folder as an argument. lib.mjs reads it at import time.
15
+ if (bridgeHome) process.env.CODEX_BRIDGE_HOME = bridgeHome;
16
+ const {
12
17
  EventTail, attemptDir, collectImages, jobDir, jobImages, killTree, readJson, writeJson,
13
- } from './lib.mjs';
18
+ } = await import('./lib.mjs');
14
19
 
15
- const [jobId, n] = process.argv.slice(2);
16
20
  const dir = attemptDir(jobId, n);
17
21
  const job = readJson(path.join(jobDir(jobId), 'job.json'));
18
22
  const spec = readJson(path.join(dir, 'spec.json'));
package/server.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // Tools:
11
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
12
13
  // codex_image - image generation; images are saved into output_dir
13
14
  // codex_result - status/result of jobs (optionally waits); lists jobs
14
15
  // codex_resume - continue an interrupted/failed job where it left off
@@ -20,6 +21,11 @@
20
21
  // CODEX_BRIDGE_IDLE_TIMEOUT_MS kill a run after this long with no Codex output (default 600000)
21
22
  // CODEX_BRIDGE_PLAN_MODEL plan model (default: Codex config default)
22
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)
23
29
  // CODEX_BRIDGE_IMAGE_MODEL image model (default gpt-6-luna)
24
30
  // CODEX_BRIDGE_IMAGE_EFFORT image reasoning effort (default high)
25
31
 
@@ -30,8 +36,8 @@ import path from 'node:path';
30
36
  import readline from 'node:readline';
31
37
  import { fileURLToPath } from 'node:url';
32
38
  import {
33
- EventTail, JOBS_DIR, attemptDir, buildCodexArgs, collectImages, isWin, jobDir, jobImages,
34
- pidAlive, readJson, resolveCodexBin, writeJson,
39
+ BRIDGE_HOME, EventTail, JOBS_DIR, attemptDir, buildCodexArgs, collectImages, insideWriteRoot, isWin, jobDir, jobImages,
40
+ pidAlive, readJson, resolveCodexBin, resolveWriteRoot, writeJson,
35
41
  } from './lib.mjs';
36
42
 
37
43
  const PKG = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -41,21 +47,31 @@ const IDLE_TIMEOUT_MS = Number(process.env.CODEX_BRIDGE_IDLE_TIMEOUT_MS) || 10 *
41
47
  const DEFAULTS = {
42
48
  codex_plan: { model: process.env.CODEX_BRIDGE_PLAN_MODEL || '', effort: process.env.CODEX_BRIDGE_PLAN_EFFORT || '' },
43
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 || '' },
44
51
  };
45
52
  const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
46
53
  const MAX_WAIT_S = 240;
47
54
  const STALE_HEARTBEAT_MS = 10 * 60 * 1000;
48
55
  const PRUNE_AFTER_MS = 14 * 24 * 3600 * 1000;
49
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`); }
50
60
 
51
61
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
52
62
  console.log(PKG.version);
53
63
  process.exit(0);
54
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
+ }
55
70
  if (process.argv.includes('--help') || process.argv.includes('-h') || process.stdin.isTTY) {
56
71
  console.log(`codex-bridge ${PKG.version}: an MCP server (stdio) that lets Claude Code consult the Codex CLI.
57
72
  It is started by an MCP client, not run by hand. Register it with Claude Code:
58
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)
59
75
  Docs: ${PKG.homepage}`);
60
76
  process.exit(0);
61
77
  }
@@ -74,6 +90,18 @@ Rules:
74
90
  Request from Claude Code:
75
91
  `;
76
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
+
77
105
  // Codex only generates; the runner collects each image from Codex's image store
78
106
  // the moment it appears. (Left to save files itself, Codex may re-type the image
79
107
  // as base64 through shell commands: slow, and can corrupt the file.)
@@ -91,8 +119,9 @@ Request:
91
119
  `;
92
120
 
93
121
  function initialPrompt(tool, args) {
94
- if (tool === 'codex_plan') {
95
- return (args.session_id ? '' : PLAN_PREAMBLE) + args.request + (args.context ? `\n\nContext:\n${args.context}` : '');
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}` : '');
96
125
  }
97
126
  return args.session_id ? `${args.prompt}\n\nRules:\n${IMAGE_RULES}` : IMAGE_PREAMBLE(args.count) + args.prompt;
98
127
  }
@@ -101,6 +130,9 @@ function resumePrompt(job) {
101
130
  if (job.tool === 'codex_plan') {
102
131
  return 'Your previous run on this request was interrupted before you finished. Continue from where you stopped and give your complete final answer.';
103
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
+ }
104
136
  const done = jobImages(job).length;
105
137
  const remaining = job.args.count ? Math.max(job.args.count - done, 0) : null;
106
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.
@@ -112,6 +144,7 @@ ${IMAGE_RULES}
112
144
 
113
145
  // ---------------------------------------------------------------- jobs
114
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.';
115
148
  const newJobId = () => `job-${new Date().toISOString().replace(/[-:T]/g, '').slice(2, 14)}-${crypto.randomBytes(2).toString('hex')}`;
116
149
  const loadJob = id => readJson(path.join(jobDir(id), 'job.json'));
117
150
  const saveJob = job => writeJson(path.join(jobDir(job.id), 'job.json'), job);
@@ -120,12 +153,12 @@ const saveJob = job => writeJson(path.join(jobDir(job.id), 'job.json'), job);
120
153
  // closing Claude doesn't kill it. On Windows that means WMI Win32_Process.Create.
121
154
  function launchRunner(jobId, n, cwd) {
122
155
  if (!isWin) {
123
- const c = spawn(process.execPath, [RUNNER, jobId, String(n)], { cwd, detached: true, stdio: 'ignore' });
156
+ const c = spawn(process.execPath, [RUNNER, jobId, String(n), BRIDGE_HOME], { cwd, detached: true, stdio: 'ignore' });
124
157
  c.unref();
125
158
  return Promise.resolve(c.pid);
126
159
  }
127
160
  const q = s => `'${String(s).replace(/'/g, "''")}'`;
128
- const cmdLine = `"${process.execPath}" "${RUNNER}" ${jobId} ${n}`;
161
+ const cmdLine = `"${process.execPath}" "${RUNNER}" ${jobId} ${n} "${BRIDGE_HOME}"`;
129
162
  const ps = `$ErrorActionPreference = 'Stop'
130
163
  $si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }
131
164
  $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${q(cmdLine)}; CurrentDirectory = ${q(cwd)}; ProcessStartupInformation = $si }
@@ -149,15 +182,21 @@ async function launchAttempt(job, prompt, sessionId) {
149
182
  const n = job.attempts + 1;
150
183
  const dir = attemptDir(job.id, n);
151
184
  fs.mkdirSync(dir); // throws EEXIST if another session launched this attempt first
152
- const cwd = job.tool === 'codex_plan' ? job.cwd : job.outDir;
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
+ }
153
191
  fs.writeFileSync(path.join(dir, 'prompt.txt'), prompt);
154
192
  writeJson(path.join(dir, 'spec.json'), {
155
193
  bin: CODEX_BIN,
156
194
  cwd,
157
195
  args: buildCodexArgs({
158
- sandbox: 'read-only',
196
+ sandbox: writes ? 'workspace-write' : 'read-only',
159
197
  cwd, sessionId, model: job.model, effort: job.effort,
160
198
  images: n === 1 ? job.refs : [],
199
+ allowTmp: WRITE_ALLOW_TMP,
161
200
  }),
162
201
  env: process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {},
163
202
  idleTimeoutMs: IDLE_TIMEOUT_MS,
@@ -180,9 +219,14 @@ async function startJob(tool, args) {
180
219
  created: Date.now(),
181
220
  attempts: 0,
182
221
  };
183
- if (tool === 'codex_plan') {
222
+ if (tool === 'codex_plan' || tool === 'codex_code') {
184
223
  job.cwd = path.resolve(args.cwd);
185
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
+ }
186
230
  } else {
187
231
  job.outDir = path.resolve(args.output_dir);
188
232
  fs.mkdirSync(job.outDir, { recursive: true });
@@ -227,7 +271,7 @@ function imageCount(job) {
227
271
  function jobLine(job) {
228
272
  const { status, result } = jobStatus(job);
229
273
  const age = secs((result?.finished ?? Date.now()) - job.created);
230
- const where = job.tool === 'codex_plan' ? job.cwd : job.outDir;
274
+ const where = job.cwd ?? job.outDir;
231
275
  const label = (job.args.request || job.args.prompt || '').replace(/\s+/g, ' ').slice(0, 70);
232
276
  const extra = [job.tool === 'codex_image' ? imageCount(job) : null, job.attempts > 1 ? `attempt ${job.attempts}` : null].filter(Boolean).join(', ');
233
277
  return `${job.id} [${job.tool}] ${status}, ${age}${extra ? `, ${extra}` : ''} - ${label} (${where})`;
@@ -247,6 +291,11 @@ function renderResult(job, result) {
247
291
  if (result.stderrTail) lines.push(result.stderrTail);
248
292
  }
249
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
+ }
250
299
  lines.push('', '---',
251
300
  `job_id: ${job.id}${job.attempts > 1 ? ` (attempt ${job.attempts})` : ''}`,
252
301
  `session_id: ${result.threadId ?? 'unknown'} (pass as session_id to continue this Codex thread)`,
@@ -383,8 +432,34 @@ const TOOLS = [
383
432
  },
384
433
  ];
385
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
+
386
460
  async function callJobTool(name, args, onActivity) {
387
- if (name === 'codex_plan' && (!args.request || !args.cwd)) throw new Error('request and cwd are required');
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');
388
463
  if (name === 'codex_image' && (!args.prompt || !args.output_dir)) throw new Error('prompt and output_dir are required');
389
464
  if (args.effort && !EFFORTS.includes(args.effort)) throw new Error(`effort must be one of ${EFFORTS.join(', ')}`);
390
465
  const job = await startJob(name, args);
@@ -451,6 +526,9 @@ function instructions() {
451
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. ' +
452
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. ' +
453
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
+ }
454
532
  try {
455
533
  const interrupted = listJobs().filter(j => jobStatus(j).status === 'interrupted').slice(0, 10);
456
534
  if (interrupted.length) {
@@ -488,7 +566,7 @@ async function handle(msg) {
488
566
  };
489
567
  try {
490
568
  let out;
491
- if (name === 'codex_plan' || name === 'codex_image') out = await callJobTool(name, args, onActivity);
569
+ if (name === 'codex_plan' || name === 'codex_image' || name === 'codex_code') out = await callJobTool(name, args, onActivity);
492
570
  else if (name === 'codex_result') out = await jobResult(args);
493
571
  else if (name === 'codex_resume') out = await resumeJob(args);
494
572
  else if (name === 'codex_cancel') out = cancelJob(args);