@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.
- package/.claude-plugin/marketplace.json +12 -0
- package/README.md +40 -3
- package/SECURITY.md +2 -1
- package/lib.mjs +35 -1
- package/package.json +7 -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 +8 -4
- package/server.mjs +90 -12
|
@@ -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
|

|
|
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 `
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 === '
|
|
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);
|