Initial commit

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Dominik Kundel 2026-03-30 09:28:15 -07:00
commit c69527eb18
No known key found for this signature in database
58 changed files with 9718 additions and 0 deletions

View File

@ -0,0 +1,21 @@
{
"name": "openai-codex",
"owner": {
"name": "OpenAI"
},
"metadata": {
"description": "Codex plugins to use in Claude Code for delegation and code review.",
"version": "1.0.0"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.0",
"author": {
"name": "OpenAI"
},
"source": "./plugins/codex"
}
]
}

150
.gitignore vendored Normal file
View File

@ -0,0 +1,150 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
*.zip
.DS_Store
**/.DS_Store
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
output/
plugins/codex/.generated/

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

13
NOTICE Normal file
View File

@ -0,0 +1,13 @@
Copyright 2026 OpenAI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

299
README.md Normal file
View File

@ -0,0 +1,299 @@
# Codex plugin for Claude Code
Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex.
This plugin is for Claude Code users who want an easy way to start using Codex from the workflow
they already have.
<video src="./docs/plugin-demo.webm" controls muted playsinline autoplay></video>
## What You Get
- `/codex:review` for a normal read-only Codex review
- `/codex:adversarial-review` for a steerable challenge review
- `/codex:rescue`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work and manage background jobs
## Requirements
- **ChatGPT subscription (incl. Free) or OpenAI API key.**
- Usage will contribute to your Codex usage limits. [Learn more](https://developers.openai.com/codex/pricing).
- **Node.js 18.18 or later**
## Install
Add the marketplace in Claude Code:
```bash
/plugin marketplace add openai/codex-plugin-cc
```
Install the plugin:
```bash
/plugin install codex@openai-codex
```
Then run:
```bash
/codex:setup
```
`/codex:setup` will tell you whether Codex is ready. If Codex is missing and npm is available, it can offer to install Codex for you.
If you prefer to install Codex yourself, use:
```bash
npm install -g @openai/codex
```
If Codex is installed but not logged in yet, run:
```bash
!codex login
```
After install, you should see:
- the slash commands listed below
- the `codex:codex-rescue` subagent in `/agents`
One simple first run is:
```bash
/codex:review --background
/codex:status
/codex:result
```
## Usage
### `/codex:review`
Runs a normal Codex review on your current work. It gives you the same quality of code review as running `/review` inside Codex directly.
> [!NOTE]
> Code review especially for multi-file changes might take a while. It's generally recommended to run it in the background.
Use it when you want:
- a review of your current uncommitted changes
- a review of your branch compared to a base branch like `main`
Use `--base <ref>` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area.
Examples:
```bash
/codex:review
/codex:review --base main
/codex:review --background
```
This command is read-only and will not perform any changes. When run in the background you can use [`/codex:status`](#codexstatus) to check on the progress and [`/codex:cancel`](#codexcancel) to cancel the ongoing task.
### `/codex:adversarial-review`
Runs a **steerable** review that questions the chosen implementation and design.
It can be used to pressure-test assumptions, tradeoffs, failure modes, and whether a different approach would have been safer or simpler.
It uses the same review target selection as `/codex:review`, including `--base <ref>` for branch review.
It also supports `--wait` and `--background`. Unlike `/codex:review`, it can take extra focus text after the flags.
Use it when you want:
- a review before shipping that challenges the direction, not just the code details
- review focused on design choices, tradeoffs, hidden assumptions, and alternative approaches
- pressure-testing around specific risk areas like auth, data loss, rollback, race conditions, or reliability
Examples:
```bash
/codex:adversarial-review
/codex:adversarial-review --base main challenge whether this was the right caching and retry design
/codex:adversarial-review --background look for race conditions and question the chosen approach
```
This command is read-only. It does not fix code.
### `/codex:rescue`
Hands a task to Codex through the `codex:codex-rescue` subagent.
Use it when you want Codex to:
- investigate a bug
- try a fix
- continue a previous Codex task
- take a faster or cheaper pass with a smaller model
> [!NOTE]
> Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background.
It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo.
Examples:
```bash
/codex:rescue investigate why the tests started failing
/codex:rescue fix the failing test with the smallest safe patch
/codex:rescue --resume apply the top fix from the last run
/codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test
/codex:rescue --model spark fix the issue quickly
/codex:rescue --background investigate the regression
```
You can also just ask for a task to be delegated to Codex:
```text
Ask Codex to redesign the database connection to be more resilient.
```
**Notes:**
- if you do not pass `--model` or `--effort`, Codex chooses its own defaults.
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
- follow-up rescue requests can continue the latest Codex task in the repo
### `/codex:status`
Shows running and recent Codex jobs for the current repository.
Examples:
```bash
/codex:status
/codex:status task-abc123
```
Use it to:
- check progress on background work
- see the latest completed job
- confirm whether a task is still running
### `/codex:result`
Shows the final stored Codex output for a finished job.
When available, it also includes the Codex session ID so you can reopen that run directly in Codex with `codex resume <session-id>`.
Examples:
```bash
/codex:result
/codex:result task-abc123
```
### `/codex:cancel`
Cancels an active background Codex job.
Examples:
```bash
/codex:cancel
/codex:cancel task-abc123
```
### `/codex:setup`
Checks whether Codex is installed and authenticated.
If Codex is missing and npm is available, it can offer to install Codex for you.
You can also use `/codex:setup` to manage the optional review gate.
#### Enabling review gate
```bash
/codex:setup --enable-review-gate
/codex:setup --disable-review-gate
```
When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first.
> [!WARNING]
> The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session.
## Typical Flows
### Review Before Shipping
```bash
/codex:review
```
### Hand A Problem To Codex
```bash
/codex:rescue investigate why the build is failing in CI
```
### Start Something Long-Running
```bash
/codex:adversarial-review --background
/codex:rescue --background investigate the flaky test
```
Then check in with:
```bash
/codex:status
/codex:result
```
## Codex Integration
The Codex plugin wraps the [Codex app server](https://developers.openai.com/codex/app-server). It uses the global `codex` binary installed in your environment and [applies the same configuration](https://developers.openai.com/codex/config-basic).
### Common Configurations
If you want to change the default reasoning effort or the default model that gets used by the plugin, you can define that inside your user-level or project-level `config.toml`. For example to always use `gpt-5.4-mini` on `high` for a specific project you can add the following to a `.codex/config.toml` file at the root of the directory you started Claude in:
```toml
model = "gpt-5.4-mini"
model_reasoning_effort = "xhigh"
```
Your configuration will be picked up based on:
- user-level config in `~/.codex/config.toml`
- project-level overrides in `.codex/config.toml`
- project-level overrides only load when the [project is trusted](https://developers.openai.com/codex/config-advanced#project-config-files-codexconfigtoml)
Check out the Codex docs for more [configuration options](https://developers.openai.com/codex/config-reference).
### Moving The Work Over To Codex
Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list.
This way you can review the Codex work or continue the work there.
## FAQ
### Do I need a separate Codex account for this plugin?
If you are already signed into Codex on this machine, that account should work immediately here too. This plugin uses your local Codex CLI authentication.
If you only use Claude Code today and have not used Codex yet, you will also need to sign in to Codex with either a ChatGPT account or an API key. [Codex is available with your ChatGPT subscription](https://developers.openai.com/codex/pricing/), and [`codex login`](https://developers.openai.com/codex/cli/reference/#codex-login) supports both ChatGPT and API key sign-in. Run `/codex:setup` to check whether Codex is ready, and use `!codex login` if it is not.
### Does the plugin use a separate Codex runtime?
No. This plugin delegates through your local [Codex CLI](https://developers.openai.com/codex/cli/) and [Codex app server](https://developers.openai.com/codex/app-server/) on the same machine.
That means:
- it uses the same Codex install you would use directly
- it uses the same local authentication state
- it uses the same repository checkout and machine-local environment
### Will it use the same Codex config I already have?
Yes. If you already use Codex, the plugin picks up the same [configuration](#common-configurations).
### Can I keep using my current API key or base URL setup?
Yes. Because the plugin uses your local Codex CLI, your existing sign-in method and config still apply.
If you need to point the built-in OpenAI provider at a different endpoint, set `openai_base_url` in your [Codex config](https://developers.openai.com/codex/config-advanced/#config-and-state-locations).

51
package-lock.json generated Normal file
View File

@ -0,0 +1,51 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^6.0.2"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
},
"scripts": {
"prebuild": "mkdir -p plugins/codex/.generated/app-server-types && codex app-server generate-ts --out plugins/codex/.generated/app-server-types",
"build": "tsc -p tsconfig.app-server.json",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^6.0.2"
}
}

View File

@ -0,0 +1,7 @@
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
}
}

View File

@ -0,0 +1,5 @@
# Changelog
## 1.0.0
- Initial version of the Codex plugin for Claude Code

201
plugins/codex/LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

13
plugins/codex/NOTICE Normal file
View File

@ -0,0 +1,13 @@
Copyright 2026 OpenAI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,45 @@
---
name: codex-rescue
description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to Codex through the shared runtime
tools: Bash
skills:
- codex-cli-runtime
- gpt-5-4-prompting
---
You are a thin forwarding wrapper around the Codex companion task runtime.
Your only job is to forward the user's rescue request to the Codex companion script. Do not do anything else.
Selection guidance:
- Do not wait for the user to explicitly ask for Codex. Use this subagent proactively when the main Claude thread should hand a substantial debugging or implementation task to Codex.
- Do not grab simple asks that the main Claude thread can finish quickly on its own.
Forwarding rules:
- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`.
- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request.
- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution.
- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it.
- Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text.
- Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own.
- Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`. This subagent only forwards to `task`.
- Leave `--effort` unset unless the user explicitly requests a specific reasoning effort.
- Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model.
- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`.
- If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`.
- Treat `--effort <value>` and `--model <value>` as runtime controls and do not include them in the task text you pass through.
- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through.
- `--resume` means add `--resume-last`.
- `--fresh` means do not add `--resume-last`.
- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present.
- Otherwise forward the task as a fresh `task` run.
- Preserve the user's task text as-is apart from stripping routing flags.
- Return the stdout of the `codex-companion` command exactly as-is.
- If the Bash call fails or Codex cannot be invoked, return nothing.
Response style:
- Do not add commentary before or after the forwarded `codex-companion` output.

View File

@ -0,0 +1,66 @@
---
description: Run a Codex review that challenges the implementation approach and design choices
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [focus ...]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion
---
Run an adversarial Codex review through the shared plugin runtime.
Position it as a challenge review that questions the chosen implementation, design choices, tradeoffs, and assumptions.
It is not just a stricter pass over implementation defects.
Raw slash-command arguments:
`$ARGUMENTS`
Core constraint:
- This command is review-only.
- Do not fix issues, apply patches, or suggest that you are about to make changes.
- Your only job is to run the review and return Codex's output verbatim to the user.
- Keep the framing focused on whether the current approach is the right one, what assumptions it depends on, and where the design could fail under real-world conditions.
Execution mode rules:
- If the raw arguments include `--wait`, do not ask. Run in the foreground.
- If the raw arguments include `--background`, do not ask. Run in a Claude background task.
- Otherwise, estimate the review size before asking:
- For working-tree review, start with `git status --short --untracked-files=all`.
- For working-tree review, also inspect both `git diff --shortstat --cached` and `git diff --shortstat`.
- For base-branch review, use `git diff --shortstat <base>...HEAD`.
- Treat untracked files or directories as reviewable work for auto or working-tree review even when `git diff --shortstat` is empty.
- Only conclude there is nothing to review when the relevant scope is actually empty.
- Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total and no sign of a broader directory-sized change.
- In every other case, including unclear size, recommend background.
- When in doubt, run the review instead of declaring that there is nothing to review.
- Then use `AskUserQuestion` exactly once with two options, putting the recommended option first and suffixing its label with `(Recommended)`:
- `Wait for results`
- `Run in background`
Argument handling:
- Preserve the user's arguments exactly.
- Do not strip `--wait` or `--background` yourself.
- Do not weaken the adversarial framing or rewrite the user's focus text.
- The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run.
- `/codex:adversarial-review` uses the same review target selection as `/codex:review`.
- It supports working-tree review, branch review, and `--base <ref>`.
- It does not support `--scope staged` or `--scope unstaged`.
- Unlike `/codex:review`, it can still take extra focus text after the flags.
Foreground flow:
- Run:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" adversarial-review "$ARGUMENTS"
```
- Return the command stdout verbatim, exactly as-is.
- Do not paraphrase, summarize, or add commentary before or after it.
- Do not fix any issues mentioned in the review output.
Background flow:
- Launch the review with `Bash` in the background:
```typescript
Bash({
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" adversarial-review "$ARGUMENTS"`,
description: "Codex adversarial review",
run_in_background: true
})
```
- Do not call `BashOutput` or wait for completion in this turn.
- After launching the command, tell the user: "Codex adversarial review started in the background. Check `/codex:status` for progress."

View File

@ -0,0 +1,8 @@
---
description: Cancel an active background Codex job in this repository
argument-hint: '[job-id]'
disable-model-invocation: true
allowed-tools: Bash(node:*)
---
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel $ARGUMENTS`

View File

@ -0,0 +1,49 @@
---
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*)
---
Route this request to the `codex:codex-rescue` subagent.
The final user-visible response must be Codex's output verbatim.
Raw user request:
$ARGUMENTS
Execution mode:
- If the request includes `--background`, run the `codex:codex-rescue` subagent in the background.
- If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground.
- If neither flag is present, default to foreground.
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json
```
- If that helper reports `available: true`, use `AskUserQuestion` exactly once to ask whether to continue the current Codex thread or start a new one.
- The two choices must be:
- `Continue current Codex thread`
- `Start a new Codex thread`
- If the user is clearly giving a follow-up instruction such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", put `Continue current Codex thread (Recommended)` first.
- Otherwise put `Start a new Codex thread (Recommended)` first.
- If the user chooses continue, add `--resume` before routing to the subagent.
- If the user chooses a new thread, add `--fresh` before routing to the subagent.
- If the helper reports `available: false`, do not ask. Route normally.
Operating rules:
- The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...` and return that command's stdout as-is.
- Return the Codex companion stdout verbatim to the user.
- Do not paraphrase, summarize, rewrite, or add commentary before or after it.
- Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own.
- Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort.
- Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `gpt-5.3-codex-spark`.
- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command.
- If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`.
- If the user did not supply a request, ask what Codex should investigate or fix.

View File

@ -0,0 +1,15 @@
---
description: Show the stored final output for a finished Codex job in this repository
argument-hint: '[job-id]'
disable-model-invocation: true
allowed-tools: Bash(node:*)
---
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result $ARGUMENTS`
Present the full command output to the user. Do not summarize or condense it. Preserve all details including:
- Job ID and status
- The complete result payload, including verdict, summary, findings, details, artifacts, and next steps
- File paths and line numbers exactly as reported
- Any error messages or parse errors
- Follow-up commands such as `/codex:status <id>` and `/codex:review`

View File

@ -0,0 +1,61 @@
---
description: Run a Codex code review against local git state
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion
---
Run a Codex review through the shared built-in reviewer.
Raw slash-command arguments:
`$ARGUMENTS`
Core constraint:
- This command is review-only.
- Do not fix issues, apply patches, or suggest that you are about to make changes.
- Your only job is to run the review and return Codex's output verbatim to the user.
Execution mode rules:
- If the raw arguments include `--wait`, do not ask. Run the review in the foreground.
- If the raw arguments include `--background`, do not ask. Run the review in a Claude background task.
- Otherwise, estimate the review size before asking:
- For working-tree review, start with `git status --short --untracked-files=all`.
- For working-tree review, also inspect both `git diff --shortstat --cached` and `git diff --shortstat`.
- For base-branch review, use `git diff --shortstat <base>...HEAD`.
- Treat untracked files or directories as reviewable work even when `git diff --shortstat` is empty.
- Only conclude there is nothing to review when the relevant working-tree status is empty or the explicit branch diff is empty.
- Recommend waiting only when the review is clearly tiny, roughly 1-2 files total and no sign of a broader directory-sized change.
- In every other case, including unclear size, recommend background.
- When in doubt, run the review instead of declaring that there is nothing to review.
- Then use `AskUserQuestion` exactly once with two options, putting the recommended option first and suffixing its label with `(Recommended)`:
- `Wait for results`
- `Run in background`
Argument handling:
- Preserve the user's arguments exactly.
- Do not strip `--wait` or `--background` yourself.
- Do not add extra review instructions or rewrite the user's intent.
- The companion script parses `--wait` and `--background`, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run.
- `/codex:review` is native-review only. It does not support staged-only review, unstaged-only review, or extra focus text.
- If the user needs custom review instructions or more adversarial framing, they should use `/codex:adversarial-review`.
Foreground flow:
- Run:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" review "$ARGUMENTS"
```
- Return the command stdout verbatim, exactly as-is.
- Do not paraphrase, summarize, or add commentary before or after it.
- Do not fix any issues mentioned in the review output.
Background flow:
- Launch the review with `Bash` in the background:
```typescript
Bash({
command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" review "$ARGUMENTS"`,
description: "Codex review",
run_in_background: true
})
```
- Do not call `BashOutput` or wait for completion in this turn.
- After launching the command, tell the user: "Codex review started in the background. Check `/codex:status` for progress."

View File

@ -0,0 +1,37 @@
---
description: Check whether the local Codex CLI is ready and optionally toggle the stop-time review gate
argument-hint: '[--enable-review-gate|--disable-review-gate]'
allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion
---
Run:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" setup --json $ARGUMENTS
```
If the result says Codex is unavailable and npm is available:
- Use `AskUserQuestion` exactly once to ask whether Claude should install Codex now.
- Put the install option first and suffix it with `(Recommended)`.
- Use these two options:
- `Install Codex (Recommended)`
- `Skip for now`
- If the user chooses install, run:
```bash
npm install -g @openai/codex
```
- Then rerun:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" setup --json $ARGUMENTS
```
If Codex is already installed or npm is unavailable:
- Do not ask about installation.
Output rules:
- Present the final setup output to the user.
- If installation was skipped, present the original setup output.
- If Codex is installed but not authenticated, preserve the guidance to run `!codex login`.

View File

@ -0,0 +1,17 @@
---
description: Show active and recent Codex jobs for this repository, including review-gate status
argument-hint: '[job-id] [--wait] [--timeout-ms <ms>] [--all]'
disable-model-invocation: true
allowed-tools: Bash(node:*)
---
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status $ARGUMENTS`
If the user did not pass a job ID:
- Render the command output as a single Markdown table for the current and past runs in this session.
- Keep it compact. Do not include progress blocks or extra prose outside the table.
- Preserve the actionable fields from the command output, including job ID, kind, status, phase, elapsed or duration, summary, and follow-up commands.
If the user did pass a job ID:
- Present the full command output to the user.
- Do not summarize or condense it.

View File

@ -0,0 +1,38 @@
{
"description": "Optional stop-time review gate for Codex Companion.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionEnd",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"",
"timeout": 900
}
]
}
]
}
}

View File

@ -0,0 +1,83 @@
<role>
You are Codex performing an adversarial software review.
Your job is to break confidence in the change, not to validate it.
</role>
<task>
Review the provided repository context as if you are trying to find the strongest reasons this change should not ship yet.
Target: {{TARGET_LABEL}}
User focus: {{USER_FOCUS}}
</task>
<operating_stance>
Default to skepticism.
Assume the change can fail in subtle, high-cost, or user-visible ways until the evidence says otherwise.
Do not give credit for good intent, partial fixes, or likely follow-up work.
If something only works on the happy path, treat that as a real weakness.
</operating_stance>
<attack_surface>
Prioritize the kinds of failures that are expensive, dangerous, or hard to detect:
- auth, permissions, tenant isolation, and trust boundaries
- data loss, corruption, duplication, and irreversible state changes
- rollback safety, retries, partial failure, and idempotency gaps
- race conditions, ordering assumptions, stale state, and re-entrancy
- empty-state, null, timeout, and degraded dependency behavior
- version skew, schema drift, migration hazards, and compatibility regressions
- observability gaps that would hide failure or make recovery harder
</attack_surface>
<review_method>
Actively try to disprove the change.
Look for violated invariants, missing guards, unhandled failure paths, and assumptions that stop being true under stress.
Trace how bad inputs, retries, concurrent actions, or partially completed operations move through the code.
If the user supplied a focus area, weight it heavily, but still report any other material issue you can defend.
</review_method>
<finding_bar>
Report only material findings.
Do not include style feedback, naming feedback, low-value cleanup, or speculative concerns without evidence.
A finding should answer:
1. What can go wrong?
2. Why is this code path vulnerable?
3. What is the likely impact?
4. What concrete change would reduce the risk?
</finding_bar>
<structured_output_contract>
Return only valid JSON matching the provided schema.
Keep the output compact and specific.
Use `needs-attention` if there is any material risk worth blocking on.
Use `approve` only if you cannot support any substantive adversarial finding from the provided context.
Every finding must include:
- the affected file
- `line_start` and `line_end`
- a confidence score from 0 to 1
- a concrete recommendation
Write the summary like a terse ship/no-ship assessment, not a neutral recap.
</structured_output_contract>
<grounding_rules>
Be aggressive, but stay grounded.
Every finding must be defensible from the provided repository context or tool outputs.
Do not invent files, lines, code paths, incidents, attack chains, or runtime behavior you cannot support.
If a conclusion depends on an inference, state that explicitly in the finding body and keep the confidence honest.
</grounding_rules>
<calibration_rules>
Prefer one strong finding over several weak ones.
Do not dilute serious issues with filler.
If the change looks safe, say so directly and return no findings.
</calibration_rules>
<final_check>
Before finalizing, check that each finding is:
- adversarial rather than stylistic
- tied to a concrete code location
- plausible under a real failure scenario
- actionable for an engineer fixing the issue
</final_check>
<repository_context>
{{REVIEW_INPUT}}
</repository_context>

View File

@ -0,0 +1,36 @@
<task>
Run a stop-gate review of the previous Claude turn.
Only review the work from the previous Claude turn.
Only review it if Claude actually did code changes in that turn.
Pure status, setup, or reporting output does not count as reviewable work.
For example, the output of /codex:setup or /codex:status does not count.
Only direct edits made in that specific turn count.
If the previous Claude turn was only a status update, a summary, a setup/login check, a review result, or output from a command that did not itself make direct edits in that turn, return ALLOW immediately and do no further work.
Challenge whether that specific work and its design choices should ship.
{{CLAUDE_RESPONSE_BLOCK}}
</task>
<compact_output_contract>
Return a compact final answer.
Your first line must be exactly one of:
- ALLOW: <short reason>
- BLOCK: <short reason>
Do not put anything before that first line.
</compact_output_contract>
<default_follow_through_policy>
Use ALLOW if the previous turn did not make code changes or if you do not see a blocking issue.
Use ALLOW immediately, without extra investigation, if the previous turn was not an edit-producing turn.
Use BLOCK only if the previous turn made code changes and you found something that still needs to be fixed before stopping.
</default_follow_through_policy>
<grounding_rules>
Ground every blocking claim in the repository context or tool outputs you inspected during this run.
Do not treat the previous Claude response as proof that code changes happened; verify that from the repository state before you block.
Do not block based on older edits from earlier turns when the immediately previous turn did not itself make direct edits.
</grounding_rules>
<dig_deeper_nudge>
If the previous turn did make code changes, check for second-order failures, empty-state behavior, retries, stale state, rollback risk, and design tradeoffs before you finalize.
</dig_deeper_nudge>

View File

@ -0,0 +1,87 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"verdict",
"summary",
"findings",
"next_steps"
],
"properties": {
"verdict": {
"type": "string",
"enum": [
"approve",
"needs-attention"
]
},
"summary": {
"type": "string",
"minLength": 1
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"severity",
"title",
"body",
"file",
"line_start",
"line_end",
"confidence",
"recommendation"
],
"properties": {
"severity": {
"type": "string",
"enum": [
"critical",
"high",
"medium",
"low"
]
},
"title": {
"type": "string",
"minLength": 1
},
"body": {
"type": "string",
"minLength": 1
},
"file": {
"type": "string",
"minLength": 1
},
"line_start": {
"type": "integer",
"minimum": 1
},
"line_end": {
"type": "integer",
"minimum": 1
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"recommendation": {
"type": "string"
}
}
}
},
"next_steps": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}

View File

@ -0,0 +1,252 @@
#!/usr/bin/env node
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import process from "node:process";
import { parseArgs } from "./lib/args.mjs";
import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs";
import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs";
const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);
function buildStreamThreadIds(method, params, result) {
const threadIds = new Set();
if (params?.threadId) {
threadIds.add(params.threadId);
}
if (method === "review/start" && result?.reviewThreadId) {
threadIds.add(result.reviewThreadId);
}
return threadIds;
}
function buildJsonRpcError(code, message, data) {
return data === undefined ? { code, message } : { code, message, data };
}
function send(socket, message) {
if (socket.destroyed) {
return;
}
socket.write(`${JSON.stringify(message)}\n`);
}
function isInterruptRequest(message) {
return message?.method === "turn/interrupt";
}
function writePidFile(pidFile) {
if (!pidFile) {
return;
}
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, `${process.pid}\n`, "utf8");
}
async function main() {
const [subcommand, ...argv] = process.argv.slice(2);
if (subcommand !== "serve") {
throw new Error("Usage: node scripts/app-server-broker.mjs serve --endpoint <value> [--cwd <path>] [--pid-file <path>]");
}
const { options } = parseArgs(argv, {
valueOptions: ["cwd", "pid-file", "endpoint"]
});
if (!options.endpoint) {
throw new Error("Missing required --endpoint.");
}
const cwd = options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd();
const endpoint = String(options.endpoint);
const listenTarget = parseBrokerEndpoint(endpoint);
const pidFile = options["pid-file"] ? path.resolve(options["pid-file"]) : null;
writePidFile(pidFile);
const appClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
let activeRequestSocket = null;
let activeStreamSocket = null;
let activeStreamThreadIds = null;
const sockets = new Set();
function clearSocketOwnership(socket) {
if (activeRequestSocket === socket) {
activeRequestSocket = null;
}
if (activeStreamSocket === socket) {
activeStreamSocket = null;
activeStreamThreadIds = null;
}
}
function routeNotification(message) {
const target = activeRequestSocket ?? activeStreamSocket;
if (!target) {
return;
}
send(target, message);
if (message.method === "turn/completed" && activeStreamSocket === target) {
const threadId = message.params?.threadId ?? null;
if (!threadId || !activeStreamThreadIds || activeStreamThreadIds.has(threadId)) {
activeStreamSocket = null;
activeStreamThreadIds = null;
if (activeRequestSocket === target) {
activeRequestSocket = null;
}
}
}
}
async function shutdown(server) {
for (const socket of sockets) {
socket.end();
}
await appClient.close().catch(() => {});
await new Promise((resolve) => server.close(resolve));
if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) {
fs.unlinkSync(listenTarget.path);
}
if (pidFile && fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
}
appClient.setNotificationHandler(routeNotification);
const server = net.createServer((socket) => {
sockets.add(socket);
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", async (chunk) => {
buffer += chunk;
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
if (!line.trim()) {
continue;
}
let message;
try {
message = JSON.parse(line);
} catch (error) {
send(socket, {
id: null,
error: buildJsonRpcError(-32700, `Invalid JSON: ${error.message}`)
});
continue;
}
if (message.id !== undefined && message.method === "initialize") {
send(socket, {
id: message.id,
result: {
userAgent: "codex-companion-broker"
}
});
continue;
}
if (message.method === "initialized" && message.id === undefined) {
continue;
}
if (message.id !== undefined && message.method === "broker/shutdown") {
send(socket, { id: message.id, result: {} });
await shutdown(server);
process.exit(0);
}
if (message.id === undefined) {
continue;
}
const allowInterruptDuringActiveStream =
isInterruptRequest(message) && activeStreamSocket && activeStreamSocket !== socket && !activeRequestSocket;
if (
((activeRequestSocket && activeRequestSocket !== socket) || (activeStreamSocket && activeStreamSocket !== socket)) &&
!allowInterruptDuringActiveStream
) {
send(socket, {
id: message.id,
error: buildJsonRpcError(BROKER_BUSY_RPC_CODE, "Shared Codex broker is busy.")
});
continue;
}
if (allowInterruptDuringActiveStream) {
try {
const result = await appClient.request(message.method, message.params ?? {});
send(socket, { id: message.id, result });
} catch (error) {
send(socket, {
id: message.id,
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
});
}
continue;
}
const isStreaming = STREAMING_METHODS.has(message.method);
activeRequestSocket = socket;
try {
const result = await appClient.request(message.method, message.params ?? {});
send(socket, { id: message.id, result });
if (isStreaming) {
activeStreamSocket = socket;
activeStreamThreadIds = buildStreamThreadIds(message.method, message.params ?? {}, result);
}
if (activeRequestSocket === socket) {
activeRequestSocket = null;
}
} catch (error) {
send(socket, {
id: message.id,
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
});
if (activeRequestSocket === socket) {
activeRequestSocket = null;
}
if (activeStreamSocket === socket && !isStreaming) {
activeStreamSocket = null;
}
}
}
});
socket.on("close", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
});
socket.on("error", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
});
});
process.on("SIGTERM", async () => {
await shutdown(server);
process.exit(0);
});
process.on("SIGINT", async () => {
await shutdown(server);
process.exit(0);
});
server.listen(listenTarget.path);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
import type {
ClientInfo,
InitializeCapabilities,
InitializeParams,
InitializeResponse,
ServerNotification
} from "../../.generated/app-server-types/index.js";
import type {
ReviewStartParams,
ReviewStartResponse,
ReviewTarget,
Thread,
ThreadItem,
ThreadListParams,
ThreadListResponse,
ThreadResumeParams as RawThreadResumeParams,
ThreadResumeResponse,
ThreadSetNameParams,
ThreadSetNameResponse,
ThreadStartParams as RawThreadStartParams,
ThreadStartResponse,
Turn,
TurnInterruptParams,
TurnInterruptResponse,
TurnStartParams,
TurnStartResponse,
UserInput
} from "../../.generated/app-server-types/v2/index.js";
export type {
ClientInfo,
InitializeCapabilities,
InitializeParams,
InitializeResponse,
ReviewTarget,
Thread,
ThreadItem,
ThreadListParams,
Turn,
TurnInterruptParams,
TurnStartParams,
UserInput
};
export type ThreadStartParams = Omit<RawThreadStartParams, "persistExtendedHistory">;
export type ThreadResumeParams = Omit<RawThreadResumeParams, "persistExtendedHistory">;
export interface CodexAppServerClientOptions {
env?: NodeJS.ProcessEnv;
clientInfo?: ClientInfo;
capabilities?: InitializeCapabilities;
brokerEndpoint?: string;
disableBroker?: boolean;
}
export interface AppServerMethodMap {
initialize: { params: InitializeParams; result: InitializeResponse };
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
"thread/list": { params: ThreadListParams; result: ThreadListResponse };
"review/start": { params: ReviewStartParams; result: ReviewStartResponse };
"turn/start": { params: TurnStartParams; result: TurnStartResponse };
"turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse };
}
export type AppServerMethod = keyof AppServerMethodMap;
export type AppServerRequestParams<M extends AppServerMethod> = AppServerMethodMap[M]["params"];
export type AppServerResponse<M extends AppServerMethod> = AppServerMethodMap[M]["result"];
export type AppServerNotification = ServerNotification;
export type AppServerNotificationHandler = (message: AppServerNotification) => void;

View File

@ -0,0 +1,332 @@
/**
* @typedef {Error & { data?: unknown, rpcCode?: number }} ProtocolError
* @typedef {import("./app-server-protocol").AppServerMethod} AppServerMethod
* @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification
* @typedef {import("./app-server-protocol").AppServerNotificationHandler} AppServerNotificationHandler
* @typedef {import("./app-server-protocol").ClientInfo} ClientInfo
* @typedef {import("./app-server-protocol").CodexAppServerClientOptions} CodexAppServerClientOptions
* @typedef {import("./app-server-protocol").InitializeCapabilities} InitializeCapabilities
*/
import fs from "node:fs";
import net from "node:net";
import process from "node:process";
import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8"));
export const BROKER_ENDPOINT_ENV = "CODEX_COMPANION_APP_SERVER_ENDPOINT";
export const BROKER_BUSY_RPC_CODE = -32001;
/** @type {ClientInfo} */
const DEFAULT_CLIENT_INFO = {
title: "Codex Plugin",
name: "Claude Code",
version: PLUGIN_MANIFEST.version ?? "0.0.0"
};
/** @type {InitializeCapabilities} */
const DEFAULT_CAPABILITIES = {
experimentalApi: false,
optOutNotificationMethods: [
"item/agentMessage/delta",
"item/reasoning/summaryTextDelta",
"item/reasoning/summaryPartAdded",
"item/reasoning/textDelta"
]
};
function buildJsonRpcError(code, message, data) {
return data === undefined ? { code, message } : { code, message, data };
}
function createProtocolError(message, data) {
const error = /** @type {ProtocolError} */ (new Error(message));
error.data = data;
if (data?.code !== undefined) {
error.rpcCode = data.code;
}
return error;
}
class AppServerClientBase {
constructor(cwd, options = {}) {
this.cwd = cwd;
this.options = options;
this.pending = new Map();
this.nextId = 1;
this.stderr = "";
this.closed = false;
this.exitError = null;
/** @type {AppServerNotificationHandler | null} */
this.notificationHandler = null;
this.lineBuffer = "";
this.transport = "unknown";
this.exitPromise = new Promise((resolve) => {
this.resolveExit = resolve;
});
}
setNotificationHandler(handler) {
this.notificationHandler = handler;
}
/**
* @template {AppServerMethod} M
* @param {M} method
* @param {import("./app-server-protocol").AppServerRequestParams<M>} params
* @returns {Promise<import("./app-server-protocol").AppServerResponse<M>>}
*/
request(method, params) {
if (this.closed) {
throw new Error("codex app-server client is closed.");
}
const id = this.nextId;
this.nextId += 1;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, method });
this.sendMessage({ id, method, params });
});
}
notify(method, params = {}) {
if (this.closed) {
return;
}
this.sendMessage({ method, params });
}
handleChunk(chunk) {
this.lineBuffer += chunk;
let newlineIndex = this.lineBuffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = this.lineBuffer.slice(0, newlineIndex);
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
this.handleLine(line);
newlineIndex = this.lineBuffer.indexOf("\n");
}
}
handleLine(line) {
if (!line.trim()) {
return;
}
let message;
try {
message = JSON.parse(line);
} catch (error) {
this.handleExit(createProtocolError(`Failed to parse codex app-server JSONL: ${error.message}`, { line }));
return;
}
if (message.id !== undefined && message.method) {
this.handleServerRequest(message);
return;
}
if (message.id !== undefined) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(createProtocolError(message.error.message ?? `codex app-server ${pending.method} failed.`, message.error));
} else {
pending.resolve(message.result ?? {});
}
return;
}
if (message.method && this.notificationHandler) {
this.notificationHandler(/** @type {AppServerNotification} */ (message));
}
}
handleServerRequest(message) {
this.sendMessage({
id: message.id,
error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`)
});
}
handleExit(error) {
if (this.exitResolved) {
return;
}
this.exitResolved = true;
this.exitError = error ?? null;
for (const pending of this.pending.values()) {
pending.reject(this.exitError ?? new Error("codex app-server connection closed."));
}
this.pending.clear();
this.resolveExit(undefined);
}
sendMessage(_message) {
throw new Error("sendMessage must be implemented by subclasses.");
}
}
class SpawnedCodexAppServerClient extends AppServerClientBase {
constructor(cwd, options = {}) {
super(cwd, options);
this.transport = "direct";
}
async initialize() {
this.proc = spawn("codex", ["app-server"], {
cwd: this.cwd,
env: this.options.env,
stdio: ["pipe", "pipe", "pipe"]
});
this.proc.stdout.setEncoding("utf8");
this.proc.stderr.setEncoding("utf8");
this.proc.stderr.on("data", (chunk) => {
this.stderr += chunk;
});
this.proc.on("error", (error) => {
this.handleExit(error);
});
this.proc.on("exit", (code, signal) => {
const detail =
code === 0
? null
: createProtocolError(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
this.handleExit(detail);
});
this.readline = readline.createInterface({ input: this.proc.stdout });
this.readline.on("line", (line) => {
this.handleLine(line);
});
await this.request("initialize", {
clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO,
capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES
});
this.notify("initialized", {});
}
async close() {
if (this.closed) {
await this.exitPromise;
return;
}
this.closed = true;
if (this.readline) {
this.readline.close();
}
if (this.proc && !this.proc.killed) {
this.proc.stdin.end();
setTimeout(() => {
if (this.proc && !this.proc.killed) {
this.proc.kill("SIGTERM");
}
}, 50).unref?.();
}
await this.exitPromise;
}
sendMessage(message) {
const line = `${JSON.stringify(message)}\n`;
const stdin = this.proc?.stdin;
if (!stdin) {
throw new Error("codex app-server stdin is not available.");
}
stdin.write(line);
}
}
class BrokerCodexAppServerClient extends AppServerClientBase {
constructor(cwd, options = {}) {
super(cwd, options);
this.transport = "broker";
this.endpoint = options.brokerEndpoint;
}
async initialize() {
await new Promise((resolve, reject) => {
const target = parseBrokerEndpoint(this.endpoint);
this.socket = net.createConnection({ path: target.path });
this.socket.setEncoding("utf8");
this.socket.on("connect", resolve);
this.socket.on("data", (chunk) => {
this.handleChunk(chunk);
});
this.socket.on("error", (error) => {
if (!this.exitResolved) {
reject(error);
}
this.handleExit(error);
});
this.socket.on("close", () => {
this.handleExit(this.exitError);
});
});
await this.request("initialize", {
clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO,
capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES
});
this.notify("initialized", {});
}
async close() {
if (this.closed) {
await this.exitPromise;
return;
}
this.closed = true;
if (this.socket) {
this.socket.end();
}
await this.exitPromise;
}
sendMessage(message) {
const line = `${JSON.stringify(message)}\n`;
const socket = this.socket;
if (!socket) {
throw new Error("codex app-server broker connection is not connected.");
}
socket.write(line);
}
}
export class CodexAppServerClient {
static async connect(cwd, options = {}) {
let brokerEndpoint = null;
if (!options.disableBroker) {
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
if (!brokerEndpoint) {
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
brokerEndpoint = brokerSession?.endpoint ?? null;
}
}
const client = brokerEndpoint
? new BrokerCodexAppServerClient(cwd, { ...options, brokerEndpoint })
: new SpawnedCodexAppServerClient(cwd, options);
await client.initialize();
return client;
}
}

View File

@ -0,0 +1,128 @@
export function parseArgs(argv, config = {}) {
const valueOptions = new Set(config.valueOptions ?? []);
const booleanOptions = new Set(config.booleanOptions ?? []);
const aliasMap = config.aliasMap ?? {};
const options = {};
const positionals = [];
let passthrough = false;
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (passthrough) {
positionals.push(token);
continue;
}
if (token === "--") {
passthrough = true;
continue;
}
if (!token.startsWith("-") || token === "-") {
positionals.push(token);
continue;
}
if (token.startsWith("--")) {
const [rawKey, inlineValue] = token.slice(2).split("=", 2);
const key = aliasMap[rawKey] ?? rawKey;
if (booleanOptions.has(key)) {
options[key] = inlineValue === undefined ? true : inlineValue !== "false";
continue;
}
if (valueOptions.has(key)) {
const nextValue = inlineValue ?? argv[index + 1];
if (nextValue === undefined) {
throw new Error(`Missing value for --${rawKey}`);
}
options[key] = nextValue;
if (inlineValue === undefined) {
index += 1;
}
continue;
}
positionals.push(token);
continue;
}
const shortKey = token.slice(1);
const key = aliasMap[shortKey] ?? shortKey;
if (booleanOptions.has(key)) {
options[key] = true;
continue;
}
if (valueOptions.has(key)) {
const nextValue = argv[index + 1];
if (nextValue === undefined) {
throw new Error(`Missing value for -${shortKey}`);
}
options[key] = nextValue;
index += 1;
continue;
}
positionals.push(token);
}
return { options, positionals };
}
export function splitRawArgumentString(raw) {
const tokens = [];
let current = "";
let quote = null;
let escaping = false;
for (const character of raw) {
if (escaping) {
current += character;
escaping = false;
continue;
}
if (character === "\\") {
escaping = true;
continue;
}
if (quote) {
if (character === quote) {
quote = null;
} else {
current += character;
}
continue;
}
if (character === "'" || character === "\"") {
quote = character;
continue;
}
if (/\s/.test(character)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += character;
}
if (escaping) {
current += "\\";
}
if (current) {
tokens.push(current);
}
return tokens;
}

View File

@ -0,0 +1,41 @@
import path from "node:path";
import process from "node:process";
function sanitizePipeName(value) {
return String(value ?? "")
.replace(/[^A-Za-z0-9._-]/g, "-")
.replace(/^-+|-+$/g, "");
}
export function createBrokerEndpoint(sessionDir, platform = process.platform) {
if (platform === "win32") {
const pipeName = sanitizePipeName(`${path.win32.basename(sessionDir)}-codex-app-server`);
return `pipe:\\\\.\\pipe\\${pipeName}`;
}
return `unix:${path.join(sessionDir, "broker.sock")}`;
}
export function parseBrokerEndpoint(endpoint) {
if (typeof endpoint !== "string" || endpoint.length === 0) {
throw new Error("Missing broker endpoint.");
}
if (endpoint.startsWith("pipe:")) {
const pipePath = endpoint.slice("pipe:".length);
if (!pipePath) {
throw new Error("Broker pipe endpoint is missing its path.");
}
return { kind: "pipe", path: pipePath };
}
if (endpoint.startsWith("unix:")) {
const socketPath = endpoint.slice("unix:".length);
if (!socketPath) {
throw new Error("Broker Unix socket endpoint is missing its path.");
}
return { kind: "unix", path: socketPath };
}
throw new Error(`Unsupported broker endpoint: ${endpoint}`);
}

View File

@ -0,0 +1,209 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { resolveStateDir } from "./state.mjs";
export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE";
export const LOG_FILE_ENV = "CODEX_COMPANION_APP_SERVER_LOG_FILE";
const BROKER_STATE_FILE = "broker.json";
export function createBrokerSessionDir(prefix = "cxc-") {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function connectToEndpoint(endpoint) {
const target = parseBrokerEndpoint(endpoint);
return net.createConnection({ path: target.path });
}
export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const ready = await new Promise((resolve) => {
const socket = connectToEndpoint(endpoint);
socket.on("connect", () => {
socket.end();
resolve(true);
});
socket.on("error", () => resolve(false));
});
if (ready) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
}
export async function sendBrokerShutdown(endpoint) {
await new Promise((resolve) => {
const socket = connectToEndpoint(endpoint);
socket.setEncoding("utf8");
socket.on("connect", () => {
socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params: {} })}\n`);
});
socket.on("data", () => {
socket.end();
resolve();
});
socket.on("error", resolve);
socket.on("close", resolve);
});
}
export function spawnBrokerProcess({ scriptPath, cwd, endpoint, pidFile, logFile, env = process.env }) {
const logFd = fs.openSync(logFile, "a");
const child = spawn(process.execPath, [scriptPath, "serve", "--endpoint", endpoint, "--cwd", cwd, "--pid-file", pidFile], {
cwd,
env,
detached: true,
stdio: ["ignore", logFd, logFd]
});
child.unref();
fs.closeSync(logFd);
return child;
}
function resolveBrokerStateFile(cwd) {
return path.join(resolveStateDir(cwd), BROKER_STATE_FILE);
}
export function loadBrokerSession(cwd) {
const stateFile = resolveBrokerStateFile(cwd);
if (!fs.existsSync(stateFile)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
} catch {
return null;
}
}
export function saveBrokerSession(cwd, session) {
const stateDir = resolveStateDir(cwd);
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(resolveBrokerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, "utf8");
}
export function clearBrokerSession(cwd) {
const stateFile = resolveBrokerStateFile(cwd);
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
}
}
async function isBrokerEndpointReady(endpoint) {
if (!endpoint) {
return false;
}
try {
return await waitForBrokerEndpoint(endpoint, 150);
} catch {
return false;
}
}
export async function ensureBrokerSession(cwd, options = {}) {
const existing = loadBrokerSession(cwd);
if (existing && (await isBrokerEndpointReady(existing.endpoint))) {
return existing;
}
if (existing) {
teardownBrokerSession({
endpoint: existing.endpoint ?? null,
pidFile: existing.pidFile ?? null,
logFile: existing.logFile ?? null,
sessionDir: existing.sessionDir ?? null,
pid: existing.pid ?? null,
killProcess: options.killProcess ?? null
});
clearBrokerSession(cwd);
}
const sessionDir = createBrokerSessionDir();
const endpointFactory = options.createBrokerEndpoint ?? createBrokerEndpoint;
const endpoint = endpointFactory(sessionDir, options.platform);
const pidFile = path.join(sessionDir, "broker.pid");
const logFile = path.join(sessionDir, "broker.log");
const scriptPath =
options.scriptPath ??
fileURLToPath(new URL("../app-server-broker.mjs", import.meta.url));
const child = spawnBrokerProcess({
scriptPath,
cwd,
endpoint,
pidFile,
logFile,
env: options.env ?? process.env
});
const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000);
if (!ready) {
teardownBrokerSession({
endpoint,
pidFile,
logFile,
sessionDir,
pid: child.pid ?? null,
killProcess: options.killProcess ?? null
});
return null;
}
const session = {
endpoint,
pidFile,
logFile,
sessionDir,
pid: child.pid ?? null
};
saveBrokerSession(cwd, session);
return session;
}
export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null }) {
if (Number.isFinite(pid) && killProcess) {
try {
killProcess(pid);
} catch {
// Ignore missing or already-exited broker processes.
}
}
if (pidFile && fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
if (logFile && fs.existsSync(logFile)) {
fs.unlinkSync(logFile);
}
if (endpoint) {
try {
const target = parseBrokerEndpoint(endpoint);
if (target.kind === "unix" && fs.existsSync(target.path)) {
fs.unlinkSync(target.path);
}
} catch {
// Ignore malformed or already-removed broker endpoints during teardown.
}
}
const resolvedSessionDir = sessionDir ?? (pidFile ? path.dirname(pidFile) : logFile ? path.dirname(logFile) : null);
if (resolvedSessionDir && fs.existsSync(resolvedSessionDir)) {
try {
fs.rmdirSync(resolvedSessionDir);
} catch {
// Ignore non-empty or missing directories.
}
}
}

View File

@ -0,0 +1,953 @@
/**
* @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification
* @typedef {import("./app-server-protocol").ReviewTarget} ReviewTarget
* @typedef {import("./app-server-protocol").ThreadItem} ThreadItem
* @typedef {import("./app-server-protocol").ThreadResumeParams} ThreadResumeParams
* @typedef {import("./app-server-protocol").ThreadStartParams} ThreadStartParams
* @typedef {import("./app-server-protocol").Turn} Turn
* @typedef {import("./app-server-protocol").UserInput} UserInput
* @typedef {((update: string | { message: string, phase: string | null, threadId?: string | null, turnId?: string | null, stderrMessage?: string | null, logTitle?: string | null, logBody?: string | null }) => void)} ProgressReporter
* @typedef {{
* threadId: string,
* rootThreadId: string,
* threadIds: Set<string>,
* threadTurnIds: Map<string, string>,
* threadLabels: Map<string, string>,
* turnId: string | null,
* bufferedNotifications: AppServerNotification[],
* completion: Promise<TurnCaptureState>,
* resolveCompletion: (state: TurnCaptureState) => void,
* rejectCompletion: (error: unknown) => void,
* finalTurn: Turn | null,
* completed: boolean,
* finalAnswerSeen: boolean,
* pendingCollaborations: Set<string>,
* activeSubagentTurns: Set<string>,
* completionTimer: ReturnType<typeof setTimeout> | null,
* lastAgentMessage: string,
* reviewText: string,
* reasoningSummary: string[],
* error: unknown,
* messages: Array<{ lifecycle: string, phase: string | null, text: string }>,
* fileChanges: ThreadItem[],
* commandExecutions: ThreadItem[],
* onProgress: ProgressReporter | null
* }} TurnCaptureState
*/
import { readJsonFile } from "./fs.mjs";
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
import { loadBrokerSession } from "./broker-lifecycle.mjs";
import { binaryAvailable, runCommand } from "./process.mjs";
const SERVICE_NAME = "claude_code_codex_plugin";
const TASK_THREAD_PREFIX = "Codex Companion Task";
const DEFAULT_CONTINUE_PROMPT =
"Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
function cleanCodexStderr(stderr) {
return stderr
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line && !line.startsWith("WARNING: proceeding, even though we could not update PATH:"))
.join("\n");
}
/** @returns {ThreadStartParams} */
function buildThreadParams(cwd, options = {}) {
return {
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
sandbox: options.sandbox ?? "read-only",
serviceName: SERVICE_NAME,
ephemeral: options.ephemeral ?? true,
experimentalRawEvents: false
};
}
/** @returns {ThreadResumeParams} */
function buildResumeParams(threadId, cwd, options = {}) {
return {
threadId,
cwd,
model: options.model ?? null,
approvalPolicy: options.approvalPolicy ?? "never",
sandbox: options.sandbox ?? "read-only"
};
}
/** @returns {UserInput[]} */
function buildTurnInput(prompt) {
return [{ type: "text", text: prompt, text_elements: [] }];
}
function shorten(text, limit = 72) {
const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
if (!normalized) {
return "";
}
if (normalized.length <= limit) {
return normalized;
}
return `${normalized.slice(0, limit - 3)}...`;
}
function looksLikeVerificationCommand(command) {
return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
command
);
}
function buildTaskThreadName(prompt) {
const excerpt = shorten(prompt, 56);
return excerpt ? `${TASK_THREAD_PREFIX}: ${excerpt}` : TASK_THREAD_PREFIX;
}
function extractThreadId(message) {
return message?.params?.threadId ?? null;
}
function extractTurnId(message) {
if (message?.params?.turnId) {
return message.params.turnId;
}
if (message?.params?.turn?.id) {
return message.params.turn.id;
}
return null;
}
function collectTouchedFiles(fileChanges) {
const paths = new Set();
for (const fileChange of fileChanges) {
for (const change of fileChange.changes ?? []) {
if (change.path) {
paths.add(change.path);
}
}
}
return [...paths];
}
function normalizeReasoningText(text) {
return String(text ?? "").replace(/\s+/g, " ").trim();
}
function extractReasoningSections(value) {
if (!value) {
return [];
}
if (typeof value === "string") {
const normalized = normalizeReasoningText(value);
return normalized ? [normalized] : [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => extractReasoningSections(entry));
}
if (typeof value === "object") {
if (typeof value.text === "string") {
return extractReasoningSections(value.text);
}
if ("summary" in value) {
return extractReasoningSections(value.summary);
}
if ("content" in value) {
return extractReasoningSections(value.content);
}
if ("parts" in value) {
return extractReasoningSections(value.parts);
}
}
return [];
}
function mergeReasoningSections(existingSections, nextSections) {
const merged = [];
for (const section of [...existingSections, ...nextSections]) {
const normalized = normalizeReasoningText(section);
if (!normalized || merged.includes(normalized)) {
continue;
}
merged.push(normalized);
}
return merged;
}
/**
* @param {ProgressReporter | null | undefined} onProgress
* @param {string | null | undefined} message
* @param {string | null | undefined} [phase]
*/
function emitProgress(onProgress, message, phase = null, extra = {}) {
if (!onProgress || !message) {
return;
}
if (!phase && Object.keys(extra).length === 0) {
onProgress(message);
return;
}
onProgress({ message, phase, ...extra });
}
function emitLogEvent(onProgress, options = {}) {
if (!onProgress) {
return;
}
onProgress({
message: options.message ?? "",
phase: options.phase ?? null,
stderrMessage: options.stderrMessage ?? null,
logTitle: options.logTitle ?? null,
logBody: options.logBody ?? null
});
}
function labelForThread(state, threadId) {
if (!threadId || threadId === state.rootThreadId || threadId === state.threadId) {
return null;
}
return state.threadLabels.get(threadId) ?? threadId;
}
function registerThread(state, threadId, options = {}) {
if (!threadId) {
return;
}
state.threadIds.add(threadId);
const label =
options.threadName ??
options.name ??
options.agentNickname ??
options.agentRole ??
state.threadLabels.get(threadId) ??
null;
if (label) {
state.threadLabels.set(threadId, label);
}
}
function describeStartedItem(state, item) {
switch (item.type) {
case "enteredReviewMode":
return { message: `Reviewer started: ${item.review}`, phase: "reviewing" };
case "commandExecution":
return {
message: `Running command: ${shorten(item.command, 96)}`,
phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running"
};
case "fileChange":
return { message: `Applying ${item.changes.length} file change(s).`, phase: "editing" };
case "mcpToolCall":
return { message: `Calling ${item.server}/${item.tool}.`, phase: "investigating" };
case "dynamicToolCall":
return { message: `Running tool: ${item.tool}.`, phase: "investigating" };
case "collabAgentToolCall": {
const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId);
const summary =
subagents.length > 0
? `Starting subagent ${subagents.join(", ")} via collaboration tool: ${item.tool}.`
: `Starting collaboration tool: ${item.tool}.`;
return { message: summary, phase: "investigating" };
}
case "webSearch":
return { message: `Searching: ${shorten(item.query, 96)}`, phase: "investigating" };
default:
return null;
}
}
function describeCompletedItem(state, item) {
switch (item.type) {
case "commandExecution": {
const exitCode = item.exitCode ?? "?";
const statusLabel = item.status === "completed" ? "completed" : item.status;
return {
message: `Command ${statusLabel}: ${shorten(item.command, 96)} (exit ${exitCode})`,
phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running"
};
}
case "fileChange":
return { message: `File changes ${item.status}.`, phase: "editing" };
case "mcpToolCall":
return { message: `Tool ${item.server}/${item.tool} ${item.status}.`, phase: "investigating" };
case "dynamicToolCall":
return { message: `Tool ${item.tool} ${item.status}.`, phase: "investigating" };
case "collabAgentToolCall": {
const subagents = (item.receiverThreadIds ?? []).map((threadId) => labelForThread(state, threadId) ?? threadId);
const summary =
subagents.length > 0
? `Subagent ${subagents.join(", ")} ${item.status}.`
: `Collaboration tool ${item.tool} ${item.status}.`;
return { message: summary, phase: "investigating" };
}
case "exitedReviewMode":
return { message: "Reviewer finished.", phase: "finalizing" };
default:
return null;
}
}
/** @returns {TurnCaptureState} */
function createTurnCaptureState(threadId, options = {}) {
let resolveCompletion;
let rejectCompletion;
const completion = new Promise((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
return {
threadId,
rootThreadId: threadId,
threadIds: new Set([threadId]),
threadTurnIds: new Map(),
threadLabels: new Map(),
turnId: null,
bufferedNotifications: [],
completion,
resolveCompletion,
rejectCompletion,
finalTurn: null,
completed: false,
finalAnswerSeen: false,
pendingCollaborations: new Set(),
activeSubagentTurns: new Set(),
completionTimer: null,
lastAgentMessage: "",
reviewText: "",
reasoningSummary: [],
error: null,
messages: [],
fileChanges: [],
commandExecutions: [],
onProgress: options.onProgress ?? null
};
}
function clearCompletionTimer(state) {
if (state.completionTimer) {
clearTimeout(state.completionTimer);
state.completionTimer = null;
}
}
function completeTurn(state, turn = null, options = {}) {
if (state.completed) {
return;
}
clearCompletionTimer(state);
state.completed = true;
if (turn) {
state.finalTurn = turn;
if (!state.turnId) {
state.turnId = turn.id;
}
} else if (!state.finalTurn) {
state.finalTurn = {
id: state.turnId ?? "inferred-turn",
status: "completed"
};
}
if (options.inferred) {
emitProgress(state.onProgress, "Turn completion inferred after the main thread finished and subagent work drained.", "finalizing");
}
state.resolveCompletion(state);
}
function scheduleInferredCompletion(state) {
if (state.completed || state.finalTurn || !state.finalAnswerSeen) {
return;
}
if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) {
return;
}
clearCompletionTimer(state);
state.completionTimer = setTimeout(() => {
state.completionTimer = null;
if (state.completed || state.finalTurn || !state.finalAnswerSeen) {
return;
}
if (state.pendingCollaborations.size > 0 || state.activeSubagentTurns.size > 0) {
return;
}
completeTurn(state, null, { inferred: true });
}, 250);
state.completionTimer.unref?.();
}
function belongsToTurn(state, message) {
const messageThreadId = extractThreadId(message);
if (!messageThreadId || !state.threadIds.has(messageThreadId)) {
return false;
}
const trackedTurnId = state.threadTurnIds.get(messageThreadId) ?? null;
const messageTurnId = extractTurnId(message);
return trackedTurnId === null || messageTurnId === null || messageTurnId === trackedTurnId;
}
function recordItem(state, item, lifecycle, threadId = null) {
if (item.type === "collabAgentToolCall") {
if (!threadId || threadId === state.threadId) {
if (lifecycle === "started" || item.status === "inProgress") {
state.pendingCollaborations.add(item.id);
} else if (lifecycle === "completed") {
state.pendingCollaborations.delete(item.id);
scheduleInferredCompletion(state);
}
}
for (const receiverThreadId of item.receiverThreadIds ?? []) {
registerThread(state, receiverThreadId);
}
}
if (item.type === "agentMessage") {
state.messages.push({
lifecycle,
phase: item.phase ?? null,
text: item.text ?? ""
});
if (item.text) {
if (!threadId || threadId === state.threadId) {
state.lastAgentMessage = item.text;
if (lifecycle === "completed" && item.phase === "final_answer") {
state.finalAnswerSeen = true;
scheduleInferredCompletion(state);
}
}
if (lifecycle === "completed") {
const sourceLabel = labelForThread(state, threadId);
emitLogEvent(state.onProgress, {
message: sourceLabel ? `Subagent ${sourceLabel}: ${shorten(item.text, 96)}` : `Assistant message captured: ${shorten(item.text, 96)}`,
stderrMessage: null,
phase: item.phase === "final_answer" ? "finalizing" : null,
logTitle: sourceLabel ? `Subagent ${sourceLabel} message` : "Assistant message",
logBody: item.text
});
}
}
return;
}
if (item.type === "exitedReviewMode") {
state.reviewText = item.review ?? "";
if (lifecycle === "completed" && item.review) {
emitLogEvent(state.onProgress, {
message: "Review output captured.",
stderrMessage: null,
phase: "finalizing",
logTitle: "Review output",
logBody: item.review
});
}
return;
}
if (item.type === "reasoning" && lifecycle === "completed") {
const nextSections = extractReasoningSections(item.summary);
state.reasoningSummary = mergeReasoningSections(state.reasoningSummary, nextSections);
if (nextSections.length > 0) {
const sourceLabel = labelForThread(state, threadId);
emitLogEvent(state.onProgress, {
message: sourceLabel
? `Subagent ${sourceLabel} reasoning: ${shorten(nextSections[0], 96)}`
: `Reasoning summary captured: ${shorten(nextSections[0], 96)}`,
stderrMessage: null,
logTitle: sourceLabel ? `Subagent ${sourceLabel} reasoning summary` : "Reasoning summary",
logBody: nextSections.map((section) => `- ${section}`).join("\n")
});
}
return;
}
if (item.type === "fileChange" && lifecycle === "completed") {
state.fileChanges.push(item);
return;
}
if (item.type === "commandExecution" && lifecycle === "completed") {
state.commandExecutions.push(item);
}
}
function applyTurnNotification(state, message) {
switch (message.method) {
case "thread/started":
registerThread(state, message.params.thread.id, {
threadName: message.params.thread.name,
name: message.params.thread.name,
agentNickname: message.params.thread.agentNickname,
agentRole: message.params.thread.agentRole
});
break;
case "thread/name/updated":
registerThread(state, message.params.threadId, {
threadName: message.params.threadName ?? null
});
break;
case "turn/started":
registerThread(state, message.params.threadId);
state.threadTurnIds.set(message.params.threadId, message.params.turn.id);
if ((message.params.threadId ?? null) !== state.threadId) {
state.activeSubagentTurns.add(message.params.threadId);
}
emitProgress(
state.onProgress,
`Turn started (${message.params.turn.id}).`,
"starting",
(message.params.threadId ?? null) === state.threadId
? {
threadId: message.params.threadId ?? null,
turnId: message.params.turn.id ?? null
}
: {}
);
break;
case "item/started":
recordItem(state, message.params.item, "started", message.params.threadId ?? null);
{
const update = describeStartedItem(state, message.params.item);
emitProgress(state.onProgress, update?.message, update?.phase ?? null);
}
break;
case "item/completed":
recordItem(state, message.params.item, "completed", message.params.threadId ?? null);
{
const update = describeCompletedItem(state, message.params.item);
emitProgress(state.onProgress, update?.message, update?.phase ?? null);
}
break;
case "error":
state.error = message.params.error;
emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed");
break;
case "turn/completed":
if ((message.params.threadId ?? null) !== state.threadId) {
state.activeSubagentTurns.delete(message.params.threadId);
scheduleInferredCompletion(state);
break;
}
emitProgress(
state.onProgress,
`Turn ${message.params.turn.status === "completed" ? "completed" : message.params.turn.status}.`,
"finalizing"
);
completeTurn(state, message.params.turn);
break;
default:
break;
}
}
async function captureTurn(client, threadId, startRequest, options = {}) {
const state = createTurnCaptureState(threadId, options);
const previousHandler = client.notificationHandler;
client.setNotificationHandler((message) => {
if (!state.turnId) {
state.bufferedNotifications.push(message);
return;
}
if (message.method === "thread/started" || message.method === "thread/name/updated") {
applyTurnNotification(state, message);
return;
}
if (!belongsToTurn(state, message)) {
if (previousHandler) {
previousHandler(message);
}
return;
}
applyTurnNotification(state, message);
});
try {
const response = await startRequest();
options.onResponse?.(response, state);
state.turnId = response.turn?.id ?? null;
if (state.turnId) {
state.threadTurnIds.set(state.threadId, state.turnId);
}
for (const message of state.bufferedNotifications) {
if (belongsToTurn(state, message)) {
applyTurnNotification(state, message);
} else {
if (previousHandler) {
previousHandler(message);
}
}
}
state.bufferedNotifications.length = 0;
if (response.turn?.status && response.turn.status !== "inProgress") {
completeTurn(state, response.turn);
}
return await state.completion;
} finally {
clearCompletionTimer(state);
client.setNotificationHandler(previousHandler ?? null);
}
}
async function withAppServer(cwd, fn) {
let client = null;
try {
client = await CodexAppServerClient.connect(cwd);
const result = await fn(client);
await client.close();
return result;
} catch (error) {
const brokerRequested = client?.transport === "broker" || Boolean(process.env[BROKER_ENDPOINT_ENV]);
const shouldRetryDirect =
(client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) ||
(brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED"));
if (client) {
await client.close().catch(() => {});
client = null;
}
if (!shouldRetryDirect) {
throw error;
}
const directClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
try {
return await fn(directClient);
} finally {
await directClient.close();
}
}
}
async function startThread(client, cwd, options = {}) {
const response = await client.request("thread/start", buildThreadParams(cwd, options));
const threadId = response.thread.id;
if (options.threadName) {
await client.request("thread/name/set", { threadId, name: options.threadName });
}
return response;
}
async function resumeThread(client, threadId, cwd, options = {}) {
return client.request("thread/resume", buildResumeParams(threadId, cwd, options));
}
function buildResultStatus(turnState) {
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}
export function getCodexAvailability(cwd) {
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
if (!versionStatus.available) {
return versionStatus;
}
const appServerStatus = binaryAvailable("codex", ["app-server", "--help"], { cwd });
if (!appServerStatus.available) {
return {
available: false,
detail: `${versionStatus.detail}; advanced runtime unavailable: ${appServerStatus.detail}`
};
}
return {
available: true,
detail: `${versionStatus.detail}; advanced runtime available`
};
}
export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) {
const endpoint = env?.[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
if (endpoint) {
return {
mode: "shared",
label: "shared session",
detail: "This Claude session is configured to reuse one shared Codex runtime.",
endpoint
};
}
return {
mode: "direct",
label: "direct startup",
detail: "No shared Codex runtime is active yet. The first review or task command will start one on demand.",
endpoint: null
};
}
export function getCodexLoginStatus(cwd) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
return {
available: false,
loggedIn: false,
detail: availability.detail
};
}
const result = runCommand("codex", ["login", "status"], { cwd });
if (result.error) {
return {
available: true,
loggedIn: false,
detail: result.error.message
};
}
if (result.status === 0) {
return {
available: true,
loggedIn: true,
detail: result.stdout.trim() || "authenticated"
};
}
return {
available: true,
loggedIn: false,
detail: result.stderr.trim() || result.stdout.trim() || "not authenticated"
};
}
export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
if (!threadId || !turnId) {
return {
attempted: false,
interrupted: false,
transport: null,
detail: "missing threadId or turnId"
};
}
const availability = getCodexAvailability(cwd);
if (!availability.available) {
return {
attempted: false,
interrupted: false,
transport: null,
detail: availability.detail
};
}
const brokerEndpoint = process.env[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
let client = null;
try {
client = brokerEndpoint
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
await client.request("turn/interrupt", { threadId, turnId });
return {
attempted: true,
interrupted: true,
transport: client.transport,
detail: `Interrupted ${turnId} on ${threadId}.`
};
} catch (error) {
return {
attempted: true,
interrupted: false,
transport: client?.transport ?? null,
detail: error instanceof Error ? error.message : String(error)
};
} finally {
await client?.close().catch(() => {});
}
}
export async function runAppServerReview(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
}
return withAppServer(cwd, async (client) => {
emitProgress(options.onProgress, "Starting Codex review thread.", "starting");
const thread = await startThread(client, cwd, {
model: options.model,
sandbox: "read-only",
ephemeral: true,
threadName: options.threadName
});
const sourceThreadId = thread.thread.id;
emitProgress(options.onProgress, `Thread ready (${sourceThreadId}).`, "starting", {
threadId: sourceThreadId
});
const delivery = options.delivery ?? "inline";
const turnState = await captureTurn(
client,
sourceThreadId,
() =>
client.request("review/start", {
threadId: sourceThreadId,
delivery,
target: options.target
}),
{
onProgress: options.onProgress,
onResponse(response, state) {
if (response.reviewThreadId) {
state.threadIds.add(response.reviewThreadId);
if (delivery === "detached") {
state.threadId = response.reviewThreadId;
}
}
}
}
);
return {
status: buildResultStatus(turnState),
threadId: turnState.threadId,
sourceThreadId,
turnId: turnState.turnId,
reviewText: turnState.reviewText,
reasoningSummary: turnState.reasoningSummary,
turn: turnState.finalTurn,
error: turnState.error,
stderr: cleanCodexStderr(client.stderr)
};
});
}
export async function runAppServerTurn(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
}
return withAppServer(cwd, async (client) => {
let threadId;
if (options.resumeThreadId) {
emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
const response = await resumeThread(client, options.resumeThreadId, cwd, {
model: options.model,
sandbox: options.sandbox,
ephemeral: false
});
threadId = response.thread.id;
} else {
emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
const response = await startThread(client, cwd, {
model: options.model,
sandbox: options.sandbox,
ephemeral: options.persistThread ? false : true,
threadName: options.persistThread ? options.threadName : options.threadName ?? null
});
threadId = response.thread.id;
}
emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", {
threadId
});
const prompt = options.prompt?.trim() || options.defaultPrompt || "";
if (!prompt) {
throw new Error("A prompt is required for this Codex run.");
}
const turnState = await captureTurn(
client,
threadId,
() =>
client.request("turn/start", {
threadId,
input: buildTurnInput(prompt),
model: options.model ?? null,
effort: options.effort ?? null,
outputSchema: options.outputSchema ?? null
}),
{ onProgress: options.onProgress }
);
return {
status: buildResultStatus(turnState),
threadId,
turnId: turnState.turnId,
finalMessage: turnState.lastAgentMessage,
reasoningSummary: turnState.reasoningSummary,
turn: turnState.finalTurn,
error: turnState.error,
stderr: cleanCodexStderr(client.stderr),
fileChanges: turnState.fileChanges,
touchedFiles: collectTouchedFiles(turnState.fileChanges),
commandExecutions: turnState.commandExecutions
};
});
}
export async function findLatestTaskThread(cwd) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
}
return withAppServer(cwd, async (client) => {
const response = await client.request("thread/list", {
cwd,
limit: 20,
sortKey: "updated_at",
sourceKinds: ["appServer"],
searchTerm: TASK_THREAD_PREFIX
});
return (
response.data.find((thread) => typeof thread.name === "string" && thread.name.startsWith(TASK_THREAD_PREFIX)) ??
null
);
});
}
export function buildPersistentTaskThreadName(prompt) {
return buildTaskThreadName(prompt);
}
export function parseStructuredOutput(rawOutput, fallback = {}) {
if (!rawOutput) {
return {
parsed: null,
parseError: fallback.failureMessage ?? "Codex did not return a final structured message.",
rawOutput: rawOutput ?? "",
...fallback
};
}
try {
return {
parsed: JSON.parse(rawOutput),
parseError: null,
rawOutput,
...fallback
};
} catch (error) {
return {
parsed: null,
parseError: error.message,
rawOutput,
...fallback
};
}
}
export function readOutputSchema(schemaPath) {
return readJsonFile(schemaPath);
}
export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX };

View File

@ -0,0 +1,40 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export function ensureAbsolutePath(cwd, maybePath) {
return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath);
}
export function createTempDir(prefix = "codex-plugin-") {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
export function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
export function writeJsonFile(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
export function safeReadFile(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
}
export function isProbablyText(buffer) {
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
for (const value of sample) {
if (value === 0) {
return false;
}
}
return true;
}
export function readStdinIfPiped() {
if (process.stdin.isTTY) {
return "";
}
return fs.readFileSync(0, "utf8");
}

View File

@ -0,0 +1,209 @@
import fs from "node:fs";
import path from "node:path";
import { isProbablyText } from "./fs.mjs";
import { runCommand, runCommandChecked } from "./process.mjs";
const MAX_UNTRACKED_BYTES = 24 * 1024;
function git(cwd, args, options = {}) {
return runCommand("git", args, { cwd, ...options });
}
function gitChecked(cwd, args, options = {}) {
return runCommandChecked("git", args, { cwd, ...options });
}
export function ensureGitRepository(cwd) {
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
const errorCode = result.error && "code" in result.error ? result.error.code : null;
if (errorCode === "ENOENT") {
throw new Error("git is not installed. Install Git and retry.");
}
if (result.status !== 0) {
throw new Error("This command must run inside a Git repository.");
}
return result.stdout.trim();
}
export function getRepoRoot(cwd) {
return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
}
export function detectDefaultBranch(cwd) {
const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
if (symbolic.status === 0) {
const remoteHead = symbolic.stdout.trim();
if (remoteHead.startsWith("refs/remotes/origin/")) {
return remoteHead.replace("refs/remotes/origin/", "");
}
}
const candidates = ["main", "master", "trunk"];
for (const candidate of candidates) {
const local = git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`]);
if (local.status === 0) {
return candidate;
}
const remote = git(cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`]);
if (remote.status === 0) {
return `origin/${candidate}`;
}
}
throw new Error("Unable to detect the repository default branch. Pass --base <ref> or use --scope working-tree.");
}
export function getCurrentBranch(cwd) {
return gitChecked(cwd, ["branch", "--show-current"]).stdout.trim() || "HEAD";
}
export function getWorkingTreeState(cwd) {
const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean);
return {
staged,
unstaged,
untracked,
isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0
};
}
export function resolveReviewTarget(cwd, options = {}) {
ensureGitRepository(cwd);
const requestedScope = options.scope ?? "auto";
const baseRef = options.base ?? null;
const state = getWorkingTreeState(cwd);
const supportedScopes = new Set(["auto", "working-tree", "branch"]);
if (baseRef) {
return {
mode: "branch",
label: `branch diff against ${baseRef}`,
baseRef,
explicit: true
};
}
if (requestedScope === "working-tree") {
return {
mode: "working-tree",
label: "working tree diff",
explicit: true
};
}
if (!supportedScopes.has(requestedScope)) {
throw new Error(
`Unsupported review scope "${requestedScope}". Use one of: auto, working-tree, branch, or pass --base <ref>.`
);
}
if (requestedScope === "branch") {
const detectedBase = detectDefaultBranch(cwd);
return {
mode: "branch",
label: `branch diff against ${detectedBase}`,
baseRef: detectedBase,
explicit: true
};
}
if (state.isDirty) {
return {
mode: "working-tree",
label: "working tree diff",
explicit: false
};
}
const detectedBase = detectDefaultBranch(cwd);
return {
mode: "branch",
label: `branch diff against ${detectedBase}`,
baseRef: detectedBase,
explicit: false
};
}
function formatSection(title, body) {
return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n");
}
function formatUntrackedFile(cwd, relativePath) {
const absolutePath = path.join(cwd, relativePath);
const stat = fs.statSync(absolutePath);
if (stat.size > MAX_UNTRACKED_BYTES) {
return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
}
const buffer = fs.readFileSync(absolutePath);
if (!isProbablyText(buffer)) {
return `### ${relativePath}\n(skipped: binary file)`;
}
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
}
function collectWorkingTreeContext(cwd, state) {
const status = gitChecked(cwd, ["status", "--short"]).stdout.trim();
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
const parts = [
formatSection("Git Status", status),
formatSection("Staged Diff", stagedDiff),
formatSection("Unstaged Diff", unstagedDiff),
formatSection("Untracked Files", untrackedBody)
];
return {
mode: "working-tree",
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
content: parts.join("\n")
};
}
function collectBranchContext(cwd, baseRef) {
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
const commitRange = `${mergeBase}..HEAD`;
const currentBranch = getCurrentBranch(cwd);
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", commitRange]).stdout.trim();
const diffStat = gitChecked(cwd, ["diff", "--stat", commitRange]).stdout.trim();
const diff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", commitRange]).stdout;
return {
mode: "branch",
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${mergeBase}.`,
content: [
formatSection("Commit Log", logOutput),
formatSection("Diff Stat", diffStat),
formatSection("Branch Diff", diff)
].join("\n")
};
}
export function collectReviewContext(cwd, target) {
const repoRoot = getRepoRoot(cwd);
const state = getWorkingTreeState(cwd);
const currentBranch = getCurrentBranch(cwd);
let details;
if (target.mode === "working-tree") {
details = collectWorkingTreeContext(repoRoot, state);
} else {
details = collectBranchContext(repoRoot, target.baseRef);
}
return {
cwd: repoRoot,
repoRoot,
branch: currentBranch,
target,
...details
};
}

View File

@ -0,0 +1,302 @@
import fs from "node:fs";
import { getSessionRuntimeStatus } from "./codex.mjs";
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "./workspace.mjs";
export const DEFAULT_MAX_STATUS_JOBS = 8;
export const DEFAULT_MAX_PROGRESS_LINES = 4;
export function sortJobsNewestFirst(jobs) {
return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
}
function getCurrentSessionId(options = {}) {
return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null;
}
function filterJobsForCurrentSession(jobs, options = {}) {
const sessionId = getCurrentSessionId(options);
if (!sessionId) {
return jobs;
}
return jobs.filter((job) => job.sessionId === sessionId);
}
function getJobTypeLabel(job) {
if (typeof job.kindLabel === "string" && job.kindLabel) {
return job.kindLabel;
}
if (job.kind === "adversarial-review") {
return "adversarial-review";
}
if (job.jobClass === "review") {
return "review";
}
if (job.jobClass === "task") {
return "rescue";
}
if (job.kind === "review") {
return "review";
}
if (job.kind === "task") {
return "rescue";
}
return "job";
}
function stripLogPrefix(line) {
return line.replace(/^\[[^\]]+\]\s*/, "").trim();
}
function isProgressBlockTitle(line) {
return (
["Final output", "Assistant message", "Reasoning summary", "Review output"].includes(line) ||
/^Subagent .+ message$/.test(line) ||
/^Subagent .+ reasoning summary$/.test(line)
);
}
export function readJobProgressPreview(logFile, maxLines = DEFAULT_MAX_PROGRESS_LINES) {
if (!logFile || !fs.existsSync(logFile)) {
return [];
}
const lines = fs
.readFileSync(logFile, "utf8")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean)
.filter((line) => line.startsWith("["))
.map(stripLogPrefix)
.filter((line) => line && !isProgressBlockTitle(line));
return lines.slice(-maxLines);
}
function formatElapsedDuration(startValue, endValue = null) {
const start = Date.parse(startValue ?? "");
if (!Number.isFinite(start)) {
return null;
}
const end = endValue ? Date.parse(endValue) : Date.now();
if (!Number.isFinite(end) || end < start) {
return null;
}
const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function looksLikeVerificationCommand(line) {
return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
line
);
}
function inferLegacyJobPhase(job, progressPreview = []) {
switch (job.status) {
case "queued":
return "queued";
case "cancelled":
return "cancelled";
case "failed":
return "failed";
case "completed":
return "done";
default:
break;
}
for (let index = progressPreview.length - 1; index >= 0; index -= 1) {
const line = progressPreview[index].toLowerCase();
if (line.startsWith("starting codex") || line.startsWith("thread ready") || line.startsWith("turn started")) {
return "starting";
}
if (line.startsWith("reviewer started") || line.includes("review mode")) {
return "reviewing";
}
if (line.startsWith("searching:") || line.startsWith("calling ") || line.startsWith("running tool:")) {
return "investigating";
}
if (line.startsWith("starting collaboration tool:")) {
return "investigating";
}
if (line.startsWith("running command:")) {
return looksLikeVerificationCommand(line)
? "verifying"
: job.jobClass === "review"
? "reviewing"
: "investigating";
}
if (line.startsWith("command completed:")) {
return looksLikeVerificationCommand(line) ? "verifying" : "running";
}
if (line.startsWith("applying ") || line.startsWith("file changes ")) {
return "editing";
}
if (line.startsWith("turn completed")) {
return "finalizing";
}
if (line.startsWith("codex error:") || line.startsWith("failed:")) {
return "failed";
}
}
return job.jobClass === "review" ? "reviewing" : "running";
}
export function enrichJob(job, options = {}) {
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
const enriched = {
...job,
kindLabel: getJobTypeLabel(job),
progressPreview:
job.status === "queued" || job.status === "running" || job.status === "failed"
? readJobProgressPreview(job.logFile, maxProgressLines)
: [],
elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
duration:
job.status === "completed" || job.status === "failed" || job.status === "cancelled"
? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt)
: null
};
return {
...enriched,
phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview)
};
}
export function readStoredJob(workspaceRoot, jobId) {
const jobFile = resolveJobFile(workspaceRoot, jobId);
if (!fs.existsSync(jobFile)) {
return null;
}
return readJobFile(jobFile);
}
function matchJobReference(jobs, reference, predicate = () => true) {
const filtered = jobs.filter(predicate);
if (!reference) {
return filtered[0] ?? null;
}
const exact = filtered.find((job) => job.id === reference);
if (exact) {
return exact;
}
const prefixMatches = filtered.filter((job) => job.id.startsWith(reference));
if (prefixMatches.length === 1) {
return prefixMatches[0];
}
if (prefixMatches.length > 1) {
throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
}
throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`);
}
export function buildStatusSnapshot(cwd, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), options));
const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
const running = jobs
.filter((job) => job.status === "queued" || job.status === "running")
.map((job) => enrichJob(job, { maxProgressLines }));
const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null;
const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null;
const recent = (options.all ? jobs : jobs.slice(0, maxJobs))
.filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id)
.map((job) => enrichJob(job, { maxProgressLines }));
return {
workspaceRoot,
config,
sessionRuntime: getSessionRuntimeStatus(options.env),
running,
latestFinished,
recent,
needsReview: Boolean(config.stopReviewGate)
};
}
export function buildSingleJobSnapshot(cwd, reference, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const selected = matchJobReference(jobs, reference);
if (!selected) {
throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`);
}
return {
workspaceRoot,
job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
};
}
export function resolveResultJob(cwd, reference) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
const selected = matchJobReference(
jobs,
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
);
if (selected) {
return { workspaceRoot, job: selected };
}
const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
if (active) {
throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`);
}
if (reference) {
throw new Error(`No finished job found for "${reference}". Run /codex:status to inspect active jobs.`);
}
throw new Error("No finished Codex jobs found for this repository yet.");
}
export function resolveCancelableJob(cwd, reference) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
if (reference) {
const selected = matchJobReference(activeJobs, reference);
if (!selected) {
throw new Error(`No active job found for "${reference}".`);
}
return { workspaceRoot, job: selected };
}
if (activeJobs.length === 1) {
return { workspaceRoot, job: activeJobs[0] };
}
if (activeJobs.length > 1) {
throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel.");
}
throw new Error("No active Codex jobs to cancel.");
}

View File

@ -0,0 +1,132 @@
import { spawnSync } from "node:child_process";
import process from "node:process";
export function runCommand(command, args = [], options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: "utf8",
input: options.input,
stdio: options.stdio ?? "pipe"
});
return {
command,
args,
status: result.status ?? 0,
signal: result.signal ?? null,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
error: result.error ?? null
};
}
export function runCommandChecked(command, args = [], options = {}) {
const result = runCommand(command, args, options);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(formatCommandFailure(result));
}
return result;
}
export function binaryAvailable(command, versionArgs = ["--version"], options = {}) {
const result = runCommand(command, versionArgs, options);
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") {
return { available: false, detail: "not found" };
}
if (result.error) {
return { available: false, detail: result.error.message };
}
if (result.status !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
return { available: false, detail };
}
return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" };
}
function looksLikeMissingProcessMessage(text) {
return /not found|no running instance|cannot find|does not exist|no such process/i.test(text);
}
export function terminateProcessTree(pid, options = {}) {
if (!Number.isFinite(pid)) {
return { attempted: false, delivered: false, method: null };
}
const platform = options.platform ?? process.platform;
const runCommandImpl = options.runCommandImpl ?? runCommand;
const killImpl = options.killImpl ?? process.kill.bind(process);
if (platform === "win32") {
const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], {
cwd: options.cwd,
env: options.env
});
if (!result.error && result.status === 0) {
return { attempted: true, delivered: true, method: "taskkill", result };
}
const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) {
return { attempted: true, delivered: false, method: "taskkill", result };
}
if (result.error?.code === "ENOENT") {
try {
killImpl(pid);
return { attempted: true, delivered: true, method: "kill" };
} catch (error) {
if (error?.code === "ESRCH") {
return { attempted: true, delivered: false, method: "kill" };
}
throw error;
}
}
if (result.error) {
throw result.error;
}
throw new Error(formatCommandFailure(result));
}
try {
killImpl(-pid, "SIGTERM");
return { attempted: true, delivered: true, method: "process-group" };
} catch (error) {
if (error?.code !== "ESRCH") {
try {
killImpl(pid, "SIGTERM");
return { attempted: true, delivered: true, method: "process" };
} catch (innerError) {
if (innerError?.code === "ESRCH") {
return { attempted: true, delivered: false, method: "process" };
}
throw innerError;
}
}
return { attempted: true, delivered: false, method: "process-group" };
}
}
export function formatCommandFailure(result) {
const parts = [`${result.command} ${result.args.join(" ")}`.trim()];
if (result.signal) {
parts.push(`signal=${result.signal}`);
} else {
parts.push(`exit=${result.status}`);
}
const stderr = (result.stderr || "").trim();
const stdout = (result.stdout || "").trim();
if (stderr) {
parts.push(stderr);
} else if (stdout) {
parts.push(stdout);
}
return parts.join(": ");
}

View File

@ -0,0 +1,13 @@
import fs from "node:fs";
import path from "node:path";
export function loadPromptTemplate(rootDir, name) {
const promptPath = path.join(rootDir, "prompts", `${name}.md`);
return fs.readFileSync(promptPath, "utf8");
}
export function interpolateTemplate(template, variables) {
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : "";
});
}

View File

@ -0,0 +1,465 @@
function severityRank(severity) {
switch (severity) {
case "critical":
return 0;
case "high":
return 1;
case "medium":
return 2;
default:
return 3;
}
}
function formatLineRange(finding) {
if (!finding.line_start) {
return "";
}
if (!finding.line_end || finding.line_end === finding.line_start) {
return `:${finding.line_start}`;
}
return `:${finding.line_start}-${finding.line_end}`;
}
function validateReviewResultShape(data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return "Expected a top-level JSON object.";
}
if (typeof data.verdict !== "string" || !data.verdict.trim()) {
return "Missing string `verdict`.";
}
if (typeof data.summary !== "string" || !data.summary.trim()) {
return "Missing string `summary`.";
}
if (!Array.isArray(data.findings)) {
return "Missing array `findings`.";
}
if (!Array.isArray(data.next_steps)) {
return "Missing array `next_steps`.";
}
return null;
}
function normalizeReviewFinding(finding, index) {
const source = finding && typeof finding === "object" && !Array.isArray(finding) ? finding : {};
const lineStart = Number.isInteger(source.line_start) && source.line_start > 0 ? source.line_start : null;
const lineEnd =
Number.isInteger(source.line_end) && source.line_end > 0 && (!lineStart || source.line_end >= lineStart)
? source.line_end
: lineStart;
return {
severity: typeof source.severity === "string" && source.severity.trim() ? source.severity.trim() : "low",
title: typeof source.title === "string" && source.title.trim() ? source.title.trim() : `Finding ${index + 1}`,
body: typeof source.body === "string" && source.body.trim() ? source.body.trim() : "No details provided.",
file: typeof source.file === "string" && source.file.trim() ? source.file.trim() : "unknown",
line_start: lineStart,
line_end: lineEnd,
recommendation: typeof source.recommendation === "string" ? source.recommendation.trim() : ""
};
}
function normalizeReviewResultData(data) {
return {
verdict: data.verdict.trim(),
summary: data.summary.trim(),
findings: data.findings.map((finding, index) => normalizeReviewFinding(finding, index)),
next_steps: data.next_steps
.filter((step) => typeof step === "string" && step.trim())
.map((step) => step.trim())
};
}
function isStructuredReviewStoredResult(storedJob) {
const result = storedJob?.result;
if (!result || typeof result !== "object" || Array.isArray(result)) {
return false;
}
return (
Object.prototype.hasOwnProperty.call(result, "result") ||
Object.prototype.hasOwnProperty.call(result, "parseError")
);
}
function formatJobLine(job) {
const parts = [job.id, `${job.status || "unknown"}`];
if (job.kindLabel) {
parts.push(job.kindLabel);
}
if (job.title) {
parts.push(job.title);
}
return parts.join(" | ");
}
function escapeMarkdownCell(value) {
return String(value ?? "")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, " ")
.trim();
}
function formatCodexResumeCommand(job) {
if (!job?.threadId) {
return null;
}
return `codex resume ${job.threadId}`;
}
function appendActiveJobsTable(lines, jobs) {
lines.push("Active jobs:");
lines.push("| Job | Kind | Status | Phase | Elapsed | Codex Session ID | Summary | Actions |");
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
for (const job of jobs) {
const actions = [`/codex:status ${job.id}`];
if (job.status === "queued" || job.status === "running") {
actions.push(`/codex:cancel ${job.id}`);
}
lines.push(
`| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? "")} | ${escapeMarkdownCell(job.threadId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("<br>")} |`
);
}
}
function pushJobDetails(lines, job, options = {}) {
lines.push(`- ${formatJobLine(job)}`);
if (job.summary) {
lines.push(` Summary: ${job.summary}`);
}
if (job.phase) {
lines.push(` Phase: ${job.phase}`);
}
if (options.showElapsed && job.elapsed) {
lines.push(` Elapsed: ${job.elapsed}`);
}
if (options.showDuration && job.duration) {
lines.push(` Duration: ${job.duration}`);
}
if (job.threadId) {
lines.push(` Codex session ID: ${job.threadId}`);
}
const resumeCommand = formatCodexResumeCommand(job);
if (resumeCommand) {
lines.push(` Resume in Codex: ${resumeCommand}`);
}
if (job.logFile && options.showLog) {
lines.push(` Log: ${job.logFile}`);
}
if ((job.status === "queued" || job.status === "running") && options.showCancelHint) {
lines.push(` Cancel: /codex:cancel ${job.id}`);
}
if (job.status !== "queued" && job.status !== "running" && options.showResultHint) {
lines.push(` Result: /codex:result ${job.id}`);
}
if (job.status !== "queued" && job.status !== "running" && job.jobClass === "task" && job.write && options.showReviewHint) {
lines.push(" Review changes: /codex:review --wait");
lines.push(" Stricter review: /codex:adversarial-review --wait");
}
if (job.progressPreview?.length) {
lines.push(" Progress:");
for (const line of job.progressPreview) {
lines.push(` ${line}`);
}
}
}
function appendReasoningSection(lines, reasoningSummary) {
if (!Array.isArray(reasoningSummary) || reasoningSummary.length === 0) {
return;
}
lines.push("", "Reasoning:");
for (const section of reasoningSummary) {
lines.push(`- ${section}`);
}
}
export function renderSetupReport(report) {
const lines = [
"# Codex Setup",
"",
`Status: ${report.ready ? "ready" : "needs attention"}`,
"",
"Checks:",
`- node: ${report.node.detail}`,
`- npm: ${report.npm.detail}`,
`- codex: ${report.codex.detail}`,
`- auth: ${report.auth.detail}`,
`- session runtime: ${report.sessionRuntime.label}`,
`- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`,
""
];
if (report.actionsTaken.length > 0) {
lines.push("Actions taken:");
for (const action of report.actionsTaken) {
lines.push(`- ${action}`);
}
lines.push("");
}
if (report.nextSteps.length > 0) {
lines.push("Next steps:");
for (const step of report.nextSteps) {
lines.push(`- ${step}`);
}
}
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderReviewResult(parsedResult, meta) {
if (!parsedResult.parsed) {
const lines = [
`# Codex ${meta.reviewLabel}`,
"",
"Codex did not return valid structured JSON.",
"",
`- Parse error: ${parsedResult.parseError}`
];
if (parsedResult.rawOutput) {
lines.push("", "Raw final message:", "", "```text", parsedResult.rawOutput, "```");
}
appendReasoningSection(lines, meta.reasoningSummary ?? parsedResult.reasoningSummary);
return `${lines.join("\n").trimEnd()}\n`;
}
const validationError = validateReviewResultShape(parsedResult.parsed);
if (validationError) {
const lines = [
`# Codex ${meta.reviewLabel}`,
"",
`Target: ${meta.targetLabel}`,
"Codex returned JSON with an unexpected review shape.",
"",
`- Validation error: ${validationError}`
];
if (parsedResult.rawOutput) {
lines.push("", "Raw final message:", "", "```text", parsedResult.rawOutput, "```");
}
appendReasoningSection(lines, meta.reasoningSummary ?? parsedResult.reasoningSummary);
return `${lines.join("\n").trimEnd()}\n`;
}
const data = normalizeReviewResultData(parsedResult.parsed);
const findings = [...data.findings].sort((left, right) => severityRank(left.severity) - severityRank(right.severity));
const lines = [
`# Codex ${meta.reviewLabel}`,
"",
`Target: ${meta.targetLabel}`,
`Verdict: ${data.verdict}`,
"",
data.summary,
""
];
if (findings.length === 0) {
lines.push("No material findings.");
} else {
lines.push("Findings:");
for (const finding of findings) {
const lineSuffix = formatLineRange(finding);
lines.push(`- [${finding.severity}] ${finding.title} (${finding.file}${lineSuffix})`);
lines.push(` ${finding.body}`);
if (finding.recommendation) {
lines.push(` Recommendation: ${finding.recommendation}`);
}
}
}
if (data.next_steps.length > 0) {
lines.push("", "Next steps:");
for (const step of data.next_steps) {
lines.push(`- ${step}`);
}
}
appendReasoningSection(lines, meta.reasoningSummary);
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderNativeReviewResult(result, meta) {
const stdout = result.stdout.trim();
const stderr = result.stderr.trim();
const lines = [
`# Codex ${meta.reviewLabel}`,
"",
`Target: ${meta.targetLabel}`,
""
];
if (stdout) {
lines.push(stdout);
} else if (result.status === 0) {
lines.push("Codex review completed without any stdout output.");
} else {
lines.push("Codex review failed.");
}
if (stderr) {
lines.push("", "stderr:", "", "```text", stderr, "```");
}
appendReasoningSection(lines, meta.reasoningSummary);
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderTaskResult(parsedResult, meta) {
const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : "";
if (rawOutput) {
return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`;
}
const message = String(parsedResult?.failureMessage ?? "").trim() || "Codex did not return a final message.";
return `${message}\n`;
}
export function renderStatusReport(report) {
const lines = [
"# Codex Status",
"",
`Session runtime: ${report.sessionRuntime.label}`,
`Review gate: ${report.config.stopReviewGate ? "enabled" : "disabled"}`,
""
];
if (report.running.length > 0) {
appendActiveJobsTable(lines, report.running);
lines.push("");
lines.push("Live details:");
for (const job of report.running) {
pushJobDetails(lines, job, {
showElapsed: true,
showLog: true
});
}
lines.push("");
}
if (report.latestFinished) {
lines.push("Latest finished:");
pushJobDetails(lines, report.latestFinished, {
showDuration: true,
showLog: report.latestFinished.status === "failed"
});
lines.push("");
}
if (report.recent.length > 0) {
lines.push("Recent jobs:");
for (const job of report.recent) {
pushJobDetails(lines, job, {
showDuration: true,
showLog: job.status === "failed"
});
}
lines.push("");
} else if (report.running.length === 0 && !report.latestFinished) {
lines.push("No jobs recorded yet.", "");
}
if (report.needsReview) {
lines.push("The stop-time review gate is enabled.");
lines.push("Ending the session will trigger a fresh Codex adversarial review and block if it finds issues.");
}
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderJobStatusReport(job) {
const lines = ["# Codex Job Status", ""];
pushJobDetails(lines, job, {
showElapsed: job.status === "queued" || job.status === "running",
showDuration: job.status !== "queued" && job.status !== "running",
showLog: true,
showCancelHint: true,
showResultHint: true,
showReviewHint: true
});
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderStoredJobResult(job, storedJob) {
const threadId = storedJob?.threadId ?? job.threadId ?? null;
const resumeCommand = threadId ? `codex resume ${threadId}` : null;
if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) {
const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`;
if (!threadId) {
return output;
}
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
}
const rawOutput =
(typeof storedJob?.result?.rawOutput === "string" && storedJob.result.rawOutput) ||
(typeof storedJob?.result?.codex?.stdout === "string" && storedJob.result.codex.stdout) ||
"";
if (rawOutput) {
const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`;
if (!threadId) {
return output;
}
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
}
if (storedJob?.rendered) {
const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`;
if (!threadId) {
return output;
}
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
}
const lines = [
`# ${job.title ?? "Codex Result"}`,
"",
`Job: ${job.id}`,
`Status: ${job.status}`
];
if (threadId) {
lines.push(`Codex session ID: ${threadId}`);
lines.push(`Resume in Codex: ${resumeCommand}`);
}
if (job.summary) {
lines.push(`Summary: ${job.summary}`);
}
if (job.errorMessage) {
lines.push("", job.errorMessage);
} else if (storedJob?.errorMessage) {
lines.push("", storedJob.errorMessage);
} else {
lines.push("", "No captured result payload was stored for this job.");
}
return `${lines.join("\n").trimEnd()}\n`;
}
export function renderCancelReport(job) {
const lines = [
"# Codex Cancel",
"",
`Cancelled ${job.id}.`,
""
];
if (job.title) {
lines.push(`- Title: ${job.title}`);
}
if (job.summary) {
lines.push(`- Summary: ${job.summary}`);
}
lines.push("- Check `/codex:status` for the updated queue.");
return `${lines.join("\n").trimEnd()}\n`;
}

View File

@ -0,0 +1,191 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveWorkspaceRoot } from "./workspace.mjs";
const STATE_VERSION = 1;
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
const MAX_JOBS = 50;
function nowIso() {
return new Date().toISOString();
}
function defaultState() {
return {
version: STATE_VERSION,
config: {
stopReviewGate: false
},
jobs: []
};
}
export function resolveStateDir(cwd) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
let canonicalWorkspaceRoot = workspaceRoot;
try {
canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot);
} catch {
canonicalWorkspaceRoot = workspaceRoot;
}
const slugSource = path.basename(workspaceRoot) || "workspace";
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
return path.join(stateRoot, `${slug}-${hash}`);
}
export function resolveStateFile(cwd) {
return path.join(resolveStateDir(cwd), STATE_FILE_NAME);
}
export function resolveJobsDir(cwd) {
return path.join(resolveStateDir(cwd), JOBS_DIR_NAME);
}
export function ensureStateDir(cwd) {
fs.mkdirSync(resolveJobsDir(cwd), { recursive: true });
}
export function loadState(cwd) {
const stateFile = resolveStateFile(cwd);
if (!fs.existsSync(stateFile)) {
return defaultState();
}
try {
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
return {
...defaultState(),
...parsed,
config: {
...defaultState().config,
...(parsed.config ?? {})
},
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
};
} catch {
return defaultState();
}
}
function pruneJobs(jobs) {
return [...jobs]
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
.slice(0, MAX_JOBS);
}
function removeFileIfExists(filePath) {
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
export function saveState(cwd, state) {
const previousJobs = loadState(cwd).jobs;
ensureStateDir(cwd);
const nextJobs = pruneJobs(state.jobs ?? []);
const nextState = {
version: STATE_VERSION,
config: {
...defaultState().config,
...(state.config ?? {})
},
jobs: nextJobs
};
const retainedIds = new Set(nextJobs.map((job) => job.id));
for (const job of previousJobs) {
if (retainedIds.has(job.id)) {
continue;
}
removeJobFile(resolveJobFile(cwd, job.id));
removeFileIfExists(job.logFile);
}
fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
return nextState;
}
export function updateState(cwd, mutate) {
const state = loadState(cwd);
mutate(state);
return saveState(cwd, state);
}
export function generateJobId(prefix = "job") {
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${Date.now().toString(36)}-${random}`;
}
export function upsertJob(cwd, jobPatch) {
return updateState(cwd, (state) => {
const timestamp = nowIso();
const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
if (existingIndex === -1) {
state.jobs.unshift({
createdAt: timestamp,
updatedAt: timestamp,
...jobPatch
});
return;
}
state.jobs[existingIndex] = {
...state.jobs[existingIndex],
...jobPatch,
updatedAt: timestamp
};
});
}
export function listJobs(cwd) {
return loadState(cwd).jobs;
}
export function setConfig(cwd, key, value) {
return updateState(cwd, (state) => {
state.config = {
...state.config,
[key]: value
};
});
}
export function getConfig(cwd) {
return loadState(cwd).config;
}
export function writeJobFile(cwd, jobId, payload) {
ensureStateDir(cwd);
const jobFile = resolveJobFile(cwd, jobId);
fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return jobFile;
}
export function readJobFile(jobFile) {
return JSON.parse(fs.readFileSync(jobFile, "utf8"));
}
function removeJobFile(jobFile) {
if (fs.existsSync(jobFile)) {
fs.unlinkSync(jobFile);
}
}
export function resolveJobLogFile(cwd, jobId) {
ensureStateDir(cwd);
return path.join(resolveJobsDir(cwd), `${jobId}.log`);
}
export function resolveJobFile(cwd, jobId) {
ensureStateDir(cwd);
return path.join(resolveJobsDir(cwd), `${jobId}.json`);
}

View File

@ -0,0 +1,204 @@
import fs from "node:fs";
import process from "node:process";
import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs";
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
export function nowIso() {
return new Date().toISOString();
}
function normalizeProgressEvent(value) {
if (value && typeof value === "object" && !Array.isArray(value)) {
return {
message: String(value.message ?? "").trim(),
phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null,
threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null,
turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null,
stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
};
}
return {
message: String(value ?? "").trim(),
phase: null,
threadId: null,
turnId: null,
stderrMessage: String(value ?? "").trim(),
logTitle: null,
logBody: null
};
}
export function appendLogLine(logFile, message) {
const normalized = String(message ?? "").trim();
if (!logFile || !normalized) {
return;
}
fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8");
}
export function appendLogBlock(logFile, title, body) {
if (!logFile || !body) {
return;
}
fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8");
}
export function createJobLogFile(workspaceRoot, jobId, title) {
const logFile = resolveJobLogFile(workspaceRoot, jobId);
fs.writeFileSync(logFile, "", "utf8");
if (title) {
appendLogLine(logFile, `Starting ${title}.`);
}
return logFile;
}
export function createJobRecord(base, options = {}) {
const env = options.env ?? process.env;
const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV];
return {
...base,
createdAt: nowIso(),
...(sessionId ? { sessionId } : {})
};
}
export function createJobProgressUpdater(workspaceRoot, jobId) {
let lastPhase = null;
let lastThreadId = null;
let lastTurnId = null;
return (event) => {
const normalized = normalizeProgressEvent(event);
const patch = { id: jobId };
let changed = false;
if (normalized.phase && normalized.phase !== lastPhase) {
lastPhase = normalized.phase;
patch.phase = normalized.phase;
changed = true;
}
if (normalized.threadId && normalized.threadId !== lastThreadId) {
lastThreadId = normalized.threadId;
patch.threadId = normalized.threadId;
changed = true;
}
if (normalized.turnId && normalized.turnId !== lastTurnId) {
lastTurnId = normalized.turnId;
patch.turnId = normalized.turnId;
changed = true;
}
if (!changed) {
return;
}
upsertJob(workspaceRoot, patch);
const jobFile = resolveJobFile(workspaceRoot, jobId);
if (!fs.existsSync(jobFile)) {
return;
}
const storedJob = readJobFile(jobFile);
writeJobFile(workspaceRoot, jobId, {
...storedJob,
...patch
});
};
}
export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) {
if (!stderr && !logFile && !onEvent) {
return null;
}
return (eventOrMessage) => {
const event = normalizeProgressEvent(eventOrMessage);
const stderrMessage = event.stderrMessage ?? event.message;
if (stderr && stderrMessage) {
process.stderr.write(`[codex] ${stderrMessage}\n`);
}
appendLogLine(logFile, event.message);
appendLogBlock(logFile, event.logTitle, event.logBody);
onEvent?.(event);
};
}
function readStoredJobOrNull(workspaceRoot, jobId) {
const jobFile = resolveJobFile(workspaceRoot, jobId);
if (!fs.existsSync(jobFile)) {
return null;
}
return readJobFile(jobFile);
}
export async function runTrackedJob(job, runner, options = {}) {
const runningRecord = {
...job,
status: "running",
startedAt: nowIso(),
phase: "starting",
pid: process.pid,
logFile: options.logFile ?? job.logFile ?? null
};
writeJobFile(job.workspaceRoot, job.id, runningRecord);
upsertJob(job.workspaceRoot, runningRecord);
try {
const execution = await runner();
const completionStatus = execution.exitStatus === 0 ? "completed" : "failed";
const completedAt = nowIso();
writeJobFile(job.workspaceRoot, job.id, {
...runningRecord,
status: completionStatus,
threadId: execution.threadId ?? null,
turnId: execution.turnId ?? null,
pid: null,
phase: completionStatus === "completed" ? "done" : "failed",
completedAt,
result: execution.payload,
rendered: execution.rendered
});
upsertJob(job.workspaceRoot, {
id: job.id,
status: completionStatus,
threadId: execution.threadId ?? null,
turnId: execution.turnId ?? null,
summary: execution.summary,
phase: completionStatus === "completed" ? "done" : "failed",
pid: null,
completedAt
});
appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered);
return execution;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord;
const completedAt = nowIso();
writeJobFile(job.workspaceRoot, job.id, {
...existing,
status: "failed",
phase: "failed",
errorMessage,
pid: null,
completedAt,
logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null
});
upsertJob(job.workspaceRoot, {
id: job.id,
status: "failed",
phase: "failed",
pid: null,
errorMessage,
completedAt
});
throw error;
}
}

View File

@ -0,0 +1,9 @@
import { ensureGitRepository } from "./git.mjs";
export function resolveWorkspaceRoot(cwd) {
try {
return ensureGitRepository(cwd);
} catch {
return cwd;
}
}

View File

@ -0,0 +1,131 @@
#!/usr/bin/env node
import fs from "node:fs";
import process from "node:process";
import { terminateProcessTree } from "./lib/process.mjs";
import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs";
import {
clearBrokerSession,
LOG_FILE_ENV,
loadBrokerSession,
PID_FILE_ENV,
sendBrokerShutdown,
teardownBrokerSession
} from "./lib/broker-lifecycle.mjs";
import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) {
return {};
}
return JSON.parse(raw);
}
function shellEscape(value) {
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
}
function appendEnvVar(name, value) {
if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
return;
}
fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
}
function cleanupSessionJobs(cwd, sessionId) {
if (!cwd || !sessionId) {
return;
}
const workspaceRoot = resolveWorkspaceRoot(cwd);
const stateFile = resolveStateFile(workspaceRoot);
if (!fs.existsSync(stateFile)) {
return;
}
const state = loadState(workspaceRoot);
const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId);
if (removedJobs.length === 0) {
return;
}
for (const job of removedJobs) {
const stillRunning = job.status === "queued" || job.status === "running";
if (!stillRunning) {
continue;
}
try {
terminateProcessTree(job.pid ?? Number.NaN);
} catch {
// Ignore teardown failures during session shutdown.
}
}
saveState(workspaceRoot, {
...state,
jobs: state.jobs.filter((job) => job.sessionId !== sessionId)
});
}
function handleSessionStart(input) {
appendEnvVar(SESSION_ID_ENV, input.session_id);
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
}
async function handleSessionEnd(input) {
const cwd = input.cwd || process.cwd();
const brokerSession =
loadBrokerSession(cwd) ??
(process.env[BROKER_ENDPOINT_ENV]
? {
endpoint: process.env[BROKER_ENDPOINT_ENV],
pidFile: process.env[PID_FILE_ENV] ?? null,
logFile: process.env[LOG_FILE_ENV] ?? null
}
: null);
const brokerEndpoint = brokerSession?.endpoint ?? null;
const pidFile = brokerSession?.pidFile ?? null;
const logFile = brokerSession?.logFile ?? null;
const sessionDir = brokerSession?.sessionDir ?? null;
const pid = brokerSession?.pid ?? null;
if (brokerEndpoint) {
await sendBrokerShutdown(brokerEndpoint);
}
cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]);
teardownBrokerSession({
endpoint: brokerEndpoint,
pidFile,
logFile,
sessionDir,
pid,
killProcess: terminateProcessTree
});
clearBrokerSession(cwd);
}
async function main() {
const input = readHookInput();
const eventName = process.argv[2] ?? input.hook_event_name ?? "";
if (eventName === "SessionStart") {
handleSessionStart(input);
return;
}
if (eventName === "SessionEnd") {
await handleSessionEnd(input);
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@ -0,0 +1,178 @@
#!/usr/bin/env node
import fs from "node:fs";
import process from "node:process";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { getCodexLoginStatus } from "./lib/codex.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import { getConfig, listJobs } from "./lib/state.mjs";
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) {
return {};
}
return JSON.parse(raw);
}
function emitDecision(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
function logNote(message) {
if (!message) {
return;
}
process.stderr.write(`${message}\n`);
}
function filterJobsForCurrentSession(jobs, input = {}) {
const sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
if (!sessionId) {
return jobs;
}
return jobs.filter((job) => job.sessionId === sessionId);
}
function buildStopReviewPrompt(input = {}) {
const lastAssistantMessage = String(input.last_assistant_message ?? "").trim();
const template = loadPromptTemplate(ROOT_DIR, "stop-review-gate");
const claudeResponseBlock = lastAssistantMessage
? ["Previous Claude response:", lastAssistantMessage].join("\n")
: "";
return interpolateTemplate(template, {
CLAUDE_RESPONSE_BLOCK: claudeResponseBlock
});
}
function buildSetupNote(cwd) {
const authStatus = getCodexLoginStatus(cwd);
if (authStatus.available && authStatus.loggedIn) {
return null;
}
const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
}
function parseStopReviewOutput(rawOutput) {
const text = String(rawOutput ?? "").trim();
if (!text) {
return {
ok: false,
reason:
"The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate."
};
}
const firstLine = text.split(/\r?\n/, 1)[0].trim();
if (firstLine.startsWith("ALLOW:")) {
return { ok: true, reason: null };
}
if (firstLine.startsWith("BLOCK:")) {
const reason = firstLine.slice("BLOCK:".length).trim() || text;
return {
ok: false,
reason: `Codex stop-time review found issues that still need fixes before ending the session: ${reason}`
};
}
return {
ok: false,
reason:
"The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate."
};
}
function runStopReview(cwd, input = {}) {
const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs");
const prompt = buildStopReviewPrompt(input);
const childEnv = {
...process.env,
...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {})
};
const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], {
cwd,
env: childEnv,
encoding: "utf8",
timeout: STOP_REVIEW_TIMEOUT_MS
});
if (result.error?.code === "ETIMEDOUT") {
return {
ok: false,
reason:
"The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate."
};
}
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || "").trim();
return {
ok: false,
reason: detail
? `The stop-time Codex review task failed: ${detail}`
: "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate."
};
}
try {
const payload = JSON.parse(result.stdout);
return parseStopReviewOutput(payload?.rawOutput);
} catch {
return {
ok: false,
reason:
"The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate."
};
}
}
function main() {
const input = readHookInput();
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input));
const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running");
const runningTaskNote = runningJob
? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.`
: null;
if (!config.stopReviewGate) {
logNote(runningTaskNote);
return;
}
const setupNote = buildSetupNote(cwd);
if (setupNote) {
logNote(setupNote);
logNote(runningTaskNote);
return;
}
const review = runStopReview(cwd, input);
if (!review.ok) {
emitDecision({
decision: "block",
reason: runningTaskNote ? `${runningTaskNote} ${review.reason}` : review.reason
});
return;
}
logNote(runningTaskNote);
}
main();

View File

@ -0,0 +1,43 @@
---
name: codex-cli-runtime
description: Internal helper contract for calling the codex-companion runtime from Claude Code
user-invocable: false
---
# Codex Runtime
Use this skill only inside the `codex:codex-rescue` subagent.
Primary helper:
- `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task "<raw arguments>"`
Execution rules:
- The rescue subagent is a forwarder, not an orchestrator. Its only job is to invoke `task` once and return that stdout unchanged.
- Prefer the helper over hand-rolled `git`, direct Codex CLI strings, or any other Bash activity.
- Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel` from `codex:codex-rescue`.
- Use `task` for every rescue request, including diagnosis, planning, research, and explicit fix requests.
- You may use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt before the single `task` call.
- That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text.
- Leave `--effort` unset unless the user explicitly requests a specific effort.
- Leave model unset by default. Add `--model` only when the user explicitly asks for one.
- Map `spark` to `--model gpt-5.3-codex-spark`.
- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
Command selection:
- Use exactly one `task` invocation per rescue handoff.
- If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text.
- If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`.
- If the forwarded request includes `--effort`, pass it through to `task`.
- If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`.
- If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`.
- `--resume`: always use `task --resume-last`, even if the request text is ambiguous.
- `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up.
- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`.
- `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run.
Safety rules:
- Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior.
- Preserve the user's task text as-is apart from stripping routing flags.
- Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own.
- Return the stdout of the `task` command exactly as-is.
- If the Bash call fails or Codex cannot be invoked, return nothing.

View File

@ -0,0 +1,21 @@
---
name: codex-result-handling
description: Internal guidance for presenting Codex helper output back to the user
user-invocable: false
---
# Codex Result Handling
When the helper returns Codex output:
- Preserve the helper's verdict, summary, findings, and next steps structure.
- For review output, present findings first and keep them ordered by severity.
- Use the file paths and line numbers exactly as the helper reports them.
- Preserve evidence boundaries. If Codex marked something as an inference, uncertainty, or follow-up question, keep that distinction.
- Preserve output sections when the prompt asked for them, such as observed facts, inferences, open questions, touched files, or next steps.
- If there are no findings, say that explicitly and keep the residual-risk note brief.
- If Codex made edits, say so explicitly and list the touched files when the helper provides them.
- For `codex:codex-rescue`, do not turn a failed or incomplete Codex run into a Claude-side implementation attempt. Report the failure and stop.
- For `codex:codex-rescue`, if Codex was never successfully invoked, do not generate a substitute answer at all.
- CRITICAL: After presenting review findings, STOP. Do not make any code changes. Do not fix any issues. You MUST explicitly ask the user which issues, if any, they want fixed before touching a single file. Auto-applying fixes from a review is strictly forbidden, even if the fix is obvious.
- If the helper reports malformed output or a failed Codex run, include the most actionable stderr lines and stop there instead of guessing.
- If the helper reports that setup or authentication is required, direct the user to `/codex:setup` and do not improvise alternate auth flows.

View File

@ -0,0 +1,54 @@
---
name: gpt-5-4-prompting
description: Internal guidance for composing Codex and GPT-5.4 prompts for coding, review, diagnosis, and research tasks inside the Codex Claude Code plugin
user-invocable: false
---
# GPT-5.4 Prompting
Use this skill when `codex:codex-rescue` needs to ask Codex or another GPT-5.4-based workflow for help.
Prompt Codex like an operator, not a collaborator. Keep prompts compact and block-structured with XML tags. State the task, the output contract, the follow-through defaults, and the small set of extra constraints that matter.
Core rules:
- Prefer one clear task per Codex run. Split unrelated asks into separate runs.
- Tell Codex what done looks like. Do not assume it will infer the desired end state.
- Add explicit grounding and verification rules for any task where unsupported guesses would hurt quality.
- Prefer better prompt contracts over raising reasoning or adding long natural-language explanations.
- Use XML tags consistently so the prompt has stable internal structure.
Default prompt recipe:
- `<task>`: the concrete job and the relevant repository or failure context.
- `<structured_output_contract>` or `<compact_output_contract>`: exact shape, ordering, and brevity requirements.
- `<default_follow_through_policy>`: what Codex should do by default instead of asking routine questions.
- `<verification_loop>` or `<completeness_contract>`: required for debugging, implementation, or risky fixes.
- `<grounding_rules>` or `<citation_rules>`: required for review, research, or anything that could drift into unsupported claims.
When to add blocks:
- Coding or debugging: add `completeness_contract`, `verification_loop`, and `missing_context_gating`.
- Review or adversarial review: add `grounding_rules`, `structured_output_contract`, and `dig_deeper_nudge`.
- Research or recommendation tasks: add `research_mode` and `citation_rules`.
- Write-capable tasks: add `action_safety` so Codex stays narrow and avoids unrelated refactors.
How to choose prompt shape:
- Use built-in `review` or `adversarial-review` commands when the job is reviewing local git changes. Those prompts already carry the review contract.
- Use `task` when the task is diagnosis, planning, research, or implementation and you need to control the prompt more directly.
- Use `task --resume-last` for follow-up instructions on the same Codex thread. Send only the delta instruction instead of restating the whole prompt unless the direction changed materially.
Working rules:
- Prefer explicit prompt contracts over vague nudges.
- Use stable XML tag names that match the block names from the reference file.
- Do not raise reasoning or complexity first. Tighten the prompt and verification rules before escalating.
- Ask Codex for brief, outcome-based progress updates only when the task is long-running or tool-heavy.
- Keep claims anchored to observed evidence. If something is a hypothesis, say so.
Prompt assembly checklist:
1. Define the exact task and scope in `<task>`.
2. Choose the smallest output contract that still makes the answer easy to use.
3. Decide whether Codex should keep going by default or stop for missing high-risk details.
4. Add verification, grounding, and safety tags only where the task needs them.
5. Remove redundant instructions before sending the prompt.
Reusable blocks live in [references/prompt-blocks.md](references/prompt-blocks.md).
Concrete end-to-end templates live in [references/codex-prompt-recipes.md](references/codex-prompt-recipes.md).
Common failure modes to avoid live in [references/codex-prompt-antipatterns.md](references/codex-prompt-antipatterns.md).

View File

@ -0,0 +1,100 @@
# Codex Prompt Anti-Patterns
Avoid these when prompting Codex or GPT-5.4.
## Vague task framing
Bad:
```text
Take a look at this and let me know what you think.
```
Better:
```xml
<task>
Review this change for material correctness and regression risks.
</task>
```
## Missing output contract
Bad:
```text
Investigate and report back.
```
Better:
```xml
<structured_output_contract>
Return:
1. root cause
2. evidence
3. smallest safe next step
</structured_output_contract>
```
## No follow-through default
Bad:
```text
Debug this failure.
```
Better:
```xml
<default_follow_through_policy>
Keep going until you have enough evidence to identify the root cause confidently.
</default_follow_through_policy>
```
## Asking for more reasoning instead of a better contract
Bad:
```text
Think harder and be very smart.
```
Better:
```xml
<verification_loop>
Before finalizing, verify that the answer matches the observed evidence and task requirements.
</verification_loop>
```
## Mixing unrelated jobs into one run
Bad:
```text
Review this diff, fix the bug you find, update the docs, and suggest a roadmap.
```
Better:
- Run review first.
- Run a separate fix prompt if needed.
- Use a third run for docs or roadmap work.
## Unsupported certainty
Bad:
```text
Tell me exactly why production failed.
```
Better:
```xml
<grounding_rules>
Ground every claim in the provided context or tool outputs.
If a point is an inference, label it clearly.
</grounding_rules>
```

View File

@ -0,0 +1,150 @@
# Codex Prompt Recipes
Use these as starting templates for Codex task prompts or other Codex/GPT-5.4 prompt construction.
Copy the smallest recipe that fits the task, then trim anything you do not need.
In `codex:codex-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior.
## Diagnosis
```xml
<task>
Diagnose why the failing test or command is breaking in this repository.
Use the available repository context and tools to identify the most likely root cause.
</task>
<compact_output_contract>
Return a compact diagnosis with:
1. most likely root cause
2. evidence
3. smallest safe next step
</compact_output_contract>
<default_follow_through_policy>
Keep going until you have enough evidence to identify the root cause confidently.
Only stop to ask questions when a missing detail changes correctness materially.
</default_follow_through_policy>
<verification_loop>
Before finalizing, verify that the proposed root cause matches the observed evidence.
</verification_loop>
<missing_context_gating>
Do not guess missing repository facts.
If required context is absent, state exactly what remains unknown.
</missing_context_gating>
```
## Narrow Fix
```xml
<task>
Implement the smallest safe fix for the identified issue in this repository.
Preserve existing behavior outside the failing path.
</task>
<structured_output_contract>
Return:
1. summary of the fix
2. touched files
3. verification performed
4. residual risks or follow-ups
</structured_output_contract>
<default_follow_through_policy>
Default to the most reasonable low-risk interpretation and keep going.
</default_follow_through_policy>
<completeness_contract>
Resolve the task fully before stopping.
Do not stop after identifying the issue without applying the fix.
</completeness_contract>
<verification_loop>
Before finalizing, verify that the fix matches the task requirements and that the changed code is coherent.
</verification_loop>
<action_safety>
Keep changes tightly scoped to the stated task.
Avoid unrelated refactors or cleanup.
</action_safety>
```
## Root-Cause Review
```xml
<task>
Analyze this change for the most likely correctness or regression issues.
Focus on the provided repository context only.
</task>
<structured_output_contract>
Return:
1. findings ordered by severity
2. supporting evidence for each finding
3. brief next steps
</structured_output_contract>
<grounding_rules>
Ground every claim in the repository context or tool outputs.
If a point is an inference, label it clearly.
</grounding_rules>
<dig_deeper_nudge>
Check for second-order failures, empty-state handling, retries, stale state, and rollback paths before finalizing.
</dig_deeper_nudge>
<verification_loop>
Before finalizing, verify that each finding is material and actionable.
</verification_loop>
```
## Research Or Recommendation
```xml
<task>
Research the available options and recommend the best path for this task.
</task>
<structured_output_contract>
Return:
1. observed facts
2. reasoned recommendation
3. tradeoffs
4. open questions
</structured_output_contract>
<research_mode>
Separate observed facts, reasoned inferences, and open questions.
Prefer breadth first, then go deeper only where the evidence changes the recommendation.
</research_mode>
<citation_rules>
Back important claims with explicit references to the sources you inspected.
Prefer primary sources.
</citation_rules>
```
## Prompt-Patching
```xml
<task>
Diagnose why this existing prompt is underperforming and propose the smallest high-leverage changes to improve it for Codex or GPT-5.4.
</task>
<structured_output_contract>
Return:
1. failure modes
2. root causes in the current prompt
3. a revised prompt
4. why the revision should work better
</structured_output_contract>
<grounding_rules>
Base your diagnosis on the prompt text and the failure examples provided.
Do not invent failure modes that are not supported by the examples.
</grounding_rules>
<verification_loop>
Before finalizing, make sure the revised prompt resolves the cited failure modes without adding contradictory instructions.
</verification_loop>
```

View File

@ -0,0 +1,172 @@
# Prompt Blocks
Use these blocks selectively when composing Codex or GPT-5.4 prompts.
Wrap each block in the XML tag shown in its heading.
## Core Wrapper
### `task`
Use in nearly every prompt.
```xml
<task>
Describe the concrete job, the relevant repository or failure context, and the expected end state.
</task>
```
## Output and Format
### `structured_output_contract`
Use when the response shape matters.
```xml
<structured_output_contract>
Return exactly the requested output shape and nothing else.
Keep the answer compact.
Put the highest-value findings or decisions first.
</structured_output_contract>
```
### `compact_output_contract`
Use when you want concise prose instead of a schema.
```xml
<compact_output_contract>
Keep the final answer compact and structured.
Do not include long scene-setting or repeated recap.
</compact_output_contract>
```
## Follow-through and Completion
### `default_follow_through_policy`
Use when Codex should act without asking routine questions.
```xml
<default_follow_through_policy>
Default to the most reasonable low-risk interpretation and keep going.
Only stop to ask questions when a missing detail changes correctness, safety, or an irreversible action.
</default_follow_through_policy>
```
### `completeness_contract`
Use for debugging, implementation, or any multi-step task that should not stop early.
```xml
<completeness_contract>
Resolve the task fully before stopping.
Do not stop at the first plausible answer.
Check whether there are follow-on fixes, edge cases, or cleanup needed for a correct result.
</completeness_contract>
```
### `verification_loop`
Use when correctness matters.
```xml
<verification_loop>
Before finalizing, verify the result against the task requirements and the changed files or tool outputs.
If a check fails, revise the answer instead of reporting the first draft.
</verification_loop>
```
## Grounding and Missing Context
### `missing_context_gating`
Use when Codex might otherwise guess.
```xml
<missing_context_gating>
Do not guess missing repository facts.
If required context is absent, retrieve it with tools or state exactly what remains unknown.
</missing_context_gating>
```
### `grounding_rules`
Use for review, research, or root-cause analysis.
```xml
<grounding_rules>
Ground every claim in the provided context or your tool outputs.
Do not present inferences as facts.
If a point is a hypothesis, label it clearly.
</grounding_rules>
```
### `citation_rules`
Use when external research or quotes matter.
```xml
<citation_rules>
Back important claims with citations or explicit references to the source material you inspected.
Prefer primary sources.
</citation_rules>
```
## Safety and Scope
### `action_safety`
Use for write-capable or potentially broad tasks.
```xml
<action_safety>
Keep changes tightly scoped to the stated task.
Avoid unrelated refactors, renames, or cleanup unless they are required for correctness.
Call out any risky or irreversible action before taking it.
</action_safety>
```
### `tool_persistence_rules`
Use for long-running tool-heavy tasks.
```xml
<tool_persistence_rules>
Keep using tools until you have enough evidence to finish the task confidently.
Do not abandon the workflow after a partial read when another targeted check would change the answer.
</tool_persistence_rules>
```
## Task-Specific Blocks
### `research_mode`
Use for exploration, comparisons, or recommendations.
```xml
<research_mode>
Separate observed facts, reasoned inferences, and open questions.
Prefer breadth first, then go deeper only where the evidence changes the recommendation.
</research_mode>
```
### `dig_deeper_nudge`
Use for review and adversarial inspection.
```xml
<dig_deeper_nudge>
After you find the first plausible issue, check for second-order failures, empty-state behavior, retries, stale state, and rollback paths before you finalize.
</dig_deeper_nudge>
```
### `progress_updates`
Use when the run may take a while.
```xml
<progress_updates>
If you provide progress updates, keep them brief and outcome-based.
Mention only major phase changes or blockers.
</progress_updates>
```

View File

@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs";
test("createBrokerEndpoint uses Unix sockets on non-Windows platforms", () => {
const endpoint = createBrokerEndpoint("/tmp/cxc-12345", "darwin");
assert.equal(endpoint, "unix:/tmp/cxc-12345/broker.sock");
assert.deepEqual(parseBrokerEndpoint(endpoint), {
kind: "unix",
path: "/tmp/cxc-12345/broker.sock"
});
});
test("createBrokerEndpoint uses named pipes on Windows", () => {
const endpoint = createBrokerEndpoint("C:\\\\Temp\\\\cxc-12345", "win32");
assert.equal(endpoint, "pipe:\\\\.\\pipe\\cxc-12345-codex-app-server");
assert.deepEqual(parseBrokerEndpoint(endpoint), {
kind: "pipe",
path: "\\\\.\\pipe\\cxc-12345-codex-app-server"
});
});

208
tests/commands.test.mjs Normal file
View File

@ -0,0 +1,208 @@
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
const ROOT = "/Users/dkundel/code/codex-plugin";
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");
function read(relativePath) {
return fs.readFileSync(path.join(PLUGIN_ROOT, relativePath), "utf8");
}
test("review command uses AskUserQuestion and background Bash while staying review-only", () => {
const source = read("commands/review.md");
assert.match(source, /AskUserQuestion/);
assert.match(source, /\bBash\(/);
assert.match(source, /Do not fix issues/i);
assert.match(source, /review-only/i);
assert.match(source, /return Codex's output verbatim to the user/i);
assert.match(source, /```bash/);
assert.match(source, /```typescript/);
assert.match(source, /review "\$ARGUMENTS"/);
assert.match(source, /\[--scope auto\|working-tree\|branch\]/);
assert.match(source, /run_in_background:\s*true/);
assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" review "\$ARGUMENTS"`/);
assert.match(source, /description:\s*"Codex review"/);
assert.match(source, /Do not call `BashOutput`/);
assert.match(source, /Return the command stdout verbatim, exactly as-is/i);
assert.match(source, /git status --short --untracked-files=all/);
assert.match(source, /git diff --shortstat/);
assert.match(source, /Treat untracked files or directories as reviewable work/i);
assert.match(source, /Recommend waiting only when the review is clearly tiny, roughly 1-2 files total/i);
assert.match(source, /In every other case, including unclear size, recommend background/i);
assert.match(source, /The companion script parses `--wait` and `--background`/i);
assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i);
assert.match(source, /When in doubt, run the review/i);
assert.match(source, /\(Recommended\)/);
assert.match(source, /does not support staged-only review, unstaged-only review, or extra focus text/i);
});
test("adversarial review command uses AskUserQuestion and background Bash while staying review-only", () => {
const source = read("commands/adversarial-review.md");
assert.match(source, /AskUserQuestion/);
assert.match(source, /\bBash\(/);
assert.match(source, /Do not fix issues/i);
assert.match(source, /review-only/i);
assert.match(source, /return Codex's output verbatim to the user/i);
assert.match(source, /```bash/);
assert.match(source, /```typescript/);
assert.match(source, /adversarial-review "\$ARGUMENTS"/);
assert.match(source, /\[--scope auto\|working-tree\|branch\] \[focus \.\.\.\]/);
assert.match(source, /run_in_background:\s*true/);
assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" adversarial-review "\$ARGUMENTS"`/);
assert.match(source, /description:\s*"Codex adversarial review"/);
assert.match(source, /Do not call `BashOutput`/);
assert.match(source, /Return the command stdout verbatim, exactly as-is/i);
assert.match(source, /git status --short --untracked-files=all/);
assert.match(source, /git diff --shortstat/);
assert.match(source, /Treat untracked files or directories as reviewable work/i);
assert.match(source, /Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total/i);
assert.match(source, /In every other case, including unclear size, recommend background/i);
assert.match(source, /The companion script parses `--wait` and `--background`/i);
assert.match(source, /Claude Code's `Bash\(..., run_in_background: true\)` is what actually detaches the run/i);
assert.match(source, /When in doubt, run the review/i);
assert.match(source, /\(Recommended\)/);
assert.match(source, /uses the same review target selection as `\/codex:review`/i);
assert.match(source, /supports working-tree review, branch review, and `--base <ref>`/i);
assert.match(source, /does not support `--scope staged` or `--scope unstaged`/i);
assert.match(source, /can still take extra focus text after the flags/i);
});
test("continue is not exposed as a user-facing command", () => {
const commandFiles = fs.readdirSync(path.join(PLUGIN_ROOT, "commands")).sort();
assert.deepEqual(commandFiles, [
"adversarial-review.md",
"cancel.md",
"rescue.md",
"result.md",
"review.md",
"setup.md",
"status.md"
]);
});
test("rescue command absorbs continue semantics", () => {
const rescue = read("commands/rescue.md");
const agent = read("agents/codex-rescue.md");
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
assert.match(rescue, /The final user-visible response must be Codex's output verbatim/i);
assert.match(rescue, /--background\|--wait/);
assert.match(rescue, /--resume\|--fresh/);
assert.match(rescue, /--model <model\|spark>/);
assert.match(rescue, /--effort <none\|minimal\|low\|medium\|high\|xhigh>/);
assert.match(rescue, /task-resume-candidate --json/);
assert.match(rescue, /AskUserQuestion/);
assert.match(rescue, /Continue current Codex thread/);
assert.match(rescue, /Start a new Codex thread/);
assert.match(rescue, /run the `codex:codex-rescue` subagent in the background/i);
assert.match(rescue, /default to foreground/i);
assert.match(rescue, /Do not forward them to `task`/i);
assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i);
assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i);
assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i);
assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i);
assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i);
assert.match(rescue, /If the user chooses continue, add `--resume`/i);
assert.match(rescue, /If the user chooses a new thread, add `--fresh`/i);
assert.match(rescue, /thin forwarder only/i);
assert.match(rescue, /Return the Codex companion stdout verbatim to the user/i);
assert.match(rescue, /Do not paraphrase, summarize, rewrite, or add commentary before or after it/i);
assert.match(rescue, /return that command's stdout as-is/i);
assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i);
assert.match(agent, /--resume/);
assert.match(agent, /--fresh/);
assert.match(agent, /thin forwarding wrapper/i);
assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i);
assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i);
assert.match(agent, /Use exactly one `Bash` call/i);
assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i);
assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i);
assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i);
assert.match(agent, /Leave model unset by default/i);
assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i);
assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i);
assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i);
assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
assert.match(agent, /gpt-5-4-prompting/);
assert.match(agent, /only to tighten the user's request into a better Codex prompt/i);
assert.match(agent, /Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work/i);
assert.match(runtimeSkill, /only job is to invoke `task` once and return that stdout unchanged/i);
assert.match(runtimeSkill, /Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel`/i);
assert.match(runtimeSkill, /use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt/i);
assert.match(runtimeSkill, /That prompt drafting is the only Claude-side work allowed/i);
assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i);
assert.match(runtimeSkill, /Leave model unset by default/i);
assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i);
assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i);
assert.match(runtimeSkill, /Strip it before calling `task`/i);
assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i);
assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i);
assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
assert.match(readme, /`codex:codex-rescue` subagent/i);
assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i);
assert.match(readme, /--model gpt-5\.4-mini --effort medium/i);
assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i);
assert.match(readme, /continue a previous Codex task/i);
assert.match(readme, /### `\/codex:setup`/);
assert.match(readme, /### `\/codex:review`/);
assert.match(readme, /### `\/codex:adversarial-review`/);
assert.match(readme, /uses the same review target selection as `\/codex:review`/i);
assert.match(readme, /--base main challenge whether this was the right caching and retry design/);
assert.match(readme, /### `\/codex:rescue`/);
assert.match(readme, /### `\/codex:status`/);
assert.match(readme, /### `\/codex:result`/);
assert.match(readme, /### `\/codex:cancel`/);
});
test("result and cancel commands are exposed as deterministic runtime entrypoints", () => {
const result = read("commands/result.md");
const cancel = read("commands/cancel.md");
const resultHandling = read("skills/codex-result-handling/SKILL.md");
assert.match(result, /disable-model-invocation:\s*true/);
assert.match(result, /codex-companion\.mjs" result \$ARGUMENTS/);
assert.match(cancel, /disable-model-invocation:\s*true/);
assert.match(cancel, /codex-companion\.mjs" cancel \$ARGUMENTS/);
assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i);
assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i);
});
test("internal docs use task terminology for rescue runs", () => {
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
const promptingSkill = read("skills/gpt-5-4-prompting/SKILL.md");
const promptRecipes = read("skills/gpt-5-4-prompting/references/codex-prompt-recipes.md");
assert.match(runtimeSkill, /codex-companion\.mjs" task "<raw arguments>"/);
assert.match(runtimeSkill, /Use `task` for every rescue request/i);
assert.match(runtimeSkill, /task --resume-last/i);
assert.match(promptingSkill, /Use `task` when the task is diagnosis/i);
assert.match(promptRecipes, /Codex task prompts/i);
assert.match(promptRecipes, /Use these as starting templates for Codex task prompts/i);
assert.match(promptRecipes, /## Diagnosis/);
assert.match(promptRecipes, /## Narrow Fix/);
});
test("hooks keep session-end cleanup and stop gating enabled", () => {
const source = read("hooks/hooks.json");
assert.match(source, /SessionStart/);
assert.match(source, /SessionEnd/);
assert.match(source, /stop-review-gate-hook\.mjs/);
assert.match(source, /session-lifecycle-hook\.mjs/);
});
test("setup command can offer Codex install and still points users to codex login", () => {
const setup = read("commands/setup.md");
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/);
assert.match(setup, /AskUserQuestion/);
assert.match(setup, /npm install -g @openai\/codex/);
assert.match(setup, /codex-companion\.mjs" setup --json \$ARGUMENTS/);
assert.match(readme, /!codex login/);
assert.match(readme, /offer to install Codex for you/i);
assert.match(readme, /\/codex:setup --enable-review-gate/);
assert.match(readme, /\/codex:setup --disable-review-gate/);
});

View File

@ -0,0 +1,517 @@
import path from "node:path";
import { writeExecutable } from "./helpers.mjs";
export function installFakeCodex(binDir, behavior = "review-ok") {
const statePath = path.join(binDir, "fake-codex-state.json");
const scriptPath = path.join(binDir, "codex");
const source = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const STATE_PATH = ${JSON.stringify(statePath)};
const BEHAVIOR = ${JSON.stringify(behavior)};
const interruptibleTurns = new Map();
function loadState() {
if (!fs.existsSync(STATE_PATH)) {
return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null };
}
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
}
function saveState(state) {
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
function requiresExperimental(field, message, state) {
if (!(field in (message.params || {}))) {
return false;
}
return !state.capabilities || state.capabilities.experimentalApi !== true;
}
function now() {
return Math.floor(Date.now() / 1000);
}
function buildThread(thread) {
return {
id: thread.id,
preview: thread.preview || "",
ephemeral: Boolean(thread.ephemeral),
modelProvider: "openai",
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
status: { type: "idle" },
path: null,
cwd: thread.cwd,
cliVersion: "fake-codex",
source: "appServer",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: thread.name || null,
turns: []
};
}
function buildTurn(id, status = "inProgress", error = null) {
return { id, status, items: [], error };
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function nextThread(state, cwd, ephemeral) {
const thread = {
id: "thr_" + state.nextThreadId++,
cwd: cwd || process.cwd(),
name: null,
preview: "",
ephemeral: Boolean(ephemeral),
createdAt: now(),
updatedAt: now()
};
state.threads.unshift(thread);
saveState(state);
return thread;
}
function ensureThread(state, threadId) {
const thread = state.threads.find((candidate) => candidate.id === threadId);
if (!thread) {
throw new Error("unknown thread " + threadId);
}
return thread;
}
function nextTurnId(state) {
const turnId = "turn_" + state.nextTurnId++;
saveState(state);
return turnId;
}
function emitTurnCompleted(threadId, turnId, item) {
const items = Array.isArray(item) ? item : [item];
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
for (const entry of items) {
if (entry && entry.started) {
send({ method: "item/started", params: { threadId, turnId, item: entry.started } });
}
if (entry && entry.completed) {
send({ method: "item/completed", params: { threadId, turnId, item: entry.completed } });
}
}
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } });
}
function emitTurnCompletedLater(threadId, turnId, item, delayMs) {
setTimeout(() => {
emitTurnCompleted(threadId, turnId, item);
}, delayMs);
}
function nativeReviewText(target) {
if (target.type === "baseBranch") {
return "Reviewed changes against " + target.branch + ".\\nNo material issues found.";
}
if (target.type === "custom") {
return "Reviewed custom target.\\nNo material issues found.";
}
return "Reviewed uncommitted changes.\\nNo material issues found.";
}
function structuredReviewPayload(prompt) {
if (prompt.includes("adversarial software review")) {
if (BEHAVIOR === "adversarial-clean") {
return JSON.stringify({
verdict: "approve",
summary: "No material issues found.",
findings: [],
next_steps: []
});
}
return JSON.stringify({
verdict: "needs-attention",
summary: "One adversarial concern surfaced.",
findings: [
{
severity: "high",
title: "Missing empty-state guard",
body: "The change assumes data is always present.",
file: "src/app.js",
line_start: 4,
line_end: 6,
confidence: 0.87,
recommendation: "Handle empty collections before indexing."
}
],
next_steps: ["Add an empty-state test."]
});
}
if (BEHAVIOR === "invalid-json") {
return "not valid json";
}
return JSON.stringify({
verdict: "approve",
summary: "No material issues found.",
findings: [],
next_steps: []
});
}
function taskPayload(prompt, resume) {
if (prompt.includes("<task>") && prompt.includes("Only review the work from the previous Claude turn.")) {
if (BEHAVIOR === "adversarial-clean") {
return "ALLOW: No blocking issues found in the previous turn.";
}
return "BLOCK: Missing empty-state guard in src/app.js:4-6.";
}
if (resume || prompt.includes("Continue from the current thread state") || prompt.includes("follow up")) {
return "Resumed the prior run.\\nFollow-up prompt accepted.";
}
return "Handled the requested task.\\nTask prompt accepted.";
}
const args = process.argv.slice(2);
if (args[0] === "--version") {
console.log("codex-cli test");
process.exit(0);
}
if (args[0] === "app-server" && args[1] === "--help") {
console.log("fake app-server help");
process.exit(0);
}
if (args[0] === "login" && args[1] === "status") {
if (BEHAVIOR === "logged-out") {
console.error("not authenticated");
process.exit(1);
}
console.log("logged in");
process.exit(0);
}
if (args[0] === "login") {
process.exit(0);
}
if (args[0] !== "app-server") {
process.exit(1);
}
const bootState = loadState();
bootState.appServerStarts = (bootState.appServerStarts || 0) + 1;
saveState(bootState);
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) {
return;
}
const message = JSON.parse(line);
const state = loadState();
try {
switch (message.method) {
case "initialize":
state.capabilities = message.params.capabilities || null;
saveState(state);
send({ id: message.id, result: { userAgent: "fake-codex-app-server" } });
break;
case "initialized":
break;
case "thread/start": {
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
}
const thread = nextThread(state, message.params.cwd, message.params.ephemeral);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
send({ method: "thread/started", params: { thread: { id: thread.id } } });
break;
}
case "thread/name/set": {
const thread = ensureThread(state, message.params.threadId);
thread.name = message.params.name;
thread.updatedAt = now();
saveState(state);
send({ id: message.id, result: {} });
break;
}
case "thread/list": {
let threads = state.threads.slice();
if (message.params.cwd) {
threads = threads.filter((thread) => thread.cwd === message.params.cwd);
}
if (message.params.searchTerm) {
threads = threads.filter((thread) => (thread.name || "").includes(message.params.searchTerm));
}
threads.sort((left, right) => right.updatedAt - left.updatedAt);
send({ id: message.id, result: { data: threads.map(buildThread), nextCursor: null } });
break;
}
case "thread/resume": {
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/resume.persistFullHistory requires experimentalApi capability");
}
const thread = ensureThread(state, message.params.threadId);
thread.updatedAt = now();
saveState(state);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
break;
}
case "review/start": {
const thread = ensureThread(state, message.params.threadId);
let reviewThread = thread;
if (message.params.delivery === "detached") {
reviewThread = nextThread(state, thread.cwd, true);
send({ method: "thread/started", params: { thread: { id: reviewThread.id } } });
}
const turnId = nextTurnId(state);
send({ id: message.id, result: { turn: buildTurn(turnId), reviewThreadId: reviewThread.id } });
emitTurnCompleted(reviewThread.id, turnId, [
{
started: { type: "enteredReviewMode", id: turnId, review: "current changes" }
},
...(BEHAVIOR === "with-reasoning"
? [
{
completed: {
type: "reasoning",
id: "reasoning_" + turnId,
summary: [{ text: "Reviewed the changed files and checked the likely regression paths." }],
content: []
}
}
]
: []),
{
completed: { type: "exitedReviewMode", id: turnId, review: nativeReviewText(message.params.target) }
}
]);
break;
}
case "turn/start": {
const thread = ensureThread(state, message.params.threadId);
const prompt = (message.params.input || [])
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\\n");
const turnId = nextTurnId(state);
thread.updatedAt = now();
state.lastTurnStart = {
threadId: message.params.threadId,
turnId,
model: message.params.model ?? null,
effort: message.params.effort ?? null,
prompt
};
saveState(state);
send({ id: message.id, result: { turn: buildTurn(turnId) } });
const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict
? structuredReviewPayload(prompt)
: taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state"));
if (
BEHAVIOR === "with-subagent" ||
BEHAVIOR === "with-late-subagent-message" ||
BEHAVIOR === "with-subagent-no-main-turn-completed"
) {
const subThread = nextThread(state, thread.cwd, true);
const subThreadRecord = ensureThread(state, subThread.id);
subThreadRecord.name = "design-challenger";
saveState(state);
const subTurnId = nextTurnId(state);
send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } });
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
send({
method: "item/started",
params: {
threadId: thread.id,
turnId,
item: {
type: "collabAgentToolCall",
id: "collab_" + turnId,
tool: "wait",
status: "inProgress",
senderThreadId: thread.id,
receiverThreadIds: [subThread.id],
prompt: "Challenge the implementation approach",
model: null,
reasoningEffort: null,
agentsStates: {
[subThread.id]: { status: "inProgress", message: "Investigating design tradeoffs" }
}
}
}
});
if (BEHAVIOR === "with-late-subagent-message") {
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
});
}
send({ method: "turn/started", params: { threadId: subThread.id, turn: buildTurn(subTurnId) } });
send({
method: "item/completed",
params: {
threadId: subThread.id,
turnId: subTurnId,
item: {
type: "reasoning",
id: "reasoning_" + subTurnId,
summary: [{ text: "Questioned the retry strategy and the cache invalidation boundaries." }],
content: []
}
}
});
send({
method: "item/completed",
params: {
threadId: subThread.id,
turnId: subTurnId,
item: {
type: "agentMessage",
id: "msg_" + subTurnId,
text: "The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees.",
phase: "analysis"
}
}
});
send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } });
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: {
type: "collabAgentToolCall",
id: "collab_" + turnId,
tool: "wait",
status: "completed",
senderThreadId: thread.id,
receiverThreadIds: [subThread.id],
prompt: "Challenge the implementation approach",
model: null,
reasoningEffort: null,
agentsStates: {
[subThread.id]: { status: "completed", message: "Finished" }
}
}
}
});
if (BEHAVIOR !== "with-late-subagent-message") {
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
});
}
if (BEHAVIOR !== "with-subagent-no-main-turn-completed") {
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}
break;
}
const items = [
...(BEHAVIOR === "with-reasoning"
? [
{
completed: {
type: "reasoning",
id: "reasoning_" + turnId,
summary: [{ text: "Inspected the prompt, gathered evidence, and checked the highest-risk paths first." }],
content: []
}
}
]
: []),
{
completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
];
if (BEHAVIOR === "interruptible-slow-task") {
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
const timer = setTimeout(() => {
if (!interruptibleTurns.has(turnId)) {
return;
}
interruptibleTurns.delete(turnId);
for (const entry of items) {
if (entry && entry.completed) {
send({ method: "item/completed", params: { threadId: thread.id, turnId, item: entry.completed } });
}
}
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}, 400);
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
} else if (BEHAVIOR === "slow-task") {
emitTurnCompletedLater(thread.id, turnId, items, 400);
} else {
emitTurnCompleted(thread.id, turnId, items);
}
break;
}
case "turn/interrupt": {
state.lastInterrupt = {
threadId: message.params.threadId,
turnId: message.params.turnId
};
saveState(state);
const pending = interruptibleTurns.get(message.params.turnId);
if (pending) {
clearTimeout(pending.timer);
interruptibleTurns.delete(message.params.turnId);
send({
method: "turn/completed",
params: {
threadId: pending.threadId,
turn: buildTurn(message.params.turnId, "interrupted")
}
});
}
send({ id: message.id, result: {} });
break;
}
default:
send({ id: message.id, error: { code: -32601, message: "Unsupported method: " + message.method } });
break;
}
} catch (error) {
send({ id: message.id, error: { code: -32000, message: error.message } });
}
});
`;
writeExecutable(scriptPath, source);
}
export function buildEnv(binDir) {
return {
...process.env,
PATH: `${binDir}:${process.env.PATH}`
};
}

70
tests/git.test.mjs Normal file
View File

@ -0,0 +1,70 @@
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { collectReviewContext, resolveReviewTarget } from "../plugins/codex/scripts/lib/git.mjs";
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";
test("resolveReviewTarget prefers working tree when repo is dirty", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n");
const target = resolveReviewTarget(cwd, {});
assert.equal(target.mode, "working-tree");
});
test("resolveReviewTarget falls back to branch diff when repo is clean", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
run("git", ["checkout", "-b", "feature/test"], { cwd });
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "change"], { cwd });
const target = resolveReviewTarget(cwd, {});
const context = collectReviewContext(cwd, target);
assert.equal(target.mode, "branch");
assert.match(target.label, /main/);
assert.match(context.content, /Branch Diff/);
});
test("resolveReviewTarget honors explicit base overrides", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
run("git", ["checkout", "-b", "feature/test"], { cwd });
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v2');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "change"], { cwd });
const target = resolveReviewTarget(cwd, { base: "main" });
assert.equal(target.mode, "branch");
assert.equal(target.baseRef, "main");
});
test("resolveReviewTarget requires an explicit base when no default branch can be inferred", () => {
const cwd = makeTempDir();
initGitRepo(cwd);
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
run("git", ["add", "app.js"], { cwd });
run("git", ["commit", "-m", "init"], { cwd });
run("git", ["branch", "-m", "feature-only"], { cwd });
assert.throws(
() => resolveReviewTarget(cwd, {}),
/Unable to detect the repository default branch\. Pass --base <ref> or use --scope working-tree\./
);
});

29
tests/helpers.mjs Normal file
View File

@ -0,0 +1,29 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
export function makeTempDir(prefix = "codex-plugin-test-") {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
export function writeExecutable(filePath, source) {
fs.writeFileSync(filePath, source, { encoding: "utf8", mode: 0o755 });
}
export function run(command, args, options = {}) {
return spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: "utf8",
input: options.input
});
}
export function initGitRepo(cwd) {
run("git", ["init", "-b", "main"], { cwd });
run("git", ["config", "user.name", "Codex Plugin Tests"], { cwd });
run("git", ["config", "user.email", "tests@example.com"], { cwd });
run("git", ["config", "commit.gpgsign", "false"], { cwd });
run("git", ["config", "tag.gpgsign", "false"], { cwd });
}

55
tests/process.test.mjs Normal file
View File

@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";
test("terminateProcessTree uses taskkill on Windows", () => {
let captured = null;
const outcome = terminateProcessTree(1234, {
platform: "win32",
runCommandImpl(command, args) {
captured = { command, args };
return {
command,
args,
status: 0,
signal: null,
stdout: "",
stderr: "",
error: null
};
},
killImpl() {
throw new Error("kill fallback should not run");
}
});
assert.deepEqual(captured, {
command: "taskkill",
args: ["/PID", "1234", "/T", "/F"]
});
assert.equal(outcome.delivered, true);
assert.equal(outcome.method, "taskkill");
});
test("terminateProcessTree treats missing Windows processes as already stopped", () => {
const outcome = terminateProcessTree(1234, {
platform: "win32",
runCommandImpl(command, args) {
return {
command,
args,
status: 128,
signal: null,
stdout: "ERROR: The process \"1234\" not found.",
stderr: "",
error: null
};
}
});
assert.equal(outcome.attempted, true);
assert.equal(outcome.method, "taskkill");
assert.equal(outcome.result.status, 128);
assert.match(outcome.result.stdout, /not found/i);
});

59
tests/render.test.mjs Normal file
View File

@ -0,0 +1,59 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs";
test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => {
const output = renderReviewResult(
{
parsed: {
verdict: "approve",
summary: "Looks fine."
},
rawOutput: JSON.stringify({
verdict: "approve",
summary: "Looks fine."
}),
parseError: null
},
{
reviewLabel: "Adversarial Review",
targetLabel: "working tree diff"
}
);
assert.match(output, /Codex returned JSON with an unexpected review shape\./);
assert.match(output, /Missing array `findings`\./);
assert.match(output, /Raw final message:/);
});
test("renderStoredJobResult prefers rendered output for structured review jobs", () => {
const output = renderStoredJobResult(
{
id: "review-123",
status: "completed",
title: "Codex Adversarial Review",
jobClass: "review",
threadId: "thr_123"
},
{
threadId: "thr_123",
rendered: "# Codex Adversarial Review\n\nTarget: working tree diff\nVerdict: needs-attention\n",
result: {
result: {
verdict: "needs-attention",
summary: "One issue.",
findings: [],
next_steps: []
},
rawOutput:
'{"verdict":"needs-attention","summary":"One issue.","findings":[],"next_steps":[]}'
}
}
);
assert.match(output, /^# Codex Adversarial Review/);
assert.doesNotMatch(output, /^\{/);
assert.match(output, /Codex session ID: thr_123/);
assert.match(output, /Resume in Codex: codex resume thr_123/);
});

1700
tests/runtime.test.mjs Normal file

File diff suppressed because it is too large Load Diff

105
tests/state.test.mjs Normal file
View File

@ -0,0 +1,105 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { makeTempDir } from "./helpers.mjs";
import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs";
test("resolveStateDir uses a temp-backed per-workspace directory", () => {
const workspace = makeTempDir();
const stateDir = resolveStateDir(workspace);
assert.equal(stateDir.startsWith(os.tmpdir()), true);
assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/);
assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
});
test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => {
const workspace = makeTempDir();
const pluginDataDir = makeTempDir();
const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA;
process.env.CLAUDE_PLUGIN_DATA = pluginDataDir;
try {
const stateDir = resolveStateDir(workspace);
assert.equal(stateDir.startsWith(path.join(pluginDataDir, "state")), true);
assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/);
assert.match(
stateDir,
new RegExp(`^${path.join(pluginDataDir, "state").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)
);
} finally {
if (previousPluginDataDir == null) {
delete process.env.CLAUDE_PLUGIN_DATA;
} else {
process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir;
}
}
});
test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => {
const workspace = makeTempDir();
const stateFile = resolveStateFile(workspace);
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const jobs = Array.from({ length: 51 }, (_, index) => {
const jobId = `job-${index}`;
const updatedAt = new Date(Date.UTC(2026, 0, 1, 0, index, 0)).toISOString();
const logFile = resolveJobLogFile(workspace, jobId);
const jobFile = resolveJobFile(workspace, jobId);
fs.writeFileSync(logFile, `log ${jobId}\n`, "utf8");
fs.writeFileSync(jobFile, JSON.stringify({ id: jobId, status: "completed" }, null, 2), "utf8");
return {
id: jobId,
status: "completed",
logFile,
updatedAt,
createdAt: updatedAt
};
});
fs.writeFileSync(
stateFile,
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs
},
null,
2
)}\n`,
"utf8"
);
saveState(workspace, {
version: 1,
config: { stopReviewGate: false },
jobs
});
const prunedJobFile = resolveJobFile(workspace, "job-0");
const prunedLogFile = resolveJobLogFile(workspace, "job-0");
const retainedJobFile = resolveJobFile(workspace, "job-50");
const retainedLogFile = resolveJobLogFile(workspace, "job-50");
const jobsDir = path.dirname(prunedJobFile);
assert.equal(fs.existsSync(retainedJobFile), true);
assert.equal(fs.existsSync(retainedLogFile), true);
const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
assert.equal(savedState.jobs.length, 50);
assert.deepEqual(
savedState.jobs.map((job) => job.id),
Array.from({ length: 50 }, (_, index) => `job-${50 - index}`)
);
assert.deepEqual(
fs.readdirSync(jobsDir).sort(),
Array.from({ length: 50 }, (_, index) => `job-${index + 1}`)
.flatMap((jobId) => [`${jobId}.json`, `${jobId}.log`])
.sort()
);
});

23
tsconfig.app-server.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": false,
"noImplicitAny": false,
"useUnknownInCatchVariables": false,
"skipLibCheck": true,
"types": ["node"]
},
"include": [
"plugins/codex/scripts/lib/app-server.mjs",
"plugins/codex/scripts/lib/codex.mjs",
"plugins/codex/scripts/lib/fs.mjs",
"plugins/codex/scripts/lib/process.mjs",
"plugins/codex/scripts/lib/app-server-protocol.d.ts",
"plugins/codex/.generated/app-server-types/**/*.ts"
]
}